Every cloud team eventually needs a quick answer to one question: is our Azure estate exposed or broken right now, and how badly?
Usually that answer comes from a mix of Defender for Cloud alerts, tribal knowledge, and a few az commands run during an incident. I wanted something continuous, so I designed a read-only dashboard. It reads Azure configuration on a timer, probes live endpoints from the internet, checks everything against rules, scores it, and streams the result to the browser.
Here's how it works and the design decisions behind it.
Start with one request path
A typical locked-down web workload on Azure looks like this:
User → Front Door (WAF) → Private Link → App Service → VNet integration → SQL Managed Instance
Tracing that path by hand with az commands is slow. It also shows how easy it is for one link in the chain to quietly drift. The usual misconfigurations in a setup like this are:
- A WAF in Detection mode, which logs attacks but doesn't block them
- Apps still reachable directly on
*.azurewebsites.net, bypassing Front Door and the WAF - VNet peerings or private endpoints in a disconnected state
- PaaS services with public network access still enabled
None of these cause errors. Everything keeps working, and that's what makes them dangerous. So the goal is to automate the trace for every app and resource, re-check it continuously, and make any gap visible.
What it checks
| Area | What is checked |
|---|---|
| App Services (Web, Functions, Logic App Standard) | The full chain per app: custom domain, TLS, HTTPS redirect, WAF attached and in Prevention mode, health probe, Private Link approved, public access locked, SCM/Kudu restricted, FTP disabled, managed identity, VNet integration with route-all, and NSG reachability to SQL MI on 1433 and 11000–11999 |
| Live probes (every 20s) | Custom domain through Front Door must answer. The direct *.azurewebsites.net URL must be blocked. DNS CNAME must point to azurefd.net. SQL MI hostname must resolve to a private IP |
| PaaS resources | Storage, API Management, Logic Apps, Redis, Azure OpenAI / AI Services, AI Search, Service Bus, Key Vault, Cosmos DB, Azure SQL, SQL MI. Checks cover public access, private endpoints, TLS, local/key auth, soft delete, purge protection |
| Network | NSG rules open to the internet, subnets without an NSG, peerings not Connected, private endpoints not Approved, private DNS zones not linked to any VNet |
The live probes are the part I'd recommend to anyone building something like this. Configuration tells you what should happen; probes tell you what does happen. An app can look locked down in ARM and still answer on its default hostname.
Architecture
┌──────────── Browser (Angular 21) ────────────┐
│ Dashboard · Apps · Resources · Network · │
│ Topology · Admin · Preferences │
└───────────────┬──────────────────────────────┘
│ HTTPS + Server-Sent Events
┌───────────────▼──────────────────────────────┐
│ Server (Node.js, zero npm dependencies) │
│ auth → Entra ID SSO + sessions │
│ collect → ARM snapshot (GET only) │
│ checks → rules & scoring │
│ probes → live HTTPS / DNS │
│ store → JSON files │
└──────┬─────────────────────┬─────────────────┘
│ read-only GETs │ GET / DNS lookups
▼ ▼
Azure Resource Manager Public internet
The server has no npm dependencies. It uses only Node's built-in http, crypto, dns and fetch. For a security tool, keeping the supply-chain surface small matters.
The loop
- Scan (every 120s): Collect VNets, NSGs, private endpoints, private DNS zones, sites and their config, SQL MI, Front Door (routes, origins, custom domains, WAF policies) and every PaaS type across the selected subscriptions. GET requests only. It never reads secrets or app settings.
- Probe (every 20s): Real HTTPS and DNS calls, max 8 concurrent, keeping the last 40 results per endpoint for latency charts.
- Evaluate: Turn the snapshot and probe results into pass / warn / fail checks, then compute scores.
- Stream: Push the new state to every open browser over Server-Sent Events. No polling and no WebSocket library.
- Remember: Store one estate-score point per minute for the trend line.
Adding a new PaaS resource type is a single entry in a checks table, which keeps it cheap to grow coverage.
Code layout
server/
├── azure.mjs ARM client: token, paging, throttling, subscription list
├── collect.mjs multi-subscription snapshot
├── resources.mjs per-resource-type checks (add a type = one entry)
├── catalog.mjs check catalog, default rules, rule validation
├── checks.mjs app chain model, network findings, scoring, topology
├── probes.mjs live HTTPS / DNS probes
├── auth.mjs Entra ID sign-in, session cookies, roles
├── store.mjs JSON persistence + settings validation
└── index.mjs HTTP server, routes, SSE, timers
web/ Angular 21 (standalone, signals, zoneless, lazy routes)
data/ git-ignored: settings, rules, history, auth config
Scoring that can't be gamed
A naive pass-rate score lies. Ninety passing low-severity checks will hide one wide-open database. So the scoring works like this:
- Every check has a severity weight: critical 10, high 6, medium 3, low 1.
- A pass earns the full weight, a warn earns 50%, and a fail earns 0.
- Score = earned ÷ possible × 100, then capped:
- at most 49 if any critical check fails
- at most 79 if any high check fails
- Category score = average across the items of that type.
- Estate score = average of the category scores, so hundreds of Logic Apps can't drown out a couple of API Management instances.
- Grades: A ≥ 90, B ≥ 75, C ≥ 60, D ≥ 40, F below that.
The caps are the key design decision. One serious gap should pull the score down hard, and no amount of passing hygiene checks should make up for it. Admins can tune weights, caps and thresholds in the UI, and scores recalculate instantly without a rescan.
SSO without a client secret
Sign-in goes through Microsoft Entra ID with an app registration configured as a single-page application, so there's no client secret to manage.
The flow:
- The server redirects to Entra with
state,nonceand a PKCE challenge, and sets a short-lived signed "flow" cookie. - After sign-in, Entra redirects back with an authorization code.
- The browser redeems the code, because Entra only allows browser redemption for SPA redirect URIs.
- The browser posts the ID token to the server, which verifies the signature (JWKS), issuer, audience, tenant, expiry, nonce and state.
- The server sets an HMAC-signed,
HttpOnly,SameSite=Laxsession cookie that lasts 8 hours and isSecureover HTTPS.
Other hardening:
- Every page and API route requires a session, apart from the sign-in routes
- Cross-origin writes are blocked
-
returnTolinks can only point inside the app, so it can't be used as an open redirect - If a client secret is ever configured, the server switches to the classic server-side flow automatically
A design point worth stressing: if an app registration doesn't require user assignment, anyone in the tenant can sign in. For a dashboard that lists your weak spots, that's not acceptable. Use a dedicated app registration with assignment required and proper app roles (for example an Admin role), or at minimum an explicit viewer allow-list.
The frontend
Angular 21 with standalone components, signals, zoneless change detection and lazy routes. The main pages:
- Dashboard: estate score and trend, critical/high failures, live outages, Front Door bypasses, top problems grouped by rule
- App Services: each app's chain shown as cards. Click a stage to filter its checks, each with its fix
- Resources: PaaS resources by type, with per-resource checks
- Topology: Front Door profiles and WAF mode, VNets → subnets → apps and private endpoints, NSG tables with flagged rules highlighted
- Admin: editable scoring rules (read-only for viewers) and scan settings
Signals and SSE pair well. The stream updates one signal, and the whole UI follows.
Hosting: where you probe from matters
| Local | Hosted | |
|---|---|---|
| Azure access | az login |
App Service managed identity with the Reader role |
| Data | local data/ folder |
/home/data (persists across restarts) |
| Size | — | One Linux App Service, Always On, single instance |
Two decisions worth calling out:
- Reader is enough. Every check reads configuration only. No data plane, no secrets.
- Keep it out of your VNets. The probes need to test reachability from the internet, the way an attacker would. Inside a VNet, private DNS gives answers that look reassuring but are misleading.
The same applies when running locally: if you're on a network whose IPs are allow-listed somewhere, those resources will look "reachable" from your machine. The probe's vantage point is part of the test.
Known limits
- The SQL MI stage shows which instances an app can reach on the network, not which database it actually uses. Confirming that would mean reading Key Vault, which is deliberately out of scope.
- Live state is held in memory, so it runs as a single instance.
- Only Front Door Standard/Premium is analysed, not classic Front Door.
Takeaways
- Trace one request path by hand first. It tells you exactly what to automate.
- Probe from the outside. Configuration and reality drift apart.
- Cap your scores. Severity-weighted averages still hide critical gaps unless one failure can sink the score.
- Treat a security dashboard as sensitive. It's a map of your weak spots, so lock down who can sign in.
- Zero dependencies is achievable on the server side for a tool like this, and it's worth it.
If you've built something similar, or you rely on Defender for Cloud or Azure Policy for this, I'd like to hear how you handle the live-probe side. Let me know in the comments.
Top comments (0)