feat(phase-30): add coverage snapshots, temporal comparison and auto re-testing (T-230 to T-232)

This commit is contained in:
2026-02-10 08:34:29 +01:00
parent 2ac8e7f4a5
commit 4d124b42dd
20 changed files with 1517 additions and 4 deletions

View File

@@ -15,6 +15,7 @@ from app.models.test_template_detection_rule import TestTemplateDetectionRule
from app.models.test_detection_result import TestDetectionResult
from app.models.campaign import Campaign, CampaignTest
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
__all__ = [
@@ -25,5 +26,6 @@ __all__ = [
"TestTemplateDetectionRule", "TestDetectionResult",
"Campaign", "CampaignTest",
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
"CoverageSnapshot", "SnapshotTechniqueState",
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
]

View File

@@ -0,0 +1,78 @@
"""Coverage snapshot models — periodic snapshots of coverage state.
CoverageSnapshot stores aggregate metrics at a point in time.
SnapshotTechniqueState stores per-technique state (normalized, one row
per technique per snapshot) to avoid bloated JSONB fields.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Float, Integer, DateTime,
ForeignKey, Index,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class CoverageSnapshot(Base):
"""A point-in-time snapshot of the organisation's overall coverage."""
__tablename__ = "coverage_snapshots"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String, nullable=True) # e.g. "Pre-remediación Q1"
organization_score = Column(Float, nullable=False)
total_techniques = Column(Integer, nullable=False)
validated_count = Column(Integer, nullable=False)
partial_count = Column(Integer, nullable=False)
not_covered_count = Column(Integer, nullable=False)
in_progress_count = Column(Integer, nullable=False)
not_evaluated_count = Column(Integer, nullable=False)
created_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
creator = relationship("User", foreign_keys=[created_by])
technique_states = relationship(
"SnapshotTechniqueState",
back_populates="snapshot",
cascade="all, delete-orphan",
)
class SnapshotTechniqueState(Base):
"""Per-technique state within a snapshot (normalised storage)."""
__tablename__ = "snapshot_technique_states"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
snapshot_id = Column(
UUID(as_uuid=True),
ForeignKey("coverage_snapshots.id", ondelete="CASCADE"),
nullable=False,
)
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
nullable=False,
)
mitre_id = Column(String, nullable=False) # denormalised for fast queries
status = Column(String, nullable=False)
score = Column(Float, nullable=True)
# Relationships
snapshot = relationship("CoverageSnapshot", back_populates="technique_states")
technique = relationship("Technique")
__table_args__ = (
Index("ix_snapshot_technique_states_snapshot", "snapshot_id"),
Index("ix_snapshot_technique_states_technique", "technique_id"),
)

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Enum
from sqlalchemy import Column, String, Text, Boolean, Integer, DateTime, ForeignKey, Enum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
@@ -54,6 +54,10 @@ class Test(Base):
remediation_status = Column(String, nullable=True) # pending / in_progress / completed / not_applicable
remediation_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# ── Re-test fields ────────────────────────────────────────────
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
retest_count = Column(Integer, default=0)
# ── Relationships ───────────────────────────────────────────────
technique = relationship("Technique", back_populates="tests")
evidences = relationship("Evidence", back_populates="test")
@@ -61,3 +65,5 @@ class Test(Base):
red_validator = relationship("User", foreign_keys=[red_validated_by])
blue_validator = relationship("User", foreign_keys=[blue_validated_by])
remediation_user = relationship("User", foreign_keys=[remediation_assignee])
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
retests = relationship("Test", foreign_keys=[retest_of], back_populates="original_test")