Skip to content

Latest commit

 

History

History
71 lines (64 loc) · 9.35 KB

File metadata and controls

71 lines (64 loc) · 9.35 KB

Threat model — OpenJobRadar

Methodology per the portfolio RESPONSIBLE-TECH-FRAMEWORK.md. Scope at M2: the tenancy primitives (src/openjobradar/tenancy/), config core, policy evaluators, and scoring subsystem. Extended at M4 (foundation) to cover the due-work scheduling surface (src/openjobradar/scheduling/), which introduces this codebase's first deliberate cross-tenant read (T8), the infrastructure-as-code in infra/ (ADR-0021/0022), the first live request path (GET /me, ADR-0024), the first outbound network calls this codebase makes — ATS adapters fetching third-party job boards (src/openjobradar/poll/, ADR-0025), introducing an SSRF-adjacent surface (T9) — the first live model call, BedrockScorer (ADR-0029), which introduces this codebase's first untrusted-output surface (T10) alongside ADR-0009's untrusted-input one — and the first UI, web/ (ADR-0030): a browser-side PKCE sign-in flow storing tokens client-side, introducing this codebase's first token-theft-via-XSS surface (T11). Review cadence: every new trust boundary (per roadmap M3/M4) and quarterly otherwise. Last reviewed: 2026-08-23.

Assets & trust boundaries

Asset Boundary Notes
Per-user rows (postings, orgs, settings, sends, applications) Browser → API → repository Isolation invariant enforced by tests/test_repository_isolation.py
BYO credentials & OAuth tokens Vault service ↔ KMS Envelope encryption planned; InsecureTestCipher is test-only by contract
Budget ledgers Data plane → Bedrock Checked before invocation
Prompt pipeline User text → scorer Fenced + sanitized (ADR-0009)

STRIDE findings & dispositions

# Threat Vector Disposition Status
T1 Cross-tenant read/write (Spoofing/Tampering) Forged or missing tenant identity in any data call TenantContext validated at construction; keys derived only from ctx; store has no raw-key API; adversarial fuzz suite is merge-gated Mitigated at primitive layer; API edge closed for GET /me (ADR-0024) — HTTP API's HttpUserPoolAuthorizer verifies the Cognito JWT before the Lambda runs, and the handler trusts only requestContext.authorizer.jwt.claims.sub, nothing else, as identity. Every future route repeats this shape; each one lands verified, not assumed, as it's added
T2 Repudiation of sensitive reads (Repudiation) Support/admin access to user data Break-glass admin role is audit-logged (AD control-plane design); impersonation deliberately not built Design committed, lands with control plane (M3)
T3 Credential theft from vault (Information disclosure) DB dump / log leakage KMS envelope encryption, plaintext never stored or logged, masked API views (****tail) Cipher boundary defined (ADR-0006); KMS impl at M4 — open
T4 Denial of wallet (DoS/economic) One user exhausts shared Bedrock budget Per-user daily/monthly caps checked pre-invocation under a platform ceiling (tenancy/budget.py) Mitigated at primitive layer
T5 Elevation via config injection (Tampering/Elevation) Malicious rubric/profile content steering the model Untrusted-content fencing + sanitization; shadow-replay gate before activation (ADR-0009/0015) Mitigated at prompt layer; red-team corpus extension tracked (M8)
T6 Tenant data residue after deletion (Information disclosure) Incomplete GDPR delete Delete path = crypto-erase of user data key + row purge (ADR-0007); DSR runbook tested before GA Open until exercised end-to-end (M9 gate)
T7 Billing state tampering / replayed webhooks (Spoofing/Tampering) Forged or duplicated billing events flipping plans Signature-verifier protocol gates entry; per-event-id idempotency in capped history; transitions auditable with source+timestamp (ADR-0017) Mitigated at handler layer; live Stripe signature checks land with real integration (M3-live)
T8 Due-work scan surface used from outside the dispatcher role (Information disclosure/Elevation) A user-facing handler calls DueWorkIndex.due_before directly instead of going through the dispatcher Deliberately outside TenantRepository/entities.ENTITIES; documented as the one narrow exception (ADR-0018); row shape is a fixed dataclass carrying only user id/work kind/key/next-due/miss-count — structurally cannot carry posting content, credentials, or settings even if misused Mitigated by row-shape constraint; open: ApiStack (ADR-0024) has exactly one route today (GET /me) and it does not touch DueWorkIndex — re-verify this line every time a new route is added, don't assume it stays true
T9 SSRF via a malicious ATS board id (Tampering/Information disclosure) A watchlist entry's ats_board_id gets formatted into a request URL; an attacker-controlled value could try to redirect the request internally poll.adapters.base.validate_board_id fails closed on anything outside [A-Za-z0-9_-]{1,100} before it ever reaches a URL template; poll.http.get_json additionally refuses any non-https:// scheme regardless — two independent checks, not one Mitigated; property-tested indirectly via the adapter test suite's malformed-input cases (tests/test_poll_adapters.py)
T10 Malformed or adversarial model output silently becoming a fabricated ScoreResult (Tampering/Information disclosure) BedrockScorer's prompt asks for a specific JSON shape; a model can return prose, a wrong type, an out-of-range value, or (in principle) attacker-influenced JD text steering the response shape, not just the scoring judgment T5 already covers Every field validated before constructing a ScoreResult — coerced only from safe numeric types, range-checked, non-empty rationale required; a hallucinated facet id is dropped as noise, everything else fails closed with MalformedModelResponseError rather than degrading to a guessed score (ADR-0029) Mitigated at the parsing boundary; open: no live model has ever produced these failure modes against this code — the test suite covers documented LLM structured-output failure patterns, not an observed adversarial corpus (red-team corpus extension already tracked under T5 for M8)
T11 Token theft via XSS in the SPA (Information disclosure/Elevation) web/'s access/ID tokens live in localStorage (ADR-0030) — any script-injection vulnerability in the app would let an attacker read them and impersonate the user The PKCE flow itself has no client secret to steal (ADR-0022's design point); no CSP, output-encoding audit, or dependency-injection review has been done on web/ yet Open: this is a real, un-mitigated gap, not a resolved one — a CSP header and an XSS-focused review belong in M8 hardening (roadmap), the same bucket the red-team prompt-injection corpus (T5) is tracked under; do not treat localStorage token storage as safe by default

Open items carried forward

  • KMS-backed Cipher implementation + key rotation drill (M4).
  • AuthZ middleware verifying Cognito JWT → TenantContext on every route (M3), with negative tests — must also confirm no route reaches DueWorkIndex.due_before (T8).
  • DPIA refresh covering real-user PII flows (M2.7 completion gate before beta).
  • Store now has a real DynamoDB adapter (ADR-0023), reusing the tenant-isolation adversarial suite against a moto-mocked table. DueWorkIndex still has no adapter — its GSI-backed cross-tenant scan (T8) is a different access pattern the Store protocol doesn't cover, and the real SQS lane queues behind Dispatcher's output are still open (M4 infra).
  • IAM least-privilege scoping on ApiStack's Lambda: it currently gets read/write on the whole TenantTable, not scoped to the users/entitlements entities GET /me actually touches (ADR-0024's consequences). Needs an IAM condition on the sk prefix, or per-entity tables.
  • stage/prod ApiStack CORS is an empty allow-list until a real web origin exists to name (ADR-0024) — expected, not a bug, but means those environments can't be browser-called yet.
  • pipeline.run_for_org (ADR-0025) still passes StubScorer by default in every test; nothing wires a real caller to construct and pass BedrockScorer (ADR-0029) instead, and no live Bedrock call has ever been made from this codebase — the budget ledger (T4, tenancy/ budget.py) must be checked pre-invocation by whoever does that wiring, since BedrockScorer itself has no budget dependency (the same decoupling every cross-service seam here keeps). notify.render/ses_sender.deliver (ADR-0028) close the "nothing sends" gap for single-posting immediate alerts only — a batched digest email doesn't exist yet, and deliver's from_email/to_email aren't wired to any real per-user resolution (profile/settings lookup, AD7's staged sender-domain plan) yet either. scheduled_poll.py (ADR-0027) connects DueWorkIndex/Dispatcher to the pipeline, but nothing invokes that chain unattended — no EventBridge/Lambda trigger exists, only tests call it today.
  • web/ (ADR-0030) has never run against a real Cognito user pool or deployed API — whether HttpUserPoolAuthorizer actually accepts the ID token api.ts sends as the bearer (rather than requiring the access token) is an unverified assumption, documented as such in the code, not confirmed. T11 (token-theft-via-XSS) has no mitigation built yet, only a documented gap.