Compare commits

..

2 Commits

Author SHA1 Message Date
kitos 8cf8001de5 feat(frontend): My Reviews queue, duplicate-test badge, tactic order, legend + tooltip fixes
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- TestsPage gains a "My Reviews" toggle for red_lead/blue_lead, separate
  from "My Tasks" — filters by reviewer_id at the red_review/blue_review
  gate instead of the unrelated in_review validation stage
- Test Catalog cards now warn when a technique already has existing
  tests, using the backend's new existing_test_count
- ExecutiveDashboardPage drops its duplicate client-side tactic-order
  sort now that the backend returns canonical order; time-range filter
  now notes which sections it actually scopes
- HeatmapLegend adds the missing review_required swatch (as an overlay
  indicator, matching how it's actually rendered on cells) and hover
  tooltips reusing StatusBadge's copy instead of being unexplained
  static color chips
- Fix a real React key bug in ControlsTable: rows were grouped in a
  keyless shorthand fragment, which can misreconcile when the table is
  filtered/sorted; switched to a keyed Fragment
- Minor: CompliancePage summary-card grid no longer strands a lone card
  on tablet widths
2026-07-08 08:46:24 +02:00
kitos 1c8fa436ab 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
2026-07-08 08:46:09 +02:00
21 changed files with 458 additions and 46 deletions
+5
View File
@@ -218,6 +218,10 @@ def list_tests(
pending_validation_side: Optional[str] = Query(
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
),
reviewer_id: Optional[uuid.UUID] = Query(
None,
description="\"My reviews\" queue — filter red_review/blue_review tests assigned to this reviewer",
),
not_in_any_campaign: bool = Query(
False, description="Only return tests not linked to any campaign"
),
@@ -259,6 +263,7 @@ def list_tests(
created_by=created_by,
# Keyword argument: pending_validation_side
pending_validation_side=pending_validation_side,
reviewer_id=reviewer_id,
not_in_any_campaign=not_in_any_campaign,
offset=offset,
# Keyword argument: limit
+3
View File
@@ -102,6 +102,9 @@ class TestTemplateSummary(BaseModel):
severity: str | None = None
# Assign is_active = True
is_active: bool = True
# Number of existing Test rows for this template's technique — lets the
# catalog UI warn before creating a likely-duplicate test.
existing_test_count: int = 0
# Assign model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True)
+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())
+20
View File
@@ -10,6 +10,7 @@ from datetime import datetime
from typing import Any
# Import Session, joinedload from sqlalchemy.orm
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
# Import from app.domain.errors
@@ -63,6 +64,8 @@ def list_tests(
created_by: uuid.UUID | None = None,
# Entry: pending_validation_side
pending_validation_side: str | None = None,
# Entry: reviewer_id — "my reviews" queue for the red_review/blue_review gate stage
reviewer_id: uuid.UUID | None = None,
not_in_any_campaign: bool = False,
offset: int = 0,
# Entry: limit
@@ -101,6 +104,23 @@ def list_tests(
Test.state == TestState.in_review,
Test.blue_validation_status.in_(["pending", None]),
)
# "My reviews" — tests currently assigned to *reviewer_id* at the
# red_review/blue_review lead-review gate (distinct from
# pending_validation_side, which covers the later in_review stage).
if reviewer_id:
if state == TestState.red_review.value:
query = query.filter(Test.red_reviewer_assignee == reviewer_id)
elif state == TestState.blue_review.value:
query = query.filter(Test.blue_reviewer_assignee == reviewer_id)
else:
query = query.filter(
Test.state.in_([TestState.red_review, TestState.blue_review]),
or_(
Test.red_reviewer_assignee == reviewer_id,
Test.blue_reviewer_assignee == reviewer_id,
),
)
if not_in_any_campaign:
linked = db.query(CampaignTest.test_id).distinct().subquery()
query = query.filter(~Test.id.in_(linked))
@@ -16,6 +16,8 @@ from app.domain.errors import EntityNotFoundError
# Import TestTemplate from app.models.test_template
from app.models.test_template import TestTemplate
from app.models.technique import Technique
from app.models.test import Test
# Import escape_like from app.utils
from app.utils import escape_like
@@ -91,6 +93,21 @@ def list_templates(
# Chain .all() call
.all()
)
# Attach existing_test_count per template — lets the catalog warn before
# creating a likely-duplicate test for a technique that already has one.
if templates:
mitre_ids = {t.mitre_technique_id for t in templates}
counts = dict(
db.query(Technique.mitre_id, func.count(Test.id))
.join(Test, Test.technique_id == Technique.id)
.filter(Technique.mitre_id.in_(mitre_ids))
.group_by(Technique.mitre_id)
.all()
)
for t in templates:
t.existing_test_count = counts.get(t.mitre_technique_id, 0)
# Return templates
return templates
@@ -28,6 +28,7 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.domain.exceptions import BusinessRuleViolation, InvalidOperationError
from app.domain.test_entity import TestEntity
from app.models.campaign import Campaign, CampaignTest
from app.models.enums import TestState, TeamSide
from app.models.evidence import Evidence
from app.models.test import Test
@@ -174,8 +175,26 @@ def transition_state(
# ---------------------------------------------------------------------------
def _get_campaign_start_date(db: Session, test_id) -> datetime | None:
"""Return the scheduled start_date of the campaign *test_id* belongs to, if any."""
campaign = (
db.query(Campaign)
.join(CampaignTest, CampaignTest.campaign_id == Campaign.id)
.filter(CampaignTest.test_id == test_id)
.first()
)
return campaign.start_date if campaign else None
def start_execution(db: Session, test: Test, user: User) -> Test:
"""Move from ``draft`` → ``red_executing``."""
campaign_start_date = _get_campaign_start_date(db, test.id)
if campaign_start_date and campaign_start_date > datetime.utcnow():
raise BusinessRuleViolation(
"This test's campaign is scheduled to start on "
f"{campaign_start_date.date().isoformat()} — it cannot be started before then."
)
entity = TestEntity.from_orm(test)
# Call entity.start_execution()
entity.start_execution()
@@ -0,0 +1,76 @@
"""Tests for the campaign start_date scheduling gate on test execution.
Block 3 fix: `Campaign.start_date` was persisted and displayed but never
actually enforced a test belonging to a campaign scheduled for the future
could still be started immediately. `start_execution` now blocks this.
"""
from datetime import datetime, timedelta
import pytest
from app.domain.errors import BusinessRuleViolation
from app.models.campaign import Campaign, CampaignTest
from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
from app.services.test_workflow_service import start_execution
def _seed_test_in_campaign(db, owner_id, start_date):
tech = Technique(mitre_id="T1059.003", name="Windows Command Shell", tactic="execution", platforms=["windows"])
db.add(tech)
db.flush()
campaign = Campaign(name="Scheduled Campaign", type="custom", status="active", created_by=owner_id, start_date=start_date)
db.add(campaign)
db.flush()
test = Test(technique_id=tech.id, name="Scheduled test", state=TestState.draft, created_by=owner_id)
db.add(test)
db.flush()
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
db.commit()
db.refresh(test)
return test
def test_start_execution_blocked_before_campaign_start_date(db, red_tech_user):
future = datetime.utcnow() + timedelta(days=7)
test = _seed_test_in_campaign(db, red_tech_user.id, future)
with pytest.raises(BusinessRuleViolation):
start_execution(db, test, red_tech_user)
def test_start_execution_allowed_after_campaign_start_date(db, red_tech_user):
past = datetime.utcnow() - timedelta(days=1)
test = _seed_test_in_campaign(db, red_tech_user.id, past)
result = start_execution(db, test, red_tech_user)
assert result.state == TestState.red_executing
def test_start_execution_allowed_for_standalone_test(db, red_tech_user):
"""A test with no campaign at all is never gated by this check."""
tech = Technique(mitre_id="T1059.004", name="Unix Shell", tactic="execution", platforms=["linux"])
db.add(tech)
db.flush()
test = Test(technique_id=tech.id, name="Standalone test", state=TestState.draft, created_by=red_tech_user.id)
db.add(test)
db.commit()
db.refresh(test)
result = start_execution(db, test, red_tech_user)
assert result.state == TestState.red_executing
def test_start_execution_allowed_when_campaign_has_no_start_date(db, red_tech_user):
test = _seed_test_in_campaign(db, red_tech_user.id, None)
result = start_execution(db, test, red_tech_user)
assert result.state == TestState.red_executing
@@ -0,0 +1,48 @@
"""Tests for canonical MITRE kill-chain ordering in coverage-by-tactic.
Block 3 fix: TacticCoverageChart.tsx (dashboard) rendered whatever order
the backend happened to return (alphabetical, and only tactics with
seeded techniques), while ExecutiveDashboardPage.tsx re-sorted client-side
into the correct kill-chain order. get_coverage_by_tactic now returns the
canonical order and all 14 tactics, zero-filled so both consumers get
consistent results without needing their own reordering logic.
"""
from app.models.technique import Technique
from app.services.metrics_query_service import MITRE_TACTIC_ORDER, get_coverage_by_tactic
def test_coverage_by_tactic_returns_canonical_kill_chain_order(db):
# Seed a few tactics out of order to prove the response isn't alphabetical.
db.add(Technique(mitre_id="T3001", name="A", tactic="impact"))
db.add(Technique(mitre_id="T3002", name="B", tactic="execution"))
db.add(Technique(mitre_id="T3003", name="C", tactic="reconnaissance"))
db.commit()
rows = get_coverage_by_tactic(db)
tactics = [r.tactic for r in rows]
canonical_present = [t for t in tactics if t in MITRE_TACTIC_ORDER]
assert canonical_present == MITRE_TACTIC_ORDER
def test_coverage_by_tactic_zero_fills_tactics_with_no_techniques(db):
db.add(Technique(mitre_id="T3004", name="D", tactic="execution"))
db.commit()
rows = get_coverage_by_tactic(db)
by_tactic = {r.tactic: r for r in rows}
assert "persistence" in by_tactic
assert by_tactic["persistence"].total == 0
assert by_tactic["execution"].total == 1
def test_coverage_by_tactic_unknown_tactic_sorts_last(db):
db.add(Technique(mitre_id="T3005", name="E", tactic=None))
db.commit()
rows = get_coverage_by_tactic(db)
tactics = [r.tactic for r in rows]
assert tactics[-1] == "unknown"
@@ -0,0 +1,60 @@
"""Tests for the `reviewer_id` "my reviews" queue filter on GET /tests.
Block 3 fix: red_lead/blue_lead had no way to see tests specifically
assigned to THEM at the red_review/blue_review lead-review gate the
existing `pending_validation_side` filter only covers the later, unrelated
in_review manager-validation stage and ignores assignment entirely.
"""
import uuid
from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
from app.services.test_crud_service import list_tests
def _make_test(db, owner_id, state, name, red_reviewer=None, blue_reviewer=None):
tech = Technique(mitre_id=f"T9{uuid.uuid4().hex[:6]}", name="X", tactic="execution", platforms=["windows"])
db.add(tech)
db.flush()
test = Test(
technique_id=tech.id,
name=name,
state=state,
created_by=owner_id,
red_reviewer_assignee=red_reviewer,
blue_reviewer_assignee=blue_reviewer,
)
db.add(test)
db.commit()
db.refresh(test)
return test
def test_reviewer_id_with_explicit_state_filters_by_matching_assignee(db, red_lead_user, blue_lead_user):
mine = _make_test(db, red_lead_user.id, TestState.red_review, "Mine", red_reviewer=red_lead_user.id)
_make_test(db, red_lead_user.id, TestState.red_review, "Someone else's", red_reviewer=blue_lead_user.id)
results = list_tests(db, state="red_review", reviewer_id=red_lead_user.id)
assert [t.id for t in results] == [mine.id]
def test_reviewer_id_without_state_covers_both_red_and_blue_review(db, red_lead_user, blue_lead_user):
red = _make_test(db, red_lead_user.id, TestState.red_review, "Red review mine", red_reviewer=red_lead_user.id)
blue = _make_test(db, red_lead_user.id, TestState.blue_review, "Blue review mine", blue_reviewer=red_lead_user.id)
_make_test(db, red_lead_user.id, TestState.red_review, "Not mine", red_reviewer=blue_lead_user.id)
_make_test(db, red_lead_user.id, TestState.draft, "Unrelated state")
results = list_tests(db, reviewer_id=red_lead_user.id)
assert {t.id for t in results} == {red.id, blue.id}
def test_reviewer_id_excludes_unassigned_tests_in_that_state(db, red_lead_user):
_make_test(db, red_lead_user.id, TestState.red_review, "Unassigned", red_reviewer=None)
results = list_tests(db, state="red_review", reviewer_id=red_lead_user.id)
assert results == []
+5 -1
View File
@@ -60,7 +60,11 @@ def _make_user(role: str = "red_tech") -> MagicMock:
def _make_db() -> MagicMock:
return MagicMock()
db = MagicMock()
# start_execution's campaign-schedule lookup (.query(Campaign).join(...).filter(...).first())
# defaults to "no campaign found" so unrelated tests aren't gated by a MagicMock date.
db.query.return_value.join.return_value.filter.return_value.first.return_value = None
return db
# ---------------------------------------------------------------------------
@@ -0,0 +1,41 @@
"""Tests for the `existing_test_count` field on TestTemplateSummary.
Block 3 fix: the Test Catalog page had no way to know a technique already
had one or more tests, so operators could unknowingly instantiate a
duplicate from a template. list_templates now attaches a per-technique
test count so the frontend can show a warning badge.
"""
from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.services.test_template_service import list_templates
def test_existing_test_count_reflects_tests_for_that_technique(db, red_lead_user):
tech = Technique(mitre_id="T1055", name="Process Injection", tactic="defense-evasion", platforms=["windows"])
db.add(tech)
db.flush()
db.add(TestTemplate(mitre_technique_id="T1055", name="Injection template", source="custom"))
db.add(Test(technique_id=tech.id, name="Existing test 1", state=TestState.draft, created_by=red_lead_user.id))
db.add(Test(technique_id=tech.id, name="Existing test 2", state=TestState.validated, created_by=red_lead_user.id))
db.commit()
templates = list_templates(db, mitre_technique_id="T1055")
assert len(templates) == 1
assert templates[0].existing_test_count == 2
def test_existing_test_count_zero_when_no_tests(db):
tech = Technique(mitre_id="T1056", name="Input Capture", tactic="collection", platforms=["windows"])
db.add(tech)
db.flush()
db.add(TestTemplate(mitre_technique_id="T1056", name="Capture template", source="custom"))
db.commit()
templates = list_templates(db, mitre_technique_id="T1056")
assert len(templates) == 1
assert templates[0].existing_test_count == 0
+5 -1
View File
@@ -153,7 +153,11 @@ def _make_user(role: str = "red_tech") -> MagicMock:
def _make_db() -> MagicMock:
return MagicMock()
db = MagicMock()
# start_execution's campaign-schedule lookup (.query(Campaign).join(...).filter(...).first())
# defaults to "no campaign found" so unrelated tests aren't gated by a MagicMock date.
db.query.return_value.join.return_value.filter.return_value.first.return_value = None
return db
# ===========================================================================
+3
View File
@@ -70,6 +70,8 @@ export interface TestListFilters {
platform?: string;
created_by?: string;
pending_validation_side?: "red" | "blue";
/** "My reviews" queue — tests assigned to this reviewer at the red_review/blue_review gate. */
reviewer_id?: string;
not_in_any_campaign?: boolean;
assigned_to_me?: boolean;
unassigned_red?: boolean;
@@ -88,6 +90,7 @@ export async function getTests(filters?: TestListFilters): Promise<Test[]> {
if (filters?.platform) params.append("platform", filters.platform);
if (filters?.created_by) params.append("created_by", filters.created_by);
if (filters?.pending_validation_side) params.append("pending_validation_side", filters.pending_validation_side);
if (filters?.reviewer_id) params.append("reviewer_id", filters.reviewer_id);
if (filters?.not_in_any_campaign) params.append("not_in_any_campaign", "true");
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
+1 -1
View File
@@ -25,7 +25,7 @@ const BADGE_LABELS: Record<TechniqueStatus, string> = {
interface TooltipLine { label: string; text: string }
const TOOLTIPS: Record<TechniqueStatus, { heading: string; lines: TooltipLine[] }> = {
export const TOOLTIPS: Record<TechniqueStatus, { heading: string; lines: TooltipLine[] }> = {
validated: {
heading: "✅ Validated",
lines: [
@@ -1,4 +1,4 @@
import { useState } from "react";
import { Fragment, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronDown, ChevronRight, Search, Filter, ExternalLink, Info, ShieldAlert } from "lucide-react";
import type { ComplianceControlStatus } from "../../api/compliance";
@@ -139,10 +139,9 @@ export default function ControlsTable({ controls }: ControlsTableProps) {
const statusStyle = STATUS_COLORS[control.status] || STATUS_COLORS.not_evaluated;
return (
<>
<Fragment key={control.control_id}>
{/* Main row */}
<tr
key={control.control_id}
className={`cursor-pointer transition-colors hover:bg-gray-800/40 ${
isExpanded ? "bg-gray-800/20" : ""
}`}
@@ -256,7 +255,7 @@ export default function ControlsTable({ controls }: ControlsTableProps) {
</td>
</tr>
)}
</>
</Fragment>
);
})}
</tbody>
@@ -1,19 +1,27 @@
import type { TechniqueStatus } from "../../types/models";
import { TOOLTIPS } from "../StatusBadge";
interface HeatmapLegendProps {
layerType: "coverage" | "threat-actor" | "detection-rules" | "campaign";
}
function tooltipText(status: TechniqueStatus): string {
const t = TOOLTIPS[status];
return `${t.heading}${t.lines.map((l) => `${l.label}: ${l.text}`).join(" ")}`;
}
const LEGENDS: Record<
string,
{ label: string; colors: { color: string; label: string }[] }
{ label: string; colors: { color: string; label: string; status?: TechniqueStatus }[] }
> = {
coverage: {
label: "Coverage Status",
colors: [
{ color: "#d3d3d3", label: "Not Evaluated (0)" },
{ color: "#ff6666", label: "Not Covered (10)" },
{ color: "#ff9933", label: "In Progress (30)" },
{ color: "#ffff66", label: "Partial (60)" },
{ color: "#66ff66", label: "Validated (100)" },
{ color: "#d3d3d3", label: "Not Evaluated (0)", status: "not_evaluated" },
{ color: "#ff6666", label: "Not Covered (10)", status: "not_covered" },
{ color: "#ff9933", label: "In Progress (30)", status: "in_progress" },
{ color: "#ffff66", label: "Partial (60)", status: "partial" },
{ color: "#66ff66", label: "Validated (100)", status: "validated" },
],
},
"threat-actor": {
@@ -66,7 +74,11 @@ export default function HeatmapLegend({ layerType }: HeatmapLegendProps) {
{/* Individual labels */}
{legend.colors.map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<div
key={item.label}
className="flex items-center gap-1.5"
title={item.status ? tooltipText(item.status) : undefined}
>
<div
className="h-3 w-3 rounded border border-gray-700"
style={{ backgroundColor: item.color }}
@@ -74,6 +86,15 @@ export default function HeatmapLegend({ layerType }: HeatmapLegendProps) {
<span className="text-xs text-gray-400">{item.label}</span>
</div>
))}
{/* Review Required an overlay indicator (amber ring + ), not a
distinct score tier, so it's shown separately from the gradient. */}
{layerType === "coverage" && (
<div className="flex items-center gap-1.5" title={tooltipText("review_required")}>
<div className="h-3 w-3 rounded border border-gray-700 ring-1 ring-amber-400/60" />
<span className="text-xs text-gray-400"> Review Required (any status)</span>
</div>
)}
</div>
);
}
+1 -1
View File
@@ -289,7 +289,7 @@ export default function CompliancePage() {
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{/* Gauge */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4 flex flex-col items-center justify-center">
<ComplianceGauge percentage={summary.compliance_percentage} size="md" />
+14 -9
View File
@@ -260,8 +260,11 @@ export default function ExecutiveDashboardPage() {
})
.slice(0, 10);
// Official MITRE ATT&CK tactic order (slug → display label)
const MITRE_TACTIC_ORDER: Record<string, string> = {
// Display labels for MITRE ATT&CK tactic slugs. Ordering itself comes
// pre-sorted from the backend (metrics_query_service.MITRE_TACTIC_ORDER),
// shared with the regular Dashboard's TacticCoverageChart so both views
// agree on tactic order without each reimplementing it.
const TACTIC_LABELS: Record<string, string> = {
"reconnaissance": "Reconnaissance",
"resource-development": "Resource Development",
"initial-access": "Initial Access",
@@ -278,22 +281,17 @@ export default function ExecutiveDashboardPage() {
"impact": "Impact",
};
const tacticDataRaw = (tacticCoverage || []).map((tc) => {
const tacticData = (tacticCoverage || []).map((tc) => {
const slug = tc.tactic.toLowerCase();
const label = MITRE_TACTIC_ORDER[slug] ||
const label = TACTIC_LABELS[slug] ||
tc.tactic.split("-").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
const order = Object.keys(MITRE_TACTIC_ORDER).indexOf(slug);
return {
name: label,
slug,
order: order === -1 ? 99 : order,
coverage: tc.total > 0 ? Math.round(((tc.validated + tc.partial) / tc.total) * 100) : 0,
};
});
// Sort by official MITRE order; include any unknown tactics at end
const tacticData = tacticDataRaw.sort((a, b) => a.order - b.order);
const getBarColor = (coverage: number) => {
if (coverage < 30) return "#ef4444";
if (coverage < 50) return "#f97316";
@@ -313,6 +311,7 @@ export default function ExecutiveDashboardPage() {
</div>
{/* Time range filter */}
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-1.5 rounded-xl border border-gray-800 bg-gray-900 p-1">
{TIME_RANGE_OPTIONS.map((opt) => (
<button
@@ -328,6 +327,12 @@ export default function ExecutiveDashboardPage() {
</button>
))}
</div>
{timeRange !== "all" && (
<p className="text-[10px] text-gray-500">
Applies to Team Performance &amp; Operational KPIs only other sections always show all-time data.
</p>
)}
</div>
</div>
{/* Section 1: Score Card + Sub-scores */}
+12
View File
@@ -10,6 +10,7 @@ import {
ChevronRight,
FlaskConical,
X,
AlertTriangle,
} from "lucide-react";
import { getTemplates } from "../api/test-templates";
import TestFromTemplateForm from "../components/TestFromTemplateForm";
@@ -349,6 +350,17 @@ function TemplateCard({
)}
</div>
{/* Duplicate-test warning */}
{template.existing_test_count > 0 && (
<div
className="mt-3 flex items-center gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 px-2.5 py-1.5 text-xs text-amber-400"
title={`${template.existing_test_count} existing test(s) already cover ${template.mitre_technique_id}`}
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{template.existing_test_count} existing test{template.existing_test_count !== 1 ? "s" : ""} for this technique
</div>
)}
{/* Spacer */}
<div className="flex-1" />
+47 -7
View File
@@ -149,6 +149,8 @@ export default function TestsPage() {
const [platformFilter, setPlatformFilter] = useState("");
const [searchText, setSearchText] = useState("");
const [showMyTasks, setShowMyTasks] = useState(false);
const [showMyReviews, setShowMyReviews] = useState(false);
const isReviewLead = user?.role === "red_lead" || user?.role === "blue_lead";
// ── Sort state ────────────────────────────────────────────────────
const [sortKey, setSortKey] = useState<SortKey>("created_at");
@@ -167,7 +169,13 @@ export default function TestsPage() {
const filters = useMemo<TestListFilters>(() => {
const f: TestListFilters = { limit: 200 };
if (showMyTasks && user) {
if (showMyReviews && user && isReviewLead) {
// "My reviews" — tests assigned to ME at the red_review/blue_review
// gate. Distinct from "My Tasks" below, which covers the later
// in_review manager-validation stage regardless of assignment.
f.state = user.role === "red_lead" ? "red_review" : "blue_review";
f.reviewer_id = user.id;
} else if (showMyTasks && user) {
switch (user.role) {
case "red_tech":
f.created_by = user.id;
@@ -192,7 +200,7 @@ export default function TestsPage() {
if (platformFilter) f.platform = platformFilter;
return f;
}, [stateFilter, platformFilter, showMyTasks, user]);
}, [stateFilter, platformFilter, showMyTasks, showMyReviews, isReviewLead, user]);
const {
data: allTests,
@@ -497,7 +505,10 @@ export default function TestsPage() {
<button
onClick={() => {
setShowMyTasks(!showMyTasks);
if (!showMyTasks) setStateFilter("");
if (!showMyTasks) {
setShowMyReviews(false);
setStateFilter("");
}
}}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
showMyTasks
@@ -510,6 +521,29 @@ export default function TestsPage() {
</button>
)}
{/* My reviews toggle red_lead/blue_lead only, the red_review/blue_review
lead-review gate assignment queue (distinct from "My Tasks" above,
which covers the later in_review manager-validation stage). */}
{isReviewLead && (
<button
onClick={() => {
setShowMyReviews(!showMyReviews);
if (!showMyReviews) {
setShowMyTasks(false);
setStateFilter("");
}
}}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
showMyReviews
? "border-cyan-500/50 bg-cyan-500/20 text-cyan-400"
: "border-gray-700 bg-gray-800 text-gray-300 hover:border-gray-600"
}`}
>
<Eye className="h-4 w-4" />
My Reviews
</button>
)}
{/* State filter */}
<div className="flex items-center gap-1.5">
<Filter className="h-4 w-4 text-gray-500" />
@@ -556,13 +590,14 @@ export default function TestsPage() {
</div>
{/* Clear filters */}
{(stateFilter || platformFilter || searchText || showMyTasks) && (
{(stateFilter || platformFilter || searchText || showMyTasks || showMyReviews) && (
<button
onClick={() => {
setStateFilter("");
setPlatformFilter("");
setSearchText("");
setShowMyTasks(false);
setShowMyReviews(false);
}}
className="text-xs text-gray-400 hover:text-white transition-colors"
>
@@ -572,9 +607,14 @@ export default function TestsPage() {
</div>
{/* Active filter summary */}
{(stateFilter || showMyTasks) && (
{(stateFilter || showMyTasks || showMyReviews) && (
<div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
<span>Showing:</span>
{showMyReviews && (
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
My Reviews
</span>
)}
{showMyTasks && (
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
{myTasksLabel}
@@ -629,11 +669,11 @@ export default function TestsPage() {
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
{showMyTasks ? myTasksLabel : "All Tests"}
{showMyReviews ? "My Reviews" : showMyTasks ? myTasksLabel : "All Tests"}
</h2>
<span className="text-sm text-gray-400">{tests.length} tests</span>
</div>
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyReviews ? "No tests currently assigned to you for review." : showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
</div>
)}
</div>
+2
View File
@@ -203,6 +203,8 @@ export interface TestTemplateSummary {
source: string;
platform: string | null;
severity: string | null;
/** Number of existing tests for this template's technique — used to warn before creating a duplicate. */
existing_test_count: number;
}
// ── Timeline ───────────────────────────────────────────────────────