Playwright Security Scan

Headed-browser audit of every domain in the WholeTech fleet — security headers, TLS, screenshots, console errors. One command, ~2 minutes, A-through-F grade per site.

1. What it does

A single Node script (scan.mjs) drives a real Chromium tab with Playwright through every domain in the GoDaddy CSV export. For each domain it captures:

The output is a self-contained dark-mode HTML report: thumbnail grid, color-coded by grade, sortable mentally at a glance. Use it as a fleet-wide snapshot or as a diff target after config changes.

2. Prerequisites

Node.js
v18+ (script uses ESM imports + native fetch)
Playwright
Installed via npm install — pulls Chromium browser bundle
GoDaddy CSV
Domain list at ~/Downloads/domainexport_*.csv — first column is the domain name
Outbound HTTPS
No auth required, only port 443 to each domain

3. How to run

Install (first time only)

cd /var/www/wholetech.com/playwright
npm install
npx playwright install chromium

Run the scan (headed — visible browser)

node scan.mjs

A Chromium window opens and walks each domain. Watch it go, or minimize and let it run. ~2 minutes for 102 domains.

Run headless (faster, no UI)

HEADLESS=1 node scan.mjs

Use a different domain list

SCAN_CSV=/path/to/your-list.csv node scan.mjs
Tip: Run headed once a quarter — it's satisfying to watch and catches visual regressions a HEAD-only scanner misses. Run headless from cron for daily diffs.

4. Reading the output

Two files land in ./out/ on every run, timestamped:

report-YYYY-MM-DD-HH-MM-SS.html
Visual grid — open in any browser
report-YYYY-MM-DD-HH-MM-SS.json
Same data, machine-readable for diffs
shots/<domain>.png
One screenshot per scanned domain

The summary bar at the top shows total scanned, OK count, error count, average header score, and the grade distribution (A / B / C / D / F). Each card shows a thumbnail, HTTP status, TLS protocol, page title, and a row of six pills — green if the header is present, red if missing.

5. The 6 graded headers

HeaderWhat it does
Strict-Transport-SecurityForces browsers to use HTTPS only
Content-Security-PolicyWhitelist of script/style/image sources — XSS defense
X-Frame-OptionsBlocks the page from being iframed (clickjacking defense)
X-Content-Type-OptionsStops MIME-type sniffing
Referrer-PolicyControls what's leaked to outbound links
Permissions-PolicyDisables browser features (camera/mic/geolocation/etc.) per page

Score = % present. A 90%+   C 50%+   F below 30%.

6. Fixing low grades

Fleet-wide fix (most sites jump to A)

The shared include /etc/nginx/conf.d/performance.conf applies to every server block that doesn't define its own add_header. Add headers there once, every static site benefits.

add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' https: data: blob:; img-src * data: blob: https:; frame-ancestors 'self'; base-uri 'self'" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;

Then: nginx -t && systemctl reload nginx

Important nginx behavior: if a server or location block has any add_header directive, all parent add_header directives are nullified for that block. To keep inheritance, those server blocks need every header re-declared, or the more_set_headers directive (ngx_headers_more module) which doesn't have this rule.

Per-site fix (the F-grade outliers)

If a site is grading F despite the shared include, it means the server block defines its own add_header. Open the site's config:

nano /etc/nginx/sites-available/<domain>

Add the full security-headers block at the top of the server { } block, or remove the conflicting add_header so the inherited ones apply.

Domains served elsewhere

Some F-grade results are domains pointed at GoDaddy parking (AWS IPs like 76.223.54.146) instead of the droplet. Headers can't be added remotely — either point DNS at the droplet and create a server block, or accept the grade for parked domains.

7. Extending the scan

8. Troubleshooting

Browser closes immediately
Re-run npx playwright install chromium
"page.goto: net::ERR_INVALID_AUTH_CREDENTIALS"
Site has basic auth — expected on gated origins (e.g. walhus.com)
TLS check returns ENOTFOUND
Domain is parked or DNS not configured — check dig +short <domain>
Scan hangs on one domain
30s timeout per page should catch it; if stuck, check the visible browser tab
All grades stuck at C
The shared include applies but specific headers are missing — see section 6