up
This commit is contained in:
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
10
src/Policy/__Libraries/StellaOps.Policy/TASKS.md
Normal file
10
src/Policy/__Libraries/StellaOps.Policy/TASKS.md
Normal 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`. |
|
||||
|
||||
Reference in New Issue
Block a user