"""Security governance facts backed by the existing auditor ledgers.""" from __future__ import annotations import uuid from collections.abc import Callable, Sequence from datetime import UTC, datetime from decimal import Decimal from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from app.domains.auditor.constants import ( EXPOSURE_STATUS_OPEN, PAT_STATUS_ACTIVE, PAT_STATUS_DORMANT, PRIV_CYCLE_CANCELLED, PRIV_CYCLE_COMPLETED, PRIV_DECISION_PENDING, SECRET_STATUS_OVERDUE, SECRET_STATUS_ROTATED, ) from app.domains.auditor.models import ( AccessCertification, AccessCertificationItem, AuditorMfaStatus, AuditorPatAudit, AuditorPublicExposureFinding, AuditorSecretRotationRecord, ) from app.identity.person_models import IdentityPerson from app.intelligence.privacy import EvidenceObject from app.intelligence.snapshot_service import ( MetricObservation, ProviderResult, ProviderUnavailable, SnapshotSubject, ) from app.scheduled_jobs.models import ScheduledJob _EVIDENCE_LIMIT = 50 _EVIDENCE_JOB_KEYS = frozenset({ "auditor.mfa_sync_daily", "auditor.public_exposure_scan_daily", "auditor.secret_rotation_check_daily", "auditor.pat_audit_daily", }) def _utc(value: datetime) -> datetime: return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) def _pct(value: int, total: int) -> Decimal: return (Decimal(value) * 100 / Decimal(total)).quantize(Decimal("0.01")) # Every sync service records what it actually reached under one of these # keys plus an ``errors`` list; a scan that only covered 2 of 3 sources # must not report its clean result at 100% confidence. This is the mirror # image of the dead-metric trap: a BLIND probe reads as "nothing wrong". _SCANNED_SOURCE_KEYS = ( "sources_scanned", "scanned_sources", "pulled_sources", ) def _scan_coverage(jobs: Sequence[ScheduledJob]) -> tuple[int, int]: """Return ``(complete_sources, blind_sources)`` over the given jobs. A source named in both the scanned list and an error entry counts as BLIND, not complete — the same rule ``PublicExposureScanService`` already applies to its own auto-resolve gate (a partially scanned source must stay out of ``sources_scanned``). Erring toward humility is the only safe direction for a confidence number. """ complete: set[str] = set() blind: set[str] = set() for job in jobs: summary = job.last_result_summary or {} if not isinstance(summary, dict): continue for key in _SCANNED_SOURCE_KEYS: value = summary.get(key) if isinstance(value, list): complete |= {str(v) for v in value} partial = summary.get("partial_sources") if isinstance(partial, dict): blind |= {str(k) for k in partial} for err in summary.get("errors") or []: if not isinstance(err, dict): continue source = err.get("source") or err.get("provider") if source: blind.add(str(source)) return len(complete - blind), len(blind) def _coverage_confidence( jobs: Sequence[ScheduledJob], *, fallback: int, ) -> Decimal | int: """Confidence = share of the intended sources actually covered. ``fallback`` applies when no job has ever run, so "we have rows but no run record" keeps its old reading instead of collapsing to zero. """ ran = [job for job in jobs if job.last_run_at is not None] if not ran: return fallback complete, blind = _scan_coverage(ran) if complete + blind == 0: return fallback return _pct(complete, complete + blind) class SecurityComplianceFactProvider: dimension = "security_compliance" def __init__(self, *, now: Callable[[], datetime] | None = None) -> None: self._now = now or (lambda: datetime.now(UTC)) @staticmethod def _validate_subject(subject: SnapshotSubject) -> None: if subject.subject_type == "platform" and subject.subject_id == "platform": return if subject.subject_type == "domain" and subject.subject_id == "security_compliance": return raise ValueError( "Security/compliance facts only support platform or security_compliance snapshots" ) async def collect(self, db: AsyncSession, subject: SnapshotSubject) -> ProviderResult: self._validate_subject(subject) try: return await self._collect(db) except SQLAlchemyError as exc: raise ProviderUnavailable("security/compliance datastore unavailable") from exc async def _collect(self, db: AsyncSession) -> ProviderResult: now = _utc(self._now()) jobs = list( ( await db.execute( select(ScheduledJob).where(ScheduledJob.name.in_(_EVIDENCE_JOB_KEYS)) ) ).scalars() ) jobs_by_name = {row.name: row for row in jobs} # MFA coverage is a PERSON-level fact over the population the # auditor dashboard and the findings worklist already share # (``DashboardService.summary`` / ``reconcile_mfa_findings``): # active, non-soft-deleted employees. Reading raw # (person, provider) rows over everyone instead meant a leaver's # suspended account and a second provider row for the same person # each landed in the denominator. mfa_rows = list((await db.execute( select(AuditorMfaStatus) .join(IdentityPerson, AuditorMfaStatus.person_id == IdentityPerson.id) .where( IdentityPerson.employment_status == "active", IdentityPerson.deleted_at.is_(None), ) )).scalars()) mfa_as_of = max((_utc(row.last_synced_at) for row in mfa_rows), default=None) # An observation only counts when the provider actually answered # BOTH questions this metric asks (enrolled AND enforced). The # 528 Atlassian ``policy_absent`` rows answer neither, and scoring # them as gaps is what pinned this metric at 0.0% / 30 points at a # claimed 100% confidence while Google Workspace enforced 2SV on # 98% of the same people. Unknown belongs in *confidence*, never # in the numerator. See ``AuditorMfaStatus.enforcement_observed``. mfa_persons: set[uuid.UUID] = {row.person_id for row in mfa_rows} decidable = [ row for row in mfa_rows if row.enrollment_observed and row.enforcement_observed ] decidable_persons = {row.person_id for row in decidable} # Any provider that says "enrolled and enforced" covers the person; # holding a second app account with weaker MFA is not a gap when # the identity provider gates the login. covered_persons = { row.person_id for row in decidable if row.is_enrolled and row.is_enforced } mfa_gaps = [ row for row in decidable if row.person_id not in covered_persons ] mfa_confidence: Decimal | int = ( _pct(len(decidable_persons), len(mfa_persons)) if mfa_persons else 0 ) latest_campaign = ( await db.execute( select(AccessCertification) .where( AccessCertification.campaign_type == "privileged_role", AccessCertification.status != PRIV_CYCLE_CANCELLED, ) .order_by(AccessCertification.started_at.desc()) .limit(1) ) ).scalar_one_or_none() review_items = list( ( await db.execute( select(AccessCertificationItem).where( AccessCertificationItem.certification_id == latest_campaign.id ) ) ).scalars() ) if latest_campaign is not None else [] pending_review = [row for row in review_items if row.decision == PRIV_DECISION_PENDING] decided_review_count = len(review_items) - len(pending_review) empty_review_is_complete = bool( latest_campaign is not None and latest_campaign.status == PRIV_CYCLE_COMPLETED and latest_campaign.snapshot_item_count == 0 ) review_as_of = ( max((_utc(row.updated_at) for row in review_items), default=None) or (_utc(latest_campaign.updated_at) if latest_campaign is not None else None) ) exposures = list( (await db.execute(select(AuditorPublicExposureFinding))).scalars() ) open_exposures = [row for row in exposures if row.status == EXPOSURE_STATUS_OPEN] exposure_as_of = max((_utc(row.last_seen_at) for row in exposures), default=None) exposure_job = jobs_by_name.get("auditor.public_exposure_scan_daily") exposure_observed = bool(exposures) or bool( exposure_job is not None and exposure_job.last_run_at is not None ) secrets = list( ( await db.execute( select(AuditorSecretRotationRecord).where( AuditorSecretRotationRecord.status != SECRET_STATUS_ROTATED ) ) ).scalars() ) pats = list( ( await db.execute( select(AuditorPatAudit).where( AuditorPatAudit.status.in_((PAT_STATUS_ACTIVE, PAT_STATUS_DORMANT)) ) ) ).scalars() ) risky_secrets = [row for row in secrets if row.status == SECRET_STATUS_OVERDUE] risky_pats = [ row for row in pats if row.status == PAT_STATUS_DORMANT or bool(row.risk_flags) ] credential_total = len(secrets) + len(pats) credential_risk_count = len(risky_secrets) + len(risky_pats) credential_as_of_values = [ *(_utc(row.last_synced_at) for row in secrets), *(_utc(row.last_synced_at) for row in pats), ] credential_as_of = max(credential_as_of_values, default=None) credential_job_keys = ( "auditor.secret_rotation_check_daily", "auditor.pat_audit_daily", ) credential_jobs = [ jobs_by_name[key] for key in credential_job_keys if jobs_by_name.get(key) is not None ] credential_jobs_observed = any( job.last_run_at is not None for job in credential_jobs ) observed_jobs = [row for row in jobs if row.last_run_at is not None] freshness_as_of = min( (_utc(row.last_run_at) for row in observed_jobs if row.last_run_at is not None), default=None, ) freshness_missed_cycles = 0 stale_or_failed_jobs: list[ScheduledJob] = [] for row in observed_jobs: last_run_at = row.last_run_at if last_run_at is None: # narrowed above; defensive for ORM mutation continue age_cycles = max(0, int((now - _utc(last_run_at)).total_seconds() // 86_400)) missed = max( age_cycles, 2 if row.last_status not in ("ok", "skipped", "running") else 0, ) freshness_missed_cycles = max(freshness_missed_cycles, missed) if missed >= 2: stale_or_failed_jobs.append(row) missing_job_count = len(_EVIDENCE_JOB_KEYS - set(jobs_by_name)) freshness_confidence = _pct(len(observed_jobs), len(_EVIDENCE_JOB_KEYS)) def refs(kind: str, rows: Sequence[object]) -> tuple[EvidenceObject, ...]: return tuple( EvidenceObject(kind, str(getattr(row, "id"))) for row in rows[:_EVIDENCE_LIMIT] ) credential_evidence = ( refs("secretrecord", risky_secrets) + refs("pataudit", risky_pats) )[:_EVIDENCE_LIMIT] metric_times = [ value for value in ( mfa_as_of, review_as_of, exposure_as_of, credential_as_of, freshness_as_of, ) if value is not None ] return ProviderResult( dimension=self.dimension, metrics=( MetricObservation( "security_compliance.mfa_enforcement_coverage", ( _pct(len(covered_persons), len(decidable_persons)) if decidable_persons else 0 ), confidence=mfa_confidence, evidence=refs("mfastatus", mfa_gaps), data_as_of=mfa_as_of, ), MetricObservation( "security_compliance.privileged_review_coverage", ( _pct(decided_review_count, len(review_items)) if review_items else 100 if empty_review_is_complete else 0 ), confidence=100 if review_items or empty_review_is_complete else 0, evidence=refs("accessreview", pending_review), data_as_of=review_as_of, ), MetricObservation( "security_compliance.public_exposure_rate", _pct(len(open_exposures), len(exposures)) if exposures else 0, # "0 open exposures" is only worth 100% confidence if # the scan actually saw everything. In production the # S3 leg has been ``AccessDenied`` on every run while # this metric handed the dimension a clean 100/100. confidence=( _coverage_confidence( [exposure_job] if exposure_job else [], fallback=100 if exposures else 0, ) if exposure_observed else 0 ), evidence=refs("exposure", open_exposures), data_as_of=exposure_as_of, ), MetricObservation( "security_compliance.credential_risk_rate", _pct(credential_risk_count, credential_total) if credential_total else 0, # Same reasoning as public exposure: ACM, Aliyun and # the Azure service-principal secrets have never been # readable, and GitHub / Atlassian tokens are not # pulled at all, so the risk rate is computed over a # partial inventory. Say so instead of claiming 100%. confidence=( _coverage_confidence( credential_jobs, fallback=100 if credential_total else 0, ) if credential_total or credential_jobs_observed else 0 ), evidence=credential_evidence, data_as_of=credential_as_of, ), MetricObservation( "security_compliance.evidence_freshness", freshness_missed_cycles, confidence=freshness_confidence, evidence=refs("job", stale_or_failed_jobs), data_as_of=freshness_as_of, ), ), aggregate_facts={ "mfa_observation_count": len(mfa_rows), "mfa_person_count": len(mfa_persons), "mfa_observable_person_count": len(decidable_persons), "mfa_compliant_count": len(covered_persons), "privileged_review_item_count": len(review_items), "privileged_review_decided_count": decided_review_count, "exposure_finding_count": len(exposures), "open_exposure_count": len(open_exposures), "credential_observation_count": credential_total, "credential_risk_count": credential_risk_count, "evidence_job_count": len(jobs), "evidence_observed_job_count": len(observed_jobs), "evidence_missing_job_count": missing_job_count, "evidence_missed_cycles": freshness_missed_cycles, }, data_as_of=min(metric_times) if metric_times else None, )