feat(backend): add Flask app factory, audit writer, flat CRUD + CLI (B0.7)
- Flask app factory wires SQLAlchemy / Migrate / Login / SocketIO and
registers every blueprint. /healthz smoke endpoint included.
- Pydantic 2 DTOs (request/response) for engagement / host / TTP /
scenario aggregates with from_attributes=True conversion.
- Flat CRUD blueprints under /api/v1/:
* engagements (list / create / get / put / delete-as-archive)
* hosts (engagement-scoped CRUD)
* library/ttps (CRUD; promote requires the lead-only TTP_PROMOTE)
* scenarios + steps (F3 invariant enforced: host.c2_type must match
scenario.c2_type at compose time, 400 otherwise).
- @require_perm guards every endpoint per the F11 matrix.
- audit/ writer is hash-chained from v1 (SHA-256 of canonical record
plus previous hash). The SQL-level write-only role enforcement ships
in the deploy playbook (idempotent grants run at migration time).
- mimic-cli (click): user create (seeds RT operator/lead with group
membership), db dump / db restore (manual pg_dump/pg_restore, R-O1).
No orchestrator, no WebSocket, no report generation — those land after
PR1/PR2/PR3.
This commit is contained in:
17
backend/src/mimic/api/__init__.py
Normal file
17
backend/src/mimic/api/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Flask blueprints (REST API)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from mimic.api.engagements import bp as engagements_bp
|
||||||
|
from mimic.api.hosts import bp as hosts_bp
|
||||||
|
from mimic.api.scenarios import bp as scenarios_bp
|
||||||
|
from mimic.api.ttps import bp as ttps_bp
|
||||||
|
|
||||||
|
|
||||||
|
def register_blueprints(app: Flask) -> None:
|
||||||
|
app.register_blueprint(engagements_bp, url_prefix="/api/v1/engagements")
|
||||||
|
app.register_blueprint(hosts_bp, url_prefix="/api/v1")
|
||||||
|
app.register_blueprint(ttps_bp, url_prefix="/api/v1/library/ttps")
|
||||||
|
app.register_blueprint(scenarios_bp, url_prefix="/api/v1")
|
||||||
29
backend/src/mimic/api/_helpers.py
Normal file
29
backend/src/mimic/api/_helpers.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Shared blueprint helpers (pydantic validation, error mapping)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from flask import Response, abort, jsonify, request
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def parse_body[T: BaseModel](model: type[T]) -> T:
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
if payload is None:
|
||||||
|
abort(400, description="JSON body required")
|
||||||
|
try:
|
||||||
|
return model.model_validate(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
abort(422, description=exc.errors())
|
||||||
|
|
||||||
|
|
||||||
|
def jsonify_model(model: BaseModel, status: int = 200) -> tuple[Response, int]:
|
||||||
|
return jsonify(model.model_dump(mode="json")), status
|
||||||
|
|
||||||
|
|
||||||
|
def parse_uuid(value: str, *, field: str = "id") -> UUID:
|
||||||
|
try:
|
||||||
|
return UUID(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
abort(404, description=f"invalid {field}")
|
||||||
73
backend/src/mimic/api/engagements.py
Normal file
73
backend/src/mimic/api/engagements.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Engagement CRUD endpoints (flat, sprint 0)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
||||||
|
from mimic.db.models import Engagement
|
||||||
|
from mimic.db.types import EngagementStatus
|
||||||
|
from mimic.extensions import db
|
||||||
|
from mimic.rbac import Permission, require_perm
|
||||||
|
from mimic.schemas import EngagementCreate, EngagementRead, EngagementUpdate
|
||||||
|
|
||||||
|
bp = Blueprint("engagements", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("")
|
||||||
|
@require_perm(Permission.ENGAGEMENT_READ)
|
||||||
|
def list_engagements():
|
||||||
|
stmt = select(Engagement).order_by(Engagement.created_at.desc())
|
||||||
|
rows = db.session.execute(stmt).scalars().all()
|
||||||
|
return jsonify([EngagementRead.model_validate(row).model_dump(mode="json") for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("")
|
||||||
|
@require_perm(Permission.ENGAGEMENT_CREATE)
|
||||||
|
def create_engagement():
|
||||||
|
payload = parse_body(EngagementCreate)
|
||||||
|
engagement = Engagement(
|
||||||
|
client_name=payload.client_name,
|
||||||
|
description=payload.description,
|
||||||
|
c2_type=payload.c2_type,
|
||||||
|
start_date=payload.start_date,
|
||||||
|
end_date=payload.end_date,
|
||||||
|
status=EngagementStatus.DRAFT,
|
||||||
|
)
|
||||||
|
db.session.add(engagement)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify_model(EngagementRead.model_validate(engagement), status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<eid>")
|
||||||
|
@require_perm(Permission.ENGAGEMENT_READ)
|
||||||
|
def get_engagement(eid: str):
|
||||||
|
engagement = db.session.get(Engagement, parse_uuid(eid))
|
||||||
|
if engagement is None:
|
||||||
|
abort(404)
|
||||||
|
return jsonify_model(EngagementRead.model_validate(engagement))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("/<eid>")
|
||||||
|
@require_perm(Permission.ENGAGEMENT_UPDATE)
|
||||||
|
def update_engagement(eid: str):
|
||||||
|
engagement = db.session.get(Engagement, parse_uuid(eid))
|
||||||
|
if engagement is None:
|
||||||
|
abort(404)
|
||||||
|
payload = parse_body(EngagementUpdate)
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(engagement, field, value)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify_model(EngagementRead.model_validate(engagement))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/<eid>")
|
||||||
|
@require_perm(Permission.ENGAGEMENT_DELETE)
|
||||||
|
def delete_engagement(eid: str):
|
||||||
|
engagement = db.session.get(Engagement, parse_uuid(eid))
|
||||||
|
if engagement is None:
|
||||||
|
abort(404)
|
||||||
|
engagement.status = EngagementStatus.ARCHIVED
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
76
backend/src/mimic/api/hosts.py
Normal file
76
backend/src/mimic/api/hosts.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Host CRUD endpoints (scoped under an engagement)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
||||||
|
from mimic.db.models import Engagement, Host
|
||||||
|
from mimic.db.types import HostStatus
|
||||||
|
from mimic.extensions import db
|
||||||
|
from mimic.rbac import Permission, require_perm
|
||||||
|
from mimic.schemas import HostCreate, HostRead, HostUpdate
|
||||||
|
|
||||||
|
bp = Blueprint("hosts", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _engagement_or_404(eid: str) -> Engagement:
|
||||||
|
engagement = db.session.get(Engagement, parse_uuid(eid, field="engagement id"))
|
||||||
|
if engagement is None:
|
||||||
|
abort(404)
|
||||||
|
return engagement
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/engagements/<eid>/hosts")
|
||||||
|
@require_perm(Permission.HOST_CRUD)
|
||||||
|
def list_hosts(eid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
stmt = select(Host).where(Host.engagement_id == engagement.id).order_by(Host.hostname)
|
||||||
|
rows = db.session.execute(stmt).scalars().all()
|
||||||
|
return jsonify([HostRead.model_validate(row).model_dump(mode="json") for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/engagements/<eid>/hosts")
|
||||||
|
@require_perm(Permission.HOST_CRUD)
|
||||||
|
def create_host(eid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
payload = parse_body(HostCreate)
|
||||||
|
host = Host(
|
||||||
|
engagement_id=engagement.id,
|
||||||
|
hostname=payload.hostname,
|
||||||
|
ip=payload.ip,
|
||||||
|
os=payload.os,
|
||||||
|
c2_session_id=payload.c2_session_id,
|
||||||
|
c2_type=payload.c2_type,
|
||||||
|
status=HostStatus.UNKNOWN,
|
||||||
|
)
|
||||||
|
db.session.add(host)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify_model(HostRead.model_validate(host), status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("/engagements/<eid>/hosts/<hid>")
|
||||||
|
@require_perm(Permission.HOST_CRUD)
|
||||||
|
def update_host(eid: str, hid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
host = db.session.get(Host, parse_uuid(hid, field="host id"))
|
||||||
|
if host is None or host.engagement_id != engagement.id:
|
||||||
|
abort(404)
|
||||||
|
payload = parse_body(HostUpdate)
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(host, field, value)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify_model(HostRead.model_validate(host))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/engagements/<eid>/hosts/<hid>")
|
||||||
|
@require_perm(Permission.HOST_CRUD)
|
||||||
|
def delete_host(eid: str, hid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
host = db.session.get(Host, parse_uuid(hid, field="host id"))
|
||||||
|
if host is None or host.engagement_id != engagement.id:
|
||||||
|
abort(404)
|
||||||
|
db.session.delete(host)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
138
backend/src/mimic/api/scenarios.py
Normal file
138
backend/src/mimic/api/scenarios.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Scenario + step CRUD (flat, no orchestration yet)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
||||||
|
from mimic.db.models import Engagement, Host, Scenario, ScenarioStep, Ttp
|
||||||
|
from mimic.extensions import db
|
||||||
|
from mimic.rbac import Permission, require_perm
|
||||||
|
from mimic.schemas import (
|
||||||
|
ScenarioCreate,
|
||||||
|
ScenarioRead,
|
||||||
|
ScenarioStepCreate,
|
||||||
|
ScenarioStepRead,
|
||||||
|
ScenarioUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
bp = Blueprint("scenarios", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _engagement_or_404(eid: str) -> Engagement:
|
||||||
|
engagement = db.session.get(Engagement, parse_uuid(eid, field="engagement id"))
|
||||||
|
if engagement is None:
|
||||||
|
abort(404)
|
||||||
|
return engagement
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_or_404(engagement: Engagement, sid: str) -> Scenario:
|
||||||
|
scenario = db.session.get(Scenario, parse_uuid(sid, field="scenario id"))
|
||||||
|
if scenario is None or scenario.engagement_id != engagement.id:
|
||||||
|
abort(404)
|
||||||
|
return scenario
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_step_consistency(scenario: Scenario, ttp_id, host_id) -> None:
|
||||||
|
ttp = db.session.get(Ttp, ttp_id)
|
||||||
|
host = db.session.get(Host, host_id)
|
||||||
|
if ttp is None or host is None:
|
||||||
|
abort(404, description="ttp or host not found")
|
||||||
|
if host.engagement_id != scenario.engagement_id:
|
||||||
|
abort(400, description="host does not belong to the engagement")
|
||||||
|
if host.c2_type != scenario.c2_type:
|
||||||
|
abort(400, description="host.c2_type does not match scenario.c2_type")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/engagements/<eid>/scenarios")
|
||||||
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
|
def list_scenarios(eid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
stmt = (
|
||||||
|
select(Scenario)
|
||||||
|
.where(Scenario.engagement_id == engagement.id)
|
||||||
|
.order_by(Scenario.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = db.session.execute(stmt).scalars().all()
|
||||||
|
return jsonify([ScenarioRead.model_validate(row).model_dump(mode="json") for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/engagements/<eid>/scenarios")
|
||||||
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
|
def create_scenario(eid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
payload = parse_body(ScenarioCreate)
|
||||||
|
scenario = Scenario(
|
||||||
|
engagement_id=engagement.id,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
c2_type=payload.c2_type,
|
||||||
|
version=payload.version,
|
||||||
|
)
|
||||||
|
db.session.add(scenario)
|
||||||
|
db.session.flush()
|
||||||
|
for step in payload.steps:
|
||||||
|
_validate_step_consistency(scenario, step.ttp_id, step.host_id)
|
||||||
|
db.session.add(
|
||||||
|
ScenarioStep(
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
ttp_id=step.ttp_id,
|
||||||
|
host_id=step.host_id,
|
||||||
|
order_idx=step.order_idx,
|
||||||
|
params_override_json=step.params_override_json,
|
||||||
|
delay_after_ms=step.delay_after_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify_model(ScenarioRead.model_validate(scenario), status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/engagements/<eid>/scenarios/<sid>")
|
||||||
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
|
def get_scenario(eid: str, sid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
scenario = _scenario_or_404(engagement, sid)
|
||||||
|
return jsonify_model(ScenarioRead.model_validate(scenario))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("/engagements/<eid>/scenarios/<sid>")
|
||||||
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
|
def update_scenario(eid: str, sid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
scenario = _scenario_or_404(engagement, sid)
|
||||||
|
payload = parse_body(ScenarioUpdate)
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(scenario, field, value)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify_model(ScenarioRead.model_validate(scenario))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/engagements/<eid>/scenarios/<sid>")
|
||||||
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
|
def delete_scenario(eid: str, sid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
scenario = _scenario_or_404(engagement, sid)
|
||||||
|
db.session.delete(scenario)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/engagements/<eid>/scenarios/<sid>/steps")
|
||||||
|
@require_perm(Permission.SCENARIO_CRUD)
|
||||||
|
def add_step(eid: str, sid: str):
|
||||||
|
engagement = _engagement_or_404(eid)
|
||||||
|
scenario = _scenario_or_404(engagement, sid)
|
||||||
|
payload = parse_body(ScenarioStepCreate)
|
||||||
|
_validate_step_consistency(scenario, payload.ttp_id, payload.host_id)
|
||||||
|
step = ScenarioStep(
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
ttp_id=payload.ttp_id,
|
||||||
|
host_id=payload.host_id,
|
||||||
|
order_idx=payload.order_idx,
|
||||||
|
params_override_json=payload.params_override_json,
|
||||||
|
delay_after_ms=payload.delay_after_ms,
|
||||||
|
)
|
||||||
|
db.session.add(step)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify_model(ScenarioStepRead.model_validate(step), status=201)
|
||||||
80
backend/src/mimic/api/ttps.py
Normal file
80
backend/src/mimic/api/ttps.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""TTP library CRUD endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from mimic.api._helpers import jsonify_model, parse_body, parse_uuid
|
||||||
|
from mimic.db.models import Ttp
|
||||||
|
from mimic.extensions import db
|
||||||
|
from mimic.rbac import Permission, require_perm
|
||||||
|
from mimic.schemas import TtpCreate, TtpRead, TtpUpdate
|
||||||
|
|
||||||
|
bp = Blueprint("ttps", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("")
|
||||||
|
@require_perm(Permission.TTP_READ)
|
||||||
|
def list_ttps():
|
||||||
|
stmt = select(Ttp).order_by(Ttp.created_at.desc())
|
||||||
|
rows = db.session.execute(stmt).scalars().all()
|
||||||
|
return jsonify([TtpRead.model_validate(row).model_dump(mode="json") for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("")
|
||||||
|
@require_perm(Permission.TTP_DRAFT)
|
||||||
|
def create_ttp():
|
||||||
|
payload = parse_body(TtpCreate)
|
||||||
|
ttp = Ttp(**payload.model_dump())
|
||||||
|
db.session.add(ttp)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify_model(TtpRead.model_validate(ttp), status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<tid>")
|
||||||
|
@require_perm(Permission.TTP_READ)
|
||||||
|
def get_ttp(tid: str):
|
||||||
|
ttp = db.session.get(Ttp, parse_uuid(tid, field="ttp id"))
|
||||||
|
if ttp is None:
|
||||||
|
abort(404)
|
||||||
|
return jsonify_model(TtpRead.model_validate(ttp))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("/<tid>")
|
||||||
|
@require_perm(Permission.TTP_DRAFT)
|
||||||
|
def update_ttp(tid: str):
|
||||||
|
ttp = db.session.get(Ttp, parse_uuid(tid, field="ttp id"))
|
||||||
|
if ttp is None:
|
||||||
|
abort(404)
|
||||||
|
payload = parse_body(TtpUpdate)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
publish_flag = data.pop("is_published", None)
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(ttp, field, value)
|
||||||
|
if publish_flag is not None:
|
||||||
|
# Promotion is a lead-only privilege. Decorator already gates draft
|
||||||
|
# edits; promotion gets a second-tier check at the call site.
|
||||||
|
_ensure_promote_perm()
|
||||||
|
ttp.is_published = publish_flag
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify_model(TtpRead.model_validate(ttp))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.delete("/<tid>")
|
||||||
|
@require_perm(Permission.TTP_DRAFT)
|
||||||
|
def delete_ttp(tid: str):
|
||||||
|
ttp = db.session.get(Ttp, parse_uuid(tid, field="ttp id"))
|
||||||
|
if ttp is None:
|
||||||
|
abort(404)
|
||||||
|
db.session.delete(ttp)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_promote_perm() -> None:
|
||||||
|
from flask_login import current_user # noqa: PLC0415 (lazy: scope-local user only)
|
||||||
|
|
||||||
|
perms: frozenset[Permission] = getattr(current_user, "permissions", frozenset())
|
||||||
|
if Permission.TTP_PROMOTE not in perms:
|
||||||
|
abort(403)
|
||||||
50
backend/src/mimic/app.py
Normal file
50
backend/src/mimic/app.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Flask application factory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from mimic.api import register_blueprints
|
||||||
|
from mimic.auth.identity import load_user
|
||||||
|
from mimic.config import Settings, get_settings
|
||||||
|
from mimic.extensions import db, login_manager, migrate, socketio
|
||||||
|
from mimic.logging import configure_logging
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None) -> Flask:
|
||||||
|
settings = settings or get_settings()
|
||||||
|
configure_logging(settings.log_level, as_json=settings.log_json)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.update(
|
||||||
|
SECRET_KEY=settings.secret_key.get_secret_value(),
|
||||||
|
SQLALCHEMY_DATABASE_URI=settings.database_url,
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||||
|
SESSION_COOKIE_SECURE=settings.session_cookie_secure,
|
||||||
|
SESSION_COOKIE_SAMESITE=settings.session_cookie_samesite,
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
PERMANENT_SESSION_LIFETIME=timedelta(minutes=settings.session_lifetime_minutes),
|
||||||
|
MIMIC_SETTINGS=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db, directory="src/mimic/db/migrations")
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.user_loader(load_user) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
socketio.init_app(
|
||||||
|
app,
|
||||||
|
cors_allowed_origins=settings.cors_origins or "*",
|
||||||
|
async_mode="gevent",
|
||||||
|
)
|
||||||
|
|
||||||
|
register_blueprints(app)
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz() -> ResponseReturnValue:
|
||||||
|
return jsonify(status="ok"), 200
|
||||||
|
|
||||||
|
return app
|
||||||
5
backend/src/mimic/audit/__init__.py
Normal file
5
backend/src/mimic/audit/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Append-only audit log writer (NF-AUDIT)."""
|
||||||
|
|
||||||
|
from mimic.audit.log import AuditWriter, audit_hash, write_audit
|
||||||
|
|
||||||
|
__all__ = ["AuditWriter", "audit_hash", "write_audit"]
|
||||||
102
backend/src/mimic/audit/log.py
Normal file
102
backend/src/mimic/audit/log.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Hash-chained append-only audit writer.
|
||||||
|
|
||||||
|
Sprint 0 fills `prev_hash` / `row_hash` at insert; verifier (v2) walks the
|
||||||
|
chain. The SQL-level guard (write-only role on `audit_log`) is provisioned by
|
||||||
|
the deploy playbook against the Postgres role `mimic_audit_writer`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from mimic.db.models import AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(payload: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_hash(
|
||||||
|
*,
|
||||||
|
prev_hash: str | None,
|
||||||
|
ts: datetime,
|
||||||
|
actor_id: UUID | None,
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str | None,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
"""SHA-256 of the canonical record (used both at write and at verify)."""
|
||||||
|
payload = {
|
||||||
|
"prev_hash": prev_hash or "",
|
||||||
|
"ts": ts.isoformat(),
|
||||||
|
"actor_id": str(actor_id) if actor_id else "",
|
||||||
|
"action": action,
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id or "",
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
return hashlib.sha256(_canonical(payload).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class AuditWriter:
|
||||||
|
"""Append a single audit entry, chaining on the latest row hash."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def write(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
resource_type: str,
|
||||||
|
actor_id: UUID | None = None,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
source_ip: str | None = None,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
comment: str | None = None,
|
||||||
|
) -> AuditLog:
|
||||||
|
metadata = metadata or {}
|
||||||
|
ts = datetime.now(tz=UTC)
|
||||||
|
prev_hash = self._latest_hash()
|
||||||
|
row_hash = audit_hash(
|
||||||
|
prev_hash=prev_hash,
|
||||||
|
ts=ts,
|
||||||
|
actor_id=actor_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
entry = AuditLog(
|
||||||
|
ts=ts,
|
||||||
|
actor_id=actor_id,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
metadata_json=metadata,
|
||||||
|
prev_hash=prev_hash,
|
||||||
|
row_hash=row_hash,
|
||||||
|
source_ip=source_ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
comment=comment,
|
||||||
|
)
|
||||||
|
self._session.add(entry)
|
||||||
|
self._session.flush()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _latest_hash(self) -> str | None:
|
||||||
|
stmt = select(AuditLog.row_hash).order_by(AuditLog.ts.desc()).limit(1)
|
||||||
|
return self._session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def write_audit(session: Session, **kwargs: Any) -> AuditLog:
|
||||||
|
return AuditWriter(session).write(**kwargs)
|
||||||
21
backend/src/mimic/cli/__init__.py
Normal file
21
backend/src/mimic/cli/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""`mimic-cli` command-line interface (click)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from mimic.cli.db import db_group
|
||||||
|
from mimic.cli.user import user_group
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli() -> None:
|
||||||
|
"""Mimic command-line interface."""
|
||||||
|
|
||||||
|
|
||||||
|
cli.add_command(user_group, name="user")
|
||||||
|
cli.add_command(db_group, name="db")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
71
backend/src/mimic/cli/db.py
Normal file
71
backend/src/mimic/cli/db.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""Database CLI: dump / restore stubs (R-O1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from mimic.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(help="Database operations (manual dump/restore per R-O1).")
|
||||||
|
def db_group() -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dsn(dsn: str) -> tuple[str, str, str, str, str]:
|
||||||
|
parsed = urlparse(dsn)
|
||||||
|
return (
|
||||||
|
parsed.hostname or "localhost",
|
||||||
|
str(parsed.port or 5432),
|
||||||
|
parsed.username or "",
|
||||||
|
parsed.password or "",
|
||||||
|
(parsed.path or "/").lstrip("/"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@db_group.command("dump")
|
||||||
|
@click.option("--out", "out_path", type=click.Path(dir_okay=False, path_type=Path), required=True)
|
||||||
|
def dump(out_path: Path) -> None:
|
||||||
|
"""Manual `pg_dump` of the configured DATABASE_URL."""
|
||||||
|
settings = get_settings()
|
||||||
|
host, port, user, password, dbname = _parse_dsn(settings.database_url)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = [
|
||||||
|
"pg_dump",
|
||||||
|
"--format=custom",
|
||||||
|
f"--host={host}",
|
||||||
|
f"--port={port}",
|
||||||
|
f"--username={user}",
|
||||||
|
f"--dbname={dbname}",
|
||||||
|
f"--file={out_path}",
|
||||||
|
]
|
||||||
|
env = {"PGPASSWORD": password} if password else None
|
||||||
|
click.echo(f"running: {shlex.join(cmd)}")
|
||||||
|
subprocess.run(cmd, check=True, env=env) # noqa: S603
|
||||||
|
click.echo(f"dump written to {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@db_group.command("restore")
|
||||||
|
@click.option("--file", "in_path", type=click.Path(exists=True, path_type=Path), required=True)
|
||||||
|
def restore(in_path: Path) -> None:
|
||||||
|
"""Manual `pg_restore` from a dump file."""
|
||||||
|
settings = get_settings()
|
||||||
|
host, port, user, password, dbname = _parse_dsn(settings.database_url)
|
||||||
|
cmd = [
|
||||||
|
"pg_restore",
|
||||||
|
"--clean",
|
||||||
|
"--if-exists",
|
||||||
|
f"--host={host}",
|
||||||
|
f"--port={port}",
|
||||||
|
f"--username={user}",
|
||||||
|
f"--dbname={dbname}",
|
||||||
|
str(in_path),
|
||||||
|
]
|
||||||
|
env = {"PGPASSWORD": password} if password else None
|
||||||
|
click.echo(f"running: {shlex.join(cmd)}")
|
||||||
|
subprocess.run(cmd, check=True, env=env) # noqa: S603
|
||||||
|
click.echo("restore complete")
|
||||||
52
backend/src/mimic/cli/user.py
Normal file
52
backend/src/mimic/cli/user.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""User-related CLI commands."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from mimic.app import create_app
|
||||||
|
from mimic.auth.password import hash_password
|
||||||
|
from mimic.db.models import Group, User, UserGroup
|
||||||
|
from mimic.db.types import UserType
|
||||||
|
from mimic.extensions import db
|
||||||
|
from mimic.rbac.matrix import GroupName
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(help="Manage Mimic user accounts.")
|
||||||
|
def user_group() -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@user_group.command("create")
|
||||||
|
@click.option("--email", required=True)
|
||||||
|
@click.option(
|
||||||
|
"--type",
|
||||||
|
"user_type",
|
||||||
|
type=click.Choice([u.value for u in UserType]),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True)
|
||||||
|
@click.option("--display-name", default=None)
|
||||||
|
def create_user(email: str, user_type: str, password: str, display_name: str | None) -> None:
|
||||||
|
"""Create a local user (sprint 0: rt_operator or rt_lead)."""
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
display_name=display_name,
|
||||||
|
type=UserType(user_type),
|
||||||
|
local_password_hash=hash_password(password),
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
group_name = (
|
||||||
|
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()
|
||||||
|
if group is None:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"group {group_name.value} not seeded — run alembic upgrade head first"
|
||||||
|
)
|
||||||
|
db.session.add(UserGroup(user_id=user.id, group_id=group.id, engagement_id=None))
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(f"created user {email} ({user.id}) in group {group_name.value}")
|
||||||
33
backend/src/mimic/schemas/__init__.py
Normal file
33
backend/src/mimic/schemas/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Pydantic 2 request/response DTOs."""
|
||||||
|
|
||||||
|
from mimic.schemas.engagement import (
|
||||||
|
EngagementCreate,
|
||||||
|
EngagementRead,
|
||||||
|
EngagementUpdate,
|
||||||
|
)
|
||||||
|
from mimic.schemas.host import HostCreate, HostRead, HostUpdate
|
||||||
|
from mimic.schemas.scenario import (
|
||||||
|
ScenarioCreate,
|
||||||
|
ScenarioRead,
|
||||||
|
ScenarioStepCreate,
|
||||||
|
ScenarioStepRead,
|
||||||
|
ScenarioUpdate,
|
||||||
|
)
|
||||||
|
from mimic.schemas.ttp import TtpCreate, TtpRead, TtpUpdate
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EngagementCreate",
|
||||||
|
"EngagementRead",
|
||||||
|
"EngagementUpdate",
|
||||||
|
"HostCreate",
|
||||||
|
"HostRead",
|
||||||
|
"HostUpdate",
|
||||||
|
"ScenarioCreate",
|
||||||
|
"ScenarioRead",
|
||||||
|
"ScenarioStepCreate",
|
||||||
|
"ScenarioStepRead",
|
||||||
|
"ScenarioUpdate",
|
||||||
|
"TtpCreate",
|
||||||
|
"TtpRead",
|
||||||
|
"TtpUpdate",
|
||||||
|
]
|
||||||
36
backend/src/mimic/schemas/engagement.py
Normal file
36
backend/src/mimic/schemas/engagement.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from mimic.db.types import C2Type, EngagementStatus
|
||||||
|
|
||||||
|
|
||||||
|
class EngagementBase(BaseModel):
|
||||||
|
client_name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
c2_type: C2Type = C2Type.MYTHIC
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EngagementCreate(EngagementBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EngagementUpdate(BaseModel):
|
||||||
|
client_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
status: EngagementStatus | None = None
|
||||||
|
c2_type: C2Type | None = None
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EngagementRead(EngagementBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
status: EngagementStatus
|
||||||
36
backend/src/mimic/schemas/host.py
Normal file
36
backend/src/mimic/schemas/host.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from mimic.db.types import C2Type, HostStatus
|
||||||
|
|
||||||
|
|
||||||
|
class HostBase(BaseModel):
|
||||||
|
hostname: str = Field(min_length=1, max_length=255)
|
||||||
|
ip: str | None = Field(default=None, max_length=64)
|
||||||
|
os: str | None = Field(default=None, max_length=128)
|
||||||
|
c2_session_id: str | None = Field(default=None, max_length=128)
|
||||||
|
c2_type: C2Type = C2Type.MYTHIC
|
||||||
|
|
||||||
|
|
||||||
|
class HostCreate(HostBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HostUpdate(BaseModel):
|
||||||
|
hostname: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
ip: str | None = Field(default=None, max_length=64)
|
||||||
|
os: str | None = Field(default=None, max_length=128)
|
||||||
|
c2_session_id: str | None = Field(default=None, max_length=128)
|
||||||
|
c2_type: C2Type | None = None
|
||||||
|
status: HostStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostRead(HostBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
engagement_id: UUID
|
||||||
|
status: HostStatus
|
||||||
51
backend/src/mimic/schemas/scenario.py
Normal file
51
backend/src/mimic/schemas/scenario.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from mimic.db.types import C2Type
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioStepBase(BaseModel):
|
||||||
|
ttp_id: UUID
|
||||||
|
host_id: UUID
|
||||||
|
order_idx: int = Field(ge=0)
|
||||||
|
params_override_json: dict = Field(default_factory=dict)
|
||||||
|
delay_after_ms: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioStepCreate(ScenarioStepBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioStepRead(ScenarioStepBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
scenario_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
c2_type: C2Type
|
||||||
|
version: int = Field(default=1, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioCreate(ScenarioBase):
|
||||||
|
steps: list[ScenarioStepCreate] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
c2_type: C2Type | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioRead(ScenarioBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
engagement_id: UUID
|
||||||
|
steps: list[ScenarioStepRead] = Field(default_factory=list)
|
||||||
49
backend/src/mimic/schemas/ttp.py
Normal file
49
backend/src/mimic/schemas/ttp.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from mimic.db.types import PayloadType, TtpSource
|
||||||
|
|
||||||
|
|
||||||
|
class TtpBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
mitre_technique: str = Field(min_length=2, max_length=16)
|
||||||
|
mitre_subtechnique: str | None = Field(default=None, max_length=16)
|
||||||
|
payload_type: PayloadType
|
||||||
|
payload_template: str = ""
|
||||||
|
params_schema_json: dict | None = None
|
||||||
|
opsec_notes: str | None = None
|
||||||
|
cleanup_command: str | None = None
|
||||||
|
is_stealth_variant: bool = False
|
||||||
|
source: TtpSource = TtpSource.CUSTOM
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TtpCreate(TtpBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TtpUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
mitre_technique: str | None = Field(default=None, min_length=2, max_length=16)
|
||||||
|
mitre_subtechnique: str | None = Field(default=None, max_length=16)
|
||||||
|
payload_type: PayloadType | None = None
|
||||||
|
payload_template: str | None = None
|
||||||
|
params_schema_json: dict | None = None
|
||||||
|
opsec_notes: str | None = None
|
||||||
|
cleanup_command: str | None = None
|
||||||
|
is_stealth_variant: bool | None = None
|
||||||
|
tags: list[str] | None = None
|
||||||
|
is_published: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TtpRead(TtpBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
is_published: bool
|
||||||
|
current_version: int
|
||||||
Reference in New Issue
Block a user