Skip to content

🔒 Security & Governance Audit

Date: 18 April 2026
Duration: ~3 hours (6:05 AM – 9:33 AM NZST)
Model: Claude Opus 4.6 (1M context)
Approach: 10 parallel audit agents + manual deep-dive + rubber-duck validation
Scope: 53 JS files (~1.5MB), 3 API functions, 8 repos, 50+ Hugo templates, MkDocs learning portal


Why This Was Done

AI-coded products are increasingly targeted for vulnerabilities. As the site grew to 46 tools with complex JS, 3 API endpoints, 6 pipeline repos, and a private learning portal — a comprehensive security audit was overdue. This session audited every nook and corner of the ecosystem.


Audit Methodology

Pass 1 — 6 Parallel Agents (different specialisations)

Agent Focus Files Checked
JS XSS Audit innerHTML, eval, URL params, localStorage, race conditions 53 JS files
API & Secrets CORS, auth, rate limiting, leaked credentials, git history 3 API functions + repo root
Pipeline Security GitHub Actions SHA pinning, PAT exposure, permissions scope 10+ workflow files across 6 repos
Headers & Deps CSP, HSTS, SRI hashes, CDN pinning, CORS config staticwebapp.config.json + all templates
Data & Privacy TOML injection, privacy claims accuracy, form security All templates + JS
Learning Portal MkDocs config, content exposure, internal data classification Full learning-docs repo

Pass 2 — 5 More Agents (different perspectives)

Agent Perspective
Defensive Review "How would someone abuse these APIs?"
Data Flow Tracing Trace every external input → DOM sink
Verify Fixes Are pass-1 fixes actually correct? Regressions?
Privacy Compliance GDPR/NZ Privacy Act, cookies, consent, data inventory
Rubber Duck Independent validation of all findings + priority ranking

Findings Summary

Severity Found Fixed
🔴 Critical 4 4 ✅
🟠 High 13 13 ✅
🟡 Medium 13 13 ✅
🟢 Low 1 1 ✅
Total 31 31 ✅

Plus: 5 repos made private, customer research docs moved to local-only.


Critical Findings & Fixes

C1: /api/stats had CORS * — anyone could scrape analytics

Before: Access-Control-Allow-Origin: * — any website could call the stats API and read GA4 page views, YouTube subscribers, GSC search queries, bio link clicks.

Fix: Restricted to https://www.aguidetocloud.com. Added rate limiting (30 req/min per IP). Sanitised all 7 error message paths to return generic errors only.

File: functions/api/stats.js

C2: Feedback API exposed user emails in public GitHub Discussions

Before: When users submitted feedback with an email, it was embedded directly in the public GitHub Discussion body: **Email:** user@example.com.

Fix: Emails are never published. The Discussion body now shows *Reply contact provided via form* instead. Server-side max length limits added (subject 200, message 5000, name 100, email 254).

File: functions/api/feedback.js

C3: Learning portal .env committed to git

Before: learning-docs/.env containing Azure tenant ID and subscription ID was committed in the initial commit and not in .gitignore.

Fix: Added .env to .gitignore, removed from git tracking with git rm --cached.

C4: Customer research docs in learning portal repo

Before: docs/customer-research/ contained Microsoft-confidential MBIE and Corrections NZ customer preparation documents.

Fix: Moved to local-only folder (C:\ssClawy\customer-research-private), removed from git and mkdocs.yml nav.


High-Priority Findings & Fixes

XSS Vulnerabilities (3 files)

deptime.js — Deprecation Timeline item IDs and urgency classes were injected raw into onclick handlers and CSS class names. A poisoned data file could break out of attributes and inject script.

Fix: Added safeId() (strips non-alphanumeric), safeClass() (whitelist of valid urgency values), and URL validation (/^https?:\/\// check before inserting into href). Applied to cards, timeline, AND modal (pass 2 caught the modal was missed in pass 1).

site-analytics.js — YouTube API response top.title was inserted into innerHTML without sanitisation. A malicious video title could execute XSS.

Fix: Wrapped in esc(). Grade field whitelisted to valid values only.

pomodoro.jsObject.assign(S.settings, JSON.parse(r)) merged localStorage JSON directly into settings, allowing prototype pollution via __proto__ keys.

Fix: Whitelist-only merge — only copies keys that exist in DEFAULTS.

Supply Chain Hardening

  • SRI hashes added to all 6 CDN scripts (Chart.js, Mermaid, qr-code-styling, JSZip, jsQR, html2canvas)
  • GitHub Actions SHA-pinned across all 6 repos (16 action references): actions/checkout@11bd7190, actions/setup-python@0b93645e, actions/setup-node@49933ea5, peaceiris/actions-hugo@75d2e847, actions/github-script@60a0d830, azure/login@a65d910e
  • PAT removed from git clone URLs in all 5 pipeline repos — now uses http.extraheader pattern
  • Learning portal mkdocs-material pinned to version 9.6.12 (was unpinned)

CSP Tightening

Change Before After
script-src unsafe-inline + unsafe-eval unsafe-inline only (eval removed)
img-src https: (any HTTPS) Specific domains only
frame-ancestors Not set 'self'
Permissions-Policy camera, mic, geo only + interest-cohort, payment, usb, bluetooth
style-src 'self' 'unsafe-inline' + https://fonts.googleapis.com
font-src 'self' + https://fonts.gstatic.com

Feedback API Origin Check

Before: origin.includes('aguidetocloud.com') — bypassable with evil-aguidetocloud.com.

After: Exact URL match against allowed origins array. Empty/null origin rejected.

Template Parameter Injection

Two category templates injected Hugo front matter params into JS strings without escaping:

window.__ainewsCategoryFilter = "{{ .Params.category_filter }}";

Fix: Changed to {{ .Params.category_filter | jsonify }} (proper JSON encoding).


Privacy & Compliance

Privacy Policy Page

Created /legal/ with comprehensive policy covering:

  • What's collected (GA4, Clarity, feedback, localStorage, HIBP)
  • Cookie inventory with types (analytics/functional/necessary)
  • Third-party service table with privacy policy links
  • NZ Privacy Act 2020 rights
  • How to opt out (ad blocker, browser settings)
  • Data retention periods
  • Children's data statement

Every page footer now includes: "This site uses anonymous analytics (Google Analytics & Microsoft Clarity) to improve content. No personal data is collected. Privacy Policy"

GA4 IP Anonymisation

Added anonymize_ip: true to GA4 config for additional privacy protection.


Repository Visibility

All pipeline repos made private to hide implementation details:

Repo Before After
-ainews 🌐 Public 🔒 Private
m365-roadmap 🌐 Public 🔒 Private
service-health 🌐 Public 🔒 Private
cert-tracker 🌐 Public 🔒 Private
m365-deprecation-timeline 🌐 Public 🔒 Private
learning-docs 🔒 Private 🔒 Private (already)
aguidetocloud-revamp 🌐 Public 🌐 Public (intentional)

What Was Verified CORRECT (Pass 2)

The verify-fixes agent confirmed:

  • ✅ No eval(), new Function(), document.write() in any JS file
  • esc() XSS helper consistently used across most tools
  • ✅ No secrets in git history (credentials.json never committed)
  • ✅ Service worker is conservative (skips /api/, /data/, HTML)
  • ✅ All CDN scripts version-pinned to exact versions
  • crossorigin="anonymous" on CDN scripts
  • ✅ localStorage writes wrapped in try/catch in most tools
  • ✅ YouTube video ID validation with regex before embedding
  • ✅ No URL params reach DOM sinks without sanitisation
  • ✅ No open redirects found
  • ✅ No postMessage without origin checks

Remaining Manual Action

Item Action Priority
Cloudflare Access for /cc/ CF Zero Trust → Access → Add self-hosted app → domain www.aguidetocloud.com, path /cc/ → Allow your email only 🟠 High

The current client-side password gate (SHA-256 hash comparison) can be bypassed via DevTools (sessionStorage.setItem('cc-ok', '1')). Cloudflare Access provides real server-side authentication for free.


Lessons Learned

  1. esc() is great but not enough — inline onclick handlers need safeId() too, and CSS classes need whitelisting. DOM construction is safer than string interpolation.
  2. CORS * on analytics APIs is the #1 risk — even though "it's just analytics", competitors can monitor your traffic, top content, and growth patterns.
  3. In-memory rate limiters on CF Workers are weak — Map() resets on cold starts and doesn't share across isolates. For real protection, use Cloudflare WAF rate limiting rules.
  4. Hugo reserves "privacy" as a namespace — you cannot create content/privacy.md because Hugo's [privacy] config section conflicts. Use a different slug (e.g., legal.md).
  5. Email in public GitHub Discussions = PII leak — even with a "submissions are public" warning, users don't read it. Never include email in public content by default.
  6. Pipeline repos should be private — they contain API prompts, dedup logic, feed URLs, and authentication patterns. Zero downside to making them private.
  7. origin.includes() is not security — substring matching for origin validation is trivially bypassable. Use exact URL comparison.
  8. Second-pass audits catch what first-pass misses — the deptime.js modal XSS was only caught in pass 2 because pass 1 fixed cards/timeline but didn't check the modal rendering path.

Commits

# Message Files Repo
1 Security audit: fix CORS, XSS, CSP, SRI, origin checks 16 aguidetocloud-revamp
2 Security: update HIBP privacy disclosure 1 aguidetocloud-revamp
3 Security: pin all GitHub Actions to commit SHA 5 aguidetocloud-revamp
4 Security pass 2: PII protection, modal XSS, error sanitization 3 aguidetocloud-revamp
5 Security: add .env to .gitignore, remove from tracking 2 learning-docs
6 Security: remove customer research docs, pin deps, SHA-pin Actions 5 learning-docs
7 Security: pin actions to SHA, remove PAT from URLs, scope permissions 1 each 5 pipeline repos
8 Add privacy policy page + footer analytics disclosure 4 aguidetocloud-revamp

Maintenance Checklist

After this audit, the following should be checked periodically:

  • [ ] Monthly: Run smoke-test.ps1 — covers core pages + assets
  • [ ] Quarterly: Re-audit innerHTML usage in any NEW JS files
  • [ ] On new tool build: Follow post-build security audit checklist (18 items in instructions)
  • [ ] On CDN version bump: Regenerate SRI hashes
  • [ ] On Action version bump: Update SHA pin + version comment
  • [ ] On PAT rotation: Update PERSONAL_PAT in all 6 repos