chore(backend): mypy strict clean + ruff format pass
Pre-merge sanity per devops checklist (ruff format --check, mypy --strict). Type fixes: - ORM models: `Mapped[dict]` → `Mapped[dict[str, Any]]` (audit, scenario, run, report, ttp, detection.artifact_files_json). Equivalent on Pydantic DTOs (TtpBase.params_schema_json, ScenarioStepBase.params_override_json). - Rename `TtpRead.current_version` → `TtpRead.version` to mirror the ORM column (which itself was renamed in D-009 cleanup). - Flask blueprints: add `-> ResponseReturnValue` to every view, plus typed UUID params on `_validate_step_consistency`. - `templating/filters.py`: rewrite the conditional re2 import so mypy can narrow the union (`ModuleType | None`); the runtime branch on `_re2 is not None` removes the unused-ignore that was triggered by warn_unused_ignores. - `pyproject.toml`: add `flask_login.*` and `pythonjsonlogger.*` to the `[[tool.mypy.overrides]]` `ignore_missing_imports` list (both ship without typed marker). - Misc: drop stale `# type: ignore` comments (`app.py:36`, `rbac/decorators.py:35`) flagged by `warn_unused_ignores`. Keep `logging.JsonFormatter` ignore because the symbol exists at runtime but is not re-exported through the typed surface. Formatting: - `ruff format` applied (15 files normalized; line-length unchanged at 100). Verification on this commit: - `ruff check` → All checks passed. - `ruff format --check` → 68 files already formatted. - `mypy --strict src` → Success: no issues found in 54 source files. - `pytest tests/unit` → 49 passed.
This commit is contained in:
@@ -113,6 +113,8 @@ module = [
|
|||||||
"re2",
|
"re2",
|
||||||
"flask_socketio.*",
|
"flask_socketio.*",
|
||||||
"flask_migrate.*",
|
"flask_migrate.*",
|
||||||
|
"flask_login.*",
|
||||||
|
"pythonjsonlogger.*",
|
||||||
"gevent.*",
|
"gevent.*",
|
||||||
"testcontainers.*",
|
"testcontainers.*",
|
||||||
"authlib.*",
|
"authlib.*",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, abort, jsonify
|
from flask import Blueprint, abort, jsonify
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
||||||
@@ -17,7 +18,7 @@ bp = Blueprint("engagements", __name__)
|
|||||||
|
|
||||||
@bp.get("")
|
@bp.get("")
|
||||||
@require_perm(Permission.ENGAGEMENT_READ)
|
@require_perm(Permission.ENGAGEMENT_READ)
|
||||||
def list_engagements():
|
def list_engagements() -> ResponseReturnValue:
|
||||||
stmt = select(Engagement).order_by(Engagement.created_at.desc())
|
stmt = select(Engagement).order_by(Engagement.created_at.desc())
|
||||||
rows = db.session.execute(stmt).scalars().all()
|
rows = db.session.execute(stmt).scalars().all()
|
||||||
return jsonify([EngagementRead.model_validate(row).model_dump(mode="json") for row in rows])
|
return jsonify([EngagementRead.model_validate(row).model_dump(mode="json") for row in rows])
|
||||||
@@ -25,7 +26,7 @@ def list_engagements():
|
|||||||
|
|
||||||
@bp.post("")
|
@bp.post("")
|
||||||
@require_perm(Permission.ENGAGEMENT_CREATE)
|
@require_perm(Permission.ENGAGEMENT_CREATE)
|
||||||
def create_engagement():
|
def create_engagement() -> ResponseReturnValue:
|
||||||
payload = parse_body(EngagementCreate)
|
payload = parse_body(EngagementCreate)
|
||||||
engagement = Engagement(
|
engagement = Engagement(
|
||||||
client_name=payload.client_name,
|
client_name=payload.client_name,
|
||||||
@@ -42,7 +43,7 @@ def create_engagement():
|
|||||||
|
|
||||||
@bp.get("/<eid>")
|
@bp.get("/<eid>")
|
||||||
@require_perm(Permission.ENGAGEMENT_READ)
|
@require_perm(Permission.ENGAGEMENT_READ)
|
||||||
def get_engagement(eid: str):
|
def get_engagement(eid: str) -> ResponseReturnValue:
|
||||||
engagement = db.session.get(Engagement, parse_uuid(eid))
|
engagement = db.session.get(Engagement, parse_uuid(eid))
|
||||||
if engagement is None:
|
if engagement is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -51,7 +52,7 @@ def get_engagement(eid: str):
|
|||||||
|
|
||||||
@bp.put("/<eid>")
|
@bp.put("/<eid>")
|
||||||
@require_perm(Permission.ENGAGEMENT_UPDATE)
|
@require_perm(Permission.ENGAGEMENT_UPDATE)
|
||||||
def update_engagement(eid: str):
|
def update_engagement(eid: str) -> ResponseReturnValue:
|
||||||
engagement = db.session.get(Engagement, parse_uuid(eid))
|
engagement = db.session.get(Engagement, parse_uuid(eid))
|
||||||
if engagement is None:
|
if engagement is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -64,7 +65,7 @@ def update_engagement(eid: str):
|
|||||||
|
|
||||||
@bp.delete("/<eid>")
|
@bp.delete("/<eid>")
|
||||||
@require_perm(Permission.ENGAGEMENT_DELETE)
|
@require_perm(Permission.ENGAGEMENT_DELETE)
|
||||||
def delete_engagement(eid: str):
|
def delete_engagement(eid: str) -> ResponseReturnValue:
|
||||||
engagement = db.session.get(Engagement, parse_uuid(eid))
|
engagement = db.session.get(Engagement, parse_uuid(eid))
|
||||||
if engagement is None:
|
if engagement is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, abort, jsonify
|
from flask import Blueprint, abort, jsonify
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
||||||
@@ -24,7 +25,7 @@ def _engagement_or_404(eid: str) -> Engagement:
|
|||||||
|
|
||||||
@bp.get("/engagements/<eid>/hosts")
|
@bp.get("/engagements/<eid>/hosts")
|
||||||
@require_perm(Permission.HOST_CRUD)
|
@require_perm(Permission.HOST_CRUD)
|
||||||
def list_hosts(eid: str):
|
def list_hosts(eid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
stmt = select(Host).where(Host.engagement_id == engagement.id).order_by(Host.hostname)
|
stmt = select(Host).where(Host.engagement_id == engagement.id).order_by(Host.hostname)
|
||||||
rows = db.session.execute(stmt).scalars().all()
|
rows = db.session.execute(stmt).scalars().all()
|
||||||
@@ -33,7 +34,7 @@ def list_hosts(eid: str):
|
|||||||
|
|
||||||
@bp.post("/engagements/<eid>/hosts")
|
@bp.post("/engagements/<eid>/hosts")
|
||||||
@require_perm(Permission.HOST_CRUD)
|
@require_perm(Permission.HOST_CRUD)
|
||||||
def create_host(eid: str):
|
def create_host(eid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
payload = parse_body(HostCreate)
|
payload = parse_body(HostCreate)
|
||||||
host = Host(
|
host = Host(
|
||||||
@@ -52,7 +53,7 @@ def create_host(eid: str):
|
|||||||
|
|
||||||
@bp.put("/engagements/<eid>/hosts/<hid>")
|
@bp.put("/engagements/<eid>/hosts/<hid>")
|
||||||
@require_perm(Permission.HOST_CRUD)
|
@require_perm(Permission.HOST_CRUD)
|
||||||
def update_host(eid: str, hid: str):
|
def update_host(eid: str, hid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
host = db.session.get(Host, parse_uuid(hid, field="host id"))
|
host = db.session.get(Host, parse_uuid(hid, field="host id"))
|
||||||
if host is None or host.engagement_id != engagement.id:
|
if host is None or host.engagement_id != engagement.id:
|
||||||
@@ -66,7 +67,7 @@ def update_host(eid: str, hid: str):
|
|||||||
|
|
||||||
@bp.delete("/engagements/<eid>/hosts/<hid>")
|
@bp.delete("/engagements/<eid>/hosts/<hid>")
|
||||||
@require_perm(Permission.HOST_CRUD)
|
@require_perm(Permission.HOST_CRUD)
|
||||||
def delete_host(eid: str, hid: str):
|
def delete_host(eid: str, hid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
host = db.session.get(Host, parse_uuid(hid, field="host id"))
|
host = db.session.get(Host, parse_uuid(hid, field="host id"))
|
||||||
if host is None or host.engagement_id != engagement.id:
|
if host is None or host.engagement_id != engagement.id:
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import Blueprint, abort, jsonify
|
from flask import Blueprint, abort, jsonify
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
||||||
@@ -34,7 +37,7 @@ def _scenario_or_404(engagement: Engagement, sid: str) -> Scenario:
|
|||||||
return scenario
|
return scenario
|
||||||
|
|
||||||
|
|
||||||
def _validate_step_consistency(scenario: Scenario, ttp_id, host_id) -> None:
|
def _validate_step_consistency(scenario: Scenario, ttp_id: UUID, host_id: UUID) -> None:
|
||||||
ttp = db.session.get(Ttp, ttp_id)
|
ttp = db.session.get(Ttp, ttp_id)
|
||||||
host = db.session.get(Host, host_id)
|
host = db.session.get(Host, host_id)
|
||||||
if ttp is None or host is None:
|
if ttp is None or host is None:
|
||||||
@@ -47,7 +50,7 @@ def _validate_step_consistency(scenario: Scenario, ttp_id, host_id) -> None:
|
|||||||
|
|
||||||
@bp.get("/engagements/<eid>/scenarios")
|
@bp.get("/engagements/<eid>/scenarios")
|
||||||
@require_perm(Permission.SCENARIO_CRUD)
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
def list_scenarios(eid: str):
|
def list_scenarios(eid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Scenario)
|
select(Scenario)
|
||||||
@@ -60,7 +63,7 @@ def list_scenarios(eid: str):
|
|||||||
|
|
||||||
@bp.post("/engagements/<eid>/scenarios")
|
@bp.post("/engagements/<eid>/scenarios")
|
||||||
@require_perm(Permission.SCENARIO_CRUD)
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
def create_scenario(eid: str):
|
def create_scenario(eid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
payload = parse_body(ScenarioCreate)
|
payload = parse_body(ScenarioCreate)
|
||||||
scenario = Scenario(
|
scenario = Scenario(
|
||||||
@@ -90,7 +93,7 @@ def create_scenario(eid: str):
|
|||||||
|
|
||||||
@bp.get("/engagements/<eid>/scenarios/<sid>")
|
@bp.get("/engagements/<eid>/scenarios/<sid>")
|
||||||
@require_perm(Permission.SCENARIO_CRUD)
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
def get_scenario(eid: str, sid: str):
|
def get_scenario(eid: str, sid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
scenario = _scenario_or_404(engagement, sid)
|
scenario = _scenario_or_404(engagement, sid)
|
||||||
return jsonify_model(ScenarioRead.model_validate(scenario))
|
return jsonify_model(ScenarioRead.model_validate(scenario))
|
||||||
@@ -98,7 +101,7 @@ def get_scenario(eid: str, sid: str):
|
|||||||
|
|
||||||
@bp.put("/engagements/<eid>/scenarios/<sid>")
|
@bp.put("/engagements/<eid>/scenarios/<sid>")
|
||||||
@require_perm(Permission.SCENARIO_CRUD)
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
def update_scenario(eid: str, sid: str):
|
def update_scenario(eid: str, sid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
scenario = _scenario_or_404(engagement, sid)
|
scenario = _scenario_or_404(engagement, sid)
|
||||||
payload = parse_body(ScenarioUpdate)
|
payload = parse_body(ScenarioUpdate)
|
||||||
@@ -110,7 +113,7 @@ def update_scenario(eid: str, sid: str):
|
|||||||
|
|
||||||
@bp.delete("/engagements/<eid>/scenarios/<sid>")
|
@bp.delete("/engagements/<eid>/scenarios/<sid>")
|
||||||
@require_perm(Permission.SCENARIO_CRUD)
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
def delete_scenario(eid: str, sid: str):
|
def delete_scenario(eid: str, sid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
scenario = _scenario_or_404(engagement, sid)
|
scenario = _scenario_or_404(engagement, sid)
|
||||||
db.session.delete(scenario)
|
db.session.delete(scenario)
|
||||||
@@ -120,7 +123,7 @@ def delete_scenario(eid: str, sid: str):
|
|||||||
|
|
||||||
@bp.post("/engagements/<eid>/scenarios/<sid>/steps")
|
@bp.post("/engagements/<eid>/scenarios/<sid>/steps")
|
||||||
@require_perm(Permission.SCENARIO_CRUD)
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
def add_step(eid: str, sid: str):
|
def add_step(eid: str, sid: str) -> ResponseReturnValue:
|
||||||
engagement = _engagement_or_404(eid)
|
engagement = _engagement_or_404(eid)
|
||||||
scenario = _scenario_or_404(engagement, sid)
|
scenario = _scenario_or_404(engagement, sid)
|
||||||
payload = parse_body(ScenarioStepCreate)
|
payload = parse_body(ScenarioStepCreate)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, abort, jsonify
|
from flask import Blueprint, abort, jsonify
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
||||||
@@ -16,7 +17,7 @@ bp = Blueprint("ttps", __name__)
|
|||||||
|
|
||||||
@bp.get("")
|
@bp.get("")
|
||||||
@require_perm(Permission.TTP_READ)
|
@require_perm(Permission.TTP_READ)
|
||||||
def list_ttps():
|
def list_ttps() -> ResponseReturnValue:
|
||||||
stmt = select(Ttp).order_by(Ttp.created_at.desc())
|
stmt = select(Ttp).order_by(Ttp.created_at.desc())
|
||||||
rows = db.session.execute(stmt).scalars().all()
|
rows = db.session.execute(stmt).scalars().all()
|
||||||
return jsonify([TtpRead.model_validate(row).model_dump(mode="json") for row in rows])
|
return jsonify([TtpRead.model_validate(row).model_dump(mode="json") for row in rows])
|
||||||
@@ -24,7 +25,7 @@ def list_ttps():
|
|||||||
|
|
||||||
@bp.post("")
|
@bp.post("")
|
||||||
@require_perm(Permission.TTP_DRAFT)
|
@require_perm(Permission.TTP_DRAFT)
|
||||||
def create_ttp():
|
def create_ttp() -> ResponseReturnValue:
|
||||||
payload = parse_body(TtpCreate)
|
payload = parse_body(TtpCreate)
|
||||||
ttp = Ttp(**payload.model_dump())
|
ttp = Ttp(**payload.model_dump())
|
||||||
db.session.add(ttp)
|
db.session.add(ttp)
|
||||||
@@ -34,7 +35,7 @@ def create_ttp():
|
|||||||
|
|
||||||
@bp.get("/<tid>")
|
@bp.get("/<tid>")
|
||||||
@require_perm(Permission.TTP_READ)
|
@require_perm(Permission.TTP_READ)
|
||||||
def get_ttp(tid: str):
|
def get_ttp(tid: str) -> ResponseReturnValue:
|
||||||
ttp = db.session.get(Ttp, parse_uuid(tid, field="ttp id"))
|
ttp = db.session.get(Ttp, parse_uuid(tid, field="ttp id"))
|
||||||
if ttp is None:
|
if ttp is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -43,7 +44,7 @@ def get_ttp(tid: str):
|
|||||||
|
|
||||||
@bp.put("/<tid>")
|
@bp.put("/<tid>")
|
||||||
@require_perm(Permission.TTP_DRAFT)
|
@require_perm(Permission.TTP_DRAFT)
|
||||||
def update_ttp(tid: str):
|
def update_ttp(tid: str) -> ResponseReturnValue:
|
||||||
ttp = db.session.get(Ttp, parse_uuid(tid, field="ttp id"))
|
ttp = db.session.get(Ttp, parse_uuid(tid, field="ttp id"))
|
||||||
if ttp is None:
|
if ttp is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -63,7 +64,7 @@ def update_ttp(tid: str):
|
|||||||
|
|
||||||
@bp.delete("/<tid>")
|
@bp.delete("/<tid>")
|
||||||
@require_perm(Permission.TTP_DRAFT)
|
@require_perm(Permission.TTP_DRAFT)
|
||||||
def delete_ttp(tid: str):
|
def delete_ttp(tid: str) -> ResponseReturnValue:
|
||||||
ttp = db.session.get(Ttp, parse_uuid(tid, field="ttp id"))
|
ttp = db.session.get(Ttp, parse_uuid(tid, field="ttp id"))
|
||||||
if ttp is None:
|
if ttp is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def create_app(settings: Settings | None = None) -> Flask:
|
|||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
migrate.init_app(app, db, directory="src/mimic/db/migrations")
|
migrate.init_app(app, db, directory="src/mimic/db/migrations")
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
login_manager.user_loader(load_user) # type: ignore[arg-type]
|
login_manager.user_loader(load_user)
|
||||||
|
|
||||||
socketio.init_app(
|
socketio.init_app(
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ def create_user(email: str, user_type: str, password: str, display_name: str | N
|
|||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
group_name = (
|
group_name = GroupName.RT_LEAD if user.type is UserType.RT_LEAD else GroupName.RT_OPERATOR
|
||||||
GroupName.RT_LEAD if user.type is UserType.RT_LEAD else GroupName.RT_OPERATOR
|
|
||||||
)
|
|
||||||
group = db.session.query(Group).filter_by(name=group_name.value).first()
|
group = db.session.query(Group).filter_by(name=group_name.value).first()
|
||||||
if group is None:
|
if group is None:
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ class ConnectorFactory:
|
|||||||
try:
|
try:
|
||||||
klass = _REGISTRY[c2_type]
|
klass = _REGISTRY[c2_type]
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(f"no connector registered for {c2_type.value}") from exc
|
||||||
f"no connector registered for {c2_type.value}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
connector = klass()
|
connector = klass()
|
||||||
connector.authenticate(self._resolver(c2_type))
|
connector.authenticate(self._resolver(c2_type))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Revision ID: 202605210001
|
|||||||
Revises:
|
Revises:
|
||||||
Create Date: 2026-05-21
|
Create Date: 2026-05-21
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -30,9 +31,7 @@ depends_on: str | None = None
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
USER_TYPE = sa.Enum("rt_operator", "rt_lead", "soc_analyst", name="user_type")
|
USER_TYPE = sa.Enum("rt_operator", "rt_lead", "soc_analyst", name="user_type")
|
||||||
ENGAGEMENT_STATUS = sa.Enum(
|
ENGAGEMENT_STATUS = sa.Enum("draft", "active", "closed", "archived", name="engagement_status")
|
||||||
"draft", "active", "closed", "archived", name="engagement_status"
|
|
||||||
)
|
|
||||||
C2_TYPE = sa.Enum("mythic", "home", name="c2_type")
|
C2_TYPE = sa.Enum("mythic", "home", name="c2_type")
|
||||||
HOST_STATUS = sa.Enum("unknown", "alive", "dead", name="host_status")
|
HOST_STATUS = sa.Enum("unknown", "alive", "dead", name="host_status")
|
||||||
PAYLOAD_TYPE = sa.Enum(
|
PAYLOAD_TYPE = sa.Enum(
|
||||||
@@ -64,18 +63,10 @@ RUN_STEP_STATUS = sa.Enum(
|
|||||||
"cleanup_failed",
|
"cleanup_failed",
|
||||||
name="run_step_status",
|
name="run_step_status",
|
||||||
)
|
)
|
||||||
CLEANUP_STATUS = sa.Enum(
|
CLEANUP_STATUS = sa.Enum("pending", "success", "failed", "partial", name="cleanup_status")
|
||||||
"pending", "success", "failed", "partial", name="cleanup_status"
|
DETECTION_LEVEL = sa.Enum("detected", "partial", "not_detected", name="detection_level")
|
||||||
)
|
DETECTION_SOURCE = sa.Enum("ndr", "edr", "siem", "manual", "other", name="detection_source")
|
||||||
DETECTION_LEVEL = sa.Enum(
|
EVIDENCE_STATUS = sa.Enum("success", "failure", "partial", name="evidence_status")
|
||||||
"detected", "partial", "not_detected", name="detection_level"
|
|
||||||
)
|
|
||||||
DETECTION_SOURCE = sa.Enum(
|
|
||||||
"ndr", "edr", "siem", "manual", "other", name="detection_source"
|
|
||||||
)
|
|
||||||
EVIDENCE_STATUS = sa.Enum(
|
|
||||||
"success", "failure", "partial", name="evidence_status"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
@@ -107,8 +98,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("local_password_hash", sa.String(255)),
|
sa.Column("local_password_hash", sa.String(255)),
|
||||||
sa.Column("disabled_at", sa.DateTime(timezone=True)),
|
sa.Column("disabled_at", sa.DateTime(timezone=True)),
|
||||||
sa.Column("last_login_at", sa.DateTime(timezone=True)),
|
sa.Column("last_login_at", sa.DateTime(timezone=True)),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
sa.UniqueConstraint("email", name="uq_user_email"),
|
sa.UniqueConstraint("email", name="uq_user_email"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,8 +121,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
sa.Column("name", sa.String(80), nullable=False),
|
sa.Column("name", sa.String(80), nullable=False),
|
||||||
sa.Column("description", sa.String(255)),
|
sa.Column("description", sa.String(255)),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
sa.UniqueConstraint("name", name="uq_group_name"),
|
sa.UniqueConstraint("name", name="uq_group_name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,13 +135,19 @@ def upgrade() -> None:
|
|||||||
sa.Column(
|
sa.Column(
|
||||||
"group_id",
|
"group_id",
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
sa.ForeignKey("group.id", ondelete="CASCADE", name="fk_group_permission_group_id_group"),
|
sa.ForeignKey(
|
||||||
|
"group.id", ondelete="CASCADE", name="fk_group_permission_group_id_group"
|
||||||
|
),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"permission_id",
|
"permission_id",
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
sa.ForeignKey("permission.id", ondelete="CASCADE", name="fk_group_permission_permission_id_permission"),
|
sa.ForeignKey(
|
||||||
|
"permission.id",
|
||||||
|
ondelete="CASCADE",
|
||||||
|
name="fk_group_permission_permission_id_permission",
|
||||||
|
),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
sa.PrimaryKeyConstraint("group_id", "permission_id", name="pk_group_permission"),
|
sa.PrimaryKeyConstraint("group_id", "permission_id", name="pk_group_permission"),
|
||||||
@@ -158,9 +163,15 @@ def upgrade() -> None:
|
|||||||
sa.Column("start_date", sa.Date),
|
sa.Column("start_date", sa.Date),
|
||||||
sa.Column("end_date", sa.Date),
|
sa.Column("end_date", sa.Date),
|
||||||
sa.Column("c2_type", C2_TYPE, nullable=False, server_default="mythic"),
|
sa.Column("c2_type", C2_TYPE, nullable=False, server_default="mythic"),
|
||||||
sa.Column("created_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")),
|
sa.Column(
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -217,8 +228,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("config_fernet", sa.LargeBinary, nullable=False),
|
sa.Column("config_fernet", sa.LargeBinary, nullable=False),
|
||||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||||
sa.Column("retired_at", sa.DateTime(timezone=True)),
|
sa.Column("retired_at", sa.DateTime(timezone=True)),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
op.create_index(
|
op.create_index(
|
||||||
"ix_c2_credential_engagement_active",
|
"ix_c2_credential_engagement_active",
|
||||||
@@ -244,8 +259,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("c2_type", C2_TYPE, nullable=False),
|
sa.Column("c2_type", C2_TYPE, nullable=False),
|
||||||
sa.Column("status", HOST_STATUS, nullable=False, server_default="unknown"),
|
sa.Column("status", HOST_STATUS, nullable=False, server_default="unknown"),
|
||||||
sa.Column("last_seen", sa.DateTime(timezone=True)),
|
sa.Column("last_seen", sa.DateTime(timezone=True)),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
op.create_index("ix_host_engagement_id", "host", ["engagement_id"])
|
op.create_index("ix_host_engagement_id", "host", ["engagement_id"])
|
||||||
|
|
||||||
@@ -267,9 +286,15 @@ def upgrade() -> None:
|
|||||||
sa.Column("tags", JSONB, nullable=False, server_default="[]"),
|
sa.Column("tags", JSONB, nullable=False, server_default="[]"),
|
||||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||||
sa.Column("is_published", sa.Boolean, nullable=False, server_default=sa.false()),
|
sa.Column("is_published", sa.Boolean, nullable=False, server_default=sa.false()),
|
||||||
sa.Column("created_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")),
|
sa.Column(
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
# No `ttp_version` table — H32 / D-009: snapshot lives on run.snapshot_json.
|
# No `ttp_version` table — H32 / D-009: snapshot lives on run.snapshot_json.
|
||||||
|
|
||||||
@@ -287,9 +312,15 @@ def upgrade() -> None:
|
|||||||
sa.Column("description", sa.Text),
|
sa.Column("description", sa.Text),
|
||||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||||
sa.Column("c2_type", C2_TYPE, nullable=False),
|
sa.Column("c2_type", C2_TYPE, nullable=False),
|
||||||
sa.Column("created_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")),
|
sa.Column(
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -316,8 +347,12 @@ def upgrade() -> None:
|
|||||||
),
|
),
|
||||||
sa.Column("params_override_json", JSONB, nullable=False, server_default="{}"),
|
sa.Column("params_override_json", JSONB, nullable=False, server_default="{}"),
|
||||||
sa.Column("delay_after_ms", sa.Integer, nullable=False, server_default="0"),
|
sa.Column("delay_after_ms", sa.Integer, nullable=False, server_default="0"),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
sa.UniqueConstraint("scenario_id", "order_idx", name="uq_scenario_step_order_idx"),
|
sa.UniqueConstraint("scenario_id", "order_idx", name="uq_scenario_step_order_idx"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -334,10 +369,16 @@ def upgrade() -> None:
|
|||||||
sa.Column("status", RUN_STATUS, nullable=False, server_default="queued"),
|
sa.Column("status", RUN_STATUS, nullable=False, server_default="queued"),
|
||||||
sa.Column("started_at", sa.DateTime(timezone=True)),
|
sa.Column("started_at", sa.DateTime(timezone=True)),
|
||||||
sa.Column("ended_at", sa.DateTime(timezone=True)),
|
sa.Column("ended_at", sa.DateTime(timezone=True)),
|
||||||
sa.Column("started_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")),
|
sa.Column(
|
||||||
|
"started_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")
|
||||||
|
),
|
||||||
sa.Column("snapshot_json", JSONB, nullable=False, server_default="{}"),
|
sa.Column("snapshot_json", JSONB, nullable=False, server_default="{}"),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -363,8 +404,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("output_blob_ref", sa.String(512)),
|
sa.Column("output_blob_ref", sa.String(512)),
|
||||||
sa.Column("exit_code", sa.Integer),
|
sa.Column("exit_code", sa.Integer),
|
||||||
sa.Column("resolved_payload_text", sa.Text),
|
sa.Column("resolved_payload_text", sa.Text),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -382,9 +427,15 @@ def upgrade() -> None:
|
|||||||
sa.Column("ended_at", sa.DateTime(timezone=True)),
|
sa.Column("ended_at", sa.DateTime(timezone=True)),
|
||||||
sa.Column("resolved_command_text", sa.Text),
|
sa.Column("resolved_command_text", sa.Text),
|
||||||
sa.Column("output", sa.Text),
|
sa.Column("output", sa.Text),
|
||||||
sa.Column("executed_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")),
|
sa.Column(
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"executed_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ----------------------------------------------------- detection / evidence
|
# ----------------------------------------------------- detection / evidence
|
||||||
@@ -408,8 +459,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("latency_ms", sa.Integer),
|
sa.Column("latency_ms", sa.Integer),
|
||||||
sa.Column("comment", sa.Text),
|
sa.Column("comment", sa.Text),
|
||||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -432,8 +487,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("artifact_files_json", JSONB, nullable=False, server_default="[]"),
|
sa.Column("artifact_files_json", JSONB, nullable=False, server_default="[]"),
|
||||||
sa.Column("comment", sa.Text),
|
sa.Column("comment", sa.Text),
|
||||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ----------------------------------------------------------------- report
|
# ----------------------------------------------------------------- report
|
||||||
@@ -452,9 +511,15 @@ def upgrade() -> None:
|
|||||||
sa.Column("pdf_path", sa.String(512)),
|
sa.Column("pdf_path", sa.String(512)),
|
||||||
sa.Column("md_path", sa.String(512)),
|
sa.Column("md_path", sa.String(512)),
|
||||||
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("generated_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")),
|
sa.Column(
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"generated_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------- soc_session
|
# ------------------------------------------------------------- soc_session
|
||||||
@@ -479,8 +544,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("last_ip", sa.String(64)),
|
sa.Column("last_ip", sa.String(64)),
|
||||||
sa.Column("last_user_agent", sa.String(512)),
|
sa.Column("last_user_agent", sa.String(512)),
|
||||||
sa.Column("last_used_at", sa.DateTime(timezone=True)),
|
sa.Column("last_used_at", sa.DateTime(timezone=True)),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
sa.Column(
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------ audit_log
|
# ------------------------------------------------------------ audit_log
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ WORM enforcement without a migration; sprint 0 fills the columns at insert.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import JSON, DateTime, ForeignKey, String, Text, func
|
from sqlalchemy import JSON, DateTime, ForeignKey, String, Text, func
|
||||||
@@ -26,13 +27,11 @@ class AuditLog(UuidPkMixin, Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
)
|
)
|
||||||
actor_id: Mapped[UUID | None] = mapped_column(
|
actor_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id", ondelete="SET NULL"))
|
||||||
ForeignKey("user.id", ondelete="SET NULL")
|
|
||||||
)
|
|
||||||
action: Mapped[str] = mapped_column(String(80), nullable=False)
|
action: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
resource_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
resource_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
resource_id: Mapped[str | None] = mapped_column(String(128))
|
resource_id: Mapped[str | None] = mapped_column(String(128))
|
||||||
metadata_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
prev_hash: Mapped[str | None] = mapped_column(String(64))
|
prev_hash: Mapped[str | None] = mapped_column(String(64))
|
||||||
row_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
row_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -45,9 +45,7 @@ class Detection(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
)
|
)
|
||||||
latency_ms: Mapped[int | None] = mapped_column(Integer)
|
latency_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
comment: Mapped[str | None] = mapped_column(Text)
|
comment: Mapped[str | None] = mapped_column(Text)
|
||||||
recorded_at: Mapped[datetime] = mapped_column(
|
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
DateTime(timezone=True), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
run_step: Mapped[RunStep] = relationship()
|
run_step: Mapped[RunStep] = relationship()
|
||||||
soc_user: Mapped[User] = relationship()
|
soc_user: Mapped[User] = relationship()
|
||||||
@@ -69,14 +67,12 @@ class Evidence(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
artifacts_text: Mapped[str | None] = mapped_column(Text)
|
artifacts_text: Mapped[str | None] = mapped_column(Text)
|
||||||
artifact_files_json: Mapped[list[dict]] = mapped_column(
|
artifact_files_json: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||||
JSON, default=list, nullable=False
|
JSON, default=list, nullable=False
|
||||||
)
|
)
|
||||||
# Each entry: {"name": str, "ref": str, "sha256": str, "size_bytes": int}
|
# Each entry: {"name": str, "ref": str, "sha256": str, "size_bytes": int}
|
||||||
comment: Mapped[str | None] = mapped_column(Text)
|
comment: Mapped[str | None] = mapped_column(Text)
|
||||||
recorded_at: Mapped[datetime] = mapped_column(
|
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
DateTime(timezone=True), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
run_step: Mapped[RunStep] = relationship()
|
run_step: Mapped[RunStep] = relationship()
|
||||||
rt_user: Mapped[User] = relationship()
|
rt_user: Mapped[User] = relationship()
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ class Engagement(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
created_by_id: Mapped[UUID | None] = mapped_column(
|
created_by_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id", ondelete="SET NULL"))
|
||||||
ForeignKey("user.id", ondelete="SET NULL")
|
|
||||||
)
|
|
||||||
|
|
||||||
hosts: Mapped[list[Host]] = relationship(
|
hosts: Mapped[list[Host]] = relationship(
|
||||||
back_populates="engagement",
|
back_populates="engagement",
|
||||||
|
|||||||
@@ -64,14 +64,10 @@ class GroupPermission(Base):
|
|||||||
class UserGroup(Base):
|
class UserGroup(Base):
|
||||||
__tablename__ = "user_group"
|
__tablename__ = "user_group"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
PrimaryKeyConstraint(
|
PrimaryKeyConstraint("user_id", "group_id", "engagement_id", name="pk_user_group"),
|
||||||
"user_id", "group_id", "engagement_id", name="pk_user_group"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
user_id: Mapped[UUID] = mapped_column(
|
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
|
||||||
ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
group_id: Mapped[UUID] = mapped_column(
|
group_id: Mapped[UUID] = mapped_column(
|
||||||
ForeignKey("group.id", ondelete="CASCADE"), nullable=False
|
ForeignKey("group.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -30,7 +30,7 @@ class Report(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
)
|
)
|
||||||
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
|
||||||
content_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
content_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
# SHA-256 of canonical JSON. Identical hash referenced in PDF footer, JSON
|
# SHA-256 of canonical JSON. Identical hash referenced in PDF footer, JSON
|
||||||
# export and Markdown export (spec H19 / F9 / F14).
|
# export and Markdown export (spec H19 / F9 / F14).
|
||||||
@@ -38,11 +38,7 @@ class Report(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
pdf_path: Mapped[str | None] = mapped_column(String(512))
|
pdf_path: Mapped[str | None] = mapped_column(String(512))
|
||||||
md_path: Mapped[str | None] = mapped_column(String(512))
|
md_path: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
|
||||||
generated_at: Mapped[datetime] = mapped_column(
|
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
DateTime(timezone=True), nullable=False
|
generated_by_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id", ondelete="SET NULL"))
|
||||||
)
|
|
||||||
generated_by_id: Mapped[UUID | None] = mapped_column(
|
|
||||||
ForeignKey("user.id", ondelete="SET NULL")
|
|
||||||
)
|
|
||||||
|
|
||||||
engagement: Mapped[Engagement] = relationship()
|
engagement: Mapped[Engagement] = relationship()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -39,11 +39,9 @@ class Run(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
started_by_id: Mapped[UUID | None] = mapped_column(
|
started_by_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id", ondelete="SET NULL"))
|
||||||
ForeignKey("user.id", ondelete="SET NULL")
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
snapshot_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
# Full self-contained snapshot of scenario + steps + resolved TTPs.
|
# Full self-contained snapshot of scenario + steps + resolved TTPs.
|
||||||
# Source of truth for replay (spec H32).
|
# Source of truth for replay (spec H32).
|
||||||
|
|
||||||
@@ -111,8 +109,6 @@ class RunStepCleanup(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
resolved_command_text: Mapped[str | None] = mapped_column(Text)
|
resolved_command_text: Mapped[str | None] = mapped_column(Text)
|
||||||
output: Mapped[str | None] = mapped_column(Text)
|
output: Mapped[str | None] = mapped_column(Text)
|
||||||
executed_by_id: Mapped[UUID | None] = mapped_column(
|
executed_by_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id", ondelete="SET NULL"))
|
||||||
ForeignKey("user.id", ondelete="SET NULL")
|
|
||||||
)
|
|
||||||
|
|
||||||
run_step: Mapped[RunStep] = relationship(back_populates="cleanup")
|
run_step: Mapped[RunStep] = relationship(back_populates="cleanup")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ run start that every referenced host matches.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -45,9 +45,7 @@ class Scenario(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
Enum(C2Type, name="c2_type", create_type=False),
|
Enum(C2Type, name="c2_type", create_type=False),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
created_by_id: Mapped[UUID | None] = mapped_column(
|
created_by_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id", ondelete="SET NULL"))
|
||||||
ForeignKey("user.id", ondelete="SET NULL")
|
|
||||||
)
|
|
||||||
|
|
||||||
engagement: Mapped[Engagement] = relationship(back_populates="scenarios")
|
engagement: Mapped[Engagement] = relationship(back_populates="scenarios")
|
||||||
steps: Mapped[list[ScenarioStep]] = relationship(
|
steps: Mapped[list[ScenarioStep]] = relationship(
|
||||||
@@ -78,7 +76,7 @@ class ScenarioStep(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
ForeignKey("host.id", ondelete="RESTRICT"),
|
ForeignKey("host.id", ondelete="RESTRICT"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
params_override_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
params_override_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
delay_after_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
delay_after_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
scenario: Mapped[Scenario] = relationship(back_populates="steps")
|
scenario: Mapped[Scenario] = relationship(back_populates="steps")
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ class SocSession(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
# bcrypt hash. Plain token returned once at creation.
|
# bcrypt hash. Plain token returned once at creation.
|
||||||
|
|
||||||
expires_at: Mapped[datetime] = mapped_column(
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
DateTime(timezone=True), nullable=False
|
|
||||||
)
|
|
||||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
last_ip: Mapped[str | None] = mapped_column(String(64))
|
last_ip: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ counter (§8) — bumped on edit, never referenced for replay.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -39,7 +40,7 @@ class Ttp(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
payload_template: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
payload_template: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
params_schema_json: Mapped[dict | None] = mapped_column(JSON)
|
params_schema_json: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||||
|
|
||||||
opsec_notes: Mapped[str | None] = mapped_column(Text)
|
opsec_notes: Mapped[str | None] = mapped_column(Text)
|
||||||
cleanup_command: Mapped[str | None] = mapped_column(Text)
|
cleanup_command: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -57,6 +58,4 @@ class Ttp(UuidPkMixin, TimestampsMixin, Base):
|
|||||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
# Promoted to the library (lead RT only — F11).
|
# Promoted to the library (lead RT only — F11).
|
||||||
|
|
||||||
created_by_id: Mapped[UUID | None] = mapped_column(
|
created_by_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id", ondelete="SET NULL"))
|
||||||
ForeignKey("user.id", ondelete="SET NULL")
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pythonjsonlogger.jsonlogger import JsonFormatter
|
from pythonjsonlogger.jsonlogger import JsonFormatter # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: str = "INFO", *, as_json: bool = True) -> None:
|
def configure_logging(level: str = "INFO", *, as_json: bool = True) -> None:
|
||||||
@@ -16,9 +16,7 @@ def configure_logging(level: str = "INFO", *, as_json: bool = True) -> None:
|
|||||||
"%(asctime)s %(levelname)s %(name)s %(message)s"
|
"%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter("%(asctime)s %(levelname)-8s %(name)s: %(message)s")
|
||||||
"%(asctime)s %(levelname)-8s %(name)s: %(message)s"
|
|
||||||
)
|
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
|
|||||||
@@ -4,16 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, ParamSpec, TypeVar
|
from typing import ParamSpec, TypeVar
|
||||||
|
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
from mimic.rbac.matrix import Permission
|
from mimic.rbac.matrix import Permission
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
P = ParamSpec("P")
|
P = ParamSpec("P")
|
||||||
R = TypeVar("R")
|
R = TypeVar("R")
|
||||||
|
|
||||||
@@ -32,7 +29,7 @@ def require_perm(perm: Permission) -> Callable[[Callable[P, R]], Callable[P, R]]
|
|||||||
abort(403)
|
abort(403)
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
return _wrapped # type: ignore[return-value]
|
return _wrapped
|
||||||
|
|
||||||
return _decorate
|
return _decorate
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -11,7 +12,7 @@ class ScenarioStepBase(BaseModel):
|
|||||||
ttp_id: UUID
|
ttp_id: UUID
|
||||||
host_id: UUID
|
host_id: UUID
|
||||||
order_idx: int = Field(ge=0)
|
order_idx: int = Field(ge=0)
|
||||||
params_override_json: dict = Field(default_factory=dict)
|
params_override_json: dict[str, Any] = Field(default_factory=dict)
|
||||||
delay_after_ms: int = Field(default=0, ge=0)
|
delay_after_ms: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -14,7 +15,7 @@ class TtpBase(BaseModel):
|
|||||||
mitre_subtechnique: str | None = Field(default=None, max_length=16)
|
mitre_subtechnique: str | None = Field(default=None, max_length=16)
|
||||||
payload_type: PayloadType
|
payload_type: PayloadType
|
||||||
payload_template: str = ""
|
payload_template: str = ""
|
||||||
params_schema_json: dict | None = None
|
params_schema_json: dict[str, Any] | None = None
|
||||||
opsec_notes: str | None = None
|
opsec_notes: str | None = None
|
||||||
cleanup_command: str | None = None
|
cleanup_command: str | None = None
|
||||||
is_stealth_variant: bool = False
|
is_stealth_variant: bool = False
|
||||||
@@ -33,7 +34,7 @@ class TtpUpdate(BaseModel):
|
|||||||
mitre_subtechnique: str | None = Field(default=None, max_length=16)
|
mitre_subtechnique: str | None = Field(default=None, max_length=16)
|
||||||
payload_type: PayloadType | None = None
|
payload_type: PayloadType | None = None
|
||||||
payload_template: str | None = None
|
payload_template: str | None = None
|
||||||
params_schema_json: dict | None = None
|
params_schema_json: dict[str, Any] | None = None
|
||||||
opsec_notes: str | None = None
|
opsec_notes: str | None = None
|
||||||
cleanup_command: str | None = None
|
cleanup_command: str | None = None
|
||||||
is_stealth_variant: bool | None = None
|
is_stealth_variant: bool | None = None
|
||||||
@@ -46,4 +47,4 @@ class TtpRead(TtpBase):
|
|||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
is_published: bool
|
is_published: bool
|
||||||
current_version: int
|
version: int
|
||||||
|
|||||||
@@ -13,17 +13,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from types import ModuleType
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
from jinja2 import TemplateError
|
from jinja2 import TemplateError
|
||||||
|
|
||||||
try: # pragma: no cover - presence depends on environment
|
try: # pragma: no cover - presence depends on environment
|
||||||
import re2 as _re2 # type: ignore[import-not-found]
|
import re2 as _imported_re2
|
||||||
|
|
||||||
_HAS_RE2 = True
|
_re2: ModuleType | None = _imported_re2
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
_re2 = None
|
_re2 = None
|
||||||
_HAS_RE2 = False
|
|
||||||
|
|
||||||
|
|
||||||
_FALLBACK_MAX_INPUT = 1 * 1024 * 1024 # 1 MB safety cap when re2 missing
|
_FALLBACK_MAX_INPUT = 1 * 1024 * 1024 # 1 MB safety cap when re2 missing
|
||||||
@@ -41,8 +41,8 @@ def regex_extract(
|
|||||||
raise TemplateError(f"regex_extract: cannot match against None for /{pattern}/")
|
raise TemplateError(f"regex_extract: cannot match against None for /{pattern}/")
|
||||||
haystack = text if isinstance(text, str) else str(text)
|
haystack = text if isinstance(text, str) else str(text)
|
||||||
|
|
||||||
if _HAS_RE2:
|
if _re2 is not None:
|
||||||
compiled = _re2.compile(pattern)
|
compiled = cast(Any, _re2).compile(pattern)
|
||||||
match = compiled.search(haystack)
|
match = compiled.search(haystack)
|
||||||
else:
|
else:
|
||||||
if len(haystack) > _FALLBACK_MAX_INPUT:
|
if len(haystack) > _FALLBACK_MAX_INPUT:
|
||||||
@@ -56,28 +56,22 @@ def regex_extract(
|
|||||||
try:
|
try:
|
||||||
captured = match.group(name)
|
captured = match.group(name)
|
||||||
except IndexError as exc:
|
except IndexError as exc:
|
||||||
raise TemplateError(
|
raise TemplateError(f"regex_extract: named group {name!r} not in /{pattern}/") from exc
|
||||||
f"regex_extract: named group {name!r} not in /{pattern}/"
|
|
||||||
) from exc
|
|
||||||
if captured is None:
|
if captured is None:
|
||||||
raise TemplateError(
|
raise TemplateError(
|
||||||
f"regex_extract: named group {name!r} captured nothing in /{pattern}/"
|
f"regex_extract: named group {name!r} captured nothing in /{pattern}/"
|
||||||
)
|
)
|
||||||
return captured
|
return str(captured)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
captured = match.group(group)
|
captured = match.group(group)
|
||||||
except IndexError:
|
except IndexError:
|
||||||
if group == 1:
|
if group == 1:
|
||||||
return match.group(0)
|
return str(match.group(0))
|
||||||
raise TemplateError(
|
raise TemplateError(f"regex_extract: group {group} out of range for /{pattern}/") from None
|
||||||
f"regex_extract: group {group} out of range for /{pattern}/"
|
|
||||||
) from None
|
|
||||||
|
|
||||||
if captured is None:
|
if captured is None:
|
||||||
if group == 1:
|
if group == 1:
|
||||||
return match.group(0)
|
return str(match.group(0))
|
||||||
raise TemplateError(
|
raise TemplateError(f"regex_extract: group {group} captured nothing in /{pattern}/")
|
||||||
f"regex_extract: group {group} captured nothing in /{pattern}/"
|
return str(captured)
|
||||||
)
|
|
||||||
return captured
|
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ def postgres_dsn() -> Iterator[str]:
|
|||||||
if PostgresContainer is None:
|
if PostgresContainer is None:
|
||||||
pytest.skip("testcontainers not installed")
|
pytest.skip("testcontainers not installed")
|
||||||
with PostgresContainer("postgres:16-alpine") as pg:
|
with PostgresContainer("postgres:16-alpine") as pg:
|
||||||
url = pg.get_connection_url().replace(
|
url = pg.get_connection_url().replace("postgresql+psycopg2", "postgresql+psycopg")
|
||||||
"postgresql+psycopg2", "postgresql+psycopg"
|
|
||||||
)
|
|
||||||
yield url
|
yield url
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user