fix(jira): store execution times as real UTC, fix RT date fields, label reopens
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
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
- execution_start_time/execution_end_time/detection_time/containment_time were captured from a datetime-local input with no local->UTC conversion, then stored/displayed as if already UTC — off by the browser's offset. Frontend now converts properly in both directions; backend schemas defensively normalize any tz-aware input to naive UTC as well. - push_rt_submitted was pushing datetime.utcnow() (whenever the submit button was clicked) into Jira's RT Start/End Date fields instead of the operator-entered execution window. Now pushes the *first* round's execution start (recovered from round_history across reopens) and the *current* round's execution end. Removed push_rt_started, which only ever pushed a meaningless click-timestamp. - Reopening a test now adds a "reopened" Jira label (read-modify-write so existing labels aren't clobbered), on top of the existing round-archived comment and status push.
This commit is contained in:
@@ -4,15 +4,29 @@
|
||||
import uuid
|
||||
|
||||
# Import datetime from datetime
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
|
||||
# Import DataClassification from app.domain.enums
|
||||
from app.domain.enums import AttackSuccessResult, ContainmentResult, DataClassification
|
||||
from app.models.enums import TestResult, TestState
|
||||
from app.schemas.evidence import EvidenceOut
|
||||
|
||||
|
||||
def _to_naive_utc(v: datetime | None) -> datetime | None:
|
||||
"""Normalize an incoming datetime to naive UTC.
|
||||
|
||||
Every timestamp column in this app is naive-UTC (set via
|
||||
``datetime.utcnow()`` server-side). Clients are expected to send a
|
||||
proper UTC ISO string (with a ``Z``/offset), but if one slips through
|
||||
tz-aware, convert it rather than let it get silently mis-stored as if
|
||||
its clock digits were already UTC.
|
||||
"""
|
||||
if v is not None and v.tzinfo is not None:
|
||||
return v.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return v
|
||||
|
||||
# ── Create ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -85,6 +99,8 @@ class TestRedUpdate(BaseModel):
|
||||
execution_start_time: datetime | None = None
|
||||
execution_end_time: datetime | None = None
|
||||
|
||||
_normalize_datetimes = field_validator("execution_start_time", "execution_end_time")(_to_naive_utc)
|
||||
|
||||
|
||||
# ── Blue Team update ───────────────────────────────────────────────
|
||||
|
||||
@@ -100,6 +116,8 @@ class TestBlueUpdate(BaseModel):
|
||||
# Assign blue_summary = None
|
||||
blue_summary: str | None = None
|
||||
|
||||
_normalize_datetimes = field_validator("detection_time", "containment_time")(_to_naive_utc)
|
||||
|
||||
|
||||
# ── Round history (archived on reopen) ─────────────────────────────
|
||||
|
||||
|
||||
@@ -1107,6 +1107,7 @@ def push_round_archived(db: Session, test, actor, *, round_data) -> None:
|
||||
comment = "\n".join(lines)
|
||||
|
||||
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||
_add_jira_label(jira, link.jira_issue_key, "reopened")
|
||||
link.last_synced_at = datetime.utcnow()
|
||||
db.flush()
|
||||
logger.info(
|
||||
@@ -1117,6 +1118,19 @@ def push_round_archived(db: Session, test, actor, *, round_data) -> None:
|
||||
logger.warning("Failed to push round-archived comment for test %s: %s", test.id, exc, exc_info=True)
|
||||
|
||||
|
||||
def _add_jira_label(jira, issue_key: str, label: str) -> None:
|
||||
"""Add *label* to an issue's labels without clobbering the existing ones.
|
||||
|
||||
Jira's update API takes the whole ``labels`` array, so adding one means
|
||||
read-modify-write: fetch the current list, append if missing, write it
|
||||
back. Failures here are logged and swallowed by the caller.
|
||||
"""
|
||||
issue = jira.issue(issue_key, fields="labels")
|
||||
current = (issue.get("fields") or {}).get("labels") or []
|
||||
if label not in current:
|
||||
jira.update_issue_field(issue_key, fields={"labels": current + [label]})
|
||||
|
||||
|
||||
def push_bt_work_started(db: Session, test, actor) -> None:
|
||||
"""Transition the Jira issue to "In Progress" when a blue tech picks up
|
||||
a queued test to start evaluating.
|
||||
@@ -1228,19 +1242,45 @@ def _update_test_fields(db: Session, test: Test, fields: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
def push_rt_started(db: Session, test: Test) -> None:
|
||||
"""Set the RT Start Date field — called when the operator hits Start Execution."""
|
||||
_update_test_fields(db, test, {
|
||||
JIRA_FIELD_RT_START_DATE: datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
})
|
||||
|
||||
|
||||
def push_rt_submitted(db: Session, test: Test) -> None:
|
||||
"""Set the RT End Date + Attack Success fields — called when Red submits for review."""
|
||||
fields: dict = {JIRA_FIELD_RT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")}
|
||||
"""Set RT Start Date, RT End Date, and Attack Success — called when Red submits for review.
|
||||
|
||||
RT Start Date always reflects the *first* round's execution start (from
|
||||
round_number=1 in round_history if this test has been reopened, else the
|
||||
live field — this is round 1 in that case). RT End Date and Attack
|
||||
Success always reflect the *current* round, since they're read live off
|
||||
the test each time this runs and get pushed again on every resubmit.
|
||||
Previously this pushed ``datetime.utcnow()`` (click time) instead of the
|
||||
operator-entered execution window — fixed to read the real fields.
|
||||
"""
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
|
||||
fields: dict = {}
|
||||
|
||||
first_round = (
|
||||
db.query(TestRoundHistory)
|
||||
.filter(
|
||||
TestRoundHistory.test_id == test.id,
|
||||
TestRoundHistory.team == "red",
|
||||
TestRoundHistory.round_number == 1,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
first_start = (
|
||||
first_round.execution_start_time
|
||||
if first_round and first_round.execution_start_time
|
||||
else test.execution_start_time
|
||||
)
|
||||
if first_start:
|
||||
fields[JIRA_FIELD_RT_START_DATE] = first_start.strftime("%Y-%m-%d")
|
||||
|
||||
if test.execution_end_time:
|
||||
fields[JIRA_FIELD_RT_END_DATE] = test.execution_end_time.strftime("%Y-%m-%d")
|
||||
|
||||
attack_success = _enum_value(test.attack_success)
|
||||
if attack_success in _ATTACK_SUCCESS_TO_JIRA:
|
||||
fields[JIRA_FIELD_ATTACK_SUCCESS] = _select_field(_ATTACK_SUCCESS_TO_JIRA[attack_success])
|
||||
|
||||
_update_test_fields(db, test, fields)
|
||||
|
||||
|
||||
|
||||
@@ -243,12 +243,6 @@ def start_execution(db: Session, test: Test, user: User) -> Test:
|
||||
except Exception as e:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_rt_started
|
||||
push_rt_started(db, test)
|
||||
except Exception as e:
|
||||
logger.warning("Jira RT start date push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user