feat: add security sink detection patterns for JavaScript/TypeScript

- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
This commit is contained in:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -0,0 +1,266 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Enforces budget constraints on release operations.
/// </summary>
public sealed class BudgetConstraintEnforcer : IBudgetConstraintEnforcer
{
private readonly IBudgetLedger _ledger;
private readonly IGateSelector _gateSelector;
private readonly ILogger<BudgetConstraintEnforcer> _logger;
public BudgetConstraintEnforcer(
IBudgetLedger ledger,
IGateSelector gateSelector,
ILogger<BudgetConstraintEnforcer>? logger = null)
{
_ledger = ledger ?? throw new ArgumentNullException(nameof(ledger));
_gateSelector = gateSelector ?? throw new ArgumentNullException(nameof(gateSelector));
_logger = logger ?? NullLogger<BudgetConstraintEnforcer>.Instance;
}
/// <summary>
/// Checks if a release can proceed given current budget.
/// </summary>
public async Task<BudgetCheckResult> CheckReleaseAsync(
ReleaseCheckInput input,
CancellationToken ct = default)
{
var budget = await _ledger.GetBudgetAsync(input.ServiceId, ct: ct).ConfigureAwait(false);
var gateResult = await _gateSelector.SelectGateAsync(input.ToGateInput(), ct).ConfigureAwait(false);
var result = new BudgetCheckResult
{
CanProceed = !gateResult.IsBlocked,
RequiredGate = gateResult.Gate,
RiskPoints = gateResult.RiskScore,
BudgetBefore = budget,
BudgetAfter = budget with { Consumed = budget.Consumed + gateResult.RiskScore },
BlockReason = gateResult.BlockReason,
Requirements = gateResult.Requirements,
Recommendations = gateResult.Recommendations
};
// Log the check
_logger.LogInformation(
"Release check for {ServiceId}: CanProceed={CanProceed}, Gate={Gate}, RP={RP}",
input.ServiceId, result.CanProceed, result.RequiredGate, result.RiskPoints);
return result;
}
/// <summary>
/// Records a release and consumes budget.
/// </summary>
public async Task<ReleaseRecordResult> RecordReleaseAsync(
ReleaseRecordInput input,
CancellationToken ct = default)
{
// First check if release can proceed
var checkResult = await CheckReleaseAsync(input.ToCheckInput(), ct).ConfigureAwait(false);
if (!checkResult.CanProceed)
{
return new ReleaseRecordResult
{
IsSuccess = false,
Error = checkResult.BlockReason ?? "Release blocked by budget constraints"
};
}
// Consume budget
var consumeResult = await _ledger.ConsumeAsync(
input.ServiceId,
checkResult.RiskPoints,
input.ReleaseId,
ct).ConfigureAwait(false);
if (!consumeResult.IsSuccess)
{
return new ReleaseRecordResult
{
IsSuccess = false,
Error = consumeResult.Error
};
}
_logger.LogInformation(
"Recorded release {ReleaseId} for {ServiceId}. Budget: {Remaining}/{Allocated} RP remaining",
input.ReleaseId, input.ServiceId,
consumeResult.Budget.Remaining, consumeResult.Budget.Allocated);
return new ReleaseRecordResult
{
IsSuccess = true,
ReleaseId = input.ReleaseId,
ConsumedRiskPoints = checkResult.RiskPoints,
Budget = consumeResult.Budget,
Gate = checkResult.RequiredGate
};
}
/// <summary>
/// Handles break-glass exception for urgent releases.
/// </summary>
public async Task<ExceptionResult> RecordExceptionAsync(
ExceptionInput input,
CancellationToken ct = default)
{
// Record the exception
var baseRiskPoints = await CalculateBaseRiskPointsAsync(input, ct).ConfigureAwait(false);
// Apply 50% penalty for exception
var penaltyRiskPoints = (int)(baseRiskPoints * 1.5);
var consumeResult = await _ledger.ConsumeAsync(
input.ServiceId,
penaltyRiskPoints,
input.ReleaseId,
ct).ConfigureAwait(false);
_logger.LogWarning(
"Break-glass exception for {ServiceId}: {ReleaseId}. Penalty: {Penalty} RP. Reason: {Reason}",
input.ServiceId, input.ReleaseId, penaltyRiskPoints - baseRiskPoints, input.Reason);
return new ExceptionResult
{
IsSuccess = consumeResult.IsSuccess,
ReleaseId = input.ReleaseId,
BaseRiskPoints = baseRiskPoints,
PenaltyRiskPoints = penaltyRiskPoints - baseRiskPoints,
TotalRiskPoints = penaltyRiskPoints,
Budget = consumeResult.Budget,
FollowUpRequired = true,
FollowUpDeadline = DateTimeOffset.UtcNow.AddDays(5)
};
}
private async Task<int> CalculateBaseRiskPointsAsync(ExceptionInput input, CancellationToken ct)
{
var gateResult = await _gateSelector.SelectGateAsync(new GateSelectionInput
{
ServiceId = input.ServiceId,
Tier = input.Tier,
DiffCategory = input.DiffCategory,
Context = input.Context,
Mitigations = input.Mitigations,
IsEmergencyFix = true
}, ct).ConfigureAwait(false);
return gateResult.RiskScore;
}
}
/// <summary>
/// Input for release check.
/// </summary>
public sealed record ReleaseCheckInput
{
public required string ServiceId { get; init; }
public required ServiceTier Tier { get; init; }
public required DiffCategory DiffCategory { get; init; }
public required OperationalContext Context { get; init; }
public required MitigationFactors Mitigations { get; init; }
public GateSelectionInput ToGateInput() => new()
{
ServiceId = ServiceId,
Tier = Tier,
DiffCategory = DiffCategory,
Context = Context,
Mitigations = Mitigations
};
}
/// <summary>
/// Result of budget check.
/// </summary>
public sealed record BudgetCheckResult
{
public required bool CanProceed { get; init; }
public required GateLevel RequiredGate { get; init; }
public required int RiskPoints { get; init; }
public required RiskBudget BudgetBefore { get; init; }
public required RiskBudget BudgetAfter { get; init; }
public string? BlockReason { get; init; }
public IReadOnlyList<string> Requirements { get; init; } = [];
public IReadOnlyList<string> Recommendations { get; init; } = [];
}
/// <summary>
/// Input for release recording.
/// </summary>
public sealed record ReleaseRecordInput
{
public required string ReleaseId { get; init; }
public required string ServiceId { get; init; }
public required ServiceTier Tier { get; init; }
public required DiffCategory DiffCategory { get; init; }
public required OperationalContext Context { get; init; }
public required MitigationFactors Mitigations { get; init; }
public ReleaseCheckInput ToCheckInput() => new()
{
ServiceId = ServiceId,
Tier = Tier,
DiffCategory = DiffCategory,
Context = Context,
Mitigations = Mitigations
};
}
/// <summary>
/// Result of release recording.
/// </summary>
public sealed record ReleaseRecordResult
{
public required bool IsSuccess { get; init; }
public string? ReleaseId { get; init; }
public int ConsumedRiskPoints { get; init; }
public RiskBudget? Budget { get; init; }
public GateLevel? Gate { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Input for exception recording.
/// </summary>
public sealed record ExceptionInput
{
public required string ReleaseId { get; init; }
public required string ServiceId { get; init; }
public required ServiceTier Tier { get; init; }
public required DiffCategory DiffCategory { get; init; }
public required OperationalContext Context { get; init; }
public required MitigationFactors Mitigations { get; init; }
public required string Reason { get; init; }
public required string ApprovedBy { get; init; }
}
/// <summary>
/// Result of exception recording.
/// </summary>
public sealed record ExceptionResult
{
public required bool IsSuccess { get; init; }
public required string ReleaseId { get; init; }
public required int BaseRiskPoints { get; init; }
public required int PenaltyRiskPoints { get; init; }
public required int TotalRiskPoints { get; init; }
public required RiskBudget Budget { get; init; }
public required bool FollowUpRequired { get; init; }
public DateTimeOffset? FollowUpDeadline { get; init; }
}
/// <summary>
/// Interface for budget constraint enforcement.
/// </summary>
public interface IBudgetConstraintEnforcer
{
Task<BudgetCheckResult> CheckReleaseAsync(ReleaseCheckInput input, CancellationToken ct = default);
Task<ReleaseRecordResult> RecordReleaseAsync(ReleaseRecordInput input, CancellationToken ct = default);
Task<ExceptionResult> RecordExceptionAsync(ExceptionInput input, CancellationToken ct = default);
}

View File

@@ -0,0 +1,278 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Ledger for tracking risk budget consumption.
/// </summary>
public sealed class BudgetLedger : IBudgetLedger
{
private readonly IBudgetStore _store;
private readonly ILogger<BudgetLedger> _logger;
public BudgetLedger(IBudgetStore store, ILogger<BudgetLedger>? logger = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = logger ?? NullLogger<BudgetLedger>.Instance;
}
/// <summary>
/// Gets the current budget for a service.
/// </summary>
public async Task<RiskBudget> GetBudgetAsync(
string serviceId,
string? window = null,
CancellationToken ct = default)
{
window ??= GetCurrentWindow();
var budget = await _store.GetAsync(serviceId, window, ct).ConfigureAwait(false);
if (budget is not null)
return budget;
// Create default budget if none exists
var tier = await GetServiceTierAsync(serviceId, ct).ConfigureAwait(false);
return await CreateBudgetAsync(serviceId, tier, window, ct).ConfigureAwait(false);
}
/// <summary>
/// Records consumption of risk points.
/// </summary>
public async Task<BudgetConsumeResult> ConsumeAsync(
string serviceId,
int riskPoints,
string releaseId,
CancellationToken ct = default)
{
var budget = await GetBudgetAsync(serviceId, ct: ct).ConfigureAwait(false);
if (budget.Remaining < riskPoints)
{
_logger.LogWarning(
"Budget exceeded for {ServiceId}: {Remaining} remaining, {Requested} requested",
serviceId, budget.Remaining, riskPoints);
return new BudgetConsumeResult
{
IsSuccess = false,
Budget = budget,
Error = "Insufficient budget remaining"
};
}
// Record the consumption
var entry = new BudgetEntry
{
EntryId = Guid.NewGuid().ToString(),
ServiceId = serviceId,
Window = budget.Window,
ReleaseId = releaseId,
RiskPoints = riskPoints,
ConsumedAt = DateTimeOffset.UtcNow
};
await _store.AddEntryAsync(entry, ct).ConfigureAwait(false);
// Update budget
var updatedBudget = budget with
{
Consumed = budget.Consumed + riskPoints,
UpdatedAt = DateTimeOffset.UtcNow
};
await _store.UpdateAsync(updatedBudget, ct).ConfigureAwait(false);
_logger.LogInformation(
"Consumed {RiskPoints} RP for {ServiceId}. Remaining: {Remaining}/{Allocated}",
riskPoints, serviceId, updatedBudget.Remaining, updatedBudget.Allocated);
return new BudgetConsumeResult
{
IsSuccess = true,
Budget = updatedBudget,
Entry = entry
};
}
/// <summary>
/// Gets the consumption history for a service.
/// </summary>
public async Task<IReadOnlyList<BudgetEntry>> GetHistoryAsync(
string serviceId,
string? window = null,
CancellationToken ct = default)
{
window ??= GetCurrentWindow();
return await _store.GetEntriesAsync(serviceId, window, ct).ConfigureAwait(false);
}
/// <summary>
/// Adjusts budget allocation (e.g., for earned capacity).
/// </summary>
public async Task<RiskBudget> AdjustAllocationAsync(
string serviceId,
int adjustment,
string reason,
CancellationToken ct = default)
{
var budget = await GetBudgetAsync(serviceId, ct: ct).ConfigureAwait(false);
var newAllocation = Math.Max(0, budget.Allocated + adjustment);
var updatedBudget = budget with
{
Allocated = newAllocation,
UpdatedAt = DateTimeOffset.UtcNow
};
await _store.UpdateAsync(updatedBudget, ct).ConfigureAwait(false);
_logger.LogInformation(
"Adjusted budget for {ServiceId} by {Adjustment} RP. Reason: {Reason}",
serviceId, adjustment, reason);
return updatedBudget;
}
private async Task<RiskBudget> CreateBudgetAsync(
string serviceId,
ServiceTier tier,
string window,
CancellationToken ct)
{
var budget = new RiskBudget
{
BudgetId = $"budget:{serviceId}:{window}",
ServiceId = serviceId,
Tier = tier,
Window = window,
Allocated = DefaultBudgetAllocations.GetMonthlyAllocation(tier),
Consumed = 0,
UpdatedAt = DateTimeOffset.UtcNow
};
await _store.CreateAsync(budget, ct).ConfigureAwait(false);
return budget;
}
private static string GetCurrentWindow() =>
DateTimeOffset.UtcNow.ToString("yyyy-MM");
private Task<ServiceTier> GetServiceTierAsync(string serviceId, CancellationToken ct)
{
// Look up service tier from configuration or default to Tier 1
return Task.FromResult(ServiceTier.CustomerFacingNonCritical);
}
}
/// <summary>
/// Entry recording a budget consumption.
/// </summary>
public sealed record BudgetEntry
{
public required string EntryId { get; init; }
public required string ServiceId { get; init; }
public required string Window { get; init; }
public required string ReleaseId { get; init; }
public required int RiskPoints { get; init; }
public required DateTimeOffset ConsumedAt { get; init; }
}
/// <summary>
/// Result of budget consumption attempt.
/// </summary>
public sealed record BudgetConsumeResult
{
public required bool IsSuccess { get; init; }
public required RiskBudget Budget { get; init; }
public BudgetEntry? Entry { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Interface for budget ledger operations.
/// </summary>
public interface IBudgetLedger
{
Task<RiskBudget> GetBudgetAsync(string serviceId, string? window = null, CancellationToken ct = default);
Task<BudgetConsumeResult> ConsumeAsync(string serviceId, int riskPoints, string releaseId, CancellationToken ct = default);
Task<IReadOnlyList<BudgetEntry>> GetHistoryAsync(string serviceId, string? window = null, CancellationToken ct = default);
Task<RiskBudget> AdjustAllocationAsync(string serviceId, int adjustment, string reason, CancellationToken ct = default);
}
/// <summary>
/// Interface for budget persistence.
/// </summary>
public interface IBudgetStore
{
Task<RiskBudget?> GetAsync(string serviceId, string window, CancellationToken ct);
Task CreateAsync(RiskBudget budget, CancellationToken ct);
Task UpdateAsync(RiskBudget budget, CancellationToken ct);
Task AddEntryAsync(BudgetEntry entry, CancellationToken ct);
Task<IReadOnlyList<BudgetEntry>> GetEntriesAsync(string serviceId, string window, CancellationToken ct);
}
/// <summary>
/// In-memory implementation of <see cref="IBudgetStore"/> for testing.
/// </summary>
public sealed class InMemoryBudgetStore : IBudgetStore
{
private readonly Dictionary<string, RiskBudget> _budgets = new();
private readonly List<BudgetEntry> _entries = [];
private readonly object _lock = new();
public Task<RiskBudget?> GetAsync(string serviceId, string window, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var key = $"{serviceId}:{window}";
lock (_lock)
{
return Task.FromResult(_budgets.TryGetValue(key, out var budget) ? budget : null);
}
}
public Task CreateAsync(RiskBudget budget, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var key = $"{budget.ServiceId}:{budget.Window}";
lock (_lock)
{
_budgets[key] = budget;
}
return Task.CompletedTask;
}
public Task UpdateAsync(RiskBudget budget, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var key = $"{budget.ServiceId}:{budget.Window}";
lock (_lock)
{
_budgets[key] = budget;
}
return Task.CompletedTask;
}
public Task AddEntryAsync(BudgetEntry entry, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
lock (_lock)
{
_entries.Add(entry);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<BudgetEntry>> GetEntriesAsync(string serviceId, string window, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
lock (_lock)
{
var result = _entries
.Where(e => e.ServiceId == serviceId && e.Window == window)
.OrderByDescending(e => e.ConsumedAt)
.ToList();
return Task.FromResult<IReadOnlyList<BudgetEntry>>(result);
}
}
}

View File

@@ -0,0 +1,122 @@
namespace StellaOps.Policy.Gates;
/// <summary>
/// Diff-aware release gate levels (G0-G4).
/// Higher levels require more checks before release.
/// </summary>
public enum GateLevel
{
/// <summary>
/// G0: No-risk / Administrative.
/// Requirements: Lint/format checks, basic CI pass.
/// Use for: docs-only, comments-only, non-functional metadata.
/// </summary>
G0 = 0,
/// <summary>
/// G1: Low risk.
/// Requirements: All automated unit tests, static analysis, 1 peer review, staging deploy, smoke checks.
/// Use for: small localized changes, non-core UI, telemetry additions.
/// </summary>
G1 = 1,
/// <summary>
/// G2: Moderate risk.
/// Requirements: G1 + integration tests, code owner review, feature flag required, staged rollout, rollback plan.
/// Use for: moderate logic changes, dependency upgrades, backward-compatible API changes.
/// </summary>
G2 = 2,
/// <summary>
/// G3: High risk.
/// Requirements: G2 + security scan, migration plan reviewed, load/performance checks, observability updates, release captain sign-off, progressive delivery with health gates.
/// Use for: schema migrations, auth/permission changes, core business logic, infra changes.
/// </summary>
G3 = 3,
/// <summary>
/// G4: Very high risk / Safety-critical.
/// Requirements: G3 + formal risk review (PM+DM+Security), rollback rehearsal, extended canary, customer comms plan, post-release verification checklist.
/// Use for: Tier 3 systems with low budget, freeze window exceptions, platform-wide changes.
/// </summary>
G4 = 4
}
/// <summary>
/// Gate level requirements documentation.
/// </summary>
public static class GateLevelRequirements
{
/// <summary>
/// Gets the requirements for a gate level.
/// </summary>
public static IReadOnlyList<string> GetRequirements(GateLevel level)
{
return level switch
{
GateLevel.G0 =>
[
"Lint/format checks pass",
"Basic CI build passes"
],
GateLevel.G1 =>
[
"All automated unit tests pass",
"Static analysis/linting clean",
"1 peer review (code owner not required)",
"Automated deploy to staging",
"Post-deploy smoke checks pass"
],
GateLevel.G2 =>
[
"All G1 requirements",
"Integration tests for impacted modules pass",
"Code owner review for touched modules",
"Feature flag required if customer impact possible",
"Staged rollout: canary or small cohort",
"Rollback plan documented in PR"
],
GateLevel.G3 =>
[
"All G2 requirements",
"Security scan + dependency audit pass",
"Migration plan (forward + rollback) reviewed",
"Load/performance checks if in hot path",
"Observability: new/updated dashboards/alerts",
"Release captain / on-call sign-off",
"Progressive delivery with automatic health gates"
],
GateLevel.G4 =>
[
"All G3 requirements",
"Formal risk review (PM+DM+Security/SRE) in writing",
"Explicit rollback rehearsal or proven rollback path",
"Extended canary period with success/abort criteria",
"Customer comms plan if impact is plausible",
"Post-release verification checklist executed and logged"
],
_ => []
};
}
/// <summary>
/// Gets a short description for a gate level.
/// </summary>
public static string GetDescription(GateLevel level)
{
return level switch
{
GateLevel.G0 => "No-risk: Basic CI only",
GateLevel.G1 => "Low risk: Unit tests + 1 review",
GateLevel.G2 => "Moderate risk: Integration tests + code owner + canary",
GateLevel.G3 => "High risk: Security scan + release captain + progressive",
GateLevel.G4 => "Very high risk: Formal review + extended canary + comms",
_ => "Unknown"
};
}
}

View File

@@ -0,0 +1,175 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Selects the appropriate gate level for a release.
/// </summary>
public sealed class GateSelector : IGateSelector
{
private readonly IRiskPointScoring _scoring;
private readonly IBudgetLedger _budgetLedger;
private readonly ILogger<GateSelector> _logger;
public GateSelector(
IRiskPointScoring scoring,
IBudgetLedger budgetLedger,
ILogger<GateSelector>? logger = null)
{
_scoring = scoring ?? throw new ArgumentNullException(nameof(scoring));
_budgetLedger = budgetLedger ?? throw new ArgumentNullException(nameof(budgetLedger));
_logger = logger ?? NullLogger<GateSelector>.Instance;
}
/// <summary>
/// Determines the gate level for a change.
/// </summary>
public async Task<GateSelectionResult> SelectGateAsync(
GateSelectionInput input,
CancellationToken ct = default)
{
// Get current budget status
var budget = await _budgetLedger.GetBudgetAsync(input.ServiceId, ct: ct).ConfigureAwait(false);
// Build context with budget status
var context = input.Context with { BudgetStatus = budget.Status };
// Calculate risk score
var scoreInput = new RiskScoreInput
{
Tier = input.Tier,
DiffCategory = input.DiffCategory,
Context = context,
Mitigations = input.Mitigations
};
var scoreResult = _scoring.CalculateScore(scoreInput);
// Apply budget-based modifiers
var finalGate = ApplyBudgetModifiers(scoreResult.RecommendedGate, budget);
// Check for blocks
var (isBlocked, blockReason) = CheckForBlocks(finalGate, budget, input);
_logger.LogInformation(
"Gate selection for {ServiceId}: Score={Score}, Gate={Gate}, Budget={BudgetStatus}",
input.ServiceId, scoreResult.Score, finalGate, budget.Status);
return new GateSelectionResult
{
Gate = finalGate,
RiskScore = scoreResult.Score,
ScoreBreakdown = scoreResult.Breakdown,
Budget = budget,
IsBlocked = isBlocked,
BlockReason = blockReason,
Requirements = GateLevelRequirements.GetRequirements(finalGate).ToList(),
Recommendations = GenerateRecommendations(scoreResult, budget)
};
}
private static GateLevel ApplyBudgetModifiers(GateLevel gate, RiskBudget budget)
{
return budget.Status switch
{
// Yellow: Escalate G2+ by one level
BudgetStatus.Yellow when gate >= GateLevel.G2 =>
gate < GateLevel.G4 ? gate + 1 : GateLevel.G4,
// Red: Escalate G1+ by one level
BudgetStatus.Red when gate >= GateLevel.G1 =>
gate < GateLevel.G4 ? gate + 1 : GateLevel.G4,
// Exhausted: Everything is G4
BudgetStatus.Exhausted => GateLevel.G4,
_ => gate
};
}
private static (bool IsBlocked, string? Reason) CheckForBlocks(
GateLevel gate, RiskBudget budget, GateSelectionInput input)
{
// Red budget blocks high-risk categories
if (budget.Status == BudgetStatus.Red &&
input.DiffCategory is DiffCategory.DatabaseMigration or DiffCategory.AuthPermission or DiffCategory.InfraNetworking)
{
return (true, "High-risk changes blocked during Red budget status");
}
// Exhausted budget blocks non-emergency changes
if (budget.Status == BudgetStatus.Exhausted && !input.IsEmergencyFix)
{
return (true, "Budget exhausted. Only incident/security fixes allowed.");
}
return (false, null);
}
private static IReadOnlyList<string> GenerateRecommendations(
RiskScoreResult score, RiskBudget budget)
{
var recommendations = new List<string>();
// Score reduction recommendations
if (score.Breakdown.DiffRisk > 5)
{
recommendations.Add("Consider breaking this change into smaller, lower-risk diffs");
}
if (score.Breakdown.Mitigations == 0)
{
recommendations.Add("Add mitigations: feature flag, canary deployment, or increased test coverage");
}
// Budget recommendations
if (budget.Status == BudgetStatus.Yellow)
{
recommendations.Add("Budget at Yellow status. Prioritize reliability work to restore capacity.");
}
if (budget.Status == BudgetStatus.Red)
{
recommendations.Add("Budget at Red status. Defer high-risk changes or decompose into smaller diffs.");
}
return recommendations;
}
}
/// <summary>
/// Input for gate selection.
/// </summary>
public sealed record GateSelectionInput
{
public required string ServiceId { get; init; }
public required ServiceTier Tier { get; init; }
public required DiffCategory DiffCategory { get; init; }
public required OperationalContext Context { get; init; }
public required MitigationFactors Mitigations { get; init; }
public bool IsEmergencyFix { get; init; }
}
/// <summary>
/// Result of gate selection.
/// </summary>
public sealed record GateSelectionResult
{
public required GateLevel Gate { get; init; }
public required int RiskScore { get; init; }
public required RiskScoreBreakdown ScoreBreakdown { get; init; }
public required RiskBudget Budget { get; init; }
public required bool IsBlocked { get; init; }
public string? BlockReason { get; init; }
public IReadOnlyList<string> Requirements { get; init; } = [];
public IReadOnlyList<string> Recommendations { get; init; } = [];
}
/// <summary>
/// Interface for gate selection.
/// </summary>
public interface IGateSelector
{
Task<GateSelectionResult> SelectGateAsync(GateSelectionInput input, CancellationToken ct = default);
}

View File

@@ -0,0 +1,136 @@
namespace StellaOps.Policy.Gates;
/// <summary>
/// Represents a risk budget for a service/product.
/// Tracks risk point allocation and consumption.
/// </summary>
public sealed record RiskBudget
{
/// <summary>
/// Unique identifier for this budget.
/// </summary>
public required string BudgetId { get; init; }
/// <summary>
/// Service or product this budget applies to.
/// </summary>
public required string ServiceId { get; init; }
/// <summary>
/// Criticality tier (0-3).
/// </summary>
public required ServiceTier Tier { get; init; }
/// <summary>
/// Budget window (e.g., "2025-01" for monthly).
/// </summary>
public required string Window { get; init; }
/// <summary>
/// Total risk points allocated for this window.
/// </summary>
public required int Allocated { get; init; }
/// <summary>
/// Risk points consumed so far.
/// </summary>
public int Consumed { get; init; }
/// <summary>
/// Risk points remaining.
/// </summary>
public int Remaining => Allocated - Consumed;
/// <summary>
/// Percentage of budget used.
/// </summary>
public decimal PercentageUsed => Allocated > 0
? (decimal)Consumed / Allocated * 100
: 0;
/// <summary>
/// Current operating status.
/// </summary>
public BudgetStatus Status => PercentageUsed switch
{
< 40 => BudgetStatus.Green,
< 70 => BudgetStatus.Yellow,
< 100 => BudgetStatus.Red,
_ => BudgetStatus.Exhausted
};
/// <summary>
/// Last updated timestamp.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Service criticality tiers.
/// </summary>
public enum ServiceTier
{
/// <summary>
/// Tier 0: Internal only, low business impact.
/// </summary>
Internal = 0,
/// <summary>
/// Tier 1: Customer-facing non-critical.
/// </summary>
CustomerFacingNonCritical = 1,
/// <summary>
/// Tier 2: Customer-facing critical.
/// </summary>
CustomerFacingCritical = 2,
/// <summary>
/// Tier 3: Safety/financial/data-critical.
/// </summary>
SafetyCritical = 3
}
/// <summary>
/// Budget operating status.
/// </summary>
public enum BudgetStatus
{
/// <summary>
/// Green: >= 60% remaining. Normal operation.
/// </summary>
Green,
/// <summary>
/// Yellow: 30-59% remaining. Increased caution.
/// </summary>
Yellow,
/// <summary>
/// Red: Less than 30% remaining. Freeze high-risk diffs.
/// </summary>
Red,
/// <summary>
/// Exhausted: 0% or less remaining. Incident/security fixes only.
/// </summary>
Exhausted
}
/// <summary>
/// Default budget allocations by tier.
/// </summary>
public static class DefaultBudgetAllocations
{
/// <summary>
/// Gets the default monthly allocation for a service tier.
/// </summary>
public static int GetMonthlyAllocation(ServiceTier tier) => tier switch
{
ServiceTier.Internal => 300,
ServiceTier.CustomerFacingNonCritical => 200,
ServiceTier.CustomerFacingCritical => 120,
ServiceTier.SafetyCritical => 80,
_ => 100
};
}

View File

@@ -0,0 +1,254 @@
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Calculates Release Risk Score (RRS) for changes.
/// RRS = Base(criticality) + Diff Risk + Operational Context - Mitigations
/// </summary>
public sealed class RiskPointScoring : IRiskPointScoring
{
private readonly RiskScoringOptions _options;
public RiskPointScoring(IOptionsMonitor<RiskScoringOptions>? options = null)
{
_options = options?.CurrentValue ?? RiskScoringOptions.Default;
}
/// <summary>
/// Calculates the Release Risk Score for a change.
/// </summary>
public RiskScoreResult CalculateScore(RiskScoreInput input)
{
var breakdown = new RiskScoreBreakdown();
// Base score from service tier
var baseScore = GetBaseScore(input.Tier);
breakdown.Base = baseScore;
// Diff risk (additive)
var diffRisk = CalculateDiffRisk(input.DiffCategory);
breakdown.DiffRisk = diffRisk;
// Operational context (additive)
var operationalContext = CalculateOperationalContext(input.Context);
breakdown.OperationalContext = operationalContext;
// Mitigations (subtract)
var mitigations = CalculateMitigations(input.Mitigations);
breakdown.Mitigations = mitigations;
// Total (minimum 1)
var total = Math.Max(1, baseScore + diffRisk + operationalContext - mitigations);
breakdown.Total = total;
// Determine gate level
var gate = DetermineGateLevel(total, input.Context.BudgetStatus);
return new RiskScoreResult
{
Score = total,
Breakdown = breakdown,
RecommendedGate = gate
};
}
private int GetBaseScore(ServiceTier tier)
{
return tier switch
{
ServiceTier.Internal => _options.BaseScores.Tier0,
ServiceTier.CustomerFacingNonCritical => _options.BaseScores.Tier1,
ServiceTier.CustomerFacingCritical => _options.BaseScores.Tier2,
ServiceTier.SafetyCritical => _options.BaseScores.Tier3,
_ => 1
};
}
private static int CalculateDiffRisk(DiffCategory category)
{
return category switch
{
DiffCategory.DocsOnly => 1,
DiffCategory.UiNonCore => 3,
DiffCategory.ApiBackwardCompatible => 6,
DiffCategory.ApiBreaking => 12,
DiffCategory.DatabaseMigration => 10,
DiffCategory.AuthPermission => 10,
DiffCategory.InfraNetworking => 15,
DiffCategory.CryptoPayment => 15,
DiffCategory.Other => 3,
_ => 3
};
}
private static int CalculateOperationalContext(OperationalContext context)
{
var score = 0;
if (context.HasRecentIncident)
score += 5;
if (context.ErrorBudgetBelow50Percent)
score += 3;
if (context.HighOnCallLoad)
score += 2;
if (context.InRestrictedWindow)
score += 5;
return score;
}
private static int CalculateMitigations(MitigationFactors mitigations)
{
var reduction = 0;
if (mitigations.HasFeatureFlag)
reduction += 3;
if (mitigations.HasCanaryDeployment)
reduction += 3;
if (mitigations.HasHighTestCoverage)
reduction += 2;
if (mitigations.HasBackwardCompatibleMigration)
reduction += 2;
if (mitigations.HasPermissionBoundary)
reduction += 2;
return reduction;
}
private static GateLevel DetermineGateLevel(int score, BudgetStatus budgetStatus)
{
var baseGate = score switch
{
<= 5 => GateLevel.G1,
<= 12 => GateLevel.G2,
<= 20 => GateLevel.G3,
_ => GateLevel.G4
};
// Escalate based on budget status
return budgetStatus switch
{
BudgetStatus.Yellow when baseGate >= GateLevel.G2 => EscalateGate(baseGate),
BudgetStatus.Red when baseGate >= GateLevel.G1 => EscalateGate(baseGate),
BudgetStatus.Exhausted => GateLevel.G4,
_ => baseGate
};
}
private static GateLevel EscalateGate(GateLevel gate) =>
gate < GateLevel.G4 ? gate + 1 : GateLevel.G4;
}
/// <summary>
/// Input for risk score calculation.
/// </summary>
public sealed record RiskScoreInput
{
public required ServiceTier Tier { get; init; }
public required DiffCategory DiffCategory { get; init; }
public required OperationalContext Context { get; init; }
public required MitigationFactors Mitigations { get; init; }
}
/// <summary>
/// Categories of diffs affecting risk score.
/// </summary>
public enum DiffCategory
{
DocsOnly,
UiNonCore,
ApiBackwardCompatible,
ApiBreaking,
DatabaseMigration,
AuthPermission,
InfraNetworking,
CryptoPayment,
Other
}
/// <summary>
/// Operational context affecting risk.
/// </summary>
public sealed record OperationalContext
{
public bool HasRecentIncident { get; init; }
public bool ErrorBudgetBelow50Percent { get; init; }
public bool HighOnCallLoad { get; init; }
public bool InRestrictedWindow { get; init; }
public BudgetStatus BudgetStatus { get; init; }
public static OperationalContext Default { get; } = new();
}
/// <summary>
/// Mitigation factors that reduce risk.
/// </summary>
public sealed record MitigationFactors
{
public bool HasFeatureFlag { get; init; }
public bool HasCanaryDeployment { get; init; }
public bool HasHighTestCoverage { get; init; }
public bool HasBackwardCompatibleMigration { get; init; }
public bool HasPermissionBoundary { get; init; }
public static MitigationFactors None { get; } = new();
}
/// <summary>
/// Result of risk score calculation.
/// </summary>
public sealed record RiskScoreResult
{
public required int Score { get; init; }
public required RiskScoreBreakdown Breakdown { get; init; }
public required GateLevel RecommendedGate { get; init; }
}
/// <summary>
/// Breakdown of score components.
/// </summary>
public sealed record RiskScoreBreakdown
{
public int Base { get; set; }
public int DiffRisk { get; set; }
public int OperationalContext { get; set; }
public int Mitigations { get; set; }
public int Total { get; set; }
}
/// <summary>
/// Options for risk scoring.
/// </summary>
public sealed record RiskScoringOptions
{
public BaseScoresByTier BaseScores { get; init; } = new();
public static RiskScoringOptions Default { get; } = new();
}
/// <summary>
/// Base scores by service tier.
/// </summary>
public sealed record BaseScoresByTier
{
public int Tier0 { get; init; } = 1;
public int Tier1 { get; init; } = 3;
public int Tier2 { get; init; } = 6;
public int Tier3 { get; init; } = 10;
}
/// <summary>
/// Interface for risk point scoring.
/// </summary>
public interface IRiskPointScoring
{
RiskScoreResult CalculateScore(RiskScoreInput input);
}