Compare commits

159 Commits

Author SHA1 Message Date
kitos 498536f3f1 fix(security): remediate CVE-2026-42043 — upgrade axios ^1.14.0
Aegis CI / lint-and-test (push) Has been cancelled
- package.json: bump axios constraint from ^1.13.5 to ^1.14.0
- Dockerfile build stage: npm ci -> npm install so the semver range
  in package.json is honoured at build time (npm ci uses the lockfile
  exactly, bypassing the updated constraint)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:17:45 +02:00
kitos bea5a8e781 fix(security): upgrade axios to >=1.14.0 — CVE-2026-42043 (CVSS 10)
Bumps minimum Axios version from 1.13.5 to 1.14.0 to remediate
CVE-2026-42043 identified by VMT / Wiz (asset: AegisTest).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:16:41 +02:00
kitos c62dafbc1f feat(campaigns): campaign start date — scheduled activation, Jira start_date
Aegis CI / lint-and-test (push) Has been cancelled
DB: migration b047 adds start_date (DateTime nullable) + index to campaigns.

Backend:
- Campaign model: start_date field
- CampaignCreate/Update schemas: accept start_date (ISO string)
- CRUD service: persist + serialize start_date in both serializers
- Activation endpoint: blocks manual activation if start_date is in the future
  (campaign will auto-activate via scheduler)
- Scheduler: new hourly job _run_scheduled_campaign_activation — finds draft
  campaigns with start_date <= now, activates them, creates Jira tickets,
  notifies red_tech team
- Jira: campaign + test tickets now include JIRA_START_DATE_FIELD (configurable,
  default customfield_10015). Campaign uses start_date if set, else created_at.
  Tests inherit campaign start_date.
- config.py: JIRA_START_DATE_FIELD setting

Frontend:
- Campaign type: start_date field on Campaign + CampaignSummary
- CampaignCreatePayload: start_date optional field
- Create form: date picker with min=today, warning message explaining behavior
- Campaign detail header: start_date badge showing days remaining or started date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:57:06 +02:00
kitos 3db9809be5 refactor(campaigns): move CampaignTimingPanel next to Progress panel
Aegis CI / lint-and-test (push) Has been cancelled
Progress and Timing now share a 2-column grid at the top of the detail page.
Removed CampaignTimingPanel from the bottom Jira section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:42:45 +02:00
kitos 7c6aaeda30 feat(compliance): add mapping confidence warnings for DORA, ISO 27001, ISO 42001
Aegis CI / lint-and-test (push) Has been cancelled
Amber banner for DORA and ISO 27001:2022 — community-based mapping, no official CTID source.
Orange banner for ISO 42001:2023 — experimental, MITRE ATT&CK has no AI-specific techniques yet.
Each notice explains the mapping source, limitations, and what executives should consider
before using the data in formal audits or regulatory submissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:37:25 +02:00
kitos 1dcff4ad20 feat(compliance): executive descriptions and mapping rationale for all 5 frameworks
Aegis CI / lint-and-test (push) Has been cancelled
Backend: expose description in control status response, add rich business-language
descriptions to all curated controls (ISO 27001, ISO 42001, CIS v8, DORA) explaining
requirements and ATT&CK mapping rationale. ISO 42001 includes infrastructure-mapping note.

Frontend: description field in type, info panel in ControlsTable expanded rows,
framework info banner with description and official standard link in CompliancePage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:28:16 +02:00
kitos 0b82d96bcc feat(compliance): add ISO/IEC 27001:2022 and ISO/IEC 42001:2023 frameworks
Aegis CI / lint-and-test (push) Has been cancelled
ISO 27001:2022: 37 Annex A controls across 4 themes (Organizational,
People, Physical, Technological) mapped to MITRE ATT&CK techniques.

ISO 42001:2023: 25 Annex A controls for AI Management Systems mapped to
relevant ATT&CK techniques covering AI supply chain, data pipeline
integrity, model serving security, and third-party AI risk.

Backend: import functions, _import_curated_framework() shared helper,
and POST /compliance/import/iso-27001 + iso-42001 endpoints.
Frontend: API client functions + import buttons in CompliancePage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:50:54 +02:00
kitos 460faf9935 feat(disputed): symmetric UX for both leads in disputed state
Aegis CI / lint-and-test (push) Has been cancelled
Lead who approved: Request Discussion button becomes Discussion Requested after sending.
Lead who rejected: new Change to Approved button to resolve conflict after offline discussion.
Both leads retain vote-change buttons. discussionSent state flag tracks send status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:09:52 +02:00
kitos 02ff89401c fix(disputed): add admin role + contact info in discussion modal
Aegis CI / lint-and-test (push) Has been cancelled
- request-discussion endpoint: add 'admin' to allowed roles
- Return rejector_email and rejector_role in the response
- Modal success state shows contact card with username, role, email link
  so the approving lead can immediately reach out to the rejecting lead
2026-06-03 13:02:57 +02:00
kitos 4e20bfa835 feat(disputed): Confirm My Validation button + discussion request modal
Aegis CI / lint-and-test (push) Has been cancelled
Backend: POST /tests/{id}/request-discussion
  - Only callable by the lead whose vote is 'approved' in a disputed test
  - Sends notification to the rejecting lead: 'Lead X confirms their
    approval and wants to discuss your rejection'
  - Logs the action in audit trail

Frontend:
- 'Confirm My Validation' button (amber outline) alongside 'Change to Rejected'
- Opens a modal showing:
    * Explanation: both leads must agree to finalise
    * Other lead's rejection reason/notes
    * What happens next (stays disputed, notification sent, either can change)
- 'Send Discussion Request' → calls the new endpoint → shows success state:
    'Lead username has been notified...'
- Instruction to reach out via team channels to resolve offline

Flow summary for disputed tests:
  Approving lead sees 2 options:
    a) 'Confirm My Validation' → modal → send request → other lead notified
    b) 'Change to Rejected' → validation modal → both agree to reject → rejected
2026-06-03 12:48:08 +02:00
kitos 46ff79e695 fix(disputed): add disputed to TestState in test_entity.py
Aegis CI / lint-and-test (push) Has been cancelled
test_entity.py has its own TestState enum separate from domain/enums.py.
Only domain/enums.py was updated, causing AttributeError when SQLAlchemy
tried to map 'disputed' from DB to the test_entity.TestState class.

Also adds disputed to VALID_TRANSITIONS so the entity can transition
into and out of the disputed state.
2026-06-03 12:36:21 +02:00
kitos 9f86c205be fix(ts): add disputed to all Record<TestState> maps to satisfy TypeScript
Aegis CI / lint-and-test (push) Has been cancelled
2026-06-03 12:23:53 +02:00
kitos 61e6037e97 feat(tests): disputed state + fix timestamps on reopen
Aegis CI / lint-and-test (push) Has been cancelled
1. New 'disputed' state — one lead approved, the other rejected:
   - Both approved → validated (unchanged)
   - Both rejected → rejected (unchanged)
   - One approves + one rejects → disputed (new)
   - DB: ALTER TYPE teststate ADD VALUE 'disputed'
   - Notification sent to the approving lead explaining the conflict
     with the rejection notes

2. Disputed UI in TestDetailHeader:
   - Amber banner showing conflict + rejection reason from notes
   - 'Change Vote to Rejected' button for the lead who approved
   - Validation indicators shown for disputed state too

3. Fix timestamps on reopen (rejected → draft):
   - Keep red_started_at, blue_started_at etc. as historical record
   - Only clear paused_at defensively
   - Timestamps naturally update when test is re-executed

4. disputed badge (amber) added to all badge color maps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:21:47 +02:00
kitos 2de95a3082 feat(tests): reopen rejected test keeps all content + rejection notes
Aegis CI / lint-and-test (push) Has been cancelled
Backend (reopen_test):
- Preserve red/blue validation NOTES — teams see exactly what to fix
  without losing the rejection context. Previously both notes were cleared.
- Preserve all content fields: procedure_text, tool_used, red_summary,
  attack_success, blue_summary, detection_result (already the case).
- Preserve evidences (separate table, unaffected — already the case).
- Still clear: validation statuses + who/when validated (fresh re-validation
  required). Phase timing reset so the new execution starts clean.

Frontend:
- Button label: 'Reopen Test' → 'Continue Test' (more accurate intent)
- Dialog title: 'Reopen Test' → 'Continue Test'
- Dialog message: replaces alarming 'workflow will be restarted / clear all'
  with accurate description of what is preserved vs reset
- Toast: explains what to do next
2026-06-03 11:31:37 +02:00
kitos 74ca8dc53a fix(TestsPage): move lastActivityDate outside component to fix TDZ error
Aegis CI / lint-and-test (push) Has been cancelled
useMemo executes its factory immediately on first render. lastActivityDate
was defined with const after the useMemo call inside the component, causing
a temporal dead zone: 'Cannot access v before initialization'.

Fix: move the function to module scope (before the component), where it
is fully initialized before any hook runs.
2026-06-03 11:26:00 +02:00
kitos ad5cd26363 fix(tests): replace updated_at (doesn't exist) with real timestamps
Aegis CI / lint-and-test (push) Has been cancelled
TestsPage 'Updated' column: compute lastActivityDate() from the most
recent available timestamp — blue_validated_at > red_validated_at >
blue_work_started_at > blue_started_at > red_started_at > created_at.
Also fixes the sort-by-updated_at case.

ValidatedTestsPage 'Validated' column: use blue_validated_at (when Blue
Lead approved) falling back to red_validated_at. Fixes both the display
and the default sort-by-validated.
2026-06-03 11:22:28 +02:00
kitos fc3b413a83 fix(tests): use blue_started_at for Waiting column (updated_at doesn't exist)
Aegis CI / lint-and-test (push) Has been cancelled
updated_at column does not exist in the tests table — it was always
undefined, so formatElapsed() always returned '—'.

Replace with blue_started_at (set when Red Team submits to Blue Team),
which correctly shows how long a test has been waiting for Blue Team
evaluation. Also fixed the waiting_time sort to use the same field.
2026-06-03 11:15:00 +02:00
kitos 9f1c4c28c9 feat(evidence): paste screenshot directly from clipboard (Ctrl+V)
Aegis CI / lint-and-test (push) Has been cancelled
- Global document paste listener captures image/* items from clipboard
- Auto-generates filename: screenshot-YYYY-MM-DDTHH-MM-SS.png
- Brief cyan pulse animation confirms the paste was detected
- Shows image preview before uploading (max-h 192px, object-contain)
- Drop zone hint now says 'Drag & drop, browse, or Ctrl+V to paste'
- Works with any source: OS screenshot (PrintScreen/Cmd+Shift+4),
  browser Inspect screenshots, any image copied to clipboard
2026-06-03 11:06:22 +02:00
kitos ea8c48755f fix(tooltip): clarify Blue Team Avg Time excludes queue wait time
Aegis CI / lint-and-test (push) Has been cancelled
2026-06-03 11:01:50 +02:00
kitos 5684484fdf fix(metrics): prevent 0.0 falsy bug for sub-hour timing values
Aegis CI / lint-and-test (push) Has been cancelled
Root cause: avg times were ~2-3 minutes (< 1h). round(0.033, 1) = 0.0
which is falsy in JS, so the frontend showed N/A instead of the value.

Fix (backend): _safe_stats() and team metrics now convert to minutes
when avg < 1 hour, adding a 'unit' field ('min' or 'hrs').

Fix (frontend): use != null instead of truthy check for avg_completion_hours,
MTTD, MTTR — correctly shows 0.0 and uses the unit field to show 'min' or 'hrs'.
2026-06-03 10:59:58 +02:00
kitos 06e8effaa4 fix(metrics): use direct timestamp fields instead of audit log lookups
Aegis CI / lint-and-test (push) Has been cancelled
MTTD: was querying AuditLog for action names that don't match actual
logged actions. Now uses red_started_at → blue_started_at directly
(both stored on the Test record). Net of red_paused_seconds.

MTTR: was searching for remediation_status=completed (no data). Redefined
as total pipeline time: red_started_at → blue_validated_at net of all
paused time. Only counts fully validated tests.

Red avg time: was using red_validated_at - created_at (created_at NULL
for many tests). Now uses blue_started_at - red_started_at net paused.

Blue avg time: was using blue_validated_at - red_validated_at (wrong
phase boundary). Now uses blue_work_started_at (or blue_started_at
fallback) → blue_validated_at net of blue_paused_seconds.
2026-06-03 10:40:05 +02:00
kitos 56d49f6de7 feat(exec-dashboard): move Red/Blue team stats above Top Threat Actors
Aegis CI / lint-and-test (push) Has been cancelled
2026-06-03 10:33:01 +02:00
kitos 688e843e03 feat(exec-dashboard): vertical bars for Coverage by Tactic in MITRE order
Aegis CI / lint-and-test (push) Has been cancelled
- Convert horizontal bar chart to vertical bars (columns)
- Sort all 14 MITRE ATT&CK tactics in official order:
  Reconnaissance → Resource Development → Initial Access → Execution →
  Persistence → Privilege Escalation → Defense Evasion → Credential Access →
  Discovery → Lateral Movement → Collection → C2 → Exfiltration → Impact
- Show ALL tactics (not a subset)
- Labels rotated -45° to fit all names
- Bars have rounded top corners; horizontal gridlines only
2026-06-03 10:13:09 +02:00
kitos e03a222ab0 fix(types): add conversion_rate fields to ValidationThroughput interface
Aegis CI / lint-and-test (push) Has been cancelled
2026-06-03 10:07:49 +02:00
kitos f53500bcb5 fix(exec-dashboard): replace time-dependent throughput with Pipeline Conversion %
Aegis CI / lint-and-test (push) Has been cancelled
'Validation Throughput (tests/week)' was time-dependent — director wanted
an activity-based metric instead.

New metric: Pipeline Conversion Rate
  formula: validated / (validated + rejected + in_review) × 100
  unit: %  (no time reference)
  meaning: 'of all tests that have entered validation, X% succeeded'
  trend: declining if in_review backlog > validated count,
         improving if conversion ≥ 80%, stable otherwise

Backend: calculate_validation_throughput() rewritten — same API key
(tests_per_week) kept for compatibility, new conversion_rate field added.
Frontend: label → 'Pipeline Conversion', unit → '%', tooltip updated.
2026-06-03 10:06:30 +02:00
kitos 9e36b683fa feat(exec-dashboard): split threat actors into exposure vs detection strength
Aegis CI / lint-and-test (push) Has been cancelled
Replace single list with two-column layout:
- LEFT '⚠ Highest Exposure': top 5 actors by uncovered technique count,
  red border, text explaining 'these attacks would go unnoticed today'
- RIGHT ' Strongest Detection': top 5 actors by coverage %, green border,
  text explaining 'Blue Team would likely detect an intrusion from these'

Shows both the risks (where to focus testing) and the strengths
(what's already well protected) to give executives a balanced view.
2026-06-03 10:01:22 +02:00
kitos b33562a34e feat: add tooltip to Overall Score gauge in Executive Dashboard
Aegis CI / lint-and-test (push) Has been cancelled
2026-06-03 09:57:46 +02:00
kitos 757d99d22a feat(dashboards): hover tooltips on all metric cards
Aegis CI / lint-and-test (push) Has been cancelled
New MetricTooltip component — a small ⓘ icon showing an executive-
friendly explanation panel on hover (CSS, no JS, instant).

DashboardPage: tooltips on all 6 coverage summary cards (Total
Techniques, Validated, Partial, In Progress, Not Covered, Not
Evaluated), Coverage Evolution chart, Test Pipeline funnel,
Team Activity and Validation Rate section headers.

ExecutiveDashboardPage: tooltips on all 4 sub-scores (Coverage,
Detection, Critical, Response), Score Trend, Top Threat Actors,
4 KPIs (MTTD, MTTR, Detection Efficacy, Validation Throughput),
Coverage by Tactic, Critical Gaps table, and all 6 team metrics
(Red/Blue Tests Done, Avg Time, Rejection).

Each tooltip explains what the metric measures, what a good/bad
value looks like, and what action to take — written for non-
technical executives.
2026-06-03 09:49:58 +02:00
kitos d896f2761d fix(tempo): enforce 1-min minimum and ceiling rounding for worklogs
Aegis CI / lint-and-test (push) Has been cancelled
Tempo rejects durations under 60 seconds ('Duration must be at least
one minute'). Now:
  - Always send ≥ 60 s (1 minute minimum)
  - Round UP to nearest whole minute (math.ceil)
  - 2 s → 60 s, 3m20s (200s) → 240 s, 5m00s (300s) → 300 s
2026-06-03 09:08:40 +02:00
kitos 2bbc65993c fix(tests): lock editing for operators until timer starts
Aegis CI / lint-and-test (push) Has been cancelled
red_tech can only edit procedure/tool/summary when the test is in
red_executing state (after pressing Start Execution). In draft state they
see a read-only view and an orange hint 'Press Start Execution to begin
editing — the timer must be running first.'

blue_tech can only edit when blue_work_started_at is set (after pressing
Start Evaluation). Before that they see an indigo hint 'Press Start
Evaluation to begin editing — pick up the test first.'

red_lead, blue_lead and admin are unaffected — they retain full edit
access in all applicable states including draft.
2026-06-03 08:14:02 +02:00
kitos 46722aec19 fix(auth): silent token refresh — active sessions no longer expire mid-use
Aegis CI / lint-and-test (push) Has been cancelled
Problem: 15-minute tokens with no refresh mechanism kicked users to login
even when actively using the app.

Fixes:
1. config.py: raise ACCESS_TOKEN_EXPIRE_MINUTES from 15 → 480 (8h).
   Reasonable for an enterprise internal tool; still configurable via env.

2. POST /auth/refresh: new endpoint that reads the current aegis_token
   cookie and issues a fresh token if the session is still valid. Returns
   the new token in the cookie + body (same shape as /auth/login).

3. frontend/api/client.ts: response interceptor now attempts a silent
   refresh on 401 before redirecting to login:
   - Calls POST /auth/refresh once per failed request
   - If refresh succeeds: retries the original request transparently
   - If refresh fails: redirects to /login as before
   - Deduplicates concurrent refresh attempts (refresh once, resolve all)
   - Never attempts refresh on /auth/refresh or /auth/login themselves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:54:15 +02:00
kitos eee0560aeb feat(admin): export/import configuration bundle for migration
Aegis CI / lint-and-test (push) Has been cancelled
Backend: GET/POST /api/v1/admin/export-config and /import-config
  Export includes (sensitive values redacted):
  - system_configs (email/jira settings)
  - webhook_configs (secrets redacted)
  - sso_configs (private key redacted)
  - scoring_config (weights)
  - test_templates (source=custom only)
  - users (no passwords/tokens, must_change_password=True on import)
  Import is idempotent — upsert by natural keys, safe to run multiple times.

Frontend: ExportImportSection in SystemPage (admin only)
  - 'Export Configuration' → downloads aegis-config-YYYY-MM-DD.json
  - 'Import Configuration' → file picker, sends JSON, shows summary
  - Visual checklist of what is/isn't included in the export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:49:51 +02:00
kitos 922fb251da fix(webhooks): auto-detect platform format for Teams/Slack/generic
Aegis CI / lint-and-test (push) Has been cancelled
Root cause: Microsoft Teams Incoming Webhooks require MessageCard JSON
format. The service was sending generic Aegis JSON which Teams rejected
with a 400, incrementing failure_count on every dispatch.

Fix: _send_webhook() now auto-detects the target from the URL:
  - webhook.office.com / teams.microsoft.com → Teams MessageCard
    (colored card with event title + key/value facts table)
  - hooks.slack.com → Slack attachments format
  - everything else → current generic Aegis JSON

Also resets failure_count=0 in production so the webhook starts fresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:35:35 +02:00
kitos b4a264f2bd feat(tests): require evidence upload before phase transitions
Aegis CI / lint-and-test (push) Has been cancelled
Backend:
- submit_red_evidence: raises InvalidOperationError if no Red Team
  evidence file has been uploaded for the test
- submit_blue_evidence: raises InvalidOperationError if no Blue Team
  evidence file has been uploaded

Frontend:
- 'Submit to Blue Team' button: disabled + '⚠ Upload evidence first'
  hint when test.red_evidences is empty
- 'Submit for Review' button: same for test.blue_evidences
- Native tooltip on disabled buttons explains the requirement
- Buttons re-enable automatically after the first file is uploaded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:27:15 +02:00
kitos 2b41b191bd feat(campaigns): campaign timing panel with Red/Blue aggregated metrics
Aegis CI / lint-and-test (push) Has been cancelled
Backend: GET /campaigns/{id}/timing-summary
  Aggregates timing across all campaign tests:
  - red_execution_secs: red_started_at → blue_started_at (minus paused)
  - blue_queue_secs:    blue_started_at → blue_work_started_at
  - blue_evaluation_secs: blue_work_started_at → validated (minus paused)
  - total_secs: sum of all three phases
  Returns totals + per-test breakdown sorted by total time desc.

Frontend: new CampaignTimingPanel component replaces WorklogTimeline
  - 4 summary cards: Red Execution / Blue Queue / Blue Evaluation / Total
  - Stacked horizontal bar showing time distribution
  - Per-test breakdown with individual mini-bars and phase durations
  - Shows 'No tests started yet' when no timing data available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:06:42 +02:00
kitos a518c06653 feat(threat-actors): hover tooltip on motivation badges
Aegis CI / lint-and-test (push) Has been cancelled
New MotivationBadge component with CSS tooltip showing:
- espionage: goal (intelligence theft), typical behavior, examples
- financial: goal (monetary), typical behavior, examples
- destruction: goal (disrupt/destroy infra), wiper/ICS attacks, examples
- hacktivism: goal (political/ideological), defacement/leaks, examples

Used in ThreatActorsPage (card list) and ThreatActorDetailPage (header).
2026-06-02 10:50:37 +02:00
kitos 61e705ece4 fix(status-badge): show tooltip below badge (not above) to prevent clipping
Aegis CI / lint-and-test (push) Has been cancelled
2026-06-02 10:45:11 +02:00
kitos 2bfcc7e58c feat(status-badge): CSS hover tooltip — replaces native title attribute
Aegis CI / lint-and-test (push) Has been cancelled
title= attribute tooltip is browser-native, tiny, and often invisible.
New StatusBadge component uses a Tailwind group-hover absolute panel
that appears immediately on hover with:
  - Clear heading per status
  - 'Meaning' and 'Action' lines
  - Arrow pointing to the badge
  - 200ms fade-in transition

Used in TechniquesPage (list table) and TechniqueDetailPage (header).
2026-06-02 10:42:13 +02:00
kitos 7e4a44bbde feat(techniques): status hover tooltips + min 2 tests for validated
Aegis CI / lint-and-test (push) Has been cancelled
1. Status logic (v3): require ≥2 validated tests with 'detected' result
   to reach 'validated' status. With only 1 validated+detected test the
   technique stays 'partial' (single test is insufficient evidence).
   Backfilled existing data: T1012 and T1059.001 downgraded to 'partial'.

2. Hover tooltips on status badges in TechniquesPage and TechniqueDetailPage:
   - validated: ≥2 tests executed and detected
   - partial: some tests done but incomplete coverage
   - in_progress: tests exist but none validated yet
   - not_covered: tests run but Blue Team didn't detect
   - not_evaluated: no tests created yet
   - review_required: recent update needs acknowledgment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:32:52 +02:00
kitos ba75baeb7d fix(exec-dashboard): sort Top Threat Actors by uncovered techniques
Aegis CI / lint-and-test (push) Has been cancelled
Previously: alphabetical order (first 5 actors from list_actors query).
Now: ranked by uncovered technique count = technique_count × (1 - coverage_pct/100).
Tiebreak: higher technique_count first (broader attack surface).

Fetches 100 actors, sorts client-side, shows top 5 with:
- Rank badge (1-5) colored red/orange/yellow/gray
- 'N uncovered / M techniques' subtitle instead of target sectors
- Coverage bar + percentage

This ensures the actors with the largest coverage gap appear first.
2026-06-02 10:19:57 +02:00
kitos 71141d9901 fix(api): add no-cache middleware to prevent Cloudflare from caching API responses
Aegis CI / lint-and-test (push) Has been cancelled
Root cause: Cloudflare CDN was caching empty/error API responses from
/api/v1/metrics/* endpoints during the backend startup window (502 errors).
Subsequent requests were served from Cloudflare edge cache, never reaching
nginx or the backend, so the dashboard always showed empty metrics data.

NoCacheAPIMiddleware adds Cache-Control: no-store + Pragma: no-cache to
all /api/ responses so Cloudflare and browsers never cache them.
2026-06-02 10:12:13 +02:00
kitos 646ac7146e fix(dashboard): force refetch on mount + refresh button for metric widgets
Aegis CI / lint-and-test (push) Has been cancelled
Root cause: after backend restart (502 errors on startup), metric queries
(pipeline, team, recent, validation) get cached in error state. When the
user stays on the dashboard, the component never remounts so queries don't
auto-retry.

Fixes:
1. refetchOnMount:'always' — queries ALWAYS refetch when component mounts,
   even if cached with error/stale data. Prevents stuck empty state.
2. gcTime:0 — error state is not cached; next mount starts a fresh query.
3. retry:3 — more retries before giving up (covers slow startup windows).
4. Refresh button in header — manually invalidates and refetches all 4
   metric queries with a single click. Spinner icon during refetch.
2026-06-02 09:48:59 +02:00
kitos 0d4c105aa3 fix(dashboard): fix empty widgets + NULL created_at on campaign tests
Aegis CI / lint-and-test (push) Has been cancelled
1. metrics_query_service: use NULLS LAST in get_recent_tests() so tests
   with actual dates always appear before NULL-dated ones.

2. campaign_service: set created_at=datetime.utcnow() when creating tests
   from campaigns (was missing, leaving 21 tests with NULL created_at).
   Fixed existing NULL values directly in production DB.

3. DashboardPage: add isError handling to all V2 metric widgets
   (pipeline, team activity, validation rate, recent tests).
   - Add retry:2 to all secondary metric queries so transient failures
     are retried before showing empty state.
   - Show 'Could not load X — refresh' instead of empty/misleading
     'No tests created yet' when a query actually fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 08:58:04 +02:00
kitos a566834e08 fix(branding): update logo reference in LoginPage
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-29 17:07:59 +02:00
kitos 51c506a86d feat(branding): replace logo with new Medusa shield emblem (PNG)
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-29 17:04:39 +02:00
kitos b98a539d93 fix(intel-scan): remove duplicate _entry_matches + replace dead NVD feed
Aegis CI / lint-and-test (push) Has been cancelled
1. Duplicate function definition: the old 2-param _entry_matches shadowed
   the new 3-param version — Python uses the last definition, so the call
   with 3 args threw TypeError. Removed the stale old definition.

2. NIST NVD deprecated their XML RSS feeds in 2023 — URL returns 404.
   Replaced with SecurityWeek RSS which is active and covers CVEs/threats.
2026-05-29 16:58:07 +02:00
kitos 65c34c3374 fix(rt-import): require Blue Lead validation before coverage counts
Aegis CI / lint-and-test (push) Has been cancelled
RT tests are created in 'in_review' state (not validated):
- red_validation_status = 'approved' (RT confirmed execution)
- blue_validation_status = null (pending Blue Lead review)
- detection_result is pre-filled from the import JSON

Blue Lead sees these in their normal validation queue and confirms
or rejects the detection result. Only after Blue Lead approval does
the technique coverage update to validated/not_covered/partial.

This gives Blue Lead oversight over RT findings rather than auto-
accepting external engagement results as ground truth.
2026-05-29 16:21:06 +02:00
kitos 2f1ef7545d feat(rt-import): import Red Team engagement results as validated tests
Aegis CI / lint-and-test (push) Has been cancelled
Backend — POST /tests/import-rt (red_lead + admin):
  Accepts engagement JSON with name/date/description/operator and
  a list of techniques each with mitre_id, result, attack_success,
  platform, notes. Creates one Test per technique directly in
  'validated' state (red + blue validation = approved) bypassing
  the normal workflow. Recalculates technique.status_global for
  all affected techniques. Returns created/skipped summary.

Frontend — /tests/import-rt (new dedicated page):
  - Format reference panel (collapsible) with field descriptions
  - Download template JSON button (generates a filled example)
  - Paste JSON textarea + file upload (.json)
  - Live validation + preview table showing what will be imported
  - Import button with spinner
  - Success / warning / error result display
  Accessible to admin and red_lead only.
  Added to sidebar under Tests > Import RT Results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:15:35 +02:00
kitos b39a4fec14 feat(intel): major intel scan improvements + Review Queue integration
Aegis CI / lint-and-test (push) Has been cancelled
Backend:
- intel_service: remove 50-technique limit (scan all techniques), improve
  pattern matching with word boundaries (\bT1059\b), raise min name length
  to 8 chars to reduce false positives, skip entries with empty titles
- technique_query_service: add intel_items to get_technique_detail() so
  the technique page now shows recent threat intel articles (last 20)
- New GET /intel/items endpoint with optional technique_id filter

Frontend:
- New api/intel.ts with listIntelItems()
- ReviewQueuePage: complete redesign
    * Expandable rows — click a technique to see its intel articles inline
    * IntelPanel component fetches articles per technique on expand
    * 'Create Template from Intel' button opens pre-filled modal:
      name (from article title), source_url (article link), technique_id
      User reads the article and fills the attack procedure
    * Updated explanation text: lists all 3 reasons a technique can be flagged
      (MITRE update / intel scan / new template or detection rule)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:04:30 +02:00
kitos 07c6164ceb fix(permissions): hide action buttons for unauthorized roles
Aegis CI / lint-and-test (push) Has been cancelled
TestCatalogPage: 'Use Template' button had no role check — any user
(including viewer/blue_tech/red_tech) could see and click it, which
would fail at the backend (POST /tests/from-template requires
red_lead|blue_lead). Added canUseTemplate check; button hidden for
viewer, blue_tech, red_tech.

TechniqueDetailPage: 'Run This Test' / 'Re-run' buttons in the
Available Templates section also had no role check. Added canRunTemplate
(same criteria: admin|red_lead|blue_lead). The 'View test' button for
active tests remains visible to everyone (read-only navigation).

Principle: if a user cannot perform the action, the button does not
appear — no permission error messages, just absence of the control.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:47:08 +02:00
kitos f590a00006 fix(permissions): hide non-actionable UI + fix viewer route access
Aegis CI / lint-and-test (push) Has been cancelled
1. /executive-dashboard: add 'viewer' to ProtectedRoute roles — sidebar
   showed the link to viewers but the route redirected them to /dashboard.
2. /comparison: same fix — viewer was in sidebar roles but not in route.
3. /techniques/review-queue: add ProtectedRoute (leads+admin) — the page
   had no route-level protection, any authenticated user could access it.
4. TechniqueDetailPage review banner: hide from users who can't act on it.
   Previously shown to everyone with a 'Leads only' badge; now only shown
   to canReview users (admin/red_lead/blue_lead). Non-leads don't need to
   see alerts about changes they cannot acknowledge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:25:36 +02:00
kitos 8a542f912d feat(threat-actors): infer motivation via curated map + description keywords
Aegis CI / lint-and-test (push) Has been cancelled
MITRE ATT&CK STIX data never includes primary_motivation on intrusion-set
objects. Motivation is now derived with a 3-tier fallback:
  1. Curated MITRE-ID override map (100+ known groups mapped by hand)
  2. STIX primary_motivation field (if MITRE ever adds it)
  3. Description keyword inference (financ/ransomware/espionage/
     nation-state/destructive/hacktivist patterns)

Re-running MITRE sync will now backfill motivation for existing actors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:13:05 +02:00
kitos e49eca0b24 fix(threat-actors): fix 500 on search + populate motivation from STIX
Aegis CI / lint-and-test (push) Has been cancelled
1. fix(search 500): func.cast(col, func.text()) is invalid SQLAlchemy —
   replaced with cast(col, Text) for both aliases and target_sectors
   JSONB columns. Generating correct CAST(col AS TEXT) SQL.

2. feat(motivation): extract primary_motivation and sophistication from
   STIX intrusion-set objects during MITRE sync. Added _normalize_motivation()
   to map STIX vocabulary → simplified frontend values (espionage / financial /
   destruction / hacktivism). Both create and update paths now set these fields.
   Run MITRE sync to backfill existing actors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:09:04 +02:00
kitos 7d856bef43 feat(compliance): add DORA (EU 2022/2554) framework with ATT&CK mappings
Aegis CI / lint-and-test (push) Has been cancelled
Implements the Digital Operational Resilience Act as a compliance framework
using the same pattern as CIS Controls v8 (hardcoded curated mappings,
no official STIX bundle exists for DORA).

22 controls across 5 chapters mapped to MITRE ATT&CK techniques:
  Ch. II  — ICT Risk Management (Art. 5–15): governance, identification,
            protection, detection, response, backup, threat intel
  Ch. III — Incident Management (Art. 17–19): classification, reporting
  Ch. IV  — Resilience Testing (Art. 24–27): general testing + TLPT
            (Art. 26 explicitly based on TIBER-EU/ATT&CK threat-led testing)
  Ch. V   — Third-Party Risk (Art. 28, 30, 42): supply chain, trusted rels.
  Ch. VI  — Information Sharing (Art. 45)

Technique mappings derived from ENISA DORA guidelines and TIBER-EU framework.
Import is triggered via POST /api/v1/compliance/import/dora (admin only).
Frontend: new 'DORA' button in the Compliance page import section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:52:51 +02:00
kitos 70b5c833d4 fix(tests): move showTemplateModal useState before early returns (React #310)
Aegis CI / lint-and-test (push) Has been cancelled
The useState hook was placed after the isLoading/error early returns,
violating the Rules of Hooks. First render hit the early return without
calling the hook; second render (after data loaded) called it, producing
'more hooks than previous render' — React error #310 and a white screen.

Moved const [showTemplateModal] to the state block at the top of the
component, alongside the other useState declarations.
2026-05-29 13:29:17 +02:00
kitos 6c8a1317fd fix(layout): add React error boundary to catch render crashes
Aegis CI / lint-and-test (push) Has been cancelled
Previously a JS rendering error produced a blank white screen with no
feedback. PageErrorBoundary now catches the error, shows the error
message + stack trace, and offers a reload button. This will surface
the exact crash message for the inaccessible test page.
2026-05-29 13:23:28 +02:00
kitos 9310652944 feat(tests): Save as Template button on test detail page
Aegis CI / lint-and-test (push) Has been cancelled
Adds a 'Save as Template' button in the Details sidebar (visible to
red_lead, blue_lead and admin only). Opens a modal pre-filled from
the test's own fields:

  test.name           → template name
  test.description    → description
  test.platform       → platform
  test.procedure_text → attack_procedure
  test.tool_used      → tool_suggested
  test.technique_mitre_id → mitre_technique_id

User can also set severity and write expected_detection (Blue Team
guidance — not stored on tests). Calls POST /test-templates with
source='custom' on submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:57:29 +02:00
kitos 193c48d031 feat(sidebar): add Techniques page to menu under ATT&CK group
Aegis CI / lint-and-test (push) Has been cancelled
/techniques (technique browser with filters) was an orphaned route —
only reachable via 'Back to techniques' button or direct URL.
Now exposed in the sidebar as part of a new ATT&CK group:
  ATT&CK ▾
    Techniques       → /techniques
    Coverage Matrix  → /matrix
    Review Queue     → /techniques/review-queue (leads+admin only)

Child role filtering added to SidebarLink.
Review Queue badge moved to the ATT&CK group header.
2026-05-29 12:45:59 +02:00
kitos 416b31a5b6 fix(d3fend): add items-start to grid so cards don't stretch to row height
Aegis CI / lint-and-test (push) Has been cancelled
CSS grid was stretching both cells in a row to the same height as the
tallest card, making the unexpanded card appear 'open' with blank space.
items-start makes each card only as tall as its own content.
2026-05-29 12:16:56 +02:00
kitos 843b545df3 fix(d3fend): use d3fend_id as expand key instead of def.id
Aegis CI / lint-and-test (push) Has been cancelled
def.id was undefined for D3FEND items, causing expandedId===undefined
to match ALL items simultaneously and opening every card at once.
d3fend_id (e.g. 'D3-DSDP') is always present and unique per defense.
2026-05-29 11:58:28 +02:00
kitos 2238ca671b fix(jira): show test Jira tickets on technique page (correct entity model)
Aegis CI / lint-and-test (push) Has been cancelled
Techniques don't have their own Jira tickets — tickets exist on tests
and campaigns. The previous JiraLinkPanel entityType='technique' always
returned empty.

Backend: add entity_ids (list) filter to GET /jira/links so multiple
  test IDs can be fetched in a single request.
Frontend API: listJiraLinks() accepts entity_ids[] and serialises them
  as repeated query params (required by FastAPI List[UUID] parsing).
TechniqueDetailPage: replace JiraLinkPanel with TechniqueJiraSection —
  a dedicated read-only component that:
  - Takes technique.tests (already loaded)
  - Batch-fetches all test Jira links in one request
  - Shows test name + ticket key + status + priority + open-in-Jira link
  - Hides itself when no tickets exist (avoids empty panel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:48:55 +02:00
kitos e9aa473a6b fix(techniques): add readOnly to JiraLinkPanel on technique detail page
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-29 11:42:08 +02:00
kitos bd0493aade fix(ui): make all Jira and time panels read-only everywhere
Aegis CI / lint-and-test (push) Has been cancelled
WorklogTimeline: add readOnly prop — hides 'Log Time' button and form.
TestPhaseTimeline: remove 'Sync to Tempo' button from TempoSyncBadge;
  only displays the green 'Tempo' badge when already synced. Cleans up
  unused imports (useState, useMutation, useQueryClient, syncTestToTempo).
CampaignDetailPage: JiraLinkPanel and WorklogTimeline both now rendered
  with readOnly=true; JiraLinkPanel receives campaign name as label.

Jira tickets and time worklogs are created automatically by the system
(campaign activation, test workflow) — no manual editing from detail pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:33:55 +02:00
kitos d7d11dfdf5 feat(review-queue): trigger review_required on new test templates
Aegis CI / lint-and-test (push) Has been cancelled
Extends the review queue triggers to cover test template imports:
- atomic_import_service: flags techniques when new Atomic Red Team
  templates are imported
- caldera_import_service: same for Caldera templates
- lolbas_import_service: same for LOLBAS templates
- test_templates router (manual creation): flags the technique when
  an admin/lead creates a custom template via the API

Pattern is identical to the Sigma/Elastic detection rule approach:
collect new mitre_ids during the loop, bulk-update after commit.
Manual creation does a single technique lookup and sets the flag
inside the existing UnitOfWork.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:26:09 +02:00
kitos 1b513b050e fix: 4 improvements — campaign test deletion, review queue triggers, technique link, Jira read-only
Aegis CI / lint-and-test (push) Has been cancelled
1. Campaign test deletion: removing a test from a campaign now also
   deletes the underlying Test record and recalculates technique status.

2. Review Queue triggers: review_required=True is now also set when
   - Sigma/Elastic detection rules are imported for a technique
   - A test is validated (coverage status changes)

3. Test detail — Technique link: 'Technique' entry added at the top of
   the Details sidebar showing MITRE ID + name as a clickable link to
   /techniques/{mitre_id}.

4. Jira panel — read-only on test page: added readOnly + label props to
   JiraLinkPanel. TestDetailPage now passes readOnly=true and the test
   name as label, hiding Link Issue / Sync / Unlink controls (automatic
   Jira creation only — no manual management).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:18:55 +02:00
kitos 727b8af7fd feat(techniques): show test status on template cards
Aegis CI / lint-and-test (push) Has been cancelled
Each template card in 'Available Test Templates' now shows contextual
status derived from technique.tests (already loaded):

- Active test (draft/executing/evaluating/in_review):
    blue 'Executing / In Review' badge + 'View test →' button
    (prevents blind duplicate creation)
- Validated / detected (fresh):
    green 'Detected' badge + dimmed 'Re-run' button
- Validated / not_detected or partial:
    red/yellow result badge + full 'Run This Test' button (re-run encouraged)
- Validated but stale (review_required=true):
    result badge + '⚠ Coverage may be stale' line
- No tests: normal 'Run This Test' button

No extra API calls — status is derived from the technique detail
already in-memory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:59:39 +02:00
kitos c467459b51 fix(campaigns): filter existing-test picker to draft + not in any campaign
Aegis CI / lint-and-test (push) Has been cancelled
Backend: add not_in_any_campaign filter to list_tests (subquery on
CampaignTest) and expose it as a query param on GET /tests.
Frontend: the 'Existing Test' tab now requests only
  state=draft & not_in_any_campaign=true
so tests already linked to any campaign or not in draft state
are never shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:55:02 +02:00
kitos b19ecc0d5f feat(campaigns): prefix test names with [Campaign] on add
Aegis CI / lint-and-test (push) Has been cancelled
- From template: name is pre-filled as '[Campaign] {template.name}'
  (user can edit before confirming).
- Existing test: renamed via PATCH /tests/{id} to prepend '[Campaign] '
  before being linked to the campaign, consistent with the APT-generated
  campaign flow.
  Idempotent — skips rename if the name already starts with '[Campaign]'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:19:07 +02:00
kitos 2910aea6b2 feat(campaigns): add 'From Template' tab in Add Test modal
Aegis CI / lint-and-test (push) Has been cancelled
The modal now has two tabs:
- 'From Template' (default): searchable/filterable template catalog
  → select template → customise name/platform/procedure/tool
  → 'Create & Add to Campaign' (two-step: POST /tests/from-template
    then POST /campaigns/{id}/tests)
- 'Existing Test': previous behaviour — add an already-created test

Both tabs share an added-count footer badge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:10:03 +02:00
kitos 20075305a5 feat(review-queue): MITRE update review queue for leads
Aegis CI / lint-and-test (push) Has been cancelled
- New /techniques/review-queue page: lists all techniques flagged for
  review after a MITRE ATT&CK sync, grouped by tactic. Leads and admins
  can mark each one reviewed inline without leaving the page.
- Sidebar: 'Review Queue' link (admin/red_lead/blue_lead only) with an
  amber badge showing the live pending count.
- TechniqueDetailPage: amber banner when review_required=true explaining
  what happened and who can act; 'Mark as Reviewed' button now amber
  coloured for visual distinction. 'Leads only' chip shown for blue_tech.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 08:58:32 +02:00
kitos 4881825fea fix(techniques): remove broken validate/reject buttons from associated tests
Aegis CI / lint-and-test (push) Has been cancelled
The tick/cross buttons navigated to /tests/:id/validate and /tests/:id/reject
which are non-existent routes (catch-all redirected to dashboard).
Removed both buttons; the View (FileText) icon is the correct entry point
to the test detail page where the full workflow lives.
2026-05-29 08:54:31 +02:00
kitos de093778f6 feat(markdown): extract MITRE citations into collapsible sources section
Aegis CI / lint-and-test (push) Has been cancelled
(Citation: ...) patterns are stripped from body text, replaced with
Unicode superscript numbers (¹²³), and shown in a compact "Sources"
section below — collapsed when there are more than 3, expanded otherwise.
Deduplication ensures the same citation reference appears only once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 08:44:52 +02:00
kitos 34340a67eb fix(frontend): align react-markdown version to ^10.1.0 to match lock file
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-29 08:40:22 +02:00
kitos db208b9f5c feat(frontend): render markdown in description and summary fields
Aegis CI / lint-and-test (push) Has been cancelled
- New shared MarkdownText component (react-markdown + remark-gfm)
  that renders links, bold, italic, lists, code, blockquotes.
  External links open in a new tab with rel=noopener.
- Applied to: technique description, threat actor description,
  test description, campaign description, detection rule descriptions,
  D3FEND defense descriptions, red/blue summaries and validation notes.
- procedure_text (code/commands) stays in <pre> — not processed as MD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 08:38:53 +02:00
kitos a8542512b4 fix(ui+backend): sidebar active state + technique status after test deletion
Aegis CI / lint-and-test (push) Has been cancelled
- Sidebar: add `end` prop to child NavLinks so "All Tests" (/tests) is
  only highlighted when exactly on /tests, not on /tests/validated.
- Backend: recalculate technique status_global for all affected techniques
  when tests are deleted via delete_campaign(delete_tests=True), preventing
  stale coverage metrics on the dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:55:04 +02:00
kitos 1120d8f2ce feat(tests): add Validated Tests as dedicated page, remove duplicate sidebar entry
Aegis CI / lint-and-test (push) Has been cancelled
- New /tests/validated page with its own route and sidebar link, showing
  only validated tests with Attack and Detection result badges.
- Removed the duplicate "My Pending Tasks" sidebar entry (same as All Tests).
- All Tests table no longer shows validated tests; clicking the Validated
  counter card navigates to the new page instead.
- Validated option removed from the state filter dropdown in All Tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:18:21 +02:00
kitos 2eed763f9e feat(tests): separate validated tests section + waiting time column
Aegis CI / lint-and-test (push) Has been cancelled
- Validated tests no longer appear in the active tests table; they are
  shown in a dedicated collapsible "Validated Tests" section at the bottom
  (with its own sortable table and count badge).
- Added "Waiting" column to the main table showing elapsed time since
  last update for blue_evaluating tests, sortable so Blue Team can
  prioritise the oldest pending evaluations.
- Sorting by Waiting pushes blue_evaluating rows to the top and orders
  them oldest-first by default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:07:16 +02:00
kitos 2865846db2 fix(auth): prevent reuse of current password on first-access change
Aegis CI / lint-and-test (push) Has been cancelled
When must_change_password is true the user must pick a genuinely new
password. Added a verify_password check against the existing hash before
accepting the new value, raising BusinessRuleViolation if they match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:56:47 +02:00
kitos 8b035b5c5c fix(compliance): fix broken table layout and expand caused by nested tbody elements
Aegis CI / lint-and-test (push) Has been cancelled
Rewrote ControlsTable with React fragments instead of nested <tbody> tags,
added ScoreBar component, improved status badges, filter header strip,
and grid layout for expanded technique cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:45:47 +02:00
kitos b248c2816e fix(tests): apply user edits when creating test from template
Aegis CI / lint-and-test (push) Has been cancelled
The form captured name/description/platform/procedure/tool edits but
never sent them — the created test always used the raw template values.

- TestTemplateInstantiate schema: add optional override fields
  (name, description, platform, procedure_text, tool_used)
- create_test_from_template service: accept *_override kwargs;
  use override value when provided, fall back to template value
- Router: pass all override fields from payload to service
- Frontend API createTestFromTemplate: accept overrides object, spread into body
- TestFromTemplateForm: pass all form state values as overrides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:38:40 +02:00
kitos fa8e7f311b feat(techniques): show detection rules on technique detail page
Aegis CI / lint-and-test (push) Has been cancelled
Backend:
- technique_query_service.get_technique_detail() now queries DetectionRule
  by mitre_technique_id == mitre_id (same field the heatmap uses)
- Rules sorted: critical → high → medium → low → informational, then alphabetically
- Returns: id, title, description, source, source_url, rule_format,
  severity, platforms, false_positive_rate

Frontend:
- New DetectionRulesSection component with expandable rows per rule
- Color-coded severity dots and badges (red/orange/yellow/blue/gray)
- Source badges (sigma=purple, elastic=blue, splunk=orange, custom=cyan)
- Shows format, false positive rate, platforms, source link on expand
- Empty state when no rules exist

Fixes: T1189 showed green in heatmap but no rules on detail page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:26:46 +02:00
kitos 2371318e9e fix(heatmap): detection rules layer uses absolute rule count, not relative max
Aegis CI / lint-and-test (push) Has been cancelled
Before: score = (rules/max_rules)*50 + (evaluated/rules)*50
  -> everything red because relative to the 1 technique with most rules

After: score = min(rules/4 * 100, 100)  — absolute thresholds
  0 rules  = gray  (not covered)
  1 rule   = red   (25 — minimal)
  2 rules  = orange (50 — some)
  3 rules  = yellow (75 — good)
  4+ rules = green  (100 — well covered)

Also update HeatmapLegend labels to show actual rule counts instead of
meaningless percentage ranges.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:11:29 +02:00
kitos 8024f32954 feat(dashboard): auto-compute risk scores + refresh button on Critical Gaps
Aegis CI / lint-and-test (push) Has been cancelled
- Auto-trigger POST /risk/compute on first load if no profiles exist
- Add "Refresh scores" button next to Critical Gaps header (spins while computing)
- Add computeRiskScores() to frontend/src/api/risk.ts
- After compute, invalidate risk-profiles query so table updates immediately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:58:49 +02:00
kitos 45b13bccde feat(dashboard): sort Critical Gaps by risk score instead of MITRE ID
Aegis CI / lint-and-test (push) Has been cancelled
- Create frontend/src/api/risk.ts with getRiskProfiles() API function
- Executive Dashboard fetches risk profiles and builds a techniqueId→profile map
- Critical Gaps sorted by risk_score DESC (highest risk shown first)
- Ties resolved: not_covered before not_evaluated; unscored techniques last
- Table now shows Risk Score (0-100, color-coded) and Risk Level badge per row
- Column renamed to "Critical Gaps — Top 10 by Risk Priority"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:42:52 +02:00
kitos 2e5b47a4a2 feat(campaigns): delete campaign button + defer Jira to Activate
Aegis CI / lint-and-test (push) Has been cancelled
- Backend: add DELETE /campaigns/{id}?delete_tests=bool endpoint
- Backend: add delete_campaign() service — handles draft-only restriction,
  optional test deletion, nullifies child campaign FKs
- Backend: remove early Jira ticket creation from POST /campaigns,
  POST /campaigns/{id}/tests, and POST /campaigns/from-threat-actor
- Backend: activate endpoint now creates campaign Jira ticket if missing,
  then creates test tickets (all deferred from creation to activation)
- Frontend: add deleteCampaign() API function to campaigns.ts
- Frontend: two-step confirmation dialog on CampaignDetailPage —
  first confirms deletion, then asks whether to also delete associated tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:36:25 +02:00
kitos 664210be3d fix(types): add tempo_worklog_id to Worklog interface
Aegis CI / lint-and-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:10:58 +02:00
kitos d3baa9c032 feat(tests): remove Time Log, move Tempo sync to Phase Timeline
Aegis CI / lint-and-test (push) Has been cancelled
- Remove WorklogTimeline (manual time log) from test detail page
- TestPhaseTimeline now accepts testId, fetches its own worklogs,
  and shows Tempo sync status on the Red Team Execution row:
    • green badge if already synced (with worklog ID tooltip)
    • 'Sync to Tempo' button (blue) if not yet synced
- Add POST /tests/{id}/sync-tempo backend endpoint for manual sync:
  finds unsynced red_team_execution worklogs and pushes them to Tempo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:09:16 +02:00
kitos 986e91a88a feat(evidence): inline preview for images and text/JSON files
Aegis CI / lint-and-test (push) Has been cancelled
Adds a View button (eye icon) on each evidence card for previewable file
types. Opens a full-screen modal:
- Images (png/jpg/gif/webp/svg/…): rendered directly via <img> tag
- JSON: fetched authenticated, pretty-printed in green mono
- Text/log/md/csv/xml/yaml/…: fetched authenticated, shown in <pre>

Non-previewable files only show the Download button as before.
Modal closes on Escape or backdrop click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:49:35 +02:00
kitos cf5332f522 fix(evidence): use @model_validator(mode='before') so evidences appear in API responses
Aegis CI / lint-and-test (push) Has been cancelled
FastAPI 0.136.1 + Pydantic 2.13.4 serialises responses via TypeAdapter which
calls the compiled Rust validator directly, bypassing any Python-level
`model_validate` classmethod override. The @model_validator(mode='before')
decorator IS invoked by the Rust pipeline, so the evidence red/blue split and
technique field population now run on every serialisation path.

Also eager-load technique in get_test_detail to avoid lazy-load surprises.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:37:18 +02:00
kitos 2ee74bf6c9 fix(tempo): fix EU base URL, trailing space in account ID, and tempo_synced tracking
Aegis CI / lint-and-test (push) Has been cancelled
Root causes found for Tempo worklogs never reaching Tempo:
1. Wrong API region: workspace is on api.eu.tempo.io/4 but code used api.tempo.io/4
   → Tempo returned "User is invalid" (400) for all POST /worklogs
2. Trailing space in jira_account_id stored in DB (now stripped with .strip())
3. tempo_synced field was never updated even on success (now set from Tempo response)

Fix: add tempo.base_url system_config key (admin-configurable without redeploy),
fall back to TEMPO_BASE_URL env-var, then global default. DB already updated with
https://api.eu.tempo.io/4 for this workspace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:48:22 +02:00
kitos 0830b36cd6 fix(schemas): avoid lazy-load in TestOut.model_validate
Aegis CI / lint-and-test (push) Has been cancelled
Accessing obj.evidences on a session-expired ORM object (mutation endpoints
do commit+refresh without joinload) triggers a lazy query that fails or
returns stale data. Use obj.__dict__.get('evidences') instead — SQLAlchemy
stores joinloaded relationships in __dict__; absent means not loaded.

Mutation endpoints (submit-red, submit-blue, etc.) return empty evidence
lists, which is fine: the frontend invalidates and refetches GET /tests/{id},
which uses joinedload and correctly populates red_evidences / blue_evidences.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:06:34 +02:00
kitos e623a0887d fix(tempo,evidence): fix SystemExit crash + evidence not shown in frontend
Aegis CI / lint-and-test (push) Has been cancelled
tempo: tempoapiclient raises SystemExit (BaseException) on API errors like
'User is invalid' 400 responses; except Exception never catches it, killing
the uvicorn worker and causing a 500. Wrap create_worklog() to intercept
BaseException and re-raise as RuntimeError so callers can catch it safely.

evidence: TestOut schema was missing red_evidences / blue_evidences fields.
The ORM model has evidences loaded via joinedload but they were never
serialized into the API response. Add both fields to TestOut and override
model_validate to split Test.evidences by team, injecting the backend-proxy
download_url for each one (/api/v1/evidence/{id}/file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:57:52 +02:00
kitos 0955f35015 fix(tempo,jira,tests,ui): fix 4 pending issues
Aegis CI / lint-and-test (push) Has been cancelled
- tempo: remove unsupported `workType` kwarg from create_worklog call;
  tempoapiclient v4 does not accept it → was causing every Tempo sync to fail
- tests: set created_at=datetime.utcnow() explicitly on test creation (both
  create_test and create_test_from_template) since the DB column has no
  server default, causing 'Created —' in the UI
- jira: remove duplicate Proof of Concept section from ticket description body;
  PoC already lives in customfield_10309, no need to repeat it in description
- ui: add TestPhaseTimeline component (read-only) showing RT execution time,
  blue queue time, blue evaluation time and lead validation timestamps derived
  from test phase timestamps; placed above WorklogTimeline in test detail page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:38:29 +02:00
kitos 7111debd8f fix(evidence): proxy download + fix Jira attachment signature
Aegis CI / lint-and-test (push) Has been cancelled
Evidence download:
- Replace presigned MinIO URLs with backend proxy endpoint
  GET /api/v1/evidence/{id}/file streams the file through the backend
  so MinIO never needs to be publicly accessible from browsers
- Add download_file() helper to storage.py (internal boto3 get_object)
- download_url in EvidenceOut now points to the proxy endpoint

Jira attachment:
- Fix add_attachment call: use add_attachment_object(issue_key, BytesIO)
  instead of add_attachment(issue_key, filename=..., content=...) which
  had wrong keyword args for the installed atlassian-python-api version

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:26:01 +02:00
kitos c886b6e8bb fix(jira,evidence,tempo,settings): 4-issue fix batch
Aegis CI / lint-and-test (push) Has been cancelled
Jira — PoC custom field:
- Add customfield_10309 (Proof of Concept) to issue fields when creating
  test tickets so the attack procedure appears in the dedicated Jira field

Tempo — blue team exclusion:
- Remove blue_team_evaluation from _TEMPO_ACTIVITY_TYPES; blue team time
  is tracked internally (worklogs) for SLA but never sent to Tempo since
  blue team has no Jira access

Evidence — uploaded_at NULL fix:
- Set uploaded_at=datetime.utcnow() explicitly in upload_evidence router;
  the DB column has no server default so it was saving as NULL

Evidence — presigned URL browser access:
- Add MINIO_PUBLIC_ENDPOINT setting (config.py, docker-compose.prod.yml)
- storage.py uses a dedicated _public_client for presigned URL generation
  so browsers receive URLs with the publicly accessible hostname instead of
  the internal Docker service name (minio:9000)
- Expose MinIO port 9000 in docker-compose.prod.yml

Evidence — Jira attachment:
- After upload to MinIO, call jira.add_attachment() to attach the file to
  the linked Jira ticket (non-fatal; errors are logged and swallowed)

Settings — hide Jira/Tempo from blue team:
- ProfileSection checks user role; blue_lead and blue_tech do not see the
  Jira Integration or Tempo Integration personal settings sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:06:31 +02:00
kitos d8a0b0c449 fix(jira): correct ticket hierarchy — campaigns=Epic, all tests=Task
Aegis CI / lint-and-test (push) Has been cancelled
- Campaign issue type changed from Task to Epic (required to nest under
  Initiative OFS-20795 in classic Jira)
- Added customfield_10011 (Epic Name) — required when creating Epics
- Removed JIRA_ISSUE_TYPE_SUBTASK; all tests are now Task regardless of
  whether they are inside a campaign or standalone
- Standalone tests use the configured standalone parent (OFS-20798, an
  Epic) so Task→Task parent is never attempted
- Campaign tests use the campaign Epic key passed via parent_ticket_override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:29:50 +02:00
kitos 27184627f8 fix(jira): standalone tests as Sub-task under OFS-20798
Aegis CI / lint-and-test (push) Has been cancelled
OFS-20798 is a Task (child of OFS-20795 Epic), so tests nested
under it must be Sub-tasks, not Tasks — Task cannot parent Task.

Logic:
- parent_ticket_override (campaign) → Sub-task (unchanged)
- standalone_parent configured and differs from general parent → Sub-task
- only general parent (Epic) → Task

This fixes 'Please select valid parent issue' for standalone tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:19:01 +02:00
kitos 323964ed9d fix(settings): rename Campaign Parent Ticket label to Parent Ticket
Aegis CI / lint-and-test (push) Has been cancelled
The field is the general parent (e.g. OFS-20795) under which campaigns
are created directly. 'Campaign Parent Ticket' was misleading.
Standalone Tests Parent Ticket remains separate (e.g. OFS-20798).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:14:48 +02:00
kitos eeee17d260 feat(jira+tests): 5 improvements from review
Aegis CI / lint-and-test (push) Has been cancelled
1. Jira status → In Progress on Start Execution
   - push_test_event calls set_issue_status("In Progress") when
     new_state == "red_executing" (non-fatal, separate try/except)

2. Jira assignee set on Start Execution
   - assign_issue() called with actor.jira_account_id when operator
     clicks Start (non-fatal)

3. Standalone tests parent ticket (OFS-20798)
   - New jira.parent_ticket_standalone config key
   - get_jira_parent_ticket_standalone() falls back to parent_ticket
   - auto_create_test_issue uses standalone parent for non-campaign tests
   - Exposed in /system/jira-config GET+PATCH and SettingsPage UI

4. Tests table: Created + Updated columns
   - Add Created column (created_at), fix Updated to show updated_at
   - Both use UTC-aware date parsing (append Z if no tz suffix)
   - updated_at added to Test TypeScript interface

5. Sortable columns in tests table
   - All 7 columns sortable: Name, Technique, State, Current Team,
     Platform, Created, Updated
   - Click to sort asc, click again to reverse; ChevronUp/Down indicator
   - Default sort: Created desc (newest first)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:07:46 +02:00
kitos 43c8b241dc fix(timer): treat backend timestamps as UTC to fix 2h offset
Aegis CI / lint-and-test (push) Has been cancelled
Backend returns naive UTC datetimes without 'Z' suffix. JavaScript
new Date("2026-05-27T09:29:18") parses as local time (UTC+2 in Spain),
making the timer start at 02:00:06 instead of 00:00:00.

Fix: append 'Z' to any timestamp string that lacks timezone info before
passing it to new Date(), so the browser always interprets it as UTC.
Applied to both startedAt and pausedAt in LiveTimer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:58:29 +02:00
kitos 398e279116 feat(tempo): blue team Tempo time from pick-up, not queue entry
Aegis CI / lint-and-test (push) Has been cancelled
Previously blue_started_at was set when the RED team submitted evidence
(= queue open time), so Tempo was getting total queue wait time instead
of actual work time.

Changes:
- DB: add blue_work_started_at column (migration b045), set when a blue
  tech explicitly picks up the test (mirrors red_started_at for red team)
- Workflow: new start_blue_work() function + POST /tests/{id}/start-blue-work
  endpoint (blue_tech / blue_lead roles). Cannot be called twice.
- submit_blue_evidence: uses blue_work_started_at (when available) as the
  phase start for the Tempo worklog, falls back to blue_started_at
- reopen_test: clears blue_work_started_at alongside other timing fields
- Tempo: both red_team_execution and blue_team_evaluation now synced;
  correct work_date and description per activity type
- Frontend: "Start Evaluation" button shown in blue_evaluating state when
  blue_work_started_at is null; live timer shows from pick-up time

What each timestamp tracks:
  blue_started_at      = queue entry (SLA / internal tracking)
  blue_work_started_at = pick-up by blue tech (Tempo start)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:50:15 +02:00
kitos 0e6cec4d07 fix(tempo): only log red team execution time, use pre-computed duration
Aegis CI / lint-and-test (push) Has been cancelled
Two bugs fixed:

1. Blue team evaluation was also sent to Tempo. Only operator (red team)
   execution time should be logged — blue team time is tracked internally
   in Aegis but does NOT represent billable operator work. Added a
   whitelist (_TEMPO_ACTIVITY_TYPES = {"red_team_execution"}).

2. _calculate_duration() re-computed duration from red_started_at to
   datetime.utcnow() at call time, without subtracting paused seconds.
   This caused inflated times (e.g. 45 min instead of 5 min) when there
   was any delay between the workflow transition and the Tempo call.
   Now the duration_seconds already computed by _create_phase_worklog
   (gross elapsed - paused) is passed directly to auto_log_test_worklog
   and used as-is, so Aegis and Tempo always agree on the duration.

Also: use red_started_at as the worklog date (not submission timestamp)
so the Tempo entry reflects when the work actually happened.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:38:44 +02:00
kitos 44ef4129a5 fix(tempo): use search_worklogs(authorIds) in test endpoint
Aegis CI / lint-and-test (push) Has been cancelled
get_worklogs_by_account_id does not exist in tempoapiclient v4.
The correct method is search_worklogs(dateFrom, dateTo, authorIds=[...]).
Also improve error messages: 401 points to where to get the token,
404 tells the user the Account ID may be wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:25:15 +02:00
kitos bd0586d296 fix(jira): campaign=Task, campaign tests=Sub-task, standalone tests=Task
Aegis CI / lint-and-test (push) Has been cancelled
Root cause: Jira rejects Task-under-Task nesting ("Please select valid
parent issue"). Campaign tickets and test tickets were both created as
Task, so nesting test under campaign failed for all 62 APT32 tests.

Fix:
- JIRA_ISSUE_TYPE_CAMPAIGN: "Epic" -> "Task" (was unused, now used)
- JIRA_ISSUE_TYPE_SUBTASK: "Sub-task" (new config key)
- auto_create_campaign_issue: uses JIRA_ISSUE_TYPE_CAMPAIGN (Task)
- auto_create_test_issue: uses Sub-task when parent_ticket_override is
  set (campaign context), Task otherwise (standalone)

Hierarchy: OFS-9107 -> Campaign (Task) -> Test (Sub-task)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:10:03 +02:00
kitos 84a6590e17 fix(jira): create test tickets under campaign on activation
Aegis CI / lint-and-test (push) Has been cancelled
When a campaign is activated (Start), iterate all its tests and create
Jira tickets nested under the campaign ticket for any test that doesn't
already have one. Mirrors the pattern used in generate_campaign_from_actor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:53:39 +02:00
kitos 69d92f500a feat(tempo): per-user Tempo API token — same pattern as Jira token
Aegis CI / lint-and-test (push) Has been cancelled
Each user can now store their own personal Tempo API token in their
profile settings. Time is logged using each user's own credentials.

Backend:
- Migration b044: adds tempo_api_token column to users table
- User model: adds tempo_api_token column
- UserPreferencesUpdate: adds tempo_api_token field (write-only)
- UserOut: adds tempo_api_token (excluded) + tempo_token_set bool;
  @model_validator derives both jira_token_set and tempo_token_set
- users router: handles tempo_api_token same as jira_api_token
  (empty string clears it, never returned in responses)
- tempo_service: refactored to per-user token; has_tempo_configured(),
  get_user_tempo_client(user) use user.tempo_api_token; global
  TEMPO_ENABLED still acts as kill-switch
- system router: /system/tempo-test now uses current user's personal
  token (any role); removed global TEMPO_API_TOKEN dependency

Frontend:
- settings.ts: UserPreferencesUpdate.tempo_api_token, UserMeOut.tempo_token_set
- SettingsPage ProfileSection: Tempo Integration section with password
  field, show/hide toggle, configured badge, and Test Tempo button —
  mirrors the Jira token UX exactly
- JiraConfigSection: removed stale global Tempo test block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:46:38 +02:00
kitos 2337abe55e fix(jira): correct browse URL, rename Procedure to Proof of Concept; feat(tempo): debug endpoint + UI
Aegis CI / lint-and-test (push) Has been cancelled
Jira URL fix:
- JiraLinkPanel now fetches the configured Jira base URL via getJiraConfig()
  instead of hardcoding https://jira.atlassian.com; falls back to the old
  value if config is not yet loaded

Description fix:
- _build_test_description: renamed 'h3. Procedure' -> 'h3. Proof of Concept'
  so the procedure/tool block maps to the correct Jira field label

Tempo debug:
- New POST /system/tempo-test endpoint: checks TEMPO_ENABLED, token,
  user jira_account_id, and makes a real API call; always returns HTTP 200
  with status field (Cloudflare-safe)
- docker-compose.prod.yml: added TEMPO_ENABLED, TEMPO_API_TOKEN,
  TEMPO_DEFAULT_WORK_TYPE env vars (default off, ready to enable)
- SettingsPage: added 'Test Tempo Connection' button in Jira admin tab
  with clear feedback showing what's missing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:33:57 +02:00
kitos 4a64ac1c8b fix(jira): remove priority field from issue creation — OFS project has non-standard priorities
Aegis CI / lint-and-test (push) Has been cancelled
The OFS Jira project does not have the default Jira priority scheme
(Highest/High/Medium/Low/Lowest), causing a 'priority selected is invalid'
error on every ticket creation. Removing the priority field lets Jira use
the project default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:18:16 +02:00
kitos f17f0a8c10 feat(jira): implement full ticket hierarchy for campaigns and tests
Aegis CI / lint-and-test (push) Has been cancelled
Jira tickets now follow the correct hierarchy:
  OFS-9107 (system parent)
  ├── Standalone test ticket  (unchanged — was already working)
  └── Campaign ticket         (NEW — created on campaign creation)
      ├── Test 1 ticket       (NEW — created per test)
      └── Test 2 ticket       (NEW — created per test)

Changes:
- jira_service: add auto_create_campaign_issue() — creates campaign
  ticket as child of OFS-9107; stores JiraLink(entity_type=campaign)
- jira_service: add get_campaign_jira_key() / get_test_jira_key()
  helpers to look up existing Jira links by entity
- jira_service: auto_create_test_issue() gains parent_ticket_override
  param — when set, uses it as parent instead of OFS-9107
- campaigns router/create_campaign: triggers auto_create_campaign_issue
  after commit
- campaigns router/from-threat-actor: triggers campaign ticket then
  iterates campaign_tests and creates each test ticket under it
- campaigns router/add_test_to_campaign: if campaign has a Jira ticket
  and the test has none yet, creates test ticket under campaign ticket

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:13:09 +02:00
kitos 5f6a098e6b fix(jira): fallback connected_as to auth email, improve 401 error detail
Aegis CI / lint-and-test (push) Has been cancelled
- jira-test: when myself() returns empty displayName/emailAddress/name,
  fall back to the configured Atlassian auth email so 'Connected as:' is
  never empty
- jira-test: 401 error message now includes which email was used, making
  misconfigured Jira email easier to diagnose
- jira-test: missing jira_url now returns HTTP 200 {status: error} instead
  of HTTP 400, consistent with Cloudflare-safe pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:04:51 +02:00
kitos a04d5308ab fix(jira): always return HTTP 200 from jira-test + strip trailing slash
Aegis CI / lint-and-test (push) Has been cancelled
- jira-test now returns {status: "ok"|"error", message: ...} with
  HTTP 200 so Cloudflare never intercepts the response
- jira_service strips trailing slash from URL before creating Jira
  client (avoids double-slash in REST paths)
- Frontend reads data.status field instead of HTTP status code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:42:12 +02:00
kitos 48a936d426 fix(jira): use model_validator(after) for jira_token_set + timeout on test
Aegis CI / lint-and-test (push) Has been cancelled
FastAPI uses __pydantic_validator__.validate_python() which bypasses
model_validate() overrides. Switch to @model_validator(mode='after')
which the Pydantic Rust core always calls, so jira_token_set is now
correctly derived from the excluded jira_api_token field.

Also add a 10s timeout to the jira-test endpoint and better error
messages (the Atlassian library's "Expecting value" JSON error was
ambiguous).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:36:35 +02:00
kitos 513a7b488b fix(settings): update cache immediately on save instead of invalidating
Aegis CI / lint-and-test (push) Has been cancelled
Using setQueryData with the PATCH response means jira_token_set is
reflected in the UI instantly — no extra GET round-trip that could
leave the badge stale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:20:40 +02:00
kitos fd4a625760 fix(settings): use useEffect for jira field init, fix token save UX
Aegis CI / lint-and-test (push) Has been cancelled
Replace render-body setState with useEffect so field initialisation
is idiomatic React and never races with user input. Also clarifies
placeholder text: empty token field = keep current, not clear it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:04:22 +02:00
kitos 217c4c88b2 feat(jira): add editable jira_email field per user
Aegis CI / lint-and-test (push) Has been cancelled
Users can now set a separate Atlassian email for Jira authentication
in Settings → Profile → Jira Integration. Falls back to the Aegis
account email when not set, so existing setups are unaffected.

- Migration b043: adds jira_email column to users table
- User model/schema: expose jira_email read/write
- jira_service: _effective_jira_email() uses jira_email ?? email
- Frontend: replaces read-only email display with editable input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:40:46 +02:00
kitos f316a249cc feat(settings): Jira config UI — admin config tab + per-user token in Profile
Aegis CI / lint-and-test (push) Has been cancelled
- backend: add parent_ticket field to JiraConfigOut/JiraConfigUpdate/_JIRA_KEYS
- backend: add get_jira_parent_ticket() helper in jira_service; use it in auto_create_test_issue() to set issue parent
- frontend/api: add jira_token_set to UserMeOut, jira_api_token to UserPreferencesUpdate, and full JiraConfigOut/Update types with getJiraConfig/updateJiraConfig/testJiraConnection functions
- frontend: expand ProfileSection with Jira API token password field (show/hide), token status badge, and account-id field
- frontend: add JiraConfigSection component (admin): enabled toggle, URL, project key, parent ticket, save + test connection
- frontend: add Jira tab (admin-only) with Link2 icon in SettingsPage sidebar
2026-05-26 16:23:24 +02:00
kitos 2675a4b7c2 fix(jira): correct down_revision id in b042 migration
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-26 15:59:23 +02:00
kitos c780ad1e78 feat(jira): per-user auth, lifecycle hooks, admin config endpoints
Aegis CI / lint-and-test (push) Has been cancelled
- Add jira_api_token field to User model + migration b042
- Per-user Jira client: user's corporate email + personal Atlassian token
- Admin-configurable Jira URL/project via system_configs (GET/PATCH /system/jira-config + POST /system/jira-test)
- Auto-create Jira ticket when a test is created (non-fatal)
- Push lifecycle comments on every state transition: draft→red_executing→blue_evaluating→in_review→validated/rejected→draft
- Rich ticket descriptions with technique, MITRE ID, priority from severity, labels
- UserOut.jira_token_set (bool) instead of exposing raw token
- PATCH /users/me/preferences now accepts jira_api_token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:56:28 +02:00
kitos 8bed3abc08 docs(wiki): add wiki creation script for Gitea
Aegis CI / lint-and-test (push) Has been cancelled
Creates 14 comprehensive wiki pages covering architecture, roles,
test lifecycle, API reference, security, deployment, and QA guide.
Run from a machine with access to internal Gitea (192.168.1.107:3000).
2026-05-22 14:30:21 +02:00
kitos c45eed2801 test(qa): fix all test failures - 77/77 passing
Aegis CI / lint-and-test (push) Has been cancelled
- Accept 409 for playbook creation (unique per technique+type is correct behavior)
- Space logins 13s apart to avoid 5/min rate limit on login endpoint
- Reuse admin session from initial login to avoid duplicate login call
2026-05-22 11:05:24 +02:00
kitos cba9bfbab9 security(webhooks): restrict all webhook endpoints to admin-only
Aegis CI / lint-and-test (push) Has been cancelled
fix(qa): pass technique_id and test_id context between test suites
fix(qa): playbook creation requires technique_id field
fix(qa): lesson creation requires what_happened and root_cause fields
fix(qa): campaign complete test now activates with test before completing
fix(qa): rate limit test notes loopback exemption instead of failing
2026-05-22 10:56:15 +02:00
kitos 43ef4ea6a0 test(qa): add automated QA runner for all roles and access control
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-22 10:30:54 +02:00
kitos 6f4901b611 security: fix 6 vulnerabilities identified in SDLC audit
Aegis CI / lint-and-test (push) Has been cancelled
- fix(auth): enforce API key scopes in require_role/require_any_role;
  attach _api_key_scopes to user on API key auth; add require_scope()
  dependency — scopes were stored but never enforced (CWE-285)

- fix(sso): read SECURE_COOKIES env var for SSO cookie instead of
  hardcoded secure=False — SAML sessions now respect HTTPS config (CWE-614)

- fix(webhooks): SSRF prevention — validate webhook URLs against private
  and reserved CIDRs at creation/update time (CWE-918)

- fix(knowledge): restrict playbook/lesson create, update and restore
  to admin/red_lead/blue_lead roles — was open to any authenticated user (CWE-284)

- fix(alerts): restrict alert acknowledge/resolve/dismiss to admin/lead
  roles — any user could silence security alerts (CWE-284)

- security: delete get_admin_creds.py, check_auth.py, deploy.py scripts
  containing hardcoded root SSH credentials and production DB access;
  add scripts/.gitignore to prevent reintroduction (CWE-798)
2026-05-22 09:46:29 +02:00
kitos f36c633d16 fix(scripts): fix verify_gaps.py Gap 1 check — call start_scheduler() before checking registered jobs
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-21 17:28:34 +02:00
kitos fc16675cf2 fix(alerts): import User model in operational_alert_service to fix NameError in _dispatch_inapp_notifications
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-21 17:11:35 +02:00
kitos d05aa94a01 test: gap verification script for Phase 13 gaps
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-21 16:08:45 +02:00
kitos 97349a1d13 feat(alerts): close Phase 13 gaps — hourly job + webhook + in-app notifications
Aegis CI / lint-and-test (push) Has been cancelled
- Add dispatch_webhook_targeted() to webhook_service for rule-specific delivery
- evaluate_all_rules() now dispatches in-app notifications (admins/leads) and
  webhooks after each alert fires (targeted + global alert.fired broadcast)
- APScheduler: _run_alert_evaluation() job registered hourly alongside existing jobs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:57:41 +02:00
kitos cfbf6a6ede fix(dashboard): make KpiBlock.snapshot_id Optional to handle missing today snapshot
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-21 15:27:26 +02:00
kitos d4b147da7c feat(alerts): Phase 13 — Operational Alert Engine
Aegis CI / lint-and-test (push) Has been cancelled
AlertRule + AlertInstance models (b041alerts migration), 8 pre-seeded system
rules (high_risk x2, stale_technique, coverage_regression, low_coverage,
expiry_wave, new_technique, orphan_spike), evaluation engine with per-rule
cooldown, full alert lifecycle (acknowledge/resolve/dismiss), custom rule CRUD,
and summary endpoint. Rules seeded at app startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:25:55 +02:00
kitos d81fc04b8f feat(enterprise): Phase 14 — API Key Management + SSO/SAML 2.0
Aegis CI / lint-and-test (push) Has been cancelled
- ApiKey model (SHA-256 hash, prefix, scopes, expiry) + Alembic migration (b040ent)
- SsoConfig model for SAML 2.0 IdP settings (attribute mapping, auto-provision)
- API key auth integrated into get_current_user (aegis_ prefix detection)
- Routers: /api/v1/api-keys (full CRUD + revoke) and /api/v1/sso (metadata, login, callback, config)
- python3-saml added to requirements; Dockerfile adds libxmlsec1-dev for SAML XML signing
- QA script: 52 assertions covering key lifecycle, API key auth, SSO config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:43:57 +02:00
kitos ab591d30c4 feat(dashboard): Phase 13 — Executive Dashboard
Aegis CI / lint-and-test (push) Has been cancelled
PostureSnapshot model, Alembic migration (b039exec), schemas, service
aggregating all phases (coverage/risk/operations/knowledge/MTTD), and
router at /api/v1/dashboard with executive view, KPIs, coverage-by-tactic,
posture-history, posture-snapshot, and activity-feed endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:20:21 +02:00
kitos 41a0c536bb fix(risk): fix remaining t.technique_id → t.mitre_id in get_recommendations
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-20 16:11:48 +02:00
kitos 7fae4783a2 fix(risk): Technique uses status_global and mitre_id (not status/technique_id)
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-20 15:59:26 +02:00
kitos 084ea4c0b2 fix(risk): correct TechniqueConfidenceScore fields, TechniqueStatus values, Test.result usage
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-20 15:58:03 +02:00
kitos 362a17aa1b feat(risk): Phase 12 — Risk Intelligence [FASE-12]
Aegis CI / lint-and-test (push) Has been cancelled
- TechniqueRiskProfile model: per-technique risk scoring (0-100)
- 4-factor weighted scoring: detection_gap(35%) + threat_actors(30%) + osint(20%) + test_failures(15%)
- Risk levels: critical(≥75) / high(≥50) / medium(≥25) / low(≥10) / info
- Detailed scoring_breakdown (JSONB) + actionable recommendations per technique
- Router /api/v1/risk: compute-all, compute-one, list, matrix, summary, recommendations, top
- Alembic migration b038risk (raw SQL, idempotent)
- QA script: 60+ tests across all endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:31:38 +02:00
kitos 0febbc67f1 fix(qa11): use relative version checks for idempotent runs
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-20 15:26:38 +02:00
kitos 852adb6e4d fix(qa11): make QA idempotent with cleanup step + robust error handling
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-20 15:25:46 +02:00
kitos 4fba4152d9 fix(knowledge): use EntityNotFoundError/DuplicateEntityError instead of DomainError(status_code=)
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-20 15:21:36 +02:00
kitos 9546ef8bc8 fix(qa11): use correct production credentials
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-20 15:14:58 +02:00
kitos e550ebb30f fix(qa11): use production admin credentials
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-20 14:31:46 +02:00
kitos 5e18db48d3 fix(qa11): fix get_token to use form data + fix check() bug
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-20 14:27:41 +02:00
kitos 4f5370db89 feat(knowledge): Phase 11 — Knowledge Management (Playbooks + Lessons Learned) [FASE-11]
Aegis CI / lint-and-test (push) Has been cancelled
- Playbooks: versioned Markdown runbooks per technique × type (attack/detect/investigate/respond/hunt)
- PlaybookVersion: immutable snapshots on every update; restore to any previous version
- LessonLearned: post-mortem records linked to tests/campaigns/attack-paths or manual
- Alembic migration b037know (raw SQL, idempotent, no PostgreSQL enums)
- Router /api/v1/knowledge: 14 endpoints for playbooks + lessons + stats
- Pydantic validators for playbook_type, severity, entity_type (422 on invalid)
- Knowledge stats endpoint: totals + breakdown by severity and playbook type
- Soft-delete on both resources; include_inactive filter for admin recovery
- QA script: 70+ tests across CRUD, versioning, filtering, auth, soft-delete, regression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:39:05 +02:00
kitos 080ce56de7 feat(attack-paths): Phase 10 — Attack Paths & Advanced Purple Team [FASE-10]
Aegis CI / lint-and-test (push) Has been cancelled
Models (5 tables):
  - AttackPath: named reusable attack scenario with template flag
  - AttackPathStep: ordered kill-chain step (technique + test link)
  - AttackPathExecution: a run with Red/Blue leads, timing, stored metrics
  - AttackPathStepResult: per-step detected/not_detected/skipped result
  - TimelineEntry: timestamped Red/Blue/system actions for MTTD/MTTR

Migration b036atk: raw SQL to avoid SQLAlchemy DDL hook issues

Service (attack_path_service.py):
  - Full CRUD for paths + steps (add, update, delete, reorder)
  - Execution lifecycle: create → start → execute steps → complete/abort
  - Pre-creates pending step results on execution creation
  - Auto-adds system timeline entries on key state transitions
  - complete_execution() computes: detection_rate, mttd_seconds,
    furthest_undetected_step, detected/not_detected/skipped counts
  - get_kill_chain_metrics(): per-step breakdown + phase summary

Router /api/v1/attack-paths (20 endpoints):
  POST/GET/PATCH/DELETE attack paths
  GET/POST/PATCH/DELETE steps + reorder
  POST/GET executions per path
  GET/POST/start/complete/abort executions
  POST/GET step results
  POST/GET timeline entries
  GET kill-chain metrics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:11:01 +02:00
kitos 4ece2293ec fix(ownership): validate reason+priority in QueueItemCreate to return 422 not 500
Aegis CI / lint-and-test (push) Has been cancelled
POST /ownership/queue with an invalid reason or priority was silently
passing Pydantic and crashing at the DB layer (PostgreSQL enum type
mismatch → 500). Added @field_validator for both fields, matching the
existing validators in QueueItemPatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:57:34 +02:00
kitos f97b9e96b7 fix(migration): rewrite b035 with raw SQL to avoid SQLAlchemy DDL hook
Aegis CI / lint-and-test (push) Has been cancelled
SQLAlchemy fires before_create for ALL known enum types when any table
is created via op.create_table, causing DuplicateObject even with
create_type=False. Rewrite both CREATE TABLE statements as raw SQL via
conn.execute(sa.text(...)) and use CREATE TABLE IF NOT EXISTS / CREATE
INDEX IF NOT EXISTS for full idempotency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:54:32 +02:00
kitos 36fe4aa250 fix(migration): use DO/EXCEPTION for idempotent enum creation in b035
Aegis CI / lint-and-test (push) Has been cancelled
Replace _enum_exists() helper (which had connection context issues in
Alembic) with PostgreSQL DO $$ BEGIN ... EXCEPTION WHEN duplicate_object
THEN NULL; END $$ blocks, which are truly idempotent regardless of
transaction state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:51:22 +02:00
kitos a8b4518485 feat(ownership): Phase 9 — Ownership & Daily Operations [FASE-9]
Aegis CI / lint-and-test (push) Has been cancelled
Backend:
- TechniqueOwnership model: per-technique owner, backup owner, team
- RevalidationQueueItem model: prioritised analyst work queue
  (critical/high/medium/low, reasons: validation_expired/infra_change/
   osint_alert/mitre_update/rule_modified/low_confidence/manual)
- Migration b035ownerq: creates technique_ownerships and
  revalidation_queue_items tables with full indexes

Services:
- ownership_service: set/get technique ownership, bulk assign by tactic
  or platform, orphan reports for techniques and assets
- revalidation_queue_service: smart queue generation (scans expired
  validations, low-confidence techniques, recent infra changes),
  list/create/update queue items, analyst dashboard

Router /api/v1/ownership:
  GET/PUT /ownership/techniques/{id}   — technique ownership
  PATCH   /ownership/assets/{id}       — asset ownership
  GET     /ownership/orphans/techniques — orphan report
  GET     /ownership/orphans/assets     — orphan report
  POST    /ownership/bulk-assign        — bulk by tactic/platform
  GET/POST /ownership/queue             — revalidation queue CRUD
  PATCH   /ownership/queue/{id}         — update item status/assignee
  POST    /ownership/queue/generate     — scan & generate items
  GET     /ownership/analyst-dashboard  — personalised daily view

Scheduler: queue_generation job daily at 02:30 (after decay engine)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:48:47 +02:00
kitos 89a951c2a2 fix(decay-engine): strip tzinfo from validated_at before datetime arithmetic
Aegis CI / lint-and-test (push) Has been cancelled
The previous fix changed _now() to return naive UTC, but the code still
called .replace(tzinfo=utc) on most_recent (from DB) before subtracting.
This caused "can't subtract offset-naive and offset-aware datetimes".
Now we strip tzinfo if present, keeping everything naive UTC consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:35:02 +02:00
kitos 9a020f97ef fix(detection-lifecycle): fix timezone naive/aware mismatch and duplicate technique mapping
Aegis CI / lint-and-test (push) Has been cancelled
- Replace datetime.now(timezone.utc) with datetime.utcnow() in _now() across
  all three Phase 8 files to match DB DateTime column type (naive UTC)
- Guard POST /assets/{id}/techniques/{tid} against duplicate mappings:
  if mapping already exists, update coverage_type/confidence_level instead
  of inserting a duplicate row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:29:04 +02:00
kitos 1fe150963c feat(dlm): Phase 8 — Detection Lifecycle Management [FASE-8]
Aegis CI / lint-and-test (push) Has been cancelled
Tasks 8.1-8.5:

Models (8.1):
- DetectionAsset: SIEM/EDR/Sigma rule assets with auto-hash
- DetectionTechniqueMapping: N:M asset ↔ technique coverage
- DetectionValidation: immutable validation records with expiry
- TechniqueConfidenceScore: computed multi-factor confidence
- InfrastructureChangeLog: infra changes that invalidate detections
- DecayPolicy: configurable freshness thresholds per platform/tactic

Services (8.2, 8.3):
- detection_asset_service: CRUD + SHA-256 rule hashing + auto-
  invalidation on rule/infra changes
- decay_engine_service: daily decay engine — expires stale validations,
  recalculates confidence (recency/coverage/health/diversity factors),
  processes infrastructure change propagation

Router (8.4): 15 endpoints under /api/v1/detection-lifecycle:
  assets CRUD, technique mappings, validations, confidence scores,
  infrastructure changes, decay trigger, executive dashboard

Scheduler (8.3): decay engine runs daily at 02:00
Seed (8.5): default policy (90/180/365d) + strict initial-access policy
Migration: b034dlm (6 tables, 11 indexes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:45:16 +02:00
kitos 0e1b8e2b39 feat(settings): Settings page with email, webhooks, notifications, profile [FASE-8]
Aegis CI / lint-and-test (push) Has been cancelled
- SystemConfig model + migration b033 for runtime key-value config
- GET/PATCH /system/email-config + POST /system/email-test (admin only)
- email_service reads SMTP config from DB (overrides .env)
- Webhooks now accessible to red_lead/blue_lead + admin
- GET /users/me already existed; /users/me/preferences already working
- SettingsPage with 4 role-aware tabs:
  * Profile & Jira: jira_account_id, user info
  * Notifications: role-specific email/in-app toggles (12 prefs)
  * Webhooks: full CRUD + test ping (leads + admin)
  * Email/SMTP: enable toggle, server config, test email (admin only)
- Added /settings route (all authenticated users)
- Settings link added to Sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:10:31 +02:00
kitos 93ebcf2b86 fix(users): add GET /users/me endpoint for current user profile
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-19 14:04:42 +02:00
kitos c1e06d4c0a feat(phases): implement webhooks (6.1), email (7.1), user preferences (7.2)
Aegis CI / lint-and-test (push) Has been cancelled
- Phase 6.1: WebhookConfig model, CRUD router (/api/v1/webhooks, admin-only),
  dispatch_webhook() with HMAC signing; integrated into test validation,
  campaign completion, and MITRE sync job
- Phase 7.1: SMTP email service with send_test_validated_email,
  send_campaign_completed_email, send_new_mitre_techniques_email;
  notify_role_with_email() added to notification_service
- Phase 7.2: notification_preferences and jira_account_id on User model;
  PATCH /users/me/preferences endpoint; Alembic migrations b031phase6 and b032phase7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:40:45 +02:00
kitos d6df7fdc09 fix(audit): show UTC suffix on timestamp display
Aegis CI / lint-and-test (push) Has been cancelled
2026-05-19 13:05:08 +02:00
kitos 7312f9664b fix(qa): CSP hash, remove pencil icon, fetch full template on modal open
Aegis CI / lint-and-test (push) Has been cancelled
- nginx.conf: add new CSP script-src hash (sha256-Yvj83pg...) alongside previous one
- SystemPage: remove pencil icon from template name button, keep cyan underline style
- SystemPage: switch from selectedTemplate state to selectedTemplateId + useQuery
  for getTemplateById() — ensures full template data (description, attack_procedure,
  expected_detection, tool_suggested etc.) loads before modal opens
- DB backfill already applied via SQL: UPDATE audit_logs SET timestamp = NOW()
  WHERE timestamp IS NULL (358 rows fixed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:53:02 +02:00
kitos 63da22b77e fix(qa): 5 bug fixes — audit dates, CSP, template modal, MITRE sync timeout, data source auto-sync
Aegis CI / lint-and-test (push) Has been cancelled
- audit_service: set timestamp=datetime.now(utc) explicitly so DB never stores NULL
- AuditLogPage: formatDate handles null/undefined timestamps (was showing Jan 1 1970)
- nginx.conf: add CSP script-src hash for inline script (sha256-31OgE8E9...)
- system.py: MITRE sync now runs in BackgroundTasks — returns immediately, no more 120s timeout
- mitre_sync_job.py: add _run_data_sources_sync job (every 6h) that checks sync_frequency
  and auto-syncs overdue enabled data sources
- SystemPage: MITRE sync result shows "started" vs "complete" message
- test-templates.ts: add updateTemplate() API function
- SystemPage: template name cell is now clickable — opens TemplateDetailModal with
  full edit form (name, description, procedure, detection, platform, severity, tool)
  and Save / Activate / Deactivate / Close buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:05:35 +02:00
kitos fd476ce460 fix(audit): timestamp Optional para evitar 500 con registros NULL
Aegis CI / lint-and-test (push) Has been cancelled
Algunos registros de audit_log tienen timestamp=NULL en DB.
AuditLogOut tenia timestamp: datetime (no opcional) causando
ValidationError -> 500 Internal Server Error al listar el audit log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:09:47 +02:00
kitos 60183f704c fix(deploy): pasa SECURE_COOKIES al backend en docker-compose.prod.yml
Aegis CI / lint-and-test (push) Has been cancelled
Permite desactivar la cookie Secure en servidores HTTP via .env.
Por defecto false para la instancia local (192.168.1.93).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:55:00 +02:00
kitos 2495423790 fix(auth,frontend): secure cookie HTTP fix, technique links y CSP
Aegis CI / lint-and-test (push) Has been cancelled
- auth: desacopla SECURE_COOKIES de AEGIS_ENV para que el login
  funcione sobre HTTP (SECURE_COOKIES=false en servidor local)
- TechniqueCell: button -> Link para href real (right-click, a11y)
- TechniquesPage: añade Link en celda MITRE ID en vista lista
- nginx CSP: amplía connect-src con ws:/wss: para evitar bloqueos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:28:39 +02:00
219 changed files with 1687 additions and 20680 deletions
+189
View File
@@ -0,0 +1,189 @@
---
description: Aegis backend Clean Architecture rules. Apply when working on any backend Python file under backend/app/ or backend/tests/.
globs: backend/**/*.py
---
# Aegis — Clean Modular Monolith Architecture
## Architecture Overview
Aegis follows a **Clean Architecture** pattern inside a modular monolith. The backend has four layers with strict dependency rules:
```
Presentation → Application → Domain ← Infrastructure
```
**The golden rule:** dependencies only point towards the Domain layer. Infrastructure implements the ports (interfaces) defined in Domain.
## Layer Structure and Rules
### Domain Layer (`backend/app/domain/`)
The innermost layer. **ZERO** imports from FastAPI, SQLAlchemy, Pydantic, or any framework.
| Directory | Purpose |
|-----------|---------|
| `domain/enums.py` | Canonical domain enums (TechniqueStatus, TestState, TeamSide, TestResult) |
| `domain/errors.py` | Exception hierarchy (DomainError → EntityNotFoundError, InvalidStateTransition, etc.) |
| `domain/exceptions.py` | Backward-compatible re-exports from errors.py |
| `domain/test_entity.py` | TestEntity — pure state machine with domain events |
| `domain/entities/` | Rich domain entities (TechniqueEntity, etc.) with business behavior |
| `domain/value_objects/` | Immutable value types (MitreId, ScoringWeights) |
| `domain/ports/repositories/` | Protocol interfaces defining data access contracts |
| `domain/ports/services/` | Protocol interfaces for external capabilities (storage, events) |
| `domain/unit_of_work.py` | UnitOfWork wrapping SQLAlchemy session |
**NEVER** import from `app.models`, `app.routers`, `app.infrastructure`, `fastapi`, or `sqlalchemy` inside `domain/`.
### Application Layer (`backend/app/application/` — future)
Use case orchestrators. Depends only on Domain.
| Directory | Purpose |
|-----------|---------|
| `application/use_cases/` | One class per business operation |
| `application/dto/` | Plain data containers for use case input/output |
| `application/interfaces/` | Application-level contracts (UnitOfWork protocol) |
### Infrastructure Layer (`backend/app/infrastructure/`)
Implements ports defined in Domain. Depends on Domain and Application.
| Directory | Purpose |
|-----------|---------|
| `infrastructure/redis_client.py` | Redis connection singleton |
| `infrastructure/persistence/repositories/` | SQLAlchemy implementations of repository ports |
| `infrastructure/persistence/mappers/` | ORM model ↔ domain entity converters |
### Presentation Layer (routers, schemas, dependencies)
HTTP boundary. Depends on Application and Domain (for exceptions).
| Directory | Purpose |
|-----------|---------|
| `routers/` | FastAPI routers — HTTP mapping only |
| `schemas/` | Pydantic request/response models |
| `dependencies/` | FastAPI `Depends()` wiring (auth, repositories) |
| `middleware/` | Error handler mapping domain exceptions → HTTP responses |
## Import Rules (Strict)
| From \ To | domain/ | application/ | infrastructure/ | presentation/ |
|-----------|---------|-------------|----------------|--------------|
| **domain/** | Self only | FORBIDDEN | FORBIDDEN | FORBIDDEN |
| **application/** | ALLOWED | Self only | FORBIDDEN | FORBIDDEN |
| **infrastructure/** | ALLOWED (ports) | ALLOWED (UoW) | Self only | FORBIDDEN |
| **presentation/** | ALLOWED (exceptions) | ALLOWED (use cases) | ALLOWED (wiring in dependencies/) | Self only |
## How to Add a New Feature
### 1. Start from the Domain
- Define or reuse domain entities in `domain/entities/`
- Add value objects if needed in `domain/value_objects/`
- Define repository port if a new aggregate root in `domain/ports/repositories/`
- Domain exceptions go in `domain/errors.py`
- Business rules live IN the entity, not in services or routers
### 2. Implement Infrastructure
- Create SQLAlchemy repository implementation in `infrastructure/persistence/repositories/`
- Create mapper if converting between ORM model and domain entity
- Repository does NOT call `commit()` — only `flush()`
- Transaction control belongs to the Unit of Work
### 3. Wire in Presentation
- Add FastAPI `Depends()` provider in `dependencies/repositories.py`
- Keep routers thin: parse request → call service/use case → return response
- Map domain exceptions to HTTP via the error handler middleware (automatic)
### 4. Tests (Mandatory)
Every change MUST include tests:
- **Domain entities/value objects**: pure unit tests, no DB, no mocking frameworks
- **Repositories**: integration tests using the `db` fixture from conftest
- **Routers**: API tests using the `client` fixture
- At least one success test + one failure/edge-case test per behavior
Before committing, run: `scripts/agent_validate_backend.sh`
## Existing Patterns to Follow
### Domain Entity Pattern (see `domain/test_entity.py`)
```python
@dataclass
class SomeEntity:
id: uuid.UUID
# fields...
_events: list[DomainEvent] = field(default_factory=list, repr=False)
@classmethod
def from_orm(cls, model: Any) -> "SomeEntity":
"""Build from SQLAlchemy model."""
...
def apply_to(self, model: Any) -> None:
"""Copy mutable fields back onto the ORM model."""
...
def some_business_method(self) -> None:
"""Business logic lives HERE, not in services."""
...
self._events.append(DomainEvent("something_happened"))
```
### Repository Port Pattern (Protocol)
```python
from typing import Protocol, runtime_checkable
@runtime_checkable
class SomeRepository(Protocol):
def find_by_id(self, id: uuid.UUID) -> SomeEntity | None: ...
def save(self, entity: SomeEntity) -> SomeEntity: ...
```
### Repository Implementation Pattern
```python
class SASomeRepository:
def __init__(self, session: Session) -> None:
self._session = session
def find_by_id(self, id: uuid.UUID) -> SomeEntity | None:
model = self._session.query(SomeModel).filter(SomeModel.id == id).first()
return SomeMapper.to_entity(model) if model else None
def save(self, entity: SomeEntity) -> SomeEntity:
model = SomeMapper.to_model(entity)
merged = self._session.merge(model)
self._session.flush() # NO commit — UoW does that
return SomeMapper.to_entity(merged)
```
### Error Handling (automatic via middleware)
Services raise domain exceptions → middleware maps to HTTP:
- `EntityNotFoundError` → 404
- `DuplicateEntityError` → 409
- `InvalidStateTransition` → 400
- `BusinessRuleViolation` → 400
- `PermissionViolation` → 403
### Coexistence Strategy
Old code (direct `db.query()` in routers) and new code (repositories) coexist. Migration is incremental:
1. New endpoints use repositories
2. Existing endpoints are migrated one at a time
3. Both access the same DB, same session, same tables
## Key Conventions
- **Enums**: canonical source is `domain/enums.py`, `models/enums.py` re-exports
- **Exceptions**: raise from `domain/errors.py`, never raise `HTTPException` from services
- **Commits**: only via `UnitOfWork.commit()` or at the router level, never inside services/repos
- **IDs**: UUID everywhere (primary keys, foreign keys)
- **Tests**: SQLite in-memory for unit/integration, PostgreSQL in CI
- **Validation**: Pydantic in schemas (presentation), domain rules in entities (domain)
-71
View File
@@ -1,71 +0,0 @@
name: Snyk Security Scan
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Weekly on Monday 06:00 UTC
jobs:
snyk-backend:
name: Python vulnerabilities (backend)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install backend dependencies
run: pip install -r backend/requirements-lock.txt
- name: Snyk — scan Python packages
uses: snyk/actions/python@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --file=backend/requirements-lock.txt --severity-threshold=high
continue-on-error: true # report without blocking CI during initial cleanup
snyk-frontend:
name: npm vulnerabilities (frontend)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Snyk — scan npm packages
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --file=frontend/package.json --severity-threshold=high
continue-on-error: true
snyk-docker-backend:
name: Docker image vulnerabilities (backend)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build backend image for scanning
run: docker build -t aegis-backend:scan backend/
- name: Snyk — scan Docker image
uses: snyk/actions/docker@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: aegis-backend:scan
args: --severity-threshold=high
continue-on-error: true
-9
View File
@@ -60,12 +60,3 @@ Thumbs.db
# Local development
*.local
# Documentation drafts — never commit, delivered directly in chat
docs/confluence/
docs/drafts/
# Editor / AI assistant working files — never commit
.claude/
.cursor/
CLAUDE.md
-2
View File
@@ -1,2 +0,0 @@
skips:
- B311
+1 -1
View File
@@ -3,7 +3,7 @@ FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
curl \
@@ -1,39 +0,0 @@
"""Add evaluation_imports table.
Revision ID: b048
Revises: b047
Create Date: 2026-06-05
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "b048"
down_revision = "b047"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"evaluation_imports",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("adversary_name", sa.String, nullable=False),
sa.Column("adversary_display", sa.String, nullable=False),
sa.Column("eval_round", sa.Integer, nullable=False),
sa.Column("imported_at", sa.DateTime, nullable=False),
sa.Column("imported_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("tests_created", sa.Integer, default=0),
sa.Column("techniques_covered", sa.Integer, default=0),
sa.Column("status", sa.String, default="completed"),
sa.Column("notes", sa.Text, nullable=True),
)
op.create_index("ix_evaluation_imports_adversary", "evaluation_imports", ["adversary_name"])
op.create_index("ix_evaluation_imports_round", "evaluation_imports", ["eval_round"])
def downgrade() -> None:
op.drop_index("ix_evaluation_imports_round", table_name="evaluation_imports")
op.drop_index("ix_evaluation_imports_adversary", table_name="evaluation_imports")
op.drop_table("evaluation_imports")
-1
View File
@@ -1 +0,0 @@
"""Aegis — MITRE ATT&CK Coverage Platform application package."""
+4 -40
View File
@@ -1,32 +1,23 @@
"""Security utilities: password hashing and JWT token management.
"""
Security utilities: password hashing and JWT token management.
This module provides pure functions for:
- Hashing and verifying passwords using bcrypt via passlib.
- Creating JWT access tokens using PyJWT.
- Creating JWT access tokens using python-jose.
- Managing a Redis-backed token blacklist for revocation.
No endpoints are defined here.
"""
# Import logging
import logging
# Import uuid
import uuid as _uuid
# Import datetime, timedelta, timezone from datetime
from datetime import datetime, timedelta, timezone
# Import jwt (PyJWT)
import jwt
# Import CryptContext from passlib.context
from jose import jwt
from passlib.context import CryptContext
# Import settings from app.config
from app.config import settings
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@@ -36,17 +27,13 @@ logger = logging.getLogger(__name__)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Define function hash_password
def hash_password(password: str) -> str:
"""Return a bcrypt hash of *password*."""
# Return pwd_context.hash(password)
return pwd_context.hash(password)
# Define function verify_password
def verify_password(plain: str, hashed: str) -> bool:
"""Return ``True`` if *plain* matches the bcrypt *hashed* value."""
# Return pwd_context.verify(plain, hashed)
return pwd_context.verify(plain, hashed)
@@ -61,21 +48,14 @@ def create_access_token(data: dict) -> str:
- ``jti`` (JWT ID): unique identifier that enables token revocation.
- ``exp``: expiration timestamp based on ``ACCESS_TOKEN_EXPIRE_MINUTES``.
"""
# Assign to_encode = data.copy()
to_encode = data.copy()
# Assign expire = datetime.now(timezone.utc) + timedelta(
expire = datetime.now(timezone.utc) + timedelta(
# Keyword argument: minutes
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
# Call to_encode.update()
to_encode.update({
# Literal argument value
"exp": expire,
# Literal argument value
"jti": str(_uuid.uuid4()),
})
# Return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGOR...
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
@@ -93,7 +73,6 @@ def create_access_token(data: dict) -> str:
_BLACKLIST_PREFIX = "blacklist:"
# Define function blacklist_token
def blacklist_token(jti: str, exp: float) -> None:
"""Add *jti* to the Redis blacklist with a TTL derived from *exp*.
@@ -101,38 +80,23 @@ def blacklist_token(jti: str, exp: float) -> None:
to ``exp - now`` so the key vanishes when the token would have expired
naturally.
"""
# Import get_redis_blacklist from app.infrastructure.redis_client
from app.infrastructure.redis_client import get_redis_blacklist
# Assign ttl = max(int(exp - datetime.now(timezone.utc).timestamp()), 1)
ttl = max(int(exp - datetime.now(timezone.utc).timestamp()), 1)
# Attempt the following; catch errors below
try:
# Assign r = get_redis_blacklist()
r = get_redis_blacklist()
# Call r.setex()
r.setex(f"{_BLACKLIST_PREFIX}{jti}", ttl, "1")
# Handle Exception
except Exception:
# Log warning: "Failed to blacklist token %s in Redis", jti, exc_
logger.warning("Failed to blacklist token %s in Redis", jti, exc_info=True)
# Define function is_token_blacklisted
def is_token_blacklisted(jti: str) -> bool:
"""Return ``True`` if *jti* has been revoked (exists in Redis)."""
# Import get_redis_blacklist from app.infrastructure.redis_client
from app.infrastructure.redis_client import get_redis_blacklist
# Attempt the following; catch errors below
try:
# Assign r = get_redis_blacklist()
r = get_redis_blacklist()
# Return r.exists(f"{_BLACKLIST_PREFIX}{jti}") > 0
return r.exists(f"{_BLACKLIST_PREFIX}{jti}") > 0
# Handle Exception
except Exception:
# Log warning: "Failed to check blacklist for %s in Redis", jti,
logger.warning("Failed to check blacklist for %s in Redis", jti, exc_info=True)
# Return False
return False
+1 -65
View File
@@ -1,21 +1,7 @@
"""Application configuration for the Aegis MITRE ATT&CK Coverage Platform.
Loads settings from environment variables and ``.env`` files via
``pydantic-settings``. Validates critical secrets at import time and raises
``RuntimeError`` (production) or issues a ``UserWarning`` (development) when
unsafe defaults are detected.
"""
# Import os
import os
# Import secrets
import secrets
# Import warnings
import warnings
# Import BaseSettings from pydantic_settings
from pydantic_settings import BaseSettings
# ---------------------------------------------------------------------------
@@ -24,11 +10,7 @@ from pydantic_settings import BaseSettings
_is_production = os.environ.get("AEGIS_ENV", "").lower() == "production"
# Define class Settings
class Settings(BaseSettings):
"""Application settings loaded from environment variables and .env file."""
# Assign DATABASE_URL = "postgresql://postgres:postgres@postgres:5432/attackdb"
DATABASE_URL: str = "postgresql://postgres:postgres@postgres:5432/attackdb"
# ── Security ──────────────────────────────────────────────────────
@@ -37,7 +19,6 @@ class Settings(BaseSettings):
# for local dev). In production it MUST be supplied via env/.env
# so tokens survive restarts.
SECRET_KEY: str = ""
# Assign ALGORITHM = "HS256"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8 hours — /auth/refresh extends active sessions
@@ -45,7 +26,6 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://redis:6379/0"
# Logical DB indices on the same Redis instance (PATH in URL is overridden).
REDIS_TOKEN_BLACKLIST_DB: int = 1
# Assign REDIS_CACHE_DB = 2
REDIS_CACHE_DB: int = 2
# ── CORS ─────────────────────────────────────────────────────────
@@ -61,11 +41,8 @@ class Settings(BaseSettings):
# the browser can reach MinIO directly. Defaults to MINIO_ENDPOINT.
MINIO_PUBLIC_ENDPOINT: str = ""
MINIO_ACCESS_KEY: str = "minioadmin"
# Assign MINIO_SECRET_KEY = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
# Assign MINIO_BUCKET = "evidence"
MINIO_BUCKET: str = "evidence"
# Assign MINIO_SECURE = False # True → use HTTPS to connect to MinIO
MINIO_SECURE: bool = False # True → use HTTPS to connect to MinIO
# ── Re-testing ───────────────────────────────────────────────────
@@ -73,15 +50,10 @@ class Settings(BaseSettings):
# ── Jira Integration ────────────────────────────────────────────
JIRA_ENABLED: bool = False
# Assign JIRA_URL = ""
JIRA_URL: str = ""
# Assign JIRA_USERNAME = ""
JIRA_USERNAME: str = ""
# Assign JIRA_API_TOKEN = ""
JIRA_API_TOKEN: str = ""
# Assign JIRA_IS_CLOUD = True
JIRA_IS_CLOUD: bool = True
# Assign JIRA_DEFAULT_PROJECT = ""
JIRA_DEFAULT_PROJECT: str = ""
JIRA_ISSUE_TYPE_TEST: str = "Task" # tests (campaign or standalone)
JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic" # campaigns (under Initiative)
@@ -91,11 +63,8 @@ class Settings(BaseSettings):
# ── Tempo Integration ─────────────────────────────────────────────
TEMPO_ENABLED: bool = False
# Assign TEMPO_API_TOKEN = ""
TEMPO_API_TOKEN: str = ""
# Assign TEMPO_API_VERSION = 4
TEMPO_API_VERSION: int = 4
# Assign TEMPO_DEFAULT_WORK_TYPE = "Red Team"
TEMPO_DEFAULT_WORK_TYPE: str = "Red Team"
# Tempo API base URL — use https://api.eu.tempo.io/4 for EU workspaces.
# Can also be set via system_configs key "tempo.base_url" at runtime.
@@ -103,16 +72,12 @@ class Settings(BaseSettings):
# ── OSINT / Intelligence ────────────────────────────────────────
NVD_API_KEY: str = "" # optional; increases NVD rate limit from 5/30s to 50/30s
# Assign STALE_THRESHOLD_DAYS = 365 # days before coverage is considered stale
STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale
# ── Reporting ─────────────────────────────────────────────────────
REPORT_TEMPLATES_DIR: str = "app/templates/reports"
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
REPORT_OUTPUT_DIR: str = "/app/reports"
# Assign COMPANY_NAME = "Organization"
REPORT_OUTPUT_DIR: str = "/tmp/aegis_reports"
COMPANY_NAME: str = "Organization"
# Assign COMPANY_LOGO_PATH = "app/templates/reports/assets/logo.png"
COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png"
# ── Email / SMTP ──────────────────────────────────────────────────
@@ -127,68 +92,43 @@ class Settings(BaseSettings):
# ── Scoring weights (must sum to 100) ────────────────────────────
SCORING_WEIGHT_TESTS: int = 40
# Assign SCORING_WEIGHT_DETECTION_RULES = 25
SCORING_WEIGHT_DETECTION_RULES: int = 25
# Assign SCORING_WEIGHT_D3FEND = 15
SCORING_WEIGHT_D3FEND: int = 15
# Assign SCORING_WEIGHT_RECENCY = 10
SCORING_WEIGHT_RECENCY: int = 10
# Assign SCORING_WEIGHT_SEVERITY = 10
SCORING_WEIGHT_SEVERITY: int = 10
# Legacy env names (mapped in scoring_config_service)
SCORING_WEIGHT_FRESHNESS: int = 10
# Assign SCORING_WEIGHT_PLATFORM_DIVERSITY = 10
SCORING_WEIGHT_PLATFORM_DIVERSITY: int = 10
# Define class Config
class Config:
"""Pydantic BaseSettings configuration — load from .env file."""
# Assign env_file = ".env"
env_file = ".env"
# Assign settings = Settings()
settings = Settings()
# ---------------------------------------------------------------------------
# Post-init validation for SECRET_KEY
# ---------------------------------------------------------------------------
_UNSAFE_SECRETS = {
# Literal argument value
"",
# Literal argument value
"change-me-in-production",
# Literal argument value
"change-me-in-production-use-a-long-random-string",
}
# Check: settings.SECRET_KEY in _UNSAFE_SECRETS
if settings.SECRET_KEY in _UNSAFE_SECRETS:
# Check: _is_production
if _is_production:
# Raise RuntimeError
raise RuntimeError(
# Literal argument value
"CRITICAL: SECRET_KEY is not configured. "
# Literal argument value
"Set a strong random value (>= 32 chars) via the SECRET_KEY "
# Literal argument value
"environment variable or in your .env file before running in "
# Literal argument value
"production. Example: openssl rand -hex 32"
)
# Development: auto-generate an ephemeral key and warn
settings.SECRET_KEY = secrets.token_hex(32)
# Call warnings.warn()
warnings.warn(
# Literal argument value
"SECRET_KEY was not set — using an auto-generated ephemeral key. "
# Literal argument value
"JWT tokens will be invalidated on every restart. "
# Literal argument value
"Set SECRET_KEY in your environment for persistent sessions.",
# Keyword argument: stacklevel
stacklevel=2,
)
@@ -196,16 +136,12 @@ if settings.SECRET_KEY in _UNSAFE_SECRETS:
# SEC-002: Reject default credentials in production
# ---------------------------------------------------------------------------
if _is_production:
# Assign _DEFAULT_CREDS = {
_DEFAULT_CREDS = {
("MINIO_ACCESS_KEY", settings.MINIO_ACCESS_KEY, "minioadmin"),
("MINIO_SECRET_KEY", settings.MINIO_SECRET_KEY, "minioadmin"),
}
# Iterate over _DEFAULT_CREDS
for name, current, default in _DEFAULT_CREDS:
# Check: current == default
if current == default:
# Raise RuntimeError
raise RuntimeError(
f"CRITICAL: {name} is using the default value '{default}'. "
f"Set a strong value via the {name} environment variable "
+9 -105
View File
@@ -1,164 +1,68 @@
"""Database engine and session management for the Aegis platform.
The engine and session factory are created lazily so that tests can override
``DATABASE_URL`` via environment variables before any import triggers real
PostgreSQL engine creation (which requires psycopg2).
"""
# Import Generator from collections.abc
from collections.abc import Generator
# Import create_engine from sqlalchemy
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
# Import Engine from sqlalchemy.engine
from sqlalchemy.engine import Engine
# Import Session, declarative_base, sessionmaker from sqlalchemy.orm
from sqlalchemy.orm import Session, declarative_base, sessionmaker
# Assign Base = declarative_base()
Base = declarative_base()
# Engine and session factory are created lazily so that tests can
# override DATABASE_URL via environment *before* any import triggers
# the real PostgreSQL engine creation (which requires psycopg2).
_engine = None
# Assign _SessionLocal = None
_SessionLocal = None
# Define function _get_engine
def _get_engine() -> Engine:
"""Return the shared SQLAlchemy engine, creating it on first call.
Returns:
Engine: Configured SQLAlchemy engine for the application database.
"""
# Declare global variable
def _get_engine():
global _engine
# Check: _engine is None
if _engine is None:
# Import settings from app.config
from app.config import settings
# Assign url = settings.DATABASE_URL
url = settings.DATABASE_URL
# Assign kwargs = {}
kwargs: dict = {}
# Check: url.startswith("postgresql")
if url.startswith("postgresql"):
# Call kwargs.update()
kwargs.update(
# Keyword argument: pool_size
pool_size=20,
# Keyword argument: max_overflow
max_overflow=10,
# Keyword argument: pool_recycle
pool_recycle=3600,
# Keyword argument: pool_pre_ping
pool_pre_ping=True,
)
# Assign _engine = create_engine(url, **kwargs)
_engine = create_engine(url, **kwargs)
# Return _engine
return _engine
# Define function _get_session_factory
def _get_session_factory() -> sessionmaker:
"""Return the shared sessionmaker, creating it on first call.
Returns:
sessionmaker: Configured sessionmaker bound to the application engine.
"""
# Declare global variable
def _get_session_factory():
global _SessionLocal
# Check: _SessionLocal is None
if _SessionLocal is None:
# Assign _SessionLocal = sessionmaker(
_SessionLocal = sessionmaker(
# Keyword argument: autocommit
autocommit=False, autoflush=False, bind=_get_engine()
)
# Return _SessionLocal
return _SessionLocal
# Define class _LazySessionLocal
class _LazySessionLocal:
"""Proxy so ``SessionLocal()`` keeps working as before but the real sessionmaker is only created on first call."""
"""Proxy so ``SessionLocal()`` keeps working as before but the real
sessionmaker is only created on first call."""
# Define function __call__
def __call__(self, *args: object, **kwargs: object) -> Session:
"""Create and return a new database session.
Args:
*args (object): Positional arguments forwarded to the sessionmaker.
**kwargs (object): Keyword arguments forwarded to the sessionmaker.
Returns:
Session: A new SQLAlchemy database session.
"""
# Return _get_session_factory()(*args, **kwargs)
def __call__(self, *args, **kwargs):
return _get_session_factory()(*args, **kwargs)
# Define function __getattr__
def __getattr__(self, name: str) -> object:
"""Delegate attribute access to the underlying sessionmaker.
Args:
name (str): Attribute name to look up on the sessionmaker.
Returns:
object: The attribute value from the underlying sessionmaker.
"""
# Return getattr(_get_session_factory(), name)
def __getattr__(self, name):
return getattr(_get_session_factory(), name)
# Assign SessionLocal = _LazySessionLocal()
SessionLocal = _LazySessionLocal()
# Define class _EngineProxy
class _EngineProxy:
"""Thin proxy so ``from app.database import engine`` still works."""
# Define function __getattr__
def __getattr__(self, name: str) -> object:
"""Delegate attribute access to the lazily-created engine.
Args:
name (str): Attribute name to look up on the real engine.
Returns:
object: The attribute value from the underlying SQLAlchemy engine.
"""
# Return getattr(_get_engine(), name)
def __getattr__(self, name):
return getattr(_get_engine(), name)
# Assign engine = _EngineProxy() # type: ignore[assignment]
engine = _EngineProxy() # type: ignore[assignment]
# Define function get_db
def get_db() -> Generator[Session, None, None]:
"""Yield a database session and close it when the request is done.
Intended for use as a FastAPI dependency.
Yields:
Session: An active SQLAlchemy session for the current request.
"""
# Assign db = SessionLocal()
def get_db():
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Yield db
yield db
# Always execute this cleanup block
finally:
# Close the database session
db.close()
-1
View File
@@ -1 +0,0 @@
"""FastAPI dependency injection helpers for auth, DB, and shared state."""
+5 -69
View File
@@ -1,4 +1,5 @@
"""Authentication and RBAC dependencies for FastAPI.
"""
Authentication and RBAC dependencies for FastAPI.
Provides:
- ``get_current_user``: decodes JWT from HttpOnly cookie (preferred) or
@@ -8,34 +9,16 @@ Provides:
(admins always pass).
"""
# Import Callable from collections.abc
from collections.abc import Callable
# Import Optional from typing
from typing import Optional
# Import Cookie, Depends, HTTPException, status from fastapi
from fastapi import Cookie, Depends, HTTPException, status
# Import OAuth2PasswordBearer from fastapi.security
from fastapi.security import OAuth2PasswordBearer
# Import jwt (PyJWT)
import jwt
# Import Session from sqlalchemy.orm
from jose import JWTError, jwt
from sqlalchemy.orm import Session
# Import auth as auth_lib from app
from app import auth as auth_lib
# Import settings from app.config
from app.config import settings
# Import get_db from app.database
from app.database import get_db
# Import User from app.models.user
from app.models.user import User
from app.models.api_key import KEY_PREFIX
@@ -54,11 +37,8 @@ _COOKIE_NAME = "aegis_token"
async def get_current_user(
# Entry: aegis_token
aegis_token: Optional[str] = Cookie(None),
# Entry: bearer_token
bearer_token: Optional[str] = Depends(oauth2_scheme),
# Entry: db
db: Session = Depends(get_db),
) -> User:
"""Decode the JWT, look up the user in *db*, and return it.
@@ -74,30 +54,20 @@ async def get_current_user(
- the ``sub`` claim is missing, or
- no matching active user exists in the database.
"""
# Assign credentials_exception = HTTPException(
credentials_exception = HTTPException(
# Keyword argument: status_code
status_code=status.HTTP_401_UNAUTHORIZED,
# Keyword argument: detail
detail="Could not validate credentials",
# Keyword argument: headers
headers={"WWW-Authenticate": "Bearer"},
)
# Assign revoked_exception = HTTPException(
revoked_exception = HTTPException(
# Keyword argument: status_code
status_code=status.HTTP_401_UNAUTHORIZED,
# Keyword argument: detail
detail="Token has been revoked",
# Keyword argument: headers
headers={"WWW-Authenticate": "Bearer"},
)
# Prefer cookie, fall back to header
token = aegis_token or bearer_token
# Check: token is None
if token is None:
# Raise credentials_exception
raise credentials_exception
# ── API Key path (Bearer token starts with "aegis_") ──────────────────
@@ -110,38 +80,25 @@ async def get_current_user(
# ── JWT path ──────────────────────────────────────────────────────────
try:
# Assign payload = jwt.decode(
payload = jwt.decode(
token,
settings.SECRET_KEY,
# Keyword argument: algorithms
algorithms=[settings.ALGORITHM],
)
# Assign username = payload.get("sub")
username: str | None = payload.get("sub")
# Check: username is None
if username is None:
# Raise credentials_exception
raise credentials_exception
# Check token blacklist (revoked tokens)
jti: str | None = payload.get("jti")
# Check: jti and auth_lib.is_token_blacklisted(jti)
if jti and auth_lib.is_token_blacklisted(jti):
# Raise revoked_exception
raise revoked_exception
# Handle any JWT validation error (expired, invalid signature, malformed)
except jwt.exceptions.InvalidTokenError:
# Raise credentials_exception
except JWTError:
raise credentials_exception
# Assign user = db.query(User).filter(User.username == username).first()
user = db.query(User).filter(User.username == username).first()
# Check: user is None or not user.is_active
if user is None or not user.is_active:
# Raise credentials_exception
raise credentials_exception
# Return user
return user
@@ -151,7 +108,6 @@ async def get_current_user(
async def require_password_changed(
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> User:
"""Block all requests when the user still needs to change their password.
@@ -159,16 +115,11 @@ async def require_password_changed(
Only ``/auth/change-password`` and ``/auth/me`` are exempt — those
endpoints do **not** depend on this function.
"""
# Check: getattr(current_user, "must_change_password", False)
if getattr(current_user, "must_change_password", False):
# Raise HTTPException
raise HTTPException(
# Keyword argument: status_code
status_code=status.HTTP_403_FORBIDDEN,
# Keyword argument: detail
detail="PASSWORD_CHANGE_REQUIRED",
)
# Return current_user
return current_user
@@ -196,30 +147,22 @@ def require_role(required_role: str):
Otherwise it raises :class:`~fastapi.HTTPException` **403**.
"""
# Define async function role_checker
async def role_checker(
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> User:
# Check: current_user.role != required_role and current_user.role != "admin"
if current_user.role != required_role and current_user.role != "admin":
# Raise HTTPException
raise HTTPException(
# Keyword argument: status_code
status_code=status.HTTP_403_FORBIDDEN,
# Keyword argument: detail
detail="Not enough permissions",
)
scope = "admin" if required_role == "admin" else "write"
_check_api_key_scope(current_user, scope)
return current_user
# Return role_checker
return role_checker
# Define function require_any_role
def require_any_role(*roles: str) -> Callable[..., object]:
def require_any_role(*roles: str):
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
Admins always pass. Also enforces API key scopes: if the only accepted
@@ -231,25 +174,18 @@ def require_any_role(*roles: str) -> Callable[..., object]:
@router.patch("/resource", dependencies=[Depends(require_any_role("red_lead", "blue_lead"))])
"""
# Define async function role_checker
async def role_checker(
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> User:
# Check: current_user.role != "admin" and current_user.role not in roles
if current_user.role != "admin" and current_user.role not in roles:
# Raise HTTPException
raise HTTPException(
# Keyword argument: status_code
status_code=status.HTTP_403_FORBIDDEN,
# Keyword argument: detail
detail="Not enough permissions",
)
scope = "admin" if set(roles) == {"admin"} else "write"
_check_api_key_scope(current_user, scope)
return current_user
# Return role_checker
return role_checker
-14
View File
@@ -4,41 +4,27 @@ Wiring lives ONLY in the presentation layer — use cases and services
never know which concrete repository implementation they receive.
"""
# Import Depends from fastapi
from fastapi import Depends
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import from app.infrastructure.persistence.repositories.sa_technique_repository
from app.infrastructure.persistence.repositories.sa_technique_repository import (
SATechniqueRepository,
)
# Import from app.infrastructure.persistence.repositories.sa_test_repository
from app.infrastructure.persistence.repositories.sa_test_repository import (
SATestRepository,
)
# Define function get_technique_repository
def get_technique_repository(
# Entry: db
db: Session = Depends(get_db),
) -> SATechniqueRepository:
"""Provide a TechniqueRepository backed by the current DB session."""
# Return SATechniqueRepository(db)
return SATechniqueRepository(db)
# Define function get_test_repository
def get_test_repository(
# Entry: db
db: Session = Depends(get_db),
) -> SATestRepository:
"""Provide a TestRepository backed by the current DB session."""
# Return SATestRepository(db)
return SATestRepository(db)
-1
View File
@@ -1 +0,0 @@
"""Domain layer — entities, value objects, errors, and repository ports."""
-16
View File
@@ -1,34 +1,18 @@
"""Domain entity classes representing core business objects."""
# Import CampaignEntity from app.domain.entities.campaign
from app.domain.entities.campaign import CampaignEntity
# Import from app.domain.entities.compliance
from app.domain.entities.compliance import (
ComplianceControlEntity,
ComplianceFrameworkEntity,
ControlCoverageStatus,
)
# Import TechniqueEntity from app.domain.entities.technique
from app.domain.entities.technique import TechniqueEntity
# Import ThreatActorEntity, ThreatActorTechniqueRef from app.domain.entities.threat_actor
from app.domain.entities.threat_actor import ThreatActorEntity, ThreatActorTechniqueRef
# Assign __all__ = [
__all__ = [
# Literal argument value
"CampaignEntity",
# Literal argument value
"ComplianceControlEntity",
# Literal argument value
"ComplianceFrameworkEntity",
# Literal argument value
"ControlCoverageStatus",
# Literal argument value
"TechniqueEntity",
# Literal argument value
"ThreatActorEntity",
# Literal argument value
"ThreatActorTechniqueRef",
]
+3 -119
View File
@@ -3,59 +3,30 @@
Pure domain logic — no framework imports.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import enum
import enum
# Import uuid
import uuid
# Import dataclass, field from dataclasses
from dataclasses import dataclass, field
from typing import Any
# Import TYPE_CHECKING from typing
from typing import TYPE_CHECKING
# Import BusinessRuleViolation, InvalidStateTransition from app.domain.errors
from app.domain.errors import BusinessRuleViolation, InvalidStateTransition
# Check: TYPE_CHECKING
if TYPE_CHECKING:
# Import Campaign as CampaignORM from app.models.campaign
from app.models.campaign import Campaign as CampaignORM
# Define class CampaignStatus
class CampaignStatus(str, enum.Enum):
"""Lifecycle states for a campaign."""
# Assign draft = "draft"
draft = "draft"
# Assign active = "active"
active = "active"
# Assign completed = "completed"
completed = "completed"
# Assign archived = "archived"
archived = "archived"
# Define class CampaignType
class CampaignType(str, enum.Enum):
"""Classification of the campaign's testing methodology."""
# Assign custom = "custom"
custom = "custom"
# Assign apt_emulation = "apt_emulation"
apt_emulation = "apt_emulation"
# Assign kill_chain = "kill_chain"
kill_chain = "kill_chain"
# Assign compliance = "compliance"
compliance = "compliance"
# Assign VALID_TRANSITIONS = {
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
CampaignStatus.draft: [CampaignStatus.active],
CampaignStatus.active: [CampaignStatus.completed],
@@ -64,156 +35,69 @@ VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
}
# Apply the @dataclass decorator
@dataclass
# Define class CampaignEntity
class CampaignEntity:
"""Pure domain representation of a security testing campaign.
Owns all lifecycle state-machine logic for campaign activation,
completion, and archival.
"""
# name: str
name: str
# Assign type = CampaignType.custom
type: CampaignType = CampaignType.custom
# Assign status = CampaignStatus.draft
status: CampaignStatus = CampaignStatus.draft
# Assign id = None
id: uuid.UUID | None = None
# Assign description = None
description: str | None = None
# Assign threat_actor_id = None
threat_actor_id: uuid.UUID | None = None
# Assign created_by = None
created_by: uuid.UUID | None = None
# Assign target_platform = None
target_platform: str | None = None
# Assign tags = field(default_factory=list)
tags: list[str] = field(default_factory=list)
# Assign test_count = 0
test_count: int = 0
# Define function can_transition_to
def can_transition_to(self, target: CampaignStatus) -> bool:
"""Check whether transitioning from the current status to *target* is valid.
Args:
target (CampaignStatus): The desired next status.
Returns:
bool: True if the transition is allowed, False otherwise.
"""
# Return target in VALID_TRANSITIONS.get(self.status, [])
return target in VALID_TRANSITIONS.get(self.status, [])
# Define function activate
def activate(self) -> None:
"""Transition the campaign from ``draft`` to ``active``.
Returns:
None
"""
# Check: not self.can_transition_to(CampaignStatus.active)
if not self.can_transition_to(CampaignStatus.active):
# Raise InvalidStateTransition
raise InvalidStateTransition(
self.status.value, CampaignStatus.active.value,
[s.value for s in VALID_TRANSITIONS[self.status]],
)
# Check: self.test_count == 0
if self.test_count == 0:
# Raise BusinessRuleViolation
raise BusinessRuleViolation(
# Literal argument value
"Campaign must have at least one test to activate"
)
# Assign self.status = CampaignStatus.active
self.status = CampaignStatus.active
# Define function complete
def complete(self) -> None:
"""Transition the campaign from ``active`` to ``completed``.
Returns:
None
"""
# Check: not self.can_transition_to(CampaignStatus.completed)
if not self.can_transition_to(CampaignStatus.completed):
# Raise InvalidStateTransition
raise InvalidStateTransition(
self.status.value, CampaignStatus.completed.value,
[s.value for s in VALID_TRANSITIONS[self.status]],
)
# Assign self.status = CampaignStatus.completed
self.status = CampaignStatus.completed
# Define function archive
def archive(self) -> None:
"""Transition the campaign from ``completed`` to ``archived``.
Returns:
None
"""
# Check: not self.can_transition_to(CampaignStatus.archived)
if not self.can_transition_to(CampaignStatus.archived):
# Raise InvalidStateTransition
raise InvalidStateTransition(
self.status.value, CampaignStatus.archived.value,
[s.value for s in VALID_TRANSITIONS[self.status]],
)
# Assign self.status = CampaignStatus.archived
self.status = CampaignStatus.archived
# Define function ensure_modifiable
def ensure_modifiable(self) -> None:
"""Raise BusinessRuleViolation if the campaign is not in a modifiable state.
Returns:
None
"""
# Check: self.status not in (CampaignStatus.draft, CampaignStatus.active)
if self.status not in (CampaignStatus.draft, CampaignStatus.active):
# Raise BusinessRuleViolation
raise BusinessRuleViolation(
f"Cannot modify campaign in '{self.status.value}' state"
)
# Apply the @classmethod decorator
@classmethod
# Define function from_orm
def from_orm(cls, orm: CampaignORM) -> CampaignEntity:
"""Build a CampaignEntity from a SQLAlchemy Campaign model.
Args:
orm (CampaignORM): The SQLAlchemy Campaign ORM model instance.
Returns:
CampaignEntity: A fully populated domain entity reflecting the ORM state.
"""
# Assign test_count = len(getattr(orm, "campaign_tests", None) or [])
def from_orm(cls, orm: Any) -> CampaignEntity:
"""Build a CampaignEntity from a SQLAlchemy Campaign model."""
test_count = len(getattr(orm, "campaign_tests", None) or [])
# Return cls(
return cls(
# Keyword argument: id
id=orm.id,
# Keyword argument: name
name=orm.name,
# Keyword argument: type
type=CampaignType(orm.type) if orm.type else CampaignType.custom,
# Keyword argument: status
status=CampaignStatus(orm.status) if orm.status else CampaignStatus.draft,
# Keyword argument: description
description=orm.description,
# Keyword argument: threat_actor_id
threat_actor_id=orm.threat_actor_id,
# Keyword argument: created_by
created_by=orm.created_by,
# Keyword argument: target_platform
target_platform=orm.target_platform,
# Keyword argument: tags
tags=orm.tags or [],
# Keyword argument: test_count
test_count=test_count,
)
-93
View File
@@ -3,161 +3,68 @@
Pure domain logic — no framework imports.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import enum
import enum
# Import uuid
import uuid
# Import dataclass, field from dataclasses
from dataclasses import dataclass, field
# Define class ControlCoverageStatus
class ControlCoverageStatus(str, enum.Enum):
"""Computed coverage level for a single compliance control."""
# Assign covered = "covered"
covered = "covered"
# Assign partially_covered = "partially_covered"
partially_covered = "partially_covered"
# Assign not_covered = "not_covered"
not_covered = "not_covered"
# Apply the @dataclass decorator
@dataclass
# Define class ComplianceControlEntity
class ComplianceControlEntity:
"""Pure domain representation of a single compliance framework control.
Derives its coverage status from the technique statuses associated
with it via the ``technique_statuses`` list.
"""
# control_id: str
control_id: str
# title: str
title: str
# Assign id = None
id: uuid.UUID | None = None
# Assign description = None
description: str | None = None
# Assign category = None
category: str | None = None
# Assign technique_statuses = field(default_factory=list)
technique_statuses: list[str] = field(default_factory=list)
# Apply the @property decorator
@property
# Define function coverage_status
def coverage_status(self) -> ControlCoverageStatus:
"""Compute the coverage status for this control based on linked technique statuses.
Returns:
ControlCoverageStatus: ``covered`` when all techniques are covered,
``partially_covered`` when at least one is covered, and
``not_covered`` when none are covered or the control has no techniques.
"""
# Check: not self.technique_statuses
if not self.technique_statuses:
# Return ControlCoverageStatus.not_covered
return ControlCoverageStatus.not_covered
# Assign covered_statuses = {"validated", "partial"}
covered_statuses = {"validated", "partial"}
# Assign covered = [s for s in self.technique_statuses if s in covered_statuses]
covered = [s for s in self.technique_statuses if s in covered_statuses]
# Check: len(covered) == len(self.technique_statuses)
if len(covered) == len(self.technique_statuses):
# Return ControlCoverageStatus.covered
return ControlCoverageStatus.covered
# Alternative: len(covered) > 0
elif len(covered) > 0:
# Return ControlCoverageStatus.partially_covered
return ControlCoverageStatus.partially_covered
# Return ControlCoverageStatus.not_covered
return ControlCoverageStatus.not_covered
# Apply the @dataclass decorator
@dataclass
# Define class ComplianceFrameworkEntity
class ComplianceFrameworkEntity:
"""Pure domain representation of a compliance framework (e.g. NIST 800-53, PCI-DSS).
Aggregates a collection of controls and provides aggregate coverage statistics.
"""
# name: str
name: str
# Assign id = None
id: uuid.UUID | None = None
# Assign version = None
version: str | None = None
# Assign description = None
description: str | None = None
# Assign is_active = True
is_active: bool = True
# Assign controls = field(default_factory=list)
controls: list[ComplianceControlEntity] = field(default_factory=list)
# Apply the @property decorator
@property
# Define function total_controls
def total_controls(self) -> int:
"""Return the total number of controls in this framework.
Returns:
int: Count of all controls regardless of coverage status.
"""
# Return len(self.controls)
return len(self.controls)
# Apply the @property decorator
@property
# Define function covered_controls
def covered_controls(self) -> int:
"""Return the number of fully covered controls in this framework.
Returns:
int: Count of controls with ``ControlCoverageStatus.covered`` status.
"""
# Return sum(
return sum(
# Literal argument value
1 for c in self.controls
if c.coverage_status == ControlCoverageStatus.covered
)
# Apply the @property decorator
@property
# Define function coverage_pct
def coverage_pct(self) -> float:
"""Return the percentage of controls that are fully covered.
Returns:
float: A value from 0.0 to 100.0, rounded to one decimal place.
Returns 0.0 when the framework has no controls.
"""
# Check: self.total_controls == 0
if self.total_controls == 0:
# Return 0.0
return 0.0
# Return round(self.covered_controls / self.total_controls * 100, 1)
return round(self.covered_controls / self.total_controls * 100, 1)
# Define function get_gap_controls
def get_gap_controls(self) -> list[ComplianceControlEntity]:
"""Return controls that are not fully covered.
Returns:
list[ComplianceControlEntity]: Controls with ``partially_covered`` or
``not_covered`` status.
"""
# Return [
return [
c for c in self.controls
if c.coverage_status != ControlCoverageStatus.covered
+19 -157
View File
@@ -12,211 +12,105 @@ Usage::
entity.apply_to(technique_orm_model)
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import uuid
import uuid
# Import dataclass, field from dataclasses
from dataclasses import dataclass, field
# Import datetime from datetime
from datetime import datetime
from typing import Any
# Import TYPE_CHECKING from typing
from typing import TYPE_CHECKING
# Import TechniqueStatus, TestResult, TestState from app.domain.enums
from app.domain.enums import TechniqueStatus, TestResult, TestState
# Import MitreId from app.domain.value_objects.mitre_id
from app.domain.value_objects.mitre_id import MitreId
# Check: TYPE_CHECKING
if TYPE_CHECKING:
# Import Technique as TechniqueORM from app.models.technique
from app.models.technique import Technique as TechniqueORM
# Apply the @dataclass decorator
@dataclass(frozen=True)
# Define class _TestSnapshot
class _TestSnapshot:
"""Minimal read-only view of a test for status calculation."""
# state: TestState
state: TestState
# detection_result: str | None
detection_result: str | None
# Apply the @dataclass decorator
@dataclass
# Define class TechniqueEntity
class TechniqueEntity:
"""Pure domain representation of a MITRE ATT&CK technique."""
# id: uuid.UUID
id: uuid.UUID
# mitre_id: str
mitre_id: str
# name: str
name: str
# Assign tactic = None
tactic: str | None = None
# Assign description = None
description: str | None = None
# Assign platforms = field(default_factory=list)
platforms: list[str] = field(default_factory=list)
# Assign is_subtechnique = False
is_subtechnique: bool = False
# Assign parent_mitre_id = None
parent_mitre_id: str | None = None
# Assign status_global = TechniqueStatus.not_evaluated
status_global: TechniqueStatus = TechniqueStatus.not_evaluated
# Assign review_required = False
review_required: bool = False
# Assign last_review_date = None
last_review_date: datetime | None = None
# Assign mitre_version = None
mitre_version: str | None = None
# Assign mitre_last_modified = None
mitre_last_modified: datetime | None = None
# -- Factory -----------------------------------------------------------
@classmethod
# Define function create
def create(
cls,
*,
# Entry: mitre_id
mitre_id: str,
# Entry: name
name: str,
# Entry: tactic
tactic: str | None = None,
# Entry: description
description: str | None = None,
# Entry: platforms
platforms: list[str] | None = None,
) -> TechniqueEntity:
"""Create a new technique, validating the MITRE ID format.
Args:
mitre_id (str): MITRE ATT&CK identifier (e.g. ``"T1059"`` or ``"T1059.001"``).
name (str): Human-readable name of the technique.
tactic (str | None): MITRE tactic category the technique belongs to.
description (str | None): Optional free-text description.
platforms (list[str] | None): List of platform strings the technique applies to.
Returns:
TechniqueEntity: A new entity with a freshly generated UUID and
``status_global`` set to ``not_evaluated``.
"""
# Assign validated_id = MitreId(mitre_id)
"""Create a new technique, validating the MITRE ID format."""
validated_id = MitreId(mitre_id)
# Return cls(
return cls(
# Keyword argument: id
id=uuid.uuid4(),
# Keyword argument: mitre_id
mitre_id=validated_id.value,
# Keyword argument: name
name=name,
# Keyword argument: tactic
tactic=tactic,
# Keyword argument: description
description=description,
# Keyword argument: platforms
platforms=platforms or [],
# Keyword argument: is_subtechnique
is_subtechnique=validated_id.is_subtechnique,
# Keyword argument: parent_mitre_id
parent_mitre_id=validated_id.parent_id,
# Keyword argument: status_global
status_global=TechniqueStatus.not_evaluated,
)
# Apply the @classmethod decorator
@classmethod
# Define function from_orm
def from_orm(cls, model: TechniqueORM) -> TechniqueEntity:
"""Build a TechniqueEntity from a SQLAlchemy Technique model.
Args:
model (TechniqueORM): The ORM model instance to convert.
Returns:
TechniqueEntity: A fully populated domain entity reflecting the ORM state.
"""
# Assign raw_status = model.status_global
def from_orm(cls, model: Any) -> TechniqueEntity:
"""Build a TechniqueEntity from a SQLAlchemy Technique model."""
raw_status = model.status_global
# Check: raw_status is None
if raw_status is None:
# Assign status = TechniqueStatus.not_evaluated
status = TechniqueStatus.not_evaluated
# Alternative: isinstance(raw_status, TechniqueStatus)
elif isinstance(raw_status, TechniqueStatus):
# Assign status = raw_status
status = raw_status
# Fallback: handle remaining cases
else:
# Assign status = TechniqueStatus(raw_status)
status = TechniqueStatus(raw_status)
# Return cls(
return cls(
# Keyword argument: id
id=model.id,
# Keyword argument: mitre_id
mitre_id=model.mitre_id,
# Keyword argument: name
name=model.name,
# Keyword argument: tactic
tactic=model.tactic,
# Keyword argument: description
description=model.description,
# Keyword argument: platforms
platforms=model.platforms or [],
# Keyword argument: is_subtechnique
is_subtechnique=model.is_subtechnique or False,
# Keyword argument: parent_mitre_id
parent_mitre_id=model.parent_mitre_id,
# Keyword argument: status_global
status_global=status,
# Keyword argument: review_required
review_required=model.review_required or False,
# Keyword argument: last_review_date
last_review_date=model.last_review_date,
# Keyword argument: mitre_version
mitre_version=getattr(model, "mitre_version", None),
# Keyword argument: mitre_last_modified
mitre_last_modified=getattr(model, "mitre_last_modified", None),
)
# Define function apply_to
def apply_to(self, model: TechniqueORM) -> None:
"""Copy mutable fields back onto the ORM model.
Args:
model (TechniqueORM): The ORM model to update in-place.
Returns:
None
"""
# Assign model.status_global = self.status_global
def apply_to(self, model: Any) -> None:
"""Copy mutable fields back onto the ORM model."""
model.status_global = self.status_global
# Assign model.review_required = self.review_required
model.review_required = self.review_required
# Assign model.last_review_date = self.last_review_date
model.last_review_date = self.last_review_date
# -- Business logic ----------------------------------------------------
def recalculate_status(
self,
# Entry: test_snapshots
test_snapshots: list[tuple[str, str | None]],
) -> TechniqueStatus:
"""Recompute ``status_global`` from a list of (state, detection_result) pairs.
@@ -224,93 +118,61 @@ class TechniqueEntity:
Rules (v3):
1. No tests -> not_evaluated
2. All tests validated -> inspect detection results:
a. All detected AND ≥ 1 validated test -> validated
b. Any partially_detected -> partial
a. All detected AND ≥ 2 validated tests -> validated
b. All detected but only 1 validated test -> partial
(single test is not enough evidence for full coverage)
c. Any partially_detected -> partial
d. Otherwise (no detected results) -> not_covered
3. Some validated, others in intermediate states -> partial
4. All tests in intermediate states (draft/executing/evaluating/review/rejected)
-> in_progress
Minimum validated count for "validated": 1 test.
Minimum validated count for "validated": 2 tests.
With only 1 validated+detected test the technique is "partial" to
signal that more testing is recommended.
Args:
test_snapshots (list[tuple[str, str | None]]): Each element is a
``(state, detection_result)`` pair where *state* is a
:class:`TestState` value string and *detection_result* is a
:class:`TestResult` value string or ``None``.
Returns:
TechniqueStatus: The newly computed status, which is also stored on
the entity's ``status_global`` field.
Returns the new status (also set on the entity).
"""
min_validated_for_full = 1 # require ≥ N validated tests for "validated"
_MIN_VALIDATED_FOR_FULL = 2 # require ≥ N validated tests for "validated"
tests = [
_TestSnapshot(
# Keyword argument: state
state=s if isinstance(s, TestState) else TestState(s),
# Keyword argument: detection_result
detection_result=dr,
)
for s, dr in test_snapshots
]
# Check: not tests
if not tests:
# Assign self.status_global = TechniqueStatus.not_evaluated
self.status_global = TechniqueStatus.not_evaluated
# Alternative: all(t.state == TestState.validated for t in tests)
elif all(t.state == TestState.validated for t in tests):
validated_count = len(tests)
results = [t.detection_result for t in tests if t.detection_result]
# Check: results and all(r == TestResult.detected or r == "detected" for r i...
if results and all(r == TestResult.detected or r == "detected" for r in results):
# Need at least min_validated_for_full tests for "validated"
if validated_count >= min_validated_for_full:
# Need at least _MIN_VALIDATED_FOR_FULL tests for "validated"
if validated_count >= _MIN_VALIDATED_FOR_FULL:
self.status_global = TechniqueStatus.validated
else:
self.status_global = TechniqueStatus.partial
elif any(
# Keyword argument: r
r == TestResult.partially_detected or r == "partially_detected"
for r in results
):
# Assign self.status_global = TechniqueStatus.partial
self.status_global = TechniqueStatus.partial
# Fallback: handle remaining cases
else:
# Assign self.status_global = TechniqueStatus.not_covered
self.status_global = TechniqueStatus.not_covered
# Alternative: any(t.state == TestState.validated for t in tests)
elif any(t.state == TestState.validated for t in tests):
# Assign self.status_global = TechniqueStatus.partial
self.status_global = TechniqueStatus.partial
# Fallback: handle remaining cases
else:
# Assign self.status_global = TechniqueStatus.in_progress
self.status_global = TechniqueStatus.in_progress
# Return self.status_global
return self.status_global
# Define function mark_reviewed
def mark_reviewed(self) -> None:
"""Mark the technique as reviewed, clearing the review flag.
Returns:
None
"""
# Assign self.review_required = False
"""Mark the technique as reviewed, clearing the review flag."""
self.review_required = False
# Assign self.last_review_date = datetime.utcnow()
self.last_review_date = datetime.utcnow()
# Define function flag_for_review
def flag_for_review(self) -> None:
"""Flag the technique as needing review.
Returns:
None
"""
# Assign self.review_required = True
"""Flag the technique as needing review."""
self.review_required = True
+2 -112
View File
@@ -3,204 +3,94 @@
Pure domain logic — no framework imports.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import uuid
import uuid
# Import dataclass, field from dataclasses
from dataclasses import dataclass, field
# Import TYPE_CHECKING from typing
from typing import TYPE_CHECKING
# Check: TYPE_CHECKING
if TYPE_CHECKING:
# Import ThreatActor as ThreatActorORM from app.models.threat_actor
from app.models.threat_actor import ThreatActor as ThreatActorORM
from typing import Any
# Apply the @dataclass decorator
@dataclass
# Define class ThreatActorTechniqueRef
class ThreatActorTechniqueRef:
"""Lightweight reference to a technique used by an actor."""
# technique_id: uuid.UUID
technique_id: uuid.UUID
# Assign mitre_id = None
mitre_id: str | None = None
# Assign name = None
name: str | None = None
# Assign status = None
status: str | None = None
# Assign usage_description = None
usage_description: str | None = None
# Apply the @dataclass decorator
@dataclass
# Define class ThreatActorEntity
class ThreatActorEntity:
"""Pure domain representation of a MITRE ATT&CK threat actor (group).
Aggregates references to the techniques the actor is known to use and
provides coverage analysis properties.
"""
# name: str
name: str
# Assign id = None
id: uuid.UUID | None = None
# Assign mitre_id = None
mitre_id: str | None = None
# Assign aliases = field(default_factory=list)
aliases: list[str] = field(default_factory=list)
# Assign description = None
description: str | None = None
# Assign country = None
country: str | None = None
# Assign target_sectors = field(default_factory=list)
target_sectors: list[str] = field(default_factory=list)
# Assign target_regions = field(default_factory=list)
target_regions: list[str] = field(default_factory=list)
# Assign motivation = None
motivation: str | None = None
# Assign sophistication = None
sophistication: str | None = None
# Assign first_seen = None
first_seen: str | None = None
# Assign last_seen = None
last_seen: str | None = None
# Assign is_active = True
is_active: bool = True
# Assign techniques = field(default_factory=list)
techniques: list[ThreatActorTechniqueRef] = field(default_factory=list)
# Apply the @property decorator
@property
# Define function technique_count
def technique_count(self) -> int:
"""Return the total number of techniques associated with this actor.
Returns:
int: Count of technique references.
"""
# Return len(self.techniques)
return len(self.techniques)
# Apply the @property decorator
@property
# Define function covered_techniques
def covered_techniques(self) -> list[ThreatActorTechniqueRef]:
"""Return technique references whose coverage status is ``validated`` or ``partial``.
Returns:
list[ThreatActorTechniqueRef]: Subset of techniques considered covered.
"""
# Return [
return [
t for t in self.techniques
if t.status in ("validated", "partial")
]
# Apply the @property decorator
@property
# Define function uncovered_techniques
def uncovered_techniques(self) -> list[ThreatActorTechniqueRef]:
"""Return technique references whose coverage status is neither ``validated`` nor ``partial``.
Returns:
list[ThreatActorTechniqueRef]: Subset of techniques not yet covered.
"""
# Return [
return [
t for t in self.techniques
if t.status not in ("validated", "partial")
]
# Apply the @property decorator
@property
# Define function coverage_pct
def coverage_pct(self) -> float:
"""Return the percentage of the actor's techniques that are covered.
Returns:
float: A value from 0.0 to 100.0, rounded to one decimal place.
Returns 0.0 when the actor has no associated techniques.
"""
# Check: not self.techniques
if not self.techniques:
# Return 0.0
return 0.0
# Return round(len(self.covered_techniques) / len(self.techniques) * 100, 1)
return round(len(self.covered_techniques) / len(self.techniques) * 100, 1)
# Apply the @classmethod decorator
@classmethod
# Define function from_orm
def from_orm(cls, orm: ThreatActorORM) -> ThreatActorEntity:
"""Build a ThreatActorEntity from a SQLAlchemy ThreatActor model.
Args:
orm (ThreatActorORM): The ORM model instance to convert.
Returns:
ThreatActorEntity: A fully populated domain entity including
technique references resolved from the ORM relationship.
"""
# Assign techs = []
def from_orm(cls, orm: Any) -> ThreatActorEntity:
techs: list[ThreatActorTechniqueRef] = []
# Iterate over getattr(orm, "techniques", None) or []
for tat in getattr(orm, "techniques", None) or []:
# Assign technique = getattr(tat, "technique", None)
technique = getattr(tat, "technique", None)
# Call techs.append()
techs.append(ThreatActorTechniqueRef(
# Keyword argument: technique_id
technique_id=tat.technique_id,
# Keyword argument: mitre_id
mitre_id=getattr(technique, "mitre_id", None) if technique else None,
# Keyword argument: name
name=getattr(technique, "name", None) if technique else None,
# Keyword argument: status
status=(
technique.status_global.value
if technique and hasattr(technique.status_global, "value")
else getattr(technique, "status_global", None) if technique else None
),
# Keyword argument: usage_description
usage_description=tat.usage_description,
))
# Return cls(
return cls(
# Keyword argument: id
id=orm.id,
# Keyword argument: name
name=orm.name,
# Keyword argument: mitre_id
mitre_id=orm.mitre_id,
# Keyword argument: aliases
aliases=orm.aliases or [],
# Keyword argument: description
description=orm.description,
# Keyword argument: country
country=orm.country,
# Keyword argument: target_sectors
target_sectors=orm.target_sectors or [],
# Keyword argument: target_regions
target_regions=orm.target_regions or [],
# Keyword argument: motivation
motivation=orm.motivation,
# Keyword argument: sophistication
sophistication=orm.sophistication,
# Keyword argument: first_seen
first_seen=orm.first_seen,
# Keyword argument: last_seen
last_seen=orm.last_seen,
# Keyword argument: is_active
is_active=orm.is_active if orm.is_active is not None else True,
# Keyword argument: techniques
techniques=techs,
)
-37
View File
@@ -5,78 +5,41 @@ truth. ``models/enums.py`` re-exports them so that existing ORM code
continues to work without changes.
"""
# Import enum
import enum
# Define class TechniqueStatus
class TechniqueStatus(str, enum.Enum):
"""Coverage and evaluation status for a MITRE ATT&CK technique."""
# Assign not_evaluated = "not_evaluated"
not_evaluated = "not_evaluated"
# Assign in_progress = "in_progress"
in_progress = "in_progress"
# Assign validated = "validated"
validated = "validated"
# Assign partial = "partial"
partial = "partial"
# Assign not_covered = "not_covered"
not_covered = "not_covered"
# Assign review_required = "review_required"
review_required = "review_required"
# Define class TestState
class TestState(str, enum.Enum):
"""Lifecycle states in the security test state machine."""
# Assign draft = "draft"
draft = "draft"
# Assign red_executing = "red_executing"
red_executing = "red_executing"
# Assign blue_evaluating = "blue_evaluating"
blue_evaluating = "blue_evaluating"
# Assign in_review = "in_review"
in_review = "in_review"
# Assign validated = "validated"
validated = "validated"
# Assign rejected = "rejected"
rejected = "rejected"
disputed = "disputed" # one lead approved, the other rejected
# Define class TeamSide
class TeamSide(str, enum.Enum):
"""Identifies which team (red or blue) an action belongs to."""
# Assign red = "red"
red = "red"
# Assign blue = "blue"
blue = "blue"
# Define class TestResult
class TestResult(str, enum.Enum):
"""Outcome of a red-team test from a detection perspective."""
# Assign detected = "detected"
detected = "detected"
# Assign not_detected = "not_detected"
not_detected = "not_detected"
# Assign partially_detected = "partially_detected"
partially_detected = "partially_detected"
# Define class DataClassification
class DataClassification(str, enum.Enum):
"""Data sensitivity classification levels for compliance and retention policies."""
# Assign public = "public"
public = "public"
# Assign internal = "internal"
internal = "internal"
# Assign sensitive = "sensitive"
sensitive = "sensitive"
# Assign restricted = "restricted"
restricted = "restricted"
+3 -99
View File
@@ -9,30 +9,15 @@ Existing code that imports from ``app.domain.exceptions`` continues to
work — that module re-exports everything defined here.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Define class DomainError
class DomainError(Exception):
"""Base for all domain errors."""
# Define function __init__
def __init__(self, message: str, *, code: str = "DOMAIN_ERROR") -> None:
"""Initialise the domain error with a human-readable message and error code.
Args:
message (str): Human-readable description of the error.
code (str): Machine-readable error code used by the HTTP error handler.
Returns:
None
"""
# Assign self.message = message
self.message = message
# Assign self.code = code
self.code = code
# Call super()
super().__init__(message)
@@ -42,45 +27,18 @@ class DomainError(Exception):
class EntityNotFoundError(DomainError):
"""A requested entity does not exist."""
# Define function __init__
def __init__(self, entity: str, identifier: str) -> None:
"""Initialise an entity-not-found error.
Args:
entity (str): Name of the entity type that was not found (e.g. "Technique").
identifier (str): The ID or key used in the failed lookup.
Returns:
None
"""
# Call super()
super().__init__(f"{entity} not found: {identifier}", code="NOT_FOUND")
# Assign self.entity = entity
self.entity = entity
# Assign self.identifier = identifier
self.identifier = identifier
# Define class DuplicateEntityError
class DuplicateEntityError(DomainError):
"""Creating an entity that already exists."""
# Define function __init__
def __init__(self, entity: str, field: str, value: str) -> None:
"""Initialise a duplicate-entity error.
Args:
entity (str): Name of the entity type that already exists (e.g. "Campaign").
field (str): Name of the field whose value conflicts (e.g. "name").
value (str): The conflicting value that is already in use.
Returns:
None
"""
# Call super()
super().__init__(
f"{entity} with {field}='{value}' already exists",
# Keyword argument: code
code="DUPLICATE",
)
@@ -88,67 +46,34 @@ class DuplicateEntityError(DomainError):
# ── State machine ────────────────────────────────────────────────────
class InvalidStateTransition(DomainError): # noqa: N818 — DDD term, renaming would break 96 call sites
class InvalidStateTransition(DomainError):
"""A state-machine transition is not allowed."""
# Define function __init__
def __init__(
self,
# Entry: current_state
current_state: str,
# Entry: target_state
target_state: str,
# Entry: valid_transitions
valid_transitions: list[str] | None = None,
) -> None:
"""Initialise an invalid state-transition error.
Args:
current_state (str): The entity's present state (e.g. "draft").
target_state (str): The state that was illegally requested.
valid_transitions (list[str] | None): Allowed target states from the
current state; included in the error message when provided.
Returns:
None
"""
# Assign msg = f"Cannot transition from '{current_state}' to '{target_state}'"
msg = f"Cannot transition from '{current_state}' to '{target_state}'"
# Check: valid_transitions
if valid_transitions:
# Assign msg = f". Valid transitions: {valid_transitions}"
msg += f". Valid transitions: {valid_transitions}"
# Call super()
super().__init__(msg, code="INVALID_TRANSITION")
# Assign self.current_state = current_state
self.current_state = current_state
# Assign self.target_state = target_state
self.target_state = target_state
# Assign self.valid_transitions = valid_transitions or []
self.valid_transitions = valid_transitions or []
# ── Business rules ────────────────────────────────────────────────────
class BusinessRuleViolation(DomainError): # noqa: N818 — DDD term, renaming would break 96 call sites
class BusinessRuleViolation(DomainError):
"""An operation violates a business invariant."""
# Define function __init__
def __init__(self, message: str) -> None:
"""Initialise a business-rule violation error.
Args:
message (str): Human-readable description of the violated rule.
Returns:
None
"""
# Call super()
super().__init__(message, code="BUSINESS_RULE_VIOLATION")
# Define class InvalidOperationError
class InvalidOperationError(BusinessRuleViolation):
"""An operation is invalid in the current context.
@@ -156,37 +81,16 @@ class InvalidOperationError(BusinessRuleViolation):
:class:`BusinessRuleViolation` directly.
"""
# Define function __init__
def __init__(self, message: str) -> None:
"""Initialise an invalid-operation error.
Args:
message (str): Human-readable description of why the operation is invalid.
Returns:
None
"""
# Call super()
super().__init__(message)
# Assign self.code = "INVALID_OPERATION"
self.code = "INVALID_OPERATION"
# ── Authorization ────────────────────────────────────────────────────
class PermissionViolation(DomainError): # noqa: N818 — DDD term, renaming would break 96 call sites
class PermissionViolation(DomainError):
"""The user lacks permissions for an action."""
# Define function __init__
def __init__(self, message: str = "Insufficient permissions") -> None:
"""Initialise a permission-violation error.
Args:
message (str): Human-readable description of the access denial.
Returns:
None
"""
# Call super()
super().__init__(message, code="FORBIDDEN")
-3
View File
@@ -6,7 +6,6 @@ old import paths so that existing code keeps working without changes::
from app.domain.exceptions import InvalidTransitionError # still works
"""
# Import # noqa: F401 from app.domain.errors
from app.domain.errors import ( # noqa: F401
BusinessRuleViolation,
DomainError,
@@ -19,7 +18,5 @@ from app.domain.errors import ( # noqa: F401
# Legacy aliases — old name → new name
DomainException = DomainError
# Assign InvalidTransitionError = InvalidStateTransition
InvalidTransitionError = InvalidStateTransition
# Assign AuthorizationError = PermissionViolation
AuthorizationError = PermissionViolation
-1
View File
@@ -1 +0,0 @@
"""Abstract port interfaces that infrastructure adapters must implement."""
+1 -78
View File
@@ -12,19 +12,14 @@ This satisfies the Open/Closed Principle — the system is open for new
import sources without modifying existing code.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import Any, Protocol, runtime_checkable from typing
from typing import Any, Protocol, runtime_checkable
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Apply the @runtime_checkable decorator
@runtime_checkable
# Define class ImportService
class ImportService(Protocol):
"""Contract for any data-import operation.
@@ -32,134 +27,62 @@ class ImportService(Protocol):
downloads, parses, and upserts records from an external source.
"""
# Define function __call__
def __call__(self, db: Session) -> dict[str, Any]:
"""Execute the import operation against the given database session.
Args:
db (Session): Active SQLAlchemy session to use for all DB operations.
Returns:
dict[str, Any]: Summary statistics for the import run (e.g. created,
updated, skipped counts).
"""
# ...
...
def __call__(self, db: Session) -> dict[str, Any]: ...
# Define class ImportServiceEntry
class ImportServiceEntry:
"""Lazy-loading wrapper that resolves a module-level function on first call."""
# Assign __slots__ = ("_module_path", "_func_name", "_resolved")
__slots__ = ("_module_path", "_func_name", "_resolved")
# Define function __init__
def __init__(self, module_path: str, func_name: str) -> None:
"""Initialise the lazy entry with the module path and function name to resolve later.
Args:
module_path (str): Dotted Python module path, e.g.
``"app.services.atomic_import_service"``.
func_name (str): Name of the callable to import from *module_path*.
Returns:
None
"""
# Assign self._module_path = module_path
self._module_path = module_path
# Assign self._func_name = func_name
self._func_name = func_name
# Assign self._resolved = None
self._resolved: ImportService | None = None
# Define function __call__
def __call__(self, db: Session) -> dict[str, Any]:
"""Resolve the import function on first call and invoke it with *db*.
Args:
db (Session): SQLAlchemy session passed through to the underlying
import function.
Returns:
dict[str, Any]: Import statistics returned by the underlying function
(e.g. counts of created/updated/skipped records).
"""
# Check: self._resolved is None
if self._resolved is None:
# Import importlib
import importlib
# Assign mod = importlib.import_module(self._module_path)
mod = importlib.import_module(self._module_path)
# Assign self._resolved = getattr(mod, self._func_name)
self._resolved = getattr(mod, self._func_name)
# Return self._resolved(db)
return self._resolved(db)
# Apply the @property decorator
@property
# Define function source_info
def source_info(self) -> str:
"""Return a human-readable identifier for this import entry.
Returns:
str: The fully qualified function reference as
``"<module_path>.<func_name>"``.
"""
# Return f"{self._module_path}.{self._func_name}"
return f"{self._module_path}.{self._func_name}"
# Assign IMPORT_REGISTRY = {
IMPORT_REGISTRY: dict[str, ImportServiceEntry] = {
# Literal argument value
"atomic_red_team": ImportServiceEntry(
# Literal argument value
"app.services.atomic_import_service", "import_atomic_red_team",
),
# Literal argument value
"sigma": ImportServiceEntry(
# Literal argument value
"app.services.sigma_import_service", "sync",
),
# Literal argument value
"lolbas": ImportServiceEntry(
# Literal argument value
"app.services.lolbas_import_service", "sync",
),
# Literal argument value
"gtfobins": ImportServiceEntry(
# Literal argument value
"app.services.lolbas_import_service", "sync_gtfobins",
),
# Literal argument value
"caldera": ImportServiceEntry(
# Literal argument value
"app.services.caldera_import_service", "sync",
),
# Literal argument value
"elastic_rules": ImportServiceEntry(
# Literal argument value
"app.services.elastic_import_service", "sync",
),
# Literal argument value
"mitre_cti": ImportServiceEntry(
# Literal argument value
"app.services.threat_actor_import_service", "sync",
),
# Literal argument value
"d3fend": ImportServiceEntry(
# Literal argument value
"app.services.d3fend_import_service", "sync",
),
}
# Define function get_import_handler
def get_import_handler(source_name: str) -> ImportServiceEntry | None:
"""Look up the import handler for *source_name*.
Returns ``None`` when no handler is registered.
"""
# Return IMPORT_REGISTRY.get(source_name)
return IMPORT_REGISTRY.get(source_name)
@@ -1,9 +1,4 @@
"""Abstract repository port interfaces for domain entity persistence."""
# Import TechniqueRepository from app.domain.ports.repositories.technique_repository
from app.domain.ports.repositories.technique_repository import TechniqueRepository
# Import TestRepository from app.domain.ports.repositories.test_repository
from app.domain.ports.repositories.test_repository import TestRepository
# Assign __all__ = ["TechniqueRepository", "TestRepository"]
__all__ = ["TechniqueRepository", "TestRepository"]
@@ -4,157 +4,54 @@ This is a domain contract — implementations live in infrastructure/.
The domain layer NEVER imports the implementation.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import uuid
import uuid
# Import NamedTuple, Protocol, runtime_checkable from typing
from typing import NamedTuple, Protocol, runtime_checkable
# Import TechniqueEntity from app.domain.entities.technique
from app.domain.entities.technique import TechniqueEntity
# Import TechniqueStatus from app.domain.enums
from app.domain.enums import TechniqueStatus
# Define class TechniqueWithCounts
class TechniqueWithCounts(NamedTuple):
"""Pre-aggregated technique data for heatmap/scoring."""
# entity: TechniqueEntity
entity: TechniqueEntity
# test_count: int
test_count: int
# validated_test_count: int
validated_test_count: int
# detection_rule_count: int
detection_rule_count: int
# Apply the @runtime_checkable decorator
@runtime_checkable
# Define class TechniqueRepository
class TechniqueRepository(Protocol):
"""Data access contract for techniques (one per aggregate root)."""
# -- Single-entity access ----------------------------------------------
def find_by_id(self, technique_id: uuid.UUID) -> TechniqueEntity | None:
"""Return the technique with the given primary key, or None if absent.
def find_by_id(self, technique_id: uuid.UUID) -> TechniqueEntity | None: ...
Args:
technique_id (uuid.UUID): Primary key of the technique to look up.
Returns:
TechniqueEntity | None: The matching entity, or None if not found.
"""
# ...
...
# Define function find_by_mitre_id
def find_by_mitre_id(self, mitre_id: str) -> TechniqueEntity | None:
"""Return the technique matching the given MITRE ATT&CK identifier, or None.
Args:
mitre_id (str): MITRE ATT&CK ID (e.g. ``"T1059"`` or ``"T1059.001"``).
Returns:
TechniqueEntity | None: The matching entity, or None if not found.
"""
# ...
...
def find_by_mitre_id(self, mitre_id: str) -> TechniqueEntity | None: ...
# -- List access -------------------------------------------------------
def list_all(
self,
*,
# Entry: tactic
tactic: str | None = None,
# Entry: status
status: TechniqueStatus | None = None,
# Entry: review_required
review_required: bool | None = None,
) -> list[TechniqueEntity]:
"""Return all techniques, optionally filtered by tactic, status, or review flag.
) -> list[TechniqueEntity]: ...
Args:
tactic (str | None): When provided, restrict results to this tactic category.
status (TechniqueStatus | None): When provided, restrict results to this status.
review_required (bool | None): When provided, restrict results to techniques
whose ``review_required`` flag matches this value.
Returns:
list[TechniqueEntity]: Matching technique entities; may be empty.
"""
# ...
...
# Define function list_by_ids
def list_by_ids(self, ids: list[uuid.UUID]) -> list[TechniqueEntity]:
"""Return all techniques whose primary keys are in *ids*.
Args:
ids (list[uuid.UUID]): List of technique UUIDs to retrieve.
Returns:
list[TechniqueEntity]: Entities found for the supplied IDs; order
is not guaranteed and missing IDs are silently omitted.
"""
# ...
...
def list_by_ids(self, ids: list[uuid.UUID]) -> list[TechniqueEntity]: ...
# -- Batch queries (scoring/heatmap performance) -----------------------
def count_by_status(self) -> dict[TechniqueStatus, int]:
"""Return a count of techniques grouped by their global status.
def count_by_status(self) -> dict[TechniqueStatus, int]: ...
Returns:
dict[TechniqueStatus, int]: Mapping from each status value to the
number of techniques in that state.
"""
# ...
...
# Define function find_all_with_test_counts
def find_all_with_test_counts(self) -> list[TechniqueWithCounts]:
"""Return all techniques together with pre-aggregated test and rule counts.
Returns:
list[TechniqueWithCounts]: Each element bundles a TechniqueEntity
with its total, validated, and detection-rule counts for use
in heatmap and scoring calculations.
"""
# ...
...
def find_all_with_test_counts(self) -> list[TechniqueWithCounts]: ...
# -- Mutations ---------------------------------------------------------
def save(self, technique: TechniqueEntity) -> TechniqueEntity:
"""Persist a technique entity and return the saved state.
def save(self, technique: TechniqueEntity) -> TechniqueEntity: ...
Args:
technique (TechniqueEntity): The entity to create or update.
Returns:
TechniqueEntity: The persisted entity, potentially with updated
fields (e.g. server-side timestamps).
"""
# ...
...
# Define function exists_by_mitre_id
def exists_by_mitre_id(self, mitre_id: str) -> bool:
"""Return True if a technique with the given MITRE ID exists in the repository.
Args:
mitre_id (str): MITRE ATT&CK ID to check (e.g. ``"T1059"``).
Returns:
bool: True if a matching technique is found, False otherwise.
"""
# ...
...
def exists_by_mitre_id(self, mitre_id: str) -> bool: ...
@@ -3,20 +3,14 @@
This is a domain contract — implementations live in infrastructure/.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import uuid
import uuid
from typing import Protocol, runtime_checkable
# Import Protocol from typing
from typing import Protocol
# Import TestState from app.domain.enums
from app.domain.enums import TestState
# Define class TestRepository
class TestRepository(Protocol):
"""Data access contract for tests."""
@@ -28,81 +22,31 @@ class TestRepository(Protocol):
Returns the ORM model directly (not a domain entity) because
the TestEntity is constructed at the service layer via
``TestEntity.from_orm()``.
Args:
test_id (uuid.UUID): Primary key of the test to look up.
Returns:
object | None: The ORM model instance, or None if not found.
"""
# ...
...
# -- List access -------------------------------------------------------
def list_by_technique(self, technique_id: uuid.UUID) -> list[object]:
"""Return all test ORM models associated with the given technique.
def list_by_technique(self, technique_id: uuid.UUID) -> list[object]: ...
Args:
technique_id (uuid.UUID): Primary key of the technique whose tests to retrieve.
def list_by_state(self, state: TestState) -> list[object]: ...
Returns:
list[object]: ORM model instances for all tests linked to this technique.
"""
# ...
...
# Define function list_by_state
def list_by_state(self, state: TestState) -> list[object]:
"""Return all test ORM models in the given state.
Args:
state (TestState): The state to filter tests by.
Returns:
list[object]: ORM model instances for all tests currently in *state*.
"""
# ...
...
# Define function count_by_technique_and_state
def count_by_technique_and_state(
self,
# Entry: technique_id
technique_id: uuid.UUID,
) -> dict[TestState, int]:
"""Return test counts grouped by state for a single technique.
Args:
technique_id (uuid.UUID): Primary key of the technique whose test
counts to aggregate.
Returns:
dict[TestState, int]: Mapping from each test state to the number of
tests in that state for the given technique.
"""
# ...
"""Return test counts grouped by state for a single technique."""
...
# -- Batch queries -----------------------------------------------------
def get_states_and_results_for_technique(
self,
# Entry: technique_id
technique_id: uuid.UUID,
) -> list[tuple[str, str | None]]:
"""Return (state, detection_result) pairs for all tests of a technique.
Used by TechniqueEntity.recalculate_status() without loading full
test models.
Args:
technique_id (uuid.UUID): Primary key of the technique whose test
data to retrieve.
Returns:
list[tuple[str, str | None]]: Each tuple contains the test state
string and the detection result string (or None if not yet set).
"""
# ...
...
+22 -311
View File
@@ -20,58 +20,34 @@ After mutations, the service layer copies ``entity.changes`` back onto
the ORM model and persists via Unit of Work.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import enum
import enum
# Import uuid
import uuid
# Import dataclass, field from dataclasses
from dataclasses import dataclass, field
# Import datetime from datetime
from datetime import datetime
from typing import Any
# Import TYPE_CHECKING, Any from typing
from typing import TYPE_CHECKING, Any
# Import from app.domain.errors
from app.domain.errors import (
BusinessRuleViolation,
InvalidOperationError,
InvalidStateTransition,
)
# Check: TYPE_CHECKING
if TYPE_CHECKING:
# Import Test as TestORM from app.models.test
from app.models.test import Test as TestORM
# ── Value objects ────────────────────────────────────────────────────
class TestState(str, enum.Enum):
"""Ordered lifecycle states for a security test."""
# Assign draft = "draft"
draft = "draft"
# Assign red_executing = "red_executing"
red_executing = "red_executing"
# Assign blue_evaluating = "blue_evaluating"
blue_evaluating = "blue_evaluating"
# Assign in_review = "in_review"
in_review = "in_review"
# Assign validated = "validated"
validated = "validated"
# Assign rejected = "rejected"
rejected = "rejected"
disputed = "disputed" # one lead approved, the other rejected
# Assign VALID_TRANSITIONS = {
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
TestState.draft: [TestState.red_executing],
TestState.red_executing: [TestState.blue_evaluating],
@@ -82,7 +58,6 @@ VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
TestState.validated: [],
}
# Assign _PAUSABLE_STATES = frozenset({TestState.red_executing, TestState.blue_evaluating})
_PAUSABLE_STATES = frozenset({TestState.red_executing, TestState.blue_evaluating})
@@ -90,13 +65,8 @@ _PAUSABLE_STATES = frozenset({TestState.red_executing, TestState.blue_evaluating
@dataclass(frozen=True)
# Define class DomainEvent
class DomainEvent:
"""Immutable record of a domain-level event emitted by the test entity."""
# name: str
name: str
# Assign payload = field(default_factory=dict)
payload: dict[str, Any] = field(default_factory=dict)
@@ -104,44 +74,30 @@ class DomainEvent:
@dataclass
# Define class TestEntity
class TestEntity:
"""Pure domain representation of a security test."""
# id: uuid.UUID
id: uuid.UUID
# state: TestState
state: TestState
# Red validation
red_validation_status: str | None = None
# Assign red_validated_by = None
red_validated_by: uuid.UUID | None = None
# Assign red_validated_at = None
red_validated_at: datetime | None = None
# Assign red_validation_notes = None
red_validation_notes: str | None = None
# Blue validation
blue_validation_status: str | None = None
# Assign blue_validated_by = None
blue_validated_by: uuid.UUID | None = None
# Assign blue_validated_at = None
blue_validated_at: datetime | None = None
# Assign blue_validation_notes = None
blue_validation_notes: str | None = None
# Phase timing
execution_date: datetime | None = None
# Assign red_started_at = None
red_started_at: datetime | None = None
# Assign blue_started_at = None
blue_started_at: datetime | None = None
# Assign paused_at = None
paused_at: datetime | None = None
# Assign red_paused_seconds = 0
red_paused_seconds: int = 0
# Assign blue_paused_seconds = 0
blue_paused_seconds: int = 0
# Internal bookkeeping (not persisted as-is)
@@ -150,134 +106,58 @@ class TestEntity:
# -- Factory --------------------------------------------------------
@classmethod
# Define function from_orm
def from_orm(cls, model: TestORM) -> TestEntity:
"""Build a TestEntity from a SQLAlchemy ``Test`` model instance.
Args:
model (TestORM): The ORM model whose fields will be copied into the entity.
Returns:
TestEntity: A fully populated domain entity reflecting the ORM state.
"""
# Assign raw_state = model.state
def from_orm(cls, model: Any) -> TestEntity:
"""Build a TestEntity from a SQLAlchemy ``Test`` model instance."""
raw_state = model.state
# Assign state = raw_state if isinstance(raw_state, TestState) else TestState(raw_st...
state = raw_state if isinstance(raw_state, TestState) else TestState(raw_state)
# Return cls(
return cls(
# Keyword argument: id
id=model.id,
# Keyword argument: state
state=state,
# Keyword argument: red_validation_status
red_validation_status=model.red_validation_status,
# Keyword argument: red_validated_by
red_validated_by=model.red_validated_by,
# Keyword argument: red_validated_at
red_validated_at=model.red_validated_at,
# Keyword argument: red_validation_notes
red_validation_notes=model.red_validation_notes,
# Keyword argument: blue_validation_status
blue_validation_status=model.blue_validation_status,
# Keyword argument: blue_validated_by
blue_validated_by=model.blue_validated_by,
# Keyword argument: blue_validated_at
blue_validated_at=model.blue_validated_at,
# Keyword argument: blue_validation_notes
blue_validation_notes=model.blue_validation_notes,
# Keyword argument: execution_date
execution_date=model.execution_date,
# Keyword argument: red_started_at
red_started_at=model.red_started_at,
# Keyword argument: blue_started_at
blue_started_at=model.blue_started_at,
# Keyword argument: paused_at
paused_at=model.paused_at,
# Keyword argument: red_paused_seconds
red_paused_seconds=model.red_paused_seconds or 0,
# Keyword argument: blue_paused_seconds
blue_paused_seconds=model.blue_paused_seconds or 0,
)
# Define function apply_to
def apply_to(self, model: TestORM) -> None:
"""Copy the entity's mutable fields back onto the ORM model.
Args:
model (TestORM): The ORM model to update in-place.
Returns:
None
"""
# Assign model.state = self.state
def apply_to(self, model: Any) -> None:
"""Copy the entity's mutable fields back onto the ORM model."""
model.state = self.state
# Assign model.red_validation_status = self.red_validation_status
model.red_validation_status = self.red_validation_status
# Assign model.red_validated_by = self.red_validated_by
model.red_validated_by = self.red_validated_by
# Assign model.red_validated_at = self.red_validated_at
model.red_validated_at = self.red_validated_at
# Assign model.red_validation_notes = self.red_validation_notes
model.red_validation_notes = self.red_validation_notes
# Assign model.blue_validation_status = self.blue_validation_status
model.blue_validation_status = self.blue_validation_status
# Assign model.blue_validated_by = self.blue_validated_by
model.blue_validated_by = self.blue_validated_by
# Assign model.blue_validated_at = self.blue_validated_at
model.blue_validated_at = self.blue_validated_at
# Assign model.blue_validation_notes = self.blue_validation_notes
model.blue_validation_notes = self.blue_validation_notes
# Assign model.execution_date = self.execution_date
model.execution_date = self.execution_date
# Assign model.red_started_at = self.red_started_at
model.red_started_at = self.red_started_at
# Assign model.blue_started_at = self.blue_started_at
model.blue_started_at = self.blue_started_at
# Assign model.paused_at = self.paused_at
model.paused_at = self.paused_at
# Assign model.red_paused_seconds = self.red_paused_seconds
model.red_paused_seconds = self.red_paused_seconds
# Assign model.blue_paused_seconds = self.blue_paused_seconds
model.blue_paused_seconds = self.blue_paused_seconds
# -- Query helpers --------------------------------------------------
@property
# Define function events
def events(self) -> list[DomainEvent]:
"""Return a snapshot of all domain events raised on this entity.
Returns:
list[DomainEvent]: Ordered list of events emitted since the entity
was constructed or last cleared.
"""
# Return list(self._events)
return list(self._events)
# Define function can_transition
def can_transition(self, target: TestState) -> bool:
"""Check whether a transition from the current state to *target* is valid.
Args:
target (TestState): The desired next state.
Returns:
bool: True if the transition is allowed, False otherwise.
"""
# Return target in VALID_TRANSITIONS.get(self.state, [])
return target in VALID_TRANSITIONS.get(self.state, [])
# Apply the @property decorator
@property
# Define function is_terminal
def is_terminal(self) -> bool:
"""Return True if the test has reached its final (validated) state.
Returns:
bool: True when state is ``validated``, False for all other states.
"""
# Return self.state == TestState.validated
return self.state == TestState.validated
# -- Core transition ------------------------------------------------
@@ -291,305 +171,148 @@ class TestEntity:
Returns the *previous* state value as a plain string.
Raises :class:`InvalidStateTransition` when the move is illegal.
Args:
target (TestState | str): The desired next state, as an enum member
or its string equivalent.
Returns:
str: The previous state value before the transition.
"""
# Assign value = target.value if hasattr(target, "value") else str(target)
value = target.value if hasattr(target, "value") else str(target)
# Assign resolved = target if isinstance(target, TestState) else TestState(value)
resolved = target if isinstance(target, TestState) else TestState(value)
# Return self._transition(resolved)
return self._transition(resolved)
# Define function _transition
def _transition(self, target: TestState) -> str:
"""Validate and apply a state transition, returning the previous state value.
Args:
target (TestState): The desired next state enum member.
Returns:
str: The previous state value before the transition was applied.
"""
# Check: not self.can_transition(target)
"""Internal: validate and apply; return previous state value."""
if not self.can_transition(target):
# Assign valid = [s.value for s in VALID_TRANSITIONS.get(self.state, [])]
valid = [s.value for s in VALID_TRANSITIONS.get(self.state, [])]
# Raise InvalidStateTransition
raise InvalidStateTransition(
# Keyword argument: current_state
current_state=self.state.value,
# Keyword argument: target_state
target_state=target.value,
# Keyword argument: valid_transitions
valid_transitions=valid,
)
# Assign previous = self.state.value
previous = self.state.value
# Assign self.state = target
self.state = target
# Call self._events.append()
self._events.append(DomainEvent(
# Literal argument value
"state_changed",
{"previous": previous, "new": target.value},
))
# Return previous
return previous
# -- Lifecycle commands --------------------------------------------
def start_execution(self) -> None:
"""Transition the test from ``draft`` to ``red_executing``.
Returns:
None
"""
# Call self._transition()
"""``draft`` -> ``red_executing``."""
self._transition(TestState.red_executing)
# Assign now = datetime.utcnow()
now = datetime.utcnow()
# Assign self.execution_date = now
self.execution_date = now
# Assign self.red_started_at = now
self.red_started_at = now
# Call self._events.append()
self._events.append(DomainEvent("execution_started"))
# Define function submit_red_evidence
def submit_red_evidence(self) -> int:
"""Transition the test from ``red_executing`` to ``blue_evaluating``.
"""``red_executing`` -> ``blue_evaluating``.
Auto-resumes if paused. Returns paused seconds accumulated
during this phase (for worklog calculation).
Returns:
int: Total seconds the red phase was paused.
"""
# Assign paused_extra = self._auto_resume()
paused_extra = self._auto_resume()
# Call self._transition()
self._transition(TestState.blue_evaluating)
# Assign total_paused = self.red_paused_seconds + paused_extra
total_paused = self.red_paused_seconds + paused_extra
# Assign self.blue_started_at = datetime.utcnow()
self.blue_started_at = datetime.utcnow()
# Assign self.blue_paused_seconds = 0
self.blue_paused_seconds = 0
# Call self._events.append()
self._events.append(DomainEvent(
# Literal argument value
"red_evidence_submitted",
{"red_paused_seconds": total_paused},
))
# Return total_paused
return total_paused
# Define function submit_blue_evidence
def submit_blue_evidence(self) -> int:
"""Transition the test from ``blue_evaluating`` to ``in_review``.
"""``blue_evaluating`` -> ``in_review``.
Auto-resumes if paused. Returns paused seconds accumulated
during this phase (for worklog calculation).
Returns:
int: Total seconds the blue phase was paused.
"""
# Assign paused_extra = self._auto_resume()
paused_extra = self._auto_resume()
# Call self._transition()
self._transition(TestState.in_review)
# Assign total_paused = self.blue_paused_seconds + paused_extra
total_paused = self.blue_paused_seconds + paused_extra
# Call self._events.append()
self._events.append(DomainEvent(
# Literal argument value
"blue_evidence_submitted",
{"blue_paused_seconds": total_paused},
))
# Return total_paused
return total_paused
# Define function pause_timer
def pause_timer(self) -> None:
"""Pause the active phase timer.
Returns:
None
"""
# Check: self.state not in _PAUSABLE_STATES
"""Pause the active phase timer."""
if self.state not in _PAUSABLE_STATES:
# Raise BusinessRuleViolation
raise BusinessRuleViolation(
f"Cannot pause timer in '{self.state.value}' state"
)
# Check: self.paused_at is not None
if self.paused_at is not None:
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Timer is already paused")
# Assign self.paused_at = datetime.utcnow()
self.paused_at = datetime.utcnow()
# Call self._events.append()
self._events.append(DomainEvent("timer_paused"))
# Define function resume_timer
def resume_timer(self) -> int:
"""Resume a paused timer.
Returns:
int: Number of seconds the timer was paused for.
"""
# Check: self.paused_at is None
"""Resume a paused timer. Returns seconds that were paused."""
if self.paused_at is None:
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Timer is not paused")
# Assign now = datetime.utcnow()
now = datetime.utcnow()
# Assign paused_seconds = max(int((now - self.paused_at).total_seconds()), 0)
paused_seconds = max(int((now - self.paused_at).total_seconds()), 0)
# Check: self.state == TestState.red_executing
if self.state == TestState.red_executing:
# Assign self.red_paused_seconds = paused_seconds
self.red_paused_seconds += paused_seconds
# Alternative: self.state == TestState.blue_evaluating
elif self.state == TestState.blue_evaluating:
# Assign self.blue_paused_seconds = paused_seconds
self.blue_paused_seconds += paused_seconds
# Assign self.paused_at = None
self.paused_at = None
# Call self._events.append()
self._events.append(DomainEvent("timer_resumed", {"paused_seconds": paused_seconds}))
# Return paused_seconds
return paused_seconds
# Define function validate_red
def validate_red(self, status: str, *, by: uuid.UUID, notes: str | None = None) -> None:
"""Record Red Lead's validation decision.
Args:
status (str): Validation outcome; must be ``"approved"`` or ``"rejected"``.
by (uuid.UUID): UUID of the Red Lead recording the decision.
notes (str | None): Optional free-text notes about the decision.
Returns:
None
"""
# Call self._assert_in_review()
"""Record Red Lead's validation decision."""
self._assert_in_review("red")
# Call self._assert_valid_vote()
self._assert_valid_vote(status)
# Assign now = datetime.utcnow()
now = datetime.utcnow()
# Assign self.red_validation_status = status
self.red_validation_status = status
# Assign self.red_validated_by = by
self.red_validated_by = by
# Assign self.red_validated_at = now
self.red_validated_at = now
# Assign self.red_validation_notes = notes
self.red_validation_notes = notes
# Call self._events.append()
self._events.append(DomainEvent("red_validated", {"status": status}))
# Call self._check_dual_validation()
self._check_dual_validation()
# Define function validate_blue
def validate_blue(self, status: str, *, by: uuid.UUID, notes: str | None = None) -> None:
"""Record Blue Lead's validation decision.
Args:
status (str): Validation outcome; must be ``"approved"`` or ``"rejected"``.
by (uuid.UUID): UUID of the Blue Lead recording the decision.
notes (str | None): Optional free-text notes about the decision.
Returns:
None
"""
# Call self._assert_in_review()
"""Record Blue Lead's validation decision."""
self._assert_in_review("blue")
# Call self._assert_valid_vote()
self._assert_valid_vote(status)
# Assign now = datetime.utcnow()
now = datetime.utcnow()
# Assign self.blue_validation_status = status
self.blue_validation_status = status
# Assign self.blue_validated_by = by
self.blue_validated_by = by
# Assign self.blue_validated_at = now
self.blue_validated_at = now
# Assign self.blue_validation_notes = notes
self.blue_validation_notes = notes
# Call self._events.append()
self._events.append(DomainEvent("blue_validated", {"status": status}))
# Call self._check_dual_validation()
self._check_dual_validation()
# Define function reopen
def reopen(self) -> None:
"""Transition the test from ``rejected`` back to ``draft``, clearing all validation and timing fields.
Returns:
None
"""
# Call self._transition()
"""``rejected`` -> ``draft``, clearing all validation/timing fields."""
self._transition(TestState.draft)
# Assign self.red_validation_status = None
self.red_validation_status = None
# Assign self.red_validated_by = None
self.red_validated_by = None
# Assign self.red_validated_at = None
self.red_validated_at = None
# Assign self.red_validation_notes = None
self.red_validation_notes = None
# Assign self.blue_validation_status = None
self.blue_validation_status = None
# Assign self.blue_validated_by = None
self.blue_validated_by = None
# Assign self.blue_validated_at = None
self.blue_validated_at = None
# Assign self.blue_validation_notes = None
self.blue_validation_notes = None
# Assign self.red_started_at = None
self.red_started_at = None
# Assign self.blue_started_at = None
self.blue_started_at = None
# Assign self.paused_at = None
self.paused_at = None
# Assign self.red_paused_seconds = 0
self.red_paused_seconds = 0
# Assign self.blue_paused_seconds = 0
self.blue_paused_seconds = 0
# Call self._events.append()
self._events.append(DomainEvent("test_reopened"))
# -- Private -------------------------------------------------------
def _auto_resume(self) -> int:
"""Accumulate pause time and clear the paused timestamp if currently paused.
Returns:
int: Extra seconds that were accumulated from the current pause, or 0
if the timer was not paused.
"""
# Check: self.paused_at is None
"""If paused, accumulate pause time and clear. Returns extra seconds."""
if self.paused_at is None:
# Return 0
return 0
# Assign now = datetime.utcnow()
now = datetime.utcnow()
# Assign extra = max(int((now - self.paused_at).total_seconds()), 0)
extra = max(int((now - self.paused_at).total_seconds()), 0)
# Assign self.paused_at = None
self.paused_at = None
# Return extra
return extra
# Define function check_dual_validation
def check_dual_validation(self) -> None:
"""Evaluate both leads' votes and advance state if appropriate.
@@ -601,10 +324,8 @@ class TestEntity:
Called automatically by :meth:`validate_red` and :meth:`validate_blue`.
"""
# Call self._check_dual_validation()
self._check_dual_validation()
# Define function _assert_in_review
def _assert_in_review(self, side: str) -> None:
if self.state not in (TestState.in_review, TestState.disputed):
raise InvalidOperationError(
@@ -612,37 +333,27 @@ class TestEntity:
f"'{self.state.value}' state (must be in_review or disputed)"
)
# Apply the @staticmethod decorator
@staticmethod
# Define function _assert_valid_vote
def _assert_valid_vote(status: str) -> None:
"""Raise InvalidOperationError if *status* is not a valid vote value.
Args:
status (str): The vote value to validate; must be ``"approved"`` or ``"rejected"``.
Returns:
None
"""
# Check: status not in ("approved", "rejected")
if status not in ("approved", "rejected"):
# Raise InvalidOperationError
raise InvalidOperationError(
# Literal argument value
"validation_status must be 'approved' or 'rejected'"
)
# Define function _check_dual_validation
def _check_dual_validation(self) -> None:
"""Advance the test state once both leads have voted."""
r, b = self.red_validation_status, self.blue_validation_status
if r == "approved" and b == "approved":
self.state = TestState.validated
# Call self._events.append()
self._events.append(DomainEvent("dual_validation_approved"))
elif r == "rejected" or b == "rejected":
# Any rejection is a veto — one lead can reject without waiting for the other
elif r == "rejected" and b == "rejected":
# Full consensus to reject
self.state = TestState.rejected
self._events.append(DomainEvent("dual_validation_rejected"))
elif (r == "approved" and b == "rejected") or (r == "rejected" and b == "approved"):
# Conflict: one approves, one rejects → needs discussion
self.state = TestState.disputed
self._events.append(DomainEvent("dual_validation_disputed"))
+1 -49
View File
@@ -20,84 +20,36 @@ Services should **never** call ``db.commit()``; they use ``db.add()`` /
osint_enrichment_service.enrich_technique_with_cves).
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import TracebackType from types
from types import TracebackType
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Define class UnitOfWork
class UnitOfWork:
"""Lightweight transaction wrapper around an existing SQLAlchemy session."""
# Define function __init__
def __init__(self, session: Session) -> None:
"""Wrap an existing SQLAlchemy session in a Unit of Work.
Args:
session (Session): The active SQLAlchemy session to manage.
Returns:
None
"""
# Assign self._session = session
self._session = session
# -- context manager -----------------------------------------------------
def __enter__(self) -> "UnitOfWork":
"""Enter the runtime context, returning this UnitOfWork instance.
Returns:
UnitOfWork: The UnitOfWork itself, for use in ``with`` statements.
"""
# Return self
return self
# Define function __exit__
def __exit__(
self,
# Entry: exc_type
exc_type: type[BaseException] | None,
# Entry: exc_val
exc_val: BaseException | None,
# Entry: exc_tb
exc_tb: TracebackType | None,
) -> None:
"""Exit the runtime context, rolling back if an exception propagated.
Args:
exc_type (type[BaseException] | None): Exception class, if raised.
exc_val (BaseException | None): Exception instance, if raised.
exc_tb (TracebackType | None): Traceback object, if an exception was raised.
Returns:
None
"""
# Check: exc_type is not None
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
if exc_type is not None:
# Call self.rollback()
self.rollback()
# -- public API ----------------------------------------------------------
def commit(self) -> None:
"""Flush pending changes and commit the transaction."""
# Call self._session.commit()
self._session.commit()
# Define function rollback
def rollback(self) -> None:
"""Roll back the current transaction."""
# Call self._session.rollback()
self._session.rollback()
# Define function flush
def flush(self) -> None:
"""Flush pending changes without committing (useful for getting IDs)."""
# Call self._session.flush()
self._session.flush()
@@ -1,9 +1,4 @@
"""Immutable domain value objects."""
# Import MitreId from app.domain.value_objects.mitre_id
from app.domain.value_objects.mitre_id import MitreId
# Import ScoringWeights from app.domain.value_objects.scoring_weights
from app.domain.value_objects.scoring_weights import ScoringWeights
# Assign __all__ = ["MitreId", "ScoringWeights"]
__all__ = ["MitreId", "ScoringWeights"]
+1 -65
View File
@@ -5,111 +5,47 @@ format: ``T`` followed by 4 digits, optionally a dot and 3 more digits
for sub-techniques (e.g. ``T1059``, ``T1059.001``).
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import re
import re
# Import dataclass from dataclasses
from dataclasses import dataclass
# Assign _MITRE_ID_RE = re.compile(r"^T\d{4}(\.\d{3})?$")
_MITRE_ID_RE = re.compile(r"^T\d{4}(\.\d{3})?$")
# Apply the @dataclass decorator
@dataclass(frozen=True, slots=True)
# Define class MitreId
class MitreId:
"""Validated MITRE ATT&CK technique identifier."""
# value: str
value: str
# Define function __post_init__
def __post_init__(self) -> None:
"""Validate that *value* matches the expected MITRE ATT&CK ID format.
Returns:
None
"""
# Check: not _MITRE_ID_RE.match(self.value)
if not _MITRE_ID_RE.match(self.value):
# Raise ValueError
raise ValueError(
f"Invalid MITRE ATT&CK ID '{self.value}'. "
# Literal argument value
"Expected format: T1234 or T1234.001"
)
# Apply the @property decorator
@property
# Define function is_subtechnique
def is_subtechnique(self) -> bool:
"""Return True if this identifier represents a sub-technique.
Returns:
bool: True when the ID contains a dot (e.g. ``T1059.001``).
"""
# Return "." in self.value
return "." in self.value
# Apply the @property decorator
@property
# Define function parent_id
def parent_id(self) -> str | None:
"""Return the parent technique ID (e.g. ``T1059`` for ``T1059.001``).
Returns:
str | None: The parent ID string, or None if this is not a sub-technique.
"""
# Check: not self.is_subtechnique
"""Return the parent technique ID (e.g. T1059 for T1059.001)."""
if not self.is_subtechnique:
# Return None
return None
# Return self.value.split(".")[0]
return self.value.split(".")[0]
# Define function __str__
def __str__(self) -> str:
"""Return the string representation of the MITRE ID.
Returns:
str: The raw identifier string (e.g. ``"T1059.001"``).
"""
# Return self.value
return self.value
# Define function __eq__
def __eq__(self, other: object) -> bool:
"""Compare this MitreId to another MitreId or a plain string.
Args:
other (object): The value to compare against; may be a
:class:`MitreId` instance or a plain ``str``.
Returns:
bool: True if the identifiers are equal, NotImplemented for
unsupported types.
"""
# Check: isinstance(other, MitreId)
if isinstance(other, MitreId):
# Return self.value == other.value
return self.value == other.value
# Check: isinstance(other, str)
if isinstance(other, str):
# Return self.value == other
return self.value == other
# Return NotImplemented
return NotImplemented
# Define function __hash__
def __hash__(self) -> int:
"""Return the hash of the identifier string.
Returns:
int: Hash value derived from the raw identifier string.
"""
# Return hash(self.value)
return hash(self.value)
@@ -3,38 +3,22 @@
Enforces that all five weights are non-negative and sum to exactly 100.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import dataclass from dataclasses
from dataclasses import dataclass
# Apply the @dataclass decorator
@dataclass(frozen=True, slots=True)
# Define class ScoringWeights
class ScoringWeights:
"""Five scoring dimension weights that must sum to 100."""
# tests: float
tests: float
# detection_rules: float
detection_rules: float
# d3fend: float
d3fend: float
# recency: float
recency: float
# severity: float
severity: float
# Define function __post_init__
def __post_init__(self) -> None:
"""Validate that all weights are non-negative and sum to exactly 100.
Returns:
None
"""
# Assign fields = [
fields = [
self.tests,
self.detection_rules,
@@ -42,66 +26,32 @@ class ScoringWeights:
self.recency,
self.severity,
]
# Iterate over fields
for f in fields:
# Check: f < 0
if f < 0:
# Raise ValueError
raise ValueError("Scoring weights must be non-negative")
# Assign total = sum(fields)
total = sum(fields)
# Check: abs(total - 100) > 0.01
if abs(total - 100) > 0.01:
# Raise ValueError
raise ValueError(
f"Scoring weights must sum to 100, got {total}"
)
# Apply the @classmethod decorator
@classmethod
# Define function default
def default(cls) -> ScoringWeights:
"""Return the default weight distribution.
Returns:
ScoringWeights: A weight set with tests=40, detection_rules=25,
d3fend=15, recency=10, severity=10.
"""
# Return cls(
"""Return the default weight distribution."""
return cls(
# Keyword argument: tests
tests=40.0,
# Keyword argument: detection_rules
detection_rules=25.0,
# Keyword argument: d3fend
d3fend=15.0,
# Keyword argument: recency
recency=10.0,
# Keyword argument: severity
severity=10.0,
)
# Backward-compatible aliases for older API payloads
@property
# Define function freshness
def freshness(self) -> float:
"""Return the recency weight (backward-compatible alias).
Returns:
float: The value of the ``recency`` weight.
"""
# Return self.recency
return self.recency
# Apply the @property decorator
@property
# Define function platform_diversity
def platform_diversity(self) -> float:
"""Return the severity weight (backward-compatible alias).
Returns:
float: The value of the ``severity`` weight.
"""
# Return self.severity
return self.severity
-1
View File
@@ -1 +0,0 @@
"""Infrastructure adapters — persistence, caching, and external services."""
@@ -1 +0,0 @@
"""SQLAlchemy-based persistence adapters for the domain repository ports."""
@@ -1 +0,0 @@
"""ORM-to-domain entity mapper functions."""
@@ -1,28 +1,20 @@
"""Technique ORM model <-> domain entity mapper."""
# Enable future language features for compatibility
from __future__ import annotations
# Import TechniqueEntity from app.domain.entities.technique
from app.domain.entities.technique import TechniqueEntity
from app.domain.enums import TechniqueStatus
# Define class TechniqueMapper
class TechniqueMapper:
"""Converts between SQLAlchemy Technique model and TechniqueEntity."""
# Apply the @staticmethod decorator
@staticmethod
# Define function to_entity
def to_entity(model: object) -> TechniqueEntity:
"""Convert an ORM Technique model to a domain TechniqueEntity."""
# Return TechniqueEntity.from_orm(model)
return TechniqueEntity.from_orm(model)
# Apply the @staticmethod decorator
@staticmethod
# Define function to_model_updates
def to_model_updates(entity: TechniqueEntity, model: object) -> None:
"""Apply entity changes back onto an existing ORM model."""
# Call entity.apply_to()
entity.apply_to(model)
@@ -1,13 +1,8 @@
"""Concrete SQLAlchemy repository implementations."""
# Import from app.infrastructure.persistence.repositories.sa_technique_repository
from app.infrastructure.persistence.repositories.sa_technique_repository import (
SATechniqueRepository,
)
# Import from app.infrastructure.persistence.repositories.sa_test_repository
from app.infrastructure.persistence.repositories.sa_test_repository import (
SATestRepository,
)
# Assign __all__ = ["SATechniqueRepository", "SATestRepository"]
__all__ = ["SATechniqueRepository", "SATestRepository"]
@@ -4,95 +4,44 @@ Receives a Session from the caller — does NOT create its own.
Does NOT call commit() — the Unit of Work owns that.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import uuid
import uuid
# Import func from sqlalchemy
from sqlalchemy import func
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import TechniqueEntity from app.domain.entities.technique
from app.domain.entities.technique import TechniqueEntity
# Import TechniqueStatus, TestState from app.domain.enums
from app.domain.enums import TechniqueStatus, TestState
# Import TechniqueWithCounts from app.domain.ports.repositories.technique_repository
from app.domain.ports.repositories.technique_repository import TechniqueWithCounts
# Import TechniqueMapper from app.infrastructure.persistence.mappers.technique_mapper
from app.infrastructure.persistence.mappers.technique_mapper import TechniqueMapper
# Import DetectionRule from app.models.detection_rule
from app.models.detection_rule import DetectionRule
# Import Technique from app.models.technique
from app.models.technique import Technique
# Import Test from app.models.test
from app.models.test import Test
# Define class SATechniqueRepository
class SATechniqueRepository:
"""Concrete repository backed by SQLAlchemy."""
# Define function __init__
def __init__(self, session: Session) -> None:
"""Initialise the repository with a caller-provided session.
Args:
session (Session): The SQLAlchemy session to use for all queries.
"""
# Assign self._session = session
self._session = session
# -- Single-entity access ----------------------------------------------
def find_by_id(self, technique_id: uuid.UUID) -> TechniqueEntity | None:
"""Return a single technique by its primary key.
Args:
technique_id (uuid.UUID): The UUID primary key of the technique.
Returns:
TechniqueEntity | None: The matching entity, or ``None`` if not found.
"""
# Assign model = (
model = (
self._session.query(Technique)
# Chain .filter() call
.filter(Technique.id == technique_id)
# Chain .first() call
.first()
)
# Return TechniqueMapper.to_entity(model) if model else None
return TechniqueMapper.to_entity(model) if model else None
# Define function find_by_mitre_id
def find_by_mitre_id(self, mitre_id: str) -> TechniqueEntity | None:
"""Return a single technique by its MITRE ATT&CK ID (e.g. ``T1059.001``).
Args:
mitre_id (str): The MITRE ATT&CK identifier string.
Returns:
TechniqueEntity | None: The matching entity, or ``None`` if not found.
"""
# Assign model = (
model = (
self._session.query(Technique)
# Chain .filter() call
.filter(Technique.mitre_id == mitre_id)
# Chain .first() call
.first()
)
# Return TechniqueMapper.to_entity(model) if model else None
return TechniqueMapper.to_entity(model) if model else None
# -- List access -------------------------------------------------------
@@ -100,111 +49,57 @@ class SATechniqueRepository:
def list_all(
self,
*,
# Entry: tactic
tactic: str | None = None,
# Entry: status
status: TechniqueStatus | None = None,
# Entry: review_required
review_required: bool | None = None,
) -> list[TechniqueEntity]:
"""Return all techniques, optionally filtered by tactic, status, or review flag.
Args:
tactic (str | None): Filter to techniques belonging to this tactic name.
status (TechniqueStatus | None): Filter to techniques with this coverage status.
review_required (bool | None): Filter to techniques where ``review_required`` matches.
Returns:
list[TechniqueEntity]: Ordered list of matching technique entities.
"""
# Assign query = self._session.query(Technique)
query = self._session.query(Technique)
# Check: tactic is not None
if tactic is not None:
# Assign query = query.filter(Technique.tactic == tactic)
query = query.filter(Technique.tactic == tactic)
# Check: status is not None
if status is not None:
# Assign query = query.filter(Technique.status_global == status)
query = query.filter(Technique.status_global == status)
# Check: review_required is not None
if review_required is not None:
# Assign query = query.filter(Technique.review_required == review_required)
query = query.filter(Technique.review_required == review_required)
# Assign models = query.order_by(Technique.mitre_id).all()
models = query.order_by(Technique.mitre_id).all()
# Return [TechniqueMapper.to_entity(m) for m in models]
return [TechniqueMapper.to_entity(m) for m in models]
# Define function list_by_ids
def list_by_ids(self, ids: list[uuid.UUID]) -> list[TechniqueEntity]:
"""Return techniques matching the provided list of UUIDs.
Args:
ids (list[uuid.UUID]): UUIDs of the techniques to retrieve.
Returns:
list[TechniqueEntity]: Technique entities corresponding to the given IDs.
"""
# Check: not ids
if not ids:
# Return []
return []
# Assign models = (
models = (
self._session.query(Technique)
# Chain .filter() call
.filter(Technique.id.in_(ids))
# Chain .all() call
.all()
)
# Return [TechniqueMapper.to_entity(m) for m in models]
return [TechniqueMapper.to_entity(m) for m in models]
# -- Batch queries (for scoring/heatmap) -------------------------------
def count_by_status(self) -> dict[TechniqueStatus, int]:
"""Return a count of techniques grouped by their coverage status.
Returns:
dict[TechniqueStatus, int]: Mapping of each status value to its technique count.
"""
# Assign rows = (
rows = (
self._session.query(
Technique.status_global,
func.count(Technique.id),
)
# Chain .group_by() call
.group_by(Technique.status_global)
# Chain .all() call
.all()
)
# Assign result = {s: 0 for s in TechniqueStatus}
result = {s: 0 for s in TechniqueStatus}
# Iterate over rows
for status_val, count in rows:
# Assign key = (
key = (
status_val
if isinstance(status_val, TechniqueStatus)
else TechniqueStatus(status_val)
)
# Assign result[key] = count
result[key] = count
# Return result
return result
# Define function find_all_with_test_counts
def find_all_with_test_counts(self) -> list[TechniqueWithCounts]:
"""Return all techniques with pre-aggregated test and detection rule counts.
"""Single query replacing the N+1 pattern.
Uses a single query with subqueries to avoid the N+1 pattern.
Returns:
list[TechniqueWithCounts]: All techniques with their associated counts.
Returns all techniques with pre-aggregated test and detection
rule counts via subqueries.
"""
# Assign test_count_sq = (
test_count_sq = (
self._session.query(
Test.technique_id,
@@ -213,24 +108,18 @@ class SATechniqueRepository:
func.cast(Test.state == TestState.validated, self._int_type())
).label("validated_count"),
)
# Chain .group_by() call
.group_by(Test.technique_id)
# Chain .subquery() call
.subquery()
)
# Assign rule_count_sq = (
rule_count_sq = (
self._session.query(
DetectionRule.mitre_technique_id,
func.count(DetectionRule.id).label("rule_count"),
)
# Chain .group_by() call
.group_by(DetectionRule.mitre_technique_id)
# Chain .subquery() call
.subquery()
)
# Assign rows = (
rows = (
self._session.query(
Technique,
@@ -238,29 +127,20 @@ class SATechniqueRepository:
func.coalesce(test_count_sq.c.validated_count, 0),
func.coalesce(rule_count_sq.c.rule_count, 0),
)
# Chain .outerjoin() call
.outerjoin(test_count_sq, Technique.id == test_count_sq.c.technique_id)
# Chain .outerjoin() call
.outerjoin(
rule_count_sq,
Technique.mitre_id == rule_count_sq.c.mitre_technique_id,
)
# Chain .order_by() call
.order_by(Technique.mitre_id)
# Chain .all() call
.all()
)
# Return [
return [
TechniqueWithCounts(
# Keyword argument: entity
entity=TechniqueMapper.to_entity(tech),
# Keyword argument: test_count
test_count=int(tc),
# Keyword argument: validated_test_count
validated_test_count=int(vtc),
# Keyword argument: detection_rule_count
detection_rule_count=int(rc),
)
for tech, tc, vtc, rc in rows
@@ -269,112 +149,55 @@ class SATechniqueRepository:
# -- Mutations ---------------------------------------------------------
def save(self, technique: TechniqueEntity) -> TechniqueEntity:
"""Persist a technique entity, inserting or updating as needed.
Args:
technique (TechniqueEntity): The domain entity to persist.
Returns:
TechniqueEntity: The persisted entity reflecting the current DB state.
"""
# Assign existing = (
existing = (
self._session.query(Technique)
# Chain .filter() call
.filter(Technique.id == technique.id)
# Chain .first() call
.first()
)
# Check: existing
if existing:
# Call technique.apply_to()
technique.apply_to(existing)
# Assign existing.mitre_id = technique.mitre_id
existing.mitre_id = technique.mitre_id
# Assign existing.name = technique.name
existing.name = technique.name
# Assign existing.tactic = technique.tactic
existing.tactic = technique.tactic
# Assign existing.description = technique.description
existing.description = technique.description
# Assign existing.platforms = technique.platforms
existing.platforms = technique.platforms
# Assign existing.is_subtechnique = technique.is_subtechnique
existing.is_subtechnique = technique.is_subtechnique
# Assign existing.parent_mitre_id = technique.parent_mitre_id
existing.parent_mitre_id = technique.parent_mitre_id
# Assign existing.mitre_version = technique.mitre_version
existing.mitre_version = technique.mitre_version
# Assign existing.mitre_last_modified = technique.mitre_last_modified
existing.mitre_last_modified = technique.mitre_last_modified
# Call self._session.flush()
self._session.flush()
# Return TechniqueMapper.to_entity(existing)
return TechniqueMapper.to_entity(existing)
# Fallback: handle remaining cases
else:
# Assign model = Technique(
model = Technique(
# Keyword argument: id
id=technique.id,
# Keyword argument: mitre_id
mitre_id=technique.mitre_id,
# Keyword argument: name
name=technique.name,
# Keyword argument: tactic
tactic=technique.tactic,
# Keyword argument: description
description=technique.description,
# Keyword argument: platforms
platforms=technique.platforms,
# Keyword argument: is_subtechnique
is_subtechnique=technique.is_subtechnique,
# Keyword argument: parent_mitre_id
parent_mitre_id=technique.parent_mitre_id,
# Keyword argument: status_global
status_global=technique.status_global,
# Keyword argument: review_required
review_required=technique.review_required,
# Keyword argument: last_review_date
last_review_date=technique.last_review_date,
# Keyword argument: mitre_version
mitre_version=technique.mitre_version,
# Keyword argument: mitre_last_modified
mitre_last_modified=technique.mitre_last_modified,
)
# Call self._session.add()
self._session.add(model)
# Call self._session.flush()
self._session.flush()
# Return TechniqueMapper.to_entity(model)
return TechniqueMapper.to_entity(model)
# Define function exists_by_mitre_id
def exists_by_mitre_id(self, mitre_id: str) -> bool:
"""Check whether a technique with the given MITRE ID already exists.
Args:
mitre_id (str): The MITRE ATT&CK identifier to look up.
Returns:
bool: ``True`` if the technique exists, ``False`` otherwise.
"""
# Return (
return (
self._session.query(Technique.id)
# Chain .filter() call
.filter(Technique.mitre_id == mitre_id)
# Chain .first() call
.first()
) is not None
# -- Internal ----------------------------------------------------------
@staticmethod
# Define function _int_type
def _int_type() -> type:
def _int_type():
"""Return an Integer type for CAST expressions (SQLite-compatible)."""
# Import Integer from sqlalchemy
from sqlalchemy import Integer
# Return Integer
return Integer
@@ -1,163 +1,78 @@
"""SQLAlchemy implementation of TestRepository."""
# Enable future language features for compatibility
from __future__ import annotations
# Import uuid
import uuid
# Import func from sqlalchemy
from sqlalchemy import func
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import TestState from app.domain.enums
from app.domain.enums import TestState
# Import Test from app.models.test
from app.models.test import Test
# Define class SATestRepository
class SATestRepository:
"""Concrete test repository backed by SQLAlchemy."""
# Define function __init__
def __init__(self, session: Session) -> None:
"""Initialise the repository with a caller-provided session.
Args:
session (Session): The SQLAlchemy session to use for all queries.
"""
# Assign self._session = session
self._session = session
# Define function find_by_id
def find_by_id(self, test_id: uuid.UUID) -> Test | None:
"""Return a single test by its primary key.
Args:
test_id (uuid.UUID): The UUID primary key of the test.
Returns:
Test | None: The ORM model instance, or ``None`` if not found.
"""
# Return (
return (
self._session.query(Test)
# Chain .filter() call
.filter(Test.id == test_id)
# Chain .first() call
.first()
)
# Define function list_by_technique
def list_by_technique(self, technique_id: uuid.UUID) -> list[Test]:
"""Return all tests for a given technique, ordered by creation date.
Args:
technique_id (uuid.UUID): The UUID of the parent technique.
Returns:
list[Test]: ORM model instances ordered by ``created_at`` ascending.
"""
# Return (
return (
self._session.query(Test)
# Chain .filter() call
.filter(Test.technique_id == technique_id)
# Chain .order_by() call
.order_by(Test.created_at)
# Chain .all() call
.all()
)
# Define function list_by_state
def list_by_state(self, state: TestState) -> list[Test]:
"""Return all tests that are currently in the given workflow state.
Args:
state (TestState): The workflow state to filter on.
Returns:
list[Test]: All ORM model instances with the specified state.
"""
# Return (
return (
self._session.query(Test)
# Chain .filter() call
.filter(Test.state == state)
# Chain .all() call
.all()
)
# Define function count_by_technique_and_state
def count_by_technique_and_state(
self,
# Entry: technique_id
technique_id: uuid.UUID,
) -> dict[TestState, int]:
"""Return per-state test counts for a specific technique.
Args:
technique_id (uuid.UUID): The UUID of the technique to aggregate for.
Returns:
dict[TestState, int]: Mapping of each state to the number of tests in that state.
"""
# Assign rows = (
rows = (
self._session.query(Test.state, func.count(Test.id))
# Chain .filter() call
.filter(Test.technique_id == technique_id)
# Chain .group_by() call
.group_by(Test.state)
# Chain .all() call
.all()
)
# Assign result = {}
result: dict[TestState, int] = {}
# Iterate over rows
for state_val, count in rows:
# Assign key = (
key = (
state_val
if isinstance(state_val, TestState)
else TestState(state_val)
)
# Assign result[key] = count
result[key] = count
# Return result
return result
# Define function get_states_and_results_for_technique
def get_states_and_results_for_technique(
self,
# Entry: technique_id
technique_id: uuid.UUID,
) -> list[tuple[str, str | None]]:
"""Return lightweight ``(state, detection_result)`` pairs for a technique.
"""Return lightweight (state, detection_result) pairs.
Used by ``TechniqueEntity.recalculate_status()`` to avoid loading full
``Test`` models.
Args:
technique_id (uuid.UUID): The UUID of the technique to query.
Returns:
list[tuple[str, str | None]]: Each tuple contains the state string
and the detection result string (or ``None``).
Used by TechniqueEntity.recalculate_status() without loading
full Test models.
"""
# Assign rows = (
rows = (
self._session.query(Test.state, Test.detection_result)
# Chain .filter() call
.filter(Test.technique_id == technique_id)
# Chain .all() call
.all()
)
# Return [
return [
(
r.state.value if hasattr(r.state, "value") else str(r.state),
@@ -13,79 +13,54 @@ Usage::
get_redis_blacklist().setex("blacklist:…", ttl, "1")
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import logging
import logging
# Import urlparse, urlunparse from urllib.parse
from urllib.parse import urlparse, urlunparse
# Import redis
import redis
# Import settings from app.config
from app.config import settings
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Assign _clients = {}
_clients: dict[str, redis.Redis] = {}
# Define function _redis_url_with_db
def _redis_url_with_db(base_url: str, db_index: int) -> str:
"""Return *base_url* with its path replaced by ``/{db_index}``."""
# Assign parsed = urlparse(base_url)
parsed = urlparse(base_url)
# Assign path = f"/{db_index}"
path = f"/{db_index}"
# Return urlunparse(
return urlunparse(
(parsed.scheme, parsed.netloc, path, "", "", ""),
)
# Define function _get_client
def _get_client(url: str) -> redis.Redis:
# Check: url not in _clients
if url not in _clients:
# Assign _clients[url] = redis.from_url(url, decode_responses=True)
_clients[url] = redis.from_url(url, decode_responses=True)
# Log info: "Redis client connected to %s", url
logger.info("Redis client connected to %s", url)
# Return _clients[url]
return _clients[url]
# Define function get_redis
def get_redis() -> redis.Redis:
"""Default Redis connection (URL from ``settings.REDIS_URL``)."""
# Return _get_client(settings.REDIS_URL)
return _get_client(settings.REDIS_URL)
# Define function get_redis_blacklist
def get_redis_blacklist() -> redis.Redis:
"""Redis DB used for JWT revocation (``jti`` keys with TTL)."""
# Assign url = _redis_url_with_db(
url = _redis_url_with_db(
settings.REDIS_URL,
settings.REDIS_TOKEN_BLACKLIST_DB,
)
# Return _get_client(url)
return _get_client(url)
# Define function get_redis_cache
def get_redis_cache() -> redis.Redis:
"""Redis DB reserved for shared cache (scores, queues, etc.)."""
# Assign url = _redis_url_with_db(
url = _redis_url_with_db(
settings.REDIS_URL,
settings.REDIS_CACHE_DB,
)
# Return _get_client(url)
return _get_client(url)
-1
View File
@@ -1 +0,0 @@
"""Background scheduler jobs (MITRE sync, Jira sync, data retention)."""
-28
View File
@@ -1,65 +1,37 @@
"""Scheduled job — syncs all Jira links hourly."""
# Import logging
import logging
# Import settings from app.config
from app.config import settings
# Import SessionLocal from app.database
from app.database import SessionLocal
# Import JiraLink from app.models.jira_link
from app.models.jira_link import JiraLink
# Import jira_service from app.services
from app.services import jira_service
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Define function sync_all_jira_links
def sync_all_jira_links() -> None:
"""Pull latest status from Jira for every stored link.
Silently skips if ``JIRA_ENABLED`` is ``False``. Individual link
failures are logged but do not abort the rest of the batch.
"""
# Check: not settings.JIRA_ENABLED
if not settings.JIRA_ENABLED:
# Return control to caller
return
# Assign db = SessionLocal()
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Assign links = db.query(JiraLink).all()
links = db.query(JiraLink).all()
# Assign synced = 0
synced = 0
# Iterate over links
for link in links:
# Attempt the following; catch errors below
try:
# Call jira_service.sync_jira_to_aegis()
jira_service.sync_jira_to_aegis(db, link)
# Assign synced = 1
synced += 1
# Handle Exception
except Exception as e:
# Log warning: "Jira sync failed for link %s: %s", link.id, e
logger.warning("Jira sync failed for link %s: %s", link.id, e)
# Commit all pending changes to the database
db.commit()
# Log info: "Jira sync completed: %d/%d links updated", synced
logger.info("Jira sync completed: %d/%d links updated", synced, len(links))
# Handle Exception
except Exception:
# Log exception: "Jira sync batch job failed"
logger.exception("Jira sync batch job failed")
# Always execute this cleanup block
finally:
# Close the database session
db.close()
+8 -246
View File
@@ -10,44 +10,22 @@ Each job manages its own database session (created on entry, closed in
sessions.
"""
# Import logging
import logging
from datetime import datetime, timedelta, timezone
# Import BackgroundScheduler from apscheduler.schedulers.background
from apscheduler.schedulers.background import BackgroundScheduler
# Import SessionLocal from app.database
from app.database import SessionLocal
# Import sync_all_jira_links from app.jobs.jira_sync_job
from app.services.mitre_sync_service import sync_mitre
from app.services.intel_service import scan_intel
from app.services.notification_service import cleanup_old_notifications
from app.services.snapshot_service import create_snapshot, cleanup_old_snapshots
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
from app.jobs.jira_sync_job import sync_all_jira_links
# Import run_retention_job from app.jobs.retention_job
from app.services.osint_enrichment_service import enrich_all_techniques
from app.services.stale_detection_service import detect_stale_coverage
from app.jobs.retention_job import run_retention_job
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
# Import scan_intel from app.services.intel_service
from app.services.intel_service import scan_intel
# Import sync_mitre from app.services.mitre_sync_service
from app.services.mitre_sync_service import sync_mitre
# Import cleanup_old_notifications from app.services.notification_service
from app.services.notification_service import cleanup_old_notifications
# Import enrich_all_techniques from app.services.osint_enrichment_service
from app.services.osint_enrichment_service import enrich_all_techniques
# Import cleanup_old_snapshots, create_snapshot from app.services.snapshot_service
from app.services.snapshot_service import cleanup_old_snapshots, create_snapshot
# Import detect_stale_coverage from app.services.stale_detection_service
from app.services.stale_detection_service import detect_stale_coverage
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@@ -66,101 +44,60 @@ def _run_mitre_sync() -> None:
"""Execute a MITRE sync inside its own DB session."""
from app.services.webhook_service import dispatch_webhook
logger.info("Scheduled MITRE sync job starting...")
# Assign db = SessionLocal()
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Assign summary = sync_mitre(db)
summary = sync_mitre(db)
# Log info: "Scheduled MITRE sync job finished — %s", summary
logger.info("Scheduled MITRE sync job finished — %s", summary)
dispatch_webhook("mitre.synced", {"created": summary.get("created", 0), "updated": summary.get("updated", 0)})
except Exception:
# Log exception: "Scheduled MITRE sync job failed"
logger.exception("Scheduled MITRE sync job failed")
# Always execute this cleanup block
finally:
# Close the database session
db.close()
# Define function _run_notification_cleanup
def _run_notification_cleanup() -> None:
"""Clean up old read notifications."""
# Log info: "Scheduled notification cleanup job starting..."
logger.info("Scheduled notification cleanup job starting...")
# Assign db = SessionLocal()
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Assign deleted = cleanup_old_notifications(db, days=90)
deleted = cleanup_old_notifications(db, days=90)
# Log info: "Notification cleanup finished — deleted %d old no
logger.info("Notification cleanup finished — deleted %d old notifications", deleted)
# Handle Exception
except Exception:
# Log exception: "Notification cleanup job failed"
logger.exception("Notification cleanup job failed")
# Always execute this cleanup block
finally:
# Close the database session
db.close()
# Define function _run_weekly_snapshot
def _run_weekly_snapshot() -> None:
"""Create a weekly coverage snapshot and clean up old ones."""
# Log info: "Scheduled weekly snapshot job starting..."
logger.info("Scheduled weekly snapshot job starting...")
# Assign db = SessionLocal()
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Assign snapshot = create_snapshot(db, name="Auto-weekly")
snapshot = create_snapshot(db, name="Auto-weekly")
# Log info:
logger.info(
# Literal argument value
"Weekly snapshot created — score %.1f, %d techniques",
snapshot.organization_score,
snapshot.total_techniques,
)
# Assign deleted = cleanup_old_snapshots(db, keep_last=52)
deleted = cleanup_old_snapshots(db, keep_last=52)
# Check: deleted
if deleted:
# Log info: "Cleaned up %d old snapshots", deleted
logger.info("Cleaned up %d old snapshots", deleted)
# Handle Exception
except Exception:
# Log exception: "Weekly snapshot job failed"
logger.exception("Weekly snapshot job failed")
# Always execute this cleanup block
finally:
# Close the database session
db.close()
# Define function _run_recurring_campaigns
def _run_recurring_campaigns() -> None:
"""Check and run any due recurring campaigns."""
# Log info: "Scheduled recurring campaigns check starting..."
logger.info("Scheduled recurring campaigns check starting...")
# Assign db = SessionLocal()
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Assign spawned = check_and_run_recurring_campaigns(db)
spawned = check_and_run_recurring_campaigns(db)
# Log info: "Recurring campaigns check finished — spawned %d c
logger.info("Recurring campaigns check finished — spawned %d campaigns", spawned)
# Handle Exception
except Exception:
# Log exception: "Recurring campaigns check failed"
logger.exception("Recurring campaigns check failed")
# Always execute this cleanup block
finally:
# Close the database session
db.close()
@@ -256,122 +193,27 @@ def _run_scheduled_campaign_activation() -> None:
def _run_intel_scan() -> None:
"""Execute an intel scan inside its own DB session."""
# Log info: "Scheduled intel scan job starting..."
logger.info("Scheduled intel scan job starting...")
# Assign db = SessionLocal()
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Assign summary = scan_intel(db)
summary = scan_intel(db)
# Log info: "Scheduled intel scan job finished — %s", summary
logger.info("Scheduled intel scan job finished — %s", summary)
# Handle Exception
except Exception:
# Log exception: "Scheduled intel scan job failed"
logger.exception("Scheduled intel scan job failed")
# Always execute this cleanup block
finally:
# Close the database session
db.close()
def _run_evaluation_round_check() -> None:
"""Weekly job: check if a new ATT&CK Evaluation round is available.
If a new round is found it is imported automatically and an admin
notification is created so the team knows new baseline data is available.
"""
logger.info("ATT&CK Evaluations new-round check starting...")
db = SessionLocal()
try:
from app.services.attck_evaluations_service import check_for_new_round, import_evaluation_round
from app.models.user import User as UserModel
result = check_for_new_round(db)
if result.get("error"):
logger.warning("ATT&CK Evaluations check failed: %s", result["error"])
return
if not result.get("new_round_available"):
logger.info(
"ATT&CK Evaluations check — latest round '%s' already imported",
result.get("latest_round", {}).get("display_name", "?"),
)
return
latest = result["latest_round"]
logger.info(
"New ATT&CK Evaluation round detected: %s (round %d) — starting auto-import",
latest["display_name"], latest["eval_round"],
)
# Use the first admin user as the importer (system action)
admin = db.query(UserModel).filter(UserModel.role == "admin").first()
if not admin:
logger.warning("ATT&CK Evaluations auto-import: no admin user found — skipping")
return
summary = import_evaluation_round(
db,
latest["name"],
latest["display_name"],
latest["eval_round"],
admin,
)
logger.info(
"ATT&CK Evaluations auto-import complete — round %d (%s): %d tests created",
latest["eval_round"], latest["display_name"], summary["created"],
)
# Notify all admins
try:
from app.services.notification_service import create_notification
admins = db.query(UserModel).filter(UserModel.role == "admin").all()
for adm in admins:
create_notification(
db,
user_id=adm.id,
title="New ATT&CK Evaluation round imported",
message=(
f"Round {latest['eval_round']}{latest['display_name']}"
f"has been automatically imported. "
f"{summary['created']} tests created in In Review state. "
f"Blue Leads must validate each result before it counts as coverage."
),
notification_type="eval_import",
entity_type="evaluation",
entity_id=None,
)
db.commit()
except Exception:
logger.warning("Failed to send eval import notifications", exc_info=True)
except Exception:
logger.exception("ATT&CK Evaluations round check job failed")
finally:
db.close()
def _run_osint_enrichment() -> None:
"""Execute weekly OSINT enrichment inside its own DB session."""
# Log info: "Scheduled OSINT enrichment job starting..."
logger.info("Scheduled OSINT enrichment job starting...")
# Assign db = SessionLocal()
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Assign total = enrich_all_techniques(db)
total = enrich_all_techniques(db)
# Log info: "OSINT enrichment finished — %d new items", total
logger.info("OSINT enrichment finished — %d new items", total)
# Handle Exception
except Exception:
# Log exception: "OSINT enrichment job failed"
logger.exception("OSINT enrichment job failed")
# Always execute this cleanup block
finally:
# Close the database session
db.close()
@@ -432,23 +274,14 @@ def _run_data_sources_sync() -> None:
def _run_stale_detection() -> None:
"""Execute daily stale coverage detection inside its own DB session."""
# Log info: "Scheduled stale coverage detection starting..."
logger.info("Scheduled stale coverage detection starting...")
# Assign db = SessionLocal()
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Assign count = detect_stale_coverage(db)
count = detect_stale_coverage(db)
# Log info: "Stale detection finished — %d techniques flagged"
logger.info("Stale detection finished — %d techniques flagged", count)
# Handle Exception
except Exception:
# Log exception: "Stale coverage detection job failed"
logger.exception("Stale coverage detection job failed")
# Always execute this cleanup block
finally:
# Close the database session
db.close()
@@ -514,67 +347,40 @@ def start_scheduler() -> None:
Neither job fires immediately on startup.
"""
# Call scheduler.add_job()
scheduler.add_job(
_run_mitre_sync,
# Keyword argument: trigger
trigger="interval",
# Keyword argument: hours
hours=24,
# Keyword argument: id
id="mitre_sync",
# Keyword argument: name
name="MITRE ATT&CK sync (every 24h)",
# Keyword argument: replace_existing
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
_run_intel_scan,
# Keyword argument: trigger
trigger="interval",
# Keyword argument: weeks
weeks=1,
# Keyword argument: id
id="intel_scan",
# Keyword argument: name
name="Intel scan (every 7d)",
# Keyword argument: replace_existing
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
_run_notification_cleanup,
# Keyword argument: trigger
trigger="interval",
# Keyword argument: hours
hours=24,
# Keyword argument: id
id="notification_cleanup",
# Keyword argument: name
name="Notification cleanup (daily)",
# Keyword argument: replace_existing
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
_run_weekly_snapshot,
# Keyword argument: trigger
trigger="cron",
# Keyword argument: day_of_week
day_of_week="sun",
# Keyword argument: hour
hour=0,
# Keyword argument: minute
minute=0,
# Keyword argument: id
id="weekly_snapshot",
# Keyword argument: name
name="Weekly coverage snapshot (Sundays 00:00)",
# Keyword argument: replace_existing
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
_run_scheduled_campaign_activation,
trigger="interval",
@@ -585,71 +391,42 @@ def start_scheduler() -> None:
)
scheduler.add_job(
_run_recurring_campaigns,
# Keyword argument: trigger
trigger="interval",
# Keyword argument: hours
hours=24,
# Keyword argument: id
id="recurring_campaigns",
# Keyword argument: name
name="Recurring campaigns check (daily)",
# Keyword argument: replace_existing
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
sync_all_jira_links,
# Keyword argument: trigger
trigger="interval",
# Keyword argument: hours
hours=1,
# Keyword argument: id
id="jira_sync",
# Keyword argument: name
name="Jira link sync (hourly)",
# Keyword argument: replace_existing
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
_run_osint_enrichment,
# Keyword argument: trigger
trigger="interval",
# Keyword argument: weeks
weeks=1,
# Keyword argument: id
id="osint_enrichment",
# Keyword argument: name
name="OSINT enrichment (weekly)",
# Keyword argument: replace_existing
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
_run_stale_detection,
# Keyword argument: trigger
trigger="interval",
# Keyword argument: hours
hours=24,
# Keyword argument: id
id="stale_detection",
# Keyword argument: name
name="Stale coverage detection (daily)",
# Keyword argument: replace_existing
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
run_retention_job,
# Keyword argument: trigger
trigger="interval",
# Keyword argument: hours
hours=24,
# Keyword argument: id
id="retention_policies",
# Keyword argument: name
name="Data retention policies (daily)",
# Keyword argument: replace_existing
replace_existing=True,
)
scheduler.add_job(
@@ -686,27 +463,12 @@ def start_scheduler() -> None:
name="Operational alert evaluation (hourly)",
replace_existing=True,
)
scheduler.add_job(
_run_evaluation_round_check,
trigger="cron",
day_of_week="mon",
hour=6,
minute=0,
id="attck_evaluation_check",
name="ATT&CK Evaluations new-round check (Mondays 06:00)",
replace_existing=True,
)
scheduler.start()
# Log info:
logger.info(
# Literal argument value
"Background scheduler started — mitre_sync (24h), intel_scan (7d), "
# Literal argument value
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
# Literal argument value
"recurring_campaigns (daily), jira_sync (1h), "
# Literal argument value
"osint_enrichment (weekly), stale_detection (daily), "
"retention_policies (daily), data_sources_sync (6h), "
"alert_evaluation (1h), attck_evaluation_check (Mondays 06:00)"
"alert_evaluation (1h)"
)
-36
View File
@@ -1,89 +1,53 @@
"""Data retention policies — scheduled cleanup of aged records."""
# Enable future language features for compatibility
from __future__ import annotations
# Import logging
import logging
# Import datetime, timedelta, timezone from datetime
from datetime import datetime, timedelta, timezone
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import SessionLocal from app.database
from app.database import SessionLocal
# Import AuditLog from app.models.audit
from app.models.audit import AuditLog
# Import cleanup_old_notifications from app.services.notification_service
from app.services.notification_service import cleanup_old_notifications
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Assign AUDIT_LOG_RETENTION_DAYS = 730
AUDIT_LOG_RETENTION_DAYS = 730
# Define function apply_retention_policies
def apply_retention_policies(db: Session) -> dict[str, int]:
"""Apply retention rules. Commits the session before returning."""
# Assign cutoff = datetime.now(timezone.utc) - timedelta(days=AUDIT_LOG_RETENTION_DAYS)
cutoff = datetime.now(timezone.utc) - timedelta(days=AUDIT_LOG_RETENTION_DAYS)
# Assign deleted_audit = (
deleted_audit = (
db.query(AuditLog)
# Chain .filter() call
.filter(AuditLog.timestamp < cutoff)
# Chain .delete() call
.delete(synchronize_session=False)
)
# Check: deleted_audit
if deleted_audit:
# Log info:
logger.info(
# Literal argument value
"Retention: deleted %d audit logs older than %d days",
deleted_audit,
AUDIT_LOG_RETENTION_DAYS,
)
# Assign deleted_notifications = cleanup_old_notifications(db, days=90)
deleted_notifications = cleanup_old_notifications(db, days=90)
# Commit all pending changes to the database
db.commit()
# Return {
return {
# Literal argument value
"audit_logs_deleted": deleted_audit,
# Literal argument value
"notifications_deleted": deleted_notifications,
}
# Define function run_retention_job
def run_retention_job() -> None:
"""Entry point for the daily retention scheduler job."""
# Log info: "Scheduled retention job starting..."
logger.info("Scheduled retention job starting...")
# Assign db = SessionLocal()
db = SessionLocal()
# Attempt the following; catch errors below
try:
# Assign summary = apply_retention_policies(db)
summary = apply_retention_policies(db)
# Log info: "Retention job finished — %s", summary
logger.info("Retention job finished — %s", summary)
# Handle Exception
except Exception:
# Log exception: "Retention job failed"
logger.exception("Retention job failed")
# Roll back all uncommitted changes
db.rollback()
# Always execute this cleanup block
finally:
# Close the database session
db.close()
-4
View File
@@ -1,10 +1,6 @@
"""Shared SlowAPI rate limiter for all routers."""
# Import Limiter from slowapi
from slowapi import Limiter
# Import get_remote_address from slowapi.util
from slowapi.util import get_remote_address
# Assign limiter = Limiter(key_func=get_remote_address)
limiter = Limiter(key_func=get_remote_address)
-41
View File
@@ -8,101 +8,60 @@ In **development** (default), uses a human-readable text format for
comfortable local work.
"""
# Enable future language features for compatibility
from __future__ import annotations
# Import json
import json
# Import logging
import logging
# Import os
import os
# Import sys
import sys
# Import datetime, timezone from datetime
from datetime import datetime, timezone
# Define class _JSONFormatter
class _JSONFormatter(logging.Formatter):
"""Emit each log record as a single-line JSON object."""
# Define function format
def format(self, record: logging.LogRecord) -> str:
# Assign payload = {
payload: dict = {
# Literal argument value
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
# Literal argument value
"level": record.levelname,
# Literal argument value
"logger": record.name,
# Literal argument value
"message": record.getMessage(),
}
# Check: record.exc_info and record.exc_info[1] is not None
if record.exc_info and record.exc_info[1] is not None:
# Assign payload["exception"] = self.formatException(record.exc_info)
payload["exception"] = self.formatException(record.exc_info)
# Assign extra = getattr(record, "_extra", None)
extra = getattr(record, "_extra", None)
# Check: extra
if extra:
# Call payload.update()
payload.update(extra)
# Return json.dumps(payload, default=str)
return json.dumps(payload, default=str)
# Assign _DEV_FORMAT = "%(asctime)s %(levelname)-8s %(name)s — %(message)s"
_DEV_FORMAT = "%(asctime)s %(levelname)-8s %(name)s%(message)s"
# Define function setup_logging
def setup_logging() -> None:
"""Configure the root logger based on the environment."""
# Assign is_production = os.environ.get("AEGIS_ENV", "").lower() == "production"
is_production = os.environ.get("AEGIS_ENV", "").lower() == "production"
# Assign level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
# Assign level = getattr(logging, level_name, logging.INFO)
level = getattr(logging, level_name, logging.INFO)
# Assign root = logging.getLogger()
root = logging.getLogger()
# Call root.setLevel()
root.setLevel(level)
# Check: root.handlers
if root.handlers:
# Call root.handlers.clear()
root.handlers.clear()
# Assign handler = logging.StreamHandler(sys.stdout)
handler = logging.StreamHandler(sys.stdout)
# Call handler.setLevel()
handler.setLevel(level)
# Check: is_production
if is_production:
# Call handler.setFormatter()
handler.setFormatter(_JSONFormatter())
# Fallback: handle remaining cases
else:
# Call handler.setFormatter()
handler.setFormatter(logging.Formatter(_DEV_FORMAT))
# Call root.addHandler()
root.addHandler(handler)
# Call logging.getLogger()
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
# Call logging.getLogger()
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+28 -184
View File
@@ -1,41 +1,13 @@
"""FastAPI application factory and global middleware/exception configuration.
Builds the ``app`` instance, wires up CORS, rate limiting, domain-error
mapping, all API routers, and async lifespan hooks (MinIO bucket creation,
APScheduler startup/shutdown).
"""
# Import logging
import logging
# Import os
import os
# Import AsyncGenerator from collections.abc
from collections.abc import AsyncGenerator
# Import asynccontextmanager from contextlib
from contextlib import asynccontextmanager
# Import FastAPI, Request, status from fastapi
from fastapi import FastAPI, Request, status
# Import RequestValidationError from fastapi.exceptions
from fastapi.exceptions import RequestValidationError
# Import CORSMiddleware from fastapi.middleware.cors
from fastapi.middleware.cors import CORSMiddleware
# Import JSONResponse from fastapi.responses
from fastapi.responses import JSONResponse
# Import _rate_limit_exceeded_handler from slowapi
from fastapi.exceptions import RequestValidationError
from slowapi import _rate_limit_exceeded_handler
# Import RateLimitExceeded from slowapi.errors
from slowapi.errors import RateLimitExceeded
# Import SQLAlchemyError from sqlalchemy.exc
from sqlalchemy.exc import SQLAlchemyError
from app.routers import auth as auth_router
@@ -78,48 +50,24 @@ from app.routers import api_keys as api_keys_router
from app.routers import sso as sso_router
from app.routers import operational_alerts as alerts_router
from app.domain.errors import DomainError
# Import scheduler, start_scheduler from app.jobs.mitre_sync_job
from app.jobs.mitre_sync_job import scheduler, start_scheduler
# Import limiter from app.limiter
from app.limiter import limiter
# Import setup_logging from app.logging_config
from app.logging_config import setup_logging
# Import domain_exception_handler from app.middleware.error_handler
from app.middleware.error_handler import domain_exception_handler
# Import RequestContextMiddleware from app.middleware.request_context
from app.middleware.request_context import RequestContextMiddleware
from app.limiter import limiter
from app.storage import ensure_bucket_exists
from app.config import settings as _settings
from starlette.middleware.base import BaseHTTPMiddleware
# Configure structured logging before any module initialises its own logger
setup_logging()
logger = logging.getLogger(__name__)
from app.jobs.mitre_sync_job import start_scheduler, scheduler
# ── Environment detection ─────────────────────────────────────────────────
_IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production"
# Apply the @asynccontextmanager decorator
# ── Logging ───────────────────────────────────────────────────────────────
from app.logging_config import setup_logging
setup_logging()
@asynccontextmanager
# Define async function lifespan
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Manage application startup and shutdown lifecycle.
Args:
app (FastAPI): The FastAPI application instance.
Yields:
None: Control is yielded to the running application.
"""
# Call ensure_bucket_exists()
async def lifespan(app: FastAPI):
"""Startup / shutdown logic."""
ensure_bucket_exists()
# Call start_scheduler()
start_scheduler()
# Seed decay policies
from app.database import SessionLocal
@@ -127,8 +75,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
db = SessionLocal()
try:
seed_decay_policies(db)
except Exception as e:
logger.warning("seed_decay_policies failed at startup: %s", e)
except Exception:
pass
finally:
db.close()
# Seed operational alert system rules
@@ -136,8 +84,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
try:
from app.services.operational_alert_service import seed_system_rules
seed_system_rules(db2)
except Exception as e:
logger.warning("seed_system_rules failed at startup: %s", e)
except Exception:
pass
finally:
db2.close()
yield
@@ -147,30 +95,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# ── In production, disable Swagger UI and ReDoc to hide API surface ──────
app = FastAPI(
# Keyword argument: title
title="Attack Coverage Platform",
# Keyword argument: lifespan
lifespan=lifespan,
# Keyword argument: docs_url
docs_url=None if _IS_PRODUCTION else "/docs",
# Keyword argument: redoc_url
redoc_url=None if _IS_PRODUCTION else "/redoc",
# Keyword argument: openapi_url
openapi_url=None if _IS_PRODUCTION else "/openapi.json",
)
# ── Rate Limiter ──────────────────────────────────────────────────────────
app.state.limiter = limiter
# Call app.add_exception_handler()
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Call app.add_middleware()
app.add_middleware(RequestContextMiddleware)
# ── No-cache middleware for all /api/ responses ───────────────────────────
# Prevents Cloudflare and browser caches from storing API responses,
# which would cause stale/empty data to be served after backend restarts.
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse
class NoCacheAPIMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
@@ -186,77 +130,49 @@ app.add_middleware(NoCacheAPIMiddleware)
app.add_exception_handler(DomainError, domain_exception_handler)
# ── CORS ──────────────────────────────────────────────────────────────────
from app.config import settings as _settings
_cors_origins: list[str] = [
o.strip() for o in _settings.CORS_ORIGINS.split(",") if o.strip()
]
# Call app.add_middleware()
app.add_middleware(
CORSMiddleware,
# Keyword argument: allow_origins
allow_origins=_cors_origins,
# Keyword argument: allow_credentials
allow_credentials=True,
# Keyword argument: allow_methods
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
# Keyword argument: allow_headers
allow_headers=["Authorization", "Content-Type"],
)
# ── Routers ──────────────────────────────────────────────────────────────
app.include_router(auth_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(techniques_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(tests_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(evidence_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(test_templates_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(system_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(metrics_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(users_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(audit_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(notifications_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(reports_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(data_sources_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(threat_actors_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(d3fend_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(detection_rules_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(campaigns_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(heatmap_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(scores_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(operational_metrics_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(compliance_router.router, prefix="/api/v1")
app.include_router(intel_router.router, prefix="/api/v1")
app.include_router(admin_config_router.router, prefix="/api/v1")
app.include_router(snapshots_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(jira_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(worklogs_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(professional_reports_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(analytics_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(advanced_metrics_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(osint_router.router, prefix="/api/v1")
app.include_router(webhooks_router.router, prefix="/api/v1")
app.include_router(detection_lifecycle_router.router, prefix="/api/v1")
@@ -270,19 +186,13 @@ app.include_router(sso_router.router, prefix="/api/v1")
app.include_router(alerts_router.router, prefix="/api/v1")
# Apply the @app.get decorator
@app.get("/health", include_in_schema=False)
# Define function health
def health() -> dict[str, str]:
"""Return a minimal liveness probe response.
def health():
"""Minimal health check — returns only an HTTP 200 with no service metadata.
Access is restricted to internal networks at the Nginx level
(see ``frontend/nginx.conf``).
Returns:
dict[str, str]: A dict with ``{"status": "ok"}``.
"""
# Return {"status": "ok"}
return {"status": "ok"}
@@ -290,117 +200,51 @@ def health() -> dict[str, str]:
def _serialize_validation_errors(exc: RequestValidationError) -> list[dict]:
"""Return validation errors safe for JSON serialization.
Converts non-serializable values inside ``ctx`` dictionaries to strings
so the response body can be safely encoded.
Args:
exc (RequestValidationError): The Pydantic validation exception.
Returns:
list[dict]: A list of sanitised error detail dictionaries.
"""
# Assign serialized = []
"""Return validation errors safe for JSON (no raw exception objects)."""
serialized: list[dict] = []
# Iterate over exc.errors()
for err in exc.errors():
# Assign item = dict(err)
item = dict(err)
# Assign ctx = item.get("ctx")
ctx = item.get("ctx")
# Check: isinstance(ctx, dict)
if isinstance(ctx, dict):
# Assign item["ctx"] = {key: str(value) for key, value in ctx.items()}
item["ctx"] = {key: str(value) for key, value in ctx.items()}
# Call serialized.append()
serialized.append(item)
# Return serialized
return serialized
# Apply the @app.exception_handler decorator
@app.exception_handler(RequestValidationError)
# Define async function validation_exception_handler
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Handle Pydantic validation errors and return a structured 422 response.
Args:
request (Request): The incoming HTTP request.
exc (RequestValidationError): The caught validation exception.
Returns:
JSONResponse: A 422 response with a ``VALIDATION_ERROR`` code and error details.
"""
# Return JSONResponse(
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle validation errors with consistent format."""
return JSONResponse(
# Keyword argument: status_code
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
# Keyword argument: content
content={
# Literal argument value
"detail": "Validation error",
# Literal argument value
"code": "VALIDATION_ERROR",
# Literal argument value
"errors": _serialize_validation_errors(exc),
},
)
# Apply the @app.exception_handler decorator
@app.exception_handler(SQLAlchemyError)
# Define async function sqlalchemy_exception_handler
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) -> JSONResponse:
"""Handle SQLAlchemy database errors and return a structured 500 response.
Args:
request (Request): The incoming HTTP request.
exc (SQLAlchemyError): The caught SQLAlchemy exception.
Returns:
JSONResponse: A 500 response with a ``DATABASE_ERROR`` code.
"""
# Log error: f"Database error: {exc}"
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
"""Handle database errors."""
logging.error(f"Database error: {exc}")
# Return JSONResponse(
return JSONResponse(
# Keyword argument: status_code
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
# Keyword argument: content
content={
# Literal argument value
"detail": "Database error occurred",
# Literal argument value
"code": "DATABASE_ERROR",
},
)
# Apply the @app.exception_handler decorator
@app.exception_handler(Exception)
# Define async function general_exception_handler
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle all otherwise-unhandled exceptions and return a structured 500 response.
Args:
request (Request): The incoming HTTP request.
exc (Exception): The unhandled exception.
Returns:
JSONResponse: A 500 response with an ``INTERNAL_ERROR`` code.
"""
# Log error: f"Unhandled exception: {exc}"
async def general_exception_handler(request: Request, exc: Exception):
"""Handle all unhandled exceptions."""
logging.error(f"Unhandled exception: {exc}")
# Return JSONResponse(
return JSONResponse(
# Keyword argument: status_code
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
# Keyword argument: content
content={
# Literal argument value
"detail": "An internal server error occurred",
# Literal argument value
"code": "INTERNAL_ERROR",
},
)
-1
View File
@@ -1 +0,0 @@
"""ASGI middleware components for request context, error handling, and rate limiting."""
-21
View File
@@ -5,13 +5,9 @@ domain-layer errors into structured JSON responses, keeping
the service layer free from FastAPI's ``HTTPException``.
"""
# Import Request from fastapi
from fastapi import Request
# Import JSONResponse from fastapi.responses
from fastapi.responses import JSONResponse
# Import from app.domain.errors
from app.domain.errors import (
BusinessRuleViolation,
DomainError,
@@ -22,45 +18,28 @@ from app.domain.errors import (
PermissionViolation,
)
# Assign EXCEPTION_STATUS_MAP = {
EXCEPTION_STATUS_MAP: dict[type[DomainError], int] = {
# Entry: EntityNotFoundError
EntityNotFoundError: 404,
# Entry: DuplicateEntityError
DuplicateEntityError: 409,
# Entry: InvalidStateTransition
InvalidStateTransition: 400,
# Entry: InvalidOperationError
InvalidOperationError: 400,
# Entry: BusinessRuleViolation
BusinessRuleViolation: 400,
# Entry: PermissionViolation
PermissionViolation: 403,
}
# Define async function domain_exception_handler
async def domain_exception_handler(
# Entry: request
request: Request,
# Entry: exc
exc: DomainError,
) -> JSONResponse:
"""Convert a :class:`DomainError` into a JSON error response."""
# Assign status_code = EXCEPTION_STATUS_MAP.get(type(exc), 400)
status_code = EXCEPTION_STATUS_MAP.get(type(exc), 400)
# Assign content = {"detail": exc.message, "code": exc.code}
content: dict = {"detail": exc.message, "code": exc.code}
# Check: isinstance(exc, InvalidStateTransition)
if isinstance(exc, InvalidStateTransition):
# Assign content["current_state"] = exc.current_state
content["current_state"] = exc.current_state
# Assign content["target_state"] = exc.target_state
content["target_state"] = exc.target_state
# Assign content["valid_transitions"] = exc.valid_transitions
content["valid_transitions"] = exc.valid_transitions
# Return JSONResponse(status_code=status_code, content=content)
return JSONResponse(status_code=status_code, content=content)
+2 -50
View File
@@ -1,74 +1,26 @@
"""Request context middleware — captures client IP and User-Agent per request."""
# Import Awaitable, Callable from collections.abc
from collections.abc import Awaitable, Callable
# Import ContextVar from contextvars
from contextvars import ContextVar
# Import Request from fastapi
from fastapi import Request
# Import BaseHTTPMiddleware from starlette.middleware.base
from starlette.middleware.base import BaseHTTPMiddleware
# Import Response from starlette.responses
from starlette.responses import Response
# Assign request_ip = ContextVar("request_ip", default="")
request_ip: ContextVar[str] = ContextVar("request_ip", default="")
# Assign request_user_agent = ContextVar("request_user_agent", default="")
request_user_agent: ContextVar[str] = ContextVar("request_user_agent", default="")
# Define function resolve_client_ip
def resolve_client_ip(request: Request) -> str:
"""Extract the real client IP, honouring ``X-Forwarded-For`` when present.
Args:
request (Request): The incoming Starlette/FastAPI request.
Returns:
str: The resolved client IP address, or ``"unknown"`` when unavailable.
"""
# Assign forwarded = request.headers.get("X-Forwarded-For")
"""Extract the client IP, honouring ``X-Forwarded-For`` when present."""
forwarded = request.headers.get("X-Forwarded-For")
# Check: forwarded
if forwarded:
# Return forwarded.split(",")[0].strip()
return forwarded.split(",")[0].strip()
# Check: request.client
if request.client:
# Return request.client.host
return request.client.host
# Return "unknown"
return "unknown"
# Define class RequestContextMiddleware
class RequestContextMiddleware(BaseHTTPMiddleware):
"""Middleware that captures client IP and User-Agent into context variables."""
# Define async function dispatch
async def dispatch(
self,
# Entry: request
request: Request,
# Entry: call_next
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Store client IP and User-Agent in context vars for the current request.
Args:
request (Request): The incoming HTTP request.
call_next (Callable[[Request], Awaitable[Response]]): The next middleware or route handler.
Returns:
Response: The HTTP response produced by the downstream handler.
"""
# Call request_ip.set()
async def dispatch(self, request: Request, call_next):
request_ip.set(resolve_client_ip(request))
# Call request_user_agent.set()
request_user_agent.set(request.headers.get("User-Agent", ""))
# Return await call_next(request)
return await call_next(request)
+7 -21
View File
@@ -1,5 +1,10 @@
"""SQLAlchemy ORM model definitions for all database tables."""
# Import all models here so Alembic can detect them
from app.models.user import User
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.evidence import Evidence
from app.models.intel import IntelItem
from app.models.audit import AuditLog
from app.models.notification import Notification
from app.models.data_source import DataSource
@@ -39,41 +44,22 @@ from app.models.executive_dashboard import PostureSnapshot
from app.models.api_key import ApiKey
from app.models.sso_config import SsoConfig
from app.models.operational_alert import AlertRule, AlertInstance
from app.models.evidence import Evidence
from app.models.intel import IntelItem
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.user import User
# Assign __all__ = [
__all__ = [
# Literal argument value
"User", "Technique", "Test", "TestTemplate", "Evidence",
# Literal argument value
"IntelItem", "AuditLog", "Notification", "DataSource",
# Literal argument value
"DetectionRule", "ThreatActor", "ThreatActorTechnique",
# Literal argument value
"DefensiveTechnique", "DefensiveTechniqueMapping",
# Literal argument value
"TestTemplateDetectionRule", "TestDetectionResult",
# Literal argument value
"Campaign", "CampaignTest",
# Literal argument value
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
# Literal argument value
"CoverageSnapshot", "SnapshotTechniqueState",
# Literal argument value
"JiraLink", "JiraLinkEntityType", "JiraSyncDirection",
# Literal argument value
"Worklog", "OsintItem", "ScoringConfig",
# Literal argument value
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
"WebhookConfig", "SystemConfig",
"DetectionAsset", "DetectionTechniqueMapping", "DetectionValidation",
"TechniqueConfidenceScore", "InfrastructureChangeLog",
"DetectionConfidence", "DetectionHealthStatus", "InvalidationReason", "DecayPolicy",
"TechniqueConfidenceScore", "InfrastructureChangeLog", "DecayPolicy",
"TechniqueOwnership", "RevalidationQueueItem",
"QueuePriority", "QueueStatus", "QueueReason",
"AttackPath", "AttackPathStep", "AttackPathExecution",
+5 -28
View File
@@ -1,58 +1,35 @@
"""SQLAlchemy model for the audit log table."""
# Import uuid
import uuid
# Import Column, DateTime, ForeignKey, Index, String, func from sqlalchemy
from sqlalchemy import Column, DateTime, ForeignKey, Index, String, func
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy import Column, String, DateTime, ForeignKey, Index, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class AuditLog
class AuditLog(Base):
"""Audit log model for tracking all system actions.
"""
Audit log model for tracking all system actions.
Records user actions, entity changes, and system events
for security auditing and compliance purposes.
"""
# Assign __tablename__ = "audit_logs"
__tablename__ = "audit_logs"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Assign action = Column(String, nullable=False)
action = Column(String, nullable=False)
# Assign entity_type = Column(String, nullable=True)
entity_type = Column(String, nullable=True)
# Assign entity_id = Column(String, nullable=True)
entity_id = Column(String, nullable=True)
# Assign timestamp = Column(DateTime(timezone=True), server_default=func.now())
timestamp = Column(DateTime(timezone=True), server_default=func.now())
# Assign details = Column(JSONB, nullable=True)
details = Column(JSONB, nullable=True)
# Assign ip_address = Column(String(45), nullable=True)
ip_address = Column(String(45), nullable=True)
# Assign user_agent = Column(String(500), nullable=True)
user_agent = Column(String(500), nullable=True)
# Assign integrity_hash = Column(String(64), nullable=True)
integrity_hash = Column(String(64), nullable=True)
# Assign session_id = Column(String(100), nullable=True)
session_id = Column(String(100), nullable=True)
# Relationships
user = relationship("User")
# Assign __table_args__ = (
__table_args__ = (
Index("ix_audit_logs_entity", "entity_type", "entity_id"),
Index("ix_audit_logs_timestamp", "timestamp"),
+7 -84
View File
@@ -4,35 +4,20 @@ Campaigns group multiple tests into a kill chain sequence,
enabling simulation of complete attack chains and APT emulations.
"""
# Import uuid
import uuid
# Import from sqlalchemy
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
Column, String, Text, Integer, Boolean, DateTime,
ForeignKey, Index, func,
)
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class Campaign
class Campaign(Base):
"""A campaign groups multiple tests into a sequenced attack chain.
"""
A campaign groups multiple tests into a sequenced attack chain.
Types:
- custom: manually created campaign
@@ -46,97 +31,62 @@ class Campaign(Base):
- completed: all tests done
- archived: historical record
"""
# Assign __tablename__ = "campaigns"
__tablename__ = "campaigns"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign name = Column(String, nullable=False)
name = Column(String, nullable=False)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign type = Column(String, nullable=False, default="custom") # custom, ap...
type = Column(String, nullable=False, default="custom") # custom, apt_emulation, kill_chain, compliance
# Assign threat_actor_id = Column(
threat_actor_id = Column(
UUID(as_uuid=True),
ForeignKey("threat_actors.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
# Assign status = Column(String, nullable=False, default="draft") # draft, activ...
status = Column(String, nullable=False, default="draft") # draft, active, completed, archived
# Assign created_by = Column(
created_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date
scheduled_at = Column(DateTime, nullable=True)
# Assign completed_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
# Assign target_platform = Column(String, nullable=True)
target_platform = Column(String, nullable=True)
# Assign tags = Column(JSONB, nullable=True, default=[])
tags = Column(JSONB, nullable=True, default=[])
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal")
# Recurring scheduling fields
is_recurring = Column(Boolean, default=False)
# Assign recurrence_pattern = Column(String, nullable=True) # weekly, monthly, quarterly
recurrence_pattern = Column(String, nullable=True) # weekly, monthly, quarterly
# Assign next_run_at = Column(DateTime, nullable=True)
next_run_at = Column(DateTime, nullable=True)
# Assign last_run_at = Column(DateTime, nullable=True)
last_run_at = Column(DateTime, nullable=True)
# Assign parent_campaign_id = Column(
parent_campaign_id = Column(
UUID(as_uuid=True),
ForeignKey("campaigns.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
# Relationships
threat_actor = relationship("ThreatActor")
# Assign creator = relationship("User", foreign_keys=[created_by])
creator = relationship("User", foreign_keys=[created_by])
# Assign campaign_tests = relationship(
campaign_tests = relationship(
# Literal argument value
"CampaignTest",
# Keyword argument: back_populates
back_populates="campaign",
# Keyword argument: cascade
cascade="all, delete-orphan",
# Keyword argument: order_by
order_by="CampaignTest.order_index",
)
# Assign parent_campaign = relationship(
parent_campaign = relationship(
# Literal argument value
"Campaign",
# Keyword argument: remote_side
remote_side="Campaign.id",
# Keyword argument: foreign_keys
foreign_keys=[parent_campaign_id],
)
# Assign child_campaigns = relationship(
child_campaigns = relationship(
# Literal argument value
"Campaign",
# Keyword argument: foreign_keys
foreign_keys=[parent_campaign_id],
# Keyword argument: back_populates
back_populates="parent_campaign",
)
# Assign __table_args__ = (
__table_args__ = (
Index('ix_campaigns_status', 'status'),
Index('ix_campaigns_type', 'type'),
@@ -148,83 +98,56 @@ class Campaign(Base):
# Kill chain phases in order (for sorting and validation)
KILL_CHAIN_PHASES = [
# Literal argument value
"reconnaissance",
# Literal argument value
"resource_development",
# Literal argument value
"initial_access",
# Literal argument value
"execution",
# Literal argument value
"persistence",
# Literal argument value
"privilege_escalation",
# Literal argument value
"defense_evasion",
# Literal argument value
"credential_access",
# Literal argument value
"discovery",
# Literal argument value
"lateral_movement",
# Literal argument value
"collection",
# Literal argument value
"command_and_control",
# Literal argument value
"exfiltration",
# Literal argument value
"impact",
]
# Define class CampaignTest
class CampaignTest(Base):
"""A test within a campaign, with ordering and dependency information.
"""
A test within a campaign, with ordering and dependency information.
``depends_on`` creates a self-referential chain (A -> B -> C).
Circular dependencies are validated at the service layer.
"""
# Assign __tablename__ = "campaign_tests"
__tablename__ = "campaign_tests"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign campaign_id = Column(
campaign_id = Column(
UUID(as_uuid=True),
ForeignKey("campaigns.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign test_id = Column(
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign order_index = Column(Integer, nullable=False, default=0)
order_index = Column(Integer, nullable=False, default=0)
# Assign depends_on = Column(
depends_on = Column(
UUID(as_uuid=True),
ForeignKey("campaign_tests.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
# Assign phase = Column(String, nullable=True) # kill chain phase
phase = Column(String, nullable=True) # kill chain phase
# Relationships
campaign = relationship("Campaign", back_populates="campaign_tests")
# Assign test = relationship("Test")
test = relationship("Test")
# Assign dependency = relationship("CampaignTest", remote_side="CampaignTest.id")
dependency = relationship("CampaignTest", remote_side="CampaignTest.id")
# Assign __table_args__ = (
__table_args__ = (
Index('ix_campaign_tests_campaign', 'campaign_id'),
Index('ix_campaign_tests_test', 'test_id'),
+2 -55
View File
@@ -4,145 +4,92 @@ Maps compliance frameworks (NIST 800-53, DORA, NIS2, ISO 27001) to
MITRE ATT&CK techniques, enabling compliance gap analysis.
"""
# Import uuid
import uuid
# Import from sqlalchemy
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
func,
Column, String, Text, Boolean, DateTime,
ForeignKey, Index, UniqueConstraint, func,
)
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class ComplianceFramework
class ComplianceFramework(Base):
"""A compliance framework (e.g. NIST 800-53, ISO 27001)."""
# Assign __tablename__ = "compliance_frameworks"
__tablename__ = "compliance_frameworks"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign name = Column(String, unique=True, nullable=False)
name = Column(String, unique=True, nullable=False)
# Assign version = Column(String, nullable=True)
version = Column(String, nullable=True)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign url = Column(String, nullable=True)
url = Column(String, nullable=True)
# Assign is_active = Column(Boolean, default=True)
is_active = Column(Boolean, default=True)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
controls = relationship(
# Literal argument value
"ComplianceControl",
# Keyword argument: back_populates
back_populates="framework",
# Keyword argument: cascade
cascade="all, delete-orphan",
)
# Define class ComplianceControl
class ComplianceControl(Base):
"""A control within a compliance framework (e.g. AC-2, PR.AC-1)."""
# Assign __tablename__ = "compliance_controls"
__tablename__ = "compliance_controls"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign framework_id = Column(
framework_id = Column(
UUID(as_uuid=True),
ForeignKey("compliance_frameworks.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign control_id = Column(String, nullable=False) # e.g. "AC-2"
control_id = Column(String, nullable=False) # e.g. "AC-2"
# Assign title = Column(String, nullable=False)
title = Column(String, nullable=False)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign category = Column(String, nullable=True)
category = Column(String, nullable=True)
# Relationships
framework = relationship("ComplianceFramework", back_populates="controls")
# Assign technique_mappings = relationship(
technique_mappings = relationship(
# Literal argument value
"ComplianceControlMapping",
# Keyword argument: back_populates
back_populates="compliance_control",
# Keyword argument: cascade
cascade="all, delete-orphan",
)
# Assign __table_args__ = (
__table_args__ = (
Index('ix_compliance_controls_framework', 'framework_id'),
)
# Define class ComplianceControlMapping
class ComplianceControlMapping(Base):
"""Maps a compliance control to a MITRE ATT&CK technique."""
# Assign __tablename__ = "compliance_control_mappings"
__tablename__ = "compliance_control_mappings"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign compliance_control_id = Column(
compliance_control_id = Column(
UUID(as_uuid=True),
ForeignKey("compliance_controls.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign technique_id = Column(
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Relationships
compliance_control = relationship(
# Literal argument value
"ComplianceControl", back_populates="technique_mappings"
)
# Assign technique = relationship("Technique")
technique = relationship("Technique")
# Assign __table_args__ = (
__table_args__ = (
Index('ix_compliance_mappings_control', 'compliance_control_id'),
Index('ix_compliance_mappings_technique', 'technique_id'),
UniqueConstraint(
# Literal argument value
'compliance_control_id', 'technique_id',
# Keyword argument: name
name='uq_control_technique',
),
)
+2 -51
View File
@@ -5,125 +5,76 @@ SnapshotTechniqueState stores per-technique state (normalized, one row
per technique per snapshot) to avoid bloated JSONB fields.
"""
# Import uuid
import uuid
# Import from sqlalchemy
from sqlalchemy import (
Column,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
func,
Column, String, Float, Integer, DateTime,
ForeignKey, Index, func,
)
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class CoverageSnapshot
class CoverageSnapshot(Base):
"""A point-in-time snapshot of the organisation's overall coverage."""
# Assign __tablename__ = "coverage_snapshots"
__tablename__ = "coverage_snapshots"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign name = Column(String, nullable=True) # e.g. "Pre-remediación Q1"
name = Column(String, nullable=True) # e.g. "Pre-remediación Q1"
# Assign organization_score = Column(Float, nullable=False)
organization_score = Column(Float, nullable=False)
# Assign total_techniques = Column(Integer, nullable=False)
total_techniques = Column(Integer, nullable=False)
# Assign validated_count = Column(Integer, nullable=False)
validated_count = Column(Integer, nullable=False)
# Assign partial_count = Column(Integer, nullable=False)
partial_count = Column(Integer, nullable=False)
# Assign not_covered_count = Column(Integer, nullable=False)
not_covered_count = Column(Integer, nullable=False)
# Assign in_progress_count = Column(Integer, nullable=False)
in_progress_count = Column(Integer, nullable=False)
# Assign not_evaluated_count = Column(Integer, nullable=False)
not_evaluated_count = Column(Integer, nullable=False)
# Assign coverage_percentage = Column(Float, nullable=False, default=0.0)
coverage_percentage = Column(Float, nullable=False, default=0.0)
# Assign by_tactic = Column(JSONB, nullable=False, default=dict)
by_tactic = Column(JSONB, nullable=False, default=dict)
# Assign by_status = Column(JSONB, nullable=False, default=dict)
by_status = Column(JSONB, nullable=False, default=dict)
# Assign stale_count = Column(Integer, nullable=False, default=0)
stale_count = Column(Integer, nullable=False, default=0)
# Assign never_tested_count = Column(Integer, nullable=False, default=0)
never_tested_count = Column(Integer, nullable=False, default=0)
# Assign created_by = Column(
created_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
creator = relationship("User", foreign_keys=[created_by])
# Assign technique_states = relationship(
technique_states = relationship(
# Literal argument value
"SnapshotTechniqueState",
# Keyword argument: back_populates
back_populates="snapshot",
# Keyword argument: cascade
cascade="all, delete-orphan",
)
# Define class SnapshotTechniqueState
class SnapshotTechniqueState(Base):
"""Per-technique state within a snapshot (normalised storage)."""
# Assign __tablename__ = "snapshot_technique_states"
__tablename__ = "snapshot_technique_states"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign snapshot_id = Column(
snapshot_id = Column(
UUID(as_uuid=True),
ForeignKey("coverage_snapshots.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign technique_id = Column(
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign mitre_id = Column(String, nullable=False) # denormalised for fast queries
mitre_id = Column(String, nullable=False) # denormalised for fast queries
# Assign status = Column(String, nullable=False)
status = Column(String, nullable=False)
# Assign score = Column(Float, nullable=True)
score = Column(Float, nullable=True)
# Relationships
snapshot = relationship("CoverageSnapshot", back_populates="technique_states")
# Assign technique = relationship("Technique")
technique = relationship("Technique")
# Assign __table_args__ = (
__table_args__ = (
Index("ix_snapshot_technique_states_snapshot", "snapshot_id"),
Index("ix_snapshot_technique_states_technique", "technique_id"),
+9 -29
View File
@@ -1,56 +1,36 @@
"""DataSource model — registry of external data sources for import."""
# Import uuid
import uuid
from sqlalchemy import Column, String, Text, Boolean, DateTime, Index, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
# Import Boolean, Column, DateTime, Index, String, Text,... from sqlalchemy
from sqlalchemy import Boolean, Column, DateTime, Index, String, Text, func
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import Base from app.database
from app.database import Base
# Define class DataSource
class DataSource(Base):
"""Unified registry of all external data sources.
Covers attack procedures, detection rules, threat intel, and defensive techniques.
Each source can be independently enabled/disabled and tracks its own synchronisation state.
"""
# Assign __tablename__ = "data_sources"
Unified registry of all external data sources (attack procedures,
detection rules, threat intel, defensive techniques).
Each source can be independently enabled/disabled and tracks its own
synchronisation state.
"""
__tablename__ = "data_sources"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign name = Column(String, unique=True, nullable=False) # e.g. "atom...
name = Column(String, unique=True, nullable=False) # e.g. "atomic_red_team"
# Assign display_name = Column(String, nullable=False) # e.g. "Atomic Red ...
display_name = Column(String, nullable=False) # e.g. "Atomic Red Team"
# Values: attack_procedure / detection_rule / threat_intel / defensive_technique
type = Column(String, nullable=False)
# Assign url = Column(String, nullable=True) # URL base...
type = Column(String, nullable=False) # attack_procedure / detection_rule / threat_intel / defensive_technique
url = Column(String, nullable=True) # URL base of repo/API
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign is_enabled = Column(Boolean, default=True)
is_enabled = Column(Boolean, default=True)
# Assign last_sync_at = Column(DateTime, nullable=True)
last_sync_at = Column(DateTime, nullable=True)
# Assign last_sync_status = Column(String, nullable=True) # success / error / in_...
last_sync_status = Column(String, nullable=True) # success / error / in_progress
# Assign last_sync_stats = Column(JSONB, nullable=True) # {"imported": X, "upd...
last_sync_stats = Column(JSONB, nullable=True) # {"imported": X, "updated": Y, ...}
# Assign sync_frequency = Column(String, nullable=True) # daily / weekly / mo...
sync_frequency = Column(String, nullable=True) # daily / weekly / monthly / manual
# Assign config = Column(JSONB, nullable=True) # source-spec...
config = Column(JSONB, nullable=True) # source-specific configuration
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign __table_args__ = (
__table_args__ = (
Index('ix_data_sources_type', 'type'),
Index('ix_data_sources_is_enabled', 'is_enabled'),
+8 -42
View File
@@ -4,108 +4,74 @@ Stores MITRE D3FEND defensive techniques and their mappings to
ATT&CK techniques, enabling recommended countermeasure lookups.
"""
# Import uuid
import uuid
# Import from sqlalchemy
from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
func,
Column, String, Text, DateTime,
ForeignKey, Index, UniqueConstraint, func,
)
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class DefensiveTechnique
class DefensiveTechnique(Base):
"""MITRE D3FEND defensive technique.
"""
MITRE D3FEND defensive technique.
Represents a countermeasure from the D3FEND framework that can be
mapped to one or more ATT&CK techniques via DefensiveTechniqueMapping.
"""
# Assign __tablename__ = "defensive_techniques"
__tablename__ = "defensive_techniques"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign d3fend_id = Column(String, unique=True, nullable=False) # e.g. "D3-AL"
d3fend_id = Column(String, unique=True, nullable=False) # e.g. "D3-AL"
# Assign name = Column(String, nullable=False)
name = Column(String, nullable=False)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign tactic = Column(String, nullable=True) # Detect, ...
tactic = Column(String, nullable=True) # Detect, Isolate, Deceive, Evict, etc.
# Assign d3fend_url = Column(String, nullable=True)
d3fend_url = Column(String, nullable=True)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
attack_mappings = relationship(
# Literal argument value
"DefensiveTechniqueMapping",
# Keyword argument: back_populates
back_populates="defensive_technique",
# Keyword argument: cascade
cascade="all, delete-orphan",
)
# Assign __table_args__ = (
__table_args__ = (
Index('ix_defensive_techniques_tactic', 'tactic'),
)
# Define class DefensiveTechniqueMapping
class DefensiveTechniqueMapping(Base):
"""Association between a MITRE ATT&CK technique and a D3FEND defensive technique."""
# Assign __tablename__ = "defensive_technique_mappings"
"""
Association between a MITRE ATT&CK technique and a D3FEND
defensive technique.
"""
__tablename__ = "defensive_technique_mappings"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign attack_technique_id = Column(
attack_technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign defensive_technique_id = Column(
defensive_technique_id = Column(
UUID(as_uuid=True),
ForeignKey("defensive_techniques.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Relationships
attack_technique = relationship("Technique")
# Assign defensive_technique = relationship("DefensiveTechnique", back_populates="attack_mappings")
defensive_technique = relationship("DefensiveTechnique", back_populates="attack_mappings")
# Assign __table_args__ = (
__table_args__ = (
Index('ix_dtm_attack_technique', 'attack_technique_id'),
Index('ix_dtm_defensive_technique', 'defensive_technique_id'),
UniqueConstraint(
# Literal argument value
'attack_technique_id', 'defensive_technique_id',
# Keyword argument: name
name='uq_attack_defensive_technique',
),
)
+4 -27
View File
@@ -1,61 +1,38 @@
"""DetectionRule model — detection rules from multiple sources."""
# Import uuid
import uuid
from sqlalchemy import Column, String, Text, Boolean, DateTime, Index, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
# Import Boolean, Column, DateTime, Index, String, Text,... from sqlalchemy
from sqlalchemy import Boolean, Column, DateTime, Index, String, Text, func
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import Base from app.database
from app.database import Base
# Define class DetectionRule
class DetectionRule(Base):
"""Detection rule from an external source (Sigma, Elastic, Splunk, custom).
"""
Detection rule from an external source (Sigma, Elastic, Splunk, custom).
Each rule is mapped to one MITRE ATT&CK technique via
``mitre_technique_id`` and stores the complete rule content in
``rule_content``.
"""
# Assign __tablename__ = "detection_rules"
__tablename__ = "detection_rules"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign mitre_technique_id = Column(String, nullable=False) # e.g. "T1059.001"
mitre_technique_id = Column(String, nullable=False) # e.g. "T1059.001"
# Assign title = Column(String, nullable=False)
title = Column(String, nullable=False)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign source = Column(String, nullable=False) # sigma / ela...
source = Column(String, nullable=False) # sigma / elastic / splunk / custom
# Assign source_id = Column(String, nullable=True) # ID in the sour...
source_id = Column(String, nullable=True) # ID in the source repo (for dedup)
# Assign source_url = Column(String, nullable=True)
source_url = Column(String, nullable=True)
# Assign rule_content = Column(Text, nullable=False) # YAML / KQL / SPL ...
rule_content = Column(Text, nullable=False) # YAML / KQL / SPL content
# Assign rule_format = Column(String, nullable=False) # sigma_yaml / kql...
rule_format = Column(String, nullable=False) # sigma_yaml / kql / spl / custom
# Assign severity = Column(String, nullable=True) # informational...
severity = Column(String, nullable=True) # informational / low / medium / high / critical
# Assign platforms = Column(JSONB, nullable=True, default=[])
platforms = Column(JSONB, nullable=True, default=[])
# Assign log_sources = Column(JSONB, nullable=True) # e.g. {"product":...
log_sources = Column(JSONB, nullable=True) # e.g. {"product": "windows", "service": "sysmon"}
# Assign false_positive_rate = Column(String, nullable=True) # low / medium / high
false_positive_rate = Column(String, nullable=True) # low / medium / high
# Assign is_active = Column(Boolean, default=True)
is_active = Column(Boolean, default=True)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign __table_args__ = (
__table_args__ = (
Index('ix_detection_rules_mitre_technique_id', 'mitre_technique_id'),
Index('ix_detection_rules_source', 'source'),
-1
View File
@@ -5,7 +5,6 @@ re-exports every enum so that existing model and router code keeps
working with ``from app.models.enums import ...``.
"""
# Import # noqa: F401 from app.domain.enums
from app.domain.enums import ( # noqa: F401
DataClassification,
TeamSide,
-34
View File
@@ -1,34 +0,0 @@
"""SQLAlchemy model for tracking imported ATT&CK Evaluation rounds."""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Integer, DateTime, Text, ForeignKey, Index
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class EvaluationImport(Base):
"""Tracks which ATT&CK Evaluation rounds have been imported into the platform.
Each row represents one vendor+adversary combination that has been processed
and turned into Test records. Used to avoid duplicate imports and to show
the admin panel which rounds are available vs imported.
"""
__tablename__ = "evaluation_imports"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
adversary_name = Column(String, nullable=False) # "apt29", "turla"
adversary_display = Column(String, nullable=False) # "APT29", "Turla"
eval_round = Column(Integer, nullable=False) # 1, 2, 3 …
imported_at = Column(DateTime, nullable=False, default=datetime.utcnow)
imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
tests_created = Column(Integer, default=0)
techniques_covered = Column(Integer, default=0)
status = Column(String, default="completed") # "completed" | "failed"
notes = Column(Text, nullable=True)
__table_args__ = (
Index("ix_evaluation_imports_adversary", "adversary_name"),
Index("ix_evaluation_imports_round", "eval_round"),
)
+5 -29
View File
@@ -1,59 +1,35 @@
"""SQLAlchemy model for the evidence table."""
# Import uuid
import uuid
# Import Column, DateTime, Enum, ForeignKey, String, Tex... from sqlalchemy
from sqlalchemy import Column, DateTime, Enum, ForeignKey, String, Text, func
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Enum, func
from sqlalchemy.dialects.postgresql import UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Import TeamSide from app.models.enums
from app.models.enums import TeamSide
# Define class Evidence
class Evidence(Base):
"""Evidence model for storing file metadata associated with tests.
"""
Evidence model for storing file metadata associated with tests.
Files are stored in MinIO, and this model tracks the file location,
integrity hash, and upload metadata.
The ``team`` field distinguishes whether this evidence was uploaded by
Red Team (attack evidence) or Blue Team (detection evidence).
"""
# Assign __tablename__ = "evidences"
__tablename__ = "evidences"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=False)
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=False)
# Assign file_name = Column(String, nullable=False)
file_name = Column(String, nullable=False)
# Assign file_path = Column(String, nullable=False) # Path in MinIO
file_path = Column(String, nullable=False) # Path in MinIO
# Assign sha256_hash = Column(String, nullable=False)
sha256_hash = Column(String, nullable=False)
# Assign uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Assign uploaded_at = Column(DateTime(timezone=True), server_default=func.now())
uploaded_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=Tea...
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
# Assign notes = Column(Text, nullable=True)
notes = Column(Text, nullable=True)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal")
# Relationships
test = relationship("Test", back_populates="evidences")
# Assign uploader = relationship("User", foreign_keys=[uploaded_by])
uploader = relationship("User", foreign_keys=[uploaded_by])
+1 -1
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from sqlalchemy import (
Column, Date, DateTime, Float, ForeignKey,
Boolean, Column, Date, DateTime, Float, ForeignKey,
Index, Integer, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
+4 -22
View File
@@ -1,44 +1,26 @@
"""SQLAlchemy model for the intel_items table."""
# Import uuid
import uuid
# Import Boolean, Column, DateTime, ForeignKey, String, ... from sqlalchemy
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, func
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class IntelItem
class IntelItem(Base):
"""Intelligence item model for tracking threat intelligence related to techniques.
"""
Intelligence item model for tracking threat intelligence related to techniques.
Stores URLs and metadata from automated intel scans that may indicate
new attack variations or detection bypasses for specific techniques.
"""
# Assign __tablename__ = "intel_items"
__tablename__ = "intel_items"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id"), nullable=True)
technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id"), nullable=True)
# Assign url = Column(String, nullable=False)
url = Column(String, nullable=False)
# Assign title = Column(String, nullable=True)
title = Column(String, nullable=True)
# Assign source = Column(String, nullable=True)
source = Column(String, nullable=True)
# Assign detected_at = Column(DateTime(timezone=True), server_default=func.now())
detected_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign reviewed = Column(Boolean, default=False)
reviewed = Column(Boolean, default=False)
# Relationships
+2 -48
View File
@@ -1,99 +1,53 @@
"""Jira integration models — link Aegis entities to Jira issues."""
# Import enum
import enum
# Import uuid
import uuid
# Import Column, DateTime, ForeignKey, Index, String, func from sqlalchemy
from sqlalchemy import Column, DateTime, ForeignKey, Index, String, func
# Import Enum as SQLEnum from sqlalchemy
from sqlalchemy import Enum as SQLEnum
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy import Column, String, DateTime, ForeignKey, Enum as SQLEnum, Index, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class JiraLinkEntityType
class JiraLinkEntityType(str, enum.Enum):
"""Aegis entity types that can be linked to a Jira issue."""
# Assign test = "test"
test = "test"
# Assign technique = "technique"
technique = "technique"
# Assign campaign = "campaign"
campaign = "campaign"
# Assign evidence = "evidence"
evidence = "evidence"
# Define class JiraSyncDirection
class JiraSyncDirection(str, enum.Enum):
"""Direction of synchronisation between Aegis and Jira."""
# Assign aegis_to_jira = "aegis_to_jira"
aegis_to_jira = "aegis_to_jira"
# Assign jira_to_aegis = "jira_to_aegis"
jira_to_aegis = "jira_to_aegis"
# Assign bidirectional = "bidirectional"
bidirectional = "bidirectional"
# Define class JiraLink
class JiraLink(Base):
"""Associates an Aegis entity with a Jira issue for bidirectional sync."""
# Assign __tablename__ = "jira_links"
__tablename__ = "jira_links"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign entity_type = Column(SQLEnum(JiraLinkEntityType), nullable=False)
entity_type = Column(SQLEnum(JiraLinkEntityType), nullable=False)
# Assign entity_id = Column(UUID(as_uuid=True), nullable=False)
entity_id = Column(UUID(as_uuid=True), nullable=False)
# Assign jira_issue_key = Column(String(50), nullable=False)
jira_issue_key = Column(String(50), nullable=False)
# Assign jira_issue_id = Column(String(50))
jira_issue_id = Column(String(50))
# Assign jira_project_key = Column(String(20))
jira_project_key = Column(String(20))
# Assign jira_status = Column(String(100))
jira_status = Column(String(100))
# Assign jira_priority = Column(String(50))
jira_priority = Column(String(50))
# Assign jira_assignee = Column(String(255))
jira_assignee = Column(String(255))
# Assign jira_story_points = Column(String(10))
jira_story_points = Column(String(10))
# Assign sync_direction = Column(
sync_direction = Column(
SQLEnum(JiraSyncDirection), default=JiraSyncDirection.bidirectional
)
# Assign last_synced_at = Column(DateTime)
last_synced_at = Column(DateTime)
# Assign sync_metadata = Column(JSONB, default={})
sync_metadata = Column(JSONB, default={})
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"))
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate...
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Assign creator = relationship("User", foreign_keys=[created_by])
creator = relationship("User", foreign_keys=[created_by])
# Assign __table_args__ = (
__table_args__ = (
Index("ix_jira_links_entity_id", "entity_id"),
Index("ix_jira_links_issue_key", "jira_issue_key"),
+3 -22
View File
@@ -1,54 +1,35 @@
"""Notification model — in-app notifications for user actions."""
# Import uuid
import uuid
# Import Boolean, Column, DateTime, ForeignKey, Index, S... from sqlalchemy
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Text, func
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Index, func
from sqlalchemy.dialects.postgresql import UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class Notification
class Notification(Base):
"""In-app notification for alerting users when they need to act.
"""
In-app notification for alerting users when they need to act.
Types include: test_assigned, validation_needed, test_rejected,
test_validated, test_state_changed, etc.
"""
# Assign __tablename__ = "notifications"
__tablename__ = "notifications"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# Assign type = Column(String, nullable=False)
type = Column(String, nullable=False)
# Assign title = Column(String, nullable=False)
title = Column(String, nullable=False)
# Assign message = Column(Text, nullable=True)
message = Column(Text, nullable=True)
# Assign entity_type = Column(String, nullable=True)
entity_type = Column(String, nullable=True)
# Assign entity_id = Column(UUID(as_uuid=True), nullable=True)
entity_id = Column(UUID(as_uuid=True), nullable=True)
# Assign read = Column(Boolean, default=False)
read = Column(Boolean, default=False)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
user = relationship("User")
# Assign __table_args__ = (
__table_args__ = (
Index("ix_notifications_user_id", "user_id"),
Index("ix_notifications_read", "read"),
+4 -25
View File
@@ -1,58 +1,37 @@
"""OSINT enrichment items — CVEs, blogs, PoCs, and advisories linked to techniques."""
# Import uuid
import uuid
# Import Boolean, Column, DateTime, ForeignKey, String, ... from sqlalchemy
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, func
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class OsintItem
class OsintItem(Base):
"""Represents an OSINT data point (CVE, blog, PoC, advisory) associated with a MITRE ATT&CK technique.
"""Represents an OSINT data point (CVE, blog, PoC, advisory) associated
with a MITRE ATT&CK technique.
Used by the enrichment pipeline to surface relevant threat intelligence
for each technique, flagging those that need review.
"""
# Assign __tablename__ = "osint_items"
__tablename__ = "osint_items"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign technique_id = Column(
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id"),
# Keyword argument: nullable
nullable=False,
# Keyword argument: index
index=True,
)
# Assign source_type = Column(String(50), nullable=False) # "cve", "blog", "poc", "advisory"
source_type = Column(String(50), nullable=False) # "cve", "blog", "poc", "advisory"
# Assign source_url = Column(Text, nullable=False)
source_url = Column(Text, nullable=False)
# Assign title = Column(String(500), nullable=False)
title = Column(String(500), nullable=False)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign severity = Column(String(20), nullable=True) # CRITICAL, HIGH, MEDIUM, LOW, U...
severity = Column(String(20), nullable=True) # CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN
# Assign discovered_at = Column(DateTime(timezone=True), server_default=func.now(), nullable...
discovered_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Assign reviewed = Column(Boolean, default=False)
reviewed = Column(Boolean, default=False)
# Assign metadata_ = Column("metadata", JSONB, default={})
metadata_ = Column("metadata", JSONB, default={})
# ── Relationships ─────────────────────────────────────────────────
+1 -19
View File
@@ -1,43 +1,25 @@
"""ScoringConfig — single-row table for persisted scoring weights."""
# Import uuid
import uuid
# Import Column, DateTime, Float, ForeignKey, func from sqlalchemy
from sqlalchemy import Column, DateTime, Float, ForeignKey, func
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy import Column, Float, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
# Import Base from app.database
from app.database import Base
# Define class ScoringConfig
class ScoringConfig(Base):
"""Single-row table persisting the active scoring weight configuration."""
# Assign __tablename__ = "scoring_config"
__tablename__ = "scoring_config"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign weight_tests = Column(Float, nullable=False, default=40.0)
weight_tests = Column(Float, nullable=False, default=40.0)
# Assign weight_detection_rules = Column(Float, nullable=False, default=25.0)
weight_detection_rules = Column(Float, nullable=False, default=25.0)
# Assign weight_d3fend = Column(Float, nullable=False, default=15.0)
weight_d3fend = Column(Float, nullable=False, default=15.0)
# Assign weight_recency = Column(Float, nullable=False, default=10.0)
weight_recency = Column(Float, nullable=False, default=10.0)
# Assign weight_severity = Column(Float, nullable=False, default=10.0)
weight_severity = Column(Float, nullable=False, default=10.0)
# Assign updated_by = Column(
updated_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
# Assign updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate...
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+1 -1
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import JSONB, UUID
from app.database import Base
+6 -31
View File
@@ -1,63 +1,38 @@
"""SQLAlchemy model for the techniques table."""
# Import uuid
import uuid
from datetime import datetime
# Import Boolean, Column, DateTime, Enum, String, Text from sqlalchemy
from sqlalchemy import Boolean, Column, DateTime, Enum, String, Text
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy import Column, String, Text, Boolean, DateTime, Enum
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Import TechniqueStatus from app.models.enums
from app.models.enums import TechniqueStatus
# Define class Technique
class Technique(Base):
"""MITRE ATT&CK Technique model.
"""
MITRE ATT&CK Technique model.
Represents an attack technique from the MITRE ATT&CK framework,
including its coverage status and associated tests.
"""
# Assign __tablename__ = "techniques"
__tablename__ = "techniques"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign mitre_id = Column(String, unique=True, nullable=False) # e.g., "T1059.001"
mitre_id = Column(String, unique=True, nullable=False) # e.g., "T1059.001"
# Assign name = Column(String, nullable=False)
name = Column(String, nullable=False)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign tactic = Column(String, nullable=True)
tactic = Column(String, nullable=True)
# Assign platforms = Column(JSONB, nullable=True, default=[])
platforms = Column(JSONB, nullable=True, default=[])
# Assign mitre_version = Column(String, nullable=True)
mitre_version = Column(String, nullable=True)
# Assign mitre_last_modified = Column(DateTime, nullable=True)
mitre_last_modified = Column(DateTime, nullable=True)
# Assign is_subtechnique = Column(Boolean, default=False)
is_subtechnique = Column(Boolean, default=False)
# Assign parent_mitre_id = Column(String, nullable=True)
parent_mitre_id = Column(String, nullable=True)
# Assign status_global = Column(
status_global = Column(
Enum(TechniqueStatus, name="techniquestatus"),
# Keyword argument: default
default=TechniqueStatus.not_evaluated
)
# Assign review_required = Column(Boolean, default=False)
review_required = Column(Boolean, default=False)
# Assign last_review_date = Column(DateTime, nullable=True)
last_review_date = Column(DateTime, nullable=True)
# Relationships
+4 -64
View File
@@ -1,140 +1,80 @@
"""SQLAlchemy model for the tests table."""
# Import uuid
import uuid
# Import from sqlalchemy
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy import Column, String, Text, Boolean, Integer, DateTime, ForeignKey, Enum, Index, func
from sqlalchemy.dialects.postgresql import UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Import TestResult, TestState from app.models.enums
from app.models.enums import TestResult, TestState
from app.models.enums import TestState, TestResult
# Define class Test
class Test(Base):
"""Test model representing a security test for a MITRE ATT&CK technique.
"""
Test model representing a security test for a MITRE ATT&CK technique.
Each test documents an attempt to validate coverage of a specific technique,
including the procedure, tools used, and outcome. V2 introduces dual
validation: Red Lead and Blue Lead must each approve independently.
"""
# Assign __tablename__ = "tests"
__tablename__ = "tests"
# ── Core fields ─────────────────────────────────────────────────
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id"), nullable=Fa...
technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id"), nullable=False)
# Assign name = Column(String, nullable=False)
name = Column(String, nullable=False)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign platform = Column(String, nullable=True)
platform = Column(String, nullable=True)
# Assign procedure_text = Column(Text, nullable=True)
procedure_text = Column(Text, nullable=True)
# Assign tool_used = Column(String, nullable=True)
tool_used = Column(String, nullable=True)
# Assign execution_date = Column(DateTime, nullable=True)
execution_date = Column(DateTime, nullable=True)
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Assign result = Column(Enum(TestResult, name="testresult"), nullable=True)
result = Column(Enum(TestResult, name="testresult"), nullable=True)
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# ── Red Team fields ─────────────────────────────────────────────
red_summary = Column(Text, nullable=True)
# Assign attack_success = Column(Boolean, nullable=True)
attack_success = Column(Boolean, nullable=True)
# Assign red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Assign red_validated_at = Column(DateTime, nullable=True)
red_validated_at = Column(DateTime, nullable=True)
# Assign red_validation_status = Column(String, nullable=True) # pending / approved / rejected
red_validation_status = Column(String, nullable=True) # pending / approved / rejected
# Assign red_validation_notes = Column(Text, nullable=True)
red_validation_notes = Column(Text, nullable=True)
# ── Blue Team fields ────────────────────────────────────────────
blue_summary = Column(Text, nullable=True)
# Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
# Assign blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Assign blue_validated_at = Column(DateTime, nullable=True)
blue_validated_at = Column(DateTime, nullable=True)
# Assign blue_validation_status = Column(String, nullable=True) # pending / approved / rejected
blue_validation_status = Column(String, nullable=True) # pending / approved / rejected
# Assign blue_validation_notes = Column(Text, nullable=True)
blue_validation_notes = Column(Text, nullable=True)
# ── Phase timing fields (for automatic Tempo worklogs) ──────────
red_started_at = Column(DateTime, nullable=True)
# Assign blue_started_at = Column(DateTime, nullable=True)
blue_started_at = Column(DateTime, nullable=True)
blue_work_started_at = Column(DateTime, nullable=True) # when blue tech picks up (Tempo start)
paused_at = Column(DateTime, nullable=True)
# Assign red_paused_seconds = Column(Integer, default=0)
red_paused_seconds = Column(Integer, default=0)
# Assign blue_paused_seconds = Column(Integer, default=0)
blue_paused_seconds = Column(Integer, default=0)
# ── Remediation fields ───────────────────────────────────────────
remediation_steps = Column(Text, nullable=True)
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
remediation_status = Column(String, nullable=True) # pending / in_progress / completed / not_applicable
# Assign remediation_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
remediation_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# ── Re-test fields ────────────────────────────────────────────
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
# Assign retest_count = Column(Integer, default=0)
retest_count = Column(Integer, default=0)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal")
# ── Relationships ───────────────────────────────────────────────
technique = relationship("Technique", back_populates="tests")
# Assign evidences = relationship("Evidence", back_populates="test")
evidences = relationship("Evidence", back_populates="test")
# Assign creator = relationship("User", foreign_keys=[created_by])
creator = relationship("User", foreign_keys=[created_by])
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
red_validator = relationship("User", foreign_keys=[red_validated_by])
# Assign blue_validator = relationship("User", foreign_keys=[blue_validated_by])
blue_validator = relationship("User", foreign_keys=[blue_validated_by])
# Assign remediation_user = relationship("User", foreign_keys=[remediation_assignee])
remediation_user = relationship("User", foreign_keys=[remediation_assignee])
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
retests = relationship("Test", foreign_keys=[retest_of], back_populates="original_test")
# Assign __table_args__ = (
__table_args__ = (
Index("ix_tests_technique_id", "technique_id"),
Index("ix_tests_state", "state"),
+4 -32
View File
@@ -4,79 +4,51 @@ When the Blue Team evaluates a test, they mark each associated detection
rule as triggered / not triggered / not applicable, along with notes.
"""
# Import uuid
import uuid
from datetime import datetime
# Import from sqlalchemy
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Text,
UniqueConstraint,
)
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class TestDetectionResult
class TestDetectionResult(Base):
"""Per-test, per-rule evaluation result.
"""
Per-test, per-rule evaluation result.
- ``triggered`` = True: rule detected the attack
- ``triggered`` = False: rule did NOT detect the attack
- ``triggered`` = None: not yet evaluated
"""
# Assign __tablename__ = "test_detection_results"
__tablename__ = "test_detection_results"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign test_id = Column(
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign detection_rule_id = Column(
detection_rule_id = Column(
UUID(as_uuid=True),
ForeignKey("detection_rules.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign triggered = Column(Boolean, nullable=True) # None = not evaluated
triggered = Column(Boolean, nullable=True) # None = not evaluated
# Assign notes = Column(Text, nullable=True)
notes = Column(Text, nullable=True)
# Assign evaluated_by = Column(
evaluated_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
# Assign evaluated_at = Column(DateTime, nullable=True)
evaluated_at = Column(DateTime, nullable=True)
# Relationships
test = relationship("Test")
# Assign detection_rule = relationship("DetectionRule")
detection_rule = relationship("DetectionRule")
# Assign evaluator = relationship("User", foreign_keys=[evaluated_by])
evaluator = relationship("User", foreign_keys=[evaluated_by])
# Assign __table_args__ = (
__table_args__ = (
Index('ix_tdr_test', 'test_id'),
Index('ix_tdr_rule', 'detection_rule_id'),
+3 -26
View File
@@ -1,21 +1,15 @@
"""TestTemplate model — predefined test catalog entries."""
# Import uuid
import uuid
# Import Boolean, Column, DateTime, Index, String, Text,... from sqlalchemy
from sqlalchemy import Boolean, Column, DateTime, Index, String, Text, func
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy import Column, String, Text, Boolean, DateTime, Index, func
from sqlalchemy.dialects.postgresql import UUID
# Import Base from app.database
from app.database import Base
# Define class TestTemplate
class TestTemplate(Base):
"""Predefined test template mapped to a MITRE ATT&CK technique.
"""
Predefined test template mapped to a MITRE ATT&CK technique.
Templates come from several sources:
- **atomic_red_team**: Atomic Red Team by Red Canary
@@ -24,41 +18,24 @@ class TestTemplate(Base):
Users can instantiate a real Test from a template.
"""
# Assign __tablename__ = "test_templates"
__tablename__ = "test_templates"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign mitre_technique_id = Column(String, nullable=False) # e.g. "T1059.001"
mitre_technique_id = Column(String, nullable=False) # e.g. "T1059.001"
# Assign name = Column(String, nullable=False)
name = Column(String, nullable=False)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign source = Column(String, nullable=False) # atomic_red_te...
source = Column(String, nullable=False) # atomic_red_team / mitre / custom
# Assign source_url = Column(String, nullable=True)
source_url = Column(String, nullable=True)
# Assign attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
# Assign expected_detection = Column(Text, nullable=True) # What blue team should detect
expected_detection = Column(Text, nullable=True) # What blue team should detect
# Assign platform = Column(String, nullable=True) # windows / linux...
platform = Column(String, nullable=True) # windows / linux / macos
# Assign tool_suggested = Column(String, nullable=True)
tool_suggested = Column(String, nullable=True)
# Assign severity = Column(String, nullable=True) # low / medium / ...
severity = Column(String, nullable=True) # low / medium / high / critical
# Assign atomic_test_id = Column(String, nullable=True) # ID in Atomic Red Team...
atomic_test_id = Column(String, nullable=True) # ID in Atomic Red Team repo
# Assign suggested_remediation = Column(Text, nullable=True)
suggested_remediation = Column(Text, nullable=True)
# Assign is_active = Column(Boolean, default=True)
is_active = Column(Boolean, default=True)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign __table_args__ = (
__table_args__ = (
Index('ix_test_templates_mitre_technique_id', 'mitre_technique_id'),
Index('ix_test_templates_source', 'source'),
@@ -4,64 +4,47 @@ Enables the Blue Team to see which detection rules should fire
for a given test template / attack procedure.
"""
# Import uuid
import uuid
from datetime import datetime
# Import Boolean, Column, ForeignKey, Index, UniqueConst... from sqlalchemy
from sqlalchemy import Boolean, Column, ForeignKey, Index, UniqueConstraint
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy import Column, Boolean, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class TestTemplateDetectionRule
class TestTemplateDetectionRule(Base):
"""Association between a test template and a detection rule.
"""
Association between a test template and a detection rule.
Auto-generated by matching mitre_technique_id, or manually curated.
``is_primary`` marks rules with severity >= high as primary detections.
"""
# Assign __tablename__ = "test_template_detection_rules"
__tablename__ = "test_template_detection_rules"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign test_template_id = Column(
test_template_id = Column(
UUID(as_uuid=True),
ForeignKey("test_templates.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=True,
)
# Assign detection_rule_id = Column(
detection_rule_id = Column(
UUID(as_uuid=True),
ForeignKey("detection_rules.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign is_primary = Column(Boolean, default=False)
is_primary = Column(Boolean, default=False)
# Relationships
test_template = relationship("TestTemplate")
# Assign detection_rule = relationship("DetectionRule")
detection_rule = relationship("DetectionRule")
# Assign __table_args__ = (
__table_args__ = (
Index('ix_ttdr_template', 'test_template_id'),
Index('ix_ttdr_rule', 'detection_rule_id'),
UniqueConstraint(
# Literal argument value
'test_template_id', 'detection_rule_id',
# Keyword argument: name
name='uq_template_detection_rule',
),
)
+7 -55
View File
@@ -4,135 +4,87 @@ Stores profiles of APT groups and their associated MITRE ATT&CK
techniques, imported from MITRE CTI (STIX 2.0).
"""
# Import uuid
import uuid
# Import from sqlalchemy
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
func,
Column, String, Text, Boolean, DateTime,
ForeignKey, Index, UniqueConstraint, func,
)
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class ThreatActor
class ThreatActor(Base):
"""Threat actor / APT group profile.
"""
Threat actor / APT group profile.
Imported from MITRE CTI ``intrusion-set`` STIX objects.
"""
# Assign __tablename__ = "threat_actors"
__tablename__ = "threat_actors"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign mitre_id = Column(String, unique=True, nullable=True) # e.g. "G00...
mitre_id = Column(String, unique=True, nullable=True) # e.g. "G0016" (APT29)
# Assign name = Column(String, nullable=False)
name = Column(String, nullable=False)
# Assign aliases = Column(JSONB, nullable=True, default=[]) # ["Cozy ...
aliases = Column(JSONB, nullable=True, default=[]) # ["Cozy Bear", "The Dukes", ...]
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign country = Column(String, nullable=True)
country = Column(String, nullable=True)
# Assign target_sectors = Column(JSONB, nullable=True, default=[]) # ["government",...
target_sectors = Column(JSONB, nullable=True, default=[]) # ["government", "defense", ...]
# Assign target_regions = Column(JSONB, nullable=True, default=[]) # ["north-americ...
target_regions = Column(JSONB, nullable=True, default=[]) # ["north-america", "europe", ...]
# Assign motivation = Column(String, nullable=True) # espionage ...
motivation = Column(String, nullable=True) # espionage / financial / destruction / ...
# Assign sophistication = Column(String, nullable=True) # low / medium /...
sophistication = Column(String, nullable=True) # low / medium / high / advanced
# Assign first_seen = Column(String, nullable=True)
first_seen = Column(String, nullable=True)
# Assign last_seen = Column(String, nullable=True)
last_seen = Column(String, nullable=True)
# Assign references = Column(JSONB, nullable=True, default=[]) # [{"url": "...
references = Column(JSONB, nullable=True, default=[]) # [{"url": "...", "description": "..."}]
# Assign mitre_url = Column(String, nullable=True)
mitre_url = Column(String, nullable=True)
# Assign is_active = Column(Boolean, default=True)
is_active = Column(Boolean, default=True)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
techniques = relationship(
# Literal argument value
"ThreatActorTechnique",
# Keyword argument: back_populates
back_populates="threat_actor",
# Keyword argument: cascade
cascade="all, delete-orphan",
)
# Assign __table_args__ = (
__table_args__ = (
Index('ix_threat_actors_country', 'country'),
Index('ix_threat_actors_motivation', 'motivation'),
)
# Define class ThreatActorTechnique
class ThreatActorTechnique(Base):
"""Association between a threat actor and a MITRE ATT&CK technique.
"""
Association between a threat actor and a MITRE ATT&CK technique.
Stores additional context about how the actor uses the technique
(from the STIX ``relationship`` ``uses`` objects).
"""
# Assign __tablename__ = "threat_actor_techniques"
__tablename__ = "threat_actor_techniques"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign threat_actor_id = Column(
threat_actor_id = Column(
UUID(as_uuid=True),
ForeignKey("threat_actors.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign technique_id = Column(
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign usage_description = Column(Text, nullable=True)
usage_description = Column(Text, nullable=True)
# Assign first_seen_using = Column(String, nullable=True)
first_seen_using = Column(String, nullable=True)
# Relationships
threat_actor = relationship("ThreatActor", back_populates="techniques")
# Assign technique = relationship("Technique")
technique = relationship("Technique")
# Assign __table_args__ = (
__table_args__ = (
Index('ix_threat_actor_techniques_actor', 'threat_actor_id'),
Index('ix_threat_actor_techniques_technique', 'technique_id'),
UniqueConstraint(
# Literal argument value
'threat_actor_id', 'technique_id',
# Keyword argument: name
name='uq_actor_technique',
),
)
+3 -17
View File
@@ -1,18 +1,14 @@
"""SQLAlchemy model for the users table."""
# Import uuid
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
# Import Base from app.database
from app.database import Base
# Define class User
class User(Base):
"""User model for authentication and authorization.
"""
User model for authentication and authorization.
Possible roles:
- admin: Full system access
- red_tech: Red team technician - can create and edit tests
@@ -21,26 +17,16 @@ class User(Base):
- blue_lead: Blue team lead - can validate tests
- viewer: Read-only access (default)
"""
# Assign __tablename__ = "users"
__tablename__ = "users"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign username = Column(String, unique=True, nullable=False)
username = Column(String, unique=True, nullable=False)
# Assign email = Column(String, nullable=True)
email = Column(String, nullable=True)
# Assign hashed_password = Column(String, nullable=False)
hashed_password = Column(String, nullable=False)
# Assign role = Column(String, nullable=False, default="viewer")
role = Column(String, nullable=False, default="viewer")
# Assign is_active = Column(Boolean, default=True)
is_active = Column(Boolean, default=True)
# Assign must_change_password = Column(Boolean, default=True)
must_change_password = Column(Boolean, default=True)
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign last_login = Column(DateTime, nullable=True)
last_login = Column(DateTime, nullable=True)
notification_preferences = Column(JSONB, nullable=True, server_default='{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}')
jira_account_id = Column(String(100), nullable=True)
+1
View File
@@ -1,5 +1,6 @@
"""WebhookConfig model — outbound HTTP notification endpoints."""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
+2 -28
View File
@@ -1,22 +1,13 @@
"""Worklog model — immutable internal time-tracking records."""
# Import uuid
import uuid
# Import Column, DateTime, ForeignKey, Index, Integer, S... from sqlalchemy
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text, func
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Text, Index, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class Worklog
class Worklog(Base):
"""Internal worklog entry with integrity hash for audit compliance.
@@ -25,42 +16,25 @@ class Worklog(Base):
the immutable fields so tampering can be detected.
"""
# Assign __tablename__ = "worklogs"
__tablename__ = "worklogs"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign entity_type = Column(String(50), nullable=False)
entity_type = Column(String(50), nullable=False)
# Assign entity_id = Column(UUID(as_uuid=True), nullable=False)
entity_id = Column(UUID(as_uuid=True), nullable=False)
# Assign user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# Assign activity_type = Column(String(100), nullable=False)
activity_type = Column(String(100), nullable=False)
# Assign started_at = Column(DateTime, nullable=False)
started_at = Column(DateTime, nullable=False)
# Assign ended_at = Column(DateTime)
ended_at = Column(DateTime)
# Assign duration_seconds = Column(Integer, nullable=False)
duration_seconds = Column(Integer, nullable=False)
# Assign description = Column(Text)
description = Column(Text)
# Assign tempo_synced = Column(DateTime)
tempo_synced = Column(DateTime)
# Assign tempo_worklog_id = Column(String(100))
tempo_worklog_id = Column(String(100))
# Assign integrity_hash = Column(String(64))
integrity_hash = Column(String(64))
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign extra_metadata = Column("metadata", JSONB, default={})
extra_metadata = Column("metadata", JSONB, default={})
# Assign user = relationship("User", foreign_keys=[user_id])
user = relationship("User", foreign_keys=[user_id])
# Assign __table_args__ = (
__table_args__ = (
Index("ix_worklogs_entity_id", "entity_id"),
Index("ix_worklogs_user_id", "user_id"),
-1
View File
@@ -1 +0,0 @@
"""FastAPI router modules — one router per feature domain."""
+3 -2
View File
@@ -13,6 +13,7 @@ What is exported (and what is NOT):
✗ atomic/sigma/elastic templates, techniques, tests, campaigns, reports
"""
import uuid
from datetime import datetime
from typing import Any
@@ -22,7 +23,7 @@ from sqlalchemy.orm import Session
from app.auth import hash_password
from app.database import get_db
from app.dependencies.auth import require_role
from app.dependencies.auth import get_current_user, require_role
from app.models.scoring_config import ScoringConfig
from app.models.sso_config import SsoConfig
from app.models.system_config import SystemConfig
@@ -149,7 +150,7 @@ def export_config(
"email": u.email if hasattr(u, "email") else None,
"role": u.role,
"is_active": u.is_active,
"must_change_password": True, # force password reset on new instance # nosec B105
"must_change_password": True, # force password reset on new instance
}
for u in db.query(User).order_by(User.username).all()
]
+4 -35
View File
@@ -1,81 +1,50 @@
"""Advanced metrics endpoints — coverage by tactic, never-tested, avg validation time."""
# Import APIRouter, Depends from fastapi
from fastapi import APIRouter, Depends
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user from app.dependencies.auth
from app.dependencies.auth import get_current_user
# Import User from app.models.user
from app.models.user import User
# Import advanced_metrics_service from app.services
from app.services import advanced_metrics_service
# Assign router = APIRouter(prefix="/metrics/advanced", tags=["advanced-metrics"])
router = APIRouter(prefix="/metrics/advanced", tags=["advanced-metrics"])
# Apply the @router.get decorator
@router.get("/coverage-by-tactic")
# Define function coverage_by_tactic
def coverage_by_tactic(
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> list:
):
"""Coverage percentage broken down by MITRE ATT&CK tactic."""
# Return advanced_metrics_service.get_coverage_by_tactic(db)
return advanced_metrics_service.get_coverage_by_tactic(db)
# Apply the @router.get decorator
@router.get("/never-tested")
# Define function never_tested_techniques
def never_tested_techniques(
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> list:
):
"""Techniques that have never had a test created."""
# Return advanced_metrics_service.get_never_tested_techniques(db)
return advanced_metrics_service.get_never_tested_techniques(db)
# Apply the @router.get decorator
@router.get("/avg-validation-time")
# Define function avg_validation_time
def avg_validation_time(
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> dict:
):
"""Average time from test creation to validation, computed from audit logs.
Returns overall average and per-phase averages where data is available.
"""
# Return advanced_metrics_service.get_avg_validation_time(db)
return advanced_metrics_service.get_avg_validation_time(db)
# Apply the @router.get decorator
@router.get("/detection-rate-trend")
# Define function detection_rate_trend
def detection_rate_trend(
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> list:
):
"""Monthly detection rate trend for the last 12 months."""
# Return advanced_metrics_service.get_detection_rate_trend(db)
return advanced_metrics_service.get_detection_rate_trend(db)
+4 -37
View File
@@ -4,85 +4,52 @@ Returns complete datasets without pagination so BI tools can ingest
directly from URL. All endpoints require authentication.
"""
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_role
# Import User from app.models.user
from app.models.user import User
# Import analytics_service from app.services
from app.services import analytics_service
# Assign router = APIRouter(prefix="/analytics", tags=["analytics"])
router = APIRouter(prefix="/analytics", tags=["analytics"])
# Apply the @router.get decorator
@router.get("/coverage")
# Define function analytics_coverage
def analytics_coverage(
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> list:
):
"""Coverage per technique — flat format for BI dashboards."""
# Return analytics_service.get_coverage_analytics(db)
return analytics_service.get_coverage_analytics(db)
# Apply the @router.get decorator
@router.get("/tests")
# Define function analytics_tests
def analytics_tests(
# Entry: date_from
date_from: str = Query(None, description="ISO date filter (>=)"),
# Entry: date_to
date_to: str = Query(None, description="ISO date filter (<=)"),
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> list:
):
"""All tests with timestamps — flat format for BI dashboards."""
# Return analytics_service.get_tests_analytics(
return analytics_service.get_tests_analytics(
db, date_from=date_from, date_to=date_to
)
# Apply the @router.get decorator
@router.get("/trends")
# Define function analytics_trends
def analytics_trends(
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> list:
):
"""Historical coverage snapshots for trend visualization."""
# Return analytics_service.get_trends_analytics(db)
return analytics_service.get_trends_analytics(db)
# Apply the @router.get decorator
@router.get("/operators")
# Define function analytics_operators
def analytics_operators(
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(require_role("admin")),
) -> list:
):
"""Per-operator metrics — for workload management dashboards."""
# Return analytics_service.get_operators_analytics(db)
return analytics_service.get_operators_analytics(db)
+1 -1
View File
@@ -1,6 +1,6 @@
"""Phase 14: API Key management router."""
from typing import List
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
+2 -1
View File
@@ -3,7 +3,7 @@
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
@@ -14,6 +14,7 @@ from app.schemas.attack_path_schema import (
ExecutionCreate, ExecutionOut,
StepExecuteRequest, StepResultOut,
TimelineEntryCreate, TimelineEntryOut,
KillChainMetrics,
)
from app.services import attack_path_service as svc
+3 -53
View File
@@ -1,127 +1,77 @@
"""Audit log viewer router (admin only)."""
# Import datetime from datetime
from datetime import datetime
# Import Optional from typing
from typing import Optional
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import require_role from app.dependencies.auth
from app.dependencies.auth import require_role
# Import User from app.models.user
from app.models.user import User
# Import AuditLogOut, AuditLogPage from app.schemas.audit
from app.schemas.audit import AuditLogOut, AuditLogPage
# Import from app.services.audit_query_service
from app.services.audit_query_service import (
list_distinct_actions,
list_distinct_entity_types,
list_logs,
)
# Assign router = APIRouter(prefix="/audit-logs", tags=["audit"])
router = APIRouter(prefix="/audit-logs", tags=["audit"])
# Apply the @router.get decorator
@router.get("", response_model=AuditLogPage)
# Define function list_audit_logs
def list_audit_logs(
# Entry: user_id
user_id: Optional[str] = Query(None, description="Filter by user ID"),
# Entry: action
action: Optional[str] = Query(None, description="Filter by action type"),
# Entry: entity_type
entity_type: Optional[str] = Query(None, description="Filter by entity type"),
# Entry: start_date
start_date: Optional[datetime] = Query(None, description="Filter by start date"),
# Entry: end_date
end_date: Optional[datetime] = Query(None, description="Filter by end date"),
# Entry: offset
offset: int = Query(0, ge=0, description="Number of records to skip"),
# Entry: limit
limit: int = Query(50, ge=1, le=100, description="Max records to return"),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> AuditLogPage:
):
"""Return paginated audit logs with optional filters.
**Requires admin role.**
"""
# Assign result = list_logs(
result = list_logs(
db,
# Keyword argument: user_id
user_id=user_id,
# Keyword argument: action
action=action,
# Keyword argument: entity_type
entity_type=entity_type,
# Keyword argument: start_date
start_date=start_date,
# Keyword argument: end_date
end_date=end_date,
# Keyword argument: offset
offset=offset,
# Keyword argument: limit
limit=limit,
)
# Return AuditLogPage(
return AuditLogPage(
# Keyword argument: items
items=[AuditLogOut(**item) for item in result["items"]],
# Keyword argument: total
total=result["total"],
# Keyword argument: offset
offset=result["offset"],
# Keyword argument: limit
limit=result["limit"],
)
# Apply the @router.get decorator
@router.get("/actions", response_model=list[str])
# Define function list_actions
def list_actions(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> list[str]:
):
"""Return a list of distinct action types in the audit log.
**Requires admin role.**
"""
# Return list_distinct_actions(db)
return list_distinct_actions(db)
# Apply the @router.get decorator
@router.get("/entity-types", response_model=list[str])
# Define function list_entity_types
def list_entity_types(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> list[str]:
):
"""Return a list of distinct entity types in the audit log.
**Requires admin role.**
"""
# Return list_distinct_entity_types(db)
return list_distinct_entity_types(db)
+10 -132
View File
@@ -7,69 +7,31 @@ the token in the body for backwards compatibility and for clients that
cannot use cookies (e.g. Swagger UI).
"""
# Import os
import os
# Import APIRouter, Cookie, Depends, Request, Response from fastapi
from fastapi import APIRouter, Cookie, Depends, Request, Response
# Import OAuth2PasswordRequestForm from fastapi.security
from fastapi.security import OAuth2PasswordRequestForm
# Import jwt (PyJWT)
import jwt
from jwt.exceptions import PyJWTError as JWTError
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import blacklist_token, create_access_token, verify_pa... from app.auth
from app.auth import blacklist_token, create_access_token, verify_password
from jose import jwt, JWTError
# Import settings from app.config
from app.auth import create_access_token, blacklist_token, verify_password
from app.config import settings
# Import get_db from app.database
from app.database import get_db
# Import get_current_user from app.dependencies.auth
from app.dependencies.auth import get_current_user
# Import BusinessRuleViolation, PermissionViolation from app.domain.errors
from app.domain.errors import BusinessRuleViolation, PermissionViolation
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import limiter from app.limiter
from app.limiter import limiter
# Import resolve_client_ip from app.middleware.request_context
from app.middleware.request_context import resolve_client_ip
# Import User from app.models.user
from app.models.user import User
# Import TokenResponse, UserOut from app.schemas.auth
from app.schemas.auth import TokenResponse, UserOut
# Import PasswordChange from app.schemas.user
from app.schemas.user import PasswordChange
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
# Import from app.services.auth_service
from app.services.auth_service import (
_DUMMY_HASH,
)
# Import from app.services.auth_service
from app.services.auth_service import (
change_password as auth_change_password,
)
from app.services.audit_service import log_action
from app.schemas.auth import TokenResponse, UserOut
from app.schemas.user import PasswordChange
# Assign router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix="/auth", tags=["auth"])
# SECURE_COOKIES desacopla la seguridad de la cookie del entorno de ejecucion.
@@ -85,182 +47,111 @@ else: # "auto" — activo solo si AEGIS_ENV=production
_COOKIE_NAME = "aegis_token"
# Apply the @router.post decorator
@router.post("/login", response_model=TokenResponse)
# Apply the @limiter.limit decorator
@limiter.limit("5/minute")
# Define function login
def login(
# Entry: request
request: Request,
# Entry: response
response: Response,
# Entry: form_data
form_data: OAuth2PasswordRequestForm = Depends(),
# Entry: db
db: Session = Depends(get_db),
) -> TokenResponse:
):
"""Authenticate a user and return a JWT access token.
Rate-limited to **5 attempts per minute per IP**. Failed and successful
logins are recorded in the audit log (SEC-009).
"""
# Assign user = db.query(User).filter(User.username == form_data.username).first()
user = db.query(User).filter(User.username == form_data.username).first()
# Assign target_hash = user.hashed_password if user else _DUMMY_HASH
target_hash = user.hashed_password if user else _DUMMY_HASH
# Assign password_valid = verify_password(form_data.password, target_hash)
password_valid = verify_password(form_data.password, target_hash)
# Assign ip = resolve_client_ip(request)
ip = resolve_client_ip(request)
# Check: user is None or not password_valid
if user is None or not password_valid:
# Open context manager
with UnitOfWork(db) as uow:
# Call log_action()
log_action(
db,
user.id if user else None,
# Literal argument value
"LOGIN_FAILED",
# Literal argument value
"auth",
# Literal argument value
None,
# Keyword argument: details
details={
# Literal argument value
"username": form_data.username,
# Literal argument value
"ip": ip,
# Literal argument value
"reason": "invalid_credentials",
},
# Keyword argument: ip_address
ip_address=ip,
)
# Call uow.commit()
uow.commit()
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Incorrect username or password")
# Check: not user.is_active
if not user.is_active:
# Raise PermissionViolation
raise PermissionViolation("Account is disabled. Contact an administrator.")
# Assign access_token = create_access_token(data={"sub": user.username})
access_token = create_access_token(data={"sub": user.username})
# Open context manager
with UnitOfWork(db) as uow:
# Call log_action()
log_action(
db,
user.id,
# Literal argument value
"LOGIN_SUCCESS",
# Literal argument value
"auth",
str(user.id),
# Keyword argument: details
details={"username": user.username, "ip": ip},
# Keyword argument: ip_address
ip_address=ip,
)
# Call uow.commit()
uow.commit()
# Call response.set_cookie()
response.set_cookie(
# Keyword argument: key
key=_COOKIE_NAME,
# Keyword argument: value
value=access_token,
# Keyword argument: httponly
httponly=True,
# Keyword argument: secure
secure=_IS_HTTPS,
# Keyword argument: samesite
samesite="strict",
# Keyword argument: max_age
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
# Keyword argument: path
path="/",
)
# Return TokenResponse(access_token=access_token)
return TokenResponse(access_token=access_token)
# Apply the @router.post decorator
@router.post("/logout")
# Define function logout
def logout(
# Entry: request
request: Request,
# Entry: response
response: Response,
# Entry: aegis_token
aegis_token: str | None = Cookie(None),
) -> dict:
):
"""Clear the authentication cookie and revoke the current token."""
# Assign bearer = (
bearer = (
request.headers.get("Authorization")
or request.headers.get("authorization")
or ""
)
# Assign bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip()
bearer = bearer.removeprefix("Bearer ").removeprefix("bearer ").strip()
# Assign seen = set()
seen: set[str] = set()
# Iterate over (aegis_token, bearer)
for raw in (aegis_token, bearer):
# Check: not raw or raw in seen
if not raw or raw in seen:
# Skip to the next loop iteration
continue
# Call seen.add()
seen.add(raw)
# Attempt the following; catch errors below
try:
# Assign payload = jwt.decode(
payload = jwt.decode(
raw,
settings.SECRET_KEY,
# Keyword argument: algorithms
algorithms=[settings.ALGORITHM],
)
# Assign jti = payload.get("jti")
jti = payload.get("jti")
# Assign exp = payload.get("exp", 0)
exp = payload.get("exp", 0)
# Check: jti
if jti:
# Call blacklist_token()
blacklist_token(jti, float(exp))
# Handle any JWT validation error during logout (token may be expired or malformed)
except jwt.exceptions.InvalidTokenError:
# Intentional no-op placeholder
except JWTError:
pass
# Call response.delete_cookie()
response.delete_cookie(
# Keyword argument: key
key=_COOKIE_NAME,
# Keyword argument: httponly
httponly=True,
# Keyword argument: secure
secure=_IS_HTTPS,
# Keyword argument: samesite
samesite="strict",
# Keyword argument: path
path="/",
)
# Return {"detail": "Logged out"}
return {"detail": "Logged out"}
@@ -316,38 +207,25 @@ def refresh_token(
@router.get("/me", response_model=UserOut)
# Define function read_current_user
def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut:
def read_current_user(current_user: User = Depends(get_current_user)):
"""Return the profile of the currently authenticated user."""
# Return current_user
return current_user
# Apply the @router.post decorator
@router.post("/change-password")
# Define function change_password
def change_password(
# Entry: body
body: PasswordChange,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Change the current user's password."""
# Call auth_change_password()
auth_change_password(
db,
current_user,
# Keyword argument: current_password
current_password=body.current_password,
# Keyword argument: new_password
new_password=body.new_password,
)
# Open context manager
with UnitOfWork(db) as uow:
# Call uow.commit()
uow.commit()
# Return {"detail": "Password changed successfully"}
return {"detail": "Password changed successfully"}
+37 -410
View File
@@ -1,174 +1,80 @@
"""Campaign endpoints — CRUD, test management, activation, and auto-generation.
Provides comprehensive campaign lifecycle management including test ordering,
progress tracking, and threat actor integration.
Provides comprehensive campaign lifecycle management including
test ordering, progress tracking, and threat actor integration.
"""
# Import logging
import logging
# Import uuid
import uuid
from datetime import datetime
from typing import Optional
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import BaseModel, Field from pydantic
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_any_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import User from app.models.user
from app.models.user import User
from app.models.campaign import Campaign, CampaignTest
from app.models.test import Test
from app.services.campaign_service import generate_campaign_from_threat_actor
from app.services.campaign_crud_service import (
add_test_to_campaign as crud_add_test,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
activate_campaign as crud_activate,
complete_campaign as crud_complete,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
create_campaign as crud_create,
delete_campaign as crud_delete,
get_campaign_detail as crud_get_detail,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
get_campaign_history as crud_get_history,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
get_campaign_progress_data as crud_get_progress,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
list_campaigns as crud_list,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
remove_test_from_campaign as crud_remove_test,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
schedule_campaign as crud_schedule,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
serialize_campaign,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
update_campaign as crud_update,
)
# Import activate_campaign from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
activate_campaign as crud_activate,
)
# Import log_action from app.services.audit_service
from app.domain.unit_of_work import UnitOfWork
from app.services.audit_service import log_action
# Import notify_role from app.services.notification_service
from app.services.notification_service import notify_role
from app.services.webhook_service import dispatch_webhook
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Assign router = APIRouter(prefix="/campaigns", tags=["campaigns"])
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
# ── Pydantic schemas ─────────────────────────────────────────────────
class CampaignCreate(BaseModel):
"""Payload for creating a new campaign."""
# name: str
name: str
# Assign description = None
description: Optional[str] = None
# Assign type = "custom"
type: str = "custom"
# Assign threat_actor_id = None
threat_actor_id: Optional[str] = None
# Assign target_platform = None
target_platform: Optional[str] = None
# Assign tags = Field(default_factory=list)
tags: Optional[list[str]] = Field(default_factory=list)
# Assign scheduled_at = None
scheduled_at: Optional[str] = None
start_date: Optional[str] = None # ISO date — campaign won't activate before this
# Define class CampaignUpdate
class CampaignUpdate(BaseModel):
"""Payload for updating an existing campaign's metadata."""
# Assign name = None
name: Optional[str] = None
# Assign description = None
description: Optional[str] = None
# Assign type = None
type: Optional[str] = None
# Assign target_platform = None
target_platform: Optional[str] = None
# Assign tags = None
tags: Optional[list[str]] = None
# Assign scheduled_at = None
scheduled_at: Optional[str] = None
start_date: Optional[str] = None # ISO date — can be updated while still in draft
# Define class AddTestPayload
class AddTestPayload(BaseModel):
"""Payload for adding a test to a campaign."""
# test_id: str
test_id: str
# Assign order_index = None
order_index: Optional[int] = None
# Assign depends_on = None
depends_on: Optional[str] = None
# Assign phase = None
phase: Optional[str] = None
# Define class SchedulePayload
class SchedulePayload(BaseModel):
"""Payload for scheduling or rescheduling a campaign run."""
# is_recurring: bool
is_recurring: bool
# Assign recurrence_pattern = None # weekly, monthly, quarterly
recurrence_pattern: Optional[str] = None # weekly, monthly, quarterly
# Assign next_run_at = None
next_run_at: Optional[str] = None
@@ -177,54 +83,24 @@ class SchedulePayload(BaseModel):
# ---------------------------------------------------------------------------
@router.get("")
# Define function list_campaigns
def list_campaigns(
# Entry: type
type: Optional[str] = Query(None),
# Entry: status
status: Optional[str] = Query(None),
# Entry: threat_actor_id
threat_actor_id: Optional[str] = Query(None),
# Entry: search
search: Optional[str] = Query(None),
# Entry: offset
offset: int = Query(0, ge=0),
# Entry: limit
limit: int = Query(50, ge=1, le=200),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""List campaigns with optional filters and pagination.
Args:
type (Optional[str]): Filter by campaign type (e.g. ``custom``, ``threat_actor``).
status (Optional[str]): Filter by campaign status (e.g. ``draft``, ``active``).
threat_actor_id (Optional[str]): Filter campaigns linked to a specific threat actor.
search (Optional[str]): Free-text search against campaign name.
offset (int): Number of records to skip for pagination.
limit (int): Maximum number of records to return.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
list: Serialised list of campaign summary dicts.
"""
# Return crud_list(
):
"""List campaigns with optional filters and pagination."""
return crud_list(
db,
# Keyword argument: type
type=type,
# Keyword argument: status
status=status,
# Keyword argument: threat_actor_id
threat_actor_id=threat_actor_id,
# Keyword argument: search
search=search,
# Keyword argument: offset
offset=offset,
# Keyword argument: limit
limit=limit,
)
@@ -234,64 +110,36 @@ def list_campaigns(
# ---------------------------------------------------------------------------
@router.post("", status_code=201)
# Define function create_campaign
def create_campaign(
# Entry: payload
payload: CampaignCreate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Create a new campaign.
Args:
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead creating the campaign.
Returns:
dict: Serialised representation of the newly created campaign.
"""
# Open context manager
):
"""Create a new campaign."""
with UnitOfWork(db) as uow:
# Assign result = crud_create(
result = crud_create(
db,
# Keyword argument: creator_id
creator_id=current_user.id,
# Keyword argument: name
name=payload.name,
# Keyword argument: description
description=payload.description,
# Keyword argument: type
type=payload.type,
# Keyword argument: threat_actor_id
threat_actor_id=payload.threat_actor_id,
# Keyword argument: target_platform
target_platform=payload.target_platform,
# Keyword argument: tags
tags=payload.tags,
# Keyword argument: scheduled_at
scheduled_at=payload.scheduled_at,
start_date=payload.start_date,
)
campaign_id = result["id"]
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="create_campaign",
# Keyword argument: entity_type
entity_type="campaign",
entity_id=campaign_id,
details={"name": payload.name, "type": payload.type},
)
# Call uow.commit()
uow.commit()
# Return result
return result
@@ -300,26 +148,12 @@ def create_campaign(
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}")
# Define function get_campaign
def get_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""Get detailed campaign info including tests and progress.
Args:
campaign_id (str): UUID string of the campaign to retrieve.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
dict: Campaign detail including associated tests and progress metrics.
"""
# Return crud_get_detail(db, campaign_id)
):
"""Get detailed campaign info including tests and progress."""
return crud_get_detail(db, campaign_id)
@@ -328,60 +162,32 @@ def get_campaign(
# ---------------------------------------------------------------------------
@router.patch("/{campaign_id}")
# Define function update_campaign
def update_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: payload
payload: CampaignUpdate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Update a campaign. Only allowed in draft or active state.
Args:
campaign_id (str): UUID string of the campaign to update.
payload (CampaignUpdate): Partial update payload; only set fields are applied.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead performing the update.
Returns:
dict: Serialised representation of the updated campaign.
"""
# Assign update_data = payload.model_dump(exclude_unset=True)
):
"""Update a campaign. Only allowed in draft or active state."""
update_data = payload.model_dump(exclude_unset=True)
# Open context manager
with UnitOfWork(db) as uow:
# Assign result = crud_update(
result = crud_update(
db,
campaign_id,
# Keyword argument: updater_id
updater_id=current_user.id,
# Keyword argument: updater_role
updater_role=current_user.role,
**update_data,
)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="update_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign_id,
# Keyword argument: details
details={"updated_fields": list(update_data.keys())},
)
# Call uow.commit()
uow.commit()
# Return result
return result
@@ -421,44 +227,22 @@ def delete_campaign(
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/tests")
# Define function add_test_to_campaign
def add_test_to_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: payload
payload: AddTestPayload,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Add a test to a campaign with optional ordering and dependency.
Args:
campaign_id (str): UUID string of the target campaign.
payload (AddTestPayload): Test ID plus optional order index, dependency, and phase.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead adding the test.
Returns:
dict: The created campaign-test association record.
"""
# Open context manager
):
"""Add a test to a campaign with optional ordering and dependency."""
with UnitOfWork(db) as uow:
# Assign result = crud_add_test(
result = crud_add_test(
db,
campaign_id,
# Keyword argument: test_id
test_id=payload.test_id,
# Keyword argument: order_index
order_index=payload.order_index,
# Keyword argument: depends_on
depends_on=payload.depends_on,
# Keyword argument: phase
phase=payload.phase,
)
# Call uow.commit()
uow.commit()
return result
@@ -469,35 +253,16 @@ def add_test_to_campaign(
# ---------------------------------------------------------------------------
@router.delete("/{campaign_id}/tests/{campaign_test_id}")
# Define function remove_test_from_campaign
def remove_test_from_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: campaign_test_id
campaign_test_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Remove a test from a campaign.
Args:
campaign_id (str): UUID string of the campaign.
campaign_test_id (str): UUID string of the campaign-test association to remove.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead removing the test.
Returns:
dict: Confirmation message with key ``detail``.
"""
# Open context manager
):
"""Remove a test from a campaign."""
with UnitOfWork(db) as uow:
# Call crud_remove_test()
crud_remove_test(db, campaign_id, campaign_test_id)
# Call uow.commit()
uow.commit()
# Return {"detail": "Test removed from campaign"}
return {"detail": "Test removed from campaign"}
@@ -506,76 +271,52 @@ def remove_test_from_campaign(
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/activate")
# Define function activate_campaign
def activate_campaign(
# Entry: campaign_id
campaign_id: str,
force: bool = Query(False, description="Activate even if start_date is in the future"),
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Activate a campaign, moving it from draft to active.
If the campaign has a start_date in the future and force=False, returns a 409
with a warning so the frontend can show a confirmation modal. If force=True,
activates immediately regardless of start_date.
If the campaign has a start_date in the future, manual activation is blocked —
the campaign will be auto-activated by the scheduler when the date arrives.
"""
from fastapi import HTTPException
# Guard: start_date must have been reached before manual activation
campaign_obj = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if campaign_obj and campaign_obj.start_date and not force:
if campaign_obj and campaign_obj.start_date:
now = datetime.utcnow()
if campaign_obj.start_date > now:
from fastapi import HTTPException
raise HTTPException(
status_code=409,
detail={
"code": "start_date_in_future",
"start_date": campaign_obj.start_date.strftime("%Y-%m-%d"),
"message": (
f"This campaign is scheduled to start on "
f"{campaign_obj.start_date.strftime('%d %b %Y')}. "
f"It will activate automatically on that date. "
f"Do you want to activate it now anyway?"
),
},
status_code=422,
detail=(
f"This campaign is scheduled to start on "
f"{campaign_obj.start_date.strftime('%Y-%m-%d')}. "
f"It will be activated automatically on that date. "
f"To activate it now, remove the start date first."
),
)
with UnitOfWork(db) as uow:
# Assign campaign = crud_activate(db, campaign_id)
campaign = crud_activate(db, campaign_id)
# Call notify_role()
notify_role(
db,
# Keyword argument: role
role="red_tech",
# Keyword argument: type
type="campaign_activated",
# Keyword argument: title
title="Campaign activated",
# Keyword argument: message
message=f'Campaign "{campaign.name}" has been activated.',
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="activate_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
# Keyword argument: details
details={"name": campaign.name},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(campaign)
# Create Jira tickets for campaign and tests at activation time (non-fatal).
@@ -613,50 +354,26 @@ def activate_campaign(
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/complete")
# Define function complete_campaign
def complete_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "admin")),
) -> dict:
"""Mark a campaign as completed.
Args:
campaign_id (str): UUID string of the campaign to complete.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or admin completing the campaign.
Returns:
dict: Serialised representation of the completed campaign.
"""
# Open context manager
):
"""Mark a campaign as completed."""
with UnitOfWork(db) as uow:
# Assign campaign = crud_complete(db, campaign_id)
campaign = crud_complete(db, campaign_id)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="complete_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
# Keyword argument: details
details={"name": campaign.name},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(campaign)
dispatch_webhook("campaign.completed", {"campaign_id": str(campaign.id), "name": campaign.name})
# Return serialize_campaign(db, campaign)
return serialize_campaign(db, campaign)
@@ -665,26 +382,12 @@ def complete_campaign(
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/progress")
# Define function get_campaign_progress_endpoint
def get_campaign_progress_endpoint(
# Entry: campaign_id
campaign_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""Get progress statistics for a campaign.
Args:
campaign_id (str): UUID string of the campaign.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
dict: Progress breakdown including counts by test state and overall percentage.
"""
# Return crud_get_progress(db, campaign_id)
):
"""Get progress statistics for a campaign."""
return crud_get_progress(db, campaign_id)
@@ -692,63 +395,34 @@ def get_campaign_progress_endpoint(
# POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign
# ---------------------------------------------------------------------------
class GenerateFromActorPayload(BaseModel):
start_date: Optional[str] = None # ISO date YYYY-MM-DD
@router.post("/from-threat-actor/{actor_id}", status_code=201)
# Define function generate_campaign_from_actor
def generate_campaign_from_actor(
# Entry: actor_id
actor_id: str,
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
):
"""Auto-generate a campaign from a threat actor's uncovered techniques.
Creates tests from the best available templates and orders them
by kill chain phase.
Args:
actor_id (str): UUID string of the threat actor to generate a campaign for.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead requesting the generation.
Returns:
dict: Serialised representation of the newly generated campaign.
"""
start_date_parsed = (
datetime.fromisoformat(payload.start_date) if payload.start_date else None
)
campaign = generate_campaign_from_threat_actor(
db,
uuid.UUID(actor_id),
current_user,
start_date=start_date_parsed,
)
# Open context manager
with UnitOfWork(db) as uow:
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="generate_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
# Keyword argument: details
details={"actor_id": actor_id, "campaign_name": campaign.name},
)
# Call uow.commit()
uow.commit()
# Return serialize_campaign(db, campaign)
return serialize_campaign(db, campaign)
@@ -757,74 +431,41 @@ def generate_campaign_from_actor(
# ---------------------------------------------------------------------------
@router.patch("/{campaign_id}/schedule")
# Define function schedule_campaign
def schedule_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: payload
payload: SchedulePayload,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
):
"""Configure or update the recurrence schedule for a campaign.
Only the campaign creator or admin can change scheduling.
Args:
campaign_id (str): UUID string of the campaign to schedule.
payload (SchedulePayload): Recurrence flag, pattern, and next run timestamp.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead (must be owner or admin).
Returns:
dict: Serialised representation of the campaign with updated schedule fields.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign campaign = crud_schedule(
campaign = crud_schedule(
db,
campaign_id,
# Keyword argument: owner_id
owner_id=current_user.id,
# Keyword argument: owner_role
owner_role=current_user.role,
# Keyword argument: is_recurring
is_recurring=payload.is_recurring,
# Keyword argument: recurrence_pattern
recurrence_pattern=payload.recurrence_pattern,
# Keyword argument: next_run_at
next_run_at=payload.next_run_at,
)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="schedule_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
# Keyword argument: details
details={
# Literal argument value
"is_recurring": campaign.is_recurring,
# Literal argument value
"recurrence_pattern": campaign.recurrence_pattern,
# Literal argument value
"next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(campaign)
# Return serialize_campaign(db, campaign)
return serialize_campaign(db, campaign)
@@ -833,26 +474,12 @@ def schedule_campaign(
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/history")
# Define function get_campaign_history
def get_campaign_history(
# Entry: campaign_id
campaign_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list:
"""List all child campaigns (execution history) of a recurring campaign.
Args:
campaign_id (str): UUID string of the parent recurring campaign.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
list: Serialised list of child campaign dicts ordered by creation date.
"""
# Return crud_get_history(db, campaign_id)
):
"""List all child campaigns (execution history) of a recurring campaign."""
return crud_get_history(db, campaign_id)
+23 -136
View File
@@ -1,46 +1,32 @@
"""Compliance endpoints — framework status, reports, and gap analysis.
Thin HTTP adapter that delegates all data logic to compliance_service.
Thin HTTP adapter: delegates all data logic to compliance_service.
Provides compliance posture assessment by mapping MITRE ATT&CK technique
coverage to compliance framework controls.
"""
# Import APIRouter, Depends from fastapi
from fastapi import APIRouter, Depends
# Import StreamingResponse from fastapi.responses
from fastapi.responses import StreamingResponse
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_role
# Import User from app.models.user
from app.models.user import User
# Import from app.services.compliance_import_service
from app.services.compliance_service import (
list_frameworks,
get_framework_status,
build_framework_report_csv,
get_framework_gaps,
)
from app.services.compliance_import_service import (
import_nist_800_53_mappings,
import_cis_controls_v8_mappings,
import_dora_mappings,
import_iso_27001_mappings,
import_iso_42001_mappings,
import_nist_800_53_mappings,
)
# Import from app.services.compliance_service
from app.services.compliance_service import (
build_framework_report_csv,
get_framework_gaps,
get_framework_status,
list_frameworks,
)
# Assign router = APIRouter(prefix="/compliance", tags=["compliance"])
router = APIRouter(prefix="/compliance", tags=["compliance"])
@@ -48,23 +34,11 @@ router = APIRouter(prefix="/compliance", tags=["compliance"])
@router.get("/frameworks")
# Define function list_frameworks_endpoint
def list_frameworks_endpoint(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list:
"""List all available compliance frameworks.
Args:
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
list: List of framework summary dicts containing id, name, and control counts.
"""
# Return list_frameworks(db)
):
"""List all available compliance frameworks."""
return list_frameworks(db)
@@ -72,26 +46,12 @@ def list_frameworks_endpoint(
@router.get("/frameworks/{framework_id}/status")
# Define function framework_status
def framework_status(
# Entry: framework_id
framework_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""Get compliance status for each control in a framework.
Args:
framework_id (str): Identifier of the compliance framework (e.g. ``nist-800-53``).
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
dict: Mapping of control IDs to their coverage status and linked techniques.
"""
# Return get_framework_status(db, framework_id)
):
"""Get compliance status for each control in a framework."""
return get_framework_status(db, framework_id)
@@ -99,26 +59,12 @@ def framework_status(
@router.get("/frameworks/{framework_id}/report")
# Define function framework_report
def framework_report(
# Entry: framework_id
framework_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""Get the full compliance report (same as status but marked as report).
Args:
framework_id (str): Identifier of the compliance framework.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
dict: Full compliance report with per-control coverage details.
"""
# Return get_framework_status(db, framework_id)
):
"""Get the full compliance report (same as status but marked as report)."""
return get_framework_status(db, framework_id)
@@ -126,35 +72,17 @@ def framework_report(
@router.get("/frameworks/{framework_id}/report/csv")
# Define function framework_report_csv
def framework_report_csv(
# Entry: framework_id
framework_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> StreamingResponse:
"""Export compliance report as CSV.
Args:
framework_id (str): Identifier of the compliance framework to export.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
StreamingResponse: CSV file attachment with compliance coverage data.
"""
# csv_bytes, filename = build_framework_report_csv(db, framework_id)
):
"""Export compliance report as CSV."""
csv_bytes, filename = build_framework_report_csv(db, framework_id)
# Return StreamingResponse(
return StreamingResponse(
iter([csv_bytes]),
# Keyword argument: media_type
media_type="text/csv",
# Keyword argument: headers
headers={
# Literal argument value
"Content-Disposition": f"attachment; filename={filename}",
},
)
@@ -164,26 +92,12 @@ def framework_report_csv(
@router.get("/frameworks/{framework_id}/gaps")
# Define function framework_gaps
def framework_gaps(
# Entry: framework_id
framework_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""Get controls with techniques that are not adequately covered.
Args:
framework_id (str): Identifier of the compliance framework to analyse.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
dict: Controls flagged as gaps, with linked technique IDs and coverage ratios.
"""
# Return get_framework_gaps(db, framework_id)
):
"""Get controls with techniques that are not adequately covered."""
return get_framework_gaps(db, framework_id)
@@ -191,49 +105,22 @@ def framework_gaps(
@router.post("/import/nist-800-53")
# Define function import_nist
def import_nist(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> dict:
"""Import NIST 800-53 Rev 5 mappings (admin only).
Args:
db (Session): SQLAlchemy database session.
current_user (User): Authenticated admin user.
Returns:
dict: Import result with counts of created and updated control mappings.
"""
# Assign result = import_nist_800_53_mappings(db)
):
"""Import NIST 800-53 Rev 5 mappings (admin only)."""
result = import_nist_800_53_mappings(db)
# Return result
return result
# Apply the @router.post decorator
@router.post("/import/cis-controls-v8")
# Define function import_cis
def import_cis(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> dict:
"""Import CIS Controls v8 mappings (admin only).
Args:
db (Session): SQLAlchemy database session.
current_user (User): Authenticated admin user.
Returns:
dict: Import result with counts of created and updated control mappings.
"""
# Assign result = import_cis_controls_v8_mappings(db)
):
"""Import CIS Controls v8 mappings (admin only)."""
result = import_cis_controls_v8_mappings(db)
# Return result
return result
+7 -53
View File
@@ -1,47 +1,26 @@
"""D3FEND endpoints — defensive technique listings, mappings, and import trigger."""
# Import logging
import logging
# Import Optional from typing
from typing import Optional
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_role
# Import User from app.models.user
from app.models.user import User
# Import from app.services.d3fend_import_service
from app.services.d3fend_import_service import (
import_d3fend_mappings,
import_d3fend_techniques,
import_d3fend_mappings,
)
# Import from app.services.d3fend_query_service
from app.services.d3fend_query_service import (
get_defenses_for_attack_technique,
list_d3fend_tactics,
)
# Import from app.services.d3fend_query_service
from app.services.d3fend_query_service import (
list_defensive_techniques as list_defensive_techniques_svc,
list_d3fend_tactics,
get_defenses_for_attack_technique,
)
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Assign router = APIRouter(prefix="/d3fend", tags=["d3fend"])
router = APIRouter(prefix="/d3fend", tags=["d3fend"])
@@ -50,23 +29,15 @@ router = APIRouter(prefix="/d3fend", tags=["d3fend"])
# ---------------------------------------------------------------------------
@router.get("")
# Define function list_defensive_techniques
def list_defensive_techniques(
# Entry: tactic
tactic: Optional[str] = Query(None),
# Entry: search
search: Optional[str] = Query(None),
# Entry: offset
offset: int = Query(0, ge=0),
# Entry: limit
limit: int = Query(50, ge=1, le=200),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""List all D3FEND defensive techniques with optional filters."""
# Return list_defensive_techniques_svc(
return list_defensive_techniques_svc(
db, tactic=tactic, search=search, offset=offset, limit=limit
)
@@ -77,15 +48,11 @@ def list_defensive_techniques(
# ---------------------------------------------------------------------------
@router.get("/tactics")
# Define function list_d3fend_tactics_endpoint
def list_d3fend_tactics_endpoint(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list:
):
"""Return a list of all D3FEND tactics with counts."""
# Return list_d3fend_tactics(db)
return list_d3fend_tactics(db)
@@ -94,17 +61,12 @@ def list_d3fend_tactics_endpoint(
# ---------------------------------------------------------------------------
@router.get("/for-technique/{mitre_id}")
# Define function get_defenses_for_attack_technique_endpoint
def get_defenses_for_attack_technique_endpoint(
# Entry: mitre_id
mitre_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Get all D3FEND defensive techniques mapped to a given ATT&CK technique."""
# Return get_defenses_for_attack_technique(db, mitre_id)
return get_defenses_for_attack_technique(db, mitre_id)
@@ -113,23 +75,15 @@ def get_defenses_for_attack_technique_endpoint(
# ---------------------------------------------------------------------------
@router.post("/import")
# Define function trigger_d3fend_import
def trigger_d3fend_import(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> dict:
):
"""Import D3FEND techniques and ATT&CK mappings. Admin only."""
# Assign tech_result = import_d3fend_techniques(db)
tech_result = import_d3fend_techniques(db)
# Assign mapping_result = import_d3fend_mappings(db)
mapping_result = import_d3fend_mappings(db)
# Return {
return {
# Literal argument value
"techniques": tech_result,
# Literal argument value
"mappings": mapping_result,
}
+9 -77
View File
@@ -5,34 +5,16 @@ Provides a centralized panel for managing all external data sources
including sync triggers, enable/disable toggles, and statistics.
"""
# Import Optional from typing
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import Optional
# Import APIRouter, Depends from fastapi
from fastapi import APIRouter, Depends
# Import BaseModel from pydantic
from pydantic import BaseModel
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import require_role from app.dependencies.auth
from app.dependencies.auth import require_role
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import User from app.models.user
from app.models.user import User
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
# Import from app.services.data_source_service
from app.services.data_source_service import (
get_source_stats,
list_sources,
@@ -41,21 +23,18 @@ from app.services.data_source_service import (
update_source,
)
# ---------------------------------------------------------------------------
# Pydantic schemas for request validation
# ---------------------------------------------------------------------------
class DataSourceUpdate(BaseModel):
"""Payload for updating a data source — only allowed fields."""
# Assign is_enabled = None
is_enabled: Optional[bool] = None
# Assign sync_frequency = None
sync_frequency: Optional[str] = None
# Assign config = None
config: Optional[dict] = None
# Assign router = APIRouter(prefix="/data-sources", tags=["data-sources"])
router = APIRouter(prefix="/data-sources", tags=["data-sources"])
@@ -65,137 +44,90 @@ router = APIRouter(prefix="/data-sources", tags=["data-sources"])
@router.get("")
# Define function list_data_sources
def list_data_sources(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> list:
):
"""List all registered data sources.
**Requires** the ``admin`` role.
"""
# Return list_sources(db)
return list_sources(db)
# Apply the @router.patch decorator
@router.patch("/{source_id}")
# Define function update_data_source
def update_data_source(
# Entry: source_id
source_id: str,
# Entry: body
body: DataSourceUpdate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> dict:
):
"""Update a data source (enable/disable, change config).
**Requires** the ``admin`` role.
"""
# Assign update_data = body.model_dump(exclude_unset=True)
update_data = body.model_dump(exclude_unset=True)
# Open context manager
with UnitOfWork(db) as uow:
# Call update_source()
update_source(db, source_id, **update_data)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="update_data_source",
# Keyword argument: entity_type
entity_type="data_source",
# Keyword argument: entity_id
entity_id=source_id,
# Keyword argument: details
details={"updates": update_data},
)
# Call uow.commit()
uow.commit()
# Return {"message": "Data source updated", "id": source_id}
return {"message": "Data source updated", "id": source_id}
# Apply the @router.post decorator
@router.post("/{source_id}/sync")
# Define function sync_data_source
def sync_data_source(
# Entry: source_id
source_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> dict:
):
"""Trigger sync/import for a specific data source.
**Requires** the ``admin`` role.
"""
# Return sync_source(db, source_id)
return sync_source(db, source_id)
# Apply the @router.post decorator
@router.post("/sync-all")
# Define function sync_all_data_sources
def sync_all_data_sources(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> dict:
):
"""Trigger sync for all enabled data sources (sequentially).
**Requires** the ``admin`` role.
"""
# Assign results = sync_all_sources(db)
results = sync_all_sources(db)
# Open context manager
with UnitOfWork(db) as uow:
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="sync_all_data_sources",
# Keyword argument: entity_type
entity_type="data_source",
# Keyword argument: entity_id
entity_id=None,
# Keyword argument: details
details={"results": results},
)
# Call uow.commit()
uow.commit()
# Return {"message": "Sync all complete", "results": results}
return {"message": "Sync all complete", "results": results}
# Apply the @router.get decorator
@router.get("/{source_id}/stats")
# Define function get_data_source_stats
def get_data_source_stats(
# Entry: source_id
source_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> dict:
):
"""Get detailed statistics for a specific data source.
**Requires** the ``admin`` role.
"""
# Return get_source_stats(db, source_id)
return get_source_stats(db, source_id)
+11 -70
View File
@@ -6,55 +6,36 @@ Provides endpoints for browsing detection rules, querying rules by technique,
and managing the template ↔ detection rule associations.
"""
# Import uuid
import uuid
# Import Optional from typing
from typing import Optional
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import BaseModel from pydantic
from pydantic import BaseModel
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_any_role, require_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role, require_role
# Import User from app.models.user
from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.user import User
# Import from app.services.detection_rule_service
from app.services.detection_rule_service import (
auto_associate_rules,
evaluate_rule,
get_rules_for_template,
get_rules_for_test,
list_rules,
get_rules_for_template,
auto_associate_rules,
get_rules_for_test,
evaluate_rule,
)
# ── Pydantic schemas for request validation ────────────────────────────
class DetectionRuleEvaluate(BaseModel):
"""Payload for evaluating a detection rule against a test."""
# test_id: uuid.UUID
test_id: uuid.UUID
# detection_rule_id: uuid.UUID
detection_rule_id: uuid.UUID
# Assign triggered = None
triggered: Optional[bool] = None
# Assign notes = None
notes: Optional[str] = None
# Assign router = APIRouter(prefix="/detection-rules", tags=["detection-rules"])
router = APIRouter(prefix="/detection-rules", tags=["detection-rules"])
@@ -62,40 +43,24 @@ router = APIRouter(prefix="/detection-rules", tags=["detection-rules"])
@router.get("")
# Define function list_detection_rules
def list_detection_rules(
# Entry: technique
technique: Optional[str] = Query(None, description="Filter by MITRE technique ID"),
# Entry: source
source: Optional[str] = Query(None, description="Filter by source (sigma, elastic, splunk, custom)"),
# Entry: severity
severity: Optional[str] = Query(None),
# Entry: search
search: Optional[str] = Query(None),
# Entry: offset
offset: int = Query(0, ge=0),
# Entry: limit
limit: int = Query(50, ge=1, le=200),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""List detection rules with optional filters and pagination."""
# Return list_rules(
return list_rules(
db,
# Keyword argument: technique
technique=technique,
# Keyword argument: source
source=source,
# Keyword argument: severity
severity=severity,
# Keyword argument: search
search=search,
# Keyword argument: offset
offset=offset,
# Keyword argument: limit
limit=limit,
)
@@ -104,17 +69,12 @@ def list_detection_rules(
@router.get("/for-template/{template_id}")
# Define function get_detection_rules_for_template
def get_detection_rules_for_template(
# Entry: template_id
template_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Get detection rules associated with a test template."""
# Return get_rules_for_template(db, template_id)
return get_rules_for_template(db, template_id)
@@ -122,20 +82,16 @@ def get_detection_rules_for_template(
@router.post("/auto-associate")
# Define function auto_associate_detection_rules
def auto_associate_detection_rules(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> dict:
):
"""Auto-associate test templates with detection rules by MITRE technique ID.
For each active template, find all active detection rules for the same
technique and create associations. Rules with severity >= high are marked
as primary.
"""
# Return auto_associate_rules(db)
return auto_associate_rules(db)
@@ -143,21 +99,16 @@ def auto_associate_detection_rules(
@router.get("/for-test/{test_id}")
# Define function get_detection_rules_for_test
def get_detection_rules_for_test(
# Entry: test_id
test_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Get detection rules relevant to a test, along with their evaluation results.
Finds rules by matching the test's technique_id to detection rules,
and returns any existing evaluation results.
"""
# Return get_rules_for_test(db, test_id)
return get_rules_for_test(db, test_id)
@@ -165,27 +116,17 @@ def get_detection_rules_for_test(
@router.post("/evaluate")
# Define function evaluate_detection_rule
def evaluate_detection_rule(
# Entry: payload
payload: DetectionRuleEvaluate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
) -> dict:
):
"""Save or update the evaluation result for a detection rule on a test."""
# Return evaluate_rule(
return evaluate_rule(
db,
# Keyword argument: test_id
test_id=payload.test_id,
# Keyword argument: detection_rule_id
detection_rule_id=payload.detection_rule_id,
# Keyword argument: triggered
triggered=payload.triggered,
# Keyword argument: notes
notes=payload.notes,
# Keyword argument: evaluator_id
evaluator_id=current_user.id,
)
+6 -110
View File
@@ -20,63 +20,39 @@ Access Control
``validated``, or ``rejected``.
"""
# Import hashlib
import hashlib
import logging
import os
# Import uuid
import uuid as _uuid
from datetime import datetime
from typing import Optional
# Import APIRouter, Depends, File, Form, Query, Request,... from fastapi
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user from app.dependencies.auth
from app.dependencies.auth import get_current_user
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import limiter from app.limiter
from app.limiter import limiter
# Import TeamSide from app.models.enums
from app.dependencies.auth import get_current_user
from app.models.enums import TeamSide
# Import Evidence from app.models.evidence
from app.models.evidence import Evidence
# Import User from app.models.user
from app.models.user import User
# Import EvidenceOut from app.schemas.evidence
from app.schemas.evidence import EvidenceOut
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
# Import from app.services.evidence_service
from app.services.evidence_service import (
MAX_UPLOAD_SIZE,
get_evidence_or_raise,
get_test_or_raise,
list_evidence_for_test,
MAX_UPLOAD_SIZE,
validate_delete_permission,
validate_file,
validate_upload_permission,
)
from app.limiter import limiter
from app.storage import download_file, upload_file
logger = logging.getLogger(__name__)
# Assign router = APIRouter(tags=["evidence"])
router = APIRouter(tags=["evidence"])
@@ -91,21 +67,13 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
never needs direct access to MinIO.
"""
return EvidenceOut(
# Keyword argument: id
id=evidence.id,
# Keyword argument: test_id
test_id=evidence.test_id,
# Keyword argument: file_name
file_name=evidence.file_name,
# Keyword argument: sha256_hash
sha256_hash=evidence.sha256_hash,
# Keyword argument: uploaded_by
uploaded_by=evidence.uploaded_by,
# Keyword argument: uploaded_at
uploaded_at=evidence.uploaded_at,
# Keyword argument: team
team=evidence.team,
# Keyword argument: notes
notes=evidence.notes,
download_url=f"/api/v1/evidence/{evidence.id}/file",
)
@@ -117,47 +85,30 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
@router.post(
# Literal argument value
"/tests/{test_id}/evidence",
# Keyword argument: response_model
response_model=EvidenceOut,
# Keyword argument: status_code
status_code=status.HTTP_201_CREATED,
)
# Apply the @limiter.limit decorator
@limiter.limit("10/minute")
# Define async function upload_evidence
async def upload_evidence(
# Entry: request
request: Request,
# Entry: test_id
test_id: _uuid.UUID,
# Entry: file
file: UploadFile = File(...),
# Entry: team
team: TeamSide = Form(TeamSide.red),
# Entry: notes
notes: Optional[str] = Form(None),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> EvidenceOut:
):
"""Upload a file as evidence for the given test.
The ``team`` field (sent as form data) determines whether this is
Red Team (attack) or Blue Team (detection) evidence.
"""
# Assign test = get_test_or_raise(db, test_id)
test = get_test_or_raise(db, test_id)
# Call validate_upload_permission()
validate_upload_permission(test, team, current_user.role)
# Assign file_name = file.filename or "unnamed"
file_name = file.filename or "unnamed"
# Assign content = await file.read(MAX_UPLOAD_SIZE + 1)
content = await file.read(MAX_UPLOAD_SIZE + 1)
# Call validate_file()
validate_file(file_name, len(content))
# Hash
@@ -165,7 +116,6 @@ async def upload_evidence(
# 4. Object key (sanitise filename to prevent path traversal in storage)
safe_name = os.path.basename(file_name)
# Assign key = f"{test_id}/{_uuid.uuid4()}_{safe_name}"
key = f"{test_id}/{_uuid.uuid4()}_{safe_name}"
# 5. Upload to MinIO
@@ -173,53 +123,32 @@ async def upload_evidence(
# 6. Persist metadata and audit
with UnitOfWork(db) as uow:
# Assign evidence = Evidence(
evidence = Evidence(
# Keyword argument: test_id
test_id=test_id,
# Keyword argument: file_name
file_name=safe_name,
# Keyword argument: file_path
file_path=key,
# Keyword argument: sha256_hash
sha256_hash=sha256,
# Keyword argument: uploaded_by
uploaded_by=current_user.id,
uploaded_at=datetime.utcnow(), # set explicitly — DB column has no server default
team=team,
# Keyword argument: notes
notes=notes,
)
# Stage new record(s) for database insertion
db.add(evidence)
# Flush changes to DB without committing the transaction
db.flush() # Get evidence.id for audit
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="upload_evidence",
# Keyword argument: entity_type
entity_type="evidence",
# Keyword argument: entity_id
entity_id=evidence.id,
# Keyword argument: details
details={
# Literal argument value
"file_name": safe_name,
# Literal argument value
"sha256": sha256,
# Literal argument value
"test_id": str(test_id),
# Literal argument value
"team": team.value,
},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(evidence)
# 7. Attach to Jira ticket if one exists (non-fatal)
@@ -265,23 +194,15 @@ def _attach_evidence_to_jira(
@router.get("/tests/{test_id}/evidence", response_model=list[EvidenceOut])
# Define function list_evidence
def list_evidence(
# Entry: test_id
test_id: _uuid.UUID,
# Entry: team
team: Optional[str] = Query(None, description="Filter by team: red or blue"),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list[EvidenceOut]:
):
"""List all evidences for a test, optionally filtered by team."""
# Call get_test_or_raise()
get_test_or_raise(db, test_id)
# Assign evidences = list_evidence_for_test(db, test_id, team=team)
evidences = list_evidence_for_test(db, test_id, team=team)
# Return [_evidence_to_out(e) for e in evidences]
return [_evidence_to_out(e) for e in evidences]
@@ -291,18 +212,13 @@ def list_evidence(
@router.get("/evidence/{evidence_id}", response_model=EvidenceOut)
# Define function get_evidence
def get_evidence(
# Entry: evidence_id
evidence_id: _uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
):
"""Return evidence metadata. ``download_url`` is a backend proxy URL."""
evidence = get_evidence_or_raise(db, evidence_id)
# Return _evidence_to_out(evidence)
return _evidence_to_out(evidence)
@@ -349,15 +265,11 @@ def download_evidence_file(
@router.delete("/evidence/{evidence_id}", status_code=status.HTTP_200_OK)
# Define function delete_evidence
def delete_evidence(
# Entry: evidence_id
evidence_id: _uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Delete an evidence record.
Only allowed in editable states:
@@ -365,40 +277,24 @@ def delete_evidence(
- Blue evidence: ``blue_evaluating``
- No deletions in ``in_review``, ``validated``, ``rejected``
"""
# Assign evidence = get_evidence_or_raise(db, evidence_id)
evidence = get_evidence_or_raise(db, evidence_id)
# Assign test = get_test_or_raise(db, evidence.test_id)
test = get_test_or_raise(db, evidence.test_id)
# Call validate_delete_permission()
validate_delete_permission(test, evidence, current_user.role, current_user.id)
# Open context manager
with UnitOfWork(db) as uow:
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="delete_evidence",
# Keyword argument: entity_type
entity_type="evidence",
# Keyword argument: entity_id
entity_id=evidence.id,
# Keyword argument: details
details={
# Literal argument value
"file_name": evidence.file_name,
# Literal argument value
"test_id": str(evidence.test_id),
# Literal argument value
"team": evidence.team.value if evidence.team else None,
},
)
# Mark record for deletion on next commit
db.delete(evidence)
# Call uow.commit()
uow.commit()
# Return {"detail": "Evidence deleted"}
return {"detail": "Evidence deleted"}
+2 -1
View File
@@ -1,6 +1,7 @@
"""Phase 13: Executive Dashboard router."""
from typing import List
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
+5 -73
View File
@@ -5,169 +5,101 @@ No business logic lives here — only request validation and response
formatting.
"""
# Import io
import io
# Import json
import json
# Import Optional from typing
from typing import Optional
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import StreamingResponse from fastapi.responses
from fastapi.responses import StreamingResponse
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user from app.dependencies.auth
from app.dependencies.auth import get_current_user
# Import User from app.models.user
from app.models.user import User
# Import heatmap_service from app.services
from app.services import heatmap_service
# Assign router = APIRouter(prefix="/heatmap", tags=["heatmap"])
router = APIRouter(prefix="/heatmap", tags=["heatmap"])
# Apply the @router.get decorator
@router.get("/coverage")
# Define function heatmap_coverage
def heatmap_coverage(
# Entry: platforms
platforms: Optional[str] = Query(None, description="Comma-separated platforms"),
# Entry: tactics
tactics: Optional[str] = Query(None, description="Comma-separated tactics"),
# Entry: min_score
min_score: int = Query(0, ge=0, le=100),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Coverage layer — score based on status_global of each technique."""
# Return heatmap_service.build_coverage_layer(
return heatmap_service.build_coverage_layer(
db, platforms=platforms, tactics=tactics, min_score=min_score,
)
# Apply the @router.get decorator
@router.get("/threat-actor/{actor_id}")
# Define function heatmap_threat_actor
def heatmap_threat_actor(
# Entry: actor_id
actor_id: str,
# Entry: platforms
platforms: Optional[str] = Query(None),
# Entry: tactics
tactics: Optional[str] = Query(None),
# Entry: min_score
min_score: int = Query(0, ge=0, le=100),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Threat actor layer — techniques used by an actor with coverage color."""
# Return heatmap_service.build_threat_actor_layer(
return heatmap_service.build_threat_actor_layer(
db, actor_id, platforms=platforms, tactics=tactics, min_score=min_score,
)
# Apply the @router.get decorator
@router.get("/detection-rules")
# Define function heatmap_detection_rules
def heatmap_detection_rules(
# Entry: platforms
platforms: Optional[str] = Query(None),
# Entry: tactics
tactics: Optional[str] = Query(None),
# Entry: min_score
min_score: int = Query(0, ge=0, le=100),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Detection rules layer — score based on ratio of rules available vs total."""
# Return heatmap_service.build_detection_rules_layer(
return heatmap_service.build_detection_rules_layer(
db, platforms=platforms, tactics=tactics, min_score=min_score,
)
# Apply the @router.get decorator
@router.get("/campaign/{campaign_id}")
# Define function heatmap_campaign
def heatmap_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: platforms
platforms: Optional[str] = Query(None),
# Entry: tactics
tactics: Optional[str] = Query(None),
# Entry: min_score
min_score: int = Query(0, ge=0, le=100),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Campaign layer — only techniques in the campaign, colored by test state."""
# Return heatmap_service.build_campaign_layer(
return heatmap_service.build_campaign_layer(
db, campaign_id, platforms=platforms, tactics=tactics, min_score=min_score,
)
# Apply the @router.get decorator
@router.get("/export-navigator")
# Define function export_navigator
def export_navigator(
# Entry: layer
layer: str = Query(..., description="Layer type: coverage, threat-actor, detection-rules, campaign"),
# Entry: layer_id
layer_id: Optional[str] = Query(None, description="Actor ID or Campaign ID (if applicable)"),
# Entry: platforms
platforms: Optional[str] = Query(None),
# Entry: tactics
tactics: Optional[str] = Query(None),
# Entry: min_score
min_score: int = Query(0, ge=0, le=100),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> StreamingResponse:
):
"""Export a heatmap layer as a downloadable JSON file for ATT&CK Navigator."""
# Assign data = heatmap_service.build_navigator_export(
data = heatmap_service.build_navigator_export(
db, layer, layer_id=layer_id,
# Keyword argument: platforms
platforms=platforms, tactics=tactics, min_score=min_score,
)
# Assign json_content = json.dumps(data, indent=2, default=str)
json_content = json.dumps(data, indent=2, default=str)
# Assign buffer = io.BytesIO(json_content.encode("utf-8"))
buffer = io.BytesIO(json_content.encode("utf-8"))
# Return StreamingResponse(
return StreamingResponse(
buffer,
# Keyword argument: media_type
media_type="application/json",
# Keyword argument: headers
headers={"Content-Disposition": f"attachment; filename=aegis_{layer}_layer.json"},
)
+6 -103
View File
@@ -1,235 +1,138 @@
"""Jira integration router — link, search, sync, create issues."""
# Import logging
import logging
# Import Optional from typing
from typing import Optional
# Import UUID from uuid
from uuid import UUID
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_role
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import JiraLinkEntityType from app.models.jira_link
from app.models.jira_link import JiraLinkEntityType
# Import User from app.models.user
from app.models.user import User
# Import from app.schemas.jira_schema
from app.schemas.jira_schema import (
JiraIssueResult,
JiraLinkCreate,
JiraLinkOut,
)
from app.services import jira_service, audit_service
# Import audit_service, jira_service from app.services
from app.services import audit_service, jira_service
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Assign router = APIRouter(prefix="/jira", tags=["jira"])
router = APIRouter(prefix="/jira", tags=["jira"])
# Apply the @router.get decorator
@router.get("/search", response_model=list[JiraIssueResult])
# Define function search_issues
def search_issues(
# Entry: q
q: str = Query(..., min_length=2),
# Entry: max_results
max_results: int = Query(10, le=50),
# Entry: user
user: User = Depends(get_current_user),
) -> list[JiraIssueResult]:
):
"""Search Jira issues by JQL or free text."""
# Return jira_service.search_jira_issues(q, max_results)
return jira_service.search_jira_issues(q, max_results)
# Apply the @router.post decorator
@router.post("/links", response_model=JiraLinkOut, status_code=201)
# Define function create_link
def create_link(
# Entry: body
body: JiraLinkCreate,
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> JiraLinkOut:
):
"""Associate an Aegis entity with a Jira issue."""
# Open context manager
with UnitOfWork(db) as uow:
# Assign link = jira_service.create_link(
link = jira_service.create_link(
db,
# Keyword argument: entity_type
entity_type=body.entity_type,
# Keyword argument: entity_id
entity_id=body.entity_id,
# Keyword argument: jira_issue_key
jira_issue_key=body.jira_issue_key,
# Keyword argument: sync_direction
sync_direction=body.sync_direction,
# Keyword argument: created_by
created_by=user.id,
)
# Call audit_service.log_action()
audit_service.log_action(
db,
# Keyword argument: user_id
user_id=user.id,
# Keyword argument: action
action="JIRA_LINK_CREATED",
# Keyword argument: entity_type
entity_type="jira_link",
# Keyword argument: entity_id
entity_id=str(link.id),
# Keyword argument: details
details={
# Literal argument value
"linked_entity_type": body.entity_type.value,
# Literal argument value
"linked_entity_id": str(body.entity_id),
# Literal argument value
"jira_issue_key": body.jira_issue_key,
},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(link)
# Return link
return link
# Apply the @router.get decorator
@router.get("/links", response_model=list[JiraLinkOut])
# Define function list_links
def list_links(
# Entry: entity_type
entity_type: Optional[JiraLinkEntityType] = None,
# Entry: entity_id
entity_id: Optional[UUID] = None,
entity_ids: Optional[list[UUID]] = Query(default=None, description="Filter by multiple entity IDs"),
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
):
"""List Jira links, optionally filtered by entity or a list of entity IDs."""
return jira_service.list_links(
db,
# Keyword argument: entity_type
entity_type=entity_type,
# Keyword argument: entity_id
entity_id=entity_id,
entity_ids=entity_ids,
)
# Apply the @router.post decorator
@router.post("/links/{link_id}/sync")
# Define function sync_link
def sync_link(
# Entry: link_id
link_id: UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(require_role("admin")),
) -> dict:
):
"""Force bidirectional sync for a specific Jira link."""
# Open context manager
with UnitOfWork(db) as uow:
# Assign link = jira_service.get_link_or_raise(db, link_id)
link = jira_service.get_link_or_raise(db, link_id)
# Call jira_service.sync_jira_to_aegis()
jira_service.sync_jira_to_aegis(db, link)
# Call uow.commit()
uow.commit()
# Return {"message": "Sync completed", "jira_status": link.jira_status}
return {"message": "Sync completed", "jira_status": link.jira_status}
# Apply the @router.delete decorator
@router.delete("/links/{link_id}", status_code=204)
# Define function delete_link
def delete_link(
# Entry: link_id
link_id: UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> None:
):
"""Remove a Jira link."""
# Open context manager
with UnitOfWork(db) as uow:
# Assign link = jira_service.delete_link(db, link_id)
link = jira_service.delete_link(db, link_id)
# Call audit_service.log_action()
audit_service.log_action(
db,
# Keyword argument: user_id
user_id=user.id,
# Keyword argument: action
action="jira_link_deleted",
# Keyword argument: entity_type
entity_type="jira_link",
# Keyword argument: entity_id
entity_id=str(link_id),
# Keyword argument: details
details={"jira_issue_key": link.jira_issue_key},
)
# Call uow.commit()
uow.commit()
# Apply the @router.post decorator
@router.post("/create-issue")
# Define function create_issue_from_entity
def create_issue_from_entity(
# Entry: entity_type
entity_type: JiraLinkEntityType,
# Entry: entity_id
entity_id: UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> dict:
):
"""Auto-create a Jira issue from an Aegis entity and link them."""
# Open context manager
with UnitOfWork(db) as uow:
# Assign result = jira_service.create_issue_and_link(
result = jira_service.create_issue_and_link(
db,
# Keyword argument: entity_type
entity_type=entity_type,
# Keyword argument: entity_id
entity_id=entity_id,
# Keyword argument: created_by
created_by=user.id,
)
# Call uow.commit()
uow.commit()
# Return result
return result
+1 -1
View File
@@ -3,7 +3,7 @@
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
+6 -43
View File
@@ -7,22 +7,12 @@ validation-rate endpoints for the Red/Blue workflow.
Thin HTTP adapter: delegates all data logic to metrics_query_service.
"""
# Import APIRouter, Depends from fastapi
from fastapi import APIRouter, Depends
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user from app.dependencies.auth
from app.dependencies.auth import get_current_user
# Import User from app.models.user
from app.models.user import User
# Import from app.schemas.metrics
from app.schemas.metrics import (
CoverageSummary,
RecentTestItem,
@@ -31,8 +21,6 @@ from app.schemas.metrics import (
TestPipelineCounts,
ValidationRate,
)
# Import from app.services.metrics_query_service
from app.services.metrics_query_service import (
get_coverage_by_tactic,
get_coverage_summary,
@@ -42,7 +30,6 @@ from app.services.metrics_query_service import (
get_validation_rate,
)
# Assign router = APIRouter(prefix="/metrics", tags=["metrics"])
router = APIRouter(prefix="/metrics", tags=["metrics"])
@@ -52,15 +39,11 @@ router = APIRouter(prefix="/metrics", tags=["metrics"])
@router.get("/summary", response_model=CoverageSummary)
# Define function coverage_summary
def coverage_summary(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> CoverageSummary:
):
"""Return a global coverage summary across all techniques."""
# Return get_coverage_summary(db)
return get_coverage_summary(db)
@@ -70,15 +53,11 @@ def coverage_summary(
@router.get("/by-tactic", response_model=list[TacticCoverage])
# Define function coverage_by_tactic
def coverage_by_tactic(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list[TacticCoverage]:
):
"""Return coverage breakdown grouped by tactic."""
# Return get_coverage_by_tactic(db)
return get_coverage_by_tactic(db)
@@ -88,15 +67,11 @@ def coverage_by_tactic(
@router.get("/test-pipeline", response_model=TestPipelineCounts)
# Define function test_pipeline
def test_pipeline(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> TestPipelineCounts:
):
"""Return how many tests are in each pipeline state."""
# Return get_test_pipeline_counts(db)
return get_test_pipeline_counts(db)
@@ -106,15 +81,11 @@ def test_pipeline(
@router.get("/team-activity", response_model=list[TeamActivity])
# Define function team_activity
def team_activity(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list[TeamActivity]:
):
"""Return activity summary for Red and Blue teams."""
# Return get_team_activity(db)
return get_team_activity(db)
@@ -124,15 +95,11 @@ def team_activity(
@router.get("/validation-rate", response_model=list[ValidationRate])
# Define function validation_rate
def validation_rate(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list[ValidationRate]:
):
"""Return approval and rejection rates for Red Lead and Blue Lead."""
# Return get_validation_rate(db)
return get_validation_rate(db)
@@ -142,13 +109,9 @@ def validation_rate(
@router.get("/recent-tests", response_model=list[RecentTestItem])
# Define function recent_tests
def recent_tests(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list[RecentTestItem]:
):
"""Return the 10 most recently created tests."""
# Return get_recent_tests(db, limit=10)
return get_recent_tests(db, limit=10)
+6 -48
View File
@@ -8,39 +8,23 @@ PATCH /notifications/{id}/read — mark one notification as read
POST /notifications/read-all — mark all as read
"""
# Import uuid
import uuid
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user from app.dependencies.auth
from app.dependencies.auth import get_current_user
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import User from app.models.user
from app.models.user import User
# Import NotificationOut, UnreadCountOut from app.schemas.notification
from app.schemas.notification import NotificationOut, UnreadCountOut
# Import from app.services.notification_service
from app.services.notification_service import (
get_unread_count,
list_notifications,
mark_all_as_read,
mark_as_read,
mark_all_as_read,
get_unread_count,
)
# Assign router = APIRouter(prefix="/notifications", tags=["notifications"])
router = APIRouter(prefix="/notifications", tags=["notifications"])
@@ -50,19 +34,13 @@ router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("", response_model=list[NotificationOut])
# Define function list_notifications_endpoint
def list_notifications_endpoint(
# Entry: offset
offset: int = Query(0, ge=0),
# Entry: limit
limit: int = Query(20, ge=1, le=100),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list[NotificationOut]:
):
"""Return paginated notifications for the current user, newest first."""
# Return list_notifications(db, current_user.id, offset=offset, limit=limit)
return list_notifications(db, current_user.id, offset=offset, limit=limit)
@@ -72,17 +50,12 @@ def list_notifications_endpoint(
@router.get("/unread-count", response_model=UnreadCountOut)
# Define function unread_count
def unread_count(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> UnreadCountOut:
):
"""Return the number of unread notifications for the current user."""
# Assign count = get_unread_count(db, current_user.id)
count = get_unread_count(db, current_user.id)
# Return UnreadCountOut(unread_count=count)
return UnreadCountOut(unread_count=count)
@@ -92,23 +65,15 @@ def unread_count(
@router.patch("/{notification_id}/read", response_model=NotificationOut)
# Define function read_notification
def read_notification(
# Entry: notification_id
notification_id: uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> NotificationOut:
):
"""Mark a single notification as read."""
# Open context manager
with UnitOfWork(db) as uow:
# Assign notif = mark_as_read(db, notification_id, current_user.id)
notif = mark_as_read(db, notification_id, current_user.id)
# Call uow.commit()
uow.commit()
# Return notif
return notif
@@ -118,19 +83,12 @@ def read_notification(
@router.post("/read-all")
# Define function read_all_notifications
def read_all_notifications(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Mark all notifications for the current user as read."""
# Open context manager
with UnitOfWork(db) as uow:
# Assign count = mark_all_as_read(db, current_user.id)
count = mark_all_as_read(db, current_user.id)
# Call uow.commit()
uow.commit()
# Return {"detail": f"Marked {count} notifications as read"}
return {"detail": f"Marked {count} notifications as read"}
+5 -29
View File
@@ -4,28 +4,18 @@ Provides operational KPIs for security teams with trend analysis
and team-level breakdowns.
"""
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user from app.dependencies.auth
from app.dependencies.auth import get_current_user
# Import User from app.models.user
from app.models.user import User
# Import from app.services.operational_metrics_service
from app.services.operational_metrics_service import (
get_metrics_by_team,
get_all_operational_metrics,
get_operational_trend,
get_metrics_by_team,
)
# Assign router = APIRouter(prefix="/metrics/operational", tags=["operational-metrics"])
router = APIRouter(prefix="/metrics/operational", tags=["operational-metrics"])
@@ -33,18 +23,13 @@ router = APIRouter(prefix="/metrics/operational", tags=["operational-metrics"])
@router.get("")
# Define function operational_metrics
def operational_metrics(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Get all operational metrics (MTTD, MTTR, etc.) — cached for 5 min."""
# Import get_operational_metrics_cached from app.services.score_cache
from app.services.score_cache import get_operational_metrics_cached
# Return get_operational_metrics_cached(db)
return get_operational_metrics_cached(db)
@@ -52,17 +37,12 @@ def operational_metrics(
@router.get("/trend")
# Define function operational_trend
def operational_trend(
# Entry: period
period: str = Query("90d", pattern="^(30d|90d|1y)$"),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Get weekly trend data for operational metrics."""
# Return get_operational_trend(db, period)
return get_operational_trend(db, period)
@@ -70,13 +50,9 @@ def operational_trend(
@router.get("/by-team")
# Define function metrics_by_team
def metrics_by_team(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
):
"""Get metrics broken down by Red Team vs Blue Team."""
# Return get_metrics_by_team(db)
return get_metrics_by_team(db)
+15 -162
View File
@@ -1,44 +1,26 @@
"""OSINT enrichment endpoints — view, review, and trigger enrichment of OSINT items linked to techniques."""
"""OSINT enrichment endpoints — view, review, and trigger enrichment of
OSINT items (CVEs, advisories, etc.) linked to techniques.
"""
# Import UUID from uuid
from uuid import UUID
# Import APIRouter, Depends, HTTPException, Query, status from fastapi
from fastapi import APIRouter, Depends, HTTPException, Query, status
# Import BaseModel from pydantic
from fastapi import APIRouter, Depends, Query, HTTPException, status
from pydantic import BaseModel
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_any_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import User from app.models.user
from app.models.user import User
# Import from app.services.osint_enrichment_service
from app.services.osint_enrichment_service import (
enrich_technique_with_cves,
get_osint_items_for_technique,
get_osint_summary,
get_technique_or_raise,
list_osint_items as service_list_osint_items,
mark_osint_reviewed,
)
# Import from app.services.osint_enrichment_service
from app.services.osint_enrichment_service import (
list_osint_items as service_list_osint_items,
)
# Assign router = APIRouter(prefix="/osint", tags=["osint"])
router = APIRouter(prefix="/osint", tags=["osint"])
@@ -46,34 +28,18 @@ router = APIRouter(prefix="/osint", tags=["osint"])
class OsintItemOut(BaseModel):
"""Serialized OSINT item returned by the API."""
# id: str
id: str
# technique_id: str
technique_id: str
# source_type: str
source_type: str
# source_url: str
source_url: str
# title: str
title: str
# description: str | None
description: str | None
# severity: str | None
severity: str | None
# discovered_at: str | None
discovered_at: str | None
# reviewed: bool
reviewed: bool
# Assign metadata_ = None
metadata_: dict | None = None
# Define class Config
class Config:
"""ORM mode configuration for SQLAlchemy model mapping."""
# Assign from_attributes = True
from_attributes = True
@@ -81,207 +47,94 @@ class OsintItemOut(BaseModel):
@router.get("/items")
# Define function list_osint_items
def list_osint_items(
# Entry: technique_id
technique_id: UUID | None = Query(None),
# Entry: source_type
source_type: str | None = Query(None),
# Entry: reviewed
reviewed: bool | None = Query(None),
# Entry: offset
offset: int = Query(0, ge=0),
# Entry: limit
limit: int = Query(50, ge=1, le=200),
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> dict:
"""List OSINT items with optional filters.
Args:
technique_id (UUID | None): Filter by the technique's UUID.
source_type (str | None): Filter by source type (e.g. ``nvd_cve``, ``advisory``).
reviewed (bool | None): Filter by review status; ``None`` returns all.
offset (int): Number of records to skip for pagination.
limit (int): Maximum number of records to return.
db (Session): SQLAlchemy database session.
user (User): Authenticated user making the request.
Returns:
list: Serialised list of OSINT item dicts matching the filters.
"""
# Return service_list_osint_items(
):
"""List OSINT items with optional filters."""
return service_list_osint_items(
db,
# Keyword argument: technique_id
technique_id=technique_id,
# Keyword argument: source_type
source_type=source_type,
# Keyword argument: reviewed
reviewed=reviewed,
# Keyword argument: offset
offset=offset,
# Keyword argument: limit
limit=limit,
)
# Apply the @router.get decorator
@router.get("/summary")
# Define function osint_summary
def osint_summary(
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> dict:
"""Return summary statistics for OSINT items.
Args:
db (Session): SQLAlchemy database session.
user (User): Authenticated user making the request.
Returns:
dict: Counts of total, reviewed, and unreviewed items broken down by source type.
"""
# Return get_osint_summary(db)
):
"""Summary statistics for OSINT items."""
return get_osint_summary(db)
# Apply the @router.post decorator
@router.post("/items/{item_id}/review")
# Define function review_osint_item
def review_osint_item(
# Entry: item_id
item_id: UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> dict:
"""Mark an OSINT item as reviewed.
Args:
item_id (UUID): Primary key of the OSINT item to mark reviewed.
db (Session): SQLAlchemy database session.
user (User): Authenticated user performing the review.
Returns:
dict: Contains ``id`` (str) and ``reviewed`` (bool ``True``).
"""
# Open context manager
):
"""Mark an OSINT item as reviewed."""
with UnitOfWork(db) as uow:
# Assign item = mark_osint_reviewed(db, str(item_id))
item = mark_osint_reviewed(db, str(item_id))
# Check: not item
if not item:
# Raise HTTPException
raise HTTPException(
# Keyword argument: status_code
status_code=status.HTTP_404_NOT_FOUND,
# Keyword argument: detail
detail="OSINT item not found",
)
# Call uow.commit()
uow.commit()
# Return {"id": str(item.id), "reviewed": True}
return {"id": str(item.id), "reviewed": True}
# Apply the @router.post decorator
@router.post("/enrich/{technique_id}")
# Define function trigger_technique_enrichment
def trigger_technique_enrichment(
# Entry: technique_id
technique_id: UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Manually trigger OSINT enrichment for a single technique.
Args:
technique_id (UUID): Primary key of the technique to enrich.
db (Session): SQLAlchemy database session.
user (User): Authenticated red_lead or blue_lead requesting enrichment.
Returns:
dict: Contains ``technique_id`` (str), ``mitre_id`` (str), and ``new_items`` (int).
"""
# Assign technique = get_technique_or_raise(db, technique_id)
):
"""Manually trigger OSINT enrichment for a single technique."""
technique = get_technique_or_raise(db, technique_id)
# Assign count = enrich_technique_with_cves(db, technique)
count = enrich_technique_with_cves(db, technique)
# Return {
return {
# Literal argument value
"technique_id": str(technique.id),
# Literal argument value
"mitre_id": technique.mitre_id,
# Literal argument value
"new_items": count,
}
# Apply the @router.get decorator
@router.get("/technique/{technique_id}")
# Define function get_technique_osint
def get_technique_osint(
# Entry: technique_id
technique_id: UUID,
# Entry: source_type
source_type: str | None = Query(None),
# Entry: reviewed
reviewed: bool | None = Query(None),
# Entry: db
db: Session = Depends(get_db),
# Entry: user
user: User = Depends(get_current_user),
) -> list:
"""Get all OSINT items for a specific technique.
Args:
technique_id (UUID): Primary key of the technique.
source_type (str | None): Filter by source type (e.g. ``nvd_cve``).
reviewed (bool | None): Filter by review status; ``None`` returns all.
db (Session): SQLAlchemy database session.
user (User): Authenticated user making the request.
Returns:
list: Dicts with OSINT item fields including source URL, severity, and review status.
"""
# Assign items = get_osint_items_for_technique(
):
"""Get all OSINT items for a specific technique."""
items = get_osint_items_for_technique(
db,
str(technique_id),
# Keyword argument: source_type
source_type=source_type,
# Keyword argument: reviewed
reviewed=reviewed,
)
# Return [
return [
{
# Literal argument value
"id": str(item.id),
# Literal argument value
"source_type": item.source_type,
# Literal argument value
"source_url": item.source_url,
# Literal argument value
"title": item.title,
# Literal argument value
"description": item.description,
# Literal argument value
"severity": item.severity,
# Literal argument value
"discovered_at": item.discovered_at.isoformat() if item.discovered_at else None,
# Literal argument value
"reviewed": item.reviewed,
# Literal argument value
"metadata": item.metadata_,
}
for item in items
+1
View File
@@ -14,6 +14,7 @@ from app.schemas.ownership_queue_schema import (
DetectionAssetOwnershipPatch,
BulkAssignRequest, BulkAssignResult,
QueueItemCreate, QueueItemPatch, QueueItemOut,
AnalystDashboard,
)
from app.services import ownership_service, revalidation_queue_service
from app.models.ownership_queue import RevalidationQueueItem

Some files were not shown because too many files have changed in this diff Show More