This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

View File

@@ -0,0 +1,182 @@
-- Policy Schema Migration 004: EPSS Data and Risk Scores
-- Adds tables for EPSS (Exploit Prediction Scoring System) data and combined risk scores
-- EPSS scores table (cached EPSS data from FIRST.org)
CREATE TABLE IF NOT EXISTS policy.epss_scores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cve_id TEXT NOT NULL,
score NUMERIC(6,5) NOT NULL CHECK (score >= 0 AND score <= 1),
percentile NUMERIC(6,5) NOT NULL CHECK (percentile >= 0 AND percentile <= 1),
model_version DATE NOT NULL,
source TEXT NOT NULL DEFAULT 'first.org',
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
UNIQUE(cve_id, model_version)
);
CREATE INDEX idx_epss_scores_cve ON policy.epss_scores(cve_id);
CREATE INDEX idx_epss_scores_percentile ON policy.epss_scores(percentile DESC);
CREATE INDEX idx_epss_scores_expires ON policy.epss_scores(expires_at);
CREATE INDEX idx_epss_scores_model ON policy.epss_scores(model_version);
-- EPSS history table (for tracking score changes over time)
CREATE TABLE IF NOT EXISTS policy.epss_history (
id BIGSERIAL PRIMARY KEY,
cve_id TEXT NOT NULL,
score NUMERIC(6,5) NOT NULL,
percentile NUMERIC(6,5) NOT NULL,
model_version DATE NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_epss_history_cve ON policy.epss_history(cve_id);
CREATE INDEX idx_epss_history_recorded ON policy.epss_history(cve_id, recorded_at DESC);
-- Combined risk scores table (CVSS + KEV + EPSS)
CREATE TABLE IF NOT EXISTS policy.risk_scores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
vulnerability_id TEXT NOT NULL,
cvss_receipt_id UUID REFERENCES policy.cvss_receipts(id),
-- Component scores
cvss_score NUMERIC(4,1) NOT NULL,
cvss_version TEXT NOT NULL,
kev_flag BOOLEAN NOT NULL DEFAULT FALSE,
kev_added_date DATE,
epss_score NUMERIC(6,5),
epss_percentile NUMERIC(6,5),
epss_model_version DATE,
-- Risk bonuses applied
kev_bonus NUMERIC(4,2) NOT NULL DEFAULT 0 CHECK (kev_bonus >= 0 AND kev_bonus <= 1),
epss_bonus NUMERIC(4,2) NOT NULL DEFAULT 0 CHECK (epss_bonus >= 0 AND epss_bonus <= 1),
-- Combined risk score (0.0 to 1.0)
combined_risk_score NUMERIC(4,3) NOT NULL CHECK (combined_risk_score >= 0 AND combined_risk_score <= 1),
-- Risk signal formula used
formula_version TEXT NOT NULL DEFAULT 'v1',
formula_params JSONB NOT NULL DEFAULT '{}',
-- Determinism
input_hash TEXT NOT NULL,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, vulnerability_id, input_hash)
);
CREATE INDEX idx_risk_scores_tenant ON policy.risk_scores(tenant_id);
CREATE INDEX idx_risk_scores_vuln ON policy.risk_scores(tenant_id, vulnerability_id);
CREATE INDEX idx_risk_scores_combined ON policy.risk_scores(tenant_id, combined_risk_score DESC);
CREATE INDEX idx_risk_scores_kev ON policy.risk_scores(kev_flag) WHERE kev_flag = TRUE;
CREATE INDEX idx_risk_scores_epss ON policy.risk_scores(epss_percentile DESC) WHERE epss_percentile IS NOT NULL;
CREATE INDEX idx_risk_scores_created ON policy.risk_scores(tenant_id, created_at DESC);
-- EPSS bonus thresholds configuration table
CREATE TABLE IF NOT EXISTS policy.epss_thresholds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name TEXT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
thresholds JSONB NOT NULL DEFAULT '[
{"percentile": 0.99, "bonus": 0.10},
{"percentile": 0.90, "bonus": 0.05},
{"percentile": 0.50, "bonus": 0.02}
]'::jsonb,
kev_bonus NUMERIC(4,2) NOT NULL DEFAULT 0.20,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, name)
);
CREATE INDEX idx_epss_thresholds_tenant ON policy.epss_thresholds(tenant_id);
CREATE INDEX idx_epss_thresholds_default ON policy.epss_thresholds(tenant_id, is_default)
WHERE is_default = TRUE;
-- Risk score history (audit trail)
CREATE TABLE IF NOT EXISTS policy.risk_score_history (
id BIGSERIAL PRIMARY KEY,
risk_score_id UUID NOT NULL REFERENCES policy.risk_scores(id),
cvss_score NUMERIC(4,1) NOT NULL,
kev_flag BOOLEAN NOT NULL,
epss_score NUMERIC(6,5),
epss_percentile NUMERIC(6,5),
combined_risk_score NUMERIC(4,3) NOT NULL,
changed_by TEXT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
change_reason TEXT
);
CREATE INDEX idx_risk_score_history_score ON policy.risk_score_history(risk_score_id);
CREATE INDEX idx_risk_score_history_changed ON policy.risk_score_history(changed_at);
-- Trigger for risk_scores updated_at
CREATE TRIGGER trg_risk_scores_updated_at
BEFORE UPDATE ON policy.risk_scores
FOR EACH ROW EXECUTE FUNCTION policy.update_updated_at();
-- Trigger for epss_thresholds updated_at
CREATE TRIGGER trg_epss_thresholds_updated_at
BEFORE UPDATE ON policy.epss_thresholds
FOR EACH ROW EXECUTE FUNCTION policy.update_updated_at();
-- Insert default EPSS threshold configuration
INSERT INTO policy.epss_thresholds (tenant_id, name, is_default, thresholds, kev_bonus, description)
VALUES (
'00000000-0000-0000-0000-000000000000'::uuid,
'default',
TRUE,
'[
{"percentile": 0.99, "bonus": 0.10, "description": "Top 1% most likely to be exploited"},
{"percentile": 0.90, "bonus": 0.05, "description": "Top 10% exploitation probability"},
{"percentile": 0.50, "bonus": 0.02, "description": "Above median exploitation probability"}
]'::jsonb,
0.20,
'Default EPSS bonus thresholds per StellaOps standard risk formula'
) ON CONFLICT DO NOTHING;
-- View for current EPSS scores (latest model version)
CREATE OR REPLACE VIEW policy.epss_current AS
SELECT DISTINCT ON (cve_id)
cve_id,
score,
percentile,
model_version,
fetched_at
FROM policy.epss_scores
WHERE expires_at > NOW()
ORDER BY cve_id, model_version DESC;
-- View for high-risk vulnerabilities (KEV or high EPSS)
CREATE OR REPLACE VIEW policy.high_risk_vulns AS
SELECT
rs.tenant_id,
rs.vulnerability_id,
rs.cvss_score,
rs.cvss_version,
rs.kev_flag,
rs.epss_percentile,
rs.combined_risk_score,
CASE
WHEN rs.kev_flag THEN 'KEV'
WHEN rs.epss_percentile >= 0.95 THEN 'High EPSS (95th+)'
WHEN rs.epss_percentile >= 0.90 THEN 'High EPSS (90th+)'
ELSE 'CVSS Only'
END AS risk_category
FROM policy.risk_scores rs
WHERE rs.kev_flag = TRUE
OR rs.epss_percentile >= 0.90
OR rs.combined_risk_score >= 0.90;
COMMENT ON TABLE policy.epss_scores IS 'Cached EPSS scores from FIRST.org for CVE exploitation probability';
COMMENT ON TABLE policy.risk_scores IS 'Combined risk scores using CVSS + KEV + EPSS formula';
COMMENT ON TABLE policy.epss_thresholds IS 'Configurable EPSS bonus thresholds for risk calculation';
COMMENT ON VIEW policy.epss_current IS 'Current (non-expired) EPSS scores per CVE';
COMMENT ON VIEW policy.high_risk_vulns IS 'Vulnerabilities flagged as high-risk due to KEV or high EPSS';

View File

@@ -0,0 +1,195 @@
-- Policy Schema Migration 005: CVSS Multi-Version Enhancements
-- Adds views and indexes for multi-version CVSS support (v2.0, v3.0, v3.1, v4.0)
-- Add version-specific columns for temporal and environmental scores (v2/v3)
-- Note: base_metrics, threat_metrics, environmental_metrics already support JSONB storage
-- Add index for CVSS version filtering
CREATE INDEX IF NOT EXISTS idx_cvss_receipts_version
ON policy.cvss_receipts(cvss_version);
-- Add index for severity filtering
CREATE INDEX IF NOT EXISTS idx_cvss_receipts_severity
ON policy.cvss_receipts(tenant_id, severity);
-- Add composite index for version + severity queries
CREATE INDEX IF NOT EXISTS idx_cvss_receipts_version_severity
ON policy.cvss_receipts(tenant_id, cvss_version, severity);
-- View for CVSS v2 receipts with metrics unpacked
CREATE OR REPLACE VIEW policy.cvss_v2_receipts AS
SELECT
id,
tenant_id,
vulnerability_id,
vector,
severity,
base_score,
-- V2-specific: temporal_score stored in threat_score column
threat_score AS temporal_score,
environmental_score,
effective_score,
-- Extract v2 base metrics
base_metrics->>'accessVector' AS access_vector,
base_metrics->>'accessComplexity' AS access_complexity,
base_metrics->>'authentication' AS authentication,
base_metrics->>'confidentialityImpact' AS confidentiality_impact,
base_metrics->>'integrityImpact' AS integrity_impact,
base_metrics->>'availabilityImpact' AS availability_impact,
-- Extract v2 temporal metrics (if present)
threat_metrics->>'exploitability' AS exploitability,
threat_metrics->>'remediationLevel' AS remediation_level,
threat_metrics->>'reportConfidence' AS report_confidence,
input_hash,
created_at,
is_active
FROM policy.cvss_receipts
WHERE cvss_version = '2.0';
-- View for CVSS v3.x receipts with metrics unpacked
CREATE OR REPLACE VIEW policy.cvss_v3_receipts AS
SELECT
id,
tenant_id,
vulnerability_id,
vector,
cvss_version,
severity,
base_score,
threat_score AS temporal_score,
environmental_score,
effective_score,
-- Extract v3 base metrics
base_metrics->>'attackVector' AS attack_vector,
base_metrics->>'attackComplexity' AS attack_complexity,
base_metrics->>'privilegesRequired' AS privileges_required,
base_metrics->>'userInteraction' AS user_interaction,
base_metrics->>'scope' AS scope,
base_metrics->>'confidentialityImpact' AS confidentiality_impact,
base_metrics->>'integrityImpact' AS integrity_impact,
base_metrics->>'availabilityImpact' AS availability_impact,
-- Extract v3 temporal metrics (if present)
threat_metrics->>'exploitCodeMaturity' AS exploit_code_maturity,
threat_metrics->>'remediationLevel' AS remediation_level,
threat_metrics->>'reportConfidence' AS report_confidence,
input_hash,
created_at,
is_active
FROM policy.cvss_receipts
WHERE cvss_version IN ('3.0', '3.1');
-- View for CVSS v4 receipts with metrics unpacked
CREATE OR REPLACE VIEW policy.cvss_v4_receipts AS
SELECT
id,
tenant_id,
vulnerability_id,
vector,
severity,
base_score,
threat_score,
environmental_score,
full_score,
effective_score,
effective_score_type,
-- Extract v4 base metrics
base_metrics->>'attackVector' AS attack_vector,
base_metrics->>'attackComplexity' AS attack_complexity,
base_metrics->>'attackRequirements' AS attack_requirements,
base_metrics->>'privilegesRequired' AS privileges_required,
base_metrics->>'userInteraction' AS user_interaction,
base_metrics->>'vulnConfidentialityImpact' AS vuln_confidentiality,
base_metrics->>'vulnIntegrityImpact' AS vuln_integrity,
base_metrics->>'vulnAvailabilityImpact' AS vuln_availability,
base_metrics->>'subConfidentialityImpact' AS sub_confidentiality,
base_metrics->>'subIntegrityImpact' AS sub_integrity,
base_metrics->>'subAvailabilityImpact' AS sub_availability,
-- Extract v4 threat metrics
threat_metrics->>'exploitMaturity' AS exploit_maturity,
-- Extract v4 supplemental metrics
supplemental_metrics->>'safety' AS safety,
supplemental_metrics->>'automatable' AS automatable,
supplemental_metrics->>'recovery' AS recovery,
supplemental_metrics->>'valueDensity' AS value_density,
supplemental_metrics->>'responseEffort' AS response_effort,
supplemental_metrics->>'providerUrgency' AS provider_urgency,
input_hash,
created_at,
is_active
FROM policy.cvss_receipts
WHERE cvss_version = '4.0';
-- Summary view by CVSS version
CREATE OR REPLACE VIEW policy.cvss_version_summary AS
SELECT
tenant_id,
cvss_version,
COUNT(*) AS total_receipts,
COUNT(*) FILTER (WHERE is_active) AS active_receipts,
ROUND(AVG(base_score)::numeric, 1) AS avg_base_score,
ROUND(AVG(effective_score)::numeric, 1) AS avg_effective_score,
COUNT(*) FILTER (WHERE severity = 'Critical') AS critical_count,
COUNT(*) FILTER (WHERE severity = 'High') AS high_count,
COUNT(*) FILTER (WHERE severity = 'Medium') AS medium_count,
COUNT(*) FILTER (WHERE severity = 'Low') AS low_count,
COUNT(*) FILTER (WHERE severity = 'None') AS none_count
FROM policy.cvss_receipts
GROUP BY tenant_id, cvss_version;
-- Function to get severity from score (version-aware)
CREATE OR REPLACE FUNCTION policy.cvss_severity(
p_score NUMERIC,
p_version TEXT
) RETURNS TEXT AS $$
BEGIN
-- V2 uses different thresholds than v3/v4
IF p_version = '2.0' THEN
RETURN CASE
WHEN p_score >= 7.0 THEN 'High'
WHEN p_score >= 4.0 THEN 'Medium'
WHEN p_score > 0 THEN 'Low'
ELSE 'None'
END;
ELSE
-- V3.x and V4.0 use the same thresholds
RETURN CASE
WHEN p_score >= 9.0 THEN 'Critical'
WHEN p_score >= 7.0 THEN 'High'
WHEN p_score >= 4.0 THEN 'Medium'
WHEN p_score >= 0.1 THEN 'Low'
ELSE 'None'
END;
END IF;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Function to validate CVSS vector format
CREATE OR REPLACE FUNCTION policy.validate_cvss_vector(
p_vector TEXT,
p_version TEXT
) RETURNS BOOLEAN AS $$
BEGIN
CASE p_version
WHEN '2.0' THEN
RETURN p_vector ~ '^(CVSS2#)?AV:[LAN]/AC:[HML]/Au:[MSN]/C:[NPC]/I:[NPC]/A:[NPC]';
WHEN '3.0', '3.1' THEN
RETURN p_vector ~ '^CVSS:3\.[01]/AV:[NALP]/AC:[LH]/PR:[NLH]/UI:[NR]/S:[UC]/C:[NLH]/I:[NLH]/A:[NLH]';
WHEN '4.0' THEN
RETURN p_vector ~ '^CVSS:4\.0/AV:[NALP]/AC:[LH]/AT:[NP]/PR:[NLH]/UI:[NAP]/VC:[NLH]/VI:[NLH]/VA:[NLH]/SC:[NLH]/SI:[NLH]/SA:[NLH]';
ELSE
RETURN FALSE;
END CASE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Add check constraint for vector format validation (optional - can be expensive)
-- ALTER TABLE policy.cvss_receipts
-- ADD CONSTRAINT cvss_receipts_vector_format_check
-- CHECK (policy.validate_cvss_vector(vector, cvss_version));
COMMENT ON VIEW policy.cvss_v2_receipts IS 'CVSS v2.0 receipts with metrics unpacked from JSONB';
COMMENT ON VIEW policy.cvss_v3_receipts IS 'CVSS v3.0/v3.1 receipts with metrics unpacked from JSONB';
COMMENT ON VIEW policy.cvss_v4_receipts IS 'CVSS v4.0 receipts with metrics unpacked from JSONB';
COMMENT ON VIEW policy.cvss_version_summary IS 'Summary statistics grouped by CVSS version';
COMMENT ON FUNCTION policy.cvss_severity IS 'Returns severity string from score using version-appropriate thresholds';
COMMENT ON FUNCTION policy.validate_cvss_vector IS 'Validates CVSS vector string format for specified version';

View File

@@ -0,0 +1,154 @@
-- Policy Schema Migration 006: Row-Level Security
-- Sprint: SPRINT_3421_0001_0001 - RLS Expansion
-- Category: B (release migration, requires coordination)
--
-- Purpose: Enable Row-Level Security on all tenant-scoped tables in the policy
-- schema to provide database-level tenant isolation as defense-in-depth.
BEGIN;
-- ============================================================================
-- Step 1: Create helper schema and function for tenant context
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS policy_app;
-- Tenant context helper function
CREATE OR REPLACE FUNCTION policy_app.require_current_tenant()
RETURNS TEXT
LANGUAGE plpgsql STABLE SECURITY DEFINER
AS $$
DECLARE
v_tenant TEXT;
BEGIN
v_tenant := current_setting('app.tenant_id', true);
IF v_tenant IS NULL OR v_tenant = '' THEN
RAISE EXCEPTION 'app.tenant_id session variable not set'
USING HINT = 'Set via: SELECT set_config(''app.tenant_id'', ''<tenant>'', false)',
ERRCODE = 'P0001';
END IF;
RETURN v_tenant;
END;
$$;
REVOKE ALL ON FUNCTION policy_app.require_current_tenant() FROM PUBLIC;
-- ============================================================================
-- Step 2: Enable RLS on tables with direct tenant_id column
-- ============================================================================
-- policy.packs
ALTER TABLE policy.packs ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.packs FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS packs_tenant_isolation ON policy.packs;
CREATE POLICY packs_tenant_isolation ON policy.packs
FOR ALL
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
-- policy.risk_profiles
ALTER TABLE policy.risk_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.risk_profiles FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS risk_profiles_tenant_isolation ON policy.risk_profiles;
CREATE POLICY risk_profiles_tenant_isolation ON policy.risk_profiles
FOR ALL
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
-- policy.evaluation_runs
ALTER TABLE policy.evaluation_runs ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.evaluation_runs FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS evaluation_runs_tenant_isolation ON policy.evaluation_runs;
CREATE POLICY evaluation_runs_tenant_isolation ON policy.evaluation_runs
FOR ALL
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
-- policy.exceptions
ALTER TABLE policy.exceptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.exceptions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS exceptions_tenant_isolation ON policy.exceptions;
CREATE POLICY exceptions_tenant_isolation ON policy.exceptions
FOR ALL
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
-- policy.audit
ALTER TABLE policy.audit ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.audit FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS audit_tenant_isolation ON policy.audit;
CREATE POLICY audit_tenant_isolation ON policy.audit
FOR ALL
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
-- ============================================================================
-- Step 3: FK-based RLS for child tables (inherit tenant from parent)
-- ============================================================================
-- policy.pack_versions inherits tenant from policy.packs
ALTER TABLE policy.pack_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.pack_versions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS pack_versions_tenant_isolation ON policy.pack_versions;
CREATE POLICY pack_versions_tenant_isolation ON policy.pack_versions
FOR ALL
USING (
pack_id IN (
SELECT id FROM policy.packs
WHERE tenant_id = policy_app.require_current_tenant()
)
);
-- policy.rules inherits tenant from policy.pack_versions -> policy.packs
ALTER TABLE policy.rules ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.rules FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS rules_tenant_isolation ON policy.rules;
CREATE POLICY rules_tenant_isolation ON policy.rules
FOR ALL
USING (
pack_version_id IN (
SELECT pv.id FROM policy.pack_versions pv
JOIN policy.packs p ON pv.pack_id = p.id
WHERE p.tenant_id = policy_app.require_current_tenant()
)
);
-- policy.risk_profile_history inherits tenant from policy.risk_profiles
ALTER TABLE policy.risk_profile_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.risk_profile_history FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS risk_profile_history_tenant_isolation ON policy.risk_profile_history;
CREATE POLICY risk_profile_history_tenant_isolation ON policy.risk_profile_history
FOR ALL
USING (
risk_profile_id IN (
SELECT id FROM policy.risk_profiles
WHERE tenant_id = policy_app.require_current_tenant()
)
);
-- policy.explanations inherits tenant from policy.evaluation_runs
ALTER TABLE policy.explanations ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.explanations FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS explanations_tenant_isolation ON policy.explanations;
CREATE POLICY explanations_tenant_isolation ON policy.explanations
FOR ALL
USING (
evaluation_run_id IN (
SELECT id FROM policy.evaluation_runs
WHERE tenant_id = policy_app.require_current_tenant()
)
);
-- ============================================================================
-- Step 4: Create admin bypass role
-- ============================================================================
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'policy_admin') THEN
CREATE ROLE policy_admin WITH NOLOGIN BYPASSRLS;
END IF;
END
$$;
COMMIT;

View File

@@ -0,0 +1,55 @@
namespace StellaOps.Policy.Scoring;
/// <summary>
/// Calculates freshness multiplier for evidence based on age.
/// Uses basis-point math for determinism (no floating point).
/// </summary>
public sealed class EvidenceFreshnessCalculator
{
private readonly FreshnessMultiplierConfig _config;
public EvidenceFreshnessCalculator(FreshnessMultiplierConfig? config = null)
{
_config = config ?? FreshnessMultiplierConfig.Default;
}
/// <summary>
/// Calculates the freshness multiplier for evidence collected at a given timestamp.
/// </summary>
/// <param name="evidenceTimestamp">When the evidence was collected.</param>
/// <param name="asOf">Reference time for freshness calculation (explicit, no implicit time).</param>
/// <returns>Multiplier in basis points (10000 = 100%).</returns>
public int CalculateMultiplierBps(DateTimeOffset evidenceTimestamp, DateTimeOffset asOf)
{
if (evidenceTimestamp > asOf)
{
return _config.Buckets[0].MultiplierBps; // Future evidence gets max freshness
}
var ageDays = (int)(asOf - evidenceTimestamp).TotalDays;
foreach (var bucket in _config.Buckets)
{
if (ageDays <= bucket.MaxAgeDays)
{
return bucket.MultiplierBps;
}
}
return _config.Buckets[^1].MultiplierBps; // Fallback to oldest bucket
}
/// <summary>
/// Applies freshness multiplier to a base score.
/// </summary>
/// <param name="baseScore">Score in range 0-100.</param>
/// <param name="evidenceTimestamp">When the evidence was collected.</param>
/// <param name="asOf">Reference time for freshness calculation.</param>
/// <returns>Adjusted score (integer, no floating point).</returns>
public int ApplyFreshness(int baseScore, DateTimeOffset evidenceTimestamp, DateTimeOffset asOf)
{
var multiplierBps = CalculateMultiplierBps(evidenceTimestamp, asOf);
return (baseScore * multiplierBps) / 10000;
}
}

View File

@@ -0,0 +1,31 @@
namespace StellaOps.Policy.Scoring;
/// <summary>
/// Defines a freshness bucket for evidence age-based scoring decay.
/// </summary>
/// <param name="MaxAgeDays">Maximum age in days for this bucket (inclusive upper bound).</param>
/// <param name="MultiplierBps">Multiplier in basis points (10000 = 100%).</param>
public sealed record FreshnessBucket(int MaxAgeDays, int MultiplierBps);
/// <summary>
/// Configuration for evidence freshness multipliers.
/// Default buckets per determinism advisory: 7d=10000, 30d=9000, 90d=7500, 180d=6000, 365d=4000, >365d=2000.
/// </summary>
public sealed record FreshnessMultiplierConfig
{
public required IReadOnlyList<FreshnessBucket> Buckets { get; init; }
public static FreshnessMultiplierConfig Default { get; } = new()
{
Buckets =
[
new FreshnessBucket(7, 10000),
new FreshnessBucket(30, 9000),
new FreshnessBucket(90, 7500),
new FreshnessBucket(180, 6000),
new FreshnessBucket(365, 4000),
new FreshnessBucket(int.MaxValue, 2000)
]
};
}

View File

@@ -0,0 +1,84 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Scoring;
/// <summary>
/// Structured explanation of a factor's contribution to the final score.
/// </summary>
/// <param name="Factor">Factor identifier (e.g., "reachability", "evidence", "provenance").</param>
/// <param name="Value">Computed value for this factor (0-100 range).</param>
/// <param name="Reason">Human-readable explanation of how the value was computed.</param>
/// <param name="ContributingDigests">Optional digests of objects that contributed to this factor.</param>
public sealed record ScoreExplanation(
string Factor,
int Value,
string Reason,
IReadOnlyList<string>? ContributingDigests = null);
/// <summary>
/// Builder for accumulating score explanations during scoring pipeline.
/// </summary>
public sealed class ScoreExplainBuilder
{
private readonly List<ScoreExplanation> _explanations = [];
public ScoreExplainBuilder Add(string factor, int value, string reason, IReadOnlyList<string>? digests = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(factor);
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
IReadOnlyList<string>? normalizedDigests = null;
if (digests is { Count: > 0 })
{
normalizedDigests = digests
.Where(d => !string.IsNullOrWhiteSpace(d))
.Select(d => d.Trim())
.OrderBy(d => d, StringComparer.Ordinal)
.ToImmutableArray();
}
_explanations.Add(new ScoreExplanation(factor.Trim(), value, reason, normalizedDigests));
return this;
}
public ScoreExplainBuilder AddReachability(int hops, int score, string entrypoint)
{
var reason = hops switch
{
0 => $"Direct entry point: {entrypoint}",
<= 2 => $"{hops} hops from {entrypoint}",
_ => $"{hops} hops from nearest entry point"
};
return Add("reachability", score, reason);
}
public ScoreExplainBuilder AddEvidence(int points, int freshnessMultiplierBps, int ageDays)
{
var freshnessPercent = freshnessMultiplierBps / 100;
var reason = $"{points} evidence points, {ageDays} days old ({freshnessPercent}% freshness)";
return Add("evidence", (points * freshnessMultiplierBps) / 10000, reason);
}
public ScoreExplainBuilder AddProvenance(string level, int score)
{
return Add("provenance", score, $"Provenance level: {level}");
}
public ScoreExplainBuilder AddBaseSeverity(decimal cvss, int score)
{
return Add("baseSeverity", score, $"CVSS {cvss:F1} mapped to {score}");
}
/// <summary>
/// Builds the explanation list, sorted by factor name for determinism.
/// </summary>
public IReadOnlyList<ScoreExplanation> Build()
{
return _explanations
.OrderBy(e => e.Factor, StringComparer.Ordinal)
.ThenBy(e => e.ContributingDigests?.FirstOrDefault() ?? "", StringComparer.Ordinal)
.ToList();
}
}

View File

@@ -10,6 +10,10 @@ public static class SplSchemaResource
private const string SchemaResourceName = "StellaOps.Policy.Schemas.spl-schema@1.json";
private const string SampleResourceName = "StellaOps.Policy.Schemas.spl-sample@1.json";
public static string GetSchema() => ReadSchemaJson();
public static string GetSample() => ReadSampleJson();
public static Stream OpenSchemaStream()
{
return OpenResourceStream(SchemaResourceName);

View File

@@ -0,0 +1,10 @@
# Policy Library Local Tasks
This file mirrors sprint work for the `StellaOps.Policy` library.
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `DET-3401-001` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `FreshnessBucket` + `FreshnessMultiplierConfig` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/FreshnessModels.cs` and covered bucket boundaries in `src/Policy/__Tests/StellaOps.Policy.Tests/Scoring/EvidenceFreshnessCalculatorTests.cs`. |
| `DET-3401-002` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Implemented `EvidenceFreshnessCalculator` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/EvidenceFreshnessCalculator.cs`. |
| `DET-3401-009` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `ScoreExplanation` + `ScoreExplainBuilder` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/ScoreExplanation.cs` and tests in `src/Policy/__Tests/StellaOps.Policy.Tests/Scoring/ScoreExplainBuilderTests.cs`. |