| T1 | Malicious or compromised domain owner reads another domain’s data (evidence, review queue, Analyst) | Capability grants for domain_owner are domain-scoped, resolved against active ownership_assignments at evaluation time (packages/shared/src/capabilities.ts, packages/server/src/kernel/authz.ts: hasCapability, domainScopeFilter). Review queue filters to owned domains and re-checks per-domain capability (packages/server/src/review/service.ts). Analyst injects tenant + domain predicates server-side; the plan cannot set or widen them, and scope violations reject fail-closed (packages/server/src/analyst/executor.ts). Tested in review.test.ts, analyst.test.ts. | Enforcement is application-layer over one shared DB; a code path that forgets the filter has full access to all rows. Domain scoping covers the exercised routes; every new route must repeat the pattern. | Repository-level mandatory scope predicates for all new modules; periodic authz-matrix tests as routes are added; row-level security when the system of record moves to PostgreSQL. |
| T2 | Overprivileged platform administrator reads content/evidence | Deliberate capability exclusion: platform_admin holds operations, connector, and scan-job capabilities but no asset.evidence.*, review.*, or analyst.* capabilities (packages/shared/src/capabilities.ts §“Deliberate exclusions”; enforced by requireCapability in each route). Excerpt reads additionally require asset.evidence.excerpt.read in the owning domain (packages/server/src/review/service.ts). | A platform admin with shell access to the host bypasses everything (reads SQLite and data/artifacts/ directly). Role assignment itself is seeded; there is no admin-grants-admin governance flow yet. | Host-level separation in the Azure design (managed identity, Key Vault, storage ACLs); audited break-glass procedure; role-assignment change workflow with audit + second person. |
| T3 | IDOR — enumerate or fetch other objects by changing IDs | Object loads are keyed by (tenant_id, id) and then capability-checked against the object’s own domain; cross-domain probes return 404, not 403, so existence does not leak (packages/server/src/review/service.ts: “Cross-domain probing gets 404”). Analyst re-resolves display values to internal IDs within the caller’s scope (packages/server/src/analyst/executor.ts). Artifact reads require the artifact’s purpose flag (packages/server/src/kernel/objectstore.ts: get). IDs are random with prefixes (packages/server/src/kernel/ids.ts), not sequential. Tested in review.test.ts (cross-domain candidate fetch). | Pattern coverage is per-route; unexercised future routes are the risk. Findings/actions listings rely on the same convention. | A shared “load-scoped-object” helper as more modules appear; fuzzing the API for ID probing as part of the acceptance suite. |
| T4 | Session forgery, fixation, or CSRF | Session cookie value is sessionId.HMAC-SHA256(secret, sessionId) verified with a constant-time compare (packages/server/src/identity/sessions.ts: parseSessionCookie). Cookies are HTTP-only, SameSite=Lax, secure in production (packages/server/src/kernel/http.ts: issueSessionCookie). Sessions expire after 8 h and support revocation. All POST/PUT/PATCH/DELETE require the per-session CSRF token in the x-fmd-csrf header (packages/server/src/kernel/http.ts). Production refuses the default session secret at boot (packages/server/src/kernel/config.ts). | HMAC key is a single env-provided secret with no rotation story. No step-up/recent-auth for sensitive actions. SameSite=Lax plus header token is solid for the slice but untested against a real cross-site deployment topology. | Entra OIDC as the production path (ADR-0004) with short-lived tokens and Conditional Access; secret rotation via Key Vault; step-up policy for approval/execution routes. |
| T5 | Prompt injection via scanned content, policy documents, or Analyst questions | Scanned text is data by construction: every prompt template wraps input in labeled <untrusted-data> delimiters with an explicit non-instruction rule (packages/server/src/aigateway/templates.ts: INJECTION_GUARD, renderUntrusted). Authorization and tenant/domain filtering live entirely outside the model: any plan — AI or deterministic — is treated as untrusted, Zod-validated against the allowlisted semantic schema, and scope-injected server-side (packages/server/src/analyst/executor.ts, packages/server/src/analyst/schema.ts). The model has no tools that mutate or fetch arbitrarily; action drafting is a separate explicitly invoked path (ADR-0009). Injection tests: hostile question and hostile fixture document (packages/server/src/analyst/analyst.test.ts §“scope enforcement and injection resistance”). | Injection resistance is proven only against the deterministic mock provider and fixture attacks; a live LLM may fail in ways the mock cannot exhibit. The guard instruction itself is not a security boundary — the executor validation is, and that holds regardless. | Re-run the injection suite against gated live providers before enabling them; add exfiltration-style fixtures (markdown links, encoded payloads); redaction of prompt inputs per provider policy (ADR-0005) when live egress is enabled. |
| T6 | Parser bombs, oversized or malformed content (DoS via extraction) | Limits enforced before parsing: the connector fetch is bounded to 5 MB (packages/server/src/scan/pipeline.ts: CONTENT_MAX_BYTES), and the extractor independently rejects any input over 25 MB and caps output at 20,000 chars (packages/server/src/intelligence/extract.ts: DEFAULT_LIMITS — maxInputBytes / maxOutputChars). Declared MIME is not trusted — magic-byte + NUL sniffing rejects disguised binaries (extract.ts: looksBinary, EXTRACTION_IMPL_VERSION). The parser set is now the text family plus DOCX/XLSX/PPTX, HTML, RTF, and best-effort born-digital PDF; OOXML decompression runs through a bounded ZIP reader (intelligence/zip.ts: maxEntries 5,000 + inflateRawSync maxOutputLength expansion cap), so a decompression-bomb surface now exists but is bounded. Scanned/image PDFs and unknown formats return an honest unsupported/failed, never a silent skip. Format and oversized-input tests in intelligence/extract.test.ts; the hard-negative fixture is exercised in intelligence/intelligence.test.ts. | Extraction runs in-process on the fetched bytes — no sandbox, no separate worker — sharing the API/worker process with source-connector reachability. The rich-format parsers (OOXML ZIP, PDF operators, RTF) are a real DoS/exploit surface: bounded, but not isolated. No OCR/image parsing exists (scanned PDFs are honest unsupported). | Move extraction into sandboxed workers (no credentials, no egress, read-only FS + bounded scratch) per handoff §10 now that rich-format parsers ship; keep tightening archive depth/entry/expansion-ratio limits; parser version already tracked per artifact (EXTRACTION_IMPL_VERSION, algo_version). |
| T7 | Action replay / duplicate execution (repeated source writes) | Three layers: (1) executor no-ops if the action is already succeeded (packages/server/src/actions/service.ts: executeApprovedAction); (2) every attempt carries the action’s idempotency key ((tenantId, idempotencyKey), action_attempts table); (3) the connector itself suppresses duplicates (packages/server/src/connectors/mock/connector.ts: executeLabelAction returns duplicate_suppressed for a seen key). Queue delivery is at-least-once with leases + heartbeats; consumers are idempotent by contract (packages/server/src/scan/queue.ts). Duplicate-delivery test in actions.test.ts (ADR-0010). | Source-side dedup is proven against the mock connector’s in-memory key set; the live Graph adapter’s idempotency behavior is unvalidated. Key set in mock is process-lifetime, which is fine for fixtures but not a durability statement. | Validate idempotency semantics against a real tenant per docs/cdx-test-runbook.md; persist executor dedup state with the same durability as the action ledger (already in SQLite). |
| T8 | Self-approval / separation-of-duties bypass | The requester can never satisfy their own approval: checked in approveAction with an explicit audit event approval_self_dealing_denied on violation (packages/server/src/actions/service.ts). Approval requires the action.approve capability, which domain_owner does not hold; remediation_approver holds approve/cancel but not draft/submit (packages/shared/src/capabilities.ts). Tested in actions.test.ts. | SoD is per-action, not per-collusion: two cooperating principals defeat it, as anywhere. Approval policy is a boolean (approval_required), not yet type/volume/classification-sensitive. | Policy-based approval matrix (action type, volume, label downgrade) per handoff §15.1; multi-approver thresholds for bulk actions. |
| T9 | Source drift — executing an approved action against changed state | Before execution, the connector re-reads the live item version and compares it to the approved snapshot; any mismatch transitions the action to drifted with an audit event and requires explicit re-preview — never silent convergence (packages/server/src/actions/service.ts: drift check, repreviewDriftedAction). Post-execution verification re-reads the source and stores an action_verifications row; executor success text alone never marks success (ADR-0010). Tested in actions.test.ts. | Drift detection is version-equality only; a same-version metadata change (where the source does not bump versions) would pass. TOCTOU window between the drift check and the write exists in principle. | Validate eTag/cTag semantics against live Graph; conditional writes (If-Match) where the API supports them. |
| T10 | Secrets or evidence leaking into logs, errors, or diagnostics | Redaction is structural, not best-effort: the logger emits only allowlisted field keys, and string values must match a conservative opaque-token pattern or are replaced with [redacted]; unknown keys are dropped and counted (packages/server/src/kernel/log.ts: SAFE_KEYS, SAFE_VALUE). Sizes are logged as bands (sizeBand). Audit details_safe_json is restricted to structured references by contract (packages/server/src/kernel/audit.ts). Dead-letter diagnostics are truncated safe strings (packages/server/src/scan/queue.ts). Unhandled errors log only the error class name (packages/server/src/kernel/http.ts: onError). No secrets in the DB — env references only (config.ts). Log-scan test per ADR-0011 (kernel/log.test.ts). | HTTP error responses include exception messages for AppError subclasses; a carelessly worded message could leak a path or name to the client (not to logs). Crash output from the runtime itself (stack traces to stderr) is not governed by the logger. | Error-message hygiene review as routes grow; structured error catalog; support-bundle allowlist schema when support tooling exists (handoff §9). |
| T11 | Cross-tenant leakage (queries, cache, index, artifacts) | Every table carries non-null tenant_id; repository helpers refuse unscoped queries (ADR-0003). Stage cache, fingerprints, search FTS rows, graph nodes/edges, artifacts, and audit are all tenant-keyed (packages/server/migrations/0001–0005, packages/server/src/kernel/objectstore.ts path layout artifacts/<tenantId>/…). Audit hash chain is per-tenant (packages/server/src/kernel/audit.ts). | Honest limitation: only one tenant is ever seeded, so isolation is enforced-by-convention but not adversarially exercised. SQLite gives no physical separation; FTS and graph projections share files. | Multi-tenant isolation tests before any second tenant exists; physical per-tenant separation decisions (DB-per-tenant vs. RLS) in the Azure design; cache keys already carry tenant — keep that invariant under review. |
| T12 | Dev identity mode reaching production | Hard startup failure: FMD_ENV=production + FMD_AUTH_MODE=dev refuses to boot (packages/server/src/kernel/config.ts: buildConfig, tested in config.test.ts). Defense in depth: production rejects dev-mode sessions even if a cookie exists (packages/server/src/kernel/http.ts: devSessionInProd). Dev persona endpoints 404 outside development (packages/server/src/identity/auth-routes.ts). Every response carries x-fmd-auth-mode; the UI shows a persistent dev banner (ADR-0004). Default session secret is refused in production (config.ts). | The lockout hinges on FMD_ENV being set correctly by the deployment; a misconfigured “production” host left as development gets no protection. The Entra sign-in path is an unimplemented 501 seam, so there is no built production authentication yet — it must be implemented before any production deployment. | Fail-closed deployment templates that pin FMD_ENV=production; build + live-verify the Entra OIDC flow (issuer/audience/nonce) on the seam before any production claim. |
| T13 | Supply-chain compromise (packages, parsers, models, images) | Deliberately tiny runtime dependency surface: hono and zod are the server’s only third-party runtime deps (packages/server/package.json); crypto, SQLite, and the test runner are Bun built-ins. bun.lock is committed. No model artifacts, containers, or auto-update channels exist in the slice. Fixture data is synthetic (fictional “Meridian Grove Holdings”); no real tenant data or credentials in the repo (verified by convention and .gitignore). | Dependency versions use ^ ranges — the lockfile pins them, but there is no vulnerability scanning, SBOM, artifact signing, or CI provenance. Bun itself is trusted implicitly. | Dependency/secret scanning in CI, SBOM generation, pinned exact versions for release builds, signed release artifacts and scanner images per handoff §19. |
| T14 | Compromised connector or AI provider credential | Exposure is minimized structurally: config stores env-var references, never secret values (config.ts *_REF indirection), and selecting a live provider/connector without a resolvable secret is an explicit BlockedError (packages/server/src/aigateway/gateway.ts: buildAiProvider; packages/server/src/connectors/registry.ts). The one live credential used to date is the read-only CDX app secret, kept out of source control (gitignored .env) and never logged (ADR-0013). Read vs. remediation Graph identities are separate config slots so a scanner credential never inherits write capability (config.ts, handoff §7.1). | A leaked env var is a full credential; the current build has no rotation, no managed identity, and the process environment is readable by the host user. Provider retention/region policies are recorded in ADR-0005 but unenforced. | Key Vault references + managed identities in the Azure deployment (infra/main.bicep); per-identity consent manifests (docs/permissions-manifest.md); revocation and rotation runbook; delete the FMD-CDX-Reader app registration to invalidate its read secret when CDX testing is done. |
| T15 | Audit tampering / repudiation | Append-only audit_events with a per-tenant SHA-256 hash chain; verifyAuditChain recomputes and reports the first broken link (packages/server/src/kernel/audit.ts). All review, Analyst, action, and authz-denied flows append audit events (http.ts onError, actions/service.ts, analyst/executor.ts). Tested in audit.test.ts. | The chain detects tampering but does not prevent it: an attacker with DB write access can rewrite the entire chain from genesis. There is no external anchor, no periodic checkpoint export, and no restricted-export control yet. | Periodic chain-head anchoring to an external write-once store (or customer SIEM) in the Azure design; audit export with access control (audit.read exists; export path is future). |
| T16 | Forged change notifications / cursor tampering / source-observation spoofing | A change-notification receiver now exists (packages/server/src/notifications/routes.ts: POST /api/webhooks/graph/notifications and /lifecycle). It is unauthenticated (Graph calls it), so trust flows entirely from a per-subscription clientState HMAC — HMAC(sessionSecret, subscriptionId), stored only as a sha256, verified in constant time before any side effect on both the notification and lifecycle paths (notifications/subscriptions.ts). A notification is treated as a hint: the pipeline reconciles by re-reading the delta feed, never trusting the notification body; a clientState mismatch is metered but not persisted (no write amplification); the Graph validation-token handshake is echoed. Cursors are persisted server-side (source_cursors) and never accepted from clients; the mock connector exercises 410 resync and duplicate delivery (scan.test.ts, ADR-0008). Observations record acquisition context and completeness — “no broad share observed” is never presented as “none exists” (pipeline.ts: upsertPermissionSet). Live subscriptions additionally require a public webhook URL (honestly blocked otherwise). Tested in notifications.test.ts (incl. clientState mismatch and missing-clientState lifecycle rejection). | clientState is derived from FMD_SESSION_SECRET; a compromise of that secret would let an attacker forge valid notifications — so it must be protected (Key Vault) with the same care as any signing key, and a mismatch only forces a re-read (no content is accepted from the body regardless). Persisted delta links/cursors are still stored in plaintext in the slice DB. | Use a dedicated subscription-signing secret distinct from the session secret; rotate it via Key Vault; encrypt persisted delta links (ADR-0008). |