Docs

Security review

Dated review (history). The review itself, sections 1–6 and the findings, is a snapshot from 2026-10-12 against cb694e5; the status table and the checklist in section 7 describe where things stand now.

Review of the Marshal console, the Windows agent and the Linux proxy (2026-10-12), and the fixes made since (agent and console 0.6.0). It covers how each part talks to the others, what protects those links, what an attacker could do, and what was done about it. Sections 1–5 describe the code as reviewed (line references are to commit cb694e5, before the fixes); the status table below says where each finding stands now.

Status after 0.6.0

FindingStatus
F1Signing key online, in the internet-facing containerFixed. Builds are signed during the image build from a BuildKit secret (agent/tools/sign); the key is in neither the image nor ./data. The console pins the public key and refuses builds without a valid signature. Each container mounts only its own key folder; the agent API has no vault key and no signing key.
F2Catalogue poisoning across customersFixed. Two-customer agreement (update_reports); supersedence from details that aren’t verified declines updates only for the customer that reported them; details differ shown in the catalogue; the update page lists what approving it declines, and for whom. The revision cap became unnecessary: a high revision from one customer changes nothing.
F3Re-enrollment takes over an approved serverFixed. Exact name match; a reattach goes back to pending (unless the token auto-approves), agent.reenrolled audit entry and an enrolled again notification. Proxies the same.
F4Pinned one-liner accepts a forged chainFixed. The one-liner pins the agent API’s public key (checked against the live key). TLS 1.2 stays for the PowerShell part, as Windows before Server 2022 has no TLS 1.3; with the key pinned, the version doesn’t affect authenticity. Windows check: TESTING.md 8.1.
F5Web port reachable around nginx, client IPs can be fakedFixed, differently than planned. Trusting forwarded headers by address doesn’t work: on Docker hosts with the userland proxy (like the test host) every client arrives from the network gateway. So nginx now sends a shared secret (PROXY_SECRET), and only requests carrying it may set the client address and scheme. UI port on 127.0.0.1 by default, TLS + HSTS nginx sample, __Host- Secure cookie, COOKIE_SECURE.
F6Local privilege escalation on WindowsFixed. Rollback XML in the protected data folder under a unique name; the data folder is owned by SYSTEM, its permissions are applied to everything in it, and the agent refuses to start if they aren’t right. Windows check: TESTING.md 5.3, 8.3.
F7Open proxy cache, uplink floodingFixed. The cache serves only the files the console lists (and only to approved proxies and allowed networks), TLS 1.3; UI warning for proxies without allowed networks.
F8No Authenticode signatureReady, needs a certificate. The build signs the exe and the MSI with osslsigncode as soon as CODESIGN_PFX_FILE (and its password) is given.
F9Downgrades possibleFixed. The signature covers a manifest (product, platform, version, sha256, allow_downgrade); agents and proxies refuse older versions unless signed as a rollback.
F10Corporate proxy credentials leakFixed. Encrypted with a key derived from the install token; redacted in API answers, check-ins, the database and logs; existing rows cleaned at start.
F11Too much open on the agent port, body limit bypassFixed. Streaming 32 MB limit (chunked too); MSI and proxy binary only with a token or a certificate; 600 requests/min per address on open routes; refused enrollments audited at most 10 per 10 min per address. Its own database role (marshal_agent): no users, sessions or notification secrets, append-only audit log.
F12CSV formula injectionFixed.
F13Token hygieneFixed. Auto-approve admin-only, 1 day by default, optional use limit, “treat it like a password” in the UI and scripts. The token still appears on the msiexec command line (MSI properties have no other way in); it is single-purpose and short-lived.
F14TLS settingsFixed: TLS 1.3 for agents, proxies and the proxy cache; TLS 1.2 only with ECDHE + AEAD. Open: separate issuing CAs, at the next CA rotation.
F15Sign-in detailsFixed: password change ends other sessions; 20 failed attempts per address per 15 min. Open: a breached-password check; linking Entra accounts by object ID only.
F16BackupsFixed: dumps encrypted with age (BACKUP_AGE_RECIPIENT). Operator: copy ./backups off the host.
F17Webhook replayFixed: X-Marshal-Timestamp + X-Marshal-Signature-V2; the old header stays for existing receivers.
F18Supply chainFixed: Python packages installed with --require-hashes, base images pinned by digest, deploy/scan.sh (pip-audit, npm audit, govulncheck). The first scan found pyjwt 2.14.0 (two advisories): upgraded to 2.15.0. Open: SBOM.
F19Smaller itemsFixed: a job’s log is capped at 2 MB. Accepted: vault entries not bound to their row (needs database write access already); setup token in the container log.
F20react-router 6.30 (found by the scan)Accepted for now: two moderate advisories (an open redirect when navigating to a URL from untrusted input, and an issue in server-side rendering). Marshal only navigates to fixed internal paths and renders in the browser, so neither applies. Upgrade to v7 as routine maintenance.

Verified on the test stack: the backend (78) and agent tests pass; the agent API refuses TLS 1.1 and non-AEAD ciphers and negotiates TLS 1.3; the MSI isn’t downloadable without a token; a chunked 100 MB upload gets 413; the agent API’s role can’t read users or the audit log; a forged X-Forwarded-For is ignored; a Docker proxy installed through the new hash-checked script enrolled, served only listed files, fetched nothing for unknown hashes, refused addresses outside its allowed networks and TLS 1.2, and removed itself; the private signing key is in no image layer; an unsigned build is refused. Still open: the Windows checks in TESTING.md (sections 5, 7.3 and 8).

Summary

The architecture is sound. Marshal manages Windows servers without opening any port on them and without storing any Windows password. Each agent dials out to the console over mutual TLS, with a certificate from Marshal’s own CA. The agent trusts only that CA, pinned at install. Every request is checked against the database, so a removed server is locked out at once. The agent can only do a fixed set of things (scan, install approved updates from Microsoft, reboot), never run a script. Agent updates are signed and roll back on their own if the new version fails. Updates themselves come straight from Microsoft and are signed by Microsoft.

What needs fixing is a small number of implementation gaps, not the design. The four that matter most:

RiskWho could do itEffort
F1The key that signs agent updates sits on the console, in every container, including the internet-facing one. Whoever takes over the console can push code to every server as SYSTEM.Someone who breaks into the console host or its image buildS (quick part), M (offline signing)
F2One agent can rewrite the update catalogue that all customers share, and so get real security updates declined for every customer.Anyone holding a valid install token for any customerM
F3A rogue install can take over an existing, approved server record without a new approval, and then report it as fully patched.Anyone with an install token and the server’s MachineGuid (not secret)S
F4The pinned PowerShell install one-liner may accept a forged certificate. A network attacker could then run their own script as admin during install. To confirm on the Windows test VM.An attacker on the network path during an installS

Everything else is medium or low: hardening of the web port, two local privilege-escalation paths on Windows, an open proxy cache, leaked corporate-proxy credentials, code signing for the MSI, and smaller items. Section 5 lists them all.

1. Trust boundaries

flowchart LR
  subgraph Admins
    B[Browser]
  end
  subgraph Console host
    N[nginx<br/>TLS for the web UI]
    W[Web app + API<br/>:8081]
    A[Agent API<br/>:8443 mTLS]
    K[(data/keys<br/>vault key, CA key,<br/>signing key)]
    DB[(Postgres)]
    WK[Worker]
  end
  subgraph Customer network
    P[Marshal proxy<br/>:8443 forward, :8444 cache]
    E[Windows server<br/>Marshal agent]
  end
  MS[Microsoft<br/>Windows Update, Entra, Graph, MSRC]

  B -- HTTPS --> N -- HTTP --> W
  W --- DB
  WK --- DB
  A --- DB
  W -.-> K
  WK -.-> K
  A -.-> K
  E -- mTLS, outbound only --> A
  E -- mTLS through blind TCP --> P -- TCP --> A
  E -- HTTPS hash-checked files --> P
  E -- WUA --> MS
  W -- HTTPS --> MS

The boundaries that matter: the internet → agent API (unauthenticated until the client certificate is checked), the browser → web app, the customer network → proxy, and any customer → the shared catalogue (data one customer’s agents send ends up in what other customers see).

#LinkTodayAssessment / better option
P1Browser → nginx → web appnginx is the operator’s. The sample deploy/nginx-marshal.conf listens on HTTP :80. nginx → app is plain HTTP on a port published on 0.0.0.0:8081. uvicorn trusts forwarded headers from any address (--forwarded-allow-ips '*', Dockerfile:58).Bind the app to 127.0.0.1 and trust forwarded headers from nginx only. Ship a TLS sample with HSTS and an 80→443 redirect. Always mark the session cookie Secure in production, with a __Host- prefix. (F5)
P2Agent ↔ agent API :8443HTTPS with mutual TLS, terminated by the app itself. Private CA (ECDSA P-256), pinned by its SHA-256. TLS 1.2 or newer.Very good. We write both ends, and Go has its own TLS stack, so TLS 1.3 only works even on Server 2019. Set AEAD-only ciphers for the 1.2 fallback the PowerShell installer needs. (F14)
P3Install one-liner (PowerShell)Net.WebClient with a validation callback that checks the certificate’s CN and the root’s thumbprint. Or, when allowed, irm <public web URL> | iex over the public PKI.Bug F4: the callback ignores chain errors. Pin the agent API’s public key (it is kept across renewals; the proxy installer already pins it) and accept no chain error other than “untrusted root”. Prefer the pinned form over the public-PKI form.
P4MSI downloadHTTPS with the Marshal CA pinned, then a SHA-256 check of the file.Good. Add Authenticode (F8).
P5Agent self-updateDownloaded over mTLS, or from the proxy cache. Ed25519 signature over the file’s SHA-256.Right idea, weak key custody: the signing key is online in every container (F1). The signature doesn’t cover the version, so an older signed build can be pushed (F9).
P6Endpoint → proxy :8443Blind TCP forward; the endpoint’s TLS ends at the console.Good: the proxy cannot read or change traffic. Allowed networks (CIDRs) are optional, and empty means anyone (F7).
P7Endpoint → proxy cache :8444HTTPS with the proxy’s certificate, no client authentication; files are checked by hash and signature.File integrity is fine. But the cache answers anyone and fetches any hash it is asked for, so it can be used to flood the customer’s uplink and the console (F7).
P8Proxy → corporate HTTP proxyCONNECT, with Basic auth in clear text to the corporate proxy. The proxy URL with its credentials is stored in plaintext, shown to every role and logged.Keep the credentials in the vault and redact them everywhere (F10). Basic auth towards a corporate proxy is normal; documented.
P9Proxy installcurl --pinnedpubkey (pin of the agent API key) + --cacert + SHA-256 check, then sudo sh.Good. The Docker variant (curl … | docker load) has no hash check; add one.
P10Inside the consolePostgres on the Compose network (password, no TLS, not published). Wake-ups via LISTEN/NOTIFY.Fine on one host. Give the agent API its own database role with only the rights it needs (phase 2).
P11Console → Microsoft and chat toolsAll HTTPS. Entra sign-in: authorization code + PKCE, state, nonce, JWKS, issuer, audience and tenant checked. Graph mail with a client secret. Webhooks must be HTTPS, with an optional HMAC.Good. For Graph, prefer a certificate credential to a client secret. Add a timestamp to the webhook HMAC against replays (F17).
P12Agent → Microsoft Windows UpdateWindows Update Agent (COM); content signed by Microsoft.Correct: Marshal never handles update files itself.
P13BackupsNightly pg_dump in plaintext to ./backups on the same host. The key folder is kept out of it on purpose (good).Encrypt the dumps (age or gpg) and copy them off the host (F16).

3. What is already done well (checked in the code)

  • No inbound ports and no stored credentials for Windows. No WinRM, PSRP, Kerberos or NTLM. The agent only dials out.
  • Certificates. The console sets the certificate subject itself and ignores what the agent asks for; weak key types are refused; usages are split (agents: client only; console: server only) (pki.py:193). The app checks the certificate again after TLS, so a TLS misconfiguration can’t let a foreign certificate in.
  • Instant revocation. Every request compares the certificate’s serial with the database (agents.py:316). Removing or re-enrolling a server locks the old agent out at once.
  • The agent trusts only Marshal. It pins the CA’s fingerprint and checks the console’s name and server usage (client.go:104). A proxy or another agent cannot pose as the console.
  • Fixed verbs only: scan, install, reboot. No scripts, no commands (execute.go). Updates are picked by their Windows Update ID and installed from Microsoft.
  • Signed self-update with automatic rollback. The install token is hidden from the MSI log and deleted from the registry on first start.
  • Web sign-in: argon2 password hashes, mandatory TOTP with replay protection, a password-only session can do nothing until MFA, only a hash of the session token is stored, 2 h idle / 12 h absolute timeouts, SameSite=Strict cookie plus a custom-header CSRF check, a strict Content-Security-Policy, no framing, a one-time setup token for the first admin.
  • Customer limits enforced in every API, returning 404 so other customers can’t be probed. Full audit log. Install tokens masked in the access logs.

4. Decisions for the fixes

  • Update signing moves off the console. Releases are signed when they are built, on the build machine or with a CI secret. The console only stores the signatures and refuses unsigned builds. The signature covers a manifest (product | platform | version | sha256), and agents refuse older versions unless the manifest is a signed rollback. The result: taking over the console no longer means code execution on every server.
  • The shared catalogue needs two customers to agree. New or changed update metadata (title, classification, severity, supersedes) takes effect only when servers of two different customers report it the same way. One rogue customer can no longer change what everyone sees or what gets declined.

5. Findings

Severity is the impact if exploited, combined with how reachable it is. Every finding has a fix in section 6; what was done is in the status table at the top.

High

F1. The update-signing key is online, and the internet-facing service holds it. All three app containers mount ./data, which holds the vault key, the CA key and the update-signing key (docker-compose.yml). The agent API loads the private signing key (agent_api/server.py:29), but only ever hands out the public key. The console also signs whatever build is in its image at startup (releases.py:49), so a tampered image build would be signed automatically. Scenario: an attacker who takes over the agent API process (the one exposed to the internet), or slips something into the image build, signs their own “agent update”. Every server installs it and runs it as SYSTEM. Fix: quick, the agent API reads only a public-key file, and each container mounts only the keys it needs. Proper, offline signing (section 4).

F2. One agent can poison the catalogue that all customers share. Scan results from any approved agent are written into the shared updates table, and the highest revision wins. The agent picks the revision (catalogue.py:85-89). Approving an update automatically declines the updates it says it supersedes (approvals.py:37). Scenario: an attacker enrolls a server with any customer’s token. It reports this month’s real cumulative update with a huge revision and a supersedes list naming real security updates. When operators approve the cumulative update for their customers, those security updates are declined for all of them. Titles, classification and severity can be falsified the same way. Fix: two-customer agreement (section 4). Also cap revision jumps, and show the supersedes list in the approval dialog.

F3. Re-enrolling takes over an approved server without approval. A reinstall on “the same machine” reattaches to the existing record. Same machine means the same MachineGuid, which the agent itself reports and which is not a secret (agents.py:270-299). The record keeps its approved status, and the real agent is locked out. The name lookup uses ilike, so % and _ in a hostname act as wildcards. Scenario: someone with a token and a server’s MachineGuid (readable by any local user, and in most inventory tools) enrolls a fake agent as that server. It reports “fully patched”, the real server drops out of patching, and the dashboard stays green. Fix: an exact case-insensitive name match. A reattach goes back to pending unless the token auto-approves. Always raise an audit entry and a notification (“server re-enrolled from IP x”).

F4. The pinned install one-liner may accept a forged certificate. To confirm on the Windows test VM. The PowerShell callback checks the certificate’s CN and that the last chain element’s thumbprint is the Marshal CA’s. It ignores the validation errors Windows reports (agents.py:146). Windows builds chains by matching names, so a forged certificate with the right CN, sent together with the (public) Marshal CA certificate, should produce a chain ending in the Marshal CA with “signature not valid”. The callback would accept it. Scenario: an attacker on the network path during an install serves their own script, which runs as administrator. Fix: pin the agent API’s public key, as the proxy installer already does, and accept no chain error other than “untrusted root”, as the full install script already does (agents.py:185). Allow TLS 1.3 where Windows supports it.

Medium

F5. The web app can be reached around nginx, and client IPs can be faked. Port 8081 is published on all interfaces (docker-compose.yml:44), and uvicorn trusts X-Forwarded-* from anyone (Dockerfile:58). Anyone on the LAN can talk to the app in plain HTTP, forge the IP addresses in the audit log, and make the session cookie lose its Secure flag. The sample nginx config is HTTP only, with no HSTS. Fix: bind to 127.0.0.1 by default; trust forwarded headers only from configured addresses (default 127.0.0.1); a TLS nginx sample with HSTS; the cookie always Secure in production, named __Host-marshal_session.

F6. Two paths for a local user to become SYSTEM on a Windows server. This matters on Remote Desktop and multi-user servers. (a) The rollback task’s XML is written to a fixed name in C:\Windows\Temp, where any user can create files (update_windows.go:55). A planted or swapped file becomes a scheduled task that runs as SYSTEM. (b) The agent locks down C:\ProgramData\Marshal with icacls, but leaves the folder’s owner and the files already in it alone (service_windows.go:105). A user who creates that folder (or state.json.tmp) before the install keeps control of state.json: the console address, CA pin and signing key. They can then point the agent at their own console, which sends it a “signed” malicious update. Fix: write the XML to a uniquely named file in the protected data folder; make SYSTEM the owner of the whole folder tree and apply the restricted permissions recursively; refuse to start if the owner is anyone else.

F7. The proxy cache answers anyone and can be used to flood the uplink. Anyone who reaches port 8444 can ask for /cache/<any hash>?src=<a release path>, and the proxy downloads the whole file from the console for every new hash, in parallel (cache.go:62). The allowed networks only apply to the forwarder, and an empty list means everyone may forward. Fix: the console sends the proxy the list of hashes it may serve, and the cache refuses everything else. Apply the allowed networks to the cache too. Warn in the UI when a proxy has no allowed networks.

F8. The agent MSI and exe carry no Authenticode signature. AppLocker and WDAC can’t allow it by publisher, EDR and SmartScreen treat it as unknown, and an admin can’t tell where it came from. Fix: a code-signing certificate (public, or from the internal AD CS for internal-only use), applied during the build (osslsigncode works in the Linux build).

F9. Update signatures don’t cover the version, so downgrades are possible. The signature covers the file’s hash only (pki.py:273), and the Windows agent installs any signed build it is offered. The proxy refuses older versions (update.go:176); the agent doesn’t. Scenario: whoever controls the console or its database rolls agents back to an old signed build with a known bug. Fix: the signed manifest from section 4, with downgrade protection.

F10. Corporate proxy credentials leak. A proxy’s outbound proxy setting (which can be http://user:password@host) is stored in plaintext in two tables, returned by the API to every role including viewers (api/proxies.py:43), echoed back at every proxy check-in, and written to the proxy’s log at startup (proxy.go:108). Fix: keep the credentials in the vault; strip them from API answers, check-ins and logs.

F11. More is reachable without a certificate than needs to be, and the body size limit can be bypassed. Enrollment, the MSI, the proxy binaries and the health check are open to the internet. The 32 MB body limit only looks at Content-Length (agent_api/app.py:44), so a chunked upload has no limit (memory exhaustion before any authentication). There is no rate limiting. Fix: enforce the size limit while reading the body; rate-limit enrollment and install routes per IP; serve the MSI and proxy binary only under a valid install token, so scanners can’t fetch builds or read off the version.

F12. CSV exports allow formula injection. Hostnames and update titles come from agents (see F2, F3) and go unchanged into CSV files opened in Excel (reports.py:141). A hostname like =HYPERLINK(...) becomes a live formula. Fix: prefix cells starting with =, +, -, @, tab or CR with '.

F13. Install tokens live long and are easy to over-grant. Tokens can be reused for 7 days. Operators (not only admins) can create tokens that approve servers automatically (enrollment.py:78). The token appears on the msiexec command line, which EDR and process auditing record. Fix: auto-approve for admins only; 24 h default lifetime; an optional limit on uses; a note in the UI that the script is a credential.

Low

  • F14. TLS settings. Set the agent API’s minimum TLS version and ciphers explicitly (agent_api/server.py), and use TLS 1.3 for the Go agent and proxy. One CA signs the console’s certificate, the agents’ and the proxies’ (proxy certificates are also server certificates, for the cache). Name checks keep them apart today. At the next CA rotation, use separate issuing CAs for servers and clients.
  • F15. Sign-in details. Changing your own password doesn’t end your other sessions (auth.py:211; an admin reset does). Lockout is per account only, so anyone can lock an admin out for 15 minutes; add a per-IP limit. The password rule is length only; add a check against common and breached passwords. Entra accounts are linked on first sign-in by preferred_username, which users can sometimes change (auth.py:261); prefer linking by object ID only, or an admin-confirmed link.
  • F16. Backups are unencrypted and on the same host. Encrypt them and copy them elsewhere.
  • F17. Webhooks. The HMAC has no timestamp header, so a captured call can be replayed. Admins can point webhooks at internal HTTPS addresses (accepted: admin-only).
  • F18. Supply chain. Python packages are pinned by version but not by hash; base images by tag, not digest. Add hash pinning, pip-audit, npm audit, govulncheck and an SBOM to the build.
  • F19. Smaller items. An agent can keep appending to a job’s log through progress updates (agents.py:420); cap the total. Vault entries aren’t bound to their row, so a database writer could swap two encrypted values (needs DB write access already). The first-admin setup token is printed to the container log (accepted: it is single-use and only valid until the first admin exists).

6. Roadmap (as planned; built in 0.6.0, see the status table)

Phase 1: quick wins (small, low-risk changes)

FindingChangeWhere
F4Pin the agent API’s public key in the one-liner; reject any chain error other than “untrusted root”; allow TLS 1.3.backend/app/services/agents.py (one_liners)
F3Exact name match; a reattach goes back to pending; audit entry and notification.backend/app/services/agents.py (enroll)
F1 (quick)The agent API reads only agent-signing.pub; separate key folders per container.security/pki.py, agent_api/server.py, docker-compose.yml
F5Bind to 127.0.0.1; trust forwarded headers from configured addresses only; TLS nginx sample with HSTS; Secure + __Host- cookie.Dockerfile, .env.example, deploy/nginx-marshal.conf, deps.py
F6Rollback XML in the data folder; folder owner set to SYSTEM; permissions applied recursively; refuse to start otherwise.agent/internal/agent/update_windows.go, agent/cmd/marshal-agent/service_windows.go
F10Proxy credentials in the vault, redacted in the API, check-ins and logs.api/proxies.py, services/proxies.py, agent/internal/proxy/proxy.go
F7Hash allowlist pushed to the proxy; allowed networks applied to the cache; UI warning.services/proxies.py, agent/internal/proxy/cache.go
F11–F13, F15Streaming body limit; CSV escaping; auto-approve admin-only, 24 h tokens; end other sessions on password change.agent_api/app.py, api/reports.py, api/enrollment.py, api/auth.py

Phase 2: architecture

  • F1 + F9: offline signing with a signed release manifest and downgrade protection (agent, proxy, tools/add_release).
  • F2: two-customer agreement for catalogue metadata, and the supersedes list shown when approving.
  • F8: Authenticode signing in the build. F14: TLS 1.3 for agents and proxies; separate issuing CAs at the next CA rotation.
  • F11: token-gated package downloads and rate limits. A separate, least-privilege database role for the agent API.
  • F16 / F18: encrypted off-host backups; hash-pinned dependencies and vulnerability scanning in the build.

How each fix is checked

  • Backend tests (pytest) and agent tests (go test ./...) gain cases for F3, F4, F7, F10, F12, F13 and F15.
  • On the test stack: re-enrolling with a forged MachineGuid, or the hostname %, is refused or lands in pending with a notification. Port 8081 can’t be reached from another host. A chunked 100 MB body to /agent/v1/enroll gets 413. The proxy cache refuses an unknown hash without fetching anything.
  • On a Windows test VM: the new one-liner still installs. Against a man-in-the-middle with a forged certificate plus the real CA certificate, the old one-liner is expected to fail (this confirms F4) and the new one must refuse. Self-update still works, with the rollback task created from the data folder. A normal user pre-creating C:\ProgramData\Marshal can’t keep control of it.

7. Operator hardening checklist

For whoever deploys Marshal in production, independent of the code fixes:

  • Web UI only through nginx with TLS (HSTS on); BIND_ADDRESS=127.0.0.1.
  • Only the agent port (8443) reachable from customer networks or the internet; the web UI only from the admin network.
  • nginx passes 8443 through untouched (stream module). It never terminates that TLS.
  • data/keys backed up offline, separately from the database dumps, and readable only by the service user.
  • Database dumps encrypted and copied off the host.
  • Every proxy has allowed endpoint networks set.
  • Install tokens short-lived; auto-approve only for test rings; revoke tokens once a rollout is done.
  • Entra sign-in with Conditional Access (MFA, compliant device) for admins; local accounts only for break-glass.
  • Notification secrets (Graph, webhooks) scoped to the minimum: Graph Mail.Send limited to the sender mailbox with an application access policy.