ed70458d8fb429bf2d92798ef6e478824c96f1e9
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ed70458d8f |
feat(m7): per-test execution — red/blue zones, evidence pipeline, activity poll
DoD M7 (spec §F5 + §F6 + §F8 + tasks/todo.md M7) covered end-to-end:
Backend
- New migration `91a4e7c6d2f3` adds `mission_tests.last_actor_id` (FK users
ON DELETE SET NULL) and `ix_mission_tests_updated_at` for the polling query.
- `detection_levels`: 4 default rows seeded at boot, `GET /detection-levels`
read-only (CRUD lands in M8).
- `mission_tests` service + `missions` API extension:
- `GET /missions/{id}/tests/{test_id}` — full detail incl. evidence list
- `PUT /missions/{id}/tests/{test_id}` — patch red/blue fields with per-field
perm classification (`mission.write_red_fields` vs `mission.write_blue_fields`)
- `POST /missions/{id}/tests/{test_id}/transition` — pending↔skipped/blocked
and pending→executed→reviewed_by_blue (+ undo paths), side-aware perm gate
that fires *before* idempotency, `executed_at` auto-stamped on the way in
- `GET /missions/{id}/activity?since=<ISO>` — drives the 15 s polling badge
- `evidence` service + top-level `/evidence/<id>` API:
- Streaming upload, SHA256 chunk-by-chunk, 25 MB cap, ext+MIME whitelist
- Content-addressed storage at ${EVIDENCE_DIR}/<mission>/<test>/<sha256><ext>
- Atomic `os.replace`, hex-validated SHA path component, root-dir guard
- Membership-aware (404 on miss/forbidden, no existence leak)
- `/diag/reset` now wipes ${EVIDENCE_DIR}/* in test mode (symlink-safe) and
re-seeds detection levels as a safety net.
Frontend
- `lib/missions.ts` — M7 types + queryKey factory + state-machine matrix.
- `pages/MissionTestPage.tsx` — two-zone layout: red border (command, output,
comment, mark-executed + override toggle) and cyan border (detection-level
select, comment, drag-and-drop evidence dropzone). Last-touched badge polls
/activity every 15 s, gated on document.visibilityState. Per-field disable
based on the user's red/blue perms (server stays the arbiter).
- `pages/MissionDetailPage.tsx` — test rows link to the new per-test page.
- `App.tsx` — registers /missions/:id/tests/:testId behind RequireAuth.
- `HomePage.tsx` — hero + roadmap card bumped to M7; next is M8.
Tests
- `backend/tests/test_mission_tests.py` — 27 pytest tests (red/blue field
gating, state-machine matrix incl. idempotent-side enforcement, executed_at
override, 24/26 MB upload + SHA256, MIME/ext whitelist, soft-delete hide,
activity polling with URL-encoded `since`, membership 404 vs admin bypass,
cross-mission evidence access).
- `e2e/tests/m7-execution.spec.ts` — 5 Playwright tests against the live stack
(red-only/blue-only API gating, mark-executed + reviewed_by_blue side
enforcement, 24 MB/26 MB upload + SHA256 round-trip, SPA per-test page save
+ transition, non-member 404 message). afterAll restores stable admin and
re-syncs MITRE.
Docs
- CHANGELOG.md: M7 section + post-M7 review-pass subsection.
- README.md: status, feature blurb, roadmap, testing-m7 link.
- tasks/testing-m7.md: manual + automated procedure with transition matrix
and perm-gating table.
- tasks/lessons.md: M7 retrospectives (LogRecord `created` trap, URL-encoded
query timestamps, perm-before-flush, atomic move, polling visibility gate).
Test count: 133 pytest / 49 Playwright, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e1b51db25f |
fix(m6): post-review pass — cache prefix, snapshot lock, perm-before-parse, LIKE escape
Addresses spec-reviewer + code-reviewer feedback on the M6 bundle:
Critical:
- frontend/src/lib/missions.ts: add `listPrefix()` so TanStack invalidation
catches every filtered list variant; the previous `list()` returned
`['missions','list',{}]` and only matched the exact empty-filter cache,
leaving filtered tables stale after create/transition/delete.
- backend/app/services/missions.py: acquire the same per-scenario
`pg_advisory_xact_lock` key used by `set_scenario_tests` before
snapshotting; without it a concurrent M5 reorder could freeze a torn
snapshot under READ COMMITTED. Sorted by key to avoid deadlocks with
another snapshotter.
Important:
- backend/app/api/missions.py: `@require_perm("mission.update",
"mission.archive")` on the transition endpoint so users without either
perm get 403 before the body is parsed (no shape leak via 400).
- backend/app/services/missions.py: escape `%` / `_` / `\` in user-typed
`q` / `client` LIKE search; users can no longer trigger wildcard
semantics by typing literal `%`. Added `escape='\\'` arg on every .like().
- backend/app/services/missions.py: filter `MissionTest.deleted_at` and
`MissionScenario.deleted_at` in the list-item and detail counts so M7+
soft-deletes don't drift the totals silently.
Nits:
- backend/app/api/users.py: order `/users/roster` by email for stable
rendering + deterministic e2e selectors.
- frontend/src/pages/MissionDetailPage.tsx: distinct accent per
transition target (cyan/orange/green/teal) matching the status legend.
- e2e/tests/m6-missions.spec.ts: switch fragile `getByRole(name=/In
Progress/i)` to the stable `mission-transition-in_progress` data-testid.
New tests:
- test_create_mission_rejects_soft_deleted_scenario
- test_transition_perm_gate_runs_before_payload_parse
- test_search_treats_wildcards_as_literals
Suite: 106 pytest passing (was 103), 43 Playwright passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
00b7557e30 |
feat(m6): missions + snapshot CRUD, membership visibility, status state machine
Adds the mission layer that materialises template snapshots, plus the SPA list / 3-step wizard / detail page. Backend: - app/services/missions.py — create_mission snapshots scenarios, tests, MITRE tags in a 4-query write; list/get apply a non-admin membership filter that collapses to 404 (no existence leak); status state machine enforces draft → in_progress → completed → archived with archived as a sink; the non-admin creator is auto-added as role_hint='red' to retain visibility. - app/api/missions.py — 8 endpoints (list, get, create, update, add scenarios, set members, transition, soft-delete) with strict pydantic schemas. The transition endpoint splits the perm gate manually so archive requires mission.archive while other targets use mission.update. - app/api/users.py — new GET /users/roster returning (id, email, display_name) only, gated by user.read OR mission.create OR mission.update — lets non-admin wizard users see assignable peers without exposing the admin /users payload. - app/api/diag.py — /diag/reset truncates the mission_* tables before the template tables because the source_*_template_id FKs are ON DELETE SET NULL, which is cheaper to short-circuit by removing the children first. Frontend: - lib/missions.ts — typed client, queryKey factory, status accent map. - pages/MissionsListPage.tsx — list cards with status accent + filters (q, client, status). - pages/MissionsCreatePage.tsx — 3-step wizard (meta → scenarios → members) with member roster fed by /users/roster. - pages/MissionDetailPage.tsx — header + transition buttons (legal next states only) + Tests/Members/Synthesis/Export tabs. - Routes + nav entry (visible to anyone with mission.read or admin). Tests: - backend/tests/test_missions.py — 22 pytest covering snapshot fidelity, MITRE propagation, membership visibility, transition state machine, perm gating, member set replace, append scenarios, soft-delete, partial update, inverted-date rejection. - e2e/tests/m6-missions.spec.ts — 5 Playwright (snapshot freezing, non-admin visibility, status transitions + 409, SPA wizard end-to-end, list filter). Docs: - CHANGELOG, tasks/testing-m6.md, tasks/lessons.md (snapshot tradeoffs, membership=404 pattern, /diag/reset order, auto-creator add). - README + tasks/todo.md updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a7e5bc030f |
fix(m5): scenario reorder 500 — wrong pg_advisory_xact_lock overload
Editing a scenario and saving (with or without changes) returned 500: function pg_advisory_xact_lock(smallint, bigint) does not exist Postgres only ships (int4, int4) and (bigint) variants. The two-arg call passed `m = hash(uuid) & 0xFFFFFFFF` which can reach 2^32-1, so psycopg promoted it to bigint and no overload matched. Switched to the single-arg bigint form. While there, replaced Python's built-in hash() with hashlib.blake2b(...) — the built-in is randomised per process via PYTHONHASHSEED, so gunicorn workers were computing different lock keys for the same scenario and the lock wasn't actually serialising across workers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ce4bd40551 |
fix(m5): post-review pass — AND filter, advisory lock, N+1, item caps, mutation cache
Spec-reviewer + code-reviewer findings applied:
Must-fix
- Filter combinator AND-semantics: tactic+technique+subtechnique now intersect
(one IN subquery per facet) instead of being pooled into one OR. Reviewers
flagged both the wrong default semantics and the theoretical UUID-collision
risk of pooling tactic/technique/sub UUIDs into a shared list across
three columns.
- Front-end mutation cache hygiene: updateMeta + setTests both
`onSettled: invalidate` so a partial failure leaves the cache consistent.
Should-fix
- Per-scenario pg_advisory_xact_lock on set_scenario_tests — serialises
concurrent reorders, mirrors M4 /mitre/sync pattern.
- Backend/front consistency on duplicate tests in a scenario: the
UNIQUE(scenario_id, position) constraint already allows the same
test_template multiple times (chained ops), so the catalogue picker no
longer excludes already-picked items.
Nice-to-have
- N+1 eradicated in test_template view rendering: _to_views_batch
builds {uuid → MitreRow} maps in 3 queries up-front; list endpoint
now issues 4 queries total regardless of list size.
- Wire-level item length caps on tags (64) and expected_iocs (255)
via Annotated[str, StringConstraints(...)] — returns 400 instead of
bubbling up StringDataRightTruncation.
- 4 new pytest covering the AND-filter, extra="forbid" rejection,
empty mitre_tags clearing, and the 65-char tag cap. Total now
81 pytest + 38 e2e pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b8fd99a5f4 |
feat(m5): test_template + scenario_template CRUD with MITRE tags and ordered tests
- Service `app/services/test_templates.py`: CRUD with MITRE tag resolution (kind, external_id) → polymorphic join, filters by tactic/technique/ subtechnique/opsec/tag, `_UNSET` sentinel for partial-update semantics. - Service `app/services/scenario_templates.py`: ordered test list, reorder via full-replace (atomic w.r.t. UNIQUE(position) constraint), soft-delete. - REST endpoints on /api/v1/test-templates and /scenario-templates with pydantic schemas + perm gating (test_template.* and scenario_template.*). - /diag/reset truncates the 4 new tables before MITRE (FK ordering). - 19 pytest covering CRUD, MITRE tag merge, soft-delete chaining, perm enforcement, and reorder atomicity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
63b48addc0 |
fix(m4): code-review pass — SSRF allowlist + advisory lock + typed contract
Six post-code-review fixes, applied before opening the PR per project
workflow (spec-review + code-review both gate the merge):
1. SSRF allowlist on `/mitre/sync`. Host must be in MITRE_ALLOWED_HOSTS
(defaults to `raw.githubusercontent.com`, env-overridable). Closes "admin
holding `mitre.sync` pivots api container at 169.254.169.254 / internal
mirrors" via a typo'd URL. New `MitreSourceForbidden` → 400
`source_forbidden`; checked at the top of `_download()` so it kicks in
before any I/O.
2. `pg_advisory_xact_lock(hashtext('mitre.seed'))` at the top of the seed
transaction. Two concurrent `/mitre/sync` requests now serialise across
the DELETE+INSERT of `mitre_technique_tactics`; previously they could
both wipe the M2M and one would fail the unique constraint on re-insert.
3. Typed SyncResult contract. Pydantic `SyncResultOut` on the Flask side
`model_validate`s the dict before returning — single source of truth
for the response shape, mirrored by a `MitreSyncResult` TS interface
(next commit). The `as Record<string, unknown>` + `as { duration_ms }`
cast in MitrePage is gone.
4. N+1 in dotted sub-technique fallback removed. Built
`{external_id → technique_id}` once at function entry. Currently a
no-op against MITRE official (0 orphans), but a latent footgun for
partial / older bundles.
5. `SETTING_VERSION` cleared explicitly when `source != MITRE_DEFAULT_URL`.
Previously it kept the stale pin label, so `/mitre/status` lied after
a custom-URL re-sync.
6. `/mitre/sync` 500s no longer echo `str(e)` to the client — URLError /
psycopg / Pydantic text now lives in the JSON log only. Public response
stays `{"error": "internal_error"}`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ba976959a1 |
feat(m4): STIX parser + seed service + CLI
- backend/app/services/mitre_seed.py: stdlib-only STIX 2.1 parser (urllib + hashlib + json). Pinned to enterprise-attack-19.0.json with sha256 df520ea0775a57db7bff760145b02fed89290802913e056b7ed5970b02f3626a (~52 MB, ~1.1 s parse). Resolves sub-technique parents via relationship[subtechnique-of] with a T1003.001→T1003 dotted-id fallback; upserts on external_id, rebuilds the technique↔tactic M2M in a single transaction so external readers never see an empty join. Persists mitre_last_sync, mitre_version, mitre_source_url in the settings table. - Custom URLs MUST be paired with expected_sha256 OR allow_unverified=true — refuses silent integrity bypass. - CLI: flask metamorph seed-mitre [--source path|url] [--checksum-sha256 hex] [--skip-checksum]. Make target wraps it. - Docker: /data/mitre/ chowned to the metamorph user at build; named volume metamorph_mitre mounted from compose for cross-restart cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bb23bf3928 |
feat(m3): RBAC — atomic perms, groups, users, admin SPA pages
Permission catalogue (services/permissions_seed.py)
- 31 atomic codes across 10 families: user.*, group.*, invitation.*,
test_template.*, scenario_template.*, mission.* (incl.
mission.write_red_fields + mission.write_blue_fields),
detection_level.{read,update}, setting.{read,update}, mitre.sync.
- Default bindings: admin = all 31; redteam = 8 (catalogue read + mission.
{read,create,update,archive,write_red_fields} + detection_level.read);
blueteam = 5 (catalogue read + mission.{read,write_blue_fields} +
detection_level.read).
- Seed runs at boot AND after /setup so a freshly truncated DB (via
/diag/reset) gets the bindings back via the bootstrap path. Idempotent +
additive (never removes a perm from a system group).
Users admin (services/users.py + api/users.py)
- list (q + is_active filter + pagination), get, patch (display_name /
locale / is_active with tri-state sentinel for clear-vs-unset),
soft-delete, set groups.
- Last-admin protection on update (deactivate), delete, and group-strip
(refusing to remove the admin group from the last active admin).
Groups admin (services/groups.py + api/groups.py)
- Full CRUD with system-group protection (no rename, no delete on
admin/redteam/blueteam).
- PUT /groups/{id}/permissions sets the perm list.
- Admin system group's perm set is locked to the full catalogue
(SystemGroupProtected → 409) — preserves the bypass invariant even if a
future refactor moves to perm-based checks.
Permissions read-only (api/permissions.py)
- GET /permissions returns the catalogue (admin or group.read holders).
/diag/reset extension
- After truncate + token mint, the limiter is also reset (limiter.reset())
so the Playwright suite doesn't hit 10/min budgets across spec files.
Guarded by limiter.enabled to no-op in APP_ENV=test.
Rate-limit scope (core/rate_limit.py)
- enabled = APP_ENV in ("prod", "staging"). A staging deployment serves
humans, so it gets the limits too. Dev/test stay unthrottled for
Playwright ergonomics. Spec §6 NF-security is an operator-facing
requirement.
Frontend chrome
- components/RequireAdmin.tsx + ui/Modal.tsx (reusable centered dialog
with accessible name + Escape + backdrop-click).
- Layout.tsx shows Admin nav links only when is_admin === true. Server
remains the arbiter — non-admins hitting /admin/* get redirected to /.
Frontend pages
- pages/AdminUsersPage.tsx, AdminGroupsPage.tsx, AdminInvitationsPage.tsx
with edit modals using TanStack Query mutations + multi-select for perms
grouped by family + copy-once invitation URL display.
- lib/admin.ts: shared types + query keys + groupPermsByFamily helper.
- lib/api.ts: apiPatch / apiPut / apiDelete added.
Playwright config (e2e/playwright.config.ts)
- workers: 1 + fullyParallel: false: spec files share the live Postgres,
so concurrent /diag/reset calls clobber each other. Intra-file order
preserved via test.describe.configure({ mode: 'serial' }).
Testing
- backend/tests/test_rbac.py: 15 integration tests (39 backend total — 1
health + 8 schema + 15 auth + 15 RBAC).
- e2e/tests/m3-rbac.spec.ts: 8 Playwright tests covering DoD §10 #2/#3
(28 e2e total — 8 M0 + 4 M1 + 8 M2 + 8 M3).
- tasks/testing-m3.md.
DoD: make test-api → 39 passed, make e2e → 28 passed. Spec-reviewer pass
applied (admin perm invariant + staging rate-limit scope).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
700b563297 |
feat(m2): auth, JWT, invitations, bootstrap, RTOps SPA pages
Crypto + tokens
- app/core/security.py: Argon2id PasswordHasher (time_cost=2, memory_cost=
64 MiB, parallelism=2) + opaque-token SHA-256 helpers (raw token shown
once, only the hash lives in the DB).
- app/core/jwt_tokens.py: HS256, claims iss/sub/type/jti/iat/exp. Access
1h, refresh 30d.
Services
- services/auth.py: login, refresh with token rotation + reuse-detection
chain revoke, logout (idempotent), change_password (forces logout-all).
- services/invitations.py: create, preview, accept, revoke. Default 7d TTL.
- services/bootstrap.py: seeds the 3 system groups (admin/redteam/blueteam),
consumes the install token, attaches the first user to admin.
- core/install_token.py: mints, persists in settings, marks consumed,
regenerate hook for /diag/reset.
API
- POST /setup (consume install token, create 1st admin) + GET /setup
(status).
- POST /auth/{login,refresh,logout,change-password} + GET /auth/me.
- POST /invitations + GET /invitations + GET /invitations/preview/<token> +
POST /invitations/accept/<token> + POST /invitations/<id>/revoke.
- POST /diag/reset: test-only kill switch (truncate auth tables + mint
fresh install token). Allowed in dev too (with WARNING log) so the e2e
suite can run against a make-up stack; production locked out.
Middleware
- @require_auth populates g.current_user (snapshot dataclass, session
closed before request handler runs).
- @require_perm(*codes): atomic perm union check; admin group bypasses.
Perm catalogue lands in M3, scaffolding here.
- flask-limiter: 10/min/IP on /auth/login & /auth/refresh, 5/min on
/auth/change-password & /setup, 10–20/min on invitation endpoints.
Disabled in APP_ENV=test.
CLI
- flask --app app.cli metamorph print-install-token [--force]
- flask --app app.cli metamorph seed-mitre (M4 placeholder)
Refresh cookie metamorph_refresh: HttpOnly + Secure (localhost is a secure
context for modern browsers) + SameSite=Strict + Path=/api/v1/auth/.
Email validation: app.api._validation.Email permissive RFC-shape regex so
internal TLDs (.local/.corp/.test) are accepted — pydantic.EmailStr's
deliverability check is too strict for red-team labs.
Frontend
- lib/{api,auth}.ts: access token in module memory, refresh cookie,
automatic 401-retry via /auth/refresh, useAuth() hook.
- components/{Layout,RequireAuth}.tsx + ui/{TextField,Alert}.tsx.
- pages/{Login,Setup,Register,Profile}.
Testing
- tests/test_auth_flow.py: 15 integration tests (24 backend total).
- e2e/tests/m2-auth.spec.ts: 8 Playwright tests (20 e2e total).
- tasks/testing-m2.md.
DoD: make test-api → 24 passed, make e2e → 20 passed; spec-reviewer pass
applied (Secure unconditional, refresh limit 10/min/IP).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|