feat(backend): enforce campaign scheduling, add reviewer queue filter, tactic order, duplicate-test counts

- start_execution now blocks a test from starting before its campaign's
  start_date, closing a gap where the field was persisted but never
  enforced
- GET /tests gains a reviewer_id "my reviews" filter for the red_review/
  blue_review lead-gate stage, distinct from pending_validation_side
  (which only ever covered the later in_review stage and ignored
  assignment entirely)
- get_coverage_by_tactic now returns all 14 MITRE tactics in canonical
  kill-chain order instead of an alphabetical, partial list — the
  regular Dashboard and Executive Dashboard both consume this endpoint
  and previously disagreed on order/completeness
- test-template listing now includes existing_test_count per technique
  so the catalog can warn before creating a likely-duplicate test
This commit is contained in:
kitos
2026-07-08 08:46:09 +02:00
parent 21c6febd22
commit 1c8fa436ab
12 changed files with 333 additions and 3 deletions
+34 -1
View File
@@ -38,6 +38,26 @@ from app.schemas.metrics import (
ValidationRate,
)
# Canonical MITRE ATT&CK kill-chain order — used to sort "coverage by
# tactic" consistently everywhere it's rendered (dashboard table, executive
# bar chart), instead of each consumer inventing its own order/subset.
MITRE_TACTIC_ORDER: list[str] = [
"reconnaissance",
"resource-development",
"initial-access",
"execution",
"persistence",
"privilege-escalation",
"defense-evasion",
"credential-access",
"discovery",
"lateral-movement",
"collection",
"command-and-control",
"exfiltration",
"impact",
]
# Define function get_coverage_summary
def get_coverage_summary(db: Session) -> CoverageSummary:
@@ -112,6 +132,12 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
lambda: {s.value: 0 for s in TechniqueStatus}
)
# Materialize every canonical tactic up front so the response always
# includes all 14, zero-filled, rather than only whichever tactics
# happen to have techniques seeded.
for tactic in MITRE_TACTIC_ORDER:
tactic_data[tactic] # noqa: B018 — touch to create the defaultdict entry
# Iterate over techniques
for tactic_str, status in techniques:
# Check: not tactic_str
@@ -128,10 +154,17 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
# Assign tactic_data[tactic][status.value] = 1
tactic_data[tactic][status.value] += 1
def _tactic_sort_key(tactic: str) -> tuple[int, str]:
try:
return (MITRE_TACTIC_ORDER.index(tactic), tactic)
except ValueError:
# Anything outside the canonical 14 (e.g. "unknown") sorts last.
return (len(MITRE_TACTIC_ORDER), tactic)
# Assign result = []
result = []
# Iterate over sorted(tactic_data)
for tactic in sorted(tactic_data):
for tactic in sorted(tactic_data, key=_tactic_sort_key):
# Assign counts = tactic_data[tactic]
counts = tactic_data[tactic]
# Assign total = sum(counts.values())