Compare commits
105 Commits
be266d4879
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 24d4a3b146 | |||
|
|
8b5b5d94d8 | ||
|
|
76bcb04c8f | ||
|
|
88b97cef2e | ||
|
|
e4b1d6cb57 | ||
|
|
a9fe2fc528 | ||
|
|
38e282a126 | ||
|
|
7d3d39639e | ||
|
|
184a2a16c9 | ||
|
|
7ff153905b | ||
|
|
8f23f59601 | ||
|
|
b83316f715 | ||
|
|
873e52a2a1 | ||
|
|
5ff6ae8940 | ||
|
|
53755a31d6 | ||
|
|
9a9c98beab | ||
|
|
813e69ee01 | ||
| 6ca614a3f3 | |||
|
|
1997a8c621 | ||
|
|
573281f454 | ||
|
|
6995c4c860 | ||
|
|
7ea2fe490c | ||
|
|
0e69eb901c | ||
|
|
5cc830554c | ||
|
|
ec7800ae38 | ||
|
|
c791e50108 | ||
|
|
64da94f10d | ||
|
|
6c05cc2e11 | ||
|
|
5627d7dcfa | ||
|
|
c85ece46b9 | ||
| e27babed5b | |||
|
|
e41679b331 | ||
|
|
2d1c113f0c | ||
|
|
3a9d9d3203 | ||
|
|
4d9447082f | ||
|
|
aeb4bdb025 | ||
|
|
7335b9f2c6 | ||
|
|
fdab324217 | ||
|
|
e4a672c443 | ||
|
|
b572a67066 | ||
|
|
3725d4415e | ||
|
|
123d9812bc | ||
|
|
57dbd14347 | ||
|
|
25877c4092 | ||
|
|
100441bdeb | ||
|
|
5471c8fd89 | ||
|
|
f1a7965ab9 | ||
|
|
87e4409530 | ||
|
|
cf006a2ba8 | ||
|
|
01434c04a7 | ||
|
|
7aaa5ccc6d | ||
| 678ee8fbfb | |||
|
|
e18ec2bf79 | ||
|
|
cbc176ab82 | ||
|
|
54959c7d5b | ||
|
|
2e59743af5 | ||
|
|
7c011db6d9 | ||
|
|
55f993fa24 | ||
|
|
33a0ca30bb | ||
|
|
20783118ee | ||
|
|
2b700115e8 | ||
|
|
90fc5bab6c | ||
|
|
1f327e9aa8 | ||
| 9873c535c6 | |||
|
|
6d2bb091e2 | ||
|
|
43ab7073f1 | ||
|
|
7d81ce9785 | ||
|
|
a824df06b2 | ||
|
|
5aa839d105 | ||
|
|
e99286ef8e | ||
|
|
988de841e5 | ||
|
|
fc530af78b | ||
|
|
9964d058f4 | ||
|
|
892692f3b8 | ||
|
|
f5ea9d16af | ||
|
|
d5ab1fd26f | ||
|
|
0f6ae857b3 | ||
|
|
89eccad1eb | ||
|
|
ba313a3880 | ||
| 27573f5228 | |||
|
|
b001f57774 | ||
|
|
df8a6b605b | ||
|
|
393b6ed416 | ||
|
|
4596f09e71 | ||
|
|
39f4076a81 | ||
|
|
771483f3b0 | ||
|
|
673b25e0b0 | ||
|
|
b5ea2929de | ||
| e1d9738f23 | |||
|
|
ddf48dd1d1 | ||
|
|
da2ce68660 | ||
|
|
2a7d27bf02 | ||
| 52611337c2 | |||
|
|
b3124ba4dd | ||
| 868097d78a | |||
|
|
9ace9ac0d8 | ||
|
|
54e90f78bb | ||
|
|
da905cc0a0 | ||
|
|
cf0e8a8a6b | ||
|
|
c9032a9057 | ||
|
|
83bf60fb30 | ||
|
|
765bb5a1a4 | ||
|
|
006c4c2c5f | ||
| 7fc79cc5a6 | |||
|
|
5104f7c429 |
85
.claude/agents/design-reviewer.md
Normal file
85
.claude/agents/design-reviewer.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: design-reviewer
|
||||||
|
description: Reviews ONLY the frontend diff of the current sprint plus the screenshots delivered by the frontend-builder. Focuses on visual quality — alignment, typography hierarchy, DESIGN.md token compliance, light/dark consistency, responsive sanity at 1280x720. Read-only, never patches code. Use at the end of every sprint, AFTER frontend-builder marks the task complete and BEFORE code-reviewer.
|
||||||
|
model: opus
|
||||||
|
tools: Read, Glob, Grep, Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the **Design Reviewer** for the Mimic project (BAS WebUI based on MITRE ATT&CK for Purple Team exercises). You review the **visual output** of the current sprint — not its logic. You flag visual defects ; you do not patch code.
|
||||||
|
|
||||||
|
## Scope discipline (critical)
|
||||||
|
|
||||||
|
You review **only the frontend diff of the current sprint** plus the screenshots the frontend-builder attached to their summary. You do NOT touch backend, e2e, or anything outside `frontend/`. Use:
|
||||||
|
```bash
|
||||||
|
git diff <sprint-base-branch>...HEAD -- frontend/
|
||||||
|
git diff <sprint-base-branch>...HEAD --name-only -- frontend/
|
||||||
|
```
|
||||||
|
The sprint base branch is in `tasks/todo.md`. If unsure, ask the team-lead.
|
||||||
|
|
||||||
|
## Your input
|
||||||
|
|
||||||
|
1. The **screenshots** (paths in the frontend-builder's summary). View each one with the `Read` tool — they are PNG images and the tool renders them visually.
|
||||||
|
2. **DESIGN.md** — your spec for tokens (palette, typography, spacing, radii, shadows). Every visual choice must trace back to a token.
|
||||||
|
3. The **diff** for `frontend/src/components/`, `frontend/src/pages/`, `frontend/src/styles/`, `frontend/tailwind.config.ts`, `frontend/src/styles/*.css`.
|
||||||
|
4. **SPEC.md § UI/UX** for theming + button convention + modal rules.
|
||||||
|
5. The current sprint's `tasks/todo.md` § 1 (user stories) — to know which screens were intended to change.
|
||||||
|
|
||||||
|
## What you look for
|
||||||
|
|
||||||
|
In order of importance:
|
||||||
|
|
||||||
|
1. **Alignment defects** — labels and inputs on different baselines, buttons sitting on the wrong row, grids that look jagged. Inspect at 1280×720 viewport since that's the project's reference.
|
||||||
|
2. **Token violations** — any color, spacing, radius, or font size that is NOT a DESIGN.md token. Hardcoded `#hexhex`, `text-white`, `bg-gray-500`, arbitrary `px` values, or off-system Tailwind classes are flags. CSS variables tied to dark mode are fine.
|
||||||
|
3. **Light / dark consistency** — both states use the same component logic, only colors swap. A light-only color leaking into dark mode (or vice versa) is a defect. Verify each screenshot pair (`*-light.png` + `*-dark.png`) tells the same visual story.
|
||||||
|
4. **Typography hierarchy** — display vs body vs caption sizes follow the scale in DESIGN.md. A heading that uses a body weight, or vice versa, is a defect.
|
||||||
|
5. **Whitespace rhythm** — DESIGN.md ships a base 8 px scale with named tokens (`xs`, `sm`, `md`, …). Padding/margins that fall outside this rhythm are flags.
|
||||||
|
6. **Responsive sanity** — at 1280×720 nothing overflows the viewport without an intentional scroll affordance. Modal content should fit without horizontal scroll unless explicitly spec'd otherwise.
|
||||||
|
7. **Button convention** (sprint 4+) — icon + short label (≤ 8 chars) preferred to phrases. Long-form buttons need a justification (workflow-critical label without an obvious icon).
|
||||||
|
8. **Accessibility scope V1** — focus visible on every interactive element ; ARIA roles present on dialogs and listboxes ; color contrast not relying on red/green alone. Full WCAG conformance is OUT OF SCOPE V1 — don't over-flag.
|
||||||
|
9. **Cohérence inter-écrans** — the same component renders the same way on every page (e.g., `StatusBadge` looks identical on the engagements list and on the detail page). Sprint-introduced inconsistencies are defects.
|
||||||
|
|
||||||
|
## What you NEVER do
|
||||||
|
|
||||||
|
- Edit any file.
|
||||||
|
- Run destructive git commands.
|
||||||
|
- Review backend code, e2e tests, or any non-`frontend/` change.
|
||||||
|
- Re-review prior sprints' UI (out of scope).
|
||||||
|
- Mark APPROVED if open findings remain.
|
||||||
|
- Patch a defect — even a one-character CSS fix. Only flag. The frontend-builder owns the fix.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
```
|
||||||
|
## Design Review — Sprint <N>
|
||||||
|
|
||||||
|
### Verdict
|
||||||
|
APPROVED | NEEDS-FIX
|
||||||
|
|
||||||
|
### Screenshots audited
|
||||||
|
- list of each screenshot path + a one-line visual summary
|
||||||
|
|
||||||
|
### Findings (assigned to frontend-builder)
|
||||||
|
For each:
|
||||||
|
- Severity: [ALIGN] | [TOKEN] | [DARK] | [TYPO] | [SPACE] | [RESP] | [BTN] | [A11Y] | [COHER] | [NIT]
|
||||||
|
- Screenshot or file:line where it shows
|
||||||
|
- What is wrong (concretely — "Password label sits 24px lower than Username label" is good ; "alignment is off" is not)
|
||||||
|
- Suggested fix (1-2 lines — class change, token to use, no patch)
|
||||||
|
|
||||||
|
### Token compliance
|
||||||
|
- list of any hardcoded colors / sizes that escaped DESIGN.md, with file:line
|
||||||
|
|
||||||
|
### Light/dark consistency
|
||||||
|
- per pair of screenshots, OK or specific divergence noted
|
||||||
|
|
||||||
|
### Coverage gaps
|
||||||
|
- screens that should have been screenshot but weren't (vs. the brief's expected list)
|
||||||
|
```
|
||||||
|
|
||||||
|
When verdict is APPROVED, notify the team-lead so the code-reviewer can take over. When NEEDS-FIX, the findings go back to the frontend-builder via the team-lead.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- KISS — flag the visible defects, not the abstract concerns.
|
||||||
|
- One screenshot tells more than ten paragraphs ; quote pixel deltas or color hexes when relevant.
|
||||||
|
- Trust the frontend-builder's choices when they sit within DESIGN.md ; push back when they don't.
|
||||||
|
- Don't re-litigate decisions already settled in `tasks/todo.md` § Décisions arrêtées.
|
||||||
@@ -11,10 +11,22 @@ You are the **Frontend Builder** for the Mimic project (BAS WebUI based on MITRE
|
|||||||
|
|
||||||
Read these files first, in order:
|
Read these files first, in order:
|
||||||
1. `SPEC.md` — global spec and technical decisions.
|
1. `SPEC.md` — global spec and technical decisions.
|
||||||
2. `DESIGN.md` — UI design system. **Mandatory** — every component you build must follow it.
|
2. `DESIGN.md` — UI design system. **Mandatory** — every component you build must follow it (tokens, slab, btn-outline, etc.).
|
||||||
3. The **backend-builder's summary** for the current sprint (in `tasks/todo.md` or the latest team-lead dispatch). This is your API contract.
|
3. The **backend-builder's summary** for the current sprint (in `tasks/todo.md` or the latest team-lead dispatch). This is your API contract.
|
||||||
4. `tasks/lessons.md` — past mistakes to avoid.
|
4. `tasks/lessons.md` — past mistakes to avoid.
|
||||||
|
|
||||||
|
## Mandatory skill — `frontend-design`
|
||||||
|
|
||||||
|
Before creating or modifying **any visible UI component** (new page, new component, layout change, state additions like loading/error/empty), you MUST invoke the `frontend-design` skill once at the start of the sprint via:
|
||||||
|
|
||||||
|
```
|
||||||
|
Skill({ skill: "frontend-design" })
|
||||||
|
```
|
||||||
|
|
||||||
|
`DESIGN.md` rules the **project-specific** tokens and motifs (slab, btn-outline, palette, BAS layout patterns). `frontend-design` adds the **universal** principles `DESIGN.md` doesn't restate: typographic hierarchy, alignment grid, contrast ratios, focus states, density rhythm, motion restraint. The two are complementary — `DESIGN.md` wins on tokens/component shape, `frontend-design` wins on visual craft.
|
||||||
|
|
||||||
|
Exception: pure logic/data-layer work with no visible UI change (hook refactor, query key rename, internal type tightening) — skip the skill, note it in your summary.
|
||||||
|
|
||||||
## What you build
|
## What you build
|
||||||
|
|
||||||
- React components under `frontend/src/components/`
|
- React components under `frontend/src/components/`
|
||||||
@@ -44,6 +56,21 @@ cd frontend && npm run test -- --run
|
|||||||
|
|
||||||
If any of these fail, fix the cause before reporting completion.
|
If any of these fail, fix the cause before reporting completion.
|
||||||
|
|
||||||
|
### Screenshots — MANDATORY (sprint 4+)
|
||||||
|
|
||||||
|
You MUST also start the dev server (`npm run dev` inside `frontend/`) and capture **one screenshot per feature or state you introduced or modified**. Concretely :
|
||||||
|
|
||||||
|
- Every new page → 1 screenshot.
|
||||||
|
- Every modified page → 1 screenshot of the new state.
|
||||||
|
- Every component with multiple visual states (loading / error / empty / populated / read-only / disabled) → 1 screenshot per distinct state you introduced or changed.
|
||||||
|
- If theming is in scope this sprint → 1 light + 1 dark screenshot per screen above.
|
||||||
|
|
||||||
|
Save them under `$CLAUDE_JOB_DIR` (or `/tmp/mimic-sprint-N/`) with descriptive names. **List the absolute paths in your final summary, grouped per screen.**
|
||||||
|
|
||||||
|
If you genuinely cannot start the dev server (port conflict, build broke, env missing), say so EXPLICITLY in the summary, list the technical reasons, and DO NOT silently skip. A "Dev server not started" line is a hard block — the team-lead must decide whether to accept or send back.
|
||||||
|
|
||||||
|
Screenshots are the **design-reviewer**'s primary input. Without them, the design-review step cannot run, the sprint cannot ship.
|
||||||
|
|
||||||
## Output format (when you return to the team-lead)
|
## Output format (when you return to the team-lead)
|
||||||
|
|
||||||
A short Markdown summary:
|
A short Markdown summary:
|
||||||
@@ -53,6 +80,7 @@ A short Markdown summary:
|
|||||||
- **Mismatches with API** (if any — flagged, not patched)
|
- **Mismatches with API** (if any — flagged, not patched)
|
||||||
- **Open questions / design ambiguities** (escalate, don't decide)
|
- **Open questions / design ambiguities** (escalate, don't decide)
|
||||||
- **Test results** (vitest summary, typecheck/lint status)
|
- **Test results** (vitest summary, typecheck/lint status)
|
||||||
|
- **Screenshots delivered** (absolute paths, grouped per screen, light + dark when in scope) — see § Before you finish
|
||||||
- **CLAUDE.md rules that helped**
|
- **CLAUDE.md rules that helped**
|
||||||
|
|
||||||
## Principles
|
## Principles
|
||||||
|
|||||||
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Mimic — environment variables
|
||||||
|
# Copy this file to `.env` and fill in real values before `make start`.
|
||||||
|
# `.env` is gitignored — never commit secrets.
|
||||||
|
|
||||||
|
# --- Required ---
|
||||||
|
|
||||||
|
# JWT signing secret. Generate with: openssl rand -hex 32
|
||||||
|
# The backend will refuse to start in production if this is unset.
|
||||||
|
MIMIC_JWT_SECRET=replace-me-with-a-strong-random-secret
|
||||||
|
|
||||||
|
# --- Optional (defaults shown) ---
|
||||||
|
|
||||||
|
# Path where SQLite stores the database, INSIDE the container.
|
||||||
|
# Must live under the /data volume mount to persist across `make restart`.
|
||||||
|
MIMIC_DB_PATH=/data/mimic.sqlite
|
||||||
|
|
||||||
|
# Port the Flask app listens on inside the container.
|
||||||
|
# To expose on a different host port, override PORT when calling make:
|
||||||
|
# make start PORT=8080
|
||||||
|
MIMIC_PORT=5000
|
||||||
|
|
||||||
|
# --- Sprint 2+ (not used in Sprint 1) ---
|
||||||
|
# MITRE_BUNDLE_PATH=/app/backend/data/mitre/enterprise-attack.json
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -89,3 +89,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# --- MITRE bundle if huge (kept by default — uncomment to ignore) ---
|
# --- MITRE bundle if huge (kept by default — uncomment to ignore) ---
|
||||||
# backend/data/mitre/enterprise-attack.json
|
# backend/data/mitre/enterprise-attack.json
|
||||||
|
|
||||||
|
# TypeScript build artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
273
CHANGELOG.md
273
CHANGELOG.md
@@ -6,14 +6,279 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed — Sprint 7 (Terminal-SOC design refresh)
|
||||||
|
|
||||||
|
**Frontend** (136 vitest passing — unchanged count, 3 assertions updated for new token names)
|
||||||
|
|
||||||
|
- `DESIGN.md` — complete rewrite from HP-catalog (346 lines) to terminal-SOC brutalist spec. Covers palette (success/warn tokens added), typography (Inter + JetBrains Mono), layout, shapes (border-radius 0 rule), component patterns, and Do/Don't list.
|
||||||
|
- `frontend/tailwind.config.ts` — added `success` / `warn` semantic color tokens (WCAG AA in both light+dark); added `fontFamily.mono` (JetBrains Mono Variable); reduced all `display-*` scale (xxl 72→40, xl 56→32, lg 44→28, md 32→24, sm 24→20, xs 20→16); `borderRadius` reduced to `none: '0px'` + `pill: '9999px'` only; `section` spacing 40px→48px.
|
||||||
|
- `frontend/src/styles/index.css` — CSS vars for `--color-success[-soft]` / `--color-warn[-soft]` (light + dark variants); all `.btn-*` classes: `rounded-none`, no `transition-colors`; `.text-input`: `rounded-none`, no transition; `.card-product`: `rounded-none`, `border border-hairline`, no shadow; `.badge-pill-*` kept `rounded-pill`; added `.tag-mitre` (angular MITRE tags, `font-mono`).
|
||||||
|
- `frontend/src/styles/fonts.css` — added `@import '@fontsource-variable/jetbrains-mono/index.css'` (local, no CDN).
|
||||||
|
- `frontend/src/components/StatusBadge.tsx` — `rounded-lg` → `rounded-pill`; new semantic colors: `planned → warn-soft`, `active → primary-soft`, `closed → cloud/graphite`.
|
||||||
|
- `frontend/src/components/SimulationStatusBadge.tsx` — `rounded-lg` → `rounded-pill`; semantic mapping: `pending → cloud`, `in_progress → primary-soft`, `review_required → warn-soft`, `done → success-soft`.
|
||||||
|
- `frontend/src/components/Toast.tsx` — removed `rounded-xl` and shadow; left border strips: `error → border-l-bloom-deep`, `success → border-l-success`, default → `border-l-primary`.
|
||||||
|
- `frontend/src/components/MitreTechniqueTag.tsx` — `rounded-full` → `rounded-none` on both technique and tactic tags; added `font-mono` (MITRE IDs are data).
|
||||||
|
- `frontend/src/components/ExportEngagementButton.tsx` — removed `rounded-r-none` / `rounded-l-none` from split-button; dropdown: `rounded-md shadow-floating` → `rounded-none`.
|
||||||
|
- `frontend/src/components/SimulationList.tsx` — dropdown: `rounded-md shadow-floating` → `rounded-none`; MITRE column + `executed_at`: added `font-mono`; split-button: removed radius classes.
|
||||||
|
- `frontend/src/pages/SimulationFormPage.tsx` — h1: `text-[44px]` → `text-[32px]`; Done/SOC banners: `rounded-xl` → `rounded-none`.
|
||||||
|
- `frontend/src/pages/UsersAdminPage.tsx` — h1: `text-[44px]` → `text-[32px]`; username column + created_at column: added `font-mono`.
|
||||||
|
- `frontend/tests/SimulationStatusBadge.test.tsx` — updated 3 assertions for renamed semantic tokens (`bg-fog` → `bg-cloud`, `bg-bloom-coral` → `bg-warn-soft`, `bg-storm-deep` → `bg-success-soft`).
|
||||||
|
|
||||||
|
**No backend changes.** No DB schema change. No migration.
|
||||||
|
|
||||||
|
### Added — Sprint 6 (Engagement export)
|
||||||
|
|
||||||
|
**Backend** (253 pytest passing — 226 sprint-1-to-4 + 28 sprint 5 + 5 sprint 5 post-code-review + 23 sprint 6 + 1 CSV-injection defense-in-depth test)
|
||||||
|
- `backend/app/services/export.py` (new, 302 lines) — 3 pure render functions (`render_engagement_markdown`, `render_engagement_csv`, `render_engagement_pdf`) + filename slugifier (`_export_filename`) + HTML helper for the PDF pipeline + CSV formula-injection defense helper (`_csv_safe`).
|
||||||
|
- New endpoint `GET /api/engagements/<int:eid>/export?format=md|csv|pdf` extended on the existing `engagements_bp`. Decorator `@role_required("admin", "redteam")` (SOC → 403). 400 on missing/unknown format, 404 on unknown engagement. Returns the rendered file body with `Content-Type` matching the format and `Content-Disposition: attachment; filename="engagement-<id>-<slug>-YYYYMMDD.<ext>"`.
|
||||||
|
- Filename slugifier uses `unicodedata.normalize('NFKD', ...).encode('ascii', 'ignore')` to strip accents (`Opération` → `operation`) and falls back to `"unnamed"` when the slug is empty after stripping.
|
||||||
|
- Markdown rendering uses fenced code blocks with `~~~bash` (tildes, not backticks) so backticks in commands don't break the fence. SOC fields are always rendered, even when blank (consistency for handoff). `_creator()` helper renders the username string only (not the `{id, username}` dict).
|
||||||
|
- CSV rendering uses stdlib `csv.writer` (handles multiline / quotes / commas natively). `_csv_safe()` prefixes a single apostrophe to any string starting with `=`, `+`, `-`, `@`, `\t`, or `\r` — defuses Excel / LibreOffice / Google Sheets formula injection on the SOC analyst's machine when they open the exported CSV. Applied to all user-controlled string fields; ISO dates and the enum status value are exempted.
|
||||||
|
- PDF rendering via **WeasyPrint** (Python HTML→PDF). The PDF is generated from the same engagement DATA as the Markdown (not from the Markdown string) via `_render_engagement_html()` and `weasyprint.HTML(string=html).write_pdf()`. CSS inline (≤ 30 lines). All user-controlled fields HTML-escaped via stdlib `html.escape()`.
|
||||||
|
- `docker/Dockerfile` python stage now installs minimal WeasyPrint deps: `libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 shared-mime-info`. `libgdk-pixbuf-2.0-0` deliberately excluded (text-only PDF).
|
||||||
|
- `weasyprint>=60.0` added to `backend/requirements.txt`.
|
||||||
|
- No DB schema change. No migration.
|
||||||
|
|
||||||
|
**Frontend** (136 vitest passing — 121 sprint-1-to-5 + 12 sprint 6 + 3 sprint 6 coverage-gap fix)
|
||||||
|
- `frontend/src/components/ExportEngagementButton.tsx` (new) — split-button dropdown `[Export ▼]` with `Download` + `ChevronDown` lucide icons. **Both halves open the dropdown** (no default left-click action — different semantic from sprint 5's `NewSimulationDropdown` where left navigates blank), because there is no obvious default format among MD/CSV/PDF. Loading state per-item, toast on error. Click-outside + Escape close (reuses the `useEffect` + `pointerdown` + `keydown` pattern from `NewSimulationDropdown`). `data-testid="export-dropdown"` for e2e selection. Visual: shares `btn-outline` class with the neighbour `Edit` button.
|
||||||
|
- `frontend/src/api/exports.ts` (new) — `downloadEngagementExport(engagementId, format)` with `responseType: 'blob'`. Reads `Content-Disposition: attachment; filename="..."`, falls back to `engagement-<id>.<ext>` when the header is absent or malformed. Throws an `Error` on non-2xx (caller catches and toasts). Helper `parseContentDispositionFilename()`.
|
||||||
|
- `frontend/src/pages/EngagementDetailPage.tsx` (edited) — integrates `<ExportEngagementButton engagementId={engagement.id} />` in the header next to the `Edit` CTA. Gated by `canEditEngagements` from `useAuth` (admin + redteam).
|
||||||
|
- New test file `frontend/tests/exports.test.ts` covers the API client directly via `axios-mock-adapter` (the component test file mocks `downloadEngagementExport` entirely, so the fallback logic inside `exports.ts` wasn't reachable from there — new file lets the real function run for 3 dedicated tests).
|
||||||
|
|
||||||
|
**Acceptance tests** (Playwright, **223 passed** — baseline sprint 5 = 201, +22 sprint 6)
|
||||||
|
- 3 new spec files (one per US): `us29-export-formats.spec.ts` (8 tests), `us30-export-rbac.spec.ts` (3 tests), `us31-export-robustness.spec.ts` (5 tests).
|
||||||
|
- No regression on sprints 1–5: full pre-sprint-6 suite still green.
|
||||||
|
|
||||||
|
**Security**
|
||||||
|
- CSV formula injection (MEDIUM) flagged by `security-guidance@claude-code-plugins` automated review during the sprint, fixed mid-sprint (commit `57dbd14`). 3 dedicated unit tests cover the apostrophe-prefix on `=`, `@` triggers and the no-op on safe strings.
|
||||||
|
- Defense-in-depth: a property test (`test_export_filename_never_contains_quote_or_crlf`) asserts the slugifier output never contains `"`, `\r`, or `\n` — guards against Content-Disposition header injection if someone later weakens the slug regex.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 2026-06-07 — SPEC.md § Export d'engagement added (between § Templates de simulations and § Stacks techniques). Committed as the **first** sprint commit (`7aaa5cc`), applying the fix-candidate from sprint 5's recurrent "SPEC.md uncommitted at sprint close" lesson. Four-sprint recurrence finally broken.
|
||||||
|
- 2026-06-08 — Team `mimic` (persistent `.claude/teams/mimic/config.json`) instantiated with the full 7-agent project roster (backend-builder, frontend-builder, spec-reviewer, code-reviewer, design-reviewer, test-verifier, devil-advocate). Agents are spawned with an idle prompt at sprint start and woken via SendMessage per phase — flip vs the old "spawn-with-task-only" policy that hit the "team roster is flat" gotcha when respawning. Persistent across sprints from sprint 7+.
|
||||||
|
- 2026-06-08 (post-review, pre-merge) — **Export schema switched to a fixed 7-column handoff layout** uniform across MD/CSV/PDF. Columns (FR headers): `Scénario`, `Test`, `Source de log`, `Commentaires SOC`, `Exécution` (multi-line concat without labels — `executed_at` → `commands` → `execution_result`), `Logs remontés au SIEM`, `Cyber incident`. Removed from the export (intentional, handoff-focused): simulation status, MITRE techniques/tactics, prerequisites, id, created_at, updated_at. Markdown switched from narrative-per-simulation to a GFM table. PDF switched from sectioned HTML to a single `<table>`. SPEC `fdab324`, backend refactor `7335b9f`, e2e adaptation `aeb4bdb`. Final counters: backend 257 pytest, frontend 136 vitest, e2e 223 Playwright.
|
||||||
|
- 2026-06-08 (post-refactor, pre-merge) — **Two MEDIUM security regressions fixed** in the 7-column refactor (`3a9d9d3`), flagged by `security-guidance@claude-code-plugins`:
|
||||||
|
1. **CSV formula injection inside the multi-line `Exécution` cell**: `_csv_safe` only checks `cell[0]`. With `executed_at` non-null, the cell starts with a safe date digit, but inner lines (commands, execution_result) starting with `=`/`+`/`-`/`@` evaded defense. Fix: `_format_execution_csv()` applies `_csv_safe` per user-controlled component BEFORE the multi-line concat. Outer `_csv_safe` on the assembled cell retained as belt-and-braces.
|
||||||
|
2. **Stored XSS in Markdown table cells**: the new GFM table allows inline HTML (we use it for `<br/>`). A `sim.commands = "<script>alert(1)</script>"` would be rendered raw by MD viewers that interpret inline HTML (Notion, Obsidian, GitHub preview). Fix: `_cell()` now calls `html.escape()` on each value BEFORE the pipe-escape and `\n` → `<br/>` substitution — mirrors the `_render_engagement_html` PDF defense. The `<br/>` we insert ourselves stays unescaped (it's not user-controlled). 2 dedicated regression tests added.
|
||||||
|
- 2026-06-09 (post-merge-review) — PDF export: A4 landscape orientation (user feedback post-merge-review). `@page { size: A4 landscape; }` added to `_CSS`; `font-size` reduced to 11px and `table-layout: fixed; word-break: break-word` added to prevent 7-column overflow on narrower portrait layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Sprint 5] — Simulation templates + instantiation + nav + dropdown (merged 2026-05-28)
|
||||||
|
|
||||||
|
### Added — Sprint 5 (Simulation templates)
|
||||||
|
|
||||||
|
**Backend** (226 pytest passing — 193 sprint-1-to-4 + 28 sprint 5 + 5 post-code-review)
|
||||||
|
- `SimulationTemplate` model (table `simulation_templates`) — UNIQUE constraint on `name`, JSON `techniques` + `tactic_ids` (default `[]`, NOT NULL via `server_default`), Text fields `description` / `commands` / `prerequisites`, FK `created_by_id` to `users`, `created_at` / `updated_at`.
|
||||||
|
- Alembic migration `0005_simulation_templates.py` — CREATE TABLE (SQLite native, no batch); downgrade via DROP TABLE.
|
||||||
|
- 5 new endpoints under `/api/templates`, all gated `@role_required("admin", "redteam")` (SOC → 403):
|
||||||
|
- `GET /api/templates` — list, sorted name ASC, serialized with enriched `techniques: [{id, name, tactics}]` and `tactics: [{id, name}]`.
|
||||||
|
- `POST /api/templates` — create. `name` required (400 if empty), unique (409 via `IntegrityError` catch, no pre-check race). `technique_ids` / `tactic_ids` validated upfront — type check `isinstance(list)` (400 with friendly message) THEN resolved against the bundle / `_TACTIC_IDS` (400 with id on unknown).
|
||||||
|
- `GET /api/templates/<tid>` — single, 404 on miss.
|
||||||
|
- `PATCH /api/templates/<tid>` — partial update. Same validations. 409 on `name` conflict; no-op rename (`name == current`) returns 200.
|
||||||
|
- `DELETE /api/templates/<tid>` — 204. **No cascade** to instantiated simulations (decoupling guarantee).
|
||||||
|
- `POST /api/engagements/<eid>/simulations` extended with optional `template_id`. When provided:
|
||||||
|
- Template loaded (404 on miss).
|
||||||
|
- Fields copied directly onto the new `Simulation` ORM object (`techniques`, `tactic_ids`, `description`, `commands`, `prerequisites`, and `name` if missing from body).
|
||||||
|
- **Explicit non-call to `apply_patch()` / `_resolve_*` helpers** — avoids re-hitting the MITRE bundle AND avoids triggering the auto-transition `pending → in_progress`. Status stays `pending`, engagement stays `planned` (no `_maybe_activate_engagement` call). Decorrelation: no `template_id` FK on `Simulation`, deep copy of JSON arrays.
|
||||||
|
- New helpers in `mitre.py` reused / re-exposed; new `serialize_template()` in `serializers.py` mirrors `serialize_simulation` (minus SOC fields, status, executed_at) and uses the shared `_enrich_techniques` + `_enrich_tactics` (no duplication).
|
||||||
|
- All migration tests (0003, 0004, 0005) now use `Path(__file__).resolve().parent.parent / "migrations" / "versions" / "..."` — sprint 4's hardcoded-path MAJOR is closed for the third sprint running.
|
||||||
|
|
||||||
|
**Frontend** (121 vitest passing — 92 sprint-1-to-4 + 26 sprint 5 + 3 post-code-review)
|
||||||
|
- New page `TemplatesListPage` (`/admin/templates`, admin+redteam only) — table (Name / MITRE count / Created by / Updated / Actions), `+ New` CTA with Plus icon.
|
||||||
|
- New page `TemplateFormPage` (`/admin/templates/new` and `/admin/templates/:id/edit`) — single-column FormField stack (sidesteps the multi-column grid trap that broke AC-17.3 on UsersAdminPage). Includes `MitreTechniquePicker` + `MitreMatrixModal` inline (NOT `MitreTechniquesField` — that one auto-saves; template form needs batched save). Delete via `ConfirmDialog`.
|
||||||
|
- New component `TemplatePickerModal` — modal listing all templates (Name / MITRE count / Created by). Empty state when `useTemplates()` returns `[]`: "No templates available — Create one from the Templates page."
|
||||||
|
- New nav link "Templates" in `Layout.tsx` topbar — visible to admin + redteam only, masked for SOC. Mirrors the pattern used by the "Users" link.
|
||||||
|
- `SimulationList` "New" button refactored into a **split-button dropdown**: `[+ New] [▼]`. Primary half → `/.../simulations/new` (blank). Dropdown → "Blank" + "From template…". Open dropdown closes on click-outside or Escape (sprint 3 picker pattern). Empty-state `SimulationList` now also exposes the same dropdown (so users can instantiate from a template on a fresh engagement without creating a blank first).
|
||||||
|
- `dark:shadow-floating-dark` consistently applied to the new dropdown and `TemplatePickerModal` — matches the sprint 4 shadow token model. `dark:hover:bg-fog` on dropdown items for contrast.
|
||||||
|
- New types: `SimulationTemplate`, `SimulationTemplateCreateInput`, `SimulationTemplatePatchInput`. `SimulationCreateInput` extended with `template_id?: number`.
|
||||||
|
- New TanStack Query hooks (`useTemplates`, `useTemplate`, `useCreateTemplate`, `useUpdateTemplate`, `useDeleteTemplate`) with cache invalidation on mutations.
|
||||||
|
- API client `frontend/src/api/templates.ts` — 5 calls to `/api/templates*`. (Sprint-5 in-flight bug : initial commit `90fc5ba` used `/simulation-templates` paths everywhere; caught immediately, fixed in `2b70011`.)
|
||||||
|
|
||||||
|
**Acceptance tests** (Playwright, **201 passed**)
|
||||||
|
- 3 new spec files (one per US): `us26-templates-crud.spec.ts` (22 tests), `us27-instantiate-from-template.spec.ts` (14 tests), `us28-templates-nav.spec.ts` (8 tests).
|
||||||
|
- Coverage gaps from code-reviewer filled: bidirectional template↔instance decorrelation, dropdown click-outside + Escape, SOC + template_id 403.
|
||||||
|
- Sprint 2/3 spec adapts: `us4-engagements.spec.ts` and `us7-simulation-create.spec.ts` now use `getByTestId('new-simulation-btn')` instead of `getByRole('link', /new simulation/)` — the link became a split-button dropdown.
|
||||||
|
- 1 pre-existing flaky in `us3-users-admin AC-3.4` (DB contamination across runs) — predates sprint 5, unrelated.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 2026-05-28 — SPEC.md § Templates de simulations added (between § Fonctionnement and § Authentification & rôles). Spells out the decoupling rule and the SOC-zero-access RBAC.
|
||||||
|
- 2026-05-28 — `POST /api/engagements/<eid>/simulations` API contract: `name` is now optional when `template_id` is provided (falls back to `template.name`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Sprint 4] — UI polish + workflow tightening + dark mode + process hygiene (merged 2026-05-28)
|
||||||
|
|
||||||
|
### Added — Sprint 4 (UI polish + workflow tightening + dark mode + process hygiene)
|
||||||
|
|
||||||
|
**Backend** (193 pytest passing — 192 sprint-1-to-3 + 1 sprint-4)
|
||||||
|
- `Simulation.tactic_ids` JSON column (default `[]`, NOT NULL via `server_default`). Sprint 3's `techniques` array is joined by a parallel `tactics` field in the serialized response.
|
||||||
|
- Alembic migration `0004_simulation_tactic_ids.py` — simple ADD COLUMN (SQLite native); downgrade via `batch_alter_table`.
|
||||||
|
- `PATCH /api/simulations/<sid>` accepts `{tactic_ids: ["TA0007", ...]}` (TA-format, validated against the hardcoded `_TACTIC_IDS` map — no MITRE bundle dependency for tactics since TA-ids are a stable MITRE standard). Dedup via `dict.fromkeys`. SOC sending `tactic_ids` → 403. Auto-transition `pending → in_progress` extended to non-empty `tactic_ids`.
|
||||||
|
- **Done is now terminal**: `PATCH /api/simulations/<sid>` on a `done` simulation → **409** `{error: "simulation is done — reopen first"}` (applies to all 3 roles, prioritised over RBAC field-level).
|
||||||
|
- **Reopen transition**: `POST /api/simulations/<sid>/transition {to: "review_required"}` from `done` → 200, open to **admin + redteam + soc**. Implemented as a special case before the `_ALLOWED_TRANSITIONS` dict lookup; other transitions from `done` (`→ pending` / `→ in_progress` / `→ done`) remain forbidden (409 via dict miss).
|
||||||
|
- **Engagement auto-status**: when any simulation transitions to `in_progress` (auto or manual), if `engagement.status == planned` → engagement passes to `active` in the same DB transaction. No auto-rollback. The `_maybe_activate_engagement` helper modifies and `db.session.add()`s only — the caller commits (no double-commit).
|
||||||
|
- `GET /api/mitre/matrix` `tactic_id` field now returned in TA-format (`"TA0007"`) instead of the internal slug (`"discovery"`). Aligns with the PATCH endpoint contract — frontend can round-trip the same `tactic_id` between matrix display and PATCH body. Spec-drift caught by the e2e test-verifier (AC-21.6 defect).
|
||||||
|
- Internal helpers : `_TACTIC_IDS` (TA-id → short-name, 12 entries, non-sequential), `_SLUG_TO_TA_ID` (reverse), `lookup_tactic()`, `get_tactic_name()`.
|
||||||
|
- Migration tests now derive paths from `__file__` instead of hardcoded worktree absolute paths (recurring sprint-3 issue resolved).
|
||||||
|
|
||||||
|
**Frontend** (92 vitest passing — typecheck + lint clean)
|
||||||
|
- **Dark mode**: full Tailwind `darkMode: 'class'` plumbing, themed surface tokens via CSS variables under `:root` / `.dark` in `index.css`. Three-state cycle (`light` / `dark` / `system`) toggle in the topbar with lucide-react icons (Sun / Moon / Monitor). Persisted under localStorage key `mimic-theme` (default `system`, follows `prefers-color-scheme`). Dedicated `useTheme()` hook orchestrates the cycle + media-query listener.
|
||||||
|
- **Slab token split**: a new `slab` / `slab-text` / `slab-muted` token family stays fixed `#111827` / `#f9fafb` / `#6b7280` regardless of theme. Used for permanently-dark surfaces (utility strip, footer, modal backdrop) that must NOT invert in dark mode. The themed `ink` token is now strictly for text. `.btn-ink` uses `@apply bg-slab` (single source of truth).
|
||||||
|
- **Modal backdrop**: new `.modal-backdrop` CSS class (fixed `rgba(0,0,0,0.6)`) replaces `bg-ink/60` (which inverted in dark mode). Applies to `MitreMatrixModal` and `ConfirmDialog`.
|
||||||
|
- **Badge contrast in dark mode**: `SimulationStatusBadge` and `StatusBadge` use `text-white` (fixed) on colored backgrounds instead of `text-canvas` / `text-ink-on` (which inverted). `Toast` error uses `bg-slab text-slab-text`.
|
||||||
|
- **Dark mode shadows**: new `soft-lift-dark` and `floating-dark` token variants, applied to cards and modals via `dark:shadow-*` so the lift remains visible on dark canvas. Hairlines bumped (`#4b5563`) for better separator visibility.
|
||||||
|
- **MITRE matrix modal overhaul**: 12-column CSS grid (`repeat(12, minmax(0, 1fr))`), no horizontal scroll at 1280×720. Compact technique cells (`text-[12px]`, hairline borders). Sticky tactic headers (uppercase, count badge). Sub-techniques expand/collapse preserved from sprint 3.
|
||||||
|
- **Tactic selection in matrix**: clicking a tactic header toggles its selection in addition to techniques + sub-techniques. Tactic chips render with `bg-primary text-canvas` (filled), distinct from technique chips (`bg-primary-soft text-primary-deep`). Apply emits one combined PATCH `{technique_ids, tactic_ids}` — no two sequential calls.
|
||||||
|
- **MITRE input redesign**: replaces the prior `Add technique` + `Quick search` button pair with an inline autocomplete input + matrix icon button to the right. Chips display the reference only (`T1059.001` or `TA0007`); full technique name surfaces on `title=` hover. Empty state minimal.
|
||||||
|
- **`done` simulation UI**: form fields are fully disabled, `MitreTechniquesField` is read-only (chips without ×, input + matrix icon hidden), action bar shows ONLY a `Reopen` button (visible to all 3 roles per RBAC). Save / Mark for review / Close / Delete are hidden in the done state. A "this simulation is done and read-only" banner replaces them.
|
||||||
|
- **UsersAdminPage `Create account` form alignment** (3rd attempt — finally pixel-perfect): refactored from `FormField` + `items-end` to an explicit 3-row grid (labels / inputs+button / hints) using `grid-rows-[auto_auto_auto]`. Labels share row 1, inputs + button share row 2, hint sits alone in row 3 — the browser cannot misalign cells of different heights.
|
||||||
|
- **EngagementsListPage dedup**: single `+ New` CTA (header + empty-state share the same label).
|
||||||
|
- **Engagement query invalidation**: `useUpdateSimulation` and `useTransitionSimulation` now invalidate both the simulation queries AND `["engagement", engagement_id]` + `["engagements"]` so the engagement status badge updates without a full reload after the auto-transition.
|
||||||
|
|
||||||
|
**Process hygiene** (US-24)
|
||||||
|
- New agent definition `.claude/agents/design-reviewer.md` — read-only, runs AFTER `frontend-builder` and BEFORE `code-reviewer`. Audits alignment, DESIGN.md token usage, light/dark consistency, typography, whitespace rhythm, responsive sanity at 1280×720, button convention, V1 a11y, and inter-screen coherence.
|
||||||
|
- Updated `.claude/agents/frontend-builder.md` Definition of Done — screenshots are now MANDATORY (one per feature/state introduced or modified, light + dark when theming is in scope). A "Dev server not started" line is a hard block.
|
||||||
|
|
||||||
|
**Infra hygiene** (US-25)
|
||||||
|
- `scripts/open-pr.sh` — wraps `POST /api/v1/repos/{owner}/{repo}/pulls` on the Gitea REST API. Reads credentials from `~/.git-credentials` (same source as `git push` — no token in env). Detects host/owner/repo from `git remote get-url origin`. Validates args, prints PR URL.
|
||||||
|
- New Makefile target `open-pr TITLE="..." BODY=path/to/body.md [BASE=main]` wrapping the script. Team-lead PR creation is now automated.
|
||||||
|
- README `Make targets` table documents the new target.
|
||||||
|
|
||||||
|
**Acceptance tests** (Playwright, **158 passed**)
|
||||||
|
- 7 new spec files (one per testable US): `us17-ui-polish`, `us18-done-readonly-reopen`, `us19-engagement-auto-status`, `us20-matrix-fits-modal`, `us21-tactic-selection`, `us22-mitre-input-redesign`, `us23-dark-mode`.
|
||||||
|
- Coverage gaps from code-reviewer filled: `+N` suffix when techniques + tactics are mixed in the `SimulationList` MITRE column ; Tab focus-trap cycle in `MitreMatrixModal` ; dark-mode `localStorage` persistence across reload.
|
||||||
|
- AC-21.6 defect caught by the e2e (matrix returned slug `tactic_id`, PATCH expected TA-format) was bounced to backend-builder and resolved within the sprint.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 2026-05-27 — SPEC.md § Fonctionnement clarified: `done` is terminal, only Reopen (open to all 3 roles) returns to `review_required`. Engagement auto-flips `planned → active` on first simulation `in_progress`, never the reverse.
|
||||||
|
- 2026-05-27 — SPEC.md § Référentiel MITRE: added the sprint 4 `tactic_ids` (separated from `technique_ids`).
|
||||||
|
- 2026-05-27 — SPEC.md § UI/UX (new section): theming (light/dark/system, default = `system`), button convention (icon + ≤8-char label), modal focus trap V1.
|
||||||
|
- 2026-05-27 — SPEC.md § Workflows: `design-reviewer` agent inserted between `frontend-builder` and `code-reviewer`. PR creation now via `make open-pr`.
|
||||||
|
- 2026-05-27 — Carry-over commit: sprint 3 `§ Simulation` multi-techniques edit had been left uncommitted at sprint 3 close; applied at sprint 4 start so SPEC.md and the shipped code finally agree (lesson logged in tasks/lessons.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Sprint 3] — Multi-technique simulations + MITRE matrix modal (merged 2026-05-27)
|
||||||
|
|
||||||
|
### Added — Sprint 3 (Multi-technique simulations + MITRE matrix modal)
|
||||||
|
|
||||||
|
**Backend** (164 pytest passing)
|
||||||
|
- `Simulation.techniques` JSON column replaces the scalar `mitre_technique_id` / `mitre_technique_name` pair. Stored as `[{"id", "name"}]`; tactics are derived at serialize time from the MITRE service (snapshot pattern survives bundle updates).
|
||||||
|
- Alembic migration `0003_simulation_techniques_array.py` — reversible upgrade (backfill from scalars → drop scalars → enforce `NOT NULL` via `batch_alter_table`) and symmetric downgrade.
|
||||||
|
- `PATCH /api/simulations/<sid>` now accepts `{technique_ids: ["T1059", "T1059.001", ...]}` (flat list of T-IDs, parents and subs at the same level). Server validates each ID against the bundle (400 on unknown), deduplicates while preserving order, resolves names, and rejects SOC payloads (403). Returns 503 if the bundle isn't loaded.
|
||||||
|
- `GET /api/mitre/matrix` — new endpoint returning the full Enterprise tree `[{tactic_id, tactic_name, techniques: [{id, name, subtechniques: [{id, name}]}]}]`. Tactics in canonical order (Initial Access → Impact). Techniques sorted alphabetically per tactic; sub-techniques nested under their parent via dot-ID detection.
|
||||||
|
- `mitre_svc` extended with `get_tactics(id)`, `lookup_name(id)`, `get_matrix()`, and a `TACTIC_NAMES` constant fixing the cosmetic `"Command And Control"` → `"Command and Control"` (MITRE canonical capitalisation).
|
||||||
|
- `REDTEAM_FIELDS | {"technique_ids"}` SOC gate in `simulation_workflow.apply_patch` preserves the sprint 2 field-level RBAC pattern.
|
||||||
|
- Auto-transition `pending → in_progress` extended: triggers when `technique_ids` is non-empty (consistent with the "non-empty value" rule from sprint 2). Empty list does not trigger.
|
||||||
|
|
||||||
|
**Frontend** (86 vitest passing)
|
||||||
|
- `MitreTechniquesField` orchestrates multi-technique selection with **auto-save** — every add (Quick Search / matrix Apply) and every remove (× on tag chip) triggers a PATCH via `useUpdateSimulation`. Toast feedback on success/error; UI disabled during the in-flight PATCH; silent dedup if the user re-adds an already-present technique.
|
||||||
|
- `MitreTechniqueTag` — chip component (`bg-primary-soft text-primary-deep rounded-full`) with an × remove button.
|
||||||
|
- `MitreMatrixModal` — full-width modal, one column per tactic (220px fixed), horizontal scroll. Each technique top-level is clickable (toggle); a chevron expands/collapses sub-techniques rendered in cascade. Search filter (case-insensitive on id + name) auto-expands the parent of a matched sub-technique. Tactic header shows a "N selected" counter (parents + subs). Footer: Cancel + "Apply N technique(s)" (or "Clear all" when N=0 and there's an existing selection). Focus trap V1: search input auto-focus on open, Tab cycles within the modal, Escape and backdrop click both = Cancel.
|
||||||
|
- `MitreTechniquePicker` (sprint 2) clean-rewritten to a one-shot `onSelect({id, name})` signature; no incoming value props. The picker resets after each selection — the parent (`MitreTechniquesField`) handles append + dedup.
|
||||||
|
- `SimulationList` MITRE column displays `T1059 +2` when 3 techniques are selected (first id + remainder counter) or `—` when empty.
|
||||||
|
- `SimulationFormPage` — `MitreTechniquesField` replaces the old standalone `MitreTechniquePicker`. The technique state moves out of the RT form (independent auto-save cycle); the Save Red Team button still batches the other RT fields.
|
||||||
|
|
||||||
|
**Acceptance tests** (Playwright)
|
||||||
|
- 4 new spec files: `us13-multi-techniques.spec.ts`, `us14-techniques-tags.spec.ts`, `us15-mitre-matrix-modal.spec.ts`, `us16-regression-sprint2.spec.ts` — all ACs (AC-13.1 → AC-16.3) pass.
|
||||||
|
- Sprint 2 specs `us8-simulation-redteam-fill.spec.ts` and `us10-mitre-autocomplete.spec.ts` adapted to the new `techniques: []` array (no more scalar field assertions).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 2026-05-27 — SPEC.md § Simulation: "Type d'attaque MITRE correspondant" (singular) → "Types d'attaque MITRE correspondants (multi-techniques) — sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale. Sub-techniques supportées."
|
||||||
|
- 2026-05-27 — Breaking API change: `mitre_technique_id` and `mitre_technique_name` removed from the `Simulation` payload (both directions). Replaced by `techniques: [{id, name, tactics}]` in responses and `technique_ids: string[]` in PATCH requests. No backwards-compatibility shim (no external consumer at this stage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Sprint 2] — Simulations + MITRE ATT&CK (merged 2026-05-27)
|
||||||
|
|
||||||
|
### Added — Sprint 2 (Simulations + MITRE ATT&CK)
|
||||||
|
|
||||||
|
**Backend** (Flask + SQLAlchemy, 131 pytest passing)
|
||||||
|
- `Simulation` model with redteam-side (`name`, `mitre_technique_id`, `mitre_technique_name`, `description`, `commands`, `prerequisites`, `executed_at`, `execution_result`) and SOC-side (`log_source`, `logs`, `soc_comment`, `incident_number`) fields, plus `status` enum (`pending` / `in_progress` / `review_required` / `done`), FK to `Engagement` (cascade delete) and `User` (creator).
|
||||||
|
- Alembic migration `0002_add_simulations.py`.
|
||||||
|
- 7 new endpoints: `GET/POST /api/engagements/<eid>/simulations`, `GET/PATCH/DELETE /api/simulations/<sid>`, `POST /api/simulations/<sid>/transition`, `GET /api/mitre/techniques?q=`.
|
||||||
|
- `simulation_workflow` service: field-level RBAC (SOC blocked when status ∈ {pending, in_progress}; SOC rejected if payload contains a redteam field), state machine (only forward transitions, validated by role), and auto-transition `pending → in_progress` when admin/redteam saves any non-empty redteam field.
|
||||||
|
- `mitre` service: STIX 2.1 Enterprise bundle loaded at boot, indexed by T-id + name + tactic. Ranked search (`exact-id > prefix-id > substring-name`), max 20 results. Includes sub-techniques (`T1059.001`). Boot-safe: missing/corrupt bundle logs a warning and the endpoint returns 503 instead of crashing the app.
|
||||||
|
- `make update-mitre` is now a real target — fetches the upstream STIX bundle and restarts the container if running. Bundle is committed at `backend/data/mitre/enterprise-attack.json` (~46 MB) so `make build` stays self-contained.
|
||||||
|
- Upfront validation of `executed_at` (no partial mutation on parse failure).
|
||||||
|
|
||||||
|
**Frontend** (React + TanStack Query, 63 vitest passing)
|
||||||
|
- `SimulationList` component rendered inside `EngagementDetailPage` (replaces the Sprint 1 placeholder). Columns: name, MITRE id, status badge, executed_at. Row click → SPA navigation via `useNavigate` (no full reload).
|
||||||
|
- `SimulationFormPage` (`/engagements/:eid/simulations/new` and `/engagements/:eid/simulations/:sid/edit`): single role-aware page with two cards ("Red Team" / "SOC"). Redteam/admin can edit all fields; SOC sees the redteam card as read-only and the SOC card disabled (with an explanatory banner) until status reaches `review_required`. Footer surfaces context-appropriate transition buttons ("Marquer en revue" / "Clôturer") and a confirmation modal for delete.
|
||||||
|
- `MitreTechniquePicker`: debounced (200 ms) autocomplete input with keyboard navigation (↑↓ / Enter / Escape), listbox accessibility, and an inline 503 error path. Selection populates both `mitre_technique_id` and `mitre_technique_name`. A `hasHydratedFromProps` ref prevents the input from being wiped mid-stroke when the parent emits `onChange(null, null)`.
|
||||||
|
- `SimulationStatusBadge`: 4 variants mapped to DESIGN.md tokens (`bg-fog`, `bg-primary-soft`, `bg-bloom-coral`, `bg-storm-deep`). Sibling of the existing `StatusBadge` rather than a forked generic — the two badges share visual scaffolding but their enums diverge.
|
||||||
|
- `ConfirmDialog`: generic modal used by the delete flow.
|
||||||
|
- TanStack Query hooks: `useEngagementSimulations`, `useSimulation`, `useCreateSimulation`, `useUpdateSimulation`, `useDeleteSimulation`, `useTransitionSimulation`, `useMitreSearch`. Mutations invalidate both the simulation detail key and the engagement-scoped list key.
|
||||||
|
|
||||||
|
**Acceptance tests** (Playwright, **68/68 passing**)
|
||||||
|
- 6 new spec files (one per user story US-7 → US-12), 32 tests, all green.
|
||||||
|
- `us4-engagements.spec.ts` AC-4.9 assertion refreshed: the Sprint 1 placeholder text was correctly replaced by the new `SimulationList` (the test now asserts the new heading + "New simulation" link).
|
||||||
|
- Sprint 1 docker-hardcoded tests (`us1`, `us6`) now resolve thanks to the podman auto-detect added to those specs in the same sprint — full suite is green on both docker and podman hosts.
|
||||||
|
- E2e assertions translated to match the i18n cleanup (French → English) shipped in the post-QA fix.
|
||||||
|
|
||||||
|
**Post-QA fixes (2026-05-26)**
|
||||||
|
- All French labels in the frontend translated to English (convention: anglais partout). Affected: `SimulationList`, `SimulationFormPage`, `ConfirmDialog` strings.
|
||||||
|
- `UsersAdminPage` "Create account" form: grid alignment fixed — the password field's `hint="≥ 8 characters"` was pushing labels out of alignment with `items-end`. Now uses `items-start` + `self-end` button wrapper so labels sit at the same baseline and the Create button stays bottom-aligned.
|
||||||
|
- `SimulationFormPage` "Execution result" field: switched from single-line `TextInput` to multiline `TextArea` (5 rows).
|
||||||
|
- `SimulationFormPage` actions reorganised: single sticky action bar at the bottom of the page replaces the previous split between RT-card footer, SOC-card footer, and workflow div. Layout: Save Red Team · Save SOC · | · Mark for review · Close · (right-aligned) Delete.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 2026-05-26 — `make update-mitre` upgraded from no-op placeholder to a real `curl` + optional container restart (Sprint 1 marker resolved).
|
||||||
|
- 2026-05-26 — `EngagementDetailPage` no longer renders the "Simulations à venir au Sprint 2" placeholder; it embeds `<SimulationList>` instead.
|
||||||
|
- 2026-05-26 — Makefile now auto-detects the container engine (`CONTAINER_CMD ?= docker || podman`) instead of hard-coding `docker`. Override with `make <target> CONTAINER_CMD=podman` or `export CONTAINER_CMD=…`. The matching e2e tests (`us1`, `us6`) were updated to mirror the same detection so they pass on podman-only machines without an explicit `MIMIC_CONTAINER_CMD` export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Sprint 1] — Auth + CRUD Engagement (merged 2026-05-26)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial `SPEC.md` covering project scope, simulation model, workflow, stack, and agent team.
|
|
||||||
- Technical decisions section in `SPEC.md`: 3-role auth (admin/redteam/soc), JWT Bearer, single-container Flask+React, local MITRE STIX bundle, minimal Engagement model, admin bootstrap via Makefile target.
|
**Backend** (Flask + SQLAlchemy + SQLite, 63 pytest passing)
|
||||||
- Sub-agent definitions under `.claude/agents/` for backend-builder, frontend-builder, spec-reviewer (project override of the built-in, covers plan-vs-spec and code-vs-spec), code-reviewer, test-verifier, devil-advocate.
|
- `User` model with `admin / redteam / soc` enum, argon2 password hashing.
|
||||||
- Project tracking scaffold: `tasks/todo.md`, `tasks/lessons.md`, `CHANGELOG.md`, `.gitignore`.
|
- `Engagement` model with `planned / active / closed` status, FK to creator user.
|
||||||
|
- JWT Bearer auth (`PyJWT`, HS256, 60-min TTL), `@login_required` and `@role_required(*roles)` decorators.
|
||||||
|
- 13 API endpoints: `/api/auth/{login,logout,me}`, `/api/users` CRUD (admin-only with last-admin protection), `/api/engagements` CRUD (RBAC per role), `/api/health`.
|
||||||
|
- Alembic migration applied at container boot by `docker/entrypoint.sh`.
|
||||||
|
- `flask create-admin` CLI with duplicate-username and short-password validation.
|
||||||
|
- Engagement serializer returns `created_by={id, username}` (not bare User object).
|
||||||
|
- SPA fallback returns JSON 404 for unknown `/api/*` paths (no HTML leakage).
|
||||||
|
|
||||||
|
**Frontend** (React + Vite + TailwindCSS + TanStack Query, 20 vitest passing)
|
||||||
|
- Inter font bundled locally via `@fontsource-variable/inter` (no CDN at runtime).
|
||||||
|
- Tailwind config maps the `DESIGN.md` token system (palette, typography, spacing, radii).
|
||||||
|
- Pages: `LoginPage`, `EngagementsListPage`, `EngagementFormPage` (new+edit), `EngagementDetailPage` (Sprint 2 placeholder), `UsersAdminPage`.
|
||||||
|
- Components: `Layout`, `ProtectedRoute` (auth + role gate), `StatusBadge`, `FormField`, `LoadingState`/`ErrorState`/`EmptyState`, `Toast` + provider.
|
||||||
|
- Axios client with Bearer interceptor; 401 → token purge + redirect `/login` + "Session expirée" toast (AC-2.6); 403 → "Accès refusé" toast (AC-3.7).
|
||||||
|
- TanStack Query hooks: `useAuth`, `useEngagements`, `useUsers`, `useToast`.
|
||||||
|
|
||||||
|
**Deployment**
|
||||||
|
- Single-container `docker/Dockerfile` (multistage: `node:20-alpine` → `python:3.12-slim`).
|
||||||
|
- `docker/entrypoint.sh` running `flask db upgrade && flask run`.
|
||||||
|
- `Makefile` with `build`, `start`, `stop`, `restart`, `update`, `logs`, `create-admin`, `update-mitre` (no-op placeholder for Sprint 2), `test-backend`, `test-frontend`, `test-e2e`, `clean`.
|
||||||
|
- `.env.example` documenting `MIMIC_JWT_SECRET`, `MIMIC_DB_PATH`, `MIMIC_PORT`.
|
||||||
|
- SQLite persisted at `/data/mimic.sqlite`, volume `mimic-data` survives `make restart`.
|
||||||
|
|
||||||
|
**Acceptance tests** (Playwright, 36 specs, all 27 ACs covered)
|
||||||
|
- `e2e/` scaffold: `playwright.config.ts`, `fixtures/{auth,api}.ts`, 6 spec files (one per user story).
|
||||||
|
- Suite is portable via `MIMIC_CONTAINER_CMD` / `MIMIC_BASE_URL` env vars (works with `docker` or `podman`).
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- `README.md` with quick-start, architecture overview, project layout, make target reference, and dev workflow.
|
||||||
|
- `pyrightconfig.json` at repo root pointing the Python LSP to `backend/.venv` and adding the worktree root to `extraPaths` for absolute imports.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- 2026-05-26 — `admin` role widened in `SPEC.md` § Décisions techniques. The initial draft restricted admin to user-management only; after the Sprint 1 plan review surfaced the operational pain (admin would need a second `redteam` account just to manage engagements), the user decided to make admin a super-user that cumulates redteam rights on engagements/simulations.
|
- 2026-05-26 — `admin` role widened in `SPEC.md` § Décisions techniques. The initial draft restricted admin to user-management only; after the Sprint 1 plan review surfaced the operational pain (admin would need a second `redteam` account just to manage engagements), the user decided to make admin a super-user that cumulates redteam rights on engagements/simulations.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- _none_
|
- _none_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Sprint 0] — Bootstrap (merged 2026-05-26)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial `SPEC.md` covering project scope, simulation model, workflow, stack, and agent team.
|
||||||
|
- Technical decisions section in `SPEC.md`: 3-role auth (admin/redteam/soc), JWT Bearer, single-container Flask+React, local MITRE STIX bundle, minimal Engagement model, admin bootstrap via Makefile target.
|
||||||
|
- Sub-agent definitions under `.claude/agents/` for backend-builder, frontend-builder, spec-reviewer (project override of the built-in, covers plan-vs-spec and code-vs-spec), code-reviewer, test-verifier, devil-advocate.
|
||||||
|
- Project tracking scaffold: `tasks/todo.md`, `tasks/lessons.md`, `CHANGELOG.md`, `.gitignore`.
|
||||||
|
|||||||
438
DESIGN.md
438
DESIGN.md
@@ -1,345 +1,263 @@
|
|||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
HP reads like a long-running consumer-electronics catalog crossed with an enterprise-software product page. The whole system sits on **pure white** (`{colors.canvas}` — `#ffffff`) with thin gray panels (`{colors.cloud}` / `{colors.fog}`) for alternating section bands. There is one chromatic action color — **HP Electric Blue** (`{colors.primary}` — `#024ad8`) — and one ink color (`{colors.ink}` — `#1a1a1a`); together they do ninety percent of the work. Type is a single family across every surface: **Forma DJR Micro**, HP's bespoke geometric grotesque, set at weight 500 for headlines and 400 for body — clean, neutral, slightly mechanical.
|
Mimic is a **BAS (Breach and Attack Simulation) purple-team console** — not a product catalog, not a marketing page. The aesthetic is **Bloomberg Terminal / SOC dashboard**: dense, angular, semantic-color-driven, zero ornament. Every surface decision reinforces operational trust: data is primary, chrome is invisible.
|
||||||
|
|
||||||
The signature gesture is **angular blue chevrons** — sharp 0-radius slashes derived from the HP wordmark's pair of parallel slashes — that anchor the homepage hero, the laptop-page hero, and the printer pricing page. They appear on the left and right edges of the primary banner card, layered behind product photography. Outside those decorative slashes, every other surface is rectilinear with **soft 8–16px corners** on cards and a 4px corner on buttons.
|
The system sits on a **pale-tinted canvas** (light: `#f3f5f8`) / **dark slab** (dark) with one chromatic action color — **Electric Blue** (`{colors.primary}` — `#024ad8`) — and semantic status signals (`success`, `warn`, `bloom-deep`). Inter handles body/headers/labels. JetBrains Mono carries data: IDs, ISO dates, commands, execution output, MITRE technique codes, metrics — anything that must be read at a glance without typographic distraction.
|
||||||
|
|
||||||
The system breaks into three voice modes: a **white commercial body** for product browsing (cards, category icons, pricing tiers); a **dark navy slab** (`{colors.ink}` near-black) for testimonial bands, the closing "How can we help?" footer-prelude, and the page footer; and a **light fog band** (`{colors.cloud}` / `{colors.fog}`) for utility sections like comparison strips and FAQ accordions. The blue accent appears only on filled CTAs, link text, the chevron decorations, and the active price-stamp on a featured tier — never as a section background.
|
**No rounded corners on containers.** No shadows on interactive surfaces. No transitions. Hover is instantaneous. Focus rings are sharp. This is a tool, not a storefront.
|
||||||
|
|
||||||
**Key Characteristics:**
|
**Key Characteristics:**
|
||||||
- Pure white canvas (`{colors.canvas}`) with deep ink (`{colors.ink}`) running every body surface; light fog bands (`{colors.cloud}`, `{colors.fog}`) alternate for section rhythm
|
- Angular surfaces everywhere (`border-radius: 0`) — exception: status pills (`rounded-pill`) and avatars
|
||||||
- HP Electric Blue (`{colors.primary}`) is the lone CTA fill and link color; it appears at most twice per viewport
|
- Zero transitions / zero animations — state changes are immediate
|
||||||
- Bespoke Forma DJR Micro across every surface — display, body, button, caption — at weights 400 / 500 / 600 / 700
|
- Semantic color signals: primary blue = action, success green = confirmed, warn amber = pending/caution, bloom-deep = destructive/error
|
||||||
- Cards round at `{rounded.xl}` (16px) for product/pricing tiles; buttons sit at `{rounded.md}` (4px) with capitalize labels
|
- Monospace data discipline: `font-mono` ONLY for IDs, dates, codes, output, metrics — never for headers, labels, prose
|
||||||
- Geometric blue chevrons (`{colors.primary}` rectangles cut at 45°) frame hero photography and reinforce the wordmark
|
- Dense spacing: section gap 48px (not 80px), card padding 12–16px on dense surfaces
|
||||||
- Dark-navy slabs (`{colors.ink}`) close every page rhythm — testimonial bands, "how can we help?" prelude, and the footer
|
- Light + dark modes both supported; dark mode is the primary operational mode for SOC analysts
|
||||||
- Section rhythm: utility-strip → top nav → white body → cloud-band → ink slab → cloud-band → ink footer
|
|
||||||
|
---
|
||||||
|
|
||||||
## Colors
|
## Colors
|
||||||
|
|
||||||
> **No Interaction sub-section.** Hover colors are silently filtered. Allowed sub-sections: Brand & Accent, Surface, Text, Semantic.
|
|
||||||
|
|
||||||
### Brand & Accent
|
### Brand & Accent
|
||||||
- **HP Electric Blue** (`{colors.primary}` — `#024ad8`): the system's lone signal — primary CTA fill, link color, chevron-decoration fill, active sub-nav indicator. Reserved.
|
- **Electric Blue** (`{colors.primary}` — `#024ad8`): primary CTA fill, active nav indicator, focus ring. Never used as a section background.
|
||||||
- **Bright Blue** (`{colors.primary-bright}` — `#296ef9`): a slightly lighter variant used inside dark slabs (testimonial-card buttons, dark-band CTA links) where the deeper blue would muddy.
|
- **Bright Blue** (`{colors.primary-bright}` — `#296ef9`): CTA on dark slab surfaces where `#024ad8` muddles.
|
||||||
- **Deep Navy** (`{colors.primary-deep}` — `#0e3191`): pressed state for the primary CTA and the visited-link color.
|
- **Deep Navy** (`{colors.primary-deep}` — `#0e3191`): pressed state for primary CTA.
|
||||||
- **Soft Blue** (`{colors.primary-soft}` — `#c9e0fc`): pale-blue surface used inside customer-story cards and selection chips.
|
- **Soft Blue** (`{colors.primary-soft}` — `#c9e0fc`): selection highlight, chip background on light surfaces.
|
||||||
|
|
||||||
### Surface
|
### Surface
|
||||||
- **Canvas** (`{colors.canvas}` — `#ffffff`): the universal page background. White, full opacity.
|
- **Canvas** (`{colors.canvas}` — `#f3f5f8` light / `#111827` dark): universal page background. In light mode, canvas is tinted while paper stays pure white so cards lift without shadow or radius, preserving brutalism.
|
||||||
- **Paper** (`{colors.paper}` — `#ffffff`): card surfaces — same white as canvas, with hairline borders or shadows providing the lift.
|
- **Paper** (`{colors.paper}` — `#ffffff` light / `#1f2937` dark): card and panel surfaces.
|
||||||
- **Cloud** (`{colors.cloud}` — `#f7f7f7`): the lightest gray section band, used for alternating-row backgrounds and product-feature card groups.
|
- **Cloud** (`{colors.cloud}` — `#f7f7f7` light / `#1f2937` dark): alternating section band, table row zebra.
|
||||||
- **Fog** (`{colors.fog}` — `#e8e8e8`): a slightly darker gray surface band, used for FAQ outer panels and the "Trending laptops" header strip.
|
- **Fog** (`{colors.fog}` — `#e8e8e8` light / `#374151` dark): secondary section band, input background on dense panels.
|
||||||
- **Steel** (`{colors.steel}` — `#c2c2c2`): hairline border used on outlined elements with stronger emphasis (focus states, active filter).
|
- **Steel** (`{colors.steel}` — `#c2c2c2` light / `#4b5563` dark): hairline borders, disabled states.
|
||||||
- **Bloom Coral / Bloom Rose** (`{colors.bloom-coral}` / `{colors.bloom-rose}` — `#ff5050`, `#f9d4d2`): the "Get 25% off" sale-tag chip + soft pink lifestyle accent on the sale hero.
|
- **Hairline** (`{colors.hairline}`): 1px divider between cells, panels, table rows.
|
||||||
- **Storm Mist / Sea / Deep** (`{colors.storm-mist}`, `{colors.storm-sea}`, `{colors.storm-deep}` — `#8ebdce`, `#7fadbe`, `#356373`): the teal-storm tones reserved for the printer-plan illustration backdrop and supporting infographic accents.
|
- **Slab** (`{colors.slab}` — `#111827`): fixed-dark surface — utility strip, footer, dark bands. Does NOT invert in dark mode.
|
||||||
|
|
||||||
### Text
|
### Text
|
||||||
- **Ink** (`{colors.ink}` — `#1a1a1a`): the universal text color on white surfaces — headlines, body, button labels, navigation.
|
- **Ink** (`{colors.ink}` — `#1a1a1a` light / `#f9fafb` dark): universal body text color.
|
||||||
- **Ink Deep** (`{colors.ink-deep}` — `#000000`): pure black used for the wordmark and 1px hairline strokes around badge outlines.
|
- **Ink Deep** (`{colors.ink-deep}` — `#000000` light / `#ffffff` dark): maximum contrast for headings.
|
||||||
- **Ink Soft** (`{colors.ink-soft}` — `#292929`): an alternate near-black used inside dark-navy slabs as a subtle textural shift.
|
- **Ink Soft** (`{colors.ink-soft}` — `#292929` light / `#e5e7eb` dark): muted body, secondary labels.
|
||||||
- **On Ink** (`{colors.on-ink}` — `#ffffff`): pure white used for headline and body text on every dark-navy slab.
|
- **On Ink** (`{colors.ink-on}` — `#ffffff` light / `#111827` dark): text on slab surfaces.
|
||||||
- **Charcoal** (`{colors.charcoal}` — `#3d3d3d`): muted body color on white surfaces — secondary descriptions, fine-print disclaimers.
|
- **Charcoal** (`{colors.charcoal}` — `#3d3d3d` light / `#d1d5db` dark): secondary descriptions, captions.
|
||||||
- **Graphite** (`{colors.graphite}` — `#636363`): smaller-print color, used for legal lines and timestamp metadata.
|
- **Graphite** (`{colors.graphite}` — `#636363` light / `#9ca3af` dark): timestamps, metadata, footnotes.
|
||||||
|
|
||||||
### Semantic
|
### Semantic (Status Signals)
|
||||||
- **Bloom Deep** (`{colors.bloom-deep}` — `#b3262b`) + **Bloom Wine** (`{colors.bloom-wine}` — `#5a1313`): error and discount-emphasis colors. The deep brick reads as "sale" or "destructive" depending on placement.
|
- **Success** (`{colors.success}` — `#16a34a` light / `#22c55e` dark): confirmed detections, done status, positive metrics. Background softened to `{colors.success-soft}` (`#dcfce7` light / `#14532d` dark) for badge fills.
|
||||||
- **Storm Deep** (`{colors.storm-deep}` — `#356373`): used as a neutral status accent (e.g., printer-plan tier "Versatile" tier color).
|
- **Warn** (`{colors.warn}` — `#d97706` light / `#f59e0b` dark): pending review, caution states, partial detection. Background softened to `{colors.warn-soft}` (`#fef3c7` light / `#78350f` dark) for badge fills.
|
||||||
|
- **Bloom Deep** (`{colors.bloom-deep}` — `#b3262b`): error, destructive action, failed detection. Bloom family covers red-spectrum signals.
|
||||||
|
- **Bloom Wine** (`{colors.bloom-wine}` — `#5a1313`): darkened destructive emphasis.
|
||||||
|
- **Bloom Coral** (`{colors.bloom-coral}` — `#ff5050`): alert highlight on dark surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Typography
|
## Typography
|
||||||
|
|
||||||
### Font Family
|
### Font Families
|
||||||
|
- **Inter Variable** (`{fontFamily.sans}`): body text, labels, headers, navigation, form fields. The universal surface font.
|
||||||
|
- **JetBrains Mono Variable** (`{fontFamily.mono}`): data cells ONLY — engagement IDs, simulation IDs, ISO 8601 dates, execution commands, terminal output, MITRE technique codes (T1059.001), numeric metrics, usernames-as-identifiers. Never used for prose, headings, or labels.
|
||||||
|
|
||||||
The voice is **single-family**: Forma DJR Micro (HP's bespoke geometric grotesque, fallback Arial) across every surface — display, body, button, caption. Forma DJR Micro is a wide, slightly rounded grotesque designed at small optical sizes to stay legible at UI-chrome scale. HP runs it at weight 400 for body, 500 for display headlines, 600/700 for emphasis and button labels.
|
Both fonts are bundled locally via `@fontsource-variable/inter` and `@fontsource-variable/jetbrains-mono`. Zero CDN loading at runtime.
|
||||||
|
|
||||||
The 16/14/12-px caption tier carries the catalog metadata — model numbers, spec rows, fine print — at weight 400 with a 1.4–1.5 line-height. Button labels lift to weight 600/700 with positive 0.5–1.1px letter-spacing and uppercase transform — the only place the system tracks letters.
|
|
||||||
|
|
||||||
### Hierarchy
|
### Hierarchy
|
||||||
|
|
||||||
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
| Token | Size | Weight | Line Height | Use |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `{typography.display-xxl}` | 72px | 500 | 1.0 | 0 | Hero headline (homepage, laptop hub) |
|
| `{typography.display-xxl}` | 40px | 500 | 1.1 | Page-level hero (rare — dashboard title) |
|
||||||
| `{typography.display-xl}` | 56px | 500 | 1.0 | 0 | Section headlines on landing pages |
|
| `{typography.display-xl}` | 32px | 500 | 1.1 | Section title (engagement name, modal header) |
|
||||||
| `{typography.display-lg}` | 44px | 500 | 1.0 | 0 | Sub-section headlines on shop pages |
|
| `{typography.display-lg}` | 28px | 500 | 1.1 | Sub-section header, panel title |
|
||||||
| `{typography.display-md}` | 32px | 500 | 1.0 | 0 | Promo strip headlines, FAQ section headers |
|
| `{typography.display-md}` | 24px | 500 | 1.1 | Card title, table header group |
|
||||||
| `{typography.display-sm}` | 24px | 500 | 1.17 | 0 | Card titles, pricing-tier names |
|
| `{typography.display-sm}` | 20px | 500 | 1.1 | Item title, form section header |
|
||||||
| `{typography.display-xs}` | 20px | 500 | 1.0 | 0 | Inline list headers, accordion labels |
|
| `{typography.display-xs}` | 16px | 600 | 1.1 | Compact section header, sidebar label |
|
||||||
| `{typography.body-lg}` | 18px | 400 | 1.33 | 0 | Lead paragraphs |
|
| `{typography.body-lg}` | 18px | 400 | 1.4 | Lead paragraph |
|
||||||
| `{typography.body-md}` | 16px | 400 | 1.38 | 0 | Default body |
|
| `{typography.body-md}` | 16px | 400 | 1.4 | Default body, form labels |
|
||||||
| `{typography.body-emphasis}` | 16px | 500 | 1.38 | 0 | Bolded run-in copy |
|
| `{typography.body-emphasis}` | 16px | 500 | 1.4 | Bolded inline copy, table column headers |
|
||||||
| `{typography.caption-md}` | 14px | 400 | 1.5 | 0 | Specs, metadata, captions |
|
| `{typography.caption-md}` | 14px | 400 | 1.5 | Secondary metadata, captions, table cells (non-data) |
|
||||||
| `{typography.caption-bold}` | 14px | 700 | 1.3 | 0 | Sale tags, in-card highlights |
|
| `{typography.caption-bold}` | 14px | 700 | 1.3 | Status labels, tag text |
|
||||||
| `{typography.caption-sm}` | 12px | 400 | 1.33 | 0 | Footnotes, legal lines |
|
| `{typography.caption-sm}` | 12px | 400 | 1.33 | Footnotes, legal fine-print |
|
||||||
| `{typography.link-md}` | 16px | 500 | 1.38 | 0 | Inline link emphasis |
|
| `{typography.button-md}` | 14px | 600 | 1.4 | Button labels (uppercase) |
|
||||||
| `{typography.button-md}` | 14px | 600 | 1.4 | 0.7px | Primary/secondary button labels (uppercase) |
|
| `{typography.button-sm}` | 12.6px | 700 | 1.0 | Compact button in tight cells |
|
||||||
| `{typography.button-sm}` | 12.6px | 700 | 1.0 | 0.126px | Compact button labels in tight cells |
|
|
||||||
| `{typography.price-md}` | 24px | 500 | 1.17 | 0 | Tier and product price stamps |
|
|
||||||
|
|
||||||
### Principles
|
### Monospace Discipline
|
||||||
|
|
||||||
The typographic decision worth flagging: HP runs **weight 500 for every display size**, including the largest 72px hero headline. Most editorial systems jump to 600/700 at hero scale; HP doesn't. The result feels open and approachable rather than commanding — appropriate for a brand that sells across consumer, SMB, and enterprise audiences in the same catalog.
|
JetBrains Mono carries data that must be scanned without typographic noise. Apply `font-mono` (Tailwind) or `font-family: var(--font-mono)` to:
|
||||||
|
|
||||||
Forma DJR Micro's rounded-grotesque shapes do most of the warmth. There's no italic in the system except inside legal disclaimers; emphasis is carried by weight (500 → body-emphasis, 700 → caption-bold) instead.
|
- Engagement IDs, simulation IDs (any UUID or integer identifier)
|
||||||
|
- ISO 8601 dates and timestamps (`2024-06-09T14:32:00`)
|
||||||
|
- MITRE technique codes (`T1059.001`, `TA0002`)
|
||||||
|
- Execution commands and command fields
|
||||||
|
- Terminal / execution output
|
||||||
|
- Numeric metrics (counts, durations)
|
||||||
|
- Usernames displayed as record identifiers (not greeting text)
|
||||||
|
|
||||||
### Note on Font Substitutes
|
Never apply `font-mono` to: page titles, section headers, body prose, navigation, form labels, button text.
|
||||||
|
|
||||||
Forma DJR Micro is proprietary (Commercial Type / Mark Caneso). Closest open-source substitutes:
|
---
|
||||||
- **Inter** at weights 400 / 500 / 600 / 700 — slightly narrower than Forma DJR Micro; bump font-size by ~3% to compensate
|
|
||||||
- **Manrope** at weights 400 / 500 / 600 / 700 — closer in proportion, gentler curves; use directly with no metric adjustment
|
|
||||||
- **Roboto** at weights 400 / 500 / 700 — flatter character; use as last-resort fallback
|
|
||||||
|
|
||||||
When swapping, set body line-height to 1.4 and display line-height to 1.0 explicitly — the Forma DJR Micro line-height numbers are tight, and most substitutes default looser.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
### Spacing System
|
### Spacing System
|
||||||
|
|
||||||
- **Base unit**: 8px. Smaller half-step at 4px. The scale is gentle — most card padding lands at 16px or 24px; section gap at 80px.
|
- **Base unit**: 8px. Half-step 4px.
|
||||||
- **Tokens (front matter)**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 80px
|
- **Tokens**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 48px
|
||||||
- **Section padding**: `{spacing.section}` (80px) vertical between major bands on desktop; collapses to ~48px on mobile.
|
- **Section padding**: 48px vertical between major bands (desktop); ~24px on mobile.
|
||||||
- **Card internal padding**: `{spacing.xl}` (24px) for product cards; `{spacing.xxl}` (32px) for promo strips and feature cards; `{spacing.md}` (16px) for compact article tiles.
|
- **Card padding**: 16px on dense panels; 24px on standard cards.
|
||||||
- **Gutter**: `{spacing.xl}` (24px) between grid columns at desktop; `{spacing.md}` (16px) on tablet/mobile.
|
- **Gutter**: 24px between grid columns on desktop; 16px on tablet/mobile.
|
||||||
|
|
||||||
The 80px section gap is the universal rhythm constant — it appears between every major homepage band, between the hero and the comparison table on the printer-plan page, and between feature rows on the laptop-shop page.
|
|
||||||
|
|
||||||
### Grid & Container
|
### Grid & Container
|
||||||
|
|
||||||
- **Desktop max-width**: 1366px content container with full-bleed-on-canvas section backgrounds.
|
- **Max-width**: 1366px content container, full-bleed on canvas.
|
||||||
- **Hero**: a single full-width photo card (homepage and laptop-hub hero) with the headline overlay positioned upper-left or upper-right.
|
- **List pages**: full-width table with 1px hairline borders, no card wrap.
|
||||||
- **Product family grid**: 4 columns at >1200px, 3 at 1024–1199px, 2 at 768–1023px, 1 below 768px.
|
- **Detail pages**: 2-column split (60/40) on desktop, stacked on mobile.
|
||||||
- **Pricing tiers**: 4 columns at >1024px, 2x2 grid at 768–1023px, single-column accordion below 768px.
|
- **Form pages**: single-column centered at 640px max-width.
|
||||||
- **Footer**: 5-column link grid at >1024px, collapsing to 2-column then accordion on mobile.
|
|
||||||
|
|
||||||
### Whitespace Philosophy
|
### Whitespace Philosophy
|
||||||
|
|
||||||
Whitespace is **commercial-clean** — generous around hero photography, tight around catalog spec rows. Product cards leave breathing room above and below the photo (≥32px) so the laptop or printer reads as a hero shot rather than a thumbnail. The fine-print disclaimer regions (legal, footnote rows) tighten line-height to 1.3 and shrink type to 11–12px so the bulk of fine print stays compact.
|
Dense but not cramped. Table rows at 44px height (WCAG touch target). Card padding minimum 12px. No whitespace used as decoration — every gap serves alignment or grouping. Editorial breathing room (80px sections, hero-scale photography) does not apply here.
|
||||||
|
|
||||||
## Elevation & Depth
|
---
|
||||||
|
|
||||||
| Level | Treatment | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| 0 — Flat | No border, no shadow. | Section bands (white, cloud, fog), full-bleed photo heroes |
|
|
||||||
| 1 — Hairline | 1px solid `{colors.hairline}` (`#e8e8e8`) border, no shadow. | Outlined buttons, comparison-table cells, FAQ accordion outers |
|
|
||||||
| 2 — Soft Lift | `0 2px 8px rgba(26, 26, 26, 0.08)`. | Product cards, pricing-tier columns, customer-story tiles |
|
|
||||||
| 3 — Floating Modal | `0 8px 24px rgba(26, 26, 26, 0.12)`. | Add-to-cart drawer, mobile-nav sheet, image zoom modal |
|
|
||||||
|
|
||||||
The system is mostly flat — depth is communicated by **color contrast** (cloud-band vs. white card on the same band) rather than shadow elevation. The Soft Lift level is the workhorse for the catalog — every product tile and pricing column gets it; nothing else does. Modal-floating is rare and reserved for transient overlays.
|
|
||||||
|
|
||||||
### Decorative Depth
|
|
||||||
|
|
||||||
The system's most distinctive depth gesture is the **HP blue chevron pair** — two angular `{colors.primary}` slashes (no radius, no shadow) that sit on the left and right of the homepage hero card and the laptop-shop hero. They're not decorative noise; they're a literal echo of the HP wordmark's two parallel slashes, scaled up to architectural size. Treat them as a brand artifact, not a generic geometric flourish.
|
|
||||||
|
|
||||||
Photography on the homepage and laptop-shop pages frames product imagery inside `{rounded.xl}` (16px) containers with a soft 1px hairline. Lifestyle photography (testimonials, "How HP works for X") sits full-bleed inside dark-navy slabs without rounding.
|
|
||||||
|
|
||||||
## Shapes
|
## Shapes
|
||||||
|
|
||||||
### Border Radius Scale
|
### Border Radius
|
||||||
|
|
||||||
| Token | Value | Use |
|
| Token | Value | Use |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `{rounded.none}` | 0px | Hero chevron decorations, full-bleed photo heroes, marquee strips |
|
| `{rounded.none}` | 0px | **Default for everything**: buttons, cards, modals, inputs, dropdowns, panels, tags, tables |
|
||||||
| `{rounded.xs}` | 2px | Secondary chip backgrounds, sale-tag pills |
|
| `{rounded.pill}` | 9999px | **Status pills only** (`StatusBadge`, `SimulationStatusBadge`) and circular avatars |
|
||||||
| `{rounded.sm}` | 3px | Default secondary CTA radius (small touch zones) |
|
|
||||||
| `{rounded.md}` | 4px | Primary buttons, secondary buttons, text inputs |
|
|
||||||
| `{rounded.lg}` | 8px | Badge pills, category-icon cards, FAQ row containers |
|
|
||||||
| `{rounded.xl}` | 16px | Product cards, pricing tiers, customer-story tiles, photo frames |
|
|
||||||
| `{rounded.pill}` | 9999px | Category sub-nav tabs, search-pill input, filter chips |
|
|
||||||
|
|
||||||
The system maintains a clear two-tier philosophy: **buttons stay sharp** (4px, almost rectilinear) while **cards and photo frames stay soft** (16px). This split is the visual signature — sharp interactive elements against softer container surfaces.
|
No intermediate radius values (`xs`, `sm`, `md`, `lg`, `xl`) are used on visible surfaces. The brutalist rule: if it's not a status pill or avatar, `border-radius: 0`.
|
||||||
|
|
||||||
### Photography Geometry
|
---
|
||||||
|
|
||||||
Hero photography sits in `{rounded.xl}` (16px) frames with no border. Product family thumbnails inside the laptop-grid are 1:1 (square) on a `{colors.canvas}` background, padded so the laptop is shown at ~70% of the frame. Customer-story photography uses 16:9 inside the same `{rounded.xl}` frame. There are no full-bleed circular avatars; testimonial avatars are 4px-rounded squares.
|
## Elevation
|
||||||
|
|
||||||
|
No shadows on interactive surfaces. Elevation is communicated by **border contrast** (1px hairline on paper over canvas) not box-shadow.
|
||||||
|
|
||||||
|
| Level | Treatment | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 — Flat | No border, no shadow | Page background, full-bleed bands |
|
||||||
|
| 1 — Hairline | `1px solid {colors.hairline}` | Cards, panels, table cells, input borders |
|
||||||
|
| 2 — Modal overlay | `rgba(0,0,0,0.6)` backdrop | Modal dialogs — backdrop only, frame stays hairline |
|
||||||
|
|
||||||
|
Shadows (`box-shadow`) are not used anywhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
> **No hover states documented.** Every component spec below documents only Default and Active/Pressed states. Variants live as separate front-matter entries.
|
> Every component spec below: border-radius **0** unless noted. No `transition-*` on any interactive surface. Hover is instantaneous.
|
||||||
|
|
||||||
### Buttons
|
### Buttons
|
||||||
|
|
||||||
**`button-primary`** — the lone HP Electric Blue CTA
|
**`.btn-primary`** — Electric Blue CTA
|
||||||
- Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.button-md}` (uppercase, 0.7px tracking), padding `{spacing.sm} {spacing.xl}` (12 × 24), height 44px, rounded `{rounded.md}`
|
- Background `{colors.primary}`, text white, uppercase, 14px/600, `h-11`, padding `12px 24px`, `rounded-none`
|
||||||
- Pressed state `button-primary-pressed` — background `{colors.primary-deep}`, same text
|
- Hover: background `{colors.primary-deep}` — no transition
|
||||||
- Disabled state `button-primary-disabled` — background `{colors.steel}`, white text
|
- Disabled: background `{colors.steel}`, cursor not-allowed
|
||||||
- Used for: "Buy now", "Shop now", "Get a printer", primary form submit
|
|
||||||
|
|
||||||
**`button-ink`** — black filled CTA
|
**`.btn-ink`** — Fixed-dark filled CTA
|
||||||
- Background `{colors.ink}`, text `{colors.on-primary}`, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}`, type `{typography.button-md}`
|
- Background `{colors.slab}`, text `{colors.slab-text}`, uppercase, same metrics as btn-primary
|
||||||
- Used for: "Buy now" on dark photo overlays, secondary primary actions where the blue would clash with imagery
|
- Used on dark band surfaces where blue would clash
|
||||||
|
|
||||||
**`button-outline`** — blue-text outlined CTA
|
**`.btn-outline`** — Outlined blue CTA
|
||||||
- Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}`
|
- Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, `rounded-none`
|
||||||
- Used for: "Compare", "Customize", "Learn more" — secondary actions on white surfaces
|
- Hover: background `{colors.primary-soft}`
|
||||||
|
|
||||||
**`button-outline-ink`** — black-text outlined CTA
|
**`.btn-outline-ink`** — Outlined neutral CTA
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}`
|
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, `rounded-none`
|
||||||
- Used for: "View" buttons inside product family card grids — neutral against the blue primary
|
- Hover: background `{colors.cloud}`
|
||||||
|
|
||||||
**`button-text-link`** — inline blue link with underline
|
**`.btn-text-link`** — Inline text link
|
||||||
- Background `{colors.canvas}`, text `{colors.primary}`, type `{typography.link-md}`, padding `{spacing.xxs} 0`
|
- Text `{colors.primary}`, no background, no border
|
||||||
- Used for: "See details", "Read more" inside cards and disclaimer rows
|
- Hover: underline, no color change
|
||||||
|
|
||||||
### Cards & Containers
|
|
||||||
|
|
||||||
**`card-product`** — the workhorse product tile
|
|
||||||
- Background `{colors.canvas}`, rounded `{rounded.xl}` (16px), padding `{spacing.xl}` (24px), Soft Lift shadow
|
|
||||||
- Layout: hero photo (1:1 ratio) on top, title in `{typography.display-xs}`, spec rows in `{typography.caption-md}`, price in `{typography.price-md}`, CTA pinned to bottom
|
|
||||||
- Used for: laptop catalog cards, desktop catalog cards
|
|
||||||
|
|
||||||
**`card-product-feature`** — full-row feature card with photo + copy
|
|
||||||
- Background `{colors.cloud}`, rounded `{rounded.xl}`, padding `{spacing.xxl}` (32px)
|
|
||||||
- Layout: photo on the left (50% width), copy on the right with section eyebrow + title + body + CTA pair
|
|
||||||
- Used for: "Trending laptops" feature rows, "Shop these must haves"
|
|
||||||
|
|
||||||
**`card-pricing-tier`** + **`card-pricing-tier-featured`**
|
|
||||||
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xl}`, Soft Lift shadow
|
|
||||||
- Tier name in `{typography.display-sm}`, monthly price in `{typography.display-md}` with `{typography.caption-md}` cadence, page count caption, full feature list, primary CTA
|
|
||||||
- Featured tier carries `{colors.primary}` text accent on the price-stamp + a `{colors.primary}` thin top border instead of a colored card background — never inverted to dark
|
|
||||||
|
|
||||||
**`card-customer-story`** — the three-up testimonial tile
|
|
||||||
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.md}` (16px), Soft Lift shadow
|
|
||||||
- 16:9 photo at top in `{rounded.xl}` frame, quote excerpt in `{typography.body-md}`, attribution row at the bottom
|
|
||||||
- Used in the "See what our customers say" homepage section
|
|
||||||
|
|
||||||
**`card-article-tile`** — the four-up "Latest from HP" tile
|
|
||||||
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.md}`, Soft Lift shadow
|
|
||||||
- 16:9 thumbnail at top, date eyebrow in `{typography.caption-sm}`, title in `{typography.body-emphasis}`, "Read more" link
|
|
||||||
|
|
||||||
**`card-category-icon`** — the small icon-and-label card in the homepage "Our Products" row
|
|
||||||
- Background `{colors.canvas}`, rounded `{rounded.lg}` (8px), padding `{spacing.md}`
|
|
||||||
- 48px icon at top, label in `{typography.body-emphasis}` below
|
|
||||||
- Used for: Laptops, Desktops, Printers, Computer Tools, Accessories, Enterprise Solutions
|
|
||||||
|
|
||||||
**`hero-promo-card`** — the homepage hero card with chevron decorations
|
|
||||||
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xxl}` (32px)
|
|
||||||
- Photography occupies left half; copy block (eyebrow + headline + price stamp + CTA pair) occupies right half
|
|
||||||
- Flanked by `chevron-decoration` blue slashes outside the card's bounding box on left and right edges
|
|
||||||
|
|
||||||
**`promo-strip-dark`** — the inline dark navy promo block
|
|
||||||
- Background `{colors.ink}`, text `{colors.on-ink}`, rounded `{rounded.xl}`, padding `{spacing.xxl} 48px`
|
|
||||||
- Used for: "When did work start getting in the way of work?" mid-page promo, the SMB testimonial slab
|
|
||||||
|
|
||||||
### Inputs & Forms
|
### Inputs & Forms
|
||||||
|
|
||||||
**`text-input`** + **`text-input-focused`**
|
**`.text-input`** — Standard text field
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 44px
|
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.steel}` border, `rounded-none`, `h-11`
|
||||||
- 1px `{colors.steel}` border in default; gains 1px `{colors.ink}` border on focus (no halo)
|
- Focus: border becomes 1px `{colors.primary}`, no halo, no transition
|
||||||
|
|
||||||
**`text-input-search`** — pill search in the top nav
|
**`textarea.text-input`** — Multiline variant
|
||||||
- Background `{colors.canvas}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 40px, 1px `{colors.steel}` border, magnifying-glass icon at right
|
- Same as text-input, `min-h-[88px]`, height auto
|
||||||
|
|
||||||
**`badge-pill-ink`** — filled tag pill
|
**`select.text-input`** — Dropdown select
|
||||||
- Background `{colors.ink}`, text `{colors.on-primary}`, rounded `{rounded.lg}`, padding 6px 12px, type `{typography.body-md}`
|
- Same surface as text-input, caret via OS or custom SVG
|
||||||
- Used inline next to product titles to mark "New" or featured indicators
|
|
||||||
|
|
||||||
**`badge-pill-outline`** — outlined tag pill
|
### Cards & Panels
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, rounded `{rounded.lg}`, padding 6px 12px
|
|
||||||
|
|
||||||
**`badge-sale-coral`** — the sale price-stamp
|
**`.card-product`** — Standard content card
|
||||||
- Background `{colors.bloom-coral}`, text `{colors.on-primary}`, rounded `{rounded.sm}`, padding `{spacing.xxs} {spacing.xs}`, type `{typography.caption-bold}`
|
- Background `{colors.paper}`, 1px `{colors.hairline}` border, `rounded-none`, padding `{spacing.md}` (16px)
|
||||||
- Used for: "Save $200", "25% off" overlay tags on hero promo cards
|
- No shadow. Dense surfaces use padding `{spacing.sm}` (12px).
|
||||||
|
|
||||||
|
**`.modal-backdrop`** — Modal overlay backdrop
|
||||||
|
- `background-color: rgba(0,0,0,0.6)` — fixed, never themed
|
||||||
|
- Modal frame: `{colors.paper}` background, 1px `{colors.hairline}` border, `rounded-none`
|
||||||
|
|
||||||
|
### Badges & Tags
|
||||||
|
|
||||||
|
**`.badge-pill-*`** — Status pills (StatusBadge, SimulationStatusBadge)
|
||||||
|
- `rounded-pill` (9999px) — THE ONLY rounded surfaces on status badges
|
||||||
|
- Semantic fill: planned→warn-soft/warn, active→primary-soft/primary, closed→cloud/graphite
|
||||||
|
- done→success-soft/success, review→warn-soft/warn, pending→cloud/graphite, in-progress→primary-soft/primary
|
||||||
|
|
||||||
|
**MITRE technique tags** — NOT pills
|
||||||
|
- Angular (`rounded-none`), 1px `{colors.hairline}` border, `{colors.cloud}` background, caption-md size
|
||||||
|
- They are labels, not status signals — no pill shape
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
|
|
||||||
**`utility-strip`** — the top-of-page utility bar
|
**`.utility-strip`** — Top utility bar
|
||||||
- Background `{colors.ink}`, text `{colors.on-primary}`, height 36px, padding 0 {spacing.xl}, type `{typography.caption-md}`
|
- Background `{colors.slab}`, text `{colors.slab-text}`, height 36px, `font-mono` for user role/username
|
||||||
- Holds: country/locale picker, "For Business / For Home" toggle, "Sign in" link, cart link
|
|
||||||
|
|
||||||
**`nav-bar-top`** — desktop top nav (sits below utility strip)
|
**`.nav-bar-top`** — Main navigation
|
||||||
- Background `{colors.canvas}`, height 64px, padding 0 32px
|
- Background `{colors.slab}` (fixed dark — does not invert), height 56px
|
||||||
- Layout: HP wordmark logo flush left → middle category list (Laptops / Desktops / Printers / Accessories / Solutions / Support) → right slot with Search field, Sign-in link, Cart icon
|
- Active link: 2px `{colors.primary}` bottom border
|
||||||
- 1px `{colors.hairline}` bottom border separates nav from page
|
|
||||||
|
|
||||||
**`nav-link`**
|
**`.nav-link`** — Navigation anchor
|
||||||
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, padding `{spacing.xs} {spacing.md}`
|
- Text `{colors.slab-text}`, caption-md, `rounded-none`
|
||||||
- Active page draws a 2px `{colors.primary}` underline below the text baseline
|
- Active state: 2px primary bottom border
|
||||||
|
|
||||||
**Top Nav (Mobile)**
|
### Data Tables
|
||||||
- Same height, hamburger icon replaces the middle category list, Search and Cart stay visible
|
|
||||||
- Drawer expands as a full-canvas sheet with `{typography.body-lg}` link list and a sticky Sign-in CTA at bottom
|
|
||||||
|
|
||||||
**`category-tab`** + **`category-tab-active`** — the pill sub-nav
|
- `table-layout: fixed`, `word-break: break-word`
|
||||||
- Default: background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-emphasis}`, rounded `{rounded.pill}`, padding `{spacing.xs} {spacing.lg}`
|
- Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border
|
||||||
- Active: background `{colors.ink}`, text `{colors.on-primary}`, same rounding
|
- Data cells: 44px min-height, 1px `{colors.hairline}` bottom border
|
||||||
- Used on the laptop-shop page for "All / Trending / On Sale" filtering, and on the homepage "How can we help?" closing band
|
- ID / date / technique columns: `font-mono`
|
||||||
|
- Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide
|
||||||
|
|
||||||
### Signature Components
|
### Toast Notifications
|
||||||
|
|
||||||
**`chevron-decoration`** — the geometric blue slash motif
|
- Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary)
|
||||||
- Background `{colors.primary}`, rounded `{rounded.none}`, no shadow
|
- Background `{colors.paper}`, 1px `{colors.hairline}` border
|
||||||
- Renders as a sharp parallelogram cut at ~60° angle, sized to the height of the hero card it flanks
|
|
||||||
- Reserved for hero bands and full-page banners — never decorative noise inside cards
|
|
||||||
|
|
||||||
**`faq-row`** — the accordion row on the printer-plan FAQ
|
---
|
||||||
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.lg} {spacing.xl}`, type `{typography.body-emphasis}`
|
|
||||||
- 1px `{colors.hairline}` divider between rows; chevron-down icon on the right collapsed, chevron-up when expanded
|
|
||||||
- Body answer renders inside the same row container in `{typography.body-md}` after expansion
|
|
||||||
|
|
||||||
**`help-band-dark`** — the closing "How can we help?" prelude band
|
|
||||||
- Background `{colors.ink}`, text `{colors.on-primary}`, padding 64px {spacing.xl}
|
|
||||||
- Layout: large lifestyle photograph as the band background (low-opacity) with chip-style category tabs centered: Browse Topics / Live Chat / Contact / Diagnose / Order Status
|
|
||||||
|
|
||||||
**`footer-dark`**
|
|
||||||
- Background `{colors.ink}`, text `{colors.on-primary}`, type `{typography.body-md}`, padding 64px {spacing.xl}
|
|
||||||
- 5-column link grid (Company / Shop / Support / Resources / Connect) with `{typography.body-emphasis}` headers and `{typography.caption-md}` link rows
|
|
||||||
- Bottom strip carries social icons, language picker, and legal lines in `{typography.caption-sm}` muted to `{colors.steel}`
|
|
||||||
|
|
||||||
## Do's and Don'ts
|
## Do's and Don'ts
|
||||||
|
|
||||||
### Do
|
### Do
|
||||||
- Reserve `{colors.primary}` for the primary CTA, link color, and `chevron-decoration` motif — at most twice per viewport
|
- `rounded-none` on every container, button, input, modal, dropdown, panel, tag
|
||||||
- Set every headline in Forma DJR Micro at weight 500 with line-height 1.0 — resist the urge to bump weight at hero scale
|
- `font-mono` on IDs, ISO dates, MITRE codes, commands, output, metrics
|
||||||
- Use `{rounded.xl}` (16px) for cards and photo frames; `{rounded.md}` (4px) for buttons and inputs — keep the two-tier split sharp
|
- Semantic color for status: success green, warn amber, bloom-deep for errors
|
||||||
- Pair white `{colors.canvas}` body bands with `{colors.cloud}` (`#f7f7f7`) alternating bands; let the gray do the breathing
|
- 1px hairline borders for panel separation — never shadow
|
||||||
- Close every page rhythm with a dark-navy `{colors.ink}` slab — the "How can we help?" prelude + footer
|
- Instant hover (no `transition-*`)
|
||||||
- Set button labels in uppercase with `{typography.button-md}` (0.7px tracking) — the only place the system tracks letters
|
- Sharp focus ring: `outline: 2px solid {colors.primary}; outline-offset: 0`
|
||||||
- Use Soft Lift shadow exclusively for product cards and pricing tiers — leave section bands flat
|
- Keep Inter for all headers, labels, prose, navigation, button text
|
||||||
- Frame product photography inside `{rounded.xl}` containers; never use full-bleed circular masks
|
|
||||||
|
|
||||||
### Don't
|
### Don't
|
||||||
- Don't introduce secondary saturated colors outside `{colors.primary}` family + the `bloom-coral` sale-tag and `storm` printer-plan accents
|
- Don't round containers — not even `2px`. If it's not a status pill or avatar, `rounded-none`
|
||||||
- Don't apply heavy material shadows — depth is via color contrast (cloud vs. white) and Soft Lift only
|
- Don't use `font-mono` for headers, labels, prose, or button text
|
||||||
- Don't round buttons above `{rounded.md}` (4px); a soft 8px+ button reads as a different brand
|
- Don't add `transition-*` on any interactive element
|
||||||
- Don't run Forma DJR Micro below 12px — small caption at 11px is the floor
|
- Don't use shadows — hairline borders only
|
||||||
- Don't use the chevron decoration as inline noise; it is a hero-only architectural element tied to the wordmark
|
- Don't use `{colors.primary}` as a background for sections
|
||||||
- Don't drop ink text opacity to create hierarchy — switch surface or shift to `{colors.charcoal}` / `{colors.graphite}` instead
|
- Don't drop opacity on ink text — use `{colors.charcoal}` or `{colors.graphite}` for hierarchy
|
||||||
- Don't replace the HP wordmark with a generic sans lockup; the wordmark is a custom mark with its own ratio
|
- Don't use animated spinners with rounded tracks — a simple spinning line or text indicator fits the terminal aesthetic better
|
||||||
|
- Don't apply `rounded-pill` to anything that is not a STATUS BADGE or AVATAR
|
||||||
|
|
||||||
## Responsive Behavior
|
---
|
||||||
|
|
||||||
### Breakpoints
|
|
||||||
|
|
||||||
| Name | Width | Key Changes |
|
|
||||||
|---|---|---|
|
|
||||||
| Mobile | < 480px | Single-column stack; hamburger nav; section padding drops to ~48px; hero serif scales to ~36px |
|
|
||||||
| Mobile-Large | 480–767px | Same column count; hero scales to ~44px; pricing tiers stack vertically |
|
|
||||||
| Tablet | 768–1023px | 2-column product grid; pricing 2x2; nav still full text labels |
|
|
||||||
| Desktop | 1024–1279px | 3-column product grid; 4-column pricing; full nav |
|
|
||||||
| Desktop-Large | ≥ 1280px | 4-column product grid; 1366px content max-width with full-bleed bands |
|
|
||||||
|
|
||||||
### Touch Targets
|
|
||||||
|
|
||||||
Every interactive element clears 44×44px on mobile. `button-primary` at 44px height + 24px horizontal padding meets WCAG-AAA touch target. `category-tab` at 8px 20px padding bumps to 12px 24px on touch screens. Nav-link tap areas extend invisibly beyond the text run to the full 44px row height. Sticky cart/sign-in icons in the top nav use 44×44 invisible hit boxes around their visible 24×24 glyph.
|
|
||||||
|
|
||||||
### Collapsing Strategy
|
|
||||||
|
|
||||||
- **Utility strip**: stays visible on every breakpoint; dropdowns collapse into a single "Account" icon below 768px
|
|
||||||
- **Top nav**: middle category list collapses into a hamburger drawer below 1024px; the right-side Search + Sign-in + Cart stay visible
|
|
||||||
- **Hero**: stays single-column at every breakpoint; chevron decorations shrink to ~60% size on tablet and disappear entirely on mobile
|
|
||||||
- **Product family grid**: 4 → 3 → 2 → 1 column as breakpoints shrink; cards keep `{rounded.xl}` corners at every size
|
|
||||||
- **Pricing comparison table**: 4-column grid on desktop collapses to 2x2 on tablet, then stacks into individual accordion-style cards on mobile
|
|
||||||
- **Footer**: 5-column link grid → 2-column tablet → single-column accordion on mobile; HP wordmark stays flush left
|
|
||||||
|
|
||||||
### Image Behavior
|
|
||||||
|
|
||||||
Hero photography uses `{rounded.xl}` containers at every breakpoint. The chevron decorations vanish on mobile; the underlying photo card centers in the viewport. Lifestyle photography in the testimonial and "how-can-we-help" bands maintains 16:9 ratio with horizontal cropping rather than letterboxing on mobile. There are no art-direction crop swaps between desktop and mobile — the same image is used at every size.
|
|
||||||
|
|
||||||
## Iteration Guide
|
## Iteration Guide
|
||||||
|
|
||||||
1. Focus on ONE component at a time; resist refactoring an entire section in one pass
|
1. One component at a time — no section refactors in one pass
|
||||||
2. Reference component names and tokens directly (`{colors.primary}`, `{typography.display-xxl}`, `{rounded.xl}`, `card-product`) — do not paraphrase to hex/px in prose
|
2. Reference tokens by name (`{colors.primary}`, `font-mono`, `rounded-none`) — not hex/px in prose
|
||||||
3. Run `npx @google/design.md lint DESIGN.md` after edits — `broken-ref`, `contrast-ratio`, and `orphaned-tokens` warnings flag issues automatically
|
3. When adding a new data field: ask "is this a datum (ID, date, code, metric)?" → `font-mono`. If no → Inter
|
||||||
4. Add new variants as separate component entries (`-pressed`, `-disabled`, `-focused`); never bury state inside prose
|
4. New status signals: map to existing semantic tokens (success/warn/bloom-deep/primary). No ad-hoc colors
|
||||||
5. Default body to `{typography.body-md}`; reach for `{typography.body-emphasis}` for run-in bolds; keep display sizes for true heading roles
|
5. For new badge-like elements: pill ONLY if it's a status indicator with semantic color. Otherwise angular tag
|
||||||
6. Keep `{colors.primary}` scarce — at most two flame elements per viewport (one CTA + one chevron decoration). Three flame items in one viewport is over-saturation
|
|
||||||
7. When introducing a new section band, choose from `{colors.canvas}` / `{colors.cloud}` / `{colors.fog}` / `{colors.ink}` — six pre-defined surface modes is the entire surface vocabulary
|
|
||||||
|
|||||||
74
Makefile
Normal file
74
Makefile
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
PORT ?= 5000
|
||||||
|
IMAGE ?= mimic:latest
|
||||||
|
CONTAINER ?= mimic
|
||||||
|
VOLUME ?= mimic-data
|
||||||
|
|
||||||
|
# Container engine: auto-detect docker first, fall back to podman.
|
||||||
|
# Override explicitly with `make <target> CONTAINER_CMD=podman` or `export CONTAINER_CMD=podman`.
|
||||||
|
CONTAINER_CMD ?= $(shell if command -v docker >/dev/null 2>&1; then echo docker; else echo podman; fi)
|
||||||
|
|
||||||
|
.PHONY: build start stop restart update logs create-admin update-mitre test-backend test-frontend test-e2e clean open-pr
|
||||||
|
|
||||||
|
build:
|
||||||
|
$(CONTAINER_CMD) build -f docker/Dockerfile -t $(IMAGE) .
|
||||||
|
|
||||||
|
start:
|
||||||
|
$(CONTAINER_CMD) run -d --name $(CONTAINER) -p $(PORT):5000 -v $(VOLUME):/data --env-file .env $(IMAGE)
|
||||||
|
|
||||||
|
stop:
|
||||||
|
$(CONTAINER_CMD) stop $(CONTAINER) && $(CONTAINER_CMD) rm $(CONTAINER)
|
||||||
|
|
||||||
|
restart:
|
||||||
|
$(MAKE) stop && $(MAKE) start
|
||||||
|
|
||||||
|
update:
|
||||||
|
git pull && $(MAKE) build && $(MAKE) restart
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(CONTAINER_CMD) logs -f $(CONTAINER)
|
||||||
|
|
||||||
|
create-admin:
|
||||||
|
ifndef USER
|
||||||
|
$(error USER is required: make create-admin USER=alice PASS=p4ssw0rd)
|
||||||
|
endif
|
||||||
|
ifndef PASS
|
||||||
|
$(error PASS is required: make create-admin USER=alice PASS=p4ssw0rd)
|
||||||
|
endif
|
||||||
|
$(CONTAINER_CMD) exec $(CONTAINER) flask create-admin $(USER) $(PASS)
|
||||||
|
|
||||||
|
MITRE_URL ?= https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json
|
||||||
|
|
||||||
|
update-mitre:
|
||||||
|
@mkdir -p backend/data/mitre
|
||||||
|
@curl -fsSL "$(MITRE_URL)" -o backend/data/mitre/enterprise-attack.json
|
||||||
|
@echo "MITRE bundle updated"
|
||||||
|
@if $(CONTAINER_CMD) ps --format '{{.Names}}' | grep -q "^$(CONTAINER)$$"; then \
|
||||||
|
echo "Restarting $(CONTAINER) to reload MITRE bundle..."; \
|
||||||
|
$(CONTAINER_CMD) restart $(CONTAINER); \
|
||||||
|
fi
|
||||||
|
|
||||||
|
test-backend:
|
||||||
|
$(CONTAINER_CMD) exec $(CONTAINER) pytest -q backend/tests/
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
cd frontend && npm run test -- --run
|
||||||
|
|
||||||
|
test-e2e:
|
||||||
|
cd e2e && npx playwright test
|
||||||
|
|
||||||
|
clean:
|
||||||
|
-$(CONTAINER_CMD) rm -f $(CONTAINER) 2>/dev/null
|
||||||
|
-$(CONTAINER_CMD) volume rm $(VOLUME) 2>/dev/null
|
||||||
|
rm -rf backend/__pycache__ frontend/node_modules frontend/dist
|
||||||
|
|
||||||
|
# Open a PR on the Gitea repo for the current branch.
|
||||||
|
# make open-pr TITLE="feat: sprint 4 — ..." BODY=path/to/body.md [BASE=main]
|
||||||
|
# Uses scripts/open-pr.sh, which reads ~/.git-credentials (no token in env).
|
||||||
|
open-pr:
|
||||||
|
ifndef TITLE
|
||||||
|
$(error TITLE is required: make open-pr TITLE="feat: ..." BODY=path/to/body.md)
|
||||||
|
endif
|
||||||
|
ifndef BODY
|
||||||
|
$(error BODY is required: make open-pr TITLE="feat: ..." BODY=path/to/body.md)
|
||||||
|
endif
|
||||||
|
./scripts/open-pr.sh --title "$(TITLE)" --body "$(BODY)" --base "$(if $(BASE),$(BASE),main)"
|
||||||
160
README.md
Normal file
160
README.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Mimic
|
||||||
|
|
||||||
|
**Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs.
|
||||||
|
|
||||||
|
> Status: **Sprint 6 — Engagement export**. Admin/redteam can now export an engagement to Markdown, CSV, or PDF in one click from `EngagementDetailPage`. The export contains the engagement header and all simulations with both Red Team and SOC fields — closing the "replace the shared Excel" loop. CSV cells are defused against spreadsheet formula injection. SOC has no access to the export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Prerequisites: Docker (or Podman) + GNU Make. Linux/macOS host.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configure secrets
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env and set MIMIC_JWT_SECRET to a strong random value:
|
||||||
|
# sed -i "s|replace-me-with-a-strong-random-secret|$(openssl rand -hex 32)|" .env
|
||||||
|
|
||||||
|
# 2. Build and start the container
|
||||||
|
make build
|
||||||
|
make start
|
||||||
|
|
||||||
|
# 3. Bootstrap the first admin (run once, the container must be up)
|
||||||
|
make create-admin USER=alice PASS=changeme8
|
||||||
|
|
||||||
|
# 4. Open the UI
|
||||||
|
xdg-open http://localhost:5000 # Linux
|
||||||
|
# or visit http://localhost:5000 manually
|
||||||
|
```
|
||||||
|
|
||||||
|
Log in with the credentials from step 3. The admin can create additional users (redteam / soc) from `/admin/users`.
|
||||||
|
|
||||||
|
To stop or restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make stop
|
||||||
|
make restart # stop + start, preserves the SQLite volume
|
||||||
|
make logs # tail container logs
|
||||||
|
```
|
||||||
|
|
||||||
|
To override the host port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make start PORT=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Single-container deployment. A multistage Dockerfile builds the Vite frontend, then copies the static assets into the Flask backend image so Flask serves both the API (under `/api/*`) and the SPA (everything else).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────┐
|
||||||
|
│ Container: mimic:latest │
|
||||||
|
│ │
|
||||||
|
│ Flask (Python 3.12) │
|
||||||
|
│ ├── /api/* ── blueprints (auth, users, │
|
||||||
|
│ │ engagements, simulations,│
|
||||||
|
│ │ mitre) │
|
||||||
|
│ └── / ── SPA fallback → React build │
|
||||||
|
│ │
|
||||||
|
│ SQLAlchemy ── SQLite at /data/mimic.sqlite │
|
||||||
|
│ (volume: mimic-data)│
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Auth**: JWT Bearer tokens (HS256, 60-min TTL). Stateless — no refresh tokens, no server-side session.
|
||||||
|
- **Roles**: `admin` (super-user — cumulates redteam rights on engagements/simulations), `redteam` (CRUD engagements + simulations, full field access), `soc` (read everything, write-only on the SOC half of simulations once the redteam marks them `review_required`).
|
||||||
|
- **Password hashing**: argon2 via `argon2-cffi`.
|
||||||
|
- **Migrations**: Alembic, applied automatically by the container entrypoint (`flask db upgrade && flask run`).
|
||||||
|
- **MITRE ATT&CK**: STIX 2.1 Enterprise bundle committed at `backend/data/mitre/enterprise-attack.json` and indexed at app boot. `make update-mitre` re-fetches the latest bundle and (if the container is running) restarts it to reload the index. The endpoint `GET /api/mitre/techniques?q=` powers the autocomplete on simulations.
|
||||||
|
- **Simulation workflow**: Pending → In progress (auto-transition when redteam saves any non-empty field) → Review required (manual, redteam) → Done (manual, redteam or SOC). The state machine is enforced server-side; the UI surfaces the appropriate transition button per role + current state.
|
||||||
|
|
||||||
|
See [`SPEC.md`](SPEC.md) § "Décisions techniques" for the full architecture rationale and [`DESIGN.md`](DESIGN.md) for the UI design system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
mimic/
|
||||||
|
├── backend/ # Flask app, SQLAlchemy models, Alembic migrations, pytest suite
|
||||||
|
├── frontend/ # Vite + React + Tailwind + TanStack Query, Vitest suite
|
||||||
|
├── e2e/ # Playwright acceptance tests (one spec per user story)
|
||||||
|
├── docker/ # Dockerfile (multistage) + entrypoint.sh
|
||||||
|
├── tasks/ # Sprint plans (tasks/todo.md) and lessons (tasks/lessons.md)
|
||||||
|
├── .claude/agents/ # Sub-agent definitions for the team (read-only at runtime)
|
||||||
|
├── Makefile # all operational entry points
|
||||||
|
├── SPEC.md # functional + technical spec
|
||||||
|
├── DESIGN.md # UI design system (palette, typography, components)
|
||||||
|
└── CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Make targets
|
||||||
|
|
||||||
|
| Target | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `make build` | Build the `mimic:latest` container image (multistage: Node → Python). Uses `docker` if installed, otherwise `podman` — override with `make build CONTAINER_CMD=podman` |
|
||||||
|
| `make start` | Start the container (port from `PORT`, default 5000; mounts `mimic-data` volume) |
|
||||||
|
| `make stop` | Stop and remove the container |
|
||||||
|
| `make restart` | `make stop && make start` — preserves the SQLite volume |
|
||||||
|
| `make update` | `git pull && make build && make restart` |
|
||||||
|
| `make logs` | `docker logs -f mimic` |
|
||||||
|
| `make create-admin USER=… PASS=…` | Run `flask create-admin` inside the container |
|
||||||
|
| `make update-mitre` | Fetch the latest MITRE STIX 2.1 Enterprise bundle into `backend/data/mitre/`; auto-restart the container if running. Commit the resulting file change manually. |
|
||||||
|
| `make test-backend` | `pytest -q` inside the container |
|
||||||
|
| `make test-frontend` | `npm run test -- --run` in `frontend/` |
|
||||||
|
| `make test-e2e` | Playwright acceptance suite (container must be running) |
|
||||||
|
| `make clean` | Remove container + volume + Python/Node caches |
|
||||||
|
| `make open-pr TITLE="…" BODY=path` | Open a PR on the Gitea repo for the current branch via the REST API. Reads credentials from `~/.git-credentials` (same source as `git push`) — no token in env. Wraps `scripts/open-pr.sh`. Defaults `BASE=main`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development (without Docker)
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
export MIMIC_JWT_SECRET=dev-secret
|
||||||
|
export MIMIC_DB_PATH=./mimic.sqlite
|
||||||
|
flask --app backend.app:create_app db upgrade
|
||||||
|
flask --app backend.app:create_app run --port 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173 with /api proxied to :5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && pytest -q # 253 tests
|
||||||
|
cd frontend && npm run test -- --run # 136 tests
|
||||||
|
cd e2e && npx playwright test # 223 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [`SPEC.md`](SPEC.md) — functional spec, technical decisions, agent team
|
||||||
|
- [`DESIGN.md`](DESIGN.md) — UI design system
|
||||||
|
- [`CHANGELOG.md`](CHANGELOG.md) — sprint-by-sprint changes
|
||||||
|
- [`tasks/todo.md`](tasks/todo.md) — current sprint plan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Internal project — not yet open-sourced.
|
||||||
107
SPEC.md
107
SPEC.md
@@ -8,7 +8,7 @@ Mimic est une application WebUI de type BAS (Breach and Attack Simulation), se b
|
|||||||
Une simulation est composée des champs suivants :
|
Une simulation est composée des champs suivants :
|
||||||
* Partie RedTeam :
|
* Partie RedTeam :
|
||||||
- Nom du test
|
- Nom du test
|
||||||
- Type d'attaque MITRE correspondant (peut être une liste de référence)
|
- Types d'attaque MITRE correspondants (multi-techniques — une simulation peut couvrir plusieurs TTPs) sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale. Sub-techniques (ex : T1059.001) supportées.
|
||||||
- Description
|
- Description
|
||||||
- Commandes exécutés (liste)
|
- Commandes exécutés (liste)
|
||||||
- Pré-requis (champs texte)
|
- Pré-requis (champs texte)
|
||||||
@@ -25,17 +25,104 @@ La redteam peut modifier l'ensemble des champs d'une simulation, tandis que l'an
|
|||||||
Un workflow de simulation doit être mis en place : Pending, In progress, Review required, Done.
|
Un workflow de simulation doit être mis en place : Pending, In progress, Review required, Done.
|
||||||
Le workflow se mettra à jour de la manière suivante :
|
Le workflow se mettra à jour de la manière suivante :
|
||||||
- Création de la simulation : pending
|
- Création de la simulation : pending
|
||||||
- La redteam saisit des informations dans la simulation : in progress
|
- La redteam saisit des informations dans la simulation : in progress (auto)
|
||||||
- La redteam décide par une action manuelle de passer la simulation en status "review required" ce qui offre à la possibilité au SOC de remplir les informations nécessaire.
|
- La redteam décide par une action manuelle de passer la simulation en status "review required" ce qui offre à la possibilité au SOC de remplir les informations nécessaire.
|
||||||
- Le SOC (ou la redteam) décide par une action manuelle de passe la simulation en status "Done".
|
- Le SOC (ou la redteam) décide par une action manuelle de passer la simulation en status "Done".
|
||||||
|
- **Done est terminal** : aucune édition n'est possible sur une simulation Done. Pour reprendre, n'importe lequel des trois rôles (admin / redteam / soc) peut déclencher une action "Reopen" qui ramène la simulation à "review required" — les champs redeviennent éditables selon les règles habituelles. Cette transition est la seule autorisée à quitter Done.
|
||||||
|
|
||||||
Un engagement correspond à une mission redteam. Il est possible d'ajouter plusieurs test dans un engagement.
|
Un engagement correspond à une mission redteam. Il est possible d'ajouter plusieurs test dans un engagement. **Le statut de l'engagement progresse automatiquement** : créer un engagement le met à "planned" ; dès qu'une simulation de cet engagement passe en "in progress" (auto-transition par la redteam ou manuelle), l'engagement passe à "active" — pas de retour arrière automatique. La transition vers "closed" reste manuelle.
|
||||||
|
|
||||||
|
## Templates de simulations
|
||||||
|
Un **template de simulation** est une simulation pré-remplie côté redteam (name + description + commandes + pré-requis + techniques MITRE + tactiques MITRE) qui sert de point de départ pour instancier rapidement des simulations dans un engagement. Le template ne contient PAS de partie SOC, ni de date d'exécution, ni de résultat d'exécution — ces champs restent par-instance.
|
||||||
|
|
||||||
|
L'instanciation d'un template dans un engagement crée une **nouvelle simulation indépendante** : le template et l'instance sont décorrélés, l'édition de l'un n'affecte pas l'autre. Aucune référence (FK `template_id`) n'est conservée sur la simulation instanciée.
|
||||||
|
|
||||||
|
**RBAC templates = ressource Red Team uniquement** : admin et redteam les gèrent (CRUD). SOC n'a aucun accès (pas de nav link, tous endpoints templates retournent 403). Les nouveaux noms de templates sont uniques pour la clarté UX du dropdown d'instanciation.
|
||||||
|
|
||||||
|
## Export d'engagement
|
||||||
|
Un engagement peut être exporté à tout moment dans 3 formats au choix : **Markdown** (handoff narratif), **CSV** (machine-readable, intégration tableurs), **PDF** (livrable client). Endpoint unique : `GET /api/engagements/<id>/export?format=md|csv|pdf`. Réservé aux rôles admin et redteam (livrable RedTeam, cohérent avec le RBAC Templates). Filename normalisé : `engagement-<id>-<slug>-YYYYMMDD.<ext>`.
|
||||||
|
|
||||||
|
**Schéma fixe à 7 colonnes** (en-têtes français) pour tous les formats — une ligne par simulation :
|
||||||
|
|
||||||
|
| # | Colonne | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Scénario | `simulation.name` |
|
||||||
|
| 2 | Test | `simulation.description` |
|
||||||
|
| 3 | Source de log | `simulation.log_source` |
|
||||||
|
| 4 | Commentaires SOC | `simulation.soc_comment` |
|
||||||
|
| 5 | Exécution | concat multi-ligne sans labels, ordre fixe : `executed_at` → `commands` → `execution_result` |
|
||||||
|
| 6 | Logs remontés au SIEM | `simulation.logs` |
|
||||||
|
| 7 | Cyber incident | `simulation.incident_number` |
|
||||||
|
|
||||||
|
CSV : exactement 1 ligne d'en-tête + 1 ligne par simulation. Markdown : en-tête engagement (name, dates, status, created_by) puis une table de 7 colonnes. PDF : même structure que le Markdown rendue via HTML→PDF (WeasyPrint). Le statut de la simulation, les techniques/tactiques MITRE, les prerequisites et les métadonnées (id, created_at) ne sont PAS exportés — l'export est un handoff focalisé RT↔SOC, pas un dump complet.
|
||||||
|
|
||||||
Prévoir un module d'authentification : dans un premier temps local à la bdd.
|
Prévoir un module d'authentification : dans un premier temps local à la bdd.
|
||||||
|
|
||||||
Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests.
|
Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests.
|
||||||
Dans un second temps, après que la V1 soit terminée, nous ajouterons une couche permettant de se connecter à un C2 (mythic ou maison) afin d'exécuter des simulation au travers du C2.
|
Dans un second temps, après que la V1 soit terminée, nous ajouterons une couche permettant de se connecter à un C2 (mythic ou maison) afin d'exécuter des simulation au travers du C2.
|
||||||
|
|
||||||
|
## Intégration C2 (Sprint 8+)
|
||||||
|
|
||||||
|
Couche d'intégration C2 permettant d'exécuter les commandes d'une simulation à travers un Command & Control distant, suivre l'avancement des tâches en quasi-temps réel, et importer l'historique d'exécutions existant. **Implémentation de référence : Mythic 3.x**, derrière une interface `C2Adapter` mince qui ne ferme pas la porte à un C2 maison ultérieur.
|
||||||
|
|
||||||
|
**RBAC C2 = ressource Red Team uniquement** (précédent Templates + Export) : admin et redteam ont accès complet (config + exécution + import). SOC retourne 403 sur tous les endpoints C2 (pas de nav link, pas d'affichage du panneau C2).
|
||||||
|
|
||||||
|
**Configuration par engagement** : chaque engagement possède au plus une `c2_config` (URL Mythic + API token + flag `verify_tls`). Le token est **chiffré au repos** via `cryptography.Fernet` ; la clé est dérivée de l'env var `MIMIC_ENCRYPTION_KEY` (variable obligatoire pour activer la fonctionnalité C2 — jamais hardcodée, conforme à la règle OPSEC zero-secret-in-code). Le token n'est jamais renvoyé en clair par l'API — `GET /api/engagements/<id>/c2-config` retourne `has_token: bool` uniquement. Mise à jour via `PUT` ; suppression via `DELETE`. La suppression d'un engagement supprime en cascade sa `c2_config`.
|
||||||
|
|
||||||
|
**Sélection d'adapter** via l'env var `MIMIC_C2_ADAPTER` :
|
||||||
|
- `mythic` (défaut) : adapter Mythic réel (GraphQL via Hasura).
|
||||||
|
- `fake` : adapter en mémoire déterministe utilisé pour la validation Playwright et le dev local sans instance Mythic.
|
||||||
|
|
||||||
|
**Modèle de données — additions** :
|
||||||
|
|
||||||
|
`c2_config` (1 ligne par engagement au max) :
|
||||||
|
| Colonne | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | int PK | |
|
||||||
|
| `engagement_id` | int FK `engagements.id` ON DELETE CASCADE, **UNIQUE** | |
|
||||||
|
| `url` | text | endpoint Mythic, ex. `https://lab.internal:7443` |
|
||||||
|
| `api_token_encrypted` | text | Fernet ciphertext, jamais en clair |
|
||||||
|
| `verify_tls` | bool, défaut `true` | `false` autorisé pour labs auto-signés |
|
||||||
|
| `created_at`, `updated_at` | datetime | |
|
||||||
|
|
||||||
|
`c2_task` (lien simulation ↔ tâche Mythic) :
|
||||||
|
| Colonne | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | int PK | |
|
||||||
|
| `simulation_id` | int FK `simulations.id` ON DELETE CASCADE | |
|
||||||
|
| `mythic_task_display_id` | int | identifiant côté Mythic |
|
||||||
|
| `callback_display_id` | int | callback Mythic sur lequel la tâche tourne |
|
||||||
|
| `command` | text | commande envoyée |
|
||||||
|
| `params` | text nullable | paramètres associés |
|
||||||
|
| `status` | text | statut brut Mythic (`submitted`, `completed`, `error`, …) |
|
||||||
|
| `completed` | bool | `true` quand la tâche est terminée |
|
||||||
|
| `output` | text nullable | sortie décodée (base64 → utf-8 ; binaire → préfixe `<binary>` + hex) |
|
||||||
|
| `source` | enum `mimic` \| `import` | tâche lancée depuis Mimic ou importée a posteriori |
|
||||||
|
| `created_at` | datetime | |
|
||||||
|
| `completed_at` | datetime nullable | timestamp de complétion |
|
||||||
|
|
||||||
|
**Endpoints C2** (tous admin+redteam ; SOC = 403) :
|
||||||
|
- `GET /api/engagements/<id>/c2-config` — `{has_token, url, verify_tls}` (jamais le token en clair).
|
||||||
|
- `PUT /api/engagements/<id>/c2-config` — `{url, api_token?, verify_tls}`.
|
||||||
|
- `DELETE /api/engagements/<id>/c2-config`.
|
||||||
|
- `POST /api/engagements/<id>/c2-config/test` — test de connectivité via l'adapter, renvoie `{ok, error?}`.
|
||||||
|
- `GET /api/engagements/<id>/c2/callbacks` — callbacks actifs de l'instance Mythic configurée.
|
||||||
|
- `POST /api/simulations/<id>/c2/execute` `{callback_display_id, commands: [str]}` — une tâche Mythic par commande, stockées dans `c2_task` (source=`mimic`). **Auto-transition** : si la simulation est `pending`, elle passe à `in_progress` (même règle que l'édition manuelle RT — cf. § Fonctionnement).
|
||||||
|
- `GET /api/simulations/<id>/c2/tasks` — poll-on-read : à la lecture, rafraîchit le statut et l'output des `c2_task` non terminées depuis Mythic, applique le mapping de sortie (voir ci-dessous) à la simulation pour chaque tâche qui vient de se terminer (idempotent — appliqué une seule fois par tâche).
|
||||||
|
- `GET /api/engagements/<id>/c2/callbacks/<cid>/history?page=` — historique paginé des tâches d'un callback, pour l'import.
|
||||||
|
- `POST /api/simulations/<id>/c2/import` `{task_display_ids: [int]}` — import sélectif de tâches (source=`import`) + mapping de sortie.
|
||||||
|
|
||||||
|
**Mapping de sortie vers la simulation** (appliqué une fois par tâche, lors de la complétion ou de l'import) :
|
||||||
|
- `simulation.execution_result` reçoit en append le bloc `\n$ <command>\n<output>\n` (préserve l'existant, jamais d'écrasement).
|
||||||
|
- `simulation.executed_at` est renseigné depuis le timestamp de la première tâche complétée si le champ est vide ; sinon non modifié.
|
||||||
|
- `simulation.commands` reçoit en append la commande si elle n'y figure pas déjà (déduplication ligne par ligne).
|
||||||
|
|
||||||
|
**Suivi temps réel** : polling court — le frontend re-fetch `GET /api/simulations/<id>/c2/tasks` toutes les **2 500 ms** via TanStack Query `refetchInterval` tant qu'une tâche attachée n'est pas terminée ; le polling s'arrête automatiquement quand toutes les tâches sont `completed`. Pas d'infrastructure ajoutée côté serveur (pas de WebSocket, pas de scheduler).
|
||||||
|
|
||||||
|
**UI** : les contrôles C2 vivent dans la carte Red Team de l'écran simulation — bouton `[Execute via C2]` ouvrant une modale (picker de callback + textarea de commandes pré-remplie depuis `commands`), panneau des tâches attachées sous la carte, et modale d'import historique. Configuration C2 visible/éditable depuis l'écran de détail/édition d'engagement.
|
||||||
|
|
||||||
|
**Validation** : MVP entièrement mocké — pytest utilise un adapter mocké (zéro HTTP live), Playwright utilise l'adapter `fake` (déterministe). Le branchement contre une instance Mythic réelle est repoussé au premier usage opérationnel et peut nécessiter un patch mineur du contrat GraphQL.
|
||||||
|
|
||||||
## Stacks techniques
|
## Stacks techniques
|
||||||
* **FrontEnd** : WebUI
|
* **FrontEnd** : WebUI
|
||||||
- Stacks standard : ReactJS, Vite, TailWind etc...
|
- Stacks standard : ReactJS, Vite, TailWind etc...
|
||||||
@@ -51,8 +138,11 @@ Dans un second temps, après que la V1 soit terminée, nous ajouterons une couch
|
|||||||
## Workflows
|
## Workflows
|
||||||
* Découpage en sprint
|
* Découpage en sprint
|
||||||
* Chaque sprint doit apporter une nouvelle fonctionnalité à tester sur l'UI
|
* Chaque sprint doit apporter une nouvelle fonctionnalité à tester sur l'UI
|
||||||
* A chaque sprint : Code + Test (Test unitaire python + Test pywright front) + Reviews (spec + code). Une fois le review OK, PR que je valide après test.
|
* A chaque sprint : Code + Test (Test unitaire python + Test pywright front) + Reviews (spec + design + code). Une fois les reviews OK, PR que je valide après test.
|
||||||
|
* **Séquence sprint (depuis sprint 4)** : spec-reviewer → backend-builder → frontend-builder (livre des screenshots obligatoires) → **design-reviewer (NOUVEAU sprint 4)** → code-reviewer → test-verifier → team-lead PR.
|
||||||
|
* **design-reviewer** revoit le diff frontend + screenshots du sprint courant, audit alignement / hiérarchie typo / usage tokens DESIGN.md / cohérence visuelle / responsive. Read-only, ne modifie rien.
|
||||||
* A chaque fin de sprint, avant mes tests, le team lead doit me faire un récapitulatif synthétique de ce qui a été fait et ce qui doit être testé (et comment le tester).
|
* A chaque fin de sprint, avant mes tests, le team lead doit me faire un récapitulatif synthétique de ce qui a été fait et ce qui doit être testé (et comment le tester).
|
||||||
|
* **Création de PR** : à la fin du sprint, le team-lead ouvre la PR via `make open-pr SPRINT=N TITLE="..." BODY=path/to/body.md` (depuis sprint 4). Le script `scripts/open-pr.sh` parse `~/.git-credentials` et POST sur l'API Gitea.
|
||||||
|
|
||||||
# Team
|
# Team
|
||||||
|
|
||||||
@@ -157,6 +247,13 @@ Conforme à la spec (partie RedTeam + partie SOC). Workflow Pending → In progr
|
|||||||
* **Bundle local** : JSON officiel STIX 2.1 MITRE Enterprise embarqué dans l'image (`backend/data/mitre/enterprise-attack.json`).
|
* **Bundle local** : JSON officiel STIX 2.1 MITRE Enterprise embarqué dans l'image (`backend/data/mitre/enterprise-attack.json`).
|
||||||
* Pas d'appel réseau au runtime. Seed/refresh manuel via `make update-mitre`.
|
* Pas d'appel réseau au runtime. Seed/refresh manuel via `make update-mitre`.
|
||||||
* Utilisé au Sprint 2+ pour l'autocomplete des TTPs (T-id + nom + tactique).
|
* Utilisé au Sprint 2+ pour l'autocomplete des TTPs (T-id + nom + tactique).
|
||||||
|
* **Sprint 3+** : multi-techniques par simulation, sélectionnables via autocomplete OU matrice cliquable.
|
||||||
|
* **Sprint 4+** : sélection de tactiques (TA-id, ex `TA0007 Discovery`) en plus des techniques. Stockées dans un champ `tactic_ids` distinct, séparé sémantiquement de `technique_ids`.
|
||||||
|
|
||||||
|
## UI/UX
|
||||||
|
* **Theming** : light + dark + system (suit `prefers-color-scheme`). Toggle dans la topbar. Persistance localStorage clé `mimic-theme`. Défaut : `system`.
|
||||||
|
* **Boutons d'action** : icône (lucide-react ou unicode) + label court (≤ 8 chars) préférés aux phrases. Exceptions justifiées pour des libellés workflow-critiques sans icône évidente (ex : "Mark for review", "Clear all").
|
||||||
|
* **Modals** : focus trap V1 minimal (focus initial sur le champ principal, Tab cycle, Escape + backdrop click = Cancel). Full WAI-ARIA conformance reportée à un sprint a11y dédié.
|
||||||
|
|
||||||
## Stack technique précisée
|
## Stack technique précisée
|
||||||
* **Backend** : Python 3.12, Flask, SQLAlchemy, Alembic, pytest, ruff, mypy. Auth via `PyJWT` + middleware decorator.
|
* **Backend** : Python 3.12, Flask, SQLAlchemy, Alembic, pytest, ruff, mypy. Auth via `PyJWT` + middleware decorator.
|
||||||
|
|||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
86
backend/app/__init__.py
Normal file
86
backend/app/__init__.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Flask application factory."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, send_from_directory
|
||||||
|
|
||||||
|
from backend.app.api import (
|
||||||
|
auth_bp,
|
||||||
|
c2_bp,
|
||||||
|
engagements_bp,
|
||||||
|
sims_c2_bp,
|
||||||
|
simulations_bp,
|
||||||
|
templates_bp,
|
||||||
|
users_bp,
|
||||||
|
)
|
||||||
|
from backend.app.cli import register_cli
|
||||||
|
from backend.app.config import Config, TestConfig
|
||||||
|
from backend.app.errors import register_error_handlers
|
||||||
|
from backend.app.extensions import db, migrate
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_object: object | None = None) -> Flask:
|
||||||
|
"""Application factory.
|
||||||
|
|
||||||
|
`config_object` is an *instance* (not a class) of Config or a subclass.
|
||||||
|
If None, picks TestConfig when MIMIC_TESTING=1, otherwise Config.
|
||||||
|
"""
|
||||||
|
static_folder = str(Path(__file__).parent / "static")
|
||||||
|
app = Flask(__name__, static_folder=static_folder, static_url_path="/static")
|
||||||
|
|
||||||
|
if config_object is None:
|
||||||
|
config_object = TestConfig() if os.environ.get("MIMIC_TESTING") == "1" else Config()
|
||||||
|
app.config.from_object(config_object)
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
migrations_dir = str(Path(__file__).parent.parent / "migrations")
|
||||||
|
migrate.init_app(app, db, directory=migrations_dir)
|
||||||
|
|
||||||
|
# Ensure models are imported so Alembic/metadata see them.
|
||||||
|
from backend.app import models # noqa: F401
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(users_bp)
|
||||||
|
app.register_blueprint(engagements_bp)
|
||||||
|
app.register_blueprint(simulations_bp)
|
||||||
|
app.register_blueprint(templates_bp)
|
||||||
|
app.register_blueprint(c2_bp)
|
||||||
|
app.register_blueprint(sims_c2_bp)
|
||||||
|
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
mitre_svc.load_bundle()
|
||||||
|
|
||||||
|
register_error_handlers(app)
|
||||||
|
register_cli(app)
|
||||||
|
|
||||||
|
static_root = Path(static_folder)
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}, 200
|
||||||
|
|
||||||
|
# Serve the built frontend (Vite output copied into app/static at image build time).
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
index_path = static_root / "index.html"
|
||||||
|
if index_path.exists():
|
||||||
|
return send_from_directory(static_folder, "index.html")
|
||||||
|
return {"status": "ok", "message": "Mimic API running. Frontend not built."}, 200
|
||||||
|
|
||||||
|
@app.get("/<path:path>")
|
||||||
|
def spa_fallback(path: str):
|
||||||
|
# Unknown /api/* paths must stay JSON 404 — never shadowed by index.html.
|
||||||
|
if path.startswith("api/"):
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
# Serve static assets if present; otherwise hand back index.html for client routing.
|
||||||
|
candidate = static_root / path
|
||||||
|
if candidate.is_file():
|
||||||
|
return send_from_directory(static_folder, path)
|
||||||
|
index_path = static_root / "index.html"
|
||||||
|
if index_path.exists():
|
||||||
|
return send_from_directory(static_folder, "index.html")
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
|
||||||
|
return app
|
||||||
17
backend/app/api/__init__.py
Normal file
17
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""API blueprints."""
|
||||||
|
from backend.app.api.auth import auth_bp
|
||||||
|
from backend.app.api.c2 import c2_bp, sims_c2_bp
|
||||||
|
from backend.app.api.engagements import engagements_bp
|
||||||
|
from backend.app.api.simulations import simulations_bp
|
||||||
|
from backend.app.api.templates import templates_bp
|
||||||
|
from backend.app.api.users import users_bp
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"auth_bp",
|
||||||
|
"c2_bp",
|
||||||
|
"sims_c2_bp",
|
||||||
|
"users_bp",
|
||||||
|
"engagements_bp",
|
||||||
|
"simulations_bp",
|
||||||
|
"templates_bp",
|
||||||
|
]
|
||||||
46
backend/app/api/auth.py
Normal file
46
backend/app/api/auth.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Auth endpoints: login, logout, me."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, g, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth import encode_token, login_required, verify_password
|
||||||
|
from backend.app.models import User
|
||||||
|
from backend.app.serializers import serialize_user
|
||||||
|
|
||||||
|
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.post("/login")
|
||||||
|
def login():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = (data.get("username") or "").strip()
|
||||||
|
password = data.get("password") or ""
|
||||||
|
|
||||||
|
generic_error = (jsonify({"error": "Invalid credentials"}), 401)
|
||||||
|
if not username or not password:
|
||||||
|
return generic_error
|
||||||
|
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if user is None or not verify_password(user.password_hash, password):
|
||||||
|
return generic_error
|
||||||
|
|
||||||
|
token = encode_token(user.id, user.role.value)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"access_token": token,
|
||||||
|
"user": {"id": user.id, "username": user.username, "role": user.role.value},
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.post("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
# V1: stateless JWT — client discards the token. No server-side blacklist.
|
||||||
|
return jsonify({"status": "ok"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.get("/me")
|
||||||
|
@login_required
|
||||||
|
def me():
|
||||||
|
return jsonify(serialize_user(g.current_user)), 200
|
||||||
517
backend/app/api/c2.py
Normal file
517
backend/app/api/c2.py
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
"""C2 endpoints — config CRUD and execution.
|
||||||
|
|
||||||
|
All endpoints:
|
||||||
|
- Require admin or redteam role (SOC → 403).
|
||||||
|
- Return 503 when MIMIC_ENCRYPTION_KEY is not set.
|
||||||
|
- Never include the cleartext API token in any response.
|
||||||
|
- Adapter errors → 502 with sanitized message (no URL or token in body).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth import role_required
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import Engagement
|
||||||
|
from backend.app.models.c2_config import C2Config
|
||||||
|
from backend.app.models.c2_task import C2Task, C2TaskSource
|
||||||
|
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||||
|
from backend.app.services.c2.adapter import C2Error
|
||||||
|
from backend.app.services.c2.factory import get_adapter
|
||||||
|
from backend.app.services.c2.mapping import apply_task_to_simulation
|
||||||
|
from backend.app.services.crypto import C2Disabled, decrypt, encrypt
|
||||||
|
from backend.app.services.simulation_workflow import promote_to_in_progress
|
||||||
|
|
||||||
|
c2_bp = Blueprint("c2", __name__, url_prefix="/api/engagements")
|
||||||
|
sims_c2_bp = Blueprint("sims_c2", __name__, url_prefix="/api/simulations")
|
||||||
|
|
||||||
|
_503_BODY = {"error": "C2 disabled: MIMIC_ENCRYPTION_KEY not set"}
|
||||||
|
|
||||||
|
|
||||||
|
def _crypto_guard():
|
||||||
|
"""Return a 503 Response when crypto key is absent, else None."""
|
||||||
|
try:
|
||||||
|
# Attempt a dummy operation to test key availability.
|
||||||
|
encrypt("probe")
|
||||||
|
return None
|
||||||
|
except C2Disabled:
|
||||||
|
return jsonify(_503_BODY), 503
|
||||||
|
|
||||||
|
|
||||||
|
@c2_bp.get("/<int:eid>/c2-config")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def get_c2_config(eid: int):
|
||||||
|
guard = _crypto_guard()
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
engagement = db.session.get(Engagement, eid)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
cfg: C2Config | None = engagement.c2_config
|
||||||
|
if cfg is None:
|
||||||
|
return jsonify({"error": "C2 config not found"}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"has_token": bool(cfg.api_token_encrypted),
|
||||||
|
"url": cfg.url,
|
||||||
|
"verify_tls": cfg.verify_tls,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@c2_bp.put("/<int:eid>/c2-config")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def upsert_c2_config(eid: int):
|
||||||
|
guard = _crypto_guard()
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
engagement = db.session.get(Engagement, eid)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
url = (data.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return jsonify({"error": "url is required"}), 400
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme != "https":
|
||||||
|
return jsonify({"error": "url must use https"}), 400
|
||||||
|
if not parsed.hostname:
|
||||||
|
return jsonify({"error": "url must contain a hostname"}), 400
|
||||||
|
|
||||||
|
verify_tls = data.get("verify_tls", True)
|
||||||
|
if not isinstance(verify_tls, bool):
|
||||||
|
return jsonify({"error": "verify_tls must be a boolean"}), 400
|
||||||
|
|
||||||
|
cfg: C2Config | None = engagement.c2_config
|
||||||
|
|
||||||
|
if cfg is None:
|
||||||
|
# New row — api_token is required on creation.
|
||||||
|
raw_token = data.get("api_token") or ""
|
||||||
|
if not raw_token:
|
||||||
|
return jsonify({"error": "api_token is required when creating a config"}), 400
|
||||||
|
encrypted = encrypt(raw_token)
|
||||||
|
cfg = C2Config(
|
||||||
|
engagement_id=eid,
|
||||||
|
url=url,
|
||||||
|
api_token_encrypted=encrypted,
|
||||||
|
verify_tls=verify_tls,
|
||||||
|
)
|
||||||
|
db.session.add(cfg)
|
||||||
|
else:
|
||||||
|
# Update — omitting api_token keeps the existing ciphertext.
|
||||||
|
cfg.url = url
|
||||||
|
cfg.verify_tls = verify_tls
|
||||||
|
cfg.updated_at = datetime.now(UTC)
|
||||||
|
raw_token = data.get("api_token") or ""
|
||||||
|
if raw_token:
|
||||||
|
cfg.api_token_encrypted = encrypt(raw_token)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
"has_token": True,
|
||||||
|
"url": cfg.url,
|
||||||
|
"verify_tls": cfg.verify_tls,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@c2_bp.delete("/<int:eid>/c2-config")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def delete_c2_config(eid: int):
|
||||||
|
guard = _crypto_guard()
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
engagement = db.session.get(Engagement, eid)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
cfg: C2Config | None = engagement.c2_config
|
||||||
|
if cfg is None:
|
||||||
|
return jsonify({"error": "C2 config not found"}), 404
|
||||||
|
|
||||||
|
db.session.delete(cfg)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@c2_bp.post("/<int:eid>/c2-config/test")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def test_c2_config(eid: int):
|
||||||
|
guard = _crypto_guard()
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
engagement = db.session.get(Engagement, eid)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
cfg: C2Config | None = engagement.c2_config
|
||||||
|
if cfg is None:
|
||||||
|
return jsonify({"error": "C2 config not found"}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
api_token = decrypt(cfg.api_token_encrypted)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"ok": False, "error": "Stored token is corrupt"}), 200
|
||||||
|
|
||||||
|
adapter = get_adapter(
|
||||||
|
url=cfg.url,
|
||||||
|
api_token=api_token,
|
||||||
|
verify_tls=cfg.verify_tls,
|
||||||
|
)
|
||||||
|
health = adapter.test_connection()
|
||||||
|
return jsonify({"ok": health.ok, "error": health.error}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M2 — callbacks listing + execute
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _load_adapter_for_engagement(engagement: Engagement):
|
||||||
|
"""Decrypt token and return adapter, or return a (response, status) error tuple."""
|
||||||
|
cfg: C2Config | None = engagement.c2_config
|
||||||
|
if cfg is None:
|
||||||
|
return None, (jsonify({"error": "C2 config not found"}), 404)
|
||||||
|
try:
|
||||||
|
api_token = decrypt(cfg.api_token_encrypted)
|
||||||
|
except ValueError:
|
||||||
|
return None, (jsonify({"error": "Stored token is corrupt"}), 500)
|
||||||
|
adapter = get_adapter(url=cfg.url, api_token=api_token, verify_tls=cfg.verify_tls)
|
||||||
|
return adapter, None
|
||||||
|
|
||||||
|
|
||||||
|
@c2_bp.get("/<int:eid>/c2/callbacks")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def list_callbacks(eid: int):
|
||||||
|
guard = _crypto_guard()
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
engagement = db.session.get(Engagement, eid)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
adapter, err = _load_adapter_for_engagement(engagement)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
try:
|
||||||
|
callbacks = adapter.list_callbacks()
|
||||||
|
except C2Error as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"callbacks": [
|
||||||
|
{
|
||||||
|
"display_id": cb.display_id,
|
||||||
|
"active": cb.active,
|
||||||
|
"host": cb.host,
|
||||||
|
"user": cb.user,
|
||||||
|
"domain": cb.domain,
|
||||||
|
"last_checkin": cb.last_checkin,
|
||||||
|
}
|
||||||
|
for cb in callbacks
|
||||||
|
]
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@sims_c2_bp.post("/<int:sid>/c2/execute")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def execute_simulation(sid: int):
|
||||||
|
guard = _crypto_guard()
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
sim = db.session.get(Simulation, sid)
|
||||||
|
if sim is None:
|
||||||
|
return jsonify({"error": "Simulation not found"}), 404
|
||||||
|
|
||||||
|
# Done is terminal — block execution.
|
||||||
|
if sim.status == SimulationStatus.DONE:
|
||||||
|
return jsonify({"error": "simulation is done — reopen first"}), 409
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
callback_display_id = data.get("callback_display_id")
|
||||||
|
commands = data.get("commands")
|
||||||
|
|
||||||
|
if not isinstance(callback_display_id, int):
|
||||||
|
return jsonify({"error": "callback_display_id must be an integer"}), 400
|
||||||
|
if not isinstance(commands, list) or len(commands) == 0:
|
||||||
|
return jsonify({"error": "commands must be a non-empty list"}), 400
|
||||||
|
for cmd in commands:
|
||||||
|
if not isinstance(cmd, str):
|
||||||
|
return jsonify({"error": "each command must be a string"}), 400
|
||||||
|
|
||||||
|
engagement = db.session.get(Engagement, sim.engagement_id)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
adapter, err = _load_adapter_for_engagement(engagement)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
created_tasks = []
|
||||||
|
try:
|
||||||
|
for command in commands:
|
||||||
|
mythic_id = adapter.create_task(
|
||||||
|
callback_display_id=callback_display_id,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
task = C2Task(
|
||||||
|
simulation_id=sid,
|
||||||
|
mythic_task_display_id=mythic_id,
|
||||||
|
callback_display_id=callback_display_id,
|
||||||
|
command=command,
|
||||||
|
params=None,
|
||||||
|
status="submitted",
|
||||||
|
completed=False,
|
||||||
|
source=C2TaskSource.MIMIC,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.session.add(task)
|
||||||
|
created_tasks.append(task)
|
||||||
|
except C2Error as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
|
||||||
|
# Auto-transition pending → in_progress (no-op for other statuses).
|
||||||
|
promote_to_in_progress(sim)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"mythic_task_display_id": t.mythic_task_display_id,
|
||||||
|
"command": t.command,
|
||||||
|
"status": t.status,
|
||||||
|
"completed": t.completed,
|
||||||
|
}
|
||||||
|
for t in created_tasks
|
||||||
|
]
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M3 — poll-on-read task listing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@sims_c2_bp.get("/<int:sid>/c2/tasks")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def list_simulation_tasks(sid: int):
|
||||||
|
guard = _crypto_guard()
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
sim = db.session.get(Simulation, sid)
|
||||||
|
if sim is None:
|
||||||
|
return jsonify({"error": "Simulation not found"}), 404
|
||||||
|
|
||||||
|
engagement = db.session.get(Engagement, sim.engagement_id)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
adapter, err = _load_adapter_for_engagement(engagement)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
tasks: list[C2Task] = C2Task.query.filter_by(simulation_id=sid).all()
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
if task.completed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = adapter.get_task(task.mythic_task_display_id)
|
||||||
|
except C2Error:
|
||||||
|
# Best-effort refresh — skip this task if the adapter fails.
|
||||||
|
continue
|
||||||
|
|
||||||
|
task.status = status.status
|
||||||
|
task.completed = status.completed
|
||||||
|
|
||||||
|
if status.completed:
|
||||||
|
task.completed_at = status.completed_at or datetime.now(UTC)
|
||||||
|
try:
|
||||||
|
task.output = adapter.get_task_output(task.mythic_task_display_id)
|
||||||
|
except C2Error:
|
||||||
|
task.output = ""
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"mythic_task_display_id": t.mythic_task_display_id,
|
||||||
|
"callback_display_id": t.callback_display_id,
|
||||||
|
"command": t.command,
|
||||||
|
"params": t.params,
|
||||||
|
"status": t.status,
|
||||||
|
"completed": t.completed,
|
||||||
|
"output": t.output,
|
||||||
|
"source": t.source.value,
|
||||||
|
"mapping_applied": t.mapping_applied,
|
||||||
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||||
|
"completed_at": t.completed_at.isoformat() if t.completed_at else None,
|
||||||
|
}
|
||||||
|
for t in tasks
|
||||||
|
]
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# M4 — callback history + task import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@c2_bp.get("/<int:eid>/c2/callbacks/<int:cid>/history")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def list_callback_history(eid: int, cid: int):
|
||||||
|
guard = _crypto_guard()
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
engagement = db.session.get(Engagement, eid)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
# Validate pagination params.
|
||||||
|
try:
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
page_size = int(request.args.get("page_size", 25))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({"error": "page and page_size must be integers"}), 400
|
||||||
|
|
||||||
|
if page < 1 or page_size < 1:
|
||||||
|
return jsonify({"error": "page and page_size must be >= 1"}), 400
|
||||||
|
if page_size > 100:
|
||||||
|
return jsonify({"error": "page_size must be <= 100"}), 400
|
||||||
|
|
||||||
|
adapter, err = _load_adapter_for_engagement(engagement)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
try:
|
||||||
|
page_result = adapter.list_callback_tasks(
|
||||||
|
callback_display_id=cid,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
except C2Error as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"display_id": t.display_id,
|
||||||
|
"command": t.command,
|
||||||
|
"params": t.params,
|
||||||
|
"status": t.status,
|
||||||
|
"completed": t.completed,
|
||||||
|
"timestamp": t.timestamp,
|
||||||
|
}
|
||||||
|
for t in page_result.items
|
||||||
|
],
|
||||||
|
"total": page_result.total,
|
||||||
|
"page": page_result.page,
|
||||||
|
"page_size": page_result.page_size,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@sims_c2_bp.post("/<int:sid>/c2/import")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def import_tasks(sid: int):
|
||||||
|
guard = _crypto_guard()
|
||||||
|
if guard is not None:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
sim = db.session.get(Simulation, sid)
|
||||||
|
if sim is None:
|
||||||
|
return jsonify({"error": "Simulation not found"}), 404
|
||||||
|
|
||||||
|
if sim.status == SimulationStatus.DONE:
|
||||||
|
return jsonify({"error": "simulation is done — reopen first"}), 409
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
callback_display_id = data.get("callback_display_id")
|
||||||
|
task_display_ids = data.get("task_display_ids")
|
||||||
|
|
||||||
|
if not isinstance(callback_display_id, int):
|
||||||
|
return jsonify({"error": "callback_display_id must be an integer"}), 400
|
||||||
|
if not isinstance(task_display_ids, list) or len(task_display_ids) == 0:
|
||||||
|
return jsonify({"error": "task_display_ids must be a non-empty list"}), 400
|
||||||
|
for tid in task_display_ids:
|
||||||
|
if not isinstance(tid, int):
|
||||||
|
return jsonify({"error": "each task_display_id must be an integer"}), 400
|
||||||
|
|
||||||
|
engagement = db.session.get(Engagement, sim.engagement_id)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
adapter, err = _load_adapter_for_engagement(engagement)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for task_display_id in task_display_ids:
|
||||||
|
# Idempotency: skip if already imported for this simulation.
|
||||||
|
existing = C2Task.query.filter_by(
|
||||||
|
simulation_id=sid,
|
||||||
|
mythic_task_display_id=task_display_id,
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = adapter.get_task(task_display_id)
|
||||||
|
task = C2Task(
|
||||||
|
simulation_id=sid,
|
||||||
|
mythic_task_display_id=task_display_id,
|
||||||
|
callback_display_id=callback_display_id,
|
||||||
|
command=status.command or "",
|
||||||
|
params=None,
|
||||||
|
status=status.status,
|
||||||
|
completed=status.completed,
|
||||||
|
source=C2TaskSource.IMPORT,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
mapping_applied=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status.completed:
|
||||||
|
task.completed_at = status.completed_at or datetime.now(UTC)
|
||||||
|
try:
|
||||||
|
task.output = adapter.get_task_output(task_display_id)
|
||||||
|
except C2Error:
|
||||||
|
task.output = ""
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.flush()
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
else:
|
||||||
|
db.session.add(task)
|
||||||
|
|
||||||
|
imported_count += 1
|
||||||
|
|
||||||
|
except C2Error as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
|
||||||
|
# Auto-transition pending → in_progress when at least one task was imported.
|
||||||
|
if imported_count > 0:
|
||||||
|
promote_to_in_progress(sim)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"imported": imported_count, "skipped": skipped_count}), 200
|
||||||
210
backend/app/api/engagements.py
Normal file
210
backend/app/api/engagements.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""Engagement CRUD endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, g, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth import login_required, role_required
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import Engagement, EngagementStatus
|
||||||
|
from backend.app.models.simulation import Simulation
|
||||||
|
from backend.app.serializers import serialize_engagement
|
||||||
|
from backend.app.services.export import (
|
||||||
|
_export_filename,
|
||||||
|
render_engagement_csv,
|
||||||
|
render_engagement_markdown,
|
||||||
|
render_engagement_pdf,
|
||||||
|
)
|
||||||
|
|
||||||
|
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: object) -> date | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_status(value: object) -> EngagementStatus | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return EngagementStatus(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.get("")
|
||||||
|
@login_required
|
||||||
|
def list_engagements():
|
||||||
|
items = Engagement.query.order_by(Engagement.id.asc()).all()
|
||||||
|
return jsonify([serialize_engagement(e) for e in items]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.post("")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def create_engagement():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
|
||||||
|
start_raw = data.get("start_date")
|
||||||
|
start_date = _parse_date(start_raw) if start_raw else None
|
||||||
|
if start_date is None:
|
||||||
|
return jsonify({"error": "start_date is required (YYYY-MM-DD)"}), 400
|
||||||
|
|
||||||
|
end_raw = data.get("end_date")
|
||||||
|
end_date: date | None = None
|
||||||
|
if end_raw:
|
||||||
|
end_date = _parse_date(end_raw)
|
||||||
|
if end_date is None:
|
||||||
|
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
|
||||||
|
if end_date < start_date:
|
||||||
|
return jsonify({"error": "end_date must be >= start_date"}), 400
|
||||||
|
|
||||||
|
status = EngagementStatus.PLANNED
|
||||||
|
if "status" in data and data.get("status") is not None:
|
||||||
|
parsed = _parse_status(data.get("status"))
|
||||||
|
if parsed is None:
|
||||||
|
return (
|
||||||
|
jsonify({"error": "status must be one of: planned, active, closed"}),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
status = parsed
|
||||||
|
|
||||||
|
engagement = Engagement(
|
||||||
|
name=name,
|
||||||
|
description=data.get("description"),
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
status=status,
|
||||||
|
created_by_id=g.current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(engagement)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_engagement(engagement)), 201
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.get("/<int:engagement_id>")
|
||||||
|
@login_required
|
||||||
|
def get_engagement(engagement_id: int):
|
||||||
|
engagement = db.session.get(Engagement, engagement_id)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
return jsonify(serialize_engagement(engagement)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.patch("/<int:engagement_id>")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def update_engagement(engagement_id: int):
|
||||||
|
engagement = db.session.get(Engagement, engagement_id)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
if "name" in data:
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name must not be empty"}), 400
|
||||||
|
engagement.name = name
|
||||||
|
|
||||||
|
if "description" in data:
|
||||||
|
engagement.description = data.get("description")
|
||||||
|
|
||||||
|
new_start = engagement.start_date
|
||||||
|
if "start_date" in data:
|
||||||
|
parsed = _parse_date(data.get("start_date"))
|
||||||
|
if parsed is None:
|
||||||
|
return jsonify({"error": "start_date must be YYYY-MM-DD"}), 400
|
||||||
|
new_start = parsed
|
||||||
|
|
||||||
|
new_end = engagement.end_date
|
||||||
|
if "end_date" in data:
|
||||||
|
if data.get("end_date") in (None, ""):
|
||||||
|
new_end = None
|
||||||
|
else:
|
||||||
|
parsed = _parse_date(data.get("end_date"))
|
||||||
|
if parsed is None:
|
||||||
|
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
|
||||||
|
new_end = parsed
|
||||||
|
|
||||||
|
if new_end is not None and new_end < new_start:
|
||||||
|
return jsonify({"error": "end_date must be >= start_date"}), 400
|
||||||
|
|
||||||
|
engagement.start_date = new_start
|
||||||
|
engagement.end_date = new_end
|
||||||
|
|
||||||
|
if "status" in data:
|
||||||
|
parsed_status = _parse_status((data.get("status") or "").strip())
|
||||||
|
if parsed_status is None:
|
||||||
|
return (
|
||||||
|
jsonify({"error": "status must be one of: planned, active, closed"}),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
engagement.status = parsed_status
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_engagement(engagement)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.delete("/<int:engagement_id>")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def delete_engagement(engagement_id: int):
|
||||||
|
engagement = db.session.get(Engagement, engagement_id)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
db.session.delete(engagement)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@engagements_bp.get("/<int:eid>/export")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def export_engagement(eid: int):
|
||||||
|
engagement = db.session.get(Engagement, eid)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
fmt = request.args.get("format", "").strip().lower()
|
||||||
|
if fmt not in ("md", "csv", "pdf"):
|
||||||
|
return jsonify({"error": "format must be one of: md, csv, pdf"}), 400
|
||||||
|
|
||||||
|
simulations = (
|
||||||
|
Simulation.query.filter_by(engagement_id=eid)
|
||||||
|
.order_by(Simulation.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if fmt == "md":
|
||||||
|
body = render_engagement_markdown(engagement, simulations)
|
||||||
|
filename = _export_filename(engagement, "md")
|
||||||
|
return Response(
|
||||||
|
body,
|
||||||
|
mimetype="text/markdown; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
if fmt == "csv":
|
||||||
|
body = render_engagement_csv(engagement, simulations)
|
||||||
|
filename = _export_filename(engagement, "csv")
|
||||||
|
return Response(
|
||||||
|
body,
|
||||||
|
mimetype="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
# pdf
|
||||||
|
body_bytes = render_engagement_pdf(engagement, simulations)
|
||||||
|
filename = _export_filename(engagement, "pdf")
|
||||||
|
return Response(
|
||||||
|
body_bytes,
|
||||||
|
mimetype="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
172
backend/app/api/simulations.py
Normal file
172
backend/app/api/simulations.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""Simulation CRUD + workflow endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from flask import Blueprint, g, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth import login_required, role_required
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import Engagement
|
||||||
|
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||||
|
from backend.app.serializers import serialize_simulation
|
||||||
|
from backend.app.services import simulation_workflow
|
||||||
|
|
||||||
|
simulations_bp = Blueprint("simulations", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nested under /api/engagements/<eid>/simulations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@simulations_bp.get("/api/engagements/<int:eid>/simulations")
|
||||||
|
@login_required
|
||||||
|
def list_simulations(eid: int):
|
||||||
|
engagement = db.session.get(Engagement, eid)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
sims = (
|
||||||
|
Simulation.query.filter_by(engagement_id=eid)
|
||||||
|
.order_by(Simulation.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return jsonify([serialize_simulation(s) for s in sims]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@simulations_bp.post("/api/engagements/<int:eid>/simulations")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def create_simulation(eid: int):
|
||||||
|
engagement = db.session.get(Engagement, eid)
|
||||||
|
if engagement is None:
|
||||||
|
return jsonify({"error": "Engagement not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
template_id = data.get("template_id")
|
||||||
|
|
||||||
|
if template_id is not None:
|
||||||
|
from backend.app.models.simulation_template import SimulationTemplate
|
||||||
|
|
||||||
|
tmpl = db.session.get(SimulationTemplate, template_id)
|
||||||
|
if tmpl is None:
|
||||||
|
return jsonify({"error": "Template not found"}), 404
|
||||||
|
if not name:
|
||||||
|
name = tmpl.name
|
||||||
|
sim = Simulation(
|
||||||
|
engagement_id=eid,
|
||||||
|
name=name,
|
||||||
|
status=SimulationStatus.PENDING,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
created_by_id=g.current_user.id,
|
||||||
|
)
|
||||||
|
sim.description = tmpl.description
|
||||||
|
sim.commands = tmpl.commands
|
||||||
|
sim.prerequisites = tmpl.prerequisites
|
||||||
|
sim.techniques = list(tmpl.techniques or [])
|
||||||
|
sim.tactic_ids = list(tmpl.tactic_ids or [])
|
||||||
|
else:
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
sim = Simulation(
|
||||||
|
engagement_id=eid,
|
||||||
|
name=name,
|
||||||
|
status=SimulationStatus.PENDING,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
created_by_id=g.current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(sim)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_simulation(sim)), 201
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flat /api/simulations/<sid>
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@simulations_bp.get("/api/simulations/<int:sid>")
|
||||||
|
@login_required
|
||||||
|
def get_simulation(sid: int):
|
||||||
|
sim = db.session.get(Simulation, sid)
|
||||||
|
if sim is None:
|
||||||
|
return jsonify({"error": "Simulation not found"}), 404
|
||||||
|
return jsonify(serialize_simulation(sim)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@simulations_bp.patch("/api/simulations/<int:sid>")
|
||||||
|
@login_required
|
||||||
|
def update_simulation(sid: int):
|
||||||
|
sim = db.session.get(Simulation, sid)
|
||||||
|
if sim is None:
|
||||||
|
return jsonify({"error": "Simulation not found"}), 404
|
||||||
|
|
||||||
|
user = g.current_user
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not data:
|
||||||
|
return jsonify(serialize_simulation(sim)), 200
|
||||||
|
|
||||||
|
err = simulation_workflow.apply_patch(sim, data, user)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_simulation(sim)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@simulations_bp.delete("/api/simulations/<int:sid>")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def delete_simulation(sid: int):
|
||||||
|
sim = db.session.get(Simulation, sid)
|
||||||
|
if sim is None:
|
||||||
|
return jsonify({"error": "Simulation not found"}), 404
|
||||||
|
db.session.delete(sim)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@simulations_bp.post("/api/simulations/<int:sid>/transition")
|
||||||
|
@login_required
|
||||||
|
def transition_simulation(sid: int):
|
||||||
|
sim = db.session.get(Simulation, sid)
|
||||||
|
if sim is None:
|
||||||
|
return jsonify({"error": "Simulation not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
to_status = data.get("to", "")
|
||||||
|
|
||||||
|
err = simulation_workflow.transition(sim, to_status, g.current_user)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
return jsonify(serialize_simulation(sim)), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MITRE autocomplete + matrix
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@simulations_bp.get("/api/mitre/techniques")
|
||||||
|
@login_required
|
||||||
|
def mitre_techniques():
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
|
||||||
|
if not mitre_svc.mitre_loaded:
|
||||||
|
return jsonify({"error": "mitre bundle not loaded"}), 503
|
||||||
|
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
results = mitre_svc.search(q)
|
||||||
|
return jsonify(results), 200
|
||||||
|
|
||||||
|
|
||||||
|
@simulations_bp.get("/api/mitre/matrix")
|
||||||
|
@login_required
|
||||||
|
def mitre_matrix():
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
|
||||||
|
if not mitre_svc.mitre_loaded:
|
||||||
|
return jsonify({"error": "mitre bundle not loaded"}), 503
|
||||||
|
|
||||||
|
return jsonify(mitre_svc.get_matrix()), 200
|
||||||
151
backend/app/api/templates.py
Normal file
151
backend/app/api/templates.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""SimulationTemplate CRUD endpoints — admin and redteam only."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import sqlalchemy.exc
|
||||||
|
from flask import Blueprint, g, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth import role_required
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models.simulation_template import SimulationTemplate
|
||||||
|
from backend.app.serializers import serialize_template
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
from backend.app.services.simulation_workflow import (
|
||||||
|
_resolve_tactic_ids,
|
||||||
|
_resolve_technique_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates_bp = Blueprint("templates", __name__)
|
||||||
|
|
||||||
|
_MUTABLE_FIELDS = {"name", "description", "commands", "prerequisites", "technique_ids", "tactic_ids"}
|
||||||
|
|
||||||
|
|
||||||
|
@templates_bp.get("/api/templates")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def list_templates():
|
||||||
|
items = SimulationTemplate.query.order_by(SimulationTemplate.name).all()
|
||||||
|
return jsonify([serialize_template(t) for t in items]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@templates_bp.post("/api/templates")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def create_template():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
|
||||||
|
techniques: list[dict] = []
|
||||||
|
tactic_ids_val: list[str] = []
|
||||||
|
|
||||||
|
if "technique_ids" in data:
|
||||||
|
if not isinstance(data["technique_ids"], list):
|
||||||
|
return jsonify({"error": "technique_ids must be a list"}), 400
|
||||||
|
if not mitre_svc.mitre_loaded:
|
||||||
|
return jsonify({"error": "mitre bundle not loaded"}), 503
|
||||||
|
resolved, err = _resolve_technique_ids(data["technique_ids"])
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
techniques = resolved or []
|
||||||
|
|
||||||
|
if "tactic_ids" in data:
|
||||||
|
if not isinstance(data["tactic_ids"], list):
|
||||||
|
return jsonify({"error": "tactic_ids must be a list"}), 400
|
||||||
|
resolved_ta, err = _resolve_tactic_ids(data["tactic_ids"])
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
tactic_ids_val = resolved_ta or []
|
||||||
|
|
||||||
|
tmpl = SimulationTemplate(
|
||||||
|
name=name,
|
||||||
|
description=data.get("description"),
|
||||||
|
commands=data.get("commands"),
|
||||||
|
prerequisites=data.get("prerequisites"),
|
||||||
|
techniques=techniques,
|
||||||
|
tactic_ids=tactic_ids_val,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
created_by_id=g.current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(tmpl)
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except sqlalchemy.exc.IntegrityError:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"error": "template name already exists"}), 409
|
||||||
|
|
||||||
|
return jsonify(serialize_template(tmpl)), 201
|
||||||
|
|
||||||
|
|
||||||
|
@templates_bp.get("/api/templates/<int:tid>")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def get_template(tid: int):
|
||||||
|
tmpl = db.session.get(SimulationTemplate, tid)
|
||||||
|
if tmpl is None:
|
||||||
|
return jsonify({"error": "Template not found"}), 404
|
||||||
|
return jsonify(serialize_template(tmpl)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@templates_bp.patch("/api/templates/<int:tid>")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def update_template(tid: int):
|
||||||
|
tmpl = db.session.get(SimulationTemplate, tid)
|
||||||
|
if tmpl is None:
|
||||||
|
return jsonify({"error": "Template not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
unknown = set(data.keys()) - _MUTABLE_FIELDS
|
||||||
|
if unknown:
|
||||||
|
return jsonify({"error": f"unknown fields: {sorted(unknown)}"}), 400
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify(serialize_template(tmpl)), 200
|
||||||
|
|
||||||
|
if "name" in data:
|
||||||
|
name = (data["name"] or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name cannot be empty"}), 400
|
||||||
|
tmpl.name = name
|
||||||
|
|
||||||
|
for field in ("description", "commands", "prerequisites"):
|
||||||
|
if field in data:
|
||||||
|
setattr(tmpl, field, data[field])
|
||||||
|
|
||||||
|
if "technique_ids" in data:
|
||||||
|
if not isinstance(data["technique_ids"], list):
|
||||||
|
return jsonify({"error": "technique_ids must be a list"}), 400
|
||||||
|
if not mitre_svc.mitre_loaded:
|
||||||
|
return jsonify({"error": "mitre bundle not loaded"}), 503
|
||||||
|
resolved, err = _resolve_technique_ids(data["technique_ids"])
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
tmpl.techniques = resolved
|
||||||
|
|
||||||
|
if "tactic_ids" in data:
|
||||||
|
if not isinstance(data["tactic_ids"], list):
|
||||||
|
return jsonify({"error": "tactic_ids must be a list"}), 400
|
||||||
|
resolved_ta, err = _resolve_tactic_ids(data["tactic_ids"])
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
tmpl.tactic_ids = resolved_ta
|
||||||
|
|
||||||
|
tmpl.updated_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except sqlalchemy.exc.IntegrityError:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({"error": "template name already exists"}), 409
|
||||||
|
|
||||||
|
return jsonify(serialize_template(tmpl)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@templates_bp.delete("/api/templates/<int:tid>")
|
||||||
|
@role_required("admin", "redteam")
|
||||||
|
def delete_template(tid: int):
|
||||||
|
tmpl = db.session.get(SimulationTemplate, tid)
|
||||||
|
if tmpl is None:
|
||||||
|
return jsonify({"error": "Template not found"}), 404
|
||||||
|
db.session.delete(tmpl)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
106
backend/app/api/users.py
Normal file
106
backend/app/api/users.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""User management endpoints (admin only)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth import hash_password, role_required
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
from backend.app.serializers import serialize_user
|
||||||
|
|
||||||
|
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_role(value: object) -> UserRole | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return UserRole(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.get("")
|
||||||
|
@role_required("admin")
|
||||||
|
def list_users():
|
||||||
|
users = User.query.order_by(User.id.asc()).all()
|
||||||
|
return jsonify([serialize_user(u) for u in users]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.post("")
|
||||||
|
@role_required("admin")
|
||||||
|
def create_user():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = (data.get("username") or "").strip()
|
||||||
|
password = data.get("password") or ""
|
||||||
|
role_raw = (data.get("role") or "").strip()
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
return jsonify({"error": "username is required"}), 400
|
||||||
|
|
||||||
|
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
|
||||||
|
if len(password) < min_len:
|
||||||
|
return jsonify({"error": f"password must be at least {min_len} characters"}), 400
|
||||||
|
|
||||||
|
role = _parse_role(role_raw)
|
||||||
|
if role is None:
|
||||||
|
return jsonify({"error": "role must be one of: admin, redteam, soc"}), 400
|
||||||
|
|
||||||
|
if User.query.filter_by(username=username).first() is not None:
|
||||||
|
return jsonify({"error": "username already exists"}), 400
|
||||||
|
|
||||||
|
user = User(username=username, password_hash=hash_password(password), role=role)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_user(user)), 201
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.patch("/<int:user_id>")
|
||||||
|
@role_required("admin")
|
||||||
|
def update_user(user_id: int):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
if "role" in data:
|
||||||
|
new_role = _parse_role((data.get("role") or "").strip())
|
||||||
|
if new_role is None:
|
||||||
|
return jsonify({"error": "role must be one of: admin, redteam, soc"}), 400
|
||||||
|
# Refuse to demote the last admin.
|
||||||
|
if user.role == UserRole.ADMIN and new_role != UserRole.ADMIN:
|
||||||
|
admin_count = User.query.filter_by(role=UserRole.ADMIN).count()
|
||||||
|
if admin_count <= 1:
|
||||||
|
return jsonify({"error": "Cannot demote the last admin"}), 409
|
||||||
|
user.role = new_role
|
||||||
|
|
||||||
|
if "password" in data:
|
||||||
|
password = data.get("password") or ""
|
||||||
|
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
|
||||||
|
if len(password) < min_len:
|
||||||
|
return (
|
||||||
|
jsonify({"error": f"password must be at least {min_len} characters"}),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
user.password_hash = hash_password(password)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(serialize_user(user)), 200
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.delete("/<int:user_id>")
|
||||||
|
@role_required("admin")
|
||||||
|
def delete_user(user_id: int):
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
if user.role == UserRole.ADMIN:
|
||||||
|
admin_count = User.query.filter_by(role=UserRole.ADMIN).count()
|
||||||
|
if admin_count <= 1:
|
||||||
|
return jsonify({"error": "Cannot delete the last admin"}), 409
|
||||||
|
|
||||||
|
db.session.delete(user)
|
||||||
|
db.session.commit()
|
||||||
|
return "", 204
|
||||||
13
backend/app/auth/__init__.py
Normal file
13
backend/app/auth/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Auth helpers (JWT, hashing, decorators)."""
|
||||||
|
from backend.app.auth.decorators import login_required, role_required
|
||||||
|
from backend.app.auth.hashing import hash_password, verify_password
|
||||||
|
from backend.app.auth.jwt import decode_token, encode_token
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"hash_password",
|
||||||
|
"verify_password",
|
||||||
|
"encode_token",
|
||||||
|
"decode_token",
|
||||||
|
"login_required",
|
||||||
|
"role_required",
|
||||||
|
]
|
||||||
67
backend/app/auth/decorators.py
Normal file
67
backend/app/auth/decorators.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Auth decorators that gate routes on a valid JWT and (optionally) role."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from flask import g, jsonify, request
|
||||||
|
|
||||||
|
from backend.app.auth.jwt import decode_token
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token() -> str | None:
|
||||||
|
header = request.headers.get("Authorization", "")
|
||||||
|
if not header.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
return header.removeprefix("Bearer ").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
"""Require a valid JWT. Populates `g.current_user` with the User row."""
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
token = _extract_token()
|
||||||
|
if not token:
|
||||||
|
return jsonify({"error": "Missing or invalid Authorization header"}), 401
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return jsonify({"error": "Token expired"}), 401
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return jsonify({"error": "Invalid token"}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = int(payload["sub"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return jsonify({"error": "Invalid token payload"}), 401
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return jsonify({"error": "User no longer exists"}), 401
|
||||||
|
|
||||||
|
g.current_user = user
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def role_required(*roles: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||||
|
"""Require the current user to hold one of the given role names."""
|
||||||
|
|
||||||
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
@wraps(fn)
|
||||||
|
@login_required
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
user = g.current_user
|
||||||
|
if user.role.value not in roles:
|
||||||
|
return jsonify({"error": "Forbidden"}), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
23
backend/app/auth/hashing.py
Normal file
23
backend/app/auth/hashing.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Password hashing using argon2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Return an argon2 hash of `password`."""
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password_hash: str, password: str) -> bool:
|
||||||
|
"""Return True iff `password` matches `password_hash`."""
|
||||||
|
try:
|
||||||
|
return _hasher.verify(password_hash, password)
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
# Malformed hash or other argon2 error — treat as auth failure.
|
||||||
|
return False
|
||||||
34
backend/app/auth/jwt.py
Normal file
34
backend/app/auth/jwt.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""JWT encode/decode helpers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
def encode_token(user_id: int, role: str) -> str:
|
||||||
|
"""Return a signed JWT for the given user."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
exp_minutes = current_app.config["JWT_EXP_MINUTES"]
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"role": role,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(minutes=exp_minutes)).timestamp()),
|
||||||
|
}
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
current_app.config["JWT_SECRET"],
|
||||||
|
algorithm=current_app.config["JWT_ALGORITHM"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict[str, Any]:
|
||||||
|
"""Decode + validate a JWT. Raises jwt.PyJWTError on failure."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
current_app.config["JWT_SECRET"],
|
||||||
|
algorithms=[current_app.config["JWT_ALGORITHM"]],
|
||||||
|
)
|
||||||
38
backend/app/cli.py
Normal file
38
backend/app/cli.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Flask CLI commands."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
from flask import Flask, current_app
|
||||||
|
|
||||||
|
from backend.app.auth import hash_password
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
def register_cli(app: Flask) -> None:
|
||||||
|
@app.cli.command("create-admin")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.argument("password")
|
||||||
|
def create_admin(username: str, password: str) -> None:
|
||||||
|
"""Create an admin user. Used to bootstrap the first account."""
|
||||||
|
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
|
||||||
|
if len(password) < min_len:
|
||||||
|
click.echo(
|
||||||
|
f"Error: password must be at least {min_len} characters.", err=True
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if User.query.filter_by(username=username).first() is not None:
|
||||||
|
click.echo(f"Error: username '{username}' already exists.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(f"Admin user '{username}' created (id={user.id}).")
|
||||||
35
backend/app/config.py
Normal file
35
backend/app/config.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Application configuration loaded from environment variables."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Base configuration. Reads from env vars; fails loud on missing secrets."""
|
||||||
|
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
JWT_EXP_MINUTES = 60
|
||||||
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
db_path = os.environ.get("MIMIC_DB_PATH", "/data/mimic.sqlite")
|
||||||
|
self.SQLALCHEMY_DATABASE_URI = f"sqlite:///{db_path}"
|
||||||
|
|
||||||
|
jwt_secret = os.environ.get("MIMIC_JWT_SECRET")
|
||||||
|
if not jwt_secret:
|
||||||
|
raise RuntimeError(
|
||||||
|
"MIMIC_JWT_SECRET environment variable is required but not set."
|
||||||
|
)
|
||||||
|
self.JWT_SECRET = jwt_secret
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig(Config):
|
||||||
|
"""Config for pytest. Uses in-memory SQLite + fixed JWT secret."""
|
||||||
|
|
||||||
|
TESTING = True
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Bypass parent's env requirement; tests inject their own secret.
|
||||||
|
self.SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
||||||
|
self.JWT_SECRET = "test-secret-do-not-use-in-prod"
|
||||||
21
backend/app/errors.py
Normal file
21
backend/app/errors.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Uniform JSON error handlers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app: Flask) -> None:
|
||||||
|
@app.errorhandler(HTTPException)
|
||||||
|
def handle_http_exception(exc: HTTPException):
|
||||||
|
response = jsonify({"error": exc.description})
|
||||||
|
response.status_code = exc.code or 500
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def handle_404(_exc):
|
||||||
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
|
||||||
|
@app.errorhandler(405)
|
||||||
|
def handle_405(_exc):
|
||||||
|
return jsonify({"error": "Method not allowed"}), 405
|
||||||
6
backend/app/extensions.py
Normal file
6
backend/app/extensions.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Shared Flask extension instances."""
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
20
backend/app/models/__init__.py
Normal file
20
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""SQLAlchemy models."""
|
||||||
|
from backend.app.models.c2_config import C2Config
|
||||||
|
from backend.app.models.c2_task import C2Task, C2TaskSource
|
||||||
|
from backend.app.models.engagement import Engagement, EngagementStatus
|
||||||
|
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||||
|
from backend.app.models.simulation_template import SimulationTemplate
|
||||||
|
from backend.app.models.user import User, UserRole
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"UserRole",
|
||||||
|
"Engagement",
|
||||||
|
"EngagementStatus",
|
||||||
|
"Simulation",
|
||||||
|
"SimulationStatus",
|
||||||
|
"SimulationTemplate",
|
||||||
|
"C2Config",
|
||||||
|
"C2Task",
|
||||||
|
"C2TaskSource",
|
||||||
|
]
|
||||||
34
backend/app/models/c2_config.py
Normal file
34
backend/app/models/c2_config.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""C2Config model — per-engagement Mythic connection settings."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class C2Config(db.Model): # type: ignore[name-defined]
|
||||||
|
__tablename__ = "c2_config"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
engagement_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("engagements.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
url = db.Column(db.Text, nullable=False)
|
||||||
|
api_token_encrypted = db.Column(db.Text, nullable=False)
|
||||||
|
verify_tls = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
engagement = db.relationship(
|
||||||
|
"Engagement",
|
||||||
|
backref=db.backref("c2_config", uselist=False, cascade="all, delete-orphan"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<C2Config engagement_id={self.engagement_id}>"
|
||||||
48
backend/app/models/c2_task.py
Normal file
48
backend/app/models/c2_task.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""C2Task model — link between a Mimic simulation and a Mythic task."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class C2TaskSource(str, enum.Enum):
|
||||||
|
MIMIC = "mimic"
|
||||||
|
IMPORT = "import"
|
||||||
|
|
||||||
|
|
||||||
|
class C2Task(db.Model): # type: ignore[name-defined]
|
||||||
|
__tablename__ = "c2_task"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
simulation_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("simulations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
mythic_task_display_id = db.Column(db.Integer, nullable=False)
|
||||||
|
callback_display_id = db.Column(db.Integer, nullable=False)
|
||||||
|
command = db.Column(db.Text, nullable=False)
|
||||||
|
params = db.Column(db.Text, nullable=True)
|
||||||
|
status = db.Column(db.Text, nullable=False)
|
||||||
|
completed = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
output = db.Column(db.Text, nullable=True)
|
||||||
|
source = db.Column(
|
||||||
|
db.Enum(C2TaskSource, name="c2task_source"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
completed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
mapping_applied = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
simulation = db.relationship(
|
||||||
|
"Simulation",
|
||||||
|
backref=db.backref("c2_tasks", cascade="all, delete-orphan", lazy="dynamic"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<C2Task simulation_id={self.simulation_id} mythic_id={self.mythic_task_display_id}>"
|
||||||
39
backend/app/models/engagement.py
Normal file
39
backend/app/models/engagement.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Engagement model."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class EngagementStatus(str, enum.Enum):
|
||||||
|
PLANNED = "planned"
|
||||||
|
ACTIVE = "active"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class Engagement(db.Model): # type: ignore[name-defined]
|
||||||
|
__tablename__ = "engagements"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
start_date = db.Column(db.Date, nullable=False)
|
||||||
|
end_date = db.Column(db.Date, nullable=True)
|
||||||
|
status = db.Column(
|
||||||
|
db.Enum(EngagementStatus, name="engagement_status"),
|
||||||
|
nullable=False,
|
||||||
|
default=EngagementStatus.PLANNED,
|
||||||
|
)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
created_by_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
created_by = db.relationship("User", backref="engagements", lazy="joined")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Engagement {self.id} {self.name!r}>"
|
||||||
62
backend/app/models/simulation.py
Normal file
62
backend/app/models/simulation.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Simulation model."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
REVIEW_REQUIRED = "review_required"
|
||||||
|
DONE = "done"
|
||||||
|
|
||||||
|
|
||||||
|
class Simulation(db.Model): # type: ignore[name-defined]
|
||||||
|
__tablename__ = "simulations"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
engagement_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("engagements.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
techniques = db.Column(db.JSON, nullable=False, default=list)
|
||||||
|
tactic_ids = db.Column(db.JSON, nullable=False, default=list)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
commands = db.Column(db.Text, nullable=True)
|
||||||
|
prerequisites = db.Column(db.Text, nullable=True)
|
||||||
|
executed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
execution_result = db.Column(db.Text, nullable=True)
|
||||||
|
log_source = db.Column(db.Text, nullable=True)
|
||||||
|
logs = db.Column(db.Text, nullable=True)
|
||||||
|
soc_comment = db.Column(db.Text, nullable=True)
|
||||||
|
incident_number = db.Column(db.String(128), nullable=True)
|
||||||
|
status = db.Column(
|
||||||
|
db.Enum(SimulationStatus, name="simulation_status"),
|
||||||
|
nullable=False,
|
||||||
|
default=SimulationStatus.PENDING,
|
||||||
|
)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
created_by_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
engagement = db.relationship(
|
||||||
|
"Engagement",
|
||||||
|
backref=db.backref("simulations", cascade="all, delete-orphan", lazy="dynamic"),
|
||||||
|
)
|
||||||
|
created_by = db.relationship("User", backref="simulations", lazy="joined")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Simulation {self.id} {self.name!r}>"
|
||||||
32
backend/app/models/simulation_template.py
Normal file
32
backend/app/models/simulation_template.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""SimulationTemplate model."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationTemplate(db.Model): # type: ignore[name-defined]
|
||||||
|
__tablename__ = "simulation_templates"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(255), nullable=False, unique=True)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
commands = db.Column(db.Text, nullable=True)
|
||||||
|
prerequisites = db.Column(db.Text, nullable=True)
|
||||||
|
techniques = db.Column(db.JSON, nullable=False, default=list)
|
||||||
|
tactic_ids = db.Column(db.JSON, nullable=False, default=list)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
created_by_id = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
created_by = db.relationship("User", lazy="joined")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<SimulationTemplate {self.id} {self.name!r}>"
|
||||||
28
backend/app/models/user.py
Normal file
28
backend/app/models/user.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""User model."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
REDTEAM = "redteam"
|
||||||
|
SOC = "soc"
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model): # type: ignore[name-defined]
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
role = db.Column(db.Enum(UserRole, name="user_role"), nullable=False)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.username} ({self.role.value})>"
|
||||||
102
backend/app/serializers.py
Normal file
102
backend/app/serializers.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""JSON serializers for API responses."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.app.models import Engagement, User
|
||||||
|
from backend.app.models.simulation import Simulation
|
||||||
|
from backend.app.models.simulation_template import SimulationTemplate
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_user(user: User) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role.value,
|
||||||
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_user_brief(user: User) -> dict[str, Any]:
|
||||||
|
return {"id": user.id, "username": user.username}
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_techniques(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Attach tactics to each {id, name} snapshot from the MITRE service."""
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"id": t["id"], "name": t["name"], "tactics": mitre_svc.get_tactics(t["id"])}
|
||||||
|
for t in (raw or [])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_tactics(tactic_ids: list[str]) -> list[dict[str, str]]:
|
||||||
|
"""Resolve TA-ids to {id, name} at runtime."""
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for tid in tactic_ids or []:
|
||||||
|
entry = mitre_svc.lookup_tactic(tid)
|
||||||
|
if entry is not None:
|
||||||
|
result.append(entry)
|
||||||
|
else:
|
||||||
|
result.append({"id": tid, "name": ""})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_simulation(simulation: Simulation) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": simulation.id,
|
||||||
|
"engagement_id": simulation.engagement_id,
|
||||||
|
"name": simulation.name,
|
||||||
|
"techniques": _enrich_techniques(simulation.techniques or []),
|
||||||
|
"tactics": _enrich_tactics(simulation.tactic_ids or []),
|
||||||
|
"description": simulation.description,
|
||||||
|
"commands": simulation.commands,
|
||||||
|
"prerequisites": simulation.prerequisites,
|
||||||
|
"executed_at": simulation.executed_at.isoformat() if simulation.executed_at else None,
|
||||||
|
"execution_result": simulation.execution_result,
|
||||||
|
"log_source": simulation.log_source,
|
||||||
|
"logs": simulation.logs,
|
||||||
|
"soc_comment": simulation.soc_comment,
|
||||||
|
"incident_number": simulation.incident_number,
|
||||||
|
"status": simulation.status.value,
|
||||||
|
"created_at": simulation.created_at.isoformat() if simulation.created_at else None,
|
||||||
|
"updated_at": simulation.updated_at.isoformat() if simulation.updated_at else None,
|
||||||
|
"created_by": serialize_user_brief(simulation.created_by) # type: ignore[arg-type]
|
||||||
|
if simulation.created_by
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_template(t: SimulationTemplate) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": t.id,
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"commands": t.commands,
|
||||||
|
"prerequisites": t.prerequisites,
|
||||||
|
"techniques": _enrich_techniques(t.techniques or []),
|
||||||
|
"tactics": _enrich_tactics(t.tactic_ids or []),
|
||||||
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||||
|
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
||||||
|
"created_by": serialize_user_brief(t.created_by) # type: ignore[arg-type]
|
||||||
|
if t.created_by
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_engagement(engagement: Engagement) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": engagement.id,
|
||||||
|
"name": engagement.name,
|
||||||
|
"description": engagement.description,
|
||||||
|
"start_date": engagement.start_date.isoformat() if engagement.start_date else None,
|
||||||
|
"end_date": engagement.end_date.isoformat() if engagement.end_date else None,
|
||||||
|
"status": engagement.status.value,
|
||||||
|
"created_at": engagement.created_at.isoformat() if engagement.created_at else None,
|
||||||
|
"created_by": serialize_user_brief(engagement.created_by) # type: ignore[arg-type]
|
||||||
|
if engagement.created_by
|
||||||
|
else None,
|
||||||
|
}
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
22
backend/app/services/c2/__init__.py
Normal file
22
backend/app/services/c2/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""C2 adapter package. Import the factory from here."""
|
||||||
|
from backend.app.services.c2.adapter import (
|
||||||
|
C2Adapter,
|
||||||
|
C2Callback,
|
||||||
|
C2Error,
|
||||||
|
C2Health,
|
||||||
|
C2TaskPage,
|
||||||
|
C2TaskStatus,
|
||||||
|
decode_response_text,
|
||||||
|
)
|
||||||
|
from backend.app.services.c2.factory import get_adapter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"C2Adapter",
|
||||||
|
"C2Callback",
|
||||||
|
"C2Error",
|
||||||
|
"C2Health",
|
||||||
|
"C2TaskPage",
|
||||||
|
"C2TaskStatus",
|
||||||
|
"decode_response_text",
|
||||||
|
"get_adapter",
|
||||||
|
]
|
||||||
117
backend/app/services/c2/adapter.py
Normal file
117
backend/app/services/c2/adapter.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Abstract C2 adapter interface and shared dataclasses."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class C2Error(Exception):
|
||||||
|
"""Raised by adapters when the C2 returns an application-level error."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class C2Health:
|
||||||
|
ok: bool
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class C2Callback:
|
||||||
|
display_id: int
|
||||||
|
active: bool
|
||||||
|
host: str
|
||||||
|
user: str
|
||||||
|
domain: str
|
||||||
|
last_checkin: str # ISO-8601 string
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class C2TaskStatus:
|
||||||
|
display_id: int
|
||||||
|
status: str
|
||||||
|
completed: bool
|
||||||
|
completed_at: datetime | None = field(default=None)
|
||||||
|
# command_name is populated by get_task() so import doesn't need a second round-trip.
|
||||||
|
command: str | None = field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class C2HistoricalTask:
|
||||||
|
"""A task entry from callback history (carries command + params, unlike C2TaskStatus)."""
|
||||||
|
|
||||||
|
display_id: int
|
||||||
|
command: str
|
||||||
|
params: str | None
|
||||||
|
status: str
|
||||||
|
completed: bool
|
||||||
|
timestamp: str | None # ISO-8601 or None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class C2TaskPage:
|
||||||
|
items: list[C2HistoricalTask]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
def decode_response_text(raw: str) -> str:
|
||||||
|
"""Decode a base64-encoded Mythic response_text field.
|
||||||
|
|
||||||
|
On binascii.Error (binary payload) returns "<binary> " + hex string
|
||||||
|
so execution_result never silently corrupts.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return base64.b64decode(raw).decode("utf-8")
|
||||||
|
except binascii.Error:
|
||||||
|
return "<binary> " + raw.encode().hex()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raw_bytes = base64.b64decode(raw)
|
||||||
|
return "<binary> " + raw_bytes.hex()
|
||||||
|
|
||||||
|
|
||||||
|
class C2Adapter(ABC):
|
||||||
|
"""Thin interface over a C2 backend (Mythic or custom)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def test_connection(self) -> C2Health:
|
||||||
|
"""Verify that the C2 is reachable and the token is valid."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_callbacks(self) -> list[C2Callback]:
|
||||||
|
"""Return active callbacks visible to this API token."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
callback_display_id: int,
|
||||||
|
command: str,
|
||||||
|
params: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Issue a task and return its Mythic display_id."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_task(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
"""Return current status of a task."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_task_output(self, task_display_id: int) -> str:
|
||||||
|
"""Return decoded, concatenated output for a completed task."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_callback_tasks(
|
||||||
|
self,
|
||||||
|
callback_display_id: int,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 25,
|
||||||
|
) -> C2TaskPage:
|
||||||
|
"""Return a paginated history of tasks for a callback."""
|
||||||
|
...
|
||||||
19
backend/app/services/c2/factory.py
Normal file
19
backend/app/services/c2/factory.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Factory that resolves the C2Adapter implementation from MIMIC_C2_ADAPTER env."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import C2Adapter
|
||||||
|
|
||||||
|
|
||||||
|
def get_adapter(url: str, api_token: str, verify_tls: bool = True) -> C2Adapter:
|
||||||
|
"""Return the correct C2Adapter based on MIMIC_C2_ADAPTER (default: mythic)."""
|
||||||
|
adapter_name = os.environ.get("MIMIC_C2_ADAPTER", "mythic").lower()
|
||||||
|
|
||||||
|
if adapter_name == "fake":
|
||||||
|
from backend.app.services.c2.fake import FakeAdapter
|
||||||
|
return FakeAdapter()
|
||||||
|
|
||||||
|
# Default: real Mythic adapter
|
||||||
|
from backend.app.services.c2.mythic import MythicAdapter
|
||||||
|
return MythicAdapter(url=url, api_token=api_token, verify_tls=verify_tls)
|
||||||
176
backend/app/services/c2/fake.py
Normal file
176
backend/app/services/c2/fake.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Deterministic in-memory C2 adapter — used when MIMIC_C2_ADAPTER=fake.
|
||||||
|
|
||||||
|
Intended for integration tests and local development without a live Mythic instance.
|
||||||
|
Task state is per-instance so parallel tests don't interfere with each other.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import (
|
||||||
|
C2Adapter,
|
||||||
|
C2Callback,
|
||||||
|
C2Error,
|
||||||
|
C2Health,
|
||||||
|
C2HistoricalTask,
|
||||||
|
C2TaskPage,
|
||||||
|
C2TaskStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Frozen base timestamp — all fake history tasks share this prefix for determinism.
|
||||||
|
_BASE_TS = "2026-06-10T00:00:00Z"
|
||||||
|
|
||||||
|
# Deterministic history for list_callback_tasks:
|
||||||
|
# callback 1 → 12 tasks, callback 2 → 0 tasks, callback 3 → 5 tasks.
|
||||||
|
# Commands cycle through a fixed set; even-indexed tasks are completed.
|
||||||
|
_HISTORY_COMMANDS = ["whoami", "hostname", "id", "ipconfig", "net user", "pwd"]
|
||||||
|
|
||||||
|
_FAKE_HISTORY: dict[int, list[C2HistoricalTask]] = {
|
||||||
|
1: [
|
||||||
|
C2HistoricalTask(
|
||||||
|
display_id=100 + i,
|
||||||
|
command=_HISTORY_COMMANDS[i % len(_HISTORY_COMMANDS)],
|
||||||
|
params=None,
|
||||||
|
status="completed" if i % 2 == 0 else "submitted",
|
||||||
|
completed=i % 2 == 0,
|
||||||
|
timestamp=_BASE_TS if i % 2 == 0 else None,
|
||||||
|
)
|
||||||
|
for i in range(12)
|
||||||
|
],
|
||||||
|
2: [],
|
||||||
|
3: [
|
||||||
|
C2HistoricalTask(
|
||||||
|
display_id=200 + i,
|
||||||
|
command=_HISTORY_COMMANDS[i % len(_HISTORY_COMMANDS)],
|
||||||
|
params=None,
|
||||||
|
status="completed" if i % 2 == 0 else "submitted",
|
||||||
|
completed=i % 2 == 0,
|
||||||
|
timestamp=_BASE_TS if i % 2 == 0 else None,
|
||||||
|
)
|
||||||
|
for i in range(5)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Three fixed callbacks the test suite can pin against.
|
||||||
|
_FAKE_CALLBACKS = [
|
||||||
|
C2Callback(
|
||||||
|
display_id=1,
|
||||||
|
active=True,
|
||||||
|
host="WORKSTATION-01",
|
||||||
|
user="jdoe",
|
||||||
|
domain="LAB",
|
||||||
|
last_checkin="2026-06-10T00:00:00Z",
|
||||||
|
),
|
||||||
|
C2Callback(
|
||||||
|
display_id=2,
|
||||||
|
active=True,
|
||||||
|
host="SERVER-DC01",
|
||||||
|
user="svc_backup",
|
||||||
|
domain="LAB",
|
||||||
|
last_checkin="2026-06-10T00:01:00Z",
|
||||||
|
),
|
||||||
|
C2Callback(
|
||||||
|
display_id=3,
|
||||||
|
active=True,
|
||||||
|
host="LAPTOP-RT",
|
||||||
|
user="admin",
|
||||||
|
domain="LAB",
|
||||||
|
last_checkin="2026-06-10T00:02:00Z",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAdapter(C2Adapter):
|
||||||
|
"""In-memory adapter with deterministic behaviour.
|
||||||
|
|
||||||
|
Each instance starts with an empty task store and display_ids from 1000.
|
||||||
|
|
||||||
|
get_task() state progression per task (keyed by display_id):
|
||||||
|
- First call after create_task → submitted, completed=False
|
||||||
|
- Second and subsequent calls → completed=True, status="completed"
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._tasks: dict[int, dict] = {}
|
||||||
|
self._next_task_id = 1000
|
||||||
|
# Tracks how many times get_task has been called per display_id.
|
||||||
|
self._get_task_calls: dict[int, int] = {}
|
||||||
|
|
||||||
|
def test_connection(self) -> C2Health:
|
||||||
|
return C2Health(ok=True)
|
||||||
|
|
||||||
|
def list_callbacks(self) -> list[C2Callback]:
|
||||||
|
return list(_FAKE_CALLBACKS)
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
callback_display_id: int,
|
||||||
|
command: str,
|
||||||
|
params: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
tid = self._next_task_id
|
||||||
|
self._next_task_id += 1
|
||||||
|
self._tasks[tid] = {
|
||||||
|
"display_id": tid,
|
||||||
|
"callback_display_id": callback_display_id,
|
||||||
|
"command": command,
|
||||||
|
"params": params,
|
||||||
|
"status": "submitted",
|
||||||
|
"completed": False,
|
||||||
|
"output": None,
|
||||||
|
}
|
||||||
|
return tid
|
||||||
|
|
||||||
|
def get_task(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
"""Deterministic state progression: first call → submitted, second+ → completed.
|
||||||
|
|
||||||
|
Tracks call count regardless of whether the task was created by this instance,
|
||||||
|
so the endpoint poll-on-read flow works across separate adapter instantiations.
|
||||||
|
"""
|
||||||
|
call_count = self._get_task_calls.get(task_display_id, 0) + 1
|
||||||
|
self._get_task_calls[task_display_id] = call_count
|
||||||
|
|
||||||
|
task = self._tasks.get(task_display_id)
|
||||||
|
|
||||||
|
if call_count >= 2:
|
||||||
|
completed = True
|
||||||
|
status = "completed"
|
||||||
|
if task is not None:
|
||||||
|
task["status"] = "completed"
|
||||||
|
task["completed"] = True
|
||||||
|
else:
|
||||||
|
completed = False
|
||||||
|
status = task["status"] if task is not None else "submitted"
|
||||||
|
|
||||||
|
return C2TaskStatus(
|
||||||
|
display_id=task_display_id,
|
||||||
|
status=status,
|
||||||
|
completed=completed,
|
||||||
|
command=task["command"] if task is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_task_output(self, task_display_id: int) -> str:
|
||||||
|
"""Returns deterministic output once task is completed; raises C2Error before that."""
|
||||||
|
# Check call count — completed if get_task was called at least twice.
|
||||||
|
if self._get_task_calls.get(task_display_id, 0) < 2:
|
||||||
|
# Also allow tasks in _tasks that were explicitly set to completed.
|
||||||
|
task = self._tasks.get(task_display_id)
|
||||||
|
if task is None or not task.get("completed", False):
|
||||||
|
raise C2Error("task not completed")
|
||||||
|
|
||||||
|
task = self._tasks.get(task_display_id)
|
||||||
|
command = task["command"] if task is not None else "unknown"
|
||||||
|
return f"output for task {task_display_id}: {command}\n"
|
||||||
|
|
||||||
|
def list_callback_tasks(
|
||||||
|
self,
|
||||||
|
callback_display_id: int,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 25,
|
||||||
|
) -> C2TaskPage:
|
||||||
|
all_items = _FAKE_HISTORY.get(callback_display_id, [])
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
return C2TaskPage(
|
||||||
|
items=all_items[start : start + page_size],
|
||||||
|
total=len(all_items),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
53
backend/app/services/c2/mapping.py
Normal file
53
backend/app/services/c2/mapping.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""C2 task → Simulation output mapping.
|
||||||
|
|
||||||
|
apply_task_to_simulation() implements the full §0.11 contract:
|
||||||
|
1. execution_result — append "$ <command>\n<output>\n" block.
|
||||||
|
2. executed_at — set from task.completed_at when currently null.
|
||||||
|
3. commands — append task.command deduplicated line-by-line.
|
||||||
|
|
||||||
|
Caller is responsible for committing the session.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.models.c2_task import C2Task
|
||||||
|
from backend.app.models.simulation import Simulation
|
||||||
|
|
||||||
|
|
||||||
|
def apply_task_to_simulation(task: C2Task, simulation: Simulation) -> None:
|
||||||
|
"""Apply completed task data to simulation fields per §0.11.
|
||||||
|
|
||||||
|
Idempotent: no-op when task.mapping_applied is already True.
|
||||||
|
Always sets mapping_applied = True on exit so the task is never re-processed.
|
||||||
|
"""
|
||||||
|
if task.mapping_applied:
|
||||||
|
return
|
||||||
|
|
||||||
|
output = (task.output or "").strip()
|
||||||
|
|
||||||
|
# 1) execution_result — "$ <command>\n<output>\n" block, only when output is non-empty.
|
||||||
|
if output:
|
||||||
|
block = f"$ {task.command}\n{output}\n"
|
||||||
|
existing = simulation.execution_result or ""
|
||||||
|
if existing:
|
||||||
|
sep = "" if existing.endswith("\n") else "\n"
|
||||||
|
simulation.execution_result = existing + sep + block
|
||||||
|
else:
|
||||||
|
simulation.execution_result = block
|
||||||
|
|
||||||
|
# 2) executed_at — set once from the first completed task's timestamp.
|
||||||
|
if simulation.executed_at is None and task.completed_at is not None:
|
||||||
|
simulation.executed_at = task.completed_at
|
||||||
|
|
||||||
|
# 3) commands — append deduplicated line.
|
||||||
|
if task.command:
|
||||||
|
existing_cmds = (simulation.commands or "").splitlines()
|
||||||
|
if task.command.strip() not in (line.strip() for line in existing_cmds):
|
||||||
|
if simulation.commands:
|
||||||
|
simulation.commands = simulation.commands + "\n" + task.command
|
||||||
|
else:
|
||||||
|
simulation.commands = task.command
|
||||||
|
|
||||||
|
simulation.updated_at = datetime.now(UTC)
|
||||||
|
task.mapping_applied = True
|
||||||
293
backend/app/services/c2/mythic.py
Normal file
293
backend/app/services/c2/mythic.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# Contract pinned from MythicMeta/Mythic_Scripting master @ 2026-06-10 (raw.githubusercontent.com/MythicMeta/Mythic_Scripting/master/mythic/mythic.py)
|
||||||
|
"""Mythic 3.x C2 adapter.
|
||||||
|
|
||||||
|
Transport: POST https://<host>:7443/graphql
|
||||||
|
Header: apitoken: <token>
|
||||||
|
Backend: Hasura-proxied Postgres behind nginx.
|
||||||
|
|
||||||
|
M1: test_connection()
|
||||||
|
M2: list_callbacks(), create_task()
|
||||||
|
M3: get_task(), get_task_output()
|
||||||
|
M4: list_callback_tasks()
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import (
|
||||||
|
C2Adapter,
|
||||||
|
C2Callback,
|
||||||
|
C2Error,
|
||||||
|
C2Health,
|
||||||
|
C2HistoricalTask,
|
||||||
|
C2TaskPage,
|
||||||
|
C2TaskStatus,
|
||||||
|
decode_response_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
_HEALTH_QUERY = "{ __typename }"
|
||||||
|
|
||||||
|
_CALLBACKS_QUERY = """
|
||||||
|
query {
|
||||||
|
callback(order_by: {id: asc}, where: {active: {_eq: true}}) {
|
||||||
|
id
|
||||||
|
display_id
|
||||||
|
active
|
||||||
|
host
|
||||||
|
user
|
||||||
|
domain
|
||||||
|
last_checkin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CREATE_TASK_MUTATION = """
|
||||||
|
mutation CreateTask($callback_id: Int!, $command: String!, $params: String!) {
|
||||||
|
createTask(
|
||||||
|
callback_id: $callback_id,
|
||||||
|
command: $command,
|
||||||
|
params: $params,
|
||||||
|
tasking_location: "command_line"
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
display_id
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_GET_TASK_QUERY = """
|
||||||
|
query GetTask($display_id: Int!) {
|
||||||
|
task(where: {display_id: {_eq: $display_id}}) {
|
||||||
|
display_id
|
||||||
|
command_name
|
||||||
|
status
|
||||||
|
completed
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LIST_CALLBACK_TASKS_QUERY = """
|
||||||
|
query ListCallbackTasks($callback_display_id: Int!, $limit: Int!, $offset: Int!) {
|
||||||
|
task(
|
||||||
|
where: {callback: {display_id: {_eq: $callback_display_id}}}
|
||||||
|
order_by: {id: desc}
|
||||||
|
limit: $limit
|
||||||
|
offset: $offset
|
||||||
|
) {
|
||||||
|
display_id
|
||||||
|
command_name
|
||||||
|
params
|
||||||
|
status
|
||||||
|
completed
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_COUNT_CALLBACK_TASKS_QUERY = """
|
||||||
|
query CountCallbackTasks($callback_display_id: Int!) {
|
||||||
|
task_aggregate(where: {callback: {display_id: {_eq: $callback_display_id}}}) {
|
||||||
|
aggregate {
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_GET_TASK_OUTPUT_QUERY = """
|
||||||
|
query GetTaskOutput($display_id: Int!) {
|
||||||
|
response(
|
||||||
|
where: {task: {display_id: {_eq: $display_id}}}
|
||||||
|
order_by: {id: asc}
|
||||||
|
) {
|
||||||
|
response_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MythicAdapter(C2Adapter):
|
||||||
|
"""Real Mythic 3.x adapter using GraphQL over HTTP."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, api_token: str, verify_tls: bool = True) -> None:
|
||||||
|
self._url = url.rstrip("/") + "/graphql"
|
||||||
|
self._token = api_token
|
||||||
|
self._verify = verify_tls
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"apitoken": self._token,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, body: dict) -> dict:
|
||||||
|
resp = requests.post(
|
||||||
|
self._url,
|
||||||
|
json=body,
|
||||||
|
headers=self._headers(),
|
||||||
|
verify=self._verify,
|
||||||
|
timeout=10,
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def test_connection(self) -> C2Health:
|
||||||
|
"""POST a trivial introspection query to verify reachability and token validity."""
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
self._url,
|
||||||
|
json={"query": _HEALTH_QUERY},
|
||||||
|
headers=self._headers(),
|
||||||
|
verify=self._verify,
|
||||||
|
timeout=10,
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return C2Health(ok=True)
|
||||||
|
return C2Health(ok=False, error=f"HTTP {resp.status_code}")
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return C2Health(ok=False, error=str(exc))
|
||||||
|
|
||||||
|
def list_callbacks(self) -> list[C2Callback]:
|
||||||
|
"""Return active callbacks from Mythic (filtered server-side: active=true)."""
|
||||||
|
try:
|
||||||
|
data = self._post({"query": _CALLBACKS_QUERY})
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
|
callbacks_raw = data.get("data", {}).get("callback", [])
|
||||||
|
return [
|
||||||
|
C2Callback(
|
||||||
|
display_id=cb["display_id"],
|
||||||
|
active=cb["active"],
|
||||||
|
host=cb.get("host") or "",
|
||||||
|
user=cb.get("user") or "",
|
||||||
|
domain=cb.get("domain") or "",
|
||||||
|
last_checkin=cb.get("last_checkin") or "",
|
||||||
|
)
|
||||||
|
for cb in callbacks_raw
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
callback_display_id: int,
|
||||||
|
command: str,
|
||||||
|
params: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Issue a task on a callback; return Mythic task display_id."""
|
||||||
|
try:
|
||||||
|
data = self._post({
|
||||||
|
"query": _CREATE_TASK_MUTATION,
|
||||||
|
"variables": {
|
||||||
|
"callback_id": callback_display_id,
|
||||||
|
"command": command,
|
||||||
|
"params": params or "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
|
task_data = data.get("data", {}).get("createTask", {})
|
||||||
|
error_msg = task_data.get("error")
|
||||||
|
if error_msg:
|
||||||
|
raise C2Error(error_msg)
|
||||||
|
return int(task_data["display_id"])
|
||||||
|
|
||||||
|
def get_task(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
"""Return current task status from Mythic."""
|
||||||
|
try:
|
||||||
|
data = self._post({
|
||||||
|
"query": _GET_TASK_QUERY,
|
||||||
|
"variables": {"display_id": task_display_id},
|
||||||
|
})
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
|
rows = data.get("data", {}).get("task", [])
|
||||||
|
if not rows:
|
||||||
|
raise C2Error(f"task {task_display_id} not found in Mythic")
|
||||||
|
row = rows[0]
|
||||||
|
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
if row.get("completed") and row.get("timestamp"):
|
||||||
|
try:
|
||||||
|
completed_at = datetime.fromisoformat(
|
||||||
|
row["timestamp"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
completed_at = None
|
||||||
|
|
||||||
|
return C2TaskStatus(
|
||||||
|
display_id=row["display_id"],
|
||||||
|
status=row["status"],
|
||||||
|
completed=bool(row.get("completed", False)),
|
||||||
|
completed_at=completed_at,
|
||||||
|
command=row.get("command_name") or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_task_output(self, task_display_id: int) -> str:
|
||||||
|
"""Return decoded, concatenated output for a task."""
|
||||||
|
try:
|
||||||
|
data = self._post({
|
||||||
|
"query": _GET_TASK_OUTPUT_QUERY,
|
||||||
|
"variables": {"display_id": task_display_id},
|
||||||
|
})
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
|
rows = data.get("data", {}).get("response", [])
|
||||||
|
return "".join(
|
||||||
|
decode_response_text(r["response_text"])
|
||||||
|
for r in rows
|
||||||
|
if r.get("response_text")
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_callback_tasks(
|
||||||
|
self,
|
||||||
|
callback_display_id: int,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 25,
|
||||||
|
) -> C2TaskPage:
|
||||||
|
"""Return a paginated, most-recent-first history of tasks for a callback."""
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
try:
|
||||||
|
data = self._post({
|
||||||
|
"query": _LIST_CALLBACK_TASKS_QUERY,
|
||||||
|
"variables": {
|
||||||
|
"callback_display_id": callback_display_id,
|
||||||
|
"limit": page_size,
|
||||||
|
"offset": offset,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
count_data = self._post({
|
||||||
|
"query": _COUNT_CALLBACK_TASKS_QUERY,
|
||||||
|
"variables": {"callback_display_id": callback_display_id},
|
||||||
|
})
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
|
||||||
|
|
||||||
|
rows = data.get("data", {}).get("task", [])
|
||||||
|
total: int = (
|
||||||
|
count_data.get("data", {})
|
||||||
|
.get("task_aggregate", {})
|
||||||
|
.get("aggregate", {})
|
||||||
|
.get("count", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
C2HistoricalTask(
|
||||||
|
display_id=r["display_id"],
|
||||||
|
command=r.get("command_name") or "",
|
||||||
|
params=r.get("params") or None,
|
||||||
|
status=r.get("status") or "",
|
||||||
|
completed=bool(r.get("completed", False)),
|
||||||
|
timestamp=r.get("timestamp") or None,
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return C2TaskPage(items=items, total=total, page=page, page_size=page_size)
|
||||||
40
backend/app/services/crypto.py
Normal file
40
backend/app/services/crypto.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Fernet-based encryption service for sensitive fields.
|
||||||
|
|
||||||
|
Key is read from the MIMIC_ENCRYPTION_KEY env var (Fernet base64-urlsafe 32-byte key).
|
||||||
|
When the key is absent the service raises C2Disabled so callers can return 503.
|
||||||
|
The key is never logged or returned in any response.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
|
||||||
|
class C2Disabled(Exception):
|
||||||
|
"""Raised when MIMIC_ENCRYPTION_KEY is not set."""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
key = os.environ.get("MIMIC_ENCRYPTION_KEY")
|
||||||
|
if not key:
|
||||||
|
raise C2Disabled("C2 disabled: MIMIC_ENCRYPTION_KEY not set")
|
||||||
|
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plaintext: str) -> str:
|
||||||
|
"""Encrypt *plaintext* and return a Fernet token (str)."""
|
||||||
|
f = _get_fernet()
|
||||||
|
return f.encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(ciphertext: str) -> str:
|
||||||
|
"""Decrypt a Fernet token and return the plaintext string."""
|
||||||
|
f = _get_fernet()
|
||||||
|
try:
|
||||||
|
return f.decrypt(ciphertext.encode()).decode()
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise ValueError("Invalid ciphertext") from exc
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["C2Disabled", "encrypt", "decrypt"]
|
||||||
277
backend/app/services/export.py
Normal file
277
backend/app/services/export.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""Engagement export renderers — Markdown, CSV, PDF."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from datetime import date
|
||||||
|
from html import escape as _html_escape
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from backend.app.models.engagement import Engagement
|
||||||
|
from backend.app.models.simulation import Simulation
|
||||||
|
|
||||||
|
|
||||||
|
def _export_filename(engagement: Engagement, ext: str) -> str:
|
||||||
|
name = engagement.name or ""
|
||||||
|
normalized = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-")[:60] or "unnamed"
|
||||||
|
today = date.today().strftime("%Y%m%d")
|
||||||
|
return f"engagement-{engagement.id}-{slug}-{today}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def _creator(obj: object) -> str:
|
||||||
|
"""Return username string from an ORM object with a created_by relationship."""
|
||||||
|
cb = getattr(obj, "created_by", None)
|
||||||
|
if cb is None:
|
||||||
|
return ""
|
||||||
|
return getattr(cb, "username", "") or ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSV formula-injection defense (defined early — used by _format_execution_csv)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still
|
||||||
|
# reaches the formula parser in some sheet versions.
|
||||||
|
_CSV_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_safe(value: object) -> object:
|
||||||
|
"""Defuse spreadsheet formula injection by prefixing user-controlled cells.
|
||||||
|
|
||||||
|
Excel / LibreOffice / Google Sheets interpret cells starting with =, +, -, @,
|
||||||
|
\\t or \\r as formulas. Since this CSV is the engagement handoff to SOC and is
|
||||||
|
explicitly opened in a spreadsheet app, an authenticated red-team user could
|
||||||
|
craft a simulation field that executes on the SOC analyst's machine. Prefixing
|
||||||
|
with a single apostrophe forces the spreadsheet to treat the cell as text.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS:
|
||||||
|
return "'" + value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Execution cell helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _format_execution_text(sim: Simulation) -> str:
|
||||||
|
"""Canonical 3-part execution concat for Markdown and PDF (no CSV sanitization)."""
|
||||||
|
parts = [
|
||||||
|
sim.executed_at.isoformat() if sim.executed_at else "",
|
||||||
|
sim.commands or "",
|
||||||
|
sim.execution_result or "",
|
||||||
|
]
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_execution_csv(sim: Simulation) -> str:
|
||||||
|
"""Execution concat for CSV: each user-controlled component is formula-defused
|
||||||
|
before joining so that inner lines starting with =, +, -, @ are safe."""
|
||||||
|
parts = [
|
||||||
|
sim.executed_at.isoformat() if sim.executed_at else "",
|
||||||
|
str(_csv_safe(sim.commands or "")),
|
||||||
|
str(_csv_safe(sim.execution_result or "")),
|
||||||
|
]
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Markdown
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MD_HEADERS = [
|
||||||
|
"Scénario",
|
||||||
|
"Test",
|
||||||
|
"Source de log",
|
||||||
|
"Commentaires SOC",
|
||||||
|
"Exécution",
|
||||||
|
"Logs remontés au SIEM",
|
||||||
|
"Cyber incident",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_engagement_markdown(
|
||||||
|
engagement: Engagement, simulations: list[Simulation]
|
||||||
|
) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
lines.append(f"# {engagement.name}")
|
||||||
|
lines.append("")
|
||||||
|
if engagement.description:
|
||||||
|
lines.append(engagement.description)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"**Status**: {engagement.status.value}")
|
||||||
|
lines.append(
|
||||||
|
f"**Start date**: {engagement.start_date.isoformat() if engagement.start_date else 'N/A'}"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"**End date**: {engagement.end_date.isoformat() if engagement.end_date else 'N/A'}"
|
||||||
|
)
|
||||||
|
lines.append(f"**Created by**: {_creator(engagement)}")
|
||||||
|
lines.append(
|
||||||
|
f"**Created at**: {engagement.created_at.isoformat() if engagement.created_at else 'N/A'}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if not simulations:
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Simulations")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
header_row = "| " + " | ".join(_MD_HEADERS) + " |"
|
||||||
|
separator = "| " + " | ".join("---" for _ in _MD_HEADERS) + " |"
|
||||||
|
lines.append(header_row)
|
||||||
|
lines.append(separator)
|
||||||
|
|
||||||
|
for sim in simulations:
|
||||||
|
def _cell(value: str | None) -> str:
|
||||||
|
# Escape HTML (including quotes) first to prevent stored XSS in MD renderers
|
||||||
|
# that interpret inline HTML, then escape pipe (GFM table syntax),
|
||||||
|
# then fold newlines to <br/> (our own safe markup, inserted after escape).
|
||||||
|
s = _html_escape(value or "")
|
||||||
|
s = s.replace("|", "\\|")
|
||||||
|
s = s.replace("\n", "<br/>")
|
||||||
|
return s
|
||||||
|
|
||||||
|
execution = _format_execution_text(sim)
|
||||||
|
row = "| " + " | ".join([
|
||||||
|
_cell(sim.name),
|
||||||
|
_cell(sim.description),
|
||||||
|
_cell(sim.log_source),
|
||||||
|
_cell(sim.soc_comment),
|
||||||
|
_cell(execution),
|
||||||
|
_cell(sim.logs),
|
||||||
|
_cell(sim.incident_number),
|
||||||
|
]) + " |"
|
||||||
|
lines.append(row)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSV
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CSV_HEADERS = [
|
||||||
|
"Scénario",
|
||||||
|
"Test",
|
||||||
|
"Source de log",
|
||||||
|
"Commentaires SOC",
|
||||||
|
"Exécution",
|
||||||
|
"Logs remontés au SIEM",
|
||||||
|
"Cyber incident",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_engagement_csv(
|
||||||
|
_engagement: Engagement, simulations: list[Simulation]
|
||||||
|
) -> str:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(_CSV_HEADERS)
|
||||||
|
|
||||||
|
for sim in simulations:
|
||||||
|
execution = _format_execution_csv(sim)
|
||||||
|
writer.writerow([
|
||||||
|
_csv_safe(sim.name or ""),
|
||||||
|
_csv_safe(sim.description or ""),
|
||||||
|
_csv_safe(sim.log_source or ""),
|
||||||
|
_csv_safe(sim.soc_comment or ""),
|
||||||
|
_csv_safe(execution), # belt-and-braces: outer check covers empty executed_at case
|
||||||
|
_csv_safe(sim.logs or ""),
|
||||||
|
_csv_safe(sim.incident_number or ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTML (internal, used by PDF renderer)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CSS = """
|
||||||
|
@page { size: A4 landscape; margin: 20mm; }
|
||||||
|
body { font-family: sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
|
||||||
|
h1 { font-size: 20px; border-bottom: 2px solid #333; padding-bottom: 6px; }
|
||||||
|
h2 { font-size: 15px; margin-top: 32px; color: #333; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; table-layout: fixed; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 3px 6px; text-align: left; vertical-align: top; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
th { background: #e0e0e0; }
|
||||||
|
.meta { color: #555; margin-bottom: 16px; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
_HTML_HEADERS = [
|
||||||
|
"Scénario",
|
||||||
|
"Test",
|
||||||
|
"Source de log",
|
||||||
|
"Commentaires SOC",
|
||||||
|
"Exécution",
|
||||||
|
"Logs remontés au SIEM",
|
||||||
|
"Cyber incident",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_engagement_html(
|
||||||
|
engagement: Engagement, simulations: list[Simulation]
|
||||||
|
) -> str:
|
||||||
|
h = _html_escape
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
parts.append("<!DOCTYPE html><html><head><meta charset='utf-8'>")
|
||||||
|
parts.append(f"<style>{_CSS}</style></head><body>")
|
||||||
|
parts.append(f"<h1>{h(engagement.name)}</h1>")
|
||||||
|
parts.append("<div class='meta'>")
|
||||||
|
if engagement.description:
|
||||||
|
parts.append(f"<p>{h(engagement.description)}</p>")
|
||||||
|
parts.append(f"<p><strong>Status:</strong> {h(engagement.status.value)}</p>")
|
||||||
|
sd = engagement.start_date.isoformat() if engagement.start_date else "N/A"
|
||||||
|
ed = engagement.end_date.isoformat() if engagement.end_date else "N/A"
|
||||||
|
parts.append(f"<p><strong>Dates:</strong> {h(sd)} → {h(ed)}</p>")
|
||||||
|
parts.append(f"<p><strong>Created by:</strong> {h(_creator(engagement))}</p>")
|
||||||
|
ca = engagement.created_at.isoformat() if engagement.created_at else "N/A"
|
||||||
|
parts.append(f"<p><strong>Created at:</strong> {h(ca)}</p>")
|
||||||
|
parts.append("</div>")
|
||||||
|
|
||||||
|
if simulations:
|
||||||
|
parts.append("<h2>Simulations</h2>")
|
||||||
|
thead = "<thead><tr>" + "".join(f"<th>{h(col)}</th>" for col in _HTML_HEADERS) + "</tr></thead>"
|
||||||
|
parts.append(f"<table>{thead}<tbody>")
|
||||||
|
for sim in simulations:
|
||||||
|
execution_html = h(_format_execution_text(sim)).replace("\n", "<br/>")
|
||||||
|
cells = [
|
||||||
|
h(sim.name or ""),
|
||||||
|
h(sim.description or ""),
|
||||||
|
h(sim.log_source or ""),
|
||||||
|
h(sim.soc_comment or ""),
|
||||||
|
execution_html,
|
||||||
|
h(sim.logs or ""),
|
||||||
|
h(sim.incident_number or ""),
|
||||||
|
]
|
||||||
|
row = "<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
|
||||||
|
parts.append(row)
|
||||||
|
parts.append("</tbody></table>")
|
||||||
|
|
||||||
|
parts.append("</body></html>")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PDF
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def render_engagement_pdf(
|
||||||
|
engagement: Engagement, simulations: list[Simulation]
|
||||||
|
) -> bytes:
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
|
html = _render_engagement_html(engagement, simulations)
|
||||||
|
return HTML(string=html).write_pdf()
|
||||||
246
backend/app/services/mitre.py
Normal file
246
backend/app/services/mitre.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"""MITRE ATT&CK bundle loader and search service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_BUNDLE_PATH = Path(__file__).parent.parent.parent / "data" / "mitre" / "enterprise-attack.json"
|
||||||
|
|
||||||
|
# Canonical Enterprise tactic order (12 tactics).
|
||||||
|
_TACTIC_ORDER = [
|
||||||
|
"initial-access",
|
||||||
|
"execution",
|
||||||
|
"persistence",
|
||||||
|
"privilege-escalation",
|
||||||
|
"defense-evasion",
|
||||||
|
"credential-access",
|
||||||
|
"discovery",
|
||||||
|
"lateral-movement",
|
||||||
|
"collection",
|
||||||
|
"command-and-control",
|
||||||
|
"exfiltration",
|
||||||
|
"impact",
|
||||||
|
]
|
||||||
|
|
||||||
|
# TA-id → short-name mapping (MITRE Enterprise, IDs are not sequential).
|
||||||
|
_TACTIC_IDS: dict[str, str] = {
|
||||||
|
"TA0001": "initial-access",
|
||||||
|
"TA0002": "execution",
|
||||||
|
"TA0003": "persistence",
|
||||||
|
"TA0004": "privilege-escalation",
|
||||||
|
"TA0005": "defense-evasion",
|
||||||
|
"TA0006": "credential-access",
|
||||||
|
"TA0007": "discovery",
|
||||||
|
"TA0008": "lateral-movement",
|
||||||
|
"TA0009": "collection",
|
||||||
|
"TA0011": "command-and-control",
|
||||||
|
"TA0010": "exfiltration",
|
||||||
|
"TA0040": "impact",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse: slug → TA-id (derived from _TACTIC_IDS, used by _build_matrix).
|
||||||
|
_SLUG_TO_TA_ID: dict[str, str] = {v: k for k, v in _TACTIC_IDS.items()}
|
||||||
|
|
||||||
|
TACTIC_NAMES: dict[str, str] = {
|
||||||
|
"initial-access": "Initial Access",
|
||||||
|
"execution": "Execution",
|
||||||
|
"persistence": "Persistence",
|
||||||
|
"privilege-escalation": "Privilege Escalation",
|
||||||
|
"defense-evasion": "Defense Evasion",
|
||||||
|
"credential-access": "Credential Access",
|
||||||
|
"discovery": "Discovery",
|
||||||
|
"lateral-movement": "Lateral Movement",
|
||||||
|
"collection": "Collection",
|
||||||
|
"command-and-control": "Command and Control",
|
||||||
|
"exfiltration": "Exfiltration",
|
||||||
|
"impact": "Impact",
|
||||||
|
}
|
||||||
|
|
||||||
|
mitre_loaded: bool = False
|
||||||
|
_index: list[dict[str, Any]] = []
|
||||||
|
_tactics_by_technique: dict[str, list[str]] = {}
|
||||||
|
_name_by_id: dict[str, str] = {}
|
||||||
|
# matrix: list of tactic dicts (built once at load time)
|
||||||
|
_matrix: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tactics(obj: dict[str, Any]) -> list[str]:
|
||||||
|
phases = obj.get("kill_chain_phases") or []
|
||||||
|
return [
|
||||||
|
p["phase_name"]
|
||||||
|
for p in phases
|
||||||
|
if isinstance(p, dict) and "phase_name" in p and p.get("kill_chain_name") == "mitre-attack"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_external_id(obj: dict[str, Any]) -> str | None:
|
||||||
|
for ref in obj.get("external_references") or []:
|
||||||
|
if isinstance(ref, dict) and ref.get("source_name") == "mitre-attack":
|
||||||
|
return ref.get("external_id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_subtechnique(tech_id: str) -> bool:
|
||||||
|
return "." in tech_id
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_id(sub_id: str) -> str:
|
||||||
|
return sub_id.split(".")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_matrix(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Build the tactic → techniques → subtechniques tree."""
|
||||||
|
# Group top-level techniques by tactic.
|
||||||
|
tactic_techs: dict[str, list[dict[str, Any]]] = {t: [] for t in _TACTIC_ORDER}
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if _is_subtechnique(entry["id"]):
|
||||||
|
continue
|
||||||
|
for tactic in entry["tactics"]:
|
||||||
|
if tactic in tactic_techs:
|
||||||
|
tactic_techs[tactic].append(entry)
|
||||||
|
|
||||||
|
# Attach sub-techniques to their parents.
|
||||||
|
parent_subs: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for entry in entries:
|
||||||
|
if not _is_subtechnique(entry["id"]):
|
||||||
|
continue
|
||||||
|
pid = _parent_id(entry["id"])
|
||||||
|
parent_subs.setdefault(pid, []).append({"id": entry["id"], "name": entry["name"]})
|
||||||
|
|
||||||
|
# Sort subs alphabetically by name.
|
||||||
|
for subs in parent_subs.values():
|
||||||
|
subs.sort(key=lambda x: x["name"])
|
||||||
|
|
||||||
|
matrix: list[dict[str, Any]] = []
|
||||||
|
for slug in _TACTIC_ORDER:
|
||||||
|
techs = tactic_techs.get(slug, [])
|
||||||
|
# Sort techniques alphabetically.
|
||||||
|
techs_sorted = sorted(techs, key=lambda x: x["name"])
|
||||||
|
tactic_name = TACTIC_NAMES.get(slug, slug.replace("-", " ").title())
|
||||||
|
# Expose TA-id so the frontend can send tactic_ids back in PATCH unchanged.
|
||||||
|
ta_id = _SLUG_TO_TA_ID.get(slug, slug)
|
||||||
|
matrix.append(
|
||||||
|
{
|
||||||
|
"tactic_id": ta_id,
|
||||||
|
"tactic_name": tactic_name,
|
||||||
|
"techniques": [
|
||||||
|
{
|
||||||
|
"id": t["id"],
|
||||||
|
"name": t["name"],
|
||||||
|
"subtechniques": parent_subs.get(t["id"], []),
|
||||||
|
}
|
||||||
|
for t in techs_sorted
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return matrix
|
||||||
|
|
||||||
|
|
||||||
|
def load_bundle(path: Path | None = None) -> None:
|
||||||
|
"""Load the MITRE bundle into memory. Called once at app boot."""
|
||||||
|
global mitre_loaded, _index, _tactics_by_technique, _name_by_id, _matrix
|
||||||
|
bundle_path = path or _BUNDLE_PATH
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = bundle_path.read_text(encoding="utf-8")
|
||||||
|
data = json.loads(raw)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("MITRE bundle not found at %s — autocomplete disabled", bundle_path)
|
||||||
|
mitre_loaded = False
|
||||||
|
return
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
logger.warning("MITRE bundle parse error: %s — autocomplete disabled", exc)
|
||||||
|
mitre_loaded = False
|
||||||
|
return
|
||||||
|
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
tactics_map: dict[str, list[str]] = {}
|
||||||
|
name_map: dict[str, str] = {}
|
||||||
|
|
||||||
|
for obj in data.get("objects") or []:
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
continue
|
||||||
|
if obj.get("type") != "attack-pattern":
|
||||||
|
continue
|
||||||
|
if obj.get("revoked") or obj.get("x_mitre_deprecated"):
|
||||||
|
continue
|
||||||
|
ext_id = _get_external_id(obj)
|
||||||
|
if not ext_id:
|
||||||
|
continue
|
||||||
|
tactics = _extract_tactics(obj)
|
||||||
|
name = obj.get("name", "")
|
||||||
|
entries.append({"id": ext_id, "name": name, "tactics": tactics})
|
||||||
|
tactics_map[ext_id] = tactics
|
||||||
|
name_map[ext_id] = name
|
||||||
|
|
||||||
|
_index = entries
|
||||||
|
_tactics_by_technique = tactics_map
|
||||||
|
_name_by_id = name_map
|
||||||
|
_matrix = _build_matrix(entries)
|
||||||
|
mitre_loaded = True
|
||||||
|
logger.info("MITRE bundle loaded: %d techniques", len(_index))
|
||||||
|
|
||||||
|
|
||||||
|
def get_tactics(technique_id: str) -> list[str]:
|
||||||
|
"""Return tactic list for a technique id; empty list if unknown."""
|
||||||
|
return _tactics_by_technique.get(technique_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_name(technique_id: str) -> str | None:
|
||||||
|
"""Return the name for a technique id, or None if not in the bundle."""
|
||||||
|
return _name_by_id.get(technique_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_matrix() -> list[dict[str, Any]]:
|
||||||
|
"""Return the full tactic → techniques → subtechniques tree."""
|
||||||
|
return _matrix
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_tactic(tactic_id: str) -> dict[str, str] | None:
|
||||||
|
"""Return {id, name} for a TA-id, or None if unknown."""
|
||||||
|
short = _TACTIC_IDS.get(tactic_id)
|
||||||
|
if short is None:
|
||||||
|
return None
|
||||||
|
return {"id": tactic_id, "name": TACTIC_NAMES[short]}
|
||||||
|
|
||||||
|
|
||||||
|
def get_tactic_name(tactic_id: str) -> str | None:
|
||||||
|
"""Return the display name for a TA-id, or None if unknown."""
|
||||||
|
short = _TACTIC_IDS.get(tactic_id)
|
||||||
|
if short is None:
|
||||||
|
return None
|
||||||
|
return TACTIC_NAMES[short]
|
||||||
|
|
||||||
|
|
||||||
|
def search(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""Return up to `limit` techniques matching `query`.
|
||||||
|
|
||||||
|
Ranking: exact id > prefix id > substring name (case-insensitive).
|
||||||
|
"""
|
||||||
|
q = query.strip().upper()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
|
||||||
|
exact: list[dict[str, Any]] = []
|
||||||
|
prefix: list[dict[str, Any]] = []
|
||||||
|
name_match: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for entry in _index:
|
||||||
|
tech_id = entry["id"].upper()
|
||||||
|
tech_name = entry["name"].upper()
|
||||||
|
|
||||||
|
if tech_id == q:
|
||||||
|
exact.append(entry)
|
||||||
|
elif tech_id.startswith(q):
|
||||||
|
prefix.append(entry)
|
||||||
|
elif q in tech_name:
|
||||||
|
name_match.append(entry)
|
||||||
|
|
||||||
|
combined = exact + prefix + name_match
|
||||||
|
return combined[:limit]
|
||||||
242
backend/app/services/simulation_workflow.py
Normal file
242
backend/app/services/simulation_workflow.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"""Simulation business logic: PATCH rules and state machine transitions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User
|
||||||
|
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||||
|
|
||||||
|
# Fields only admin/redteam may write (excluding technique_ids/tactic_ids handled separately).
|
||||||
|
REDTEAM_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"commands",
|
||||||
|
"prerequisites",
|
||||||
|
"executed_at",
|
||||||
|
"execution_result",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SOC_FIELDS = frozenset({"log_source", "logs", "soc_comment", "incident_number"})
|
||||||
|
|
||||||
|
_ALLOWED_TRANSITIONS: dict[str, dict[str, set[str]]] = {
|
||||||
|
"review_required": {
|
||||||
|
"from": {"pending", "in_progress"},
|
||||||
|
"roles": {"admin", "redteam"},
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"from": {"review_required"},
|
||||||
|
"roles": {"admin", "redteam", "soc"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_non_empty(value: Any) -> bool:
|
||||||
|
"""Return True if value counts as "filled" for auto-transition purposes."""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if isinstance(value, str) and value == "":
|
||||||
|
return False
|
||||||
|
return not (isinstance(value, list) and len(value) == 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_technique_ids(
|
||||||
|
technique_ids: list[str],
|
||||||
|
) -> tuple[list[dict[str, str]] | None, tuple[Any, int] | None]:
|
||||||
|
"""Validate and resolve technique IDs to [{id, name}] snapshots.
|
||||||
|
|
||||||
|
Returns (resolved_list, None) on success or (None, error_tuple) on failure.
|
||||||
|
Deduplicates while preserving order.
|
||||||
|
"""
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
|
||||||
|
if not mitre_svc.mitre_loaded:
|
||||||
|
return None, (jsonify({"error": "mitre bundle not loaded"}), 503)
|
||||||
|
|
||||||
|
seen: dict[str, None] = dict.fromkeys(technique_ids)
|
||||||
|
resolved: list[dict[str, str]] = []
|
||||||
|
for tid in seen:
|
||||||
|
name = mitre_svc.lookup_name(tid)
|
||||||
|
if name is None:
|
||||||
|
return None, (jsonify({"error": f"unknown technique id: {tid}"}), 400)
|
||||||
|
resolved.append({"id": tid, "name": name})
|
||||||
|
return resolved, None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tactic_ids(
|
||||||
|
tactic_ids: list[str],
|
||||||
|
) -> tuple[list[str] | None, tuple[Any, int] | None]:
|
||||||
|
"""Validate and deduplicate tactic TA-ids.
|
||||||
|
|
||||||
|
Returns (deduped_list, None) on success or (None, error_tuple) on failure.
|
||||||
|
Bundle does not need to be loaded — validation is against the hardcoded _TACTIC_IDS map.
|
||||||
|
"""
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
|
||||||
|
seen: dict[str, None] = dict.fromkeys(tactic_ids)
|
||||||
|
for tid in seen:
|
||||||
|
if mitre_svc.lookup_tactic(tid) is None:
|
||||||
|
return None, (jsonify({"error": f"unknown tactic id: {tid}"}), 400)
|
||||||
|
return list(seen), None
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_activate_engagement(simulation: Simulation) -> None:
|
||||||
|
"""If simulation's engagement is planned, advance it to active.
|
||||||
|
|
||||||
|
Caller must commit — do not commit here to avoid double-commit.
|
||||||
|
"""
|
||||||
|
from backend.app.models.engagement import Engagement, EngagementStatus
|
||||||
|
|
||||||
|
engagement: Engagement | None = getattr(simulation, "engagement", None)
|
||||||
|
if engagement is not None and engagement.status == EngagementStatus.PLANNED:
|
||||||
|
engagement.status = EngagementStatus.ACTIVE
|
||||||
|
db.session.add(engagement)
|
||||||
|
|
||||||
|
|
||||||
|
def promote_to_in_progress(simulation: Simulation) -> None:
|
||||||
|
"""Transition simulation pending → in_progress if it is currently pending.
|
||||||
|
|
||||||
|
Also advances the engagement planned → active via _maybe_activate_engagement.
|
||||||
|
No-op when the simulation is already in any other status.
|
||||||
|
Caller must commit.
|
||||||
|
"""
|
||||||
|
if simulation.status == SimulationStatus.PENDING:
|
||||||
|
simulation.status = SimulationStatus.IN_PROGRESS
|
||||||
|
simulation.updated_at = datetime.now(UTC)
|
||||||
|
_maybe_activate_engagement(simulation)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_patch(
|
||||||
|
simulation: Simulation, payload: dict[str, Any], user: User
|
||||||
|
) -> tuple[Any, int] | None:
|
||||||
|
"""Apply a validated PATCH payload to a simulation.
|
||||||
|
|
||||||
|
Returns a (response, status_code) tuple on error, or None on success
|
||||||
|
(caller is responsible for committing).
|
||||||
|
"""
|
||||||
|
# Done guard — applies to ALL roles before any RBAC check.
|
||||||
|
if simulation.status == SimulationStatus.DONE:
|
||||||
|
return jsonify({"error": "simulation is done — reopen first"}), 409
|
||||||
|
|
||||||
|
role = user.role.value
|
||||||
|
|
||||||
|
if role == "soc":
|
||||||
|
if simulation.status not in (
|
||||||
|
SimulationStatus.REVIEW_REQUIRED,
|
||||||
|
SimulationStatus.DONE,
|
||||||
|
):
|
||||||
|
return jsonify({"error": "simulation not ready for SOC review"}), 403
|
||||||
|
|
||||||
|
# SOC must not send redteam fields, technique_ids, or tactic_ids.
|
||||||
|
redteam_keys_in_payload = (
|
||||||
|
REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}
|
||||||
|
) & payload.keys()
|
||||||
|
if redteam_keys_in_payload:
|
||||||
|
return jsonify({"error": "soc cannot edit redteam fields"}), 403
|
||||||
|
|
||||||
|
for field in SOC_FIELDS:
|
||||||
|
if field in payload:
|
||||||
|
setattr(simulation, field, payload[field])
|
||||||
|
|
||||||
|
else:
|
||||||
|
# admin / redteam path.
|
||||||
|
redteam_keys_present = REDTEAM_FIELDS & payload.keys()
|
||||||
|
|
||||||
|
# Validate executed_at upfront before any writes.
|
||||||
|
executed_at_value: datetime | None = None
|
||||||
|
if "executed_at" in redteam_keys_present:
|
||||||
|
val = payload["executed_at"]
|
||||||
|
if val is not None:
|
||||||
|
if not isinstance(val, str):
|
||||||
|
return jsonify({"error": "invalid executed_at"}), 400
|
||||||
|
try:
|
||||||
|
executed_at_value = datetime.fromisoformat(val)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "invalid executed_at"}), 400
|
||||||
|
|
||||||
|
# Validate and resolve technique_ids upfront.
|
||||||
|
resolved_techniques: list[dict[str, str]] | None = None
|
||||||
|
if "technique_ids" in payload:
|
||||||
|
raw_ids = payload["technique_ids"]
|
||||||
|
if not isinstance(raw_ids, list):
|
||||||
|
return jsonify({"error": "technique_ids must be a list"}), 400
|
||||||
|
resolved_techniques, err = _resolve_technique_ids(raw_ids)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
# Validate and deduplicate tactic_ids upfront.
|
||||||
|
resolved_tactic_ids: list[str] | None = None
|
||||||
|
if "tactic_ids" in payload:
|
||||||
|
raw_tids = payload["tactic_ids"]
|
||||||
|
if not isinstance(raw_tids, list):
|
||||||
|
return jsonify({"error": "tactic_ids must be a list"}), 400
|
||||||
|
resolved_tactic_ids, err = _resolve_tactic_ids(raw_tids)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
# Apply scalar redteam fields.
|
||||||
|
for field in redteam_keys_present:
|
||||||
|
if field == "executed_at":
|
||||||
|
simulation.executed_at = executed_at_value
|
||||||
|
else:
|
||||||
|
setattr(simulation, field, payload[field])
|
||||||
|
|
||||||
|
# Apply resolved techniques.
|
||||||
|
if resolved_techniques is not None:
|
||||||
|
simulation.techniques = resolved_techniques
|
||||||
|
|
||||||
|
# Apply resolved tactic_ids.
|
||||||
|
if resolved_tactic_ids is not None:
|
||||||
|
simulation.tactic_ids = resolved_tactic_ids
|
||||||
|
|
||||||
|
# Apply SOC fields (admin/redteam may also write them).
|
||||||
|
for field in SOC_FIELDS:
|
||||||
|
if field in payload:
|
||||||
|
setattr(simulation, field, payload[field])
|
||||||
|
|
||||||
|
# Auto-transition pending → in_progress.
|
||||||
|
# Triggers when any redteam scalar has a non-empty value, technique_ids or tactic_ids non-empty.
|
||||||
|
auto_trigger = any(_is_non_empty(payload[k]) for k in redteam_keys_present)
|
||||||
|
if not auto_trigger and "technique_ids" in payload:
|
||||||
|
auto_trigger = len(payload["technique_ids"]) > 0
|
||||||
|
if not auto_trigger and "tactic_ids" in payload:
|
||||||
|
auto_trigger = len(payload["tactic_ids"]) > 0
|
||||||
|
|
||||||
|
if simulation.status == SimulationStatus.PENDING and auto_trigger:
|
||||||
|
simulation.status = SimulationStatus.IN_PROGRESS
|
||||||
|
_maybe_activate_engagement(simulation)
|
||||||
|
|
||||||
|
simulation.updated_at = datetime.now(UTC)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def transition(
|
||||||
|
simulation: Simulation, to_status: str, user: User
|
||||||
|
) -> tuple[Any, int] | None:
|
||||||
|
"""Attempt a manual transition. Returns error tuple or None on success."""
|
||||||
|
# Special case: done → review_required (Reopen), allowed for all 3 roles.
|
||||||
|
if to_status == "review_required" and simulation.status == SimulationStatus.DONE:
|
||||||
|
simulation.status = SimulationStatus.REVIEW_REQUIRED
|
||||||
|
simulation.updated_at = datetime.now(UTC)
|
||||||
|
db.session.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
rule = _ALLOWED_TRANSITIONS.get(to_status)
|
||||||
|
if rule is None:
|
||||||
|
return jsonify({"error": "invalid transition"}), 409
|
||||||
|
|
||||||
|
if simulation.status.value not in rule["from"]:
|
||||||
|
return jsonify({"error": "invalid transition"}), 409
|
||||||
|
|
||||||
|
if user.role.value not in rule["roles"]:
|
||||||
|
return jsonify({"error": "Forbidden"}), 403
|
||||||
|
|
||||||
|
simulation.status = SimulationStatus(to_status)
|
||||||
|
simulation.updated_at = datetime.now(UTC)
|
||||||
|
db.session.commit()
|
||||||
|
return None
|
||||||
795203
backend/data/mitre/enterprise-attack.json
Normal file
795203
backend/data/mitre/enterprise-attack.json
Normal file
File diff suppressed because it is too large
Load Diff
1
backend/migrations/README
Normal file
1
backend/migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration for Flask-Migrate / Alembic.
|
||||||
40
backend/migrations/alembic.ini
Normal file
40
backend/migrations/alembic.ini
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Alembic config. Most settings are injected at runtime by Flask-Migrate.
|
||||||
|
[alembic]
|
||||||
|
script_location = %(here)s
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
73
backend/migrations/env.py
Normal file
73
backend/migrations/env.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Alembic environment, wired to Flask-Migrate."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger("alembic.env")
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# Flask-SQLAlchemy < 3.x
|
||||||
|
return current_app.extensions["migrate"].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# Flask-SQLAlchemy >= 3.x
|
||||||
|
return current_app.extensions["migrate"].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url() -> str:
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace("%", "%%")
|
||||||
|
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", get_engine_url())
|
||||||
|
target_db = current_app.extensions["migrate"].db
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, "metadatas"):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
def process_revision_directives(context, revision, directives): # noqa: ANN001
|
||||||
|
if getattr(config.cmd_opts, "autogenerate", False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info("No changes in schema detected.")
|
||||||
|
|
||||||
|
conf_args = current_app.extensions["migrate"].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
backend/migrations/script.py.mako
Normal file
24
backend/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
60
backend/migrations/versions/0001_initial_schema.py
Normal file
60
backend/migrations/versions/0001_initial_schema.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""initial schema: users + engagements
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-26 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("username", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"role",
|
||||||
|
sa.Enum("admin", "redteam", "soc", name="user_role"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.UniqueConstraint("username", name="uq_users_username"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_username", "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"engagements",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("start_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("end_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum("planned", "active", "closed", name="engagement_status"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("created_by_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by_id"], ["users.id"], ondelete="RESTRICT",
|
||||||
|
name="fk_engagements_created_by_id_users",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("engagements")
|
||||||
|
op.drop_index("ix_users_username", table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
|
sa.Enum(name="engagement_status").drop(op.get_bind(), checkfirst=True)
|
||||||
|
sa.Enum(name="user_role").drop(op.get_bind(), checkfirst=True)
|
||||||
59
backend/migrations/versions/0002_add_simulations.py
Normal file
59
backend/migrations/versions/0002_add_simulations.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""add simulations table
|
||||||
|
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2026-05-26 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0002"
|
||||||
|
down_revision = "0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"simulations",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("engagement_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("mitre_technique_id", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("mitre_technique_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("commands", sa.Text(), nullable=True),
|
||||||
|
sa.Column("prerequisites", sa.Text(), nullable=True),
|
||||||
|
sa.Column("executed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("execution_result", sa.Text(), nullable=True),
|
||||||
|
sa.Column("log_source", sa.Text(), nullable=True),
|
||||||
|
sa.Column("logs", sa.Text(), nullable=True),
|
||||||
|
sa.Column("soc_comment", sa.Text(), nullable=True),
|
||||||
|
sa.Column("incident_number", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum("pending", "in_progress", "review_required", "done", name="simulation_status"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_by_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["engagement_id"], ["engagements.id"], ondelete="CASCADE",
|
||||||
|
name="fk_simulations_engagement_id_engagements",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by_id"], ["users.id"], ondelete="RESTRICT",
|
||||||
|
name="fk_simulations_created_by_id_users",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_simulations_engagement_id", "simulations", ["engagement_id"])
|
||||||
|
op.create_index("ix_simulations_created_by_id", "simulations", ["created_by_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_simulations_created_by_id", table_name="simulations")
|
||||||
|
op.drop_index("ix_simulations_engagement_id", table_name="simulations")
|
||||||
|
op.drop_table("simulations")
|
||||||
|
sa.Enum(name="simulation_status").drop(op.get_bind(), checkfirst=True)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""replace scalar MITRE columns with techniques JSON array
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002
|
||||||
|
Create Date: 2026-05-27 00:00:00.000000
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.sql import text
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0003"
|
||||||
|
down_revision = "0002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Add techniques column (nullable while we backfill).
|
||||||
|
op.add_column("simulations", sa.Column("techniques", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
# 2. Backfill: scalar → JSON array.
|
||||||
|
rows = bind.execute(
|
||||||
|
text("SELECT id, mitre_technique_id, mitre_technique_name FROM simulations")
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
if row[1]: # mitre_technique_id is not null
|
||||||
|
val = json.dumps([{"id": row[1], "name": row[2] or ""}])
|
||||||
|
else:
|
||||||
|
val = "[]"
|
||||||
|
bind.execute(
|
||||||
|
text("UPDATE simulations SET techniques = :v WHERE id = :id"),
|
||||||
|
{"v": val, "id": row[0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Make NOT NULL now that every row has a value.
|
||||||
|
with op.batch_alter_table("simulations") as batch_op:
|
||||||
|
batch_op.alter_column("techniques", existing_type=sa.Text(), nullable=False)
|
||||||
|
|
||||||
|
# 4. Drop old scalar columns.
|
||||||
|
with op.batch_alter_table("simulations") as batch_op:
|
||||||
|
batch_op.drop_column("mitre_technique_id")
|
||||||
|
batch_op.drop_column("mitre_technique_name")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Re-add scalar columns.
|
||||||
|
with op.batch_alter_table("simulations") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("mitre_technique_id", sa.String(length=32), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("mitre_technique_name", sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
# 2. Back-fill: take first element of techniques array.
|
||||||
|
rows = bind.execute(text("SELECT id, techniques FROM simulations")).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
techniques = json.loads(row[1] or "[]")
|
||||||
|
if techniques:
|
||||||
|
first = techniques[0]
|
||||||
|
bind.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE simulations SET mitre_technique_id = :tid, mitre_technique_name = :tname WHERE id = :id"
|
||||||
|
),
|
||||||
|
{"tid": first.get("id"), "tname": first.get("name"), "id": row[0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Drop techniques column.
|
||||||
|
with op.batch_alter_table("simulations") as batch_op:
|
||||||
|
batch_op.drop_column("techniques")
|
||||||
33
backend/migrations/versions/0004_simulation_tactic_ids.py
Normal file
33
backend/migrations/versions/0004_simulation_tactic_ids.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""add tactic_ids JSON column to simulations
|
||||||
|
|
||||||
|
Revision ID: 0004
|
||||||
|
Revises: 0003
|
||||||
|
Create Date: 2026-05-27 00:00:00.000000
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.sql import text
|
||||||
|
|
||||||
|
revision = "0004"
|
||||||
|
down_revision = "0003"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ADD COLUMN is safe on SQLite without batch mode.
|
||||||
|
# server_default='[]' satisfies NOT NULL for existing rows.
|
||||||
|
op.add_column(
|
||||||
|
"simulations",
|
||||||
|
sa.Column(
|
||||||
|
"tactic_ids",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("'[]'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("simulations") as batch_op:
|
||||||
|
batch_op.drop_column("tactic_ids")
|
||||||
40
backend/migrations/versions/0005_simulation_templates.py
Normal file
40
backend/migrations/versions/0005_simulation_templates.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""create simulation_templates table
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2026-05-28 00:00:00.000000
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0005"
|
||||||
|
down_revision = "0004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"simulation_templates",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False, unique=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("commands", sa.Text(), nullable=True),
|
||||||
|
sa.Column("prerequisites", sa.Text(), nullable=True),
|
||||||
|
sa.Column("techniques", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
|
||||||
|
sa.Column("tactic_ids", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_by_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_simulation_templates_name", "simulation_templates", ["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_simulation_templates_name", "simulation_templates")
|
||||||
|
op.drop_table("simulation_templates")
|
||||||
67
backend/migrations/versions/0006_c2_layer.py
Normal file
67
backend/migrations/versions/0006_c2_layer.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""create c2_config and c2_task tables
|
||||||
|
|
||||||
|
Revision ID: 0006
|
||||||
|
Revises: 0005
|
||||||
|
Create Date: 2026-06-10 00:00:00.000000
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0006"
|
||||||
|
down_revision = "0005"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"c2_config",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"engagement_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("engagements.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
sa.Column("url", sa.Text(), nullable=False),
|
||||||
|
sa.Column("api_token_encrypted", sa.Text(), nullable=False),
|
||||||
|
sa.Column("verify_tls", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_c2_config_engagement_id", "c2_config", ["engagement_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"c2_task",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"simulation_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("simulations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("mythic_task_display_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("callback_display_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("command", sa.Text(), nullable=False),
|
||||||
|
sa.Column("params", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False),
|
||||||
|
sa.Column("completed", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("output", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"source",
|
||||||
|
sa.Enum("mimic", "import", name="c2task_source"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("c2_task")
|
||||||
|
op.drop_index("ix_c2_config_engagement_id", "c2_config")
|
||||||
|
op.drop_table("c2_config")
|
||||||
|
# Remove the enum type (no-op on SQLite, required on Postgres)
|
||||||
|
sa.Enum(name="c2task_source").drop(op.get_bind(), checkfirst=True)
|
||||||
30
backend/migrations/versions/0007_c2_task_mapping_applied.py
Normal file
30
backend/migrations/versions/0007_c2_task_mapping_applied.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""add mapping_applied column to c2_task
|
||||||
|
|
||||||
|
Revision ID: 0007
|
||||||
|
Revises: 0006
|
||||||
|
Create Date: 2026-06-10 00:00:00.000000
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0007"
|
||||||
|
down_revision = "0006"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("c2_task") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"mapping_applied",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("c2_task") as batch_op:
|
||||||
|
batch_op.drop_column("mapping_applied")
|
||||||
28
backend/pyproject.toml
Normal file
28
backend/pyproject.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[project]
|
||||||
|
name = "mimic-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Mimic BAS backend (Flask API)"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
extend-exclude = ["migrations/versions"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "B", "UP", "SIM"]
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
strict = false
|
||||||
|
ignore_missing_imports = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
no_implicit_optional = true
|
||||||
|
exclude = ["migrations/", "tests/"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-ra"
|
||||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Migrate==4.0.7
|
||||||
|
PyJWT==2.9.0
|
||||||
|
argon2-cffi==23.1.0
|
||||||
|
weasyprint>=60.0
|
||||||
|
cryptography==44.0.0
|
||||||
|
requests==2.32.3
|
||||||
|
pytest==8.3.3
|
||||||
|
ruff==0.6.9
|
||||||
|
mypy==1.11.2
|
||||||
|
types-requests==2.32.0.20240914
|
||||||
|
requests-mock==1.12.1
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
92
backend/tests/conftest.py
Normal file
92
backend/tests/conftest.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Shared pytest fixtures."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app import create_app
|
||||||
|
from backend.app.auth import hash_password
|
||||||
|
from backend.app.config import TestConfig
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def app() -> Generator[Flask, None, None]:
|
||||||
|
application = create_app(TestConfig())
|
||||||
|
with application.app_context():
|
||||||
|
db.create_all()
|
||||||
|
yield application
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(app: Flask) -> FlaskClient:
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin_user(app: Flask) -> User:
|
||||||
|
user = User(
|
||||||
|
username="admin1",
|
||||||
|
password_hash=hash_password("adminpass1"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def redteam_user(app: Flask) -> User:
|
||||||
|
user = User(
|
||||||
|
username="redteam1",
|
||||||
|
password_hash=hash_password("redteampass1"),
|
||||||
|
role=UserRole.REDTEAM,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def soc_user(app: Flask) -> User:
|
||||||
|
user = User(
|
||||||
|
username="soc1",
|
||||||
|
password_hash=hash_password("socpass1"),
|
||||||
|
role=UserRole.SOC,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: FlaskClient, username: str, password: str) -> str:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"username": username, "password": password}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
return resp.get_json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin_token(client: FlaskClient, admin_user: User) -> str:
|
||||||
|
return _login(client, "admin1", "adminpass1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def redteam_token(client: FlaskClient, redteam_user: User) -> str:
|
||||||
|
return _login(client, "redteam1", "redteampass1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def soc_token(client: FlaskClient, soc_user: User) -> str:
|
||||||
|
return _login(client, "soc1", "socpass1")
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers(token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
41
backend/tests/test_app.py
Normal file
41
backend/tests/test_app.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""App-level tests: SPA fallback, health, error shapes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_endpoint(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_api_path_returns_json_404(client: FlaskClient) -> None:
|
||||||
|
"""SPA fallback must not shadow unknown /api/* routes with index.html."""
|
||||||
|
resp = client.get("/api/nonexistent")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.is_json, f"expected JSON, got Content-Type={resp.content_type}"
|
||||||
|
assert resp.get_json() == {"error": "Not found"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_nested_api_path_returns_json_404(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/foo/bar/baz")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.is_json
|
||||||
|
assert resp.get_json() == {"error": "Not found"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_method_on_api_returns_json(client: FlaskClient) -> None:
|
||||||
|
# PUT is not defined on /api/auth/login — should stay JSON, not HTML.
|
||||||
|
resp = client.put("/api/auth/login")
|
||||||
|
assert resp.status_code in (404, 405)
|
||||||
|
assert resp.is_json
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_without_built_frontend_returns_json(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Tests run with no built frontend → factory falls back to a JSON status payload.
|
||||||
|
assert resp.is_json
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
93
backend/tests/test_auth.py
Normal file
93
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Auth endpoint tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success(client: FlaskClient, admin_user: User) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin1", "password": "adminpass1"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "access_token" in body and body["access_token"]
|
||||||
|
assert body["user"] == {"id": admin_user.id, "username": "admin1", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password(client: FlaskClient, admin_user: User) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin1", "password": "wrong"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json() == {"error": "Invalid credentials"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_unknown_user(client: FlaskClient) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login", json={"username": "ghost", "password": "anything!!"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
# Generic message — must not leak whether username exists.
|
||||||
|
assert resp.get_json() == {"error": "Invalid credentials"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_missing_fields(client: FlaskClient) -> None:
|
||||||
|
resp = client.post("/api/auth/login", json={})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_requires_token(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_returns_current_user(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["username"] == "admin1"
|
||||||
|
assert body["role"] == "admin"
|
||||||
|
assert "password_hash" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_with_invalid_token(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/auth/me", headers={"Authorization": "Bearer not.a.jwt"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_with_expired_token(
|
||||||
|
app: Flask, client: FlaskClient, admin_user: User
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(UTC) - timedelta(minutes=120)
|
||||||
|
payload = {
|
||||||
|
"sub": str(admin_user.id),
|
||||||
|
"role": "admin",
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(minutes=1)).timestamp()),
|
||||||
|
}
|
||||||
|
token = jwt.encode(
|
||||||
|
payload, app.config["JWT_SECRET"], algorithm=app.config["JWT_ALGORITHM"]
|
||||||
|
)
|
||||||
|
resp = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json() == {"error": "Token expired"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_ok_with_token(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/logout", headers={"Authorization": f"Bearer {admin_token}"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_without_token_is_401(client: FlaskClient) -> None:
|
||||||
|
resp = client.post("/api/auth/logout")
|
||||||
|
assert resp.status_code == 401
|
||||||
30
backend/tests/test_c2_adapter_fake.py
Normal file
30
backend/tests/test_c2_adapter_fake.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Tests for the FakeAdapter deterministic in-memory implementation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import C2Health
|
||||||
|
from backend.app.services.c2.fake import FakeAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class TestFakeAdapterTestConnection:
|
||||||
|
def test_returns_ok_true(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
health = adapter.test_connection()
|
||||||
|
assert isinstance(health, C2Health)
|
||||||
|
assert health.ok is True
|
||||||
|
assert health.error is None
|
||||||
|
|
||||||
|
def test_list_callbacks_returns_list(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
callbacks = adapter.list_callbacks()
|
||||||
|
assert isinstance(callbacks, list)
|
||||||
|
assert len(callbacks) >= 1
|
||||||
|
|
||||||
|
def test_list_callbacks_fields(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
cb = adapter.list_callbacks()[0]
|
||||||
|
assert hasattr(cb, "display_id")
|
||||||
|
assert hasattr(cb, "active")
|
||||||
|
assert hasattr(cb, "host")
|
||||||
|
assert hasattr(cb, "user")
|
||||||
|
assert hasattr(cb, "domain")
|
||||||
|
assert hasattr(cb, "last_checkin")
|
||||||
62
backend/tests/test_c2_adapter_fake_m2.py
Normal file
62
backend/tests/test_c2_adapter_fake_m2.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""FakeAdapter M2 tests — list_callbacks shape, create_task monotonicity."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.app.services.c2.fake import FakeAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class TestFakeAdapterListCallbacks:
|
||||||
|
def test_returns_three_callbacks(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
callbacks = adapter.list_callbacks()
|
||||||
|
assert len(callbacks) == 3
|
||||||
|
|
||||||
|
def test_all_active(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
for cb in adapter.list_callbacks():
|
||||||
|
assert cb.active is True
|
||||||
|
|
||||||
|
def test_display_ids_are_1_2_3(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
ids = [cb.display_id for cb in adapter.list_callbacks()]
|
||||||
|
assert ids == [1, 2, 3]
|
||||||
|
|
||||||
|
def test_pinned_last_checkin_format(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
for cb in adapter.list_callbacks():
|
||||||
|
assert cb.last_checkin.startswith("2026-06-10")
|
||||||
|
|
||||||
|
def test_callbacks_have_host_user_domain(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
for cb in adapter.list_callbacks():
|
||||||
|
assert cb.host
|
||||||
|
assert cb.user
|
||||||
|
assert cb.domain
|
||||||
|
|
||||||
|
|
||||||
|
class TestFakeAdapterCreateTask:
|
||||||
|
def test_returns_monotonic_ids_from_1000(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
id1 = adapter.create_task(1, "whoami")
|
||||||
|
id2 = adapter.create_task(1, "ipconfig")
|
||||||
|
assert id1 == 1000
|
||||||
|
assert id2 == 1001
|
||||||
|
|
||||||
|
def test_separate_instances_start_at_1000_independently(self):
|
||||||
|
a1 = FakeAdapter()
|
||||||
|
a2 = FakeAdapter()
|
||||||
|
assert a1.create_task(1, "cmd") == 1000
|
||||||
|
assert a2.create_task(1, "cmd") == 1000
|
||||||
|
|
||||||
|
def test_stores_command_and_callback(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
tid = adapter.create_task(callback_display_id=2, command="ls", params="-la")
|
||||||
|
task = adapter._tasks[tid]
|
||||||
|
assert task["command"] == "ls"
|
||||||
|
assert task["params"] == "-la"
|
||||||
|
assert task["callback_display_id"] == 2
|
||||||
|
|
||||||
|
def test_initial_status_submitted(self):
|
||||||
|
adapter = FakeAdapter()
|
||||||
|
tid = adapter.create_task(1, "hostname")
|
||||||
|
assert adapter._tasks[tid]["status"] == "submitted"
|
||||||
|
assert adapter._tasks[tid]["completed"] is False
|
||||||
110
backend/tests/test_c2_adapter_fake_m3.py
Normal file
110
backend/tests/test_c2_adapter_fake_m3.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""FakeAdapter M3 state-progression tests — get_task and get_task_output."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import C2Error
|
||||||
|
from backend.app.services.c2.fake import FakeAdapter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def adapter() -> FakeAdapter:
|
||||||
|
return FakeAdapter()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def adapter_with_task(adapter: FakeAdapter) -> tuple[FakeAdapter, int]:
|
||||||
|
tid = adapter.create_task(callback_display_id=1, command="whoami")
|
||||||
|
return adapter, tid
|
||||||
|
|
||||||
|
|
||||||
|
class TestFakeAdapterGetTaskProgression:
|
||||||
|
def test_first_call_returns_submitted(self, adapter_with_task):
|
||||||
|
a, tid = adapter_with_task
|
||||||
|
status = a.get_task(tid)
|
||||||
|
assert status.status == "submitted"
|
||||||
|
assert status.completed is False
|
||||||
|
|
||||||
|
def test_second_call_returns_completed(self, adapter_with_task):
|
||||||
|
a, tid = adapter_with_task
|
||||||
|
a.get_task(tid) # first call
|
||||||
|
status = a.get_task(tid) # second call
|
||||||
|
assert status.status == "completed"
|
||||||
|
assert status.completed is True
|
||||||
|
|
||||||
|
def test_subsequent_calls_stay_completed(self, adapter_with_task):
|
||||||
|
a, tid = adapter_with_task
|
||||||
|
for _ in range(5):
|
||||||
|
a.get_task(tid)
|
||||||
|
status = a.get_task(tid)
|
||||||
|
assert status.completed is True
|
||||||
|
|
||||||
|
def test_unknown_task_id_returns_submitted_on_first_call(self, adapter):
|
||||||
|
"""A task ID not created by this instance still goes through submitted→completed."""
|
||||||
|
status = adapter.get_task(9999)
|
||||||
|
assert status.display_id == 9999
|
||||||
|
assert status.status == "submitted"
|
||||||
|
assert status.completed is False
|
||||||
|
|
||||||
|
def test_call_counters_are_per_task(self, adapter):
|
||||||
|
"""Two tasks have independent state — completing one does not affect the other."""
|
||||||
|
t1 = adapter.create_task(callback_display_id=1, command="whoami")
|
||||||
|
t2 = adapter.create_task(callback_display_id=1, command="ipconfig")
|
||||||
|
|
||||||
|
# Advance t1 to completed via two calls.
|
||||||
|
adapter.get_task(t1)
|
||||||
|
adapter.get_task(t1)
|
||||||
|
|
||||||
|
# t2 first call should still be submitted.
|
||||||
|
s2 = adapter.get_task(t2)
|
||||||
|
assert s2.status == "submitted"
|
||||||
|
assert s2.completed is False
|
||||||
|
|
||||||
|
def test_instances_are_isolated(self):
|
||||||
|
"""Per-instance counters — different FakeAdapter instances don't share state."""
|
||||||
|
a1 = FakeAdapter()
|
||||||
|
a2 = FakeAdapter()
|
||||||
|
|
||||||
|
t1 = a1.create_task(1, "cmd")
|
||||||
|
t2 = a2.create_task(1, "cmd")
|
||||||
|
|
||||||
|
a1.get_task(t1)
|
||||||
|
a1.get_task(t1) # a1's task is now completed
|
||||||
|
|
||||||
|
# a2's task with same display_id (both start at 1000) should be independent.
|
||||||
|
assert t1 == t2 == 1000
|
||||||
|
s2 = a2.get_task(t2)
|
||||||
|
assert s2.status == "submitted"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFakeAdapterGetTaskOutput:
|
||||||
|
def test_raises_before_completed(self, adapter_with_task):
|
||||||
|
a, tid = adapter_with_task
|
||||||
|
with pytest.raises(C2Error, match="task not completed"):
|
||||||
|
a.get_task_output(tid)
|
||||||
|
|
||||||
|
def test_raises_after_first_get_task_call_only(self, adapter_with_task):
|
||||||
|
a, tid = adapter_with_task
|
||||||
|
a.get_task(tid) # first call — still submitted
|
||||||
|
with pytest.raises(C2Error, match="task not completed"):
|
||||||
|
a.get_task_output(tid)
|
||||||
|
|
||||||
|
def test_returns_output_after_completed(self, adapter_with_task):
|
||||||
|
a, tid = adapter_with_task
|
||||||
|
a.get_task(tid)
|
||||||
|
a.get_task(tid) # now completed
|
||||||
|
output = a.get_task_output(tid)
|
||||||
|
assert "whoami" in output
|
||||||
|
assert str(tid) in output
|
||||||
|
|
||||||
|
def test_output_format(self, adapter):
|
||||||
|
tid = adapter.create_task(callback_display_id=2, command="ipconfig /all")
|
||||||
|
adapter.get_task(tid)
|
||||||
|
adapter.get_task(tid)
|
||||||
|
output = adapter.get_task_output(tid)
|
||||||
|
assert output == f"output for task {tid}: ipconfig /all\n"
|
||||||
|
|
||||||
|
def test_unknown_task_raises_c2error(self, adapter):
|
||||||
|
"""Task ID never created and never polled — not completed → C2Error."""
|
||||||
|
with pytest.raises(C2Error, match="task not completed"):
|
||||||
|
adapter.get_task_output(9999)
|
||||||
75
backend/tests/test_c2_adapter_fake_m4.py
Normal file
75
backend/tests/test_c2_adapter_fake_m4.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""FakeAdapter M4 tests — list_callback_tasks pagination."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import C2HistoricalTask
|
||||||
|
from backend.app.services.c2.fake import FakeAdapter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def adapter() -> FakeAdapter:
|
||||||
|
return FakeAdapter()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFakeAdapterListCallbackTasks:
|
||||||
|
def test_callback_1_returns_12_total(self, adapter):
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
|
||||||
|
assert page.total == 12
|
||||||
|
|
||||||
|
def test_callback_2_returns_0_tasks(self, adapter):
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=2, page=1, page_size=25)
|
||||||
|
assert page.total == 0
|
||||||
|
assert page.items == []
|
||||||
|
|
||||||
|
def test_callback_3_returns_5_tasks(self, adapter):
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=3, page=1, page_size=25)
|
||||||
|
assert page.total == 5
|
||||||
|
assert len(page.items) == 5
|
||||||
|
|
||||||
|
def test_items_are_c2_historical_task_instances(self, adapter):
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
|
||||||
|
for item in page.items:
|
||||||
|
assert isinstance(item, C2HistoricalTask)
|
||||||
|
|
||||||
|
def test_pagination_page1(self, adapter):
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
|
||||||
|
assert len(page.items) == 5
|
||||||
|
assert page.page == 1
|
||||||
|
assert page.page_size == 5
|
||||||
|
|
||||||
|
def test_pagination_page2(self, adapter):
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=5)
|
||||||
|
assert len(page.items) == 5
|
||||||
|
assert page.page == 2
|
||||||
|
|
||||||
|
def test_pagination_last_page_partial(self, adapter):
|
||||||
|
# 12 tasks, page_size=5 → page 3 has 2 items.
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=5)
|
||||||
|
assert len(page.items) == 2
|
||||||
|
assert page.total == 12
|
||||||
|
|
||||||
|
def test_pagination_beyond_range_returns_empty(self, adapter):
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1, page=99, page_size=25)
|
||||||
|
assert len(page.items) == 0
|
||||||
|
assert page.total == 12
|
||||||
|
|
||||||
|
def test_history_is_deterministic_across_instances(self):
|
||||||
|
a1 = FakeAdapter()
|
||||||
|
a2 = FakeAdapter()
|
||||||
|
p1 = a1.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
|
||||||
|
p2 = a2.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
|
||||||
|
assert [t.display_id for t in p1.items] == [t.display_id for t in p2.items]
|
||||||
|
|
||||||
|
def test_completed_and_submitted_mix(self, adapter):
|
||||||
|
"""Callback 1 has alternating completed/submitted tasks (even=completed)."""
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=12)
|
||||||
|
completed = [t for t in page.items if t.completed]
|
||||||
|
submitted = [t for t in page.items if not t.completed]
|
||||||
|
assert len(completed) == 6
|
||||||
|
assert len(submitted) == 6
|
||||||
|
|
||||||
|
def test_unknown_callback_returns_empty(self, adapter):
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=999, page=1, page_size=25)
|
||||||
|
assert page.total == 0
|
||||||
|
assert page.items == []
|
||||||
151
backend/tests/test_c2_adapter_mythic.py
Normal file
151
backend/tests/test_c2_adapter_mythic.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""MythicAdapter unit tests — mocked HTTP with requests-mock."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import requests_mock as rm_module
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import C2Error
|
||||||
|
from backend.app.services.c2.mythic import MythicAdapter
|
||||||
|
|
||||||
|
_BASE_URL = "https://mythic.lab:7443"
|
||||||
|
_GQL_URL = _BASE_URL + "/graphql"
|
||||||
|
_TOKEN = "fake-api-token"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def adapter():
|
||||||
|
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMythicAdapterListCallbacks:
|
||||||
|
def test_returns_callbacks_from_graphql(self, adapter):
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"callback": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"display_id": 1,
|
||||||
|
"active": True,
|
||||||
|
"host": "HOST-01",
|
||||||
|
"user": "jdoe",
|
||||||
|
"domain": "LAB",
|
||||||
|
"last_checkin": "2026-06-10T00:00:00Z",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
callbacks = adapter.list_callbacks()
|
||||||
|
|
||||||
|
assert len(callbacks) == 1
|
||||||
|
assert callbacks[0].display_id == 1
|
||||||
|
assert callbacks[0].host == "HOST-01"
|
||||||
|
assert callbacks[0].user == "jdoe"
|
||||||
|
|
||||||
|
def test_sends_apitoken_header(self, adapter):
|
||||||
|
payload = {"data": {"callback": []}}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
adapter.list_callbacks()
|
||||||
|
sent_headers = m.last_request.headers
|
||||||
|
|
||||||
|
assert sent_headers.get("apitoken") == _TOKEN
|
||||||
|
|
||||||
|
def test_verify_tls_flag_passed(self):
|
||||||
|
"""Adapter with verify_tls=True should pass verify=True to requests."""
|
||||||
|
adapter_tls = MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=True)
|
||||||
|
payload = {"data": {"callback": []}}
|
||||||
|
# requests-mock intercepts before TLS — just confirm no error path triggered.
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
callbacks = adapter_tls.list_callbacks()
|
||||||
|
assert isinstance(callbacks, list)
|
||||||
|
|
||||||
|
def test_network_error_raises_c2error(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("connection refused"))
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.list_callbacks()
|
||||||
|
|
||||||
|
def test_http_error_raises_c2error(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, status_code=500, text="Internal Server Error")
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.list_callbacks()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMythicAdapterCreateTask:
|
||||||
|
def test_returns_display_id_on_success(self, adapter):
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"createTask": {
|
||||||
|
"id": 42,
|
||||||
|
"display_id": 7,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
tid = adapter.create_task(callback_display_id=1, command="whoami")
|
||||||
|
|
||||||
|
assert tid == 7
|
||||||
|
|
||||||
|
def test_sends_apitoken_header(self, adapter):
|
||||||
|
payload = {"data": {"createTask": {"id": 1, "display_id": 1, "error": None}}}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
adapter.create_task(1, "cmd")
|
||||||
|
sent_headers = m.last_request.headers
|
||||||
|
|
||||||
|
assert sent_headers.get("apitoken") == _TOKEN
|
||||||
|
|
||||||
|
def test_error_field_raises_c2error(self, adapter):
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"createTask": {
|
||||||
|
"id": None,
|
||||||
|
"display_id": None,
|
||||||
|
"error": "callback not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
with pytest.raises(C2Error, match="callback not found"):
|
||||||
|
adapter.create_task(1, "whoami")
|
||||||
|
|
||||||
|
def test_network_error_raises_c2error(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, exc=requests.exceptions.Timeout("timeout"))
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.create_task(1, "whoami")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMythicAdapterErrorSanitization:
|
||||||
|
def test_connection_error_message_does_not_contain_url(self, adapter):
|
||||||
|
"""C2Error message must not expose the configured Mythic URL."""
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError(
|
||||||
|
f"HTTPSConnectionPool(host='{_BASE_URL}', port=7443): Max retries exceeded"
|
||||||
|
))
|
||||||
|
with pytest.raises(C2Error) as exc_info:
|
||||||
|
adapter.list_callbacks()
|
||||||
|
|
||||||
|
assert _BASE_URL not in str(exc_info.value)
|
||||||
|
assert "ConnectionError" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMythicAdapterNoRedirects:
|
||||||
|
def test_does_not_follow_redirect(self, adapter):
|
||||||
|
"""Adapter must not follow HTTP redirects (allow_redirects=False)."""
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
# Simulate a redirect response; requests-mock won't auto-follow it.
|
||||||
|
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/graphql"})
|
||||||
|
# With allow_redirects=False the 301 is treated as a non-2xx → raise_for_status raises.
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.list_callbacks()
|
||||||
|
# Exactly one request was made — no follow-up to Location.
|
||||||
|
assert len(m.request_history) == 1
|
||||||
188
backend/tests/test_c2_adapter_mythic_m3.py
Normal file
188
backend/tests/test_c2_adapter_mythic_m3.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"""MythicAdapter M3 tests — get_task and get_task_output, mocked HTTP."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import requests_mock as rm_module
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import C2Error
|
||||||
|
from backend.app.services.c2.mythic import MythicAdapter
|
||||||
|
|
||||||
|
_BASE_URL = "https://mythic.lab:7443"
|
||||||
|
_GQL_URL = _BASE_URL + "/graphql"
|
||||||
|
_TOKEN = "fake-api-token"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def adapter():
|
||||||
|
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMythicAdapterGetTask:
|
||||||
|
def test_returns_status_for_incomplete_task(self, adapter):
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"task": [
|
||||||
|
{
|
||||||
|
"display_id": 7,
|
||||||
|
"status": "processing",
|
||||||
|
"completed": False,
|
||||||
|
"timestamp": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
status = adapter.get_task(7)
|
||||||
|
|
||||||
|
assert status.display_id == 7
|
||||||
|
assert status.status == "processing"
|
||||||
|
assert status.completed is False
|
||||||
|
assert status.completed_at is None
|
||||||
|
|
||||||
|
def test_returns_completed_at_for_completed_task(self, adapter):
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"task": [
|
||||||
|
{
|
||||||
|
"display_id": 7,
|
||||||
|
"status": "completed",
|
||||||
|
"completed": True,
|
||||||
|
"timestamp": "2026-06-10T12:00:00Z",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
status = adapter.get_task(7)
|
||||||
|
|
||||||
|
assert status.completed is True
|
||||||
|
assert status.completed_at is not None
|
||||||
|
assert status.completed_at.year == 2026
|
||||||
|
|
||||||
|
def test_raises_when_task_not_found(self, adapter):
|
||||||
|
payload = {"data": {"task": []}}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
with pytest.raises(C2Error, match="not found"):
|
||||||
|
adapter.get_task(999)
|
||||||
|
|
||||||
|
def test_sends_apitoken_header(self, adapter):
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"task": [
|
||||||
|
{"display_id": 1, "status": "submitted", "completed": False, "timestamp": None}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
adapter.get_task(1)
|
||||||
|
assert m.last_request.headers.get("apitoken") == _TOKEN
|
||||||
|
|
||||||
|
def test_network_error_raises_c2error(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("refused"))
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.get_task(1)
|
||||||
|
|
||||||
|
def test_no_redirect_followed(self, adapter):
|
||||||
|
"""get_task must not follow HTTP redirects."""
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.get_task(1)
|
||||||
|
assert len(m.request_history) == 1
|
||||||
|
|
||||||
|
def test_invalid_timestamp_does_not_crash(self, adapter):
|
||||||
|
"""A malformed timestamp field falls back to completed_at=None without raising."""
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"task": [
|
||||||
|
{
|
||||||
|
"display_id": 5,
|
||||||
|
"status": "completed",
|
||||||
|
"completed": True,
|
||||||
|
"timestamp": "not-a-date",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
status = adapter.get_task(5)
|
||||||
|
|
||||||
|
assert status.completed is True
|
||||||
|
assert status.completed_at is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMythicAdapterGetTaskOutput:
|
||||||
|
def test_returns_decoded_output(self, adapter):
|
||||||
|
import base64
|
||||||
|
encoded = base64.b64encode(b"Administrator\r\n").decode()
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"response": [{"response_text": encoded}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
output = adapter.get_task_output(7)
|
||||||
|
|
||||||
|
assert "Administrator" in output
|
||||||
|
|
||||||
|
def test_concatenates_multiple_responses(self, adapter):
|
||||||
|
import base64
|
||||||
|
r1 = base64.b64encode(b"line one\n").decode()
|
||||||
|
r2 = base64.b64encode(b"line two\n").decode()
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"response": [{"response_text": r1}, {"response_text": r2}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
output = adapter.get_task_output(7)
|
||||||
|
|
||||||
|
assert "line one" in output
|
||||||
|
assert "line two" in output
|
||||||
|
|
||||||
|
def test_returns_empty_string_when_no_responses(self, adapter):
|
||||||
|
payload = {"data": {"response": []}}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
output = adapter.get_task_output(7)
|
||||||
|
|
||||||
|
assert output == ""
|
||||||
|
|
||||||
|
def test_skips_empty_response_text(self, adapter):
|
||||||
|
import base64
|
||||||
|
encoded = base64.b64encode(b"real output").decode()
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"response": [
|
||||||
|
{"response_text": ""},
|
||||||
|
{"response_text": encoded},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
output = adapter.get_task_output(7)
|
||||||
|
|
||||||
|
assert output == "real output"
|
||||||
|
|
||||||
|
def test_network_error_raises_c2error(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, exc=requests.exceptions.Timeout("timeout"))
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.get_task_output(7)
|
||||||
|
|
||||||
|
def test_no_redirect_followed(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, status_code=302, headers={"Location": "https://evil.example/"})
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.get_task_output(1)
|
||||||
|
assert len(m.request_history) == 1
|
||||||
167
backend/tests/test_c2_adapter_mythic_m4.py
Normal file
167
backend/tests/test_c2_adapter_mythic_m4.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""MythicAdapter M4 tests — list_callback_tasks, mocked HTTP."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import requests_mock as rm_module
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import C2Error, C2HistoricalTask
|
||||||
|
from backend.app.services.c2.mythic import MythicAdapter
|
||||||
|
|
||||||
|
_BASE_URL = "https://mythic.lab:7443"
|
||||||
|
_GQL_URL = _BASE_URL + "/graphql"
|
||||||
|
_TOKEN = "fake-api-token"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def adapter():
|
||||||
|
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_list_payload(tasks: list[dict]) -> dict:
|
||||||
|
return {"data": {"task": tasks}}
|
||||||
|
|
||||||
|
|
||||||
|
def _count_payload(count: int) -> dict:
|
||||||
|
return {"data": {"task_aggregate": {"aggregate": {"count": count}}}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMythicAdapterListCallbackTasks:
|
||||||
|
def test_returns_tasks_from_graphql(self, adapter):
|
||||||
|
tasks_payload = _task_list_payload([
|
||||||
|
{
|
||||||
|
"display_id": 7,
|
||||||
|
"command_name": "whoami",
|
||||||
|
"params": "",
|
||||||
|
"status": "completed",
|
||||||
|
"completed": True,
|
||||||
|
"timestamp": "2026-06-10T12:00:00Z",
|
||||||
|
}
|
||||||
|
])
|
||||||
|
count_payload = _count_payload(1)
|
||||||
|
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
|
||||||
|
|
||||||
|
assert page.total == 1
|
||||||
|
assert len(page.items) == 1
|
||||||
|
item = page.items[0]
|
||||||
|
assert isinstance(item, C2HistoricalTask)
|
||||||
|
assert item.display_id == 7
|
||||||
|
assert item.command == "whoami"
|
||||||
|
assert item.completed is True
|
||||||
|
|
||||||
|
def test_pagination_offset_calculation(self, adapter):
|
||||||
|
"""page=2, page_size=10 → offset=10 must be sent to Mythic."""
|
||||||
|
tasks_payload = _task_list_payload([])
|
||||||
|
count_payload = _count_payload(0)
|
||||||
|
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
|
||||||
|
adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=10)
|
||||||
|
|
||||||
|
# First request is the task list; check variables.
|
||||||
|
first_body = m.request_history[0].json()
|
||||||
|
variables = first_body.get("variables", {})
|
||||||
|
|
||||||
|
assert variables.get("offset") == 10
|
||||||
|
assert variables.get("limit") == 10
|
||||||
|
|
||||||
|
def test_sends_apitoken_header(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, [
|
||||||
|
{"json": _task_list_payload([])},
|
||||||
|
{"json": _count_payload(0)},
|
||||||
|
])
|
||||||
|
adapter.list_callback_tasks(callback_display_id=1)
|
||||||
|
for req in m.request_history:
|
||||||
|
assert req.headers.get("apitoken") == _TOKEN
|
||||||
|
|
||||||
|
def test_empty_task_list(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, [
|
||||||
|
{"json": _task_list_payload([])},
|
||||||
|
{"json": _count_payload(0)},
|
||||||
|
])
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1)
|
||||||
|
|
||||||
|
assert page.total == 0
|
||||||
|
assert page.items == []
|
||||||
|
|
||||||
|
def test_network_error_raises_c2error(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("refused"))
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.list_callback_tasks(callback_display_id=1)
|
||||||
|
|
||||||
|
def test_http_error_raises_c2error(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, status_code=500, text="error")
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.list_callback_tasks(callback_display_id=1)
|
||||||
|
|
||||||
|
def test_no_redirect_followed(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
|
||||||
|
with pytest.raises(C2Error):
|
||||||
|
adapter.list_callback_tasks(callback_display_id=1)
|
||||||
|
# Both requests (tasks + count) should each only make one attempt.
|
||||||
|
for req in m.request_history:
|
||||||
|
assert req.method == "POST"
|
||||||
|
|
||||||
|
def test_page_and_page_size_in_response(self, adapter):
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, [
|
||||||
|
{"json": _task_list_payload([])},
|
||||||
|
{"json": _count_payload(50)},
|
||||||
|
])
|
||||||
|
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=10)
|
||||||
|
|
||||||
|
assert page.page == 3
|
||||||
|
assert page.page_size == 10
|
||||||
|
assert page.total == 50
|
||||||
|
|
||||||
|
|
||||||
|
class TestMythicAdapterGetTaskCommandField:
|
||||||
|
"""Ensure command_name is surfaced via get_task() C2TaskStatus.command."""
|
||||||
|
|
||||||
|
def test_get_task_returns_command(self, adapter):
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"task": [
|
||||||
|
{
|
||||||
|
"display_id": 7,
|
||||||
|
"command_name": "shell",
|
||||||
|
"status": "completed",
|
||||||
|
"completed": True,
|
||||||
|
"timestamp": "2026-06-10T12:00:00Z",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
status = adapter.get_task(7)
|
||||||
|
|
||||||
|
assert status.command == "shell"
|
||||||
|
|
||||||
|
def test_get_task_command_none_when_missing(self, adapter):
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"task": [
|
||||||
|
{
|
||||||
|
"display_id": 7,
|
||||||
|
"command_name": None,
|
||||||
|
"status": "submitted",
|
||||||
|
"completed": False,
|
||||||
|
"timestamp": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with rm_module.Mocker() as m:
|
||||||
|
m.post(_GQL_URL, json=payload)
|
||||||
|
status = adapter.get_task(7)
|
||||||
|
|
||||||
|
assert status.command is None
|
||||||
142
backend/tests/test_c2_callbacks.py
Normal file
142
backend/tests/test_c2_callbacks.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""Tests for GET /api/engagements/<id>/c2/callbacks."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import C2Error
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
_FERNET_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def set_encryption_key(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_fake_adapter(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Alpha", "start_date": "2026-06-10"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/engagements/{eid}/c2-config",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCallbacksHappyPath:
|
||||||
|
def test_returns_3_callbacks_with_fake_adapter(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/c2/callbacks",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "callbacks" in body
|
||||||
|
assert len(body["callbacks"]) == 3
|
||||||
|
|
||||||
|
def test_callback_shape(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/c2/callbacks",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
cb = resp.get_json()["callbacks"][0]
|
||||||
|
assert "display_id" in cb
|
||||||
|
assert "active" in cb
|
||||||
|
assert "host" in cb
|
||||||
|
assert "user" in cb
|
||||||
|
assert "domain" in cb
|
||||||
|
assert "last_checkin" in cb
|
||||||
|
|
||||||
|
def test_redteam_allowed(
|
||||||
|
self, client: FlaskClient, admin_token: str, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/c2/callbacks",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCallbacksErrorCases:
|
||||||
|
def test_404_when_no_config(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/c2/callbacks",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_404_engagement_not_found(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.get(
|
||||||
|
"/api/engagements/9999/c2/callbacks",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_403_soc(
|
||||||
|
self, client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/c2/callbacks",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_503_no_key(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/c2/callbacks",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
def test_502_when_adapter_raises(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _boom(self):
|
||||||
|
raise C2Error("mythic unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "list_callbacks", _boom)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/c2/callbacks",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert "mythic unreachable" in resp.get_json().get("error", "")
|
||||||
367
backend/tests/test_c2_config.py
Normal file
367
backend/tests/test_c2_config.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
"""Tests for C2 config CRUD endpoints.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- GET 404 when no config exists
|
||||||
|
- PUT create (api_token required)
|
||||||
|
- PUT update with omitted token keeps old ciphertext
|
||||||
|
- GET 200 returns has_token=True, never cleartext
|
||||||
|
- DELETE 204
|
||||||
|
- Cascade delete when engagement is deleted
|
||||||
|
- RBAC: admin OK / redteam OK / SOC 403 on all 4 endpoints
|
||||||
|
- 503 guard when MIMIC_ENCRYPTION_KEY is unset
|
||||||
|
- POST /test with fake adapter
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.models.c2_config import C2Config
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FERNET_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def set_encryption_key(monkeypatch):
|
||||||
|
"""Default: key is present. Individual tests can override."""
|
||||||
|
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_fake_adapter(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Alpha", "start_date": "2026-06-10"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _put_config(
|
||||||
|
client: FlaskClient,
|
||||||
|
token: str,
|
||||||
|
eid: int,
|
||||||
|
*,
|
||||||
|
url: str = "https://c2.internal:7443",
|
||||||
|
api_token: str | None = "s3cr3t",
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
payload: dict = {"url": url, "verify_tls": verify_tls}
|
||||||
|
if api_token is not None:
|
||||||
|
payload["api_token"] = api_token
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/engagements/{eid}/c2-config",
|
||||||
|
headers=_h(token),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET — 404 when no config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_not_found(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_engagement_not_found(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.get("/api/engagements/9999/c2-config", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PUT — create
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_rejects_http_scheme(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = _put_config(client, admin_token, eng["id"], url="http://c2.internal:7443")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "https" in resp.get_json().get("error", "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_rejects_missing_hostname(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
# urlparse("https://:7443") produces an empty hostname
|
||||||
|
resp = _put_config(client, admin_token, eng["id"], url="https://:7443")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "hostname" in resp.get_json().get("error", "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_creates_config(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = _put_config(client, admin_token, eng["id"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["has_token"] is True
|
||||||
|
assert body["url"] == "https://c2.internal:7443"
|
||||||
|
assert body["verify_tls"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_create_requires_api_token(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = _put_config(client, admin_token, eng["id"], api_token=None)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_create_requires_url(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/engagements/{eng['id']}/c2-config",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"api_token": "tok", "verify_tls": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PUT — update, omitting api_token preserves old ciphertext
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_update_omits_token_keeps_old(
|
||||||
|
app: Flask, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"], api_token="original-token")
|
||||||
|
|
||||||
|
# Read ciphertext from DB before update.
|
||||||
|
with app.app_context():
|
||||||
|
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
|
||||||
|
assert cfg is not None
|
||||||
|
old_cipher = cfg.api_token_encrypted
|
||||||
|
|
||||||
|
# Update URL, omit api_token.
|
||||||
|
resp = _put_config(
|
||||||
|
client, admin_token, eng["id"],
|
||||||
|
url="https://new.internal:7443", api_token=None,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
|
||||||
|
assert cfg is not None
|
||||||
|
assert cfg.api_token_encrypted == old_cipher
|
||||||
|
assert cfg.url == "https://new.internal:7443"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_update_with_token_replaces_ciphertext(
|
||||||
|
app: Flask, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"], api_token="original-token")
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
|
||||||
|
assert cfg is not None
|
||||||
|
old_cipher = cfg.api_token_encrypted
|
||||||
|
|
||||||
|
_put_config(client, admin_token, eng["id"], api_token="new-token")
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
|
||||||
|
assert cfg is not None
|
||||||
|
assert cfg.api_token_encrypted != old_cipher
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET — 200, has_token=True, never cleartext
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_returns_has_token_not_cleartext(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"], api_token="s3cr3t")
|
||||||
|
|
||||||
|
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["has_token"] is True
|
||||||
|
assert "api_token" not in body
|
||||||
|
assert "api_token_encrypted" not in body
|
||||||
|
assert "s3cr3t" not in str(body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_verify_tls_default_true(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
|
||||||
|
assert resp.get_json()["verify_tls"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE — 204
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_config_204(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# Subsequent GET returns 404.
|
||||||
|
resp2 = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
|
||||||
|
assert resp2.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_config_not_found(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CASCADE — delete engagement removes config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_delete_engagement_removes_config(
|
||||||
|
app: Flask, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 1
|
||||||
|
|
||||||
|
client.delete(f"/api/engagements/{eng['id']}", headers=_h(admin_token))
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RBAC matrix
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path_suffix", [
|
||||||
|
("GET", "/c2-config"),
|
||||||
|
("PUT", "/c2-config"),
|
||||||
|
("DELETE", "/c2-config"),
|
||||||
|
("POST", "/c2-config/test"),
|
||||||
|
])
|
||||||
|
def test_soc_gets_403(
|
||||||
|
client: FlaskClient, admin_token: str, soc_token: str,
|
||||||
|
method: str, path_suffix: str,
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
url = f"/api/engagements/{eng['id']}{path_suffix}"
|
||||||
|
resp = getattr(client, method.lower())(url, headers=_h(soc_token), json={})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path_suffix", [
|
||||||
|
("GET", "/c2-config"),
|
||||||
|
("DELETE", "/c2-config"),
|
||||||
|
("POST", "/c2-config/test"),
|
||||||
|
])
|
||||||
|
def test_redteam_gets_allowed(
|
||||||
|
client: FlaskClient, admin_token: str, redteam_token: str,
|
||||||
|
method: str, path_suffix: str,
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
# Ensure config exists for GET/DELETE/test.
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
url = f"/api/engagements/{eng['id']}{path_suffix}"
|
||||||
|
resp = getattr(client, method.lower())(url, headers=_h(redteam_token), json={})
|
||||||
|
# Not 403 and not 401.
|
||||||
|
assert resp.status_code not in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
def test_redteam_can_put_config(
|
||||||
|
client: FlaskClient, admin_token: str, redteam_token: str,
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = _put_config(client, redteam_token, eng["id"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 503 guard when MIMIC_ENCRYPTION_KEY is unset
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path_suffix", [
|
||||||
|
("GET", "/c2-config"),
|
||||||
|
("PUT", "/c2-config"),
|
||||||
|
("DELETE", "/c2-config"),
|
||||||
|
("POST", "/c2-config/test"),
|
||||||
|
])
|
||||||
|
def test_503_when_key_unset(
|
||||||
|
monkeypatch,
|
||||||
|
client: FlaskClient,
|
||||||
|
admin_token: str,
|
||||||
|
method: str,
|
||||||
|
path_suffix: str,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
url = f"/api/engagements/{eng['id']}{path_suffix}"
|
||||||
|
resp = getattr(client, method.lower())(url, headers=_h(admin_token), json={
|
||||||
|
"url": "https://c2", "api_token": "tok", "verify_tls": True,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert "MIMIC_ENCRYPTION_KEY" in resp.get_json().get("error", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /test — connectivity check via fake adapter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_test_returns_ok_true(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eng['id']}/c2-config/test",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_test_no_config_returns_404(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eng['id']}/c2-config/test",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
324
backend/tests/test_c2_execute.py
Normal file
324
backend/tests/test_c2_execute.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"""Tests for POST /api/simulations/<id>/c2/execute."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models.c2_task import C2Task
|
||||||
|
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||||
|
from backend.app.services.c2.adapter import C2Error
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
_FERNET_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def set_encryption_key(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_fake_adapter(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Alpha", "start_date": "2026-06-10"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/engagements/{eid}/c2-config",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Sim Alpha"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _execute(
|
||||||
|
client: FlaskClient,
|
||||||
|
token: str,
|
||||||
|
sid: int,
|
||||||
|
commands: list,
|
||||||
|
callback_display_id: int = 1,
|
||||||
|
):
|
||||||
|
return client.post(
|
||||||
|
f"/api/simulations/{sid}/c2/execute",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"callback_display_id": callback_display_id, "commands": commands},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_to_in_progress(client: FlaskClient, token: str, sid: int) -> None:
|
||||||
|
client.patch(
|
||||||
|
f"/api/simulations/{sid}",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Sim Alpha"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_to_review_required(client: FlaskClient, token: str, sid: int) -> None:
|
||||||
|
_advance_to_in_progress(client, token, sid)
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sid}/transition",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"to": "review_required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_to_done(client: FlaskClient, redteam_token: str, soc_token: str, sid: int) -> None:
|
||||||
|
_advance_to_review_required(client, redteam_token, sid)
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sid}/transition",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
json={"to": "done"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteHappyPath:
|
||||||
|
def test_two_commands_create_two_tasks(
|
||||||
|
self, app: Flask, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, admin_token, sim["id"], ["whoami", "ipconfig"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert len(body["tasks"]) == 2
|
||||||
|
assert body["tasks"][0]["command"] == "whoami"
|
||||||
|
assert body["tasks"][1]["command"] == "ipconfig"
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
rows = C2Task.query.filter_by(simulation_id=sim["id"]).all()
|
||||||
|
assert len(rows) == 2
|
||||||
|
|
||||||
|
def test_task_response_shape(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, admin_token, sim["id"], ["hostname"])
|
||||||
|
task = resp.get_json()["tasks"][0]
|
||||||
|
assert "id" in task
|
||||||
|
assert "mythic_task_display_id" in task
|
||||||
|
assert "command" in task
|
||||||
|
assert "status" in task
|
||||||
|
assert "completed" in task
|
||||||
|
|
||||||
|
def test_pending_sim_transitions_to_in_progress(
|
||||||
|
self, app: Flask, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
updated = db.session.get(Simulation, sim["id"])
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.status == SimulationStatus.IN_PROGRESS
|
||||||
|
|
||||||
|
def test_already_in_progress_stays_in_progress(
|
||||||
|
self, app: Flask, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_advance_to_in_progress(client, admin_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
updated = db.session.get(Simulation, sim["id"])
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.status == SimulationStatus.IN_PROGRESS
|
||||||
|
|
||||||
|
def test_review_required_sim_still_allowed(
|
||||||
|
self, app: Flask, client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_advance_to_review_required(client, admin_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, admin_token, sim["id"], ["net use"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Status stays review_required — no regression to in_progress.
|
||||||
|
with app.app_context():
|
||||||
|
updated = db.session.get(Simulation, sim["id"])
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.status == SimulationStatus.REVIEW_REQUIRED
|
||||||
|
|
||||||
|
def test_redteam_can_execute(
|
||||||
|
self, client: FlaskClient, admin_token: str, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, redteam_token, sim["id"], ["whoami"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_mythic_task_display_id_stored(
|
||||||
|
self, app: Flask, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
|
||||||
|
assert task is not None
|
||||||
|
assert task.mythic_task_display_id == 1000 # FakeAdapter starts at 1000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteValidation:
|
||||||
|
def test_400_empty_commands(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, admin_token, sim["id"], [])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_400_non_string_command(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/c2/execute",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"callback_display_id": 1, "commands": [42]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_400_missing_callback_display_id(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/c2/execute",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"commands": ["whoami"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_409_done_sim(
|
||||||
|
self,
|
||||||
|
client: FlaskClient,
|
||||||
|
admin_token: str,
|
||||||
|
soc_token: str,
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_advance_to_done(client, admin_token, soc_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "done" in resp.get_json().get("error", "").lower()
|
||||||
|
|
||||||
|
def test_404_simulation_not_found(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = _execute(client, admin_token, 9999, ["whoami"])
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_404_no_c2_config(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_403_soc(
|
||||||
|
self, client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, soc_token, sim["id"], ["whoami"])
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_503_no_key(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
def test_502_adapter_error(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _boom(self, callback_display_id, command, params=None):
|
||||||
|
raise C2Error("task queue full")
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "create_task", _boom)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert "task queue full" in resp.get_json().get("error", "")
|
||||||
215
backend/tests/test_c2_history.py
Normal file
215
backend/tests/test_c2_history.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""Tests for GET /api/engagements/<id>/c2/callbacks/<cid>/history."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.services.c2.adapter import C2Error
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
_FERNET_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def set_encryption_key(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_fake_adapter(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Alpha", "start_date": "2026-06-10"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/engagements/{eid}/c2-config",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def _history(client: FlaskClient, token: str, eid: int, cid: int, **params):
|
||||||
|
return client.get(
|
||||||
|
f"/api/engagements/{eid}/c2/callbacks/{cid}/history",
|
||||||
|
headers=_h(token),
|
||||||
|
query_string=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryHappyPath:
|
||||||
|
def test_returns_200(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_response_shape(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1)
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "tasks" in body
|
||||||
|
assert "total" in body
|
||||||
|
assert "page" in body
|
||||||
|
assert "page_size" in body
|
||||||
|
|
||||||
|
def test_task_shape(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1)
|
||||||
|
task = resp.get_json()["tasks"][0]
|
||||||
|
for field in ("display_id", "command", "params", "status", "completed", "timestamp"):
|
||||||
|
assert field in task, f"missing field: {field}"
|
||||||
|
|
||||||
|
def test_default_page_is_1(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1)
|
||||||
|
assert resp.get_json()["page"] == 1
|
||||||
|
|
||||||
|
def test_default_page_size_is_25(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1)
|
||||||
|
assert resp.get_json()["page_size"] == 25
|
||||||
|
|
||||||
|
def test_callback_1_has_12_total(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1)
|
||||||
|
assert resp.get_json()["total"] == 12
|
||||||
|
|
||||||
|
def test_callback_2_has_0_tasks(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 2)
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["total"] == 0
|
||||||
|
assert body["tasks"] == []
|
||||||
|
|
||||||
|
def test_pagination_page_size_applied(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1, page=1, page_size=5)
|
||||||
|
body = resp.get_json()
|
||||||
|
assert len(body["tasks"]) == 5
|
||||||
|
assert body["page_size"] == 5
|
||||||
|
|
||||||
|
def test_redteam_can_view_history(
|
||||||
|
self, client: FlaskClient, admin_token: str, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, redteam_token, eng["id"], 1)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryValidation:
|
||||||
|
def test_400_page_size_too_large(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1, page_size=101)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_400_page_zero(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1, page=0)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_400_page_size_zero(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1, page_size=0)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_400_page_negative(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1, page=-1)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_400_page_size_100_is_ok(self, client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1, page_size=100)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Authorization / error cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryErrors:
|
||||||
|
def test_403_soc(
|
||||||
|
self, client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, soc_token, eng["id"], 1)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_503_no_key(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1)
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
def test_404_engagement_not_found(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = _history(client, admin_token, 9999, 1)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_404_no_c2_config(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_502_adapter_error(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _boom(self, callback_display_id, page=1, page_size=25):
|
||||||
|
raise C2Error("upstream error")
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "list_callback_tasks", _boom)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
resp = _history(client, admin_token, eng["id"], 1)
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert "upstream error" in resp.get_json().get("error", "")
|
||||||
437
backend/tests/test_c2_import.py
Normal file
437
backend/tests/test_c2_import.py
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
"""Tests for POST /api/simulations/<id>/c2/import."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models.c2_task import C2Task, C2TaskSource
|
||||||
|
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||||
|
from backend.app.services.c2.adapter import C2Error, C2TaskStatus
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
_FERNET_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def set_encryption_key(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_fake_adapter(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Alpha", "start_date": "2026-06-10"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/engagements/{eid}/c2-config",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Sim Alpha"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _import(client: FlaskClient, token: str, sid: int, task_display_ids: list, callback_display_id: int = 1):
|
||||||
|
return client.post(
|
||||||
|
f"/api/simulations/{sid}/c2/import",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"callback_display_id": callback_display_id, "task_display_ids": task_display_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_completed_get_task(monkeypatch, command: str = "whoami"):
|
||||||
|
"""Patch FakeAdapter.get_task to return completed=True with a command."""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _completed(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
return C2TaskStatus(
|
||||||
|
display_id=task_display_id,
|
||||||
|
status="completed",
|
||||||
|
completed=True,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
|
||||||
|
|
||||||
|
def _output(self, task_display_id: int) -> str:
|
||||||
|
return f"output for {task_display_id}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_to_review_required(client, token, sid):
|
||||||
|
client.patch(f"/api/simulations/{sid}", headers=_h(token), json={"name": "Sim Alpha"})
|
||||||
|
client.post(f"/api/simulations/{sid}/transition", headers=_h(token), json={"to": "review_required"})
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_to_done(client, admin_token, soc_token, sid):
|
||||||
|
_advance_to_review_required(client, admin_token, sid)
|
||||||
|
client.post(f"/api/simulations/{sid}/transition", headers=_h(soc_token), json={"to": "done"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportHappyPath:
|
||||||
|
def test_imports_two_completed_tasks(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_completed_get_task(monkeypatch, command="whoami")
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _import(client, admin_token, sim["id"], [100, 101])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["imported"] == 2
|
||||||
|
assert body["skipped"] == 0
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
rows = C2Task.query.filter_by(simulation_id=sim["id"]).all()
|
||||||
|
assert len(rows) == 2
|
||||||
|
|
||||||
|
def test_imported_tasks_have_source_import(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_completed_get_task(monkeypatch)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
_import(client, admin_token, sim["id"], [100])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
|
||||||
|
assert task is not None
|
||||||
|
assert task.source == C2TaskSource.IMPORT
|
||||||
|
|
||||||
|
def test_completed_tasks_get_mapping_applied(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_completed_get_task(monkeypatch)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
_import(client, admin_token, sim["id"], [100])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
|
||||||
|
assert task is not None
|
||||||
|
assert task.mapping_applied is True
|
||||||
|
|
||||||
|
def test_idempotent_import_counts_skipped(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_completed_get_task(monkeypatch)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
# First import.
|
||||||
|
_import(client, admin_token, sim["id"], [100, 101])
|
||||||
|
|
||||||
|
# Second import with one overlap.
|
||||||
|
resp = _import(client, admin_token, sim["id"], [100, 102])
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["imported"] == 1
|
||||||
|
assert body["skipped"] == 1
|
||||||
|
|
||||||
|
def test_auto_transition_pending_to_in_progress(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_completed_get_task(monkeypatch)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
_import(client, admin_token, sim["id"], [100])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
updated = db.session.get(Simulation, sim["id"])
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.status == SimulationStatus.IN_PROGRESS
|
||||||
|
|
||||||
|
def test_no_transition_when_already_in_progress(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_completed_get_task(monkeypatch)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
# Advance to in_progress manually.
|
||||||
|
client.patch(
|
||||||
|
f"/api/simulations/{sim['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"name": "Sim Alpha"},
|
||||||
|
)
|
||||||
|
|
||||||
|
_import(client, admin_token, sim["id"], [100])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
updated = db.session.get(Simulation, sim["id"])
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.status == SimulationStatus.IN_PROGRESS
|
||||||
|
|
||||||
|
def test_no_transition_when_review_required(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_completed_get_task(monkeypatch)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_advance_to_review_required(client, admin_token, sim["id"])
|
||||||
|
|
||||||
|
_import(client, admin_token, sim["id"], [100])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
updated = db.session.get(Simulation, sim["id"])
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.status == SimulationStatus.REVIEW_REQUIRED
|
||||||
|
|
||||||
|
def test_incomplete_task_stored_without_mapping(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
"""An incomplete task is stored as-is; mapping_applied stays False."""
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _submitted(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
return C2TaskStatus(
|
||||||
|
display_id=task_display_id,
|
||||||
|
status="submitted",
|
||||||
|
completed=False,
|
||||||
|
command="shell",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _submitted)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _import(client, admin_token, sim["id"], [200])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["imported"] == 1
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
|
||||||
|
assert task is not None
|
||||||
|
assert task.completed is False
|
||||||
|
assert task.mapping_applied is False
|
||||||
|
assert task.output is None
|
||||||
|
|
||||||
|
def test_command_stored_from_get_task(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
"""Command field on the stored row comes from adapter.get_task().command."""
|
||||||
|
_make_completed_get_task(monkeypatch, command="net user /domain")
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
_import(client, admin_token, sim["id"], [100])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
|
||||||
|
assert task is not None
|
||||||
|
assert task.command == "net user /domain"
|
||||||
|
|
||||||
|
def test_redteam_can_import(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_completed_get_task(monkeypatch)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _import(client, redteam_token, sim["id"], [100])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_source_field_is_import_in_tasks_listing(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
"""Imported tasks appear with source='import' in GET /c2/tasks response."""
|
||||||
|
_make_completed_get_task(monkeypatch)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
_import(client, admin_token, sim["id"], [100])
|
||||||
|
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/simulations/{sim['id']}/c2/tasks",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
task = resp.get_json()["tasks"][0]
|
||||||
|
assert task["source"] == "import"
|
||||||
|
|
||||||
|
def test_no_transition_when_all_skipped(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
"""If imported=0 (all skipped), do not transition pending→in_progress."""
|
||||||
|
_make_completed_get_task(monkeypatch)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
_import(client, admin_token, sim["id"], [100]) # first import
|
||||||
|
_import(client, admin_token, sim["id"], []) # empty — should 400 before this matters
|
||||||
|
|
||||||
|
# Reset to pending state via a fresh sim (can't undo, just verify the 0-skipped case).
|
||||||
|
# We test: importing same task again = skipped=1, imported=0 → no double-transition.
|
||||||
|
resp = _import(client, admin_token, sim["id"], [100])
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["imported"] == 0
|
||||||
|
assert body["skipped"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportValidation:
|
||||||
|
def test_400_empty_task_display_ids(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _import(client, admin_token, sim["id"], [])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_400_non_int_task_display_id(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/c2/import",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"callback_display_id": 1, "task_display_ids": ["not-an-int"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_400_missing_callback_display_id(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/c2/import",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"task_display_ids": [100]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_409_done_simulation(
|
||||||
|
self, client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_advance_to_done(client, admin_token, soc_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _import(client, admin_token, sim["id"], [100])
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
def test_404_simulation_not_found(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = _import(client, admin_token, 9999, [100])
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Authorization / error cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportErrors:
|
||||||
|
def test_403_soc(
|
||||||
|
self, client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _import(client, soc_token, sim["id"], [100])
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_503_no_key(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _import(client, admin_token, sim["id"], [100])
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
def test_404_no_c2_config(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _import(client, admin_token, sim["id"], [100])
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_502_adapter_error_on_get_task(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _boom(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
raise C2Error("Mythic unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _boom)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _import(client, admin_token, sim["id"], [100])
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert "Mythic unreachable" in resp.get_json().get("error", "")
|
||||||
208
backend/tests/test_c2_mapping.py
Normal file
208
backend/tests/test_c2_mapping.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""Unit tests for apply_task_to_simulation() mapping helper — §0.11 contract."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from backend.app.services.c2.mapping import apply_task_to_simulation
|
||||||
|
|
||||||
|
|
||||||
|
def _make_task(
|
||||||
|
command: str = "whoami",
|
||||||
|
output: str | None = "root",
|
||||||
|
mapping_applied: bool = False,
|
||||||
|
completed_at: datetime | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
task = MagicMock()
|
||||||
|
task.command = command
|
||||||
|
task.output = output
|
||||||
|
task.mapping_applied = mapping_applied
|
||||||
|
task.completed_at = completed_at
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(
|
||||||
|
execution_result: str | None = None,
|
||||||
|
executed_at: datetime | None = None,
|
||||||
|
commands: str | None = None,
|
||||||
|
) -> MagicMock:
|
||||||
|
sim = MagicMock()
|
||||||
|
sim.execution_result = execution_result
|
||||||
|
sim.executed_at = executed_at
|
||||||
|
sim.commands = commands
|
||||||
|
sim.updated_at = None
|
||||||
|
return sim
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecutionResult:
|
||||||
|
def test_first_task_produces_command_block(self):
|
||||||
|
task = _make_task(command="whoami", output="root")
|
||||||
|
sim = _make_sim()
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.execution_result == "$ whoami\nroot\n"
|
||||||
|
|
||||||
|
def test_second_task_appended_with_block_separator(self):
|
||||||
|
"""Two tasks → two '$ command\noutput\n' blocks separated by a single newline."""
|
||||||
|
sim = _make_sim()
|
||||||
|
t1 = _make_task(command="whoami", output="root")
|
||||||
|
t2 = _make_task(command="hostname", output="lab-1")
|
||||||
|
|
||||||
|
apply_task_to_simulation(t1, sim)
|
||||||
|
apply_task_to_simulation(t2, sim)
|
||||||
|
|
||||||
|
assert sim.execution_result == "$ whoami\nroot\n$ hostname\nlab-1\n"
|
||||||
|
|
||||||
|
def test_no_double_blank_line_when_existing_ends_with_newline(self):
|
||||||
|
"""If existing result already ends with \n, no extra blank line is inserted."""
|
||||||
|
sim = _make_sim(execution_result="$ id\nuid=0\n")
|
||||||
|
task = _make_task(command="hostname", output="lab-1")
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.execution_result == "$ id\nuid=0\n$ hostname\nlab-1\n"
|
||||||
|
|
||||||
|
def test_empty_output_skips_block_but_marks_applied(self):
|
||||||
|
task = _make_task(output="")
|
||||||
|
sim = _make_sim(execution_result="$ id\nuid=0\n")
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.execution_result == "$ id\nuid=0\n"
|
||||||
|
assert task.mapping_applied is True
|
||||||
|
|
||||||
|
def test_none_output_skips_block_but_marks_applied(self):
|
||||||
|
task = _make_task(output=None)
|
||||||
|
sim = _make_sim()
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.execution_result is None
|
||||||
|
assert task.mapping_applied is True
|
||||||
|
|
||||||
|
def test_command_with_empty_string_produces_dollar_header(self):
|
||||||
|
"""Empty command → block header is '$ \n<output>\n' (consistent, not suppressed)."""
|
||||||
|
task = _make_task(command="", output="some output")
|
||||||
|
sim = _make_sim()
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.execution_result == "$ \nsome output\n" or sim.execution_result == "$ \nsome output\n"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecutedAt:
|
||||||
|
def test_sets_executed_at_from_task_when_null(self):
|
||||||
|
ts = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC)
|
||||||
|
task = _make_task(completed_at=ts)
|
||||||
|
sim = _make_sim(executed_at=None)
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.executed_at == ts
|
||||||
|
|
||||||
|
def test_does_not_overwrite_existing_executed_at(self):
|
||||||
|
original_ts = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
later_ts = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC)
|
||||||
|
task = _make_task(completed_at=later_ts)
|
||||||
|
sim = _make_sim(executed_at=original_ts)
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.executed_at == original_ts
|
||||||
|
|
||||||
|
def test_executed_at_stays_null_when_task_completed_at_is_none(self):
|
||||||
|
task = _make_task(completed_at=None)
|
||||||
|
sim = _make_sim(executed_at=None)
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.executed_at is None
|
||||||
|
|
||||||
|
def test_first_task_sets_executed_at_second_does_not_overwrite(self):
|
||||||
|
ts1 = datetime(2026, 6, 10, 10, 0, 0, tzinfo=UTC)
|
||||||
|
ts2 = datetime(2026, 6, 10, 11, 0, 0, tzinfo=UTC)
|
||||||
|
t1 = _make_task(command="whoami", output="root", completed_at=ts1)
|
||||||
|
t2 = _make_task(command="hostname", output="lab-1", completed_at=ts2)
|
||||||
|
sim = _make_sim(executed_at=None)
|
||||||
|
|
||||||
|
apply_task_to_simulation(t1, sim)
|
||||||
|
apply_task_to_simulation(t2, sim)
|
||||||
|
|
||||||
|
assert sim.executed_at == ts1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandsDedup:
|
||||||
|
def test_appends_command_to_empty_commands(self):
|
||||||
|
task = _make_task(command="whoami", output="root")
|
||||||
|
sim = _make_sim(commands=None)
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.commands == "whoami"
|
||||||
|
|
||||||
|
def test_appends_second_distinct_command(self):
|
||||||
|
sim = _make_sim(commands=None)
|
||||||
|
t1 = _make_task(command="whoami", output="root")
|
||||||
|
t2 = _make_task(command="hostname", output="lab-1")
|
||||||
|
|
||||||
|
apply_task_to_simulation(t1, sim)
|
||||||
|
apply_task_to_simulation(t2, sim)
|
||||||
|
|
||||||
|
assert sim.commands == "whoami\nhostname"
|
||||||
|
|
||||||
|
def test_deduplicates_repeated_command(self):
|
||||||
|
sim = _make_sim(commands=None)
|
||||||
|
t1 = _make_task(command="whoami", output="root")
|
||||||
|
t2 = _make_task(command="whoami", output="root2")
|
||||||
|
|
||||||
|
apply_task_to_simulation(t1, sim)
|
||||||
|
apply_task_to_simulation(t2, sim)
|
||||||
|
|
||||||
|
assert sim.commands == "whoami"
|
||||||
|
|
||||||
|
def test_dedup_is_case_and_whitespace_stripped(self):
|
||||||
|
sim = _make_sim(commands="whoami")
|
||||||
|
task = _make_task(command=" whoami ", output="root")
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
# " whoami ".strip() == "whoami" which is already present → no append.
|
||||||
|
assert sim.commands == "whoami"
|
||||||
|
|
||||||
|
def test_empty_command_not_appended(self):
|
||||||
|
task = _make_task(command="", output="output")
|
||||||
|
sim = _make_sim(commands=None)
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
# task.command is falsy → commands block skipped.
|
||||||
|
assert sim.commands is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdempotency:
|
||||||
|
def test_no_op_when_mapping_already_applied(self):
|
||||||
|
task = _make_task(output="root", mapping_applied=True)
|
||||||
|
sim = _make_sim(execution_result="existing")
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.execution_result == "existing"
|
||||||
|
|
||||||
|
def test_always_marks_mapping_applied(self):
|
||||||
|
task = _make_task(output="root")
|
||||||
|
sim = _make_sim()
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert task.mapping_applied is True
|
||||||
|
|
||||||
|
def test_updated_at_is_set(self):
|
||||||
|
task = _make_task(output="root")
|
||||||
|
sim = _make_sim()
|
||||||
|
before = datetime.now(UTC)
|
||||||
|
|
||||||
|
apply_task_to_simulation(task, sim)
|
||||||
|
|
||||||
|
assert sim.updated_at is not None
|
||||||
|
assert sim.updated_at >= before
|
||||||
375
backend/tests/test_c2_tasks_list.py
Normal file
375
backend/tests/test_c2_tasks_list.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
"""Tests for GET /api/simulations/<id>/c2/tasks — poll-on-read endpoint."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models.c2_task import C2Task
|
||||||
|
from backend.app.models.simulation import Simulation
|
||||||
|
from backend.app.services.c2.adapter import C2Error, C2TaskStatus
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
_FERNET_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def set_encryption_key(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_fake_adapter(monkeypatch):
|
||||||
|
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Alpha", "start_date": "2026-06-10"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/engagements/{eid}/c2-config",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Sim Alpha"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _execute(client: FlaskClient, token: str, sid: int, commands: list, callback_display_id: int = 1):
|
||||||
|
return client.post(
|
||||||
|
f"/api/simulations/{sid}/c2/execute",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"callback_display_id": callback_display_id, "commands": commands},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_tasks(client: FlaskClient, token: str, sid: int):
|
||||||
|
return client.get(
|
||||||
|
f"/api/simulations/{sid}/c2/tasks",
|
||||||
|
headers=_h(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListTasksHappyPath:
|
||||||
|
def test_returns_empty_list_when_no_tasks(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["tasks"] == []
|
||||||
|
|
||||||
|
def test_returns_task_after_execute(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tasks = resp.get_json()["tasks"]
|
||||||
|
assert len(tasks) == 1
|
||||||
|
assert tasks[0]["command"] == "whoami"
|
||||||
|
|
||||||
|
def test_task_shape(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["hostname"])
|
||||||
|
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
task = resp.get_json()["tasks"][0]
|
||||||
|
for field in ("id", "mythic_task_display_id", "callback_display_id",
|
||||||
|
"command", "params", "status", "completed", "output",
|
||||||
|
"source", "mapping_applied", "created_at", "completed_at"):
|
||||||
|
assert field in task, f"missing field: {field}"
|
||||||
|
assert task["source"] == "mimic"
|
||||||
|
|
||||||
|
def test_first_poll_returns_submitted(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
# First GET — FakeAdapter.get_task() first call → submitted.
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
task = resp.get_json()["tasks"][0]
|
||||||
|
assert task["status"] == "submitted"
|
||||||
|
assert task["completed"] is False
|
||||||
|
|
||||||
|
def test_poll_marks_completed_when_adapter_returns_completed(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
"""When adapter.get_task returns completed=True the task is updated in DB."""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _completed(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
return C2TaskStatus(
|
||||||
|
display_id=task_display_id,
|
||||||
|
status="completed",
|
||||||
|
completed=True,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
task = resp.get_json()["tasks"][0]
|
||||||
|
assert task["completed"] is True
|
||||||
|
assert task["status"] == "completed"
|
||||||
|
|
||||||
|
def test_output_populated_after_completion(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
"""Output is fetched and stored when task transitions to completed."""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _completed(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
return C2TaskStatus(
|
||||||
|
display_id=task_display_id,
|
||||||
|
status="completed",
|
||||||
|
completed=True,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _output(self, task_display_id: int) -> str:
|
||||||
|
return f"whoami result for task {task_display_id}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
task = resp.get_json()["tasks"][0]
|
||||||
|
assert task["output"] is not None
|
||||||
|
assert "whoami" in task["output"]
|
||||||
|
|
||||||
|
def test_mapping_applied_set_after_completion(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _completed(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
return C2TaskStatus(
|
||||||
|
display_id=task_display_id,
|
||||||
|
status="completed",
|
||||||
|
completed=True,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
_list_tasks(client, admin_token, sim["id"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
|
||||||
|
assert task is not None
|
||||||
|
assert task.mapping_applied is True
|
||||||
|
|
||||||
|
def test_execution_result_updated_on_simulation(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _completed(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
return C2TaskStatus(
|
||||||
|
display_id=task_display_id,
|
||||||
|
status="completed",
|
||||||
|
completed=True,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _output(self, task_display_id: int) -> str:
|
||||||
|
return f"WORKSTATION-01\\whoami output {task_display_id}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
_list_tasks(client, admin_token, sim["id"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
updated_sim = db.session.get(Simulation, sim["id"])
|
||||||
|
assert updated_sim is not None
|
||||||
|
assert updated_sim.execution_result is not None
|
||||||
|
assert "whoami" in updated_sim.execution_result
|
||||||
|
|
||||||
|
def test_completed_task_not_re_polled(
|
||||||
|
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
"""Once task.completed=True in DB, subsequent GETs skip polling (no re-poll)."""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
def _completed(self, task_display_id: int) -> C2TaskStatus:
|
||||||
|
call_count["n"] += 1
|
||||||
|
return C2TaskStatus(
|
||||||
|
display_id=task_display_id,
|
||||||
|
status="completed",
|
||||||
|
completed=True,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
_list_tasks(client, admin_token, sim["id"]) # 1st GET — marks task completed (1 call)
|
||||||
|
first_count = call_count["n"]
|
||||||
|
|
||||||
|
_list_tasks(client, admin_token, sim["id"]) # 2nd GET — task already completed, skip poll
|
||||||
|
|
||||||
|
# get_task should NOT have been called again on the 2nd GET.
|
||||||
|
assert call_count["n"] == first_count, "completed task should not be re-polled"
|
||||||
|
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
task = resp.get_json()["tasks"][0]
|
||||||
|
assert task["completed"] is True
|
||||||
|
|
||||||
|
def test_redteam_can_list_tasks(
|
||||||
|
self, client: FlaskClient, admin_token: str, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
resp = _list_tasks(client, redteam_token, sim["id"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListTasksErrors:
|
||||||
|
def test_404_simulation_not_found(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = _list_tasks(client, admin_token, 9999)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_403_soc_forbidden(
|
||||||
|
self, client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _list_tasks(client, soc_token, sim["id"])
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_503_no_encryption_key(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
def test_404_no_c2_config(
|
||||||
|
self, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_adapter_error_during_poll_is_tolerated(
|
||||||
|
self, monkeypatch, client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
"""If get_task raises C2Error during poll, the task is skipped (best-effort)."""
|
||||||
|
from backend.app.services.c2 import fake as fake_mod
|
||||||
|
|
||||||
|
def _boom(self, task_display_id: int):
|
||||||
|
raise C2Error("upstream unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _boom)
|
||||||
|
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_put_config(client, admin_token, eng["id"])
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_execute(client, admin_token, sim["id"], ["whoami"])
|
||||||
|
|
||||||
|
# Should still return 200 with the task (un-refreshed status).
|
||||||
|
resp = _list_tasks(client, admin_token, sim["id"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tasks = resp.get_json()["tasks"]
|
||||||
|
assert len(tasks) == 1
|
||||||
|
# Status is stale (not updated due to error) — still "submitted".
|
||||||
|
assert tasks[0]["status"] == "submitted"
|
||||||
47
backend/tests/test_cli_create_admin.py
Normal file
47
backend/tests/test_cli_create_admin.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""CLI tests for `flask create-admin`."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from backend.app.auth import hash_password
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_admin_success(app: Flask) -> None:
|
||||||
|
runner = app.test_cli_runner()
|
||||||
|
result = runner.invoke(args=["create-admin", "alice", "p4ssw0rd"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "created" in result.output.lower()
|
||||||
|
user = User.query.filter_by(username="alice").first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.role == UserRole.ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_admin_duplicate_username(app: Flask) -> None:
|
||||||
|
existing = User(
|
||||||
|
username="bob", password_hash=hash_password("originalpw"), role=UserRole.ADMIN
|
||||||
|
)
|
||||||
|
db.session.add(existing)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
runner = app.test_cli_runner()
|
||||||
|
result = runner.invoke(args=["create-admin", "bob", "anotherpw1"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "exists" in (result.output + (result.stderr_bytes.decode() if result.stderr_bytes else "")).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_admin_short_password(app: Flask) -> None:
|
||||||
|
runner = app.test_cli_runner()
|
||||||
|
result = runner.invoke(args=["create-admin", "charlie", "abc"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
combined = (result.output + (result.stderr_bytes.decode() if result.stderr_bytes else "")).lower()
|
||||||
|
assert "8 characters" in combined
|
||||||
|
assert User.query.filter_by(username="charlie").first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_admin_missing_args(app: Flask) -> None:
|
||||||
|
runner = app.test_cli_runner()
|
||||||
|
result = runner.invoke(args=["create-admin"])
|
||||||
|
# Click's UsageError exits with code 2
|
||||||
|
assert result.exit_code != 0
|
||||||
52
backend/tests/test_crypto.py
Normal file
52
backend/tests/test_crypto.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Tests for the Fernet crypto service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from backend.app.services.crypto import C2Disabled, decrypt, encrypt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fernet_key(monkeypatch) -> str:
|
||||||
|
key = Fernet.generate_key().decode()
|
||||||
|
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", key)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def no_key(monkeypatch):
|
||||||
|
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncryptDecrypt:
|
||||||
|
def test_round_trip(self, fernet_key):
|
||||||
|
plaintext = "s3cr3t-api-token"
|
||||||
|
ciphertext = encrypt(plaintext)
|
||||||
|
assert ciphertext != plaintext
|
||||||
|
assert decrypt(ciphertext) == plaintext
|
||||||
|
|
||||||
|
def test_different_tokens_for_same_input(self, fernet_key):
|
||||||
|
# Fernet tokens are non-deterministic (random IV).
|
||||||
|
t1 = encrypt("same")
|
||||||
|
t2 = encrypt("same")
|
||||||
|
assert t1 != t2
|
||||||
|
assert decrypt(t1) == decrypt(t2) == "same"
|
||||||
|
|
||||||
|
def test_decrypt_invalid_ciphertext(self, fernet_key):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
decrypt("not-valid-fernet-token")
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyAbsent:
|
||||||
|
def test_encrypt_raises_c2disabled(self, no_key):
|
||||||
|
with pytest.raises(C2Disabled):
|
||||||
|
encrypt("anything")
|
||||||
|
|
||||||
|
def test_decrypt_raises_c2disabled(self, no_key):
|
||||||
|
with pytest.raises(C2Disabled):
|
||||||
|
decrypt("anything")
|
||||||
|
|
||||||
|
def test_c2disabled_message(self, no_key):
|
||||||
|
with pytest.raises(C2Disabled, match="MIMIC_ENCRYPTION_KEY"):
|
||||||
|
encrypt("x")
|
||||||
181
backend/tests/test_engagement_lifecycle.py
Normal file
181
backend/tests/test_engagement_lifecycle.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Sprint 4 — engagement auto-status planned→active (AC-19)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str, **kwargs) -> dict:
|
||||||
|
payload = {"name": "Eng", "start_date": "2026-01-01", **kwargs}
|
||||||
|
resp = client.post("/api/engagements", headers=_h(token), json=payload)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_engagement(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.get(f"/api/engagements/{eid}", headers=_h(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Sim"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_sim(client: FlaskClient, token: str, sid: int, payload: dict) -> dict:
|
||||||
|
resp = client.patch(f"/api/simulations/{sid}", headers=_h(token), json=payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-19.1 — Auto-activate engagement on first sim in_progress
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_sim_creation_does_not_activate_engagement(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
_make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
eng_data = _get_engagement(client, redteam_token, eng["id"])
|
||||||
|
assert eng_data["status"] == "planned"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_rt_field_activates_planned_engagement(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
sim_data = _patch_sim(client, redteam_token, sim["id"], {"description": "started"})
|
||||||
|
assert sim_data["status"] == "in_progress"
|
||||||
|
|
||||||
|
eng_data = _get_engagement(client, redteam_token, eng["id"])
|
||||||
|
assert eng_data["status"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_tactic_ids_activates_planned_engagement(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
_patch_sim(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
|
||||||
|
|
||||||
|
eng_data = _get_engagement(client, redteam_token, eng["id"])
|
||||||
|
assert eng_data["status"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-19.2 — Already active → stays active (no change)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_rt_field_does_not_change_active_engagement(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
# First patch triggers activation.
|
||||||
|
_patch_sim(client, redteam_token, sim["id"], {"description": "started"})
|
||||||
|
|
||||||
|
# Second patch: engagement should remain active (no state change).
|
||||||
|
_patch_sim(client, redteam_token, sim["id"], {"description": "updated"})
|
||||||
|
|
||||||
|
eng_data = _get_engagement(client, redteam_token, eng["id"])
|
||||||
|
assert eng_data["status"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-19.3 — Engagement in closed state → not touched
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_does_not_reopen_closed_engagement(
|
||||||
|
client: FlaskClient, redteam_token: str, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
# Manually close the engagement via API.
|
||||||
|
close_resp = client.patch(
|
||||||
|
f"/api/engagements/{eng['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"status": "closed"},
|
||||||
|
)
|
||||||
|
assert close_resp.status_code == 200
|
||||||
|
|
||||||
|
# PATCH a sim field that would normally trigger in_progress.
|
||||||
|
_patch_sim(client, redteam_token, sim["id"], {"description": "new work"})
|
||||||
|
|
||||||
|
eng_data = _get_engagement(client, redteam_token, eng["id"])
|
||||||
|
assert eng_data["status"] == "closed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Migration 0004 — tactic_ids column NOT NULL after upgrade
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_0004_tactic_ids_not_null_after_upgrade() -> None:
|
||||||
|
"""Alembic round-trip: tactic_ids column is NOT NULL after migration 0004."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import sqlalchemy as _sa
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
|
||||||
|
engine = _sa.create_engine("sqlite:///:memory:")
|
||||||
|
|
||||||
|
# Create post-0003 schema (simulations with techniques column).
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(_sa.text(
|
||||||
|
"CREATE TABLE simulations ("
|
||||||
|
" id INTEGER PRIMARY KEY,"
|
||||||
|
" techniques TEXT NOT NULL DEFAULT '[]'"
|
||||||
|
")"
|
||||||
|
))
|
||||||
|
conn.execute(_sa.text(
|
||||||
|
"INSERT INTO simulations (id, techniques) VALUES (1, '[]')"
|
||||||
|
))
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn, opts={"as_sql": False})
|
||||||
|
ops = Operations(ctx)
|
||||||
|
|
||||||
|
import alembic.op as _op_module
|
||||||
|
_op_module._proxy = ops # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
_mig_path = (
|
||||||
|
pathlib.Path(__file__).parent.parent
|
||||||
|
/ "migrations" / "versions" / "0004_simulation_tactic_ids.py"
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_file_location("mig_0004", _mig_path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
mig = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mig) # type: ignore[union-attr]
|
||||||
|
mig.upgrade()
|
||||||
|
|
||||||
|
insp = _sa.inspect(engine)
|
||||||
|
cols = {c["name"]: c for c in insp.get_columns("simulations")}
|
||||||
|
assert "tactic_ids" in cols, "tactic_ids column must exist after upgrade"
|
||||||
|
assert cols["tactic_ids"]["nullable"] is False, "tactic_ids must be NOT NULL"
|
||||||
|
|
||||||
|
# Existing row should have server_default applied.
|
||||||
|
with engine.connect() as conn:
|
||||||
|
row = conn.execute(_sa.text("SELECT tactic_ids FROM simulations WHERE id=1")).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
import json
|
||||||
|
assert json.loads(row[0]) == []
|
||||||
281
backend/tests/test_engagements.py
Normal file
281
backend/tests/test_engagements.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""Engagement endpoint tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.models import User
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
|
||||||
|
def _create(
|
||||||
|
client: FlaskClient, token: str, **overrides: object
|
||||||
|
) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"name": "Op Alpha",
|
||||||
|
"description": "first engagement",
|
||||||
|
"start_date": "2026-06-01",
|
||||||
|
"end_date": "2026-06-10",
|
||||||
|
"status": "planned",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
resp = client.post("/api/engagements", headers=_h(token), json=payload)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_as_redteam(
|
||||||
|
client: FlaskClient, redteam_user: User, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
body = _create(client, redteam_token)
|
||||||
|
assert body["name"] == "Op Alpha"
|
||||||
|
assert body["status"] == "planned"
|
||||||
|
assert body["created_by"] == {"id": redteam_user.id, "username": "redteam1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_as_admin(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
body = _create(client, admin_token, name="Op Admin")
|
||||||
|
assert body["created_by"]["username"] == "admin1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_soc_forbidden(
|
||||||
|
client: FlaskClient, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
json={"name": "x", "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_unauth(client: FlaskClient) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements", json={"name": "x", "start_date": "2026-06-01"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_missing_name(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_bad_date(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": "bad", "start_date": "not-a-date"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_end_before_start(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={
|
||||||
|
"name": "bad",
|
||||||
|
"start_date": "2026-06-10",
|
||||||
|
"end_date": "2026-06-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_bad_status(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": "bad", "start_date": "2026-06-01", "status": "wat"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_engagement_default_status_planned(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": "Op default", "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json()["status"] == "planned"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_engagements_all_roles_can_read(
|
||||||
|
client: FlaskClient,
|
||||||
|
redteam_token: str,
|
||||||
|
soc_token: str,
|
||||||
|
admin_token: str,
|
||||||
|
) -> None:
|
||||||
|
_create(client, redteam_token)
|
||||||
|
for token in (redteam_token, soc_token, admin_token):
|
||||||
|
resp = client.get("/api/engagements", headers=_h(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert isinstance(body, list)
|
||||||
|
assert len(body) >= 1
|
||||||
|
assert body[0]["created_by"] == {
|
||||||
|
"id": body[0]["created_by"]["id"],
|
||||||
|
"username": body[0]["created_by"]["username"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_engagement_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = client.get("/api/engagements/9999", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_engagement_ok(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{created['id']}", headers=_h(redteam_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["id"] == created["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"status": "active", "description": "now in progress"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "active"
|
||||||
|
assert body["description"] == "now in progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_admin(
|
||||||
|
client: FlaskClient, redteam_token: str, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"name": "Op renamed"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["name"] == "Op renamed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_bad_status(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"status": "wat"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_soc_forbidden(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
json={"status": "closed"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_end_before_start(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token, start_date="2026-06-01")
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"end_date": "2026-05-30"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_clear_end_date(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token, end_date="2026-06-30")
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"end_date": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["end_date"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_empty_name_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/engagements/{created['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": " "},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_engagement_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/engagements/9999", headers=_h(redteam_token), json={"name": "x"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_engagement_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/engagements/{created['id']}", headers=_h(redteam_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_engagement_admin(
|
||||||
|
client: FlaskClient, redteam_token: str, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/engagements/{created['id']}", headers=_h(admin_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_engagement_soc_forbidden(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _create(client, redteam_token)
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/engagements/{created['id']}", headers=_h(soc_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_engagement_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = client.delete("/api/engagements/9999", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
270
backend/tests/test_export_engagement.py
Normal file
270
backend/tests/test_export_engagement.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""Endpoint tests for GET /api/engagements/<eid>/export."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import Engagement, EngagementStatus, User
|
||||||
|
from backend.app.models.simulation import Simulation, SimulationStatus
|
||||||
|
from backend.app.services.export import _export_filename
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str, name: str = "Op Alpha") -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": name, "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int, name: str = "Sim One") -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": name},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _export(client: FlaskClient, token: str, eid: int, fmt: str):
|
||||||
|
return client.get(
|
||||||
|
f"/api/engagements/{eid}/export?format={fmt}",
|
||||||
|
headers=_h(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RBAC
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulations(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_make_sim(client, admin_token, eng["id"], "Lateral Movement")
|
||||||
|
_make_sim(client, admin_token, eng["id"], "Persistence Check")
|
||||||
|
|
||||||
|
resp = _export(client, admin_token, eng["id"], "md")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "text/markdown" in resp.content_type
|
||||||
|
body = resp.data.decode()
|
||||||
|
assert "Op Alpha" in body
|
||||||
|
# Both simulation names appear as cells in the 7-column table
|
||||||
|
assert "Lateral Movement" in body
|
||||||
|
assert "Persistence Check" in body
|
||||||
|
# Table uses French column headers
|
||||||
|
assert "Scénario" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_markdown_redteam_ok(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
resp = _export(client, redteam_token, eng["id"], "md")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_markdown_soc_403(
|
||||||
|
client: FlaskClient, soc_token: str, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = _export(client, soc_token, eng["id"], "md")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_unauthenticated_401(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.get(f"/api/engagements/{eng['id']}/export?format=md")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSV
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_csv_returns_csv_with_one_row_per_simulation(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_make_sim(client, admin_token, eng["id"], "S1")
|
||||||
|
_make_sim(client, admin_token, eng["id"], "S2")
|
||||||
|
|
||||||
|
resp = _export(client, admin_token, eng["id"], "csv")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "text/csv" in resp.content_type
|
||||||
|
|
||||||
|
import csv as csv_mod
|
||||||
|
import io
|
||||||
|
|
||||||
|
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
|
||||||
|
# 1 header + 2 simulations
|
||||||
|
assert len(rows) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_csv_columns_match_contract(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = _export(client, admin_token, eng["id"], "csv")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
import csv as csv_mod
|
||||||
|
import io
|
||||||
|
|
||||||
|
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
|
||||||
|
expected_headers = [
|
||||||
|
"Scénario",
|
||||||
|
"Test",
|
||||||
|
"Source de log",
|
||||||
|
"Commentaires SOC",
|
||||||
|
"Exécution",
|
||||||
|
"Logs remontés au SIEM",
|
||||||
|
"Cyber incident",
|
||||||
|
]
|
||||||
|
assert rows[0] == expected_headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_csv_escapes_special_characters(
|
||||||
|
client: FlaskClient, admin_token: str, app
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
admin = User.query.filter_by(username="admin1").first()
|
||||||
|
sim = Simulation(
|
||||||
|
engagement_id=eng["id"],
|
||||||
|
name='Sim "quoted"',
|
||||||
|
commands='cmd1, cmd2\nnewline "here"',
|
||||||
|
status=SimulationStatus.PENDING,
|
||||||
|
created_by_id=admin.id,
|
||||||
|
)
|
||||||
|
db.session.add(sim)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
resp = _export(client, admin_token, eng["id"], "csv")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.data.decode()
|
||||||
|
# csv.writer must have quoted the fields — no raw unquoted double-quotes breaking rows
|
||||||
|
import csv as csv_mod
|
||||||
|
import io
|
||||||
|
|
||||||
|
rows = list(csv_mod.reader(io.StringIO(body)))
|
||||||
|
assert len(rows) == 2 # header + 1 sim
|
||||||
|
name_col = rows[1][0] # col 0 = Scénario
|
||||||
|
assert "quoted" in name_col
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PDF
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_pdf_returns_pdf_magic_bytes_and_non_empty(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
_make_sim(client, admin_token, eng["id"], "S1")
|
||||||
|
|
||||||
|
resp = _export(client, admin_token, eng["id"], "pdf")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content_type == "application/pdf"
|
||||||
|
assert resp.data[:4] == b"%PDF"
|
||||||
|
assert len(resp.data) > 1024
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 400 / 404
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_unknown_format_400(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/export?format=xml",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "format must be one of" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_missing_format_400(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/export",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "format must be one of" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_unknown_engagement_404(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.get(
|
||||||
|
"/api/engagements/99999/export?format=md",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Edge cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_engagement_with_zero_simulations_renders_header_only(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token, "Empty Engagement")
|
||||||
|
|
||||||
|
resp_md = _export(client, admin_token, eng["id"], "md")
|
||||||
|
assert resp_md.status_code == 200
|
||||||
|
assert "Empty Engagement" in resp_md.data.decode()
|
||||||
|
|
||||||
|
resp_csv = _export(client, admin_token, eng["id"], "csv")
|
||||||
|
assert resp_csv.status_code == 200
|
||||||
|
import csv as csv_mod
|
||||||
|
import io
|
||||||
|
|
||||||
|
rows = list(csv_mod.reader(io.StringIO(resp_csv.data.decode())))
|
||||||
|
assert len(rows) == 1 # header only
|
||||||
|
|
||||||
|
resp_pdf = _export(client, admin_token, eng["id"], "pdf")
|
||||||
|
assert resp_pdf.status_code == 200
|
||||||
|
assert resp_pdf.data[:4] == b"%PDF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_filename_slugifies_name_and_carries_date(app, admin_user: User) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = Engagement(
|
||||||
|
name="Opération Spéciale!",
|
||||||
|
start_date=date(2026, 6, 1),
|
||||||
|
status=EngagementStatus.PLANNED,
|
||||||
|
created_by_id=admin_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(eng)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
fname = _export_filename(eng, "md")
|
||||||
|
from datetime import date as _date
|
||||||
|
|
||||||
|
today = _date.today().strftime("%Y%m%d")
|
||||||
|
assert fname.startswith(f"engagement-{eng.id}-")
|
||||||
|
assert "operation-speciale" in fname
|
||||||
|
assert fname.endswith(f"-{today}.md")
|
||||||
317
backend/tests/test_export_render.py
Normal file
317
backend/tests/test_export_render.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""Unit tests for render functions in backend.app.services.export."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv as _csv
|
||||||
|
import io as _io
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.app.services.export import (
|
||||||
|
render_engagement_csv,
|
||||||
|
render_engagement_markdown,
|
||||||
|
render_engagement_pdf,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures / factories
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(**kw) -> Any:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Test Engagement",
|
||||||
|
"description": "A purple team exercise",
|
||||||
|
"start_date": date(2026, 6, 1),
|
||||||
|
"end_date": date(2026, 6, 30),
|
||||||
|
"status": SimpleNamespace(value="active"),
|
||||||
|
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||||
|
"created_by": SimpleNamespace(username="alice"),
|
||||||
|
}
|
||||||
|
defaults.update(kw)
|
||||||
|
return SimpleNamespace(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"id": sid,
|
||||||
|
"name": name,
|
||||||
|
"status": SimpleNamespace(value="pending"),
|
||||||
|
"description": "Execute a script",
|
||||||
|
"commands": "whoami",
|
||||||
|
"executed_at": None,
|
||||||
|
"execution_result": None,
|
||||||
|
"log_source": None,
|
||||||
|
"logs": None,
|
||||||
|
"soc_comment": None,
|
||||||
|
"incident_number": None,
|
||||||
|
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||||
|
"updated_at": None,
|
||||||
|
"created_by": SimpleNamespace(username="bob"),
|
||||||
|
}
|
||||||
|
defaults.update(kw)
|
||||||
|
return SimpleNamespace(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FR_HEADERS = [
|
||||||
|
"Scénario",
|
||||||
|
"Test",
|
||||||
|
"Source de log",
|
||||||
|
"Commentaires SOC",
|
||||||
|
"Exécution",
|
||||||
|
"Logs remontés au SIEM",
|
||||||
|
"Cyber incident",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Markdown tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_markdown_includes_header_fields(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
result = render_engagement_markdown(eng, [])
|
||||||
|
assert "Test Engagement" in result
|
||||||
|
assert "2026-06-01" in result
|
||||||
|
assert "2026-06-30" in result
|
||||||
|
assert "active" in result
|
||||||
|
assert "alice" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_markdown_has_seven_column_table_headers(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim()
|
||||||
|
result = render_engagement_markdown(eng, [sim])
|
||||||
|
for header in _FR_HEADERS:
|
||||||
|
assert header in result, f"Expected French header '{header}' in markdown table"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_markdown_lists_all_simulations_in_order(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sims = [_make_sim(1, "First Sim"), _make_sim(2, "Second Sim")]
|
||||||
|
result = render_engagement_markdown(eng, sims)
|
||||||
|
first_pos = result.index("First Sim")
|
||||||
|
second_pos = result.index("Second Sim")
|
||||||
|
assert first_pos < second_pos
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_markdown_no_simulations_has_no_table(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
result = render_engagement_markdown(eng, [])
|
||||||
|
assert "Scénario" not in result
|
||||||
|
assert "## Simulations" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_markdown_execution_cell_uses_br_separator(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim(
|
||||||
|
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||||
|
commands="whoami",
|
||||||
|
execution_result="admin@host",
|
||||||
|
)
|
||||||
|
result = render_engagement_markdown(eng, [sim])
|
||||||
|
assert "<br/>" in result
|
||||||
|
assert "whoami" in result
|
||||||
|
assert "admin@host" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_markdown_pipe_in_cell_is_escaped(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim(name="Name | with pipe")
|
||||||
|
result = render_engagement_markdown(eng, [sim])
|
||||||
|
assert "Name \\| with pipe" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSV tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csv(csv_text: str) -> list[list[str]]:
|
||||||
|
return list(_csv.reader(_io.StringIO(csv_text)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_csv_has_header_row(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
result = render_engagement_csv(eng, [])
|
||||||
|
rows = _parse_csv(result)
|
||||||
|
assert rows[0] == _FR_HEADERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_csv_has_one_row_per_simulation(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sims = [_make_sim(1, "S1"), _make_sim(2, "S2")]
|
||||||
|
result = render_engagement_csv(eng, sims)
|
||||||
|
rows = _parse_csv(result)
|
||||||
|
assert len(rows) == 3 # header + 2 sims
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_csv_columns_are_seven(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim()
|
||||||
|
result = render_engagement_csv(eng, [sim])
|
||||||
|
rows = _parse_csv(result)
|
||||||
|
assert len(rows[0]) == 7
|
||||||
|
assert len(rows[1]) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_csv_execution_column_contains_commands(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim(
|
||||||
|
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||||
|
commands="net user /domain",
|
||||||
|
execution_result="success",
|
||||||
|
)
|
||||||
|
result = render_engagement_csv(eng, [sim])
|
||||||
|
rows = _parse_csv(result)
|
||||||
|
exec_cell = rows[1][4] # col index 4 = Exécution
|
||||||
|
assert "2026-06-01" in exec_cell
|
||||||
|
assert "net user /domain" in exec_cell
|
||||||
|
assert "success" in exec_cell
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSV formula injection defense
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csv_data_row(csv_text: str, row_index: int = 1) -> list[str]:
|
||||||
|
return _parse_csv(csv_text)[row_index]
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_csv_escapes_formula_injection_in_scenario(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim(name="=cmd|'/c calc'!A1")
|
||||||
|
result = render_engagement_csv(eng, [sim])
|
||||||
|
cells = _parse_csv_data_row(result)
|
||||||
|
# col 0 = Scénario
|
||||||
|
assert cells[0] == "'=cmd|'/c calc'!A1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_csv_escapes_formula_injection_in_execution(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
# executed_at=None so concat is "\ncommand\n" — leading \n is not a trigger.
|
||||||
|
# Use a formula-triggering execution_result to test the final concat.
|
||||||
|
sim = _make_sim(execution_result="=HYPERLINK(\"http://evil\")")
|
||||||
|
result = render_engagement_csv(eng, [sim])
|
||||||
|
cells = _parse_csv_data_row(result)
|
||||||
|
# col 4 = Exécution; concat starts with "\n" (empty executed_at) so not triggered
|
||||||
|
# but the execution_result value is embedded — verify it's present
|
||||||
|
assert "HYPERLINK" in cells[4]
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_csv_defuses_formula_in_inner_execution_lines(app) -> None:
|
||||||
|
"""When executed_at is set, the cell starts with a safe date, but commands
|
||||||
|
line may inject formulas. Each user-controlled component must be defused."""
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim(
|
||||||
|
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||||
|
commands="=cmd|'/c calc'!A1",
|
||||||
|
execution_result="@SUM(1)",
|
||||||
|
)
|
||||||
|
result = render_engagement_csv(eng, [sim])
|
||||||
|
cells = list(_csv.reader(_io.StringIO(result)))[1]
|
||||||
|
execution_cell = cells[4] # Exécution column
|
||||||
|
assert "'=cmd|'/c calc'!A1" in execution_cell
|
||||||
|
assert "'@SUM(1)" in execution_cell
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_csv_does_not_alter_safe_strings(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim(name="Mimikatz LSASS Dump", commands="whoami /all")
|
||||||
|
result = render_engagement_csv(eng, [sim])
|
||||||
|
cells = _parse_csv_data_row(result)
|
||||||
|
assert cells[0] == "Mimikatz LSASS Dump"
|
||||||
|
assert "whoami /all" in cells[4]
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_markdown_escapes_html_in_table_cells(app) -> None:
|
||||||
|
"""User content in table cells must be HTML-escaped to prevent stored XSS
|
||||||
|
when the .md is opened in a renderer that interprets inline HTML."""
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim(
|
||||||
|
name="<script>alert(1)</script>",
|
||||||
|
commands='<img src=x onerror="alert(1)">',
|
||||||
|
)
|
||||||
|
result = render_engagement_markdown(eng, [sim])
|
||||||
|
assert "<script>" not in result
|
||||||
|
assert 'onerror="alert' not in result
|
||||||
|
assert "<script>" in result
|
||||||
|
assert "<img" in result
|
||||||
|
# double-quotes in attribute values are also escaped
|
||||||
|
assert """ in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PDF tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_pdf_starts_with_pdf_magic(app) -> None:
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim()
|
||||||
|
result = render_engagement_pdf(eng, [sim])
|
||||||
|
assert isinstance(result, bytes)
|
||||||
|
assert result[:4] == b"%PDF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_pdf_contains_simulation_table(app) -> None:
|
||||||
|
from backend.app.services.export import _render_engagement_html
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
sim = _make_sim()
|
||||||
|
html = _render_engagement_html(eng, [sim])
|
||||||
|
assert "<table>" in html
|
||||||
|
for header in _FR_HEADERS:
|
||||||
|
assert header in html, f"Expected French header '{header}' in HTML"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_engagement_html_has_landscape_page_rule(app) -> None:
|
||||||
|
from backend.app.services.export import _render_engagement_html
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
eng = _make_engagement()
|
||||||
|
html = _render_engagement_html(eng, [])
|
||||||
|
assert "landscape" in html, "HTML must include A4 landscape @page rule for PDF output"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defense-in-depth: filename header injection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_filename_never_contains_quote_or_crlf() -> None:
|
||||||
|
"""Defense-in-depth: even with malicious engagement names, the filename
|
||||||
|
used in Content-Disposition must never contain header-injection chars."""
|
||||||
|
from backend.app.services.export import _export_filename
|
||||||
|
|
||||||
|
evil = SimpleNamespace(id=42, name='evil"name\r\nX-Injected: yes')
|
||||||
|
fname = _export_filename(evil, "md")
|
||||||
|
assert '"' not in fname
|
||||||
|
assert '\r' not in fname
|
||||||
|
assert '\n' not in fname
|
||||||
199
backend/tests/test_migration_0006_c2.py
Normal file
199
backend/tests/test_migration_0006_c2.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""Migration round-trip test for 0006_c2_layer.
|
||||||
|
|
||||||
|
Verifies that upgrade() creates c2_config and c2_task with the expected schema,
|
||||||
|
and that downgrade() removes both tables cleanly.
|
||||||
|
|
||||||
|
Uses the resolved-path pattern (derives path from __file__) to avoid the
|
||||||
|
hardcoded-path regression documented in lessons.md Sprint 4.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
|
||||||
|
|
||||||
|
def _load_migration():
|
||||||
|
versions_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
|
||||||
|
path = versions_dir / "0006_c2_layer.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("migration_0006", path)
|
||||||
|
assert spec and spec.loader
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_engine():
|
||||||
|
"""In-memory SQLite with the tables that 0006 depends on already present."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE engagements (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'planned',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE simulations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
engagement_id INTEGER NOT NULL REFERENCES engagements(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
techniques JSON NOT NULL DEFAULT '[]',
|
||||||
|
tactic_ids JSON NOT NULL DEFAULT '[]',
|
||||||
|
description TEXT,
|
||||||
|
commands TEXT,
|
||||||
|
prerequisites TEXT,
|
||||||
|
executed_at DATETIME,
|
||||||
|
execution_result TEXT,
|
||||||
|
log_source TEXT,
|
||||||
|
logs TEXT,
|
||||||
|
soc_comment TEXT,
|
||||||
|
incident_number TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME,
|
||||||
|
created_by_id INTEGER NOT NULL REFERENCES users(id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _run_upgrade(engine, migration_mod):
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn)
|
||||||
|
ops = Operations(ctx)
|
||||||
|
ops._install_proxy() # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
migration_mod.upgrade()
|
||||||
|
finally:
|
||||||
|
ops._remove_proxy() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_downgrade(engine, migration_mod):
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn)
|
||||||
|
ops = Operations(ctx)
|
||||||
|
ops._install_proxy() # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
migration_mod.downgrade()
|
||||||
|
finally:
|
||||||
|
ops._remove_proxy() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration0006Upgrade:
|
||||||
|
def test_c2_config_table_created(self):
|
||||||
|
engine = _fresh_engine()
|
||||||
|
mod = _load_migration()
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
|
||||||
|
insp = inspect(engine)
|
||||||
|
assert "c2_config" in insp.get_table_names()
|
||||||
|
|
||||||
|
def test_c2_task_table_created(self):
|
||||||
|
engine = _fresh_engine()
|
||||||
|
mod = _load_migration()
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
|
||||||
|
insp = inspect(engine)
|
||||||
|
assert "c2_task" in insp.get_table_names()
|
||||||
|
|
||||||
|
def test_c2_config_columns(self):
|
||||||
|
engine = _fresh_engine()
|
||||||
|
mod = _load_migration()
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
|
||||||
|
insp = inspect(engine)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("c2_config")}
|
||||||
|
assert {"id", "engagement_id", "url", "api_token_encrypted",
|
||||||
|
"verify_tls", "created_at", "updated_at"} <= cols
|
||||||
|
|
||||||
|
def test_c2_config_unique_constraint_on_engagement_id(self):
|
||||||
|
engine = _fresh_engine()
|
||||||
|
mod = _load_migration()
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
|
||||||
|
# Insert a user and engagement first.
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO users (id, username, password_hash, role, created_at) "
|
||||||
|
"VALUES (1, 'u', 'h', 'admin', '2026-01-01')"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO engagements (id, name, start_date, status, created_at, created_by_id) "
|
||||||
|
"VALUES (1, 'Op', '2026-01-01', 'planned', '2026-01-01', 1)"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO c2_config (engagement_id, url, api_token_encrypted, verify_tls, created_at) "
|
||||||
|
"VALUES (1, 'https://c2', 'tok', 1, '2026-01-01')"
|
||||||
|
))
|
||||||
|
# Second insert on same engagement_id must fail.
|
||||||
|
try:
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO c2_config (engagement_id, url, api_token_encrypted, verify_tls, created_at) "
|
||||||
|
"VALUES (1, 'https://c2b', 'tok2', 1, '2026-01-01')"
|
||||||
|
))
|
||||||
|
raised = False
|
||||||
|
except Exception:
|
||||||
|
raised = True
|
||||||
|
assert raised, "UNIQUE constraint on c2_config.engagement_id must be enforced"
|
||||||
|
|
||||||
|
def test_c2_task_columns(self):
|
||||||
|
engine = _fresh_engine()
|
||||||
|
mod = _load_migration()
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
|
||||||
|
insp = inspect(engine)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("c2_task")}
|
||||||
|
assert {"id", "simulation_id", "mythic_task_display_id", "callback_display_id",
|
||||||
|
"command", "params", "status", "completed", "output", "source",
|
||||||
|
"created_at", "completed_at"} <= cols
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration0006Downgrade:
|
||||||
|
def test_downgrade_removes_c2_config(self):
|
||||||
|
engine = _fresh_engine()
|
||||||
|
mod = _load_migration()
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
_run_downgrade(engine, mod)
|
||||||
|
|
||||||
|
insp = inspect(engine)
|
||||||
|
assert "c2_config" not in insp.get_table_names()
|
||||||
|
|
||||||
|
def test_downgrade_removes_c2_task(self):
|
||||||
|
engine = _fresh_engine()
|
||||||
|
mod = _load_migration()
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
_run_downgrade(engine, mod)
|
||||||
|
|
||||||
|
insp = inspect(engine)
|
||||||
|
assert "c2_task" not in insp.get_table_names()
|
||||||
124
backend/tests/test_migration_0007_c2.py
Normal file
124
backend/tests/test_migration_0007_c2.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Migration round-trip test for 0007_c2_task_mapping_applied.
|
||||||
|
|
||||||
|
Verifies that upgrade() adds the mapping_applied column and downgrade() removes it.
|
||||||
|
Uses the resolved-path pattern per lessons.md Sprint 4.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
|
||||||
|
|
||||||
|
def _load_migration(name: str):
|
||||||
|
versions_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
|
||||||
|
path = versions_dir / name
|
||||||
|
spec = importlib.util.spec_from_file_location(name.removesuffix(".py"), path)
|
||||||
|
assert spec and spec.loader
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_engine_with_c2_task():
|
||||||
|
"""In-memory SQLite with c2_task already created (as left by 0006 upgrade)."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE c2_task (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
simulation_id INTEGER NOT NULL,
|
||||||
|
mythic_task_display_id INTEGER NOT NULL,
|
||||||
|
callback_display_id INTEGER NOT NULL,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
params TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
completed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
output TEXT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
completed_at DATETIME
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _run_upgrade(engine, migration_mod):
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn)
|
||||||
|
ops = Operations(ctx)
|
||||||
|
ops._install_proxy() # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
migration_mod.upgrade()
|
||||||
|
finally:
|
||||||
|
ops._remove_proxy() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_downgrade(engine, migration_mod):
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn)
|
||||||
|
ops = Operations(ctx)
|
||||||
|
ops._install_proxy() # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
migration_mod.downgrade()
|
||||||
|
finally:
|
||||||
|
ops._remove_proxy() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration0007Upgrade:
|
||||||
|
def test_mapping_applied_column_added(self):
|
||||||
|
engine = _fresh_engine_with_c2_task()
|
||||||
|
mod = _load_migration("0007_c2_task_mapping_applied.py")
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
|
||||||
|
insp = inspect(engine)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("c2_task")}
|
||||||
|
assert "mapping_applied" in cols
|
||||||
|
|
||||||
|
def test_mapping_applied_defaults_to_false(self):
|
||||||
|
engine = _fresh_engine_with_c2_task()
|
||||||
|
mod = _load_migration("0007_c2_task_mapping_applied.py")
|
||||||
|
|
||||||
|
# Insert a row before upgrading (no mapping_applied column yet).
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO c2_task "
|
||||||
|
"(simulation_id, mythic_task_display_id, callback_display_id, "
|
||||||
|
"command, status, completed, source, created_at) "
|
||||||
|
"VALUES (1, 1000, 1, 'whoami', 'submitted', 0, 'mimic', '2026-01-01')"
|
||||||
|
))
|
||||||
|
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
text("SELECT mapping_applied FROM c2_task WHERE id = 1")
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
# SQLite stores booleans as 0/1.
|
||||||
|
assert row[0] == 0 or row[0] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration0007Downgrade:
|
||||||
|
def test_downgrade_removes_mapping_applied(self):
|
||||||
|
engine = _fresh_engine_with_c2_task()
|
||||||
|
mod = _load_migration("0007_c2_task_mapping_applied.py")
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
_run_downgrade(engine, mod)
|
||||||
|
|
||||||
|
insp = inspect(engine)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("c2_task")}
|
||||||
|
assert "mapping_applied" not in cols
|
||||||
|
|
||||||
|
def test_downgrade_does_not_drop_other_columns(self):
|
||||||
|
engine = _fresh_engine_with_c2_task()
|
||||||
|
mod = _load_migration("0007_c2_task_mapping_applied.py")
|
||||||
|
_run_upgrade(engine, mod)
|
||||||
|
_run_downgrade(engine, mod)
|
||||||
|
|
||||||
|
insp = inspect(engine)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("c2_task")}
|
||||||
|
assert {"id", "simulation_id", "command", "status", "completed"} <= cols
|
||||||
403
backend/tests/test_mitre.py
Normal file
403
backend/tests/test_mitre.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
"""MITRE service and endpoint tests. Uses a tiny fixture bundle, not the 40 MB file."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture STIX bundle (minimal, 4 techniques including one sub-technique)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FIXTURE_BUNDLE = {
|
||||||
|
"type": "bundle",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "Command and Scripting Interpreter",
|
||||||
|
"external_references": [
|
||||||
|
{"source_name": "mitre-attack", "external_id": "T1059"}
|
||||||
|
],
|
||||||
|
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "PowerShell",
|
||||||
|
"external_references": [
|
||||||
|
{"source_name": "mitre-attack", "external_id": "T1059.001"}
|
||||||
|
],
|
||||||
|
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "Python",
|
||||||
|
"external_references": [
|
||||||
|
{"source_name": "mitre-attack", "external_id": "T1059.006"}
|
||||||
|
],
|
||||||
|
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "Phishing",
|
||||||
|
"external_references": [
|
||||||
|
{"source_name": "mitre-attack", "external_id": "T1566"}
|
||||||
|
],
|
||||||
|
"kill_chain_phases": [{"phase_name": "initial-access", "kill_chain_name": "mitre-attack"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "Valid Accounts",
|
||||||
|
"external_references": [
|
||||||
|
{"source_name": "mitre-attack", "external_id": "T1078"}
|
||||||
|
],
|
||||||
|
"kill_chain_phases": [
|
||||||
|
{"phase_name": "initial-access", "kill_chain_name": "mitre-attack"},
|
||||||
|
{"phase_name": "persistence", "kill_chain_name": "mitre-attack"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Revoked — must be excluded from index.
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "Old Technique",
|
||||||
|
"revoked": True,
|
||||||
|
"external_references": [
|
||||||
|
{"source_name": "mitre-attack", "external_id": "T9999"}
|
||||||
|
],
|
||||||
|
"kill_chain_phases": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "Application Layer Protocol",
|
||||||
|
"external_references": [
|
||||||
|
{"source_name": "mitre-attack", "external_id": "T1071"}
|
||||||
|
],
|
||||||
|
"kill_chain_phases": [{"phase_name": "command-and-control", "kill_chain_name": "mitre-attack"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Not an attack-pattern — must be ignored.
|
||||||
|
"type": "relationship",
|
||||||
|
"name": "Ignored",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_mitre():
|
||||||
|
"""Reset the MITRE service state between tests."""
|
||||||
|
original_loaded = mitre_svc.mitre_loaded
|
||||||
|
original_index = list(mitre_svc._index)
|
||||||
|
original_tactics = dict(mitre_svc._tactics_by_technique)
|
||||||
|
original_names = dict(mitre_svc._name_by_id)
|
||||||
|
original_matrix = list(mitre_svc._matrix)
|
||||||
|
yield
|
||||||
|
mitre_svc.mitre_loaded = original_loaded
|
||||||
|
mitre_svc._index = original_index
|
||||||
|
mitre_svc._tactics_by_technique = original_tactics
|
||||||
|
mitre_svc._name_by_id = original_names
|
||||||
|
mitre_svc._matrix = original_matrix
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def bundle_file(tmp_path: pathlib.Path) -> pathlib.Path:
|
||||||
|
p = tmp_path / "enterprise-attack.json"
|
||||||
|
p.write_text(json.dumps(_FIXTURE_BUNDLE), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests for load_bundle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_bundle_success(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
assert mitre_svc.mitre_loaded is True
|
||||||
|
assert len(mitre_svc._index) == 6 # 7 attack-patterns minus 1 revoked = 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_bundle_missing_file() -> None:
|
||||||
|
mitre_svc.load_bundle(pathlib.Path("/nonexistent/path.json"))
|
||||||
|
assert mitre_svc.mitre_loaded is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_bundle_invalid_json(tmp_path: pathlib.Path) -> None:
|
||||||
|
bad = tmp_path / "bad.json"
|
||||||
|
bad.write_text("{ not json }", encoding="utf-8")
|
||||||
|
mitre_svc.load_bundle(bad)
|
||||||
|
assert mitre_svc.mitre_loaded is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_bundle_excludes_revoked(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
ids = [e["id"] for e in mitre_svc._index]
|
||||||
|
assert "T9999" not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_bundle_includes_subtechniques(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
ids = [e["id"] for e in mitre_svc._index]
|
||||||
|
assert "T1059.001" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_bundle_extracts_tactics(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
t1078 = next(e for e in mitre_svc._index if e["id"] == "T1078")
|
||||||
|
assert "initial-access" in t1078["tactics"]
|
||||||
|
assert "persistence" in t1078["tactics"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests for search
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_exact_id_first(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
results = mitre_svc.search("T1059")
|
||||||
|
assert results[0]["id"] == "T1059"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_prefix_id(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
results = mitre_svc.search("T105")
|
||||||
|
ids = [r["id"] for r in results]
|
||||||
|
assert "T1059" in ids
|
||||||
|
assert "T1059.001" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_name_substring(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
results = mitre_svc.search("phish")
|
||||||
|
assert any(r["id"] == "T1566" for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_case_insensitive(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
results = mitre_svc.search("POWERSHELL")
|
||||||
|
assert any(r["id"] == "T1059.001" for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_limit(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
results = mitre_svc.search("T", limit=2)
|
||||||
|
assert len(results) <= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_empty_query(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
assert mitre_svc.search("") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_ranking_order(bundle_file: pathlib.Path) -> None:
|
||||||
|
"""exact-id > prefix-id > name match."""
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
results = mitre_svc.search("T1059")
|
||||||
|
# T1059 must come before T1059.001 (prefix match)
|
||||||
|
ids = [r["id"] for r in results]
|
||||||
|
assert ids.index("T1059") < ids.index("T1059.001")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoint tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_mitre_endpoint_503_when_not_loaded(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
mitre_svc.mitre_loaded = False
|
||||||
|
mitre_svc._index = []
|
||||||
|
resp = client.get("/api/mitre/techniques?q=T1059", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert resp.get_json()["error"] == "mitre bundle not loaded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mitre_endpoint_returns_results(
|
||||||
|
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
|
||||||
|
) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
resp = client.get("/api/mitre/techniques?q=T1059", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
assert any(r["id"] == "T1059" for r in data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mitre_endpoint_requires_auth(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/mitre/techniques?q=T1059")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mitre_endpoint_all_roles_can_access(
|
||||||
|
client: FlaskClient,
|
||||||
|
redteam_token: str,
|
||||||
|
soc_token: str,
|
||||||
|
admin_token: str,
|
||||||
|
bundle_file: pathlib.Path,
|
||||||
|
) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
for token in (redteam_token, soc_token, admin_token):
|
||||||
|
resp = client.get("/api/mitre/techniques?q=T1059", headers=_h(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_mitre_endpoint_max_20_results(
|
||||||
|
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
|
||||||
|
) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
resp = client.get("/api/mitre/techniques?q=T", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.get_json()) <= 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_mitre_endpoint_includes_tactics(
|
||||||
|
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
|
||||||
|
) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
resp = client.get("/api/mitre/techniques?q=T1566", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
phishing = next((r for r in data if r["id"] == "T1566"), None)
|
||||||
|
assert phishing is not None
|
||||||
|
assert "initial-access" in phishing["tactics"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sprint 3: get_tactics, lookup_name, get_matrix
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_tactics_known(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
tactics = mitre_svc.get_tactics("T1078")
|
||||||
|
assert "initial-access" in tactics
|
||||||
|
assert "persistence" in tactics
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_tactics_unknown_returns_empty(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
assert mitre_svc.get_tactics("T0000") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_name_known(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
assert mitre_svc.lookup_name("T1059") == "Command and Scripting Interpreter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_name_subtechnique(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
assert mitre_svc.lookup_name("T1059.001") == "PowerShell"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_name_unknown_returns_none(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
assert mitre_svc.lookup_name("T0000") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_matrix_returns_ordered_tactics(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
matrix = mitre_svc.get_matrix()
|
||||||
|
tactic_ids = [t["tactic_id"] for t in matrix]
|
||||||
|
# TA0001 (initial-access) must come before TA0002 (execution) in canonical order.
|
||||||
|
assert tactic_ids.index("TA0001") < tactic_ids.index("TA0002")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_matrix_subtechniques_nested(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
matrix = mitre_svc.get_matrix()
|
||||||
|
exec_tactic = next(t for t in matrix if t["tactic_id"] == "TA0002")
|
||||||
|
t1059 = next((t for t in exec_tactic["techniques"] if t["id"] == "T1059"), None)
|
||||||
|
assert t1059 is not None
|
||||||
|
sub_ids = [s["id"] for s in t1059["subtechniques"]]
|
||||||
|
assert "T1059.001" in sub_ids
|
||||||
|
assert "T1059.006" in sub_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_matrix_subtechniques_sorted_by_name(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
matrix = mitre_svc.get_matrix()
|
||||||
|
exec_tactic = next(t for t in matrix if t["tactic_id"] == "TA0002")
|
||||||
|
t1059 = next(t for t in exec_tactic["techniques"] if t["id"] == "T1059")
|
||||||
|
names = [s["name"] for s in t1059["subtechniques"]]
|
||||||
|
assert names == sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_matrix_techniques_sorted_by_name(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
matrix = mitre_svc.get_matrix()
|
||||||
|
ia_tactic = next(t for t in matrix if t["tactic_id"] == "TA0001")
|
||||||
|
names = [t["name"] for t in ia_tactic["techniques"]]
|
||||||
|
assert names == sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_matrix_technique_no_subtechniques(bundle_file: pathlib.Path) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
matrix = mitre_svc.get_matrix()
|
||||||
|
ia_tactic = next(t for t in matrix if t["tactic_id"] == "TA0001")
|
||||||
|
phishing = next((t for t in ia_tactic["techniques"] if t["id"] == "T1566"), None)
|
||||||
|
assert phishing is not None
|
||||||
|
assert phishing["subtechniques"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_endpoint_ok(
|
||||||
|
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
|
||||||
|
) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
resp = client.get("/api/mitre/matrix", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
tactic_ids = [t["tactic_id"] for t in data]
|
||||||
|
assert "TA0001" in tactic_ids # initial-access
|
||||||
|
assert "TA0002" in tactic_ids # execution
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_endpoint_503_when_not_loaded(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
mitre_svc.mitre_loaded = False
|
||||||
|
resp = client.get("/api/mitre/matrix", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_endpoint_requires_auth(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/mitre/matrix")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_endpoint_all_roles(
|
||||||
|
client: FlaskClient,
|
||||||
|
redteam_token: str,
|
||||||
|
soc_token: str,
|
||||||
|
admin_token: str,
|
||||||
|
bundle_file: pathlib.Path,
|
||||||
|
) -> None:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
for token in (redteam_token, soc_token, admin_token):
|
||||||
|
resp = client.get("/api/mitre/matrix", headers=_h(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_matrix_command_and_control_display_name(bundle_file: pathlib.Path) -> None:
|
||||||
|
"""MITRE official name uses lowercase 'and' — not title-cased."""
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
matrix = mitre_svc.get_matrix()
|
||||||
|
c2 = next((t for t in matrix if t["tactic_id"] == "TA0011"), None)
|
||||||
|
assert c2 is not None
|
||||||
|
assert c2["tactic_name"] == "Command and Control"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_matrix_tactic_id_is_ta_format(bundle_file: pathlib.Path) -> None:
|
||||||
|
"""Matrix tactic_id must use TA-format so frontend can send it back in PATCH tactic_ids."""
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
matrix = mitre_svc.get_matrix()
|
||||||
|
for entry in matrix:
|
||||||
|
tid = entry["tactic_id"]
|
||||||
|
assert tid.startswith("TA"), f"tactic_id {tid!r} must be TA-format, not a slug"
|
||||||
289
backend/tests/test_simulation_templates_crud.py
Normal file
289
backend/tests/test_simulation_templates_crud.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""SimulationTemplate CRUD: list, create, get, patch, delete + RBAC + dedup."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User
|
||||||
|
from backend.app.models.simulation_template import SimulationTemplate
|
||||||
|
from backend.tests.conftest import auth_headers as _h # noqa: E402
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_template(client: FlaskClient, token: str, **kw) -> dict:
|
||||||
|
payload = {"name": "Template Alpha", **kw}
|
||||||
|
resp = client.post("/api/templates", headers=_h(token), json=payload)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_templates_empty(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.get("/api/templates", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_templates_soc_forbidden(client: FlaskClient, soc_token: str) -> None:
|
||||||
|
resp = client.get("/api/templates", headers=_h(soc_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_templates_unauthenticated(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/templates")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_template_as_admin(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
body = _make_template(
|
||||||
|
client,
|
||||||
|
admin_token,
|
||||||
|
description="desc",
|
||||||
|
commands="cmd",
|
||||||
|
prerequisites="prereq",
|
||||||
|
)
|
||||||
|
assert body["name"] == "Template Alpha"
|
||||||
|
assert body["description"] == "desc"
|
||||||
|
assert body["commands"] == "cmd"
|
||||||
|
assert body["prerequisites"] == "prereq"
|
||||||
|
assert body["techniques"] == []
|
||||||
|
assert body["tactics"] == []
|
||||||
|
assert body["created_by"] == {"id": admin_user.id, "username": "admin1"}
|
||||||
|
assert body["id"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_template_as_redteam(
|
||||||
|
client: FlaskClient, redteam_user: User, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
body = _make_template(client, redteam_token)
|
||||||
|
assert body["created_by"]["username"] == "redteam1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_template_soc_forbidden(client: FlaskClient, soc_token: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/templates", headers=_h(soc_token), json={"name": "T"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_template_missing_name(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.post("/api/templates", headers=_h(admin_token), json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "name" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_template_duplicate_name_409(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_template(client, admin_token)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/templates", headers=_h(admin_token), json={"name": "Template Alpha"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "already exists" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_template_unknown_technique_id_400(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/templates",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"name": "T", "technique_ids": ["T9999.999"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "unknown technique id" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_template_unknown_tactic_id_400(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/templates",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"name": "T", "tactic_ids": ["TA9999"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "unknown tactic id" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Get single
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_template(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.get(f"/api/templates/{created['id']}", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["id"] == created["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_template_not_found(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.get("/api/templates/9999", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_template_soc_forbidden(
|
||||||
|
client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.get(f"/api/templates/{created['id']}", headers=_h(soc_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Patch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_template_name(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/templates/{created['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"name": "Renamed"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["name"] == "Renamed"
|
||||||
|
assert resp.get_json()["updated_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_template_empty_name_rejected(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/templates/{created['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"name": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_template_unknown_field_rejected(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/templates/{created['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"bogus_field": "x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "unknown fields" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_template_duplicate_name_409(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
_make_template(client, admin_token, name="T1")
|
||||||
|
t2 = _make_template(client, admin_token, name="T2")
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/templates/{t2['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"name": "T1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_template_soc_forbidden(
|
||||||
|
client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/templates/{created['id']}",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
json={"name": "X"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_template_not_found(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/templates/9999", headers=_h(admin_token), json={"name": "X"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_template_unknown_technique_id_400(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/templates/{created['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"technique_ids": ["T9999.999"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "unknown technique id" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_template_unknown_tactic_id_400(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/templates/{created['id']}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"tactic_ids": ["TA9999"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "unknown tactic id" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_template(
|
||||||
|
client: FlaskClient, app, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.delete(f"/api/templates/{created['id']}", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 204
|
||||||
|
with app.app_context():
|
||||||
|
assert db.session.get(SimulationTemplate, created["id"]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_template_not_found(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.delete("/api/templates/9999", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_template_soc_forbidden(
|
||||||
|
client: FlaskClient, admin_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
created = _make_template(client, admin_token)
|
||||||
|
resp = client.delete(f"/api/templates/{created['id']}", headers=_h(soc_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List returns ordered by name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_templates_ordered_by_name(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
for name in ("Zebra", "Alpha", "Midpoint"):
|
||||||
|
_make_template(client, admin_token, name=name)
|
||||||
|
body = client.get("/api/templates", headers=_h(admin_token)).get_json()
|
||||||
|
names = [t["name"] for t in body]
|
||||||
|
assert names == sorted(names)
|
||||||
236
backend/tests/test_simulations_crud.py
Normal file
236
backend/tests/test_simulations_crud.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""Simulation CRUD tests: create, list, get, delete, cascade."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User
|
||||||
|
from backend.app.models.simulation import Simulation
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Alpha", "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int, **kw) -> dict:
|
||||||
|
payload = {"name": "Sim 1", **kw}
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations", headers=_h(token), json=payload
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_as_redteam(
|
||||||
|
client: FlaskClient, redteam_user: User, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
body = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
assert body["name"] == "Sim 1"
|
||||||
|
assert body["status"] == "pending"
|
||||||
|
assert body["engagement_id"] == eng["id"]
|
||||||
|
assert body["created_by"] == {"id": redteam_user.id, "username": "redteam1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_as_admin(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
body = _make_sim(client, admin_token, eng["id"])
|
||||||
|
assert body["created_by"]["username"] == "admin1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_soc_forbidden(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eng['id']}/simulations",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
json={"name": "x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_unauth(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eng['id']}/simulations", json={"name": "x"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_missing_name(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eng['id']}/simulations",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_engagement_not_found(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements/9999/simulations",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"name": "x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_simulations_empty(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/simulations", headers=_h(redteam_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_simulations_ordered_desc(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
_make_sim(client, redteam_token, eng["id"], name="First")
|
||||||
|
_make_sim(client, redteam_token, eng["id"], name="Second")
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/simulations", headers=_h(redteam_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
items = resp.get_json()
|
||||||
|
assert len(items) == 2
|
||||||
|
# Most recent first
|
||||||
|
assert items[0]["name"] == "Second"
|
||||||
|
assert items[1]["name"] == "First"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_simulations_soc_can_read(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
_make_sim(client, redteam_token, eng["id"])
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/engagements/{eng['id']}/simulations", headers=_h(soc_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.get_json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_simulations_engagement_not_found(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.get(
|
||||||
|
"/api/engagements/9999/simulations", headers=_h(redteam_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Get
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_simulation_ok(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["id"] == sim["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_simulation_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = client.get("/api/simulations/9999", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_simulation_soc_can_read(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(soc_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_simulation_redteam(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/simulations/{sim['id']}", headers=_h(redteam_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_simulation_admin(
|
||||||
|
client: FlaskClient, redteam_token: str, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
resp = client.delete(f"/api/simulations/{sim['id']}", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_simulation_soc_forbidden(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
resp = client.delete(f"/api/simulations/{sim['id']}", headers=_h(soc_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_simulation_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = client.delete("/api/simulations/9999", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cascade delete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_delete_engagement_removes_simulations(
|
||||||
|
app, client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
sim_id = sim["id"]
|
||||||
|
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/engagements/{eng['id']}", headers=_h(redteam_token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
assert db.session.get(Simulation, sim_id) is None
|
||||||
191
backend/tests/test_simulations_done_readonly.py
Normal file
191
backend/tests/test_simulations_done_readonly.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""Sprint 4 — done read-only + Reopen tests (AC-18)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Eng", "start_date": "2026-01-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Sim"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_to_done(client: FlaskClient, redteam_token: str, soc_token: str, sid: int) -> None:
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sid}/transition",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"to": "review_required"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sid}/transition",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
json={"to": "done"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(client: FlaskClient, token: str, sid: int, payload: dict):
|
||||||
|
return client.patch(
|
||||||
|
f"/api/simulations/{sid}",
|
||||||
|
headers=_h(token),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _transition(client: FlaskClient, token: str, sid: int, to: str):
|
||||||
|
return client.post(
|
||||||
|
f"/api/simulations/{sid}/transition",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"to": to},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-18.1 — PATCH on done → 409 for all roles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_done_sim_admin_returns_409(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_advance_to_done(client, redteam_token, soc_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, admin_token, sim["id"], {"name": "renamed"})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert resp.get_json()["error"] == "simulation is done — reopen first"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_done_sim_redteam_returns_409(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_advance_to_done(client, redteam_token, soc_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"description": "x"})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_done_sim_soc_returns_409(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_advance_to_done(client, redteam_token, soc_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "afterthought"})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-18.2 — Reopen: done → review_required, all 3 roles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", ["redteam", "soc", "admin"])
|
||||||
|
def test_reopen_done_sim_allowed_for_all_roles(
|
||||||
|
client: FlaskClient,
|
||||||
|
redteam_token: str,
|
||||||
|
soc_token: str,
|
||||||
|
admin_token: str,
|
||||||
|
role: str,
|
||||||
|
) -> None:
|
||||||
|
token = {"redteam": redteam_token, "soc": soc_token, "admin": admin_token}[role]
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_advance_to_done(client, redteam_token, soc_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _transition(client, token, sim["id"], "review_required")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "review_required"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-18.3 — Other transitions from done → 409
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_done_to_done_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_advance_to_done(client, redteam_token, soc_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "done")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_done_to_in_progress_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_advance_to_done(client, redteam_token, soc_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "in_progress")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_done_to_pending_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_advance_to_done(client, redteam_token, soc_token, sim["id"])
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "pending")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# After reopen, PATCH is allowed again
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_allowed_after_reopen(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_advance_to_done(client, redteam_token, soc_token, sim["id"])
|
||||||
|
_transition(client, redteam_token, sim["id"], "review_required")
|
||||||
|
|
||||||
|
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "re-reviewed"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["soc_comment"] == "re-reviewed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-18.3 — Normal review_required path (pending/in_progress) unchanged
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_review_required_from_in_progress_still_needs_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
# Auto-advance to in_progress.
|
||||||
|
_patch(client, redteam_token, sim["id"], {"description": "active"})
|
||||||
|
|
||||||
|
resp = _transition(client, soc_token, sim["id"], "review_required")
|
||||||
|
assert resp.status_code == 403
|
||||||
209
backend/tests/test_simulations_from_template.py
Normal file
209
backend/tests/test_simulations_from_template.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""Tests for creating simulations from a template (POST /api/engagements/<eid>/simulations)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Bravo", "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_template(client: FlaskClient, token: str, **kw) -> dict:
|
||||||
|
payload = {"name": "Base Template", **kw}
|
||||||
|
resp = client.post("/api/templates", headers=_h(token), json=payload)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int, **kw) -> dict:
|
||||||
|
payload = {"name": "Sim From Template", **kw}
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations", headers=_h(token), json=payload
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Instantiation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_from_template_copies_fields(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
tmpl = _make_template(
|
||||||
|
client,
|
||||||
|
admin_token,
|
||||||
|
description="template desc",
|
||||||
|
commands="template cmd",
|
||||||
|
prerequisites="template prereq",
|
||||||
|
)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"], template_id=tmpl["id"])
|
||||||
|
|
||||||
|
assert sim["description"] == "template desc"
|
||||||
|
assert sim["commands"] == "template cmd"
|
||||||
|
assert sim["prerequisites"] == "template prereq"
|
||||||
|
assert sim["techniques"] == []
|
||||||
|
assert sim["tactics"] == []
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_name_overrides_template(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
tmpl = _make_template(client, admin_token)
|
||||||
|
sim = _make_sim(
|
||||||
|
client, admin_token, eng["id"], name="Custom Name", template_id=tmpl["id"]
|
||||||
|
)
|
||||||
|
assert sim["name"] == "Custom Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_name_falls_back_to_template_name(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
tmpl = _make_template(client, admin_token, name="Recon Template")
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eng['id']}/simulations",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"template_id": tmpl["id"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json()["name"] == "Recon Template"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_template_not_found(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eng['id']}/simulations",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"name": "S", "template_id": 9999},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert "Template not found" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_without_template_unaffected(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
assert sim["description"] is None
|
||||||
|
assert sim["commands"] is None
|
||||||
|
assert sim["prerequisites"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_simulation_from_template_status_is_pending(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
tmpl = _make_template(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"], template_id=tmpl["id"])
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_template_does_not_cascade_to_simulations(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
tmpl = _make_template(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"], template_id=tmpl["id"])
|
||||||
|
sid = sim["id"]
|
||||||
|
|
||||||
|
# Delete the template.
|
||||||
|
del_resp = client.delete(
|
||||||
|
f"/api/templates/{tmpl['id']}", headers=_h(admin_token)
|
||||||
|
)
|
||||||
|
assert del_resp.status_code == 204
|
||||||
|
|
||||||
|
# Simulation must still be retrievable.
|
||||||
|
get_resp = client.get(f"/api/simulations/{sid}", headers=_h(admin_token))
|
||||||
|
assert get_resp.status_code == 200
|
||||||
|
assert get_resp.get_json()["id"] == sid
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Migration round-trip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_0005_round_trip() -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
migration_file = (
|
||||||
|
Path(__file__).parent.parent
|
||||||
|
/ "migrations"
|
||||||
|
/ "versions"
|
||||||
|
/ "0005_simulation_templates.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("m0005", migration_file)
|
||||||
|
assert spec is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec.loader is not None
|
||||||
|
spec.loader.exec_module(module) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn)
|
||||||
|
import alembic.op as op_module
|
||||||
|
|
||||||
|
op_module._proxy = Operations(ctx) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# Create users table (FK dependency).
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE users ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"username TEXT NOT NULL, "
|
||||||
|
"password_hash TEXT NOT NULL, "
|
||||||
|
"role TEXT NOT NULL DEFAULT 'redteam', "
|
||||||
|
"created_at DATETIME"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
module.upgrade()
|
||||||
|
|
||||||
|
tables_after = conn.execute(
|
||||||
|
text("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
).fetchall()
|
||||||
|
table_names = {r[0] for r in tables_after}
|
||||||
|
assert "simulation_templates" in table_names
|
||||||
|
|
||||||
|
cols = conn.execute(
|
||||||
|
text("PRAGMA table_info(simulation_templates)")
|
||||||
|
).fetchall()
|
||||||
|
col_names = {c[1] for c in cols}
|
||||||
|
for expected in ("id", "name", "techniques", "tactic_ids", "created_by_id"):
|
||||||
|
assert expected in col_names, f"missing column: {expected}"
|
||||||
|
|
||||||
|
module.downgrade()
|
||||||
|
|
||||||
|
tables_after_down = conn.execute(
|
||||||
|
text("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
).fetchall()
|
||||||
|
table_names_down = {r[0] for r in tables_after_down}
|
||||||
|
assert "simulation_templates" not in table_names_down
|
||||||
297
backend/tests/test_simulations_patch.py
Normal file
297
backend/tests/test_simulations_patch.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""Simulation PATCH tests: auto-transition, RBAC field-level, SOC restrictions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Beta", "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Test Sim"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(client: FlaskClient, token: str, sid: int, payload: dict):
|
||||||
|
return client.patch(
|
||||||
|
f"/api/simulations/{sid}", headers=_h(token), json=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auto-transition pending → in_progress (AC-8.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_triggers_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"description": "some desc"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_name_triggers_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"name": "Updated name"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_commands_triggers_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"commands": "cmd1\ncmd2"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_null_value_does_not_trigger_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"description": None})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_empty_string_does_not_trigger_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"description": ""})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_admin_triggers_auto_transition(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, admin_token, sim["id"], {"execution_result": "success"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_soc_does_not_trigger_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
"""SOC patch on review_required must not trigger auto-transition."""
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
# Manually advance to review_required.
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/transition",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"to": "review_required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "looks good"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "review_required"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Field updates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_updates_commands_as_text(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
commands = "whoami\nnet user\nipconfig"
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"commands": commands})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["commands"] == commands
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_updates_executed_at(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(
|
||||||
|
client, redteam_token, sim["id"], {"executed_at": "2026-06-01T12:00:00"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "2026-06-01" in resp.get_json()["executed_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_invalid_executed_at(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(
|
||||||
|
client, redteam_token, sim["id"], {"executed_at": "not-a-date"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.get_json()["error"] == "invalid executed_at"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_clear_executed_at(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_patch(client, redteam_token, sim["id"], {"executed_at": "2026-06-01T12:00:00"})
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"executed_at": None})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["executed_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SOC RBAC field-level (AC-9.1, AC-9.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_soc_cannot_patch_before_review_required(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "hello"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert "not ready" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_soc_cannot_patch_in_progress(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_patch(client, redteam_token, sim["id"], {"description": "in progress now"})
|
||||||
|
|
||||||
|
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "hello"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_soc_can_patch_when_review_required(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/transition",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"to": "review_required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _patch(
|
||||||
|
client,
|
||||||
|
soc_token,
|
||||||
|
sim["id"],
|
||||||
|
{"soc_comment": "Detected", "log_source": "SIEM", "incident_number": "INC-001"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["soc_comment"] == "Detected"
|
||||||
|
assert body["log_source"] == "SIEM"
|
||||||
|
assert body["incident_number"] == "INC-001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_when_done_returns_409(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
"""Done is terminal — PATCH is rejected for ALL roles (AC-18.1)."""
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/transition",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"to": "review_required"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/transition",
|
||||||
|
headers=_h(soc_token),
|
||||||
|
json={"to": "done"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "Final note"})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert resp.get_json()["error"] == "simulation is done — reopen first"
|
||||||
|
|
||||||
|
|
||||||
|
def test_soc_cannot_edit_redteam_fields(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/transition",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"to": "review_required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _patch(client, soc_token, sim["id"], {"description": "redteam field"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.get_json()["error"] == "soc cannot edit redteam fields"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_simulation_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = _patch(client, redteam_token, 9999, {"name": "x"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_executed_at_does_not_mutate_other_fields(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
"""invalid executed_at must return 400 without persisting other fields in the payload."""
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
original_description = sim["description"]
|
||||||
|
|
||||||
|
resp = _patch(
|
||||||
|
client,
|
||||||
|
redteam_token,
|
||||||
|
sim["id"],
|
||||||
|
{"description": "should-not-stick", "executed_at": "not-a-date"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
get_resp = client.get(
|
||||||
|
f"/api/simulations/{sim['id']}",
|
||||||
|
headers={"Authorization": f"Bearer {redteam_token}"},
|
||||||
|
)
|
||||||
|
assert get_resp.status_code == 200
|
||||||
|
assert get_resp.get_json()["description"] == original_description
|
||||||
237
backend/tests/test_simulations_tactics.py
Normal file
237
backend/tests/test_simulations_tactics.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"""Sprint 4 — tactic_ids PATCH tests (AC-21)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
_FIXTURE_BUNDLE = {
|
||||||
|
"type": "bundle",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "Command and Scripting Interpreter",
|
||||||
|
"external_references": [{"source_name": "mitre-attack", "external_id": "T1059"}],
|
||||||
|
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_mitre():
|
||||||
|
original_loaded = mitre_svc.mitre_loaded
|
||||||
|
original_index = list(mitre_svc._index)
|
||||||
|
original_tactics = dict(mitre_svc._tactics_by_technique)
|
||||||
|
original_names = dict(mitre_svc._name_by_id)
|
||||||
|
original_matrix = list(mitre_svc._matrix)
|
||||||
|
yield
|
||||||
|
mitre_svc.mitre_loaded = original_loaded
|
||||||
|
mitre_svc._index = original_index
|
||||||
|
mitre_svc._tactics_by_technique = original_tactics
|
||||||
|
mitre_svc._name_by_id = original_names
|
||||||
|
mitre_svc._matrix = original_matrix
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def bundle_file(tmp_path: pathlib.Path) -> pathlib.Path:
|
||||||
|
import json
|
||||||
|
p = tmp_path / "enterprise-attack.json"
|
||||||
|
p.write_text(json.dumps(_FIXTURE_BUNDLE), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Eng", "start_date": "2026-01-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Sim"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(client: FlaskClient, token: str, sid: int, payload: dict):
|
||||||
|
return client.patch(
|
||||||
|
f"/api/simulations/{sid}",
|
||||||
|
headers=_h(token),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tactic_ids happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_tactic_ids_valid(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["tactics"] == [{"id": "TA0007", "name": "Discovery"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_tactic_ids_multiple(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0001", "TA0002"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tactics = resp.get_json()["tactics"]
|
||||||
|
ids = [t["id"] for t in tactics]
|
||||||
|
assert "TA0001" in ids
|
||||||
|
assert "TA0002" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_tactic_ids_empty_clears(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": []})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["tactics"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_tactic_ids_dedup(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007", "TA0007"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tactics = resp.get_json()["tactics"]
|
||||||
|
assert len(tactics) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tactic_ids error paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_tactic_ids_unknown_returns_400(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA9999"]})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "unknown tactic id" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_tactic_ids_not_a_list_returns_400(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": "TA0007"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SOC gate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_soc_cannot_patch_tactic_ids(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
# Advance to review_required so SOC can act.
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/transition",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"to": "review_required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _patch(client, soc_token, sim["id"], {"tactic_ids": ["TA0007"]})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auto-transition via tactic_ids
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_tactic_ids_triggers_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_empty_tactic_ids_no_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": []})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tactic_ids not affected by MITRE bundle loaded state
|
||||||
|
# (validation uses hardcoded _TACTIC_IDS, not the live bundle)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_tactic_ids_works_without_bundle(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
"""tactic_ids validation is hardcoded — bundle state is irrelevant."""
|
||||||
|
mitre_svc.mitre_loaded = False
|
||||||
|
mitre_svc._index = []
|
||||||
|
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_bundle_not_loaded_returns_503(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
"""technique_ids still needs the bundle (different from tactic_ids)."""
|
||||||
|
mitre_svc.mitre_loaded = False
|
||||||
|
mitre_svc._index = []
|
||||||
|
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]})
|
||||||
|
assert resp.status_code == 503
|
||||||
430
backend/tests/test_simulations_techniques.py
Normal file
430
backend/tests/test_simulations_techniques.py
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
"""Sprint 3 — multi-technique simulation tests (AC-13)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.services import mitre as mitre_svc
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Minimal STIX fixture (reused from test_mitre.py pattern)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FIXTURE_BUNDLE = {
|
||||||
|
"type": "bundle",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "Command and Scripting Interpreter",
|
||||||
|
"external_references": [{"source_name": "mitre-attack", "external_id": "T1059"}],
|
||||||
|
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "PowerShell",
|
||||||
|
"external_references": [{"source_name": "mitre-attack", "external_id": "T1059.001"}],
|
||||||
|
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "attack-pattern",
|
||||||
|
"name": "Valid Accounts",
|
||||||
|
"external_references": [{"source_name": "mitre-attack", "external_id": "T1078"}],
|
||||||
|
"kill_chain_phases": [
|
||||||
|
{"phase_name": "initial-access", "kill_chain_name": "mitre-attack"},
|
||||||
|
{"phase_name": "persistence", "kill_chain_name": "mitre-attack"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_mitre():
|
||||||
|
original_loaded = mitre_svc.mitre_loaded
|
||||||
|
original_index = list(mitre_svc._index)
|
||||||
|
original_tactics = dict(mitre_svc._tactics_by_technique)
|
||||||
|
original_names = dict(mitre_svc._name_by_id)
|
||||||
|
original_matrix = list(mitre_svc._matrix)
|
||||||
|
yield
|
||||||
|
mitre_svc.mitre_loaded = original_loaded
|
||||||
|
mitre_svc._index = original_index
|
||||||
|
mitre_svc._tactics_by_technique = original_tactics
|
||||||
|
mitre_svc._name_by_id = original_names
|
||||||
|
mitre_svc._matrix = original_matrix
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def bundle_file(tmp_path: pathlib.Path) -> pathlib.Path:
|
||||||
|
p = tmp_path / "enterprise-attack.json"
|
||||||
|
p.write_text(json.dumps(_FIXTURE_BUNDLE), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def loaded_bundle(bundle_file: pathlib.Path) -> pathlib.Path:
|
||||||
|
mitre_svc.load_bundle(bundle_file)
|
||||||
|
return bundle_file
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Sprint3", "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Technique Test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(client: FlaskClient, token: str, sid: int, payload: dict):
|
||||||
|
return client.patch(f"/api/simulations/{sid}", headers=_h(token), json=payload)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-13.1 — new simulation has techniques = []
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_simulation_has_empty_techniques(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
assert sim["techniques"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-13.3 — serializer enriches techniques with tactics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_techniques_enriched_with_tactics(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_patch(client, redteam_token, sim["id"], {"technique_ids": ["T1078"]})
|
||||||
|
|
||||||
|
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
techs = resp.get_json()["techniques"]
|
||||||
|
assert len(techs) == 1
|
||||||
|
assert techs[0]["id"] == "T1078"
|
||||||
|
assert "initial-access" in techs[0]["tactics"]
|
||||||
|
assert "persistence" in techs[0]["tactics"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_techniques_with_unknown_id_returns_empty_tactics(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
"""If a technique was removed from the bundle after save, tactics gracefully = []."""
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
# Bypass service, write directly an id not in the bundle.
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models.simulation import Simulation
|
||||||
|
|
||||||
|
with client.application.app_context():
|
||||||
|
s = db.session.get(Simulation, sim["id"])
|
||||||
|
s.techniques = [{"id": "T0000", "name": "Removed Technique"}]
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(redteam_token))
|
||||||
|
techs = resp.get_json()["techniques"]
|
||||||
|
assert techs[0]["tactics"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-13.4 — PATCH technique_ids
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_sets_techniques(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059", "T1078"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
techs = resp.get_json()["techniques"]
|
||||||
|
assert len(techs) == 2
|
||||||
|
ids = [t["id"] for t in techs]
|
||||||
|
assert "T1059" in ids
|
||||||
|
assert "T1078" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_resolves_name(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tech = resp.get_json()["techniques"][0]
|
||||||
|
assert tech["name"] == "Command and Scripting Interpreter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_unknown_returns_400(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T9999"]})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "unknown technique id: T9999" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_partial_unknown_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
# One valid, one unknown — whole request rejected.
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059", "T9999"]})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_includes_subtechnique(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059.001"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
techs = resp.get_json()["techniques"]
|
||||||
|
assert techs[0]["id"] == "T1059.001"
|
||||||
|
assert techs[0]["name"] == "PowerShell"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_replaces_list(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]})
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1078"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
ids = [t["id"] for t in resp.get_json()["techniques"]]
|
||||||
|
assert ids == ["T1078"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_empty_clears_list(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]})
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": []})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["techniques"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_not_list_returns_400(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": "T1059"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dedup (spec-reviewer note: AC-13.4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_deduplicates(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(
|
||||||
|
client, redteam_token, sim["id"], {"technique_ids": ["T1059", "T1078", "T1059"]}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
techs = resp.get_json()["techniques"]
|
||||||
|
assert len(techs) == 2
|
||||||
|
# Order preserved: T1059 first.
|
||||||
|
assert techs[0]["id"] == "T1059"
|
||||||
|
assert techs[1]["id"] == "T1078"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC-13.5 — auto-transition on technique_ids
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_technique_ids_non_empty_triggers_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def test_technique_ids_empty_does_not_trigger_auto_transition(
|
||||||
|
client: FlaskClient, redteam_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": []})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bundle not loaded — 503 on technique_ids PATCH
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_technique_ids_bundle_not_loaded_returns_503(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
"""When MITRE bundle is absent, PATCH with technique_ids must return 503."""
|
||||||
|
mitre_svc.mitre_loaded = False
|
||||||
|
mitre_svc._index = []
|
||||||
|
mitre_svc._name_by_id = {}
|
||||||
|
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert resp.get_json()["error"] == "mitre bundle not loaded"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SOC cannot patch technique_ids (it's a redteam field)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_soc_cannot_patch_technique_ids(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str, loaded_bundle
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
# Advance to review_required so SOC can touch the simulation at all.
|
||||||
|
client.post(
|
||||||
|
f"/api/simulations/{sim['id']}/transition",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"to": "review_required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _patch(client, soc_token, sim["id"], {"technique_ids": ["T1059"]})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Migration backfill test (inline, no Alembic runner needed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_backfill_logic() -> None:
|
||||||
|
"""Verify the backfill logic used in upgrade(): scalar → [{id, name}]."""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
def _backfill(tech_id, tech_name):
|
||||||
|
if tech_id:
|
||||||
|
return _json.loads(_json.dumps([{"id": tech_id, "name": tech_name or ""}]))
|
||||||
|
return []
|
||||||
|
|
||||||
|
assert _backfill("T1059", "Command and Scripting Interpreter") == [
|
||||||
|
{"id": "T1059", "name": "Command and Scripting Interpreter"}
|
||||||
|
]
|
||||||
|
assert _backfill(None, None) == []
|
||||||
|
assert _backfill("T1059", None) == [{"id": "T1059", "name": ""}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_0003_techniques_not_null_after_upgrade() -> None:
|
||||||
|
"""Run migration 0003 upgrade() against a real SQLite DB and assert techniques is NOT NULL."""
|
||||||
|
import importlib
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import sqlalchemy as _sa
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
|
||||||
|
engine = _sa.create_engine("sqlite:///:memory:")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# Create the pre-migration schema (0002 state).
|
||||||
|
conn.execute(_sa.text(
|
||||||
|
"CREATE TABLE simulations ("
|
||||||
|
" id INTEGER PRIMARY KEY,"
|
||||||
|
" mitre_technique_id VARCHAR(32),"
|
||||||
|
" mitre_technique_name VARCHAR(255)"
|
||||||
|
")"
|
||||||
|
))
|
||||||
|
conn.execute(_sa.text(
|
||||||
|
"INSERT INTO simulations (id, mitre_technique_id, mitre_technique_name)"
|
||||||
|
" VALUES (1, 'T1059', 'Command and Scripting Interpreter')"
|
||||||
|
))
|
||||||
|
conn.execute(_sa.text(
|
||||||
|
"INSERT INTO simulations (id, mitre_technique_id, mitre_technique_name)"
|
||||||
|
" VALUES (2, NULL, NULL)"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Run upgrade() via Alembic Operations context.
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn, opts={"as_sql": False})
|
||||||
|
ops = Operations(ctx)
|
||||||
|
|
||||||
|
# Patch the module-level proxy so the migration's op.* calls work.
|
||||||
|
import alembic.op as _op_module
|
||||||
|
_op_module._proxy = ops # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
_mig_path = (
|
||||||
|
pathlib.Path(__file__).parent.parent
|
||||||
|
/ "migrations" / "versions" / "0003_simulation_techniques_array.py"
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_file_location("mig_0003", _mig_path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
mig = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mig) # type: ignore[union-attr]
|
||||||
|
mig.upgrade()
|
||||||
|
|
||||||
|
# Verify schema: techniques column exists and is NOT NULL.
|
||||||
|
insp = _sa.inspect(engine)
|
||||||
|
cols = {c["name"]: c for c in insp.get_columns("simulations")}
|
||||||
|
assert "techniques" in cols, "techniques column must exist after upgrade"
|
||||||
|
assert cols["techniques"]["nullable"] is False, "techniques must be NOT NULL after upgrade"
|
||||||
|
assert "mitre_technique_id" not in cols
|
||||||
|
assert "mitre_technique_name" not in cols
|
||||||
|
|
||||||
|
# Verify data was backfilled correctly.
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = conn.execute(_sa.text("SELECT id, techniques FROM simulations ORDER BY id")).fetchall()
|
||||||
|
assert _json.loads(rows[0][1]) == [{"id": "T1059", "name": "Command and Scripting Interpreter"}]
|
||||||
|
assert _json.loads(rows[1][1]) == []
|
||||||
194
backend/tests/test_simulations_workflow.py
Normal file
194
backend/tests/test_simulations_workflow.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""Simulation workflow / state machine tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engagement(client: FlaskClient, token: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/engagements",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Op Gamma", "start_date": "2026-06-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/engagements/{eid}/simulations",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"name": "Workflow Sim"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def _transition(client: FlaskClient, token: str, sid: int, to: str):
|
||||||
|
return client.post(
|
||||||
|
f"/api/simulations/{sid}/transition",
|
||||||
|
headers=_h(token),
|
||||||
|
json={"to": to},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Valid transitions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_to_review_required_from_pending(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
assert sim["status"] == "pending"
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "review_required")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "review_required"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_to_review_required_from_in_progress(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
# Auto-advance to in_progress
|
||||||
|
client.patch(
|
||||||
|
f"/api/simulations/{sim['id']}",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"description": "started"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "review_required")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "review_required"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_to_done_by_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_transition(client, redteam_token, sim["id"], "review_required")
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "done")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_to_done_by_soc(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_transition(client, redteam_token, sim["id"], "review_required")
|
||||||
|
|
||||||
|
resp = _transition(client, soc_token, sim["id"], "done")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_to_done_by_admin(
|
||||||
|
client: FlaskClient, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, admin_token)
|
||||||
|
sim = _make_sim(client, admin_token, eng["id"])
|
||||||
|
_transition(client, admin_token, sim["id"], "review_required")
|
||||||
|
|
||||||
|
resp = _transition(client, admin_token, sim["id"], "done")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Invalid transitions (AC-11.3)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_done_from_pending_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "done")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_to_pending_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_transition(client, redteam_token, sim["id"], "review_required")
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "pending")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_to_in_progress_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "in_progress")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_unknown_status_rejected(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "nonexistent")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_review_required_from_done_is_reopen(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
"""done → review_required is the Reopen path, now allowed (AC-18.2)."""
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
_transition(client, redteam_token, sim["id"], "review_required")
|
||||||
|
_transition(client, redteam_token, sim["id"], "done")
|
||||||
|
|
||||||
|
resp = _transition(client, redteam_token, sim["id"], "review_required")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["status"] == "review_required"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RBAC by role
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_soc_cannot_transition_to_review_required(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _transition(client, soc_token, sim["id"], "review_required")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_soc_cannot_transition_to_done_from_pending(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_token: str
|
||||||
|
) -> None:
|
||||||
|
eng = _make_engagement(client, redteam_token)
|
||||||
|
sim = _make_sim(client, redteam_token, eng["id"])
|
||||||
|
|
||||||
|
resp = _transition(client, soc_token, sim["id"], "done")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_simulation_404(client: FlaskClient, redteam_token: str) -> None:
|
||||||
|
resp = _transition(client, redteam_token, 9999, "review_required")
|
||||||
|
assert resp.status_code == 404
|
||||||
201
backend/tests/test_users.py
Normal file
201
backend/tests/test_users.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""User management endpoint tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from backend.app.auth import hash_password
|
||||||
|
from backend.app.extensions import db
|
||||||
|
from backend.app.models import User, UserRole
|
||||||
|
from backend.tests.conftest import auth_headers as _h
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_admin_only(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.get("/api/users", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert isinstance(body, list)
|
||||||
|
assert any(u["username"] == "admin1" for u in body)
|
||||||
|
assert all("password_hash" not in u for u in body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_forbidden_for_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.get("/api/users", headers=_h(redteam_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_forbidden_for_soc(client: FlaskClient, soc_token: str) -> None:
|
||||||
|
resp = client.get("/api/users", headers=_h(soc_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_unauth(client: FlaskClient) -> None:
|
||||||
|
resp = client.get("/api/users")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_success(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"username": "newbie", "password": "longenough1", "role": "redteam"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["username"] == "newbie"
|
||||||
|
assert body["role"] == "redteam"
|
||||||
|
assert "password_hash" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_duplicate_username(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"username": "admin1", "password": "longenough1", "role": "redteam"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "exists" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_short_password(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"username": "short", "password": "abc", "role": "soc"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "8 characters" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_invalid_role(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"username": "x", "password": "longenough1", "role": "godmode"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_forbidden_for_non_admin(
|
||||||
|
client: FlaskClient, redteam_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users",
|
||||||
|
headers=_h(redteam_token),
|
||||||
|
json={"username": "x", "password": "longenough1", "role": "soc"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_change_role(
|
||||||
|
client: FlaskClient, admin_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{soc_user.id}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"role": "redteam"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["role"] == "redteam"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_change_password(
|
||||||
|
client: FlaskClient, admin_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{soc_user.id}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"password": "anotherone1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# New password should now allow login.
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login", json={"username": "soc1", "password": "anotherone1"}
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_short_password(
|
||||||
|
client: FlaskClient, admin_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{soc_user.id}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"password": "no"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_404(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/users/9999", headers=_h(admin_token), json={"role": "soc"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_forbidden_for_redteam(
|
||||||
|
client: FlaskClient, redteam_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{soc_user.id}", headers=_h(redteam_token), json={"role": "admin"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_success(
|
||||||
|
client: FlaskClient, admin_token: str, soc_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.delete(f"/api/users/{soc_user.id}", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_last_admin_blocked(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.delete(f"/api/users/{admin_user.id}", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert "last admin" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_admin_when_other_admin_exists(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
other = User(
|
||||||
|
username="admin2",
|
||||||
|
password_hash=hash_password("adminpass2"),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
db.session.add(other)
|
||||||
|
db.session.commit()
|
||||||
|
other_id = other.id
|
||||||
|
|
||||||
|
resp = client.delete(f"/api/users/{other_id}", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_demote_last_admin_blocked(
|
||||||
|
client: FlaskClient, admin_user: User, admin_token: str
|
||||||
|
) -> None:
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/users/{admin_user.id}",
|
||||||
|
headers=_h(admin_token),
|
||||||
|
json={"role": "redteam"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_404(client: FlaskClient, admin_token: str) -> None:
|
||||||
|
resp = client.delete("/api/users/9999", headers=_h(admin_token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_forbidden_for_soc(
|
||||||
|
client: FlaskClient, soc_token: str, redteam_user: User
|
||||||
|
) -> None:
|
||||||
|
resp = client.delete(f"/api/users/{redteam_user.id}", headers=_h(soc_token))
|
||||||
|
assert resp.status_code == 403
|
||||||
38
docker/Dockerfile
Normal file
38
docker/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Stage 1: build front
|
||||||
|
FROM node:20-alpine AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: python runtime
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libcairo2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangoft2-1.0-0 \
|
||||||
|
libharfbuzz0b \
|
||||||
|
libfontconfig1 \
|
||||||
|
shared-mime-info \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY backend/requirements.txt ./backend/
|
||||||
|
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||||
|
COPY backend/ ./backend/
|
||||||
|
COPY --from=frontend-build /app/frontend/dist ./backend/app/static
|
||||||
|
|
||||||
|
ENV FLASK_APP=backend.app:create_app
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
# Variables surchargeables au `docker run` :
|
||||||
|
ENV MIMIC_PORT=5000
|
||||||
|
ENV MIMIC_DB_PATH=/data/mimic.sqlite
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Entrypoint : applique les migrations Alembic puis lance Flask
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
4
docker/entrypoint.sh
Executable file
4
docker/entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
flask db upgrade
|
||||||
|
exec flask run --host=0.0.0.0 --port="${MIMIC_PORT:-5000}"
|
||||||
171
e2e/fixtures/api.ts
Normal file
171
e2e/fixtures/api.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Thin axios client used by tests to seed/teardown users and engagements
|
||||||
|
* without going through the UI. The bootstrap admin is created out-of-band
|
||||||
|
* (via `make create-admin`) and logs in once to provision per-suite users.
|
||||||
|
*/
|
||||||
|
import axios, { AxiosInstance, isAxiosError } from 'axios';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: 'admin' | 'redteam' | 'soc';
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Engagement {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string | null;
|
||||||
|
status: 'planned' | 'active' | 'closed';
|
||||||
|
created_at: string | null;
|
||||||
|
created_by: { id: number; username: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Role = User['role'];
|
||||||
|
|
||||||
|
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||||
|
const BOOTSTRAP_ADMIN_USER = process.env.MIMIC_BOOTSTRAP_USER ?? 'root';
|
||||||
|
const BOOTSTRAP_ADMIN_PASS = process.env.MIMIC_BOOTSTRAP_PASS ?? 'rootpass8';
|
||||||
|
|
||||||
|
export function makeClient(token?: string): AxiosInstance {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: `${BASE_URL}/api`,
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
validateStatus: () => true, // tests assert on status themselves
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ token: string; user: User }> {
|
||||||
|
const client = makeClient();
|
||||||
|
const r = await client.post('/auth/login', { username, password });
|
||||||
|
if (r.status !== 200) {
|
||||||
|
throw new Error(`login(${username}) failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||||
|
}
|
||||||
|
return { token: r.data.access_token as string, user: r.data.user as User };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminToken(): Promise<string> {
|
||||||
|
const { token } = await login(BOOTSTRAP_ADMIN_USER, BOOTSTRAP_ADMIN_PASS);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent helper: ensures a user with the given username/role exists and
|
||||||
|
* has the requested password. Returns the user record.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - try login: if it succeeds, we're done.
|
||||||
|
* - else: as admin, list users; if username found, PATCH password+role; else POST.
|
||||||
|
*/
|
||||||
|
export async function ensureUser(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
role: Role,
|
||||||
|
): Promise<User> {
|
||||||
|
try {
|
||||||
|
const { user } = await login(username, password);
|
||||||
|
if (user.role !== role) {
|
||||||
|
const admin = await adminToken();
|
||||||
|
const client = makeClient(admin);
|
||||||
|
const r = await client.patch(`/users/${user.id}`, { role });
|
||||||
|
if (r.status !== 200) throw new Error(`patch role: ${r.status}`);
|
||||||
|
return r.data as User;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
// fall through to admin path
|
||||||
|
}
|
||||||
|
const admin = await adminToken();
|
||||||
|
const client = makeClient(admin);
|
||||||
|
const list = await client.get('/users');
|
||||||
|
if (list.status !== 200) {
|
||||||
|
throw new Error(`list users failed: ${list.status} ${JSON.stringify(list.data)}`);
|
||||||
|
}
|
||||||
|
const existing = (list.data as User[]).find((u) => u.username === username);
|
||||||
|
if (existing) {
|
||||||
|
const r = await client.patch(`/users/${existing.id}`, { password, role });
|
||||||
|
if (r.status !== 200) {
|
||||||
|
throw new Error(`patch user failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||||
|
}
|
||||||
|
return r.data as User;
|
||||||
|
}
|
||||||
|
const r = await client.post('/users', { username, password, role });
|
||||||
|
if (r.status !== 201) {
|
||||||
|
throw new Error(`create user failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||||
|
}
|
||||||
|
return r.data as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserByUsername(token: string, username: string): Promise<void> {
|
||||||
|
const client = makeClient(token);
|
||||||
|
const list = await client.get('/users');
|
||||||
|
if (list.status !== 200) return;
|
||||||
|
const u = (list.data as User[]).find((x) => x.username === username);
|
||||||
|
if (!u) return;
|
||||||
|
await client.delete(`/users/${u.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEngagement(
|
||||||
|
token: string,
|
||||||
|
payload: Partial<Pick<Engagement, 'name' | 'description' | 'start_date' | 'end_date' | 'status'>>,
|
||||||
|
): Promise<Engagement> {
|
||||||
|
const client = makeClient(token);
|
||||||
|
const body = {
|
||||||
|
name: payload.name ?? 'Test Engagement',
|
||||||
|
description: payload.description,
|
||||||
|
start_date: payload.start_date ?? '2026-01-01',
|
||||||
|
end_date: payload.end_date,
|
||||||
|
status: payload.status ?? 'planned',
|
||||||
|
};
|
||||||
|
const r = await client.post('/engagements', body);
|
||||||
|
if (r.status !== 201) {
|
||||||
|
throw new Error(`create engagement failed: ${r.status} ${JSON.stringify(r.data)}`);
|
||||||
|
}
|
||||||
|
return r.data as Engagement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEngagement(token: string, id: number): Promise<void> {
|
||||||
|
const client = makeClient(token);
|
||||||
|
await client.delete(`/engagements/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEngagements(token: string): Promise<Engagement[]> {
|
||||||
|
const client = makeClient(token);
|
||||||
|
const r = await client.get('/engagements');
|
||||||
|
if (r.status !== 200) {
|
||||||
|
throw new Error(`list engagements failed: ${r.status}`);
|
||||||
|
}
|
||||||
|
return r.data as Engagement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllEngagements(token: string): Promise<void> {
|
||||||
|
const items = await listEngagements(token);
|
||||||
|
await Promise.all(items.map((e) => deleteEngagement(token, e.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForHealth(timeoutMs = 30_000): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
const client = makeClient();
|
||||||
|
let lastErr: unknown = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const r = await client.get('/health');
|
||||||
|
if (r.status === 200) return;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`backend not healthy after ${timeoutMs}ms: ${
|
||||||
|
isAxiosError(lastErr) ? lastErr.message : String(lastErr)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BASE = BASE_URL;
|
||||||
67
e2e/fixtures/auth.ts
Normal file
67
e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Playwright auth helpers: log in via the API once per role, hydrate
|
||||||
|
* localStorage so subsequent page.goto() lands inside the SPA already
|
||||||
|
* authenticated.
|
||||||
|
*
|
||||||
|
* Avoids the per-test cost of driving the LoginPage form just to land on
|
||||||
|
* /engagements. The actual UI login flow IS exercised in us2-login.spec.ts.
|
||||||
|
*/
|
||||||
|
import { type BrowserContext, type Page } from '@playwright/test';
|
||||||
|
import { BASE, login, type Role, type User } from './api';
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject token into localStorage so the SPA's bootstrap hook picks it up
|
||||||
|
* as if the user had already logged in. The frontend stores the JWT under
|
||||||
|
* `mimic.token` (see frontend/src/api/client.ts).
|
||||||
|
*/
|
||||||
|
export async function seedTokenInStorage(
|
||||||
|
context: BrowserContext,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await context.addInitScript((t) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('mimic.token', t);
|
||||||
|
} catch {
|
||||||
|
/* storage might not exist on about:blank — harmless */
|
||||||
|
}
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAuthStorage(context: BrowserContext): Promise<void> {
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem('mimic.token');
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log in as the given role and return both the API session and a helper
|
||||||
|
* that prepares a Page with the auth token already seeded.
|
||||||
|
*/
|
||||||
|
export async function loginAs(
|
||||||
|
context: BrowserContext,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<Session> {
|
||||||
|
const session = await login(username, password);
|
||||||
|
await seedTokenInStorage(context, session.token);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: navigate to a path on a page that's already had its
|
||||||
|
* context seeded with a token.
|
||||||
|
*/
|
||||||
|
export async function gotoApp(page: Page, path = '/engagements'): Promise<void> {
|
||||||
|
await page.goto(`${BASE}${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Role };
|
||||||
470
e2e/package-lock.json
generated
Normal file
470
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
{
|
||||||
|
"name": "mimic-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "mimic-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.48.0",
|
||||||
|
"@types/node": "^22.7.5",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||||
|
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||||
|
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.16.0",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user