Skip to content

Security: AntoPorter/TrustM365

Security

docs/security.md

Security

This document describes how TrustM365 handles sensitive data, what it stores, what it never stores, and how to harden a production deployment.


Threat model

TrustM365 stores credentials that have read (and optionally write) access to your Microsoft 365 tenant. The primary risk is unauthorised access to the TrustM365 database or .env file, which would expose encrypted tenant credentials. Mitigations are described throughout this document.

TrustM365 contacts no server other than Microsoft Graph. There is no telemetry, no licence server, no update check, and no analytics of any kind.


Credential storage

What is stored

When you register a tenant, TrustM365 stores:

Field How it is stored
Tenant ID Plaintext — not sensitive; visible in any Entra URL
Application (Client) ID Plaintext — not sensitive; visible in the Entra portal
Client Secret Value AES-256-GCM encrypted using a key that lives only in your .env

The client secret is the only sensitive credential. It is encrypted before being written to the database and decrypted in memory only at the moment a Graph API token is being acquired. It is never written to logs.

Encryption details

  • Algorithm: AES-256-GCM — authenticated encryption that protects both confidentiality and integrity
  • Key: 256-bit (64 hex chars) random key generated by npm run generate:key
  • Key storage: Your .env file or App Service application settings — never in the database
  • IV: Randomly generated per encryption operation — stored alongside the ciphertext in the DB

If the database file is exfiltrated without the encryption key, the stored secrets cannot be decrypted.

Critical: Protect your .env file. Anyone with access to both the database file and the encryption key can decrypt all stored tenant credentials. Never commit .env to version control.


Token handling

Access tokens (used to call the Graph API) are:

  • Never written to disk — held in process memory only for the duration of the API call
  • Never logged — the logger automatically redacts any field named token, secret, key, or password
  • Short-lived — MSAL Node manages token lifetime and refresh automatically
  • Scoped to app-only — the client credentials flow is used; no user identity or delegated access is involved

Network exposure

Local deployment (NODE_ENV=development)

The backend API binds to 127.0.0.1:3001 — accessible only from the same machine, not from other devices on the network. The Vite dev server (localhost:5173) proxies all /api requests to the backend internally.

CORS is restricted to localhost:5173 and 127.0.0.1:5173 only. API calls from any other origin are rejected.

Production deployments (NODE_ENV=production)

Setting NODE_ENV=production makes two changes:

  1. The backend binds to 0.0.0.0 — necessary for Docker (nginx routes to the backend container over the internal network), Azure App Service (the platform's load balancer routes external traffic to the process), and any reverse-proxy setup.

  2. CORS is relaxed — in Docker and single-App-Service Azure deployments, the frontend and backend share the same origin via nginx proxying, so the browser never makes a cross-origin request and CORS is effectively not needed. If FRONTEND_URL is set (e.g. for split-origin Azure deployments), CORS is locked to that specific origin only.

Scenario Binds to CORS
Local dev 127.0.0.1 localhost:5173 only
Docker Compose 0.0.0.0 Same origin via nginx — not applicable
Azure single App Service 0.0.0.0 Same origin via nginx — not applicable
Azure split App Services 0.0.0.0 Locked to FRONTEND_URL value

Do not run production deployments with NODE_ENV=development. The backend will bind to localhost only and will be unreachable from Docker's internal network or Azure's platform routing.

Azure App Service

HTTPS is provided automatically via the *.azurewebsites.net domain — Azure terminates TLS, no certificate management is required.

Restrict access to your organisation's IP range:

az webapp config access-restriction add \
  --name trustm365-yourorg \
  --resource-group trustm365-rg \
  --rule-name "CorporateOnly" \
  --action Allow \
  --ip-address YOUR.CORP.IP.RANGE/24 \
  --priority 100

For maximum isolation, deploy inside an Azure Virtual Network with a private endpoint. See Azure App Service VNet integration.

Docker Compose

By default the nginx container binds to 0.0.0.0:80. For production:

  1. Bind to a specific interface in docker-compose.yml:
    ports:
      - "127.0.0.1:80:80"
  2. Place a reverse proxy (nginx, Caddy, Traefik) in front with TLS termination
  3. Restrict inbound access via your host firewall or cloud security group

HTTP security headers

TrustM365 uses Helmet.js on the Express backend, which sets the following headers on all API responses:

Header Value
X-Content-Type-Options nosniff
X-Frame-Options DENY
X-XSS-Protection 0 — disabled; modern browsers handle this natively
Referrer-Policy no-referrer
Strict-Transport-Security max-age=15552000 on HTTPS deployments

The nginx container (Docker/Azure) adds X-Frame-Options: SAMEORIGIN and X-Content-Type-Options: nosniff at the frontend layer.


Least-privilege deployment

TrustM365 works with read-only permissions. If you do not need the restore feature, omit all ReadWrite permissions from the App Registration. The dashboard shows drift without displaying restore buttons.

This reduces your attack surface significantly: a compromised TrustM365 deployment with read-only permissions cannot modify your M365 tenant configuration.

Custom collectors are always read-only regardless of the App Registration's write permissions — the restore path is deliberately blocked in the collector layer.

See prerequisites.md for the complete permissions breakdown.


Logging

TrustM365 uses Pino for structured JSON logging.

Sensitive fields are automatically redacted before any log is written:

secret, token, key, password, clientSecret, encryptionKey

Log level is controlled by LOG_LEVEL in .env. Default is info. Use error in production to minimise log volume. Do not use debug in production — request bodies may be included.

Logs are written to stdout. In Docker they are captured by the container runtime. On Azure App Service they are available via Log Stream and Application Insights.


Database contents

The SQLite database (trustm365.db) contains:

  • Encrypted tenant credentials (client secrets)
  • All baseline definitions and version history
  • Live configuration snapshots
  • Drift detection results and history
  • Restore audit logs (including what was patched and when)
  • Custom collector definitions
  • Tenant overview and insights cache
  • Security check results
  • MSSP settings

Protect this file as you would a password vault.

Environment Recommendation
Local chmod 600 data/trustm365.db — restrict to the owning user
Docker Do not bind-mount the data volume to a world-readable host path
Azure App Service The /home filesystem is private to your App Service instance

Backup security

Database backups created by npm run db:backup contain the same encrypted credentials as the live database. Apply the same access controls to backup files.

Never store backups in the same location as the .env file — if both are exfiltrated together, all tenant credentials can be decrypted.


Reporting security vulnerabilities

Please do not open a public GitHub issue for security vulnerabilities.

Email the maintainer directly. Include:

  • A description of the vulnerability
  • Steps to reproduce
  • Potential impact

You will receive a response within 48 hours. Responsible disclosure is appreciated.

There aren’t any published security advisories