diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12dc458..c896c5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,8 +102,19 @@ jobs: --exclude ".gitignore" \ --exclude "index.html" \ --exclude "contact.html" \ + --exclude ".well-known/*" \ --cache-control "public, max-age=31536000, immutable" + # security.txt (RFC 9116) must stay updatable — it carries an Expires + # date that has to be refreshed before it lapses, so it must NOT ride + # the immutable one-year cache above. It is excluded from the sync + # (and therefore untouched by --delete, same as the HTML pages) and + # copied separately with a short TTL and an explicit text/plain type. + aws s3 cp .well-known/security.txt \ + s3://${{ vars.S3_BUCKET }}/.well-known/security.txt \ + --content-type "text/plain; charset=utf-8" \ + --cache-control "public, max-age=3600" + # HTML pages must not be cached so users always get the latest. # Cache-bust the immutable CSS/JS: append ?v= to each local # asset ref so returning visitors fetch fresh assets every release diff --git a/.well-known/security.txt b/.well-known/security.txt new file mode 100644 index 0000000..094cd1e --- /dev/null +++ b/.well-known/security.txt @@ -0,0 +1,11 @@ +# security.txt for edulab.appalachiancloud.co (RFC 9116) +# Appalachian Cloud Engineering — LessonsAtHome / edu-lab +# +# If you have found a security issue in this site, please report it to the +# contact below. We aim to acknowledge reports within 5 business days. +# Please do not run automated scans that degrade service for other users. + +Contact: mailto:cgallagher@keengeek.com +Expires: 2027-08-31T00:00:00.000Z +Preferred-Languages: en +Canonical: https://edulab.appalachiancloud.co/.well-known/security.txt diff --git a/AGENTS.md b/AGENTS.md index 28269c7..647db3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ - Unlike Boss BOS, edu-lab uses **AES256** (not KMS) with no origin-request policy and no `aws:RequestedRegion` condition, so it does not need those fixes. - **Asset caching / cache-busting (#16, fixed):** CSS/JS are uploaded `immutable, max-age=1yr` on **unversioned filenames**, so CloudFront's `/*` invalidation alone does *not* refresh **returning browsers**. The deploy fixes this by rewriting the HTML pages (`index.html` + `contact.html`, a loop in `ci.yml`'s "Sync static site to S3" step) at upload time to append `?v=` (`${GITHUB_SHA::8}`) to each local `.css`/`.js` ref; the HTML pages themselves stay `no-cache`. **Do not** reference CSS/JS from the HTML in a way the `sed` can't version (keep refs as bare local `name.css`/`name.js`), don't drop the versioning step — without it, CSS/JS changes are invisible to repeat visitors until hard-refresh — and **any new top-level `.html` page must be added to both the sync `--exclude` list and that loop**. - **S3 sync is a whole-repo publish — the `--exclude` list is the ONLY thing keeping repo internals off the public site.** The "Sync static site to S3" step runs `aws s3 sync . s3://…`, so *anything* not excluded (or not caught by the trailing `index.html` special-case) is served at the site root. The exclude list must cover every tooling/internal directory — `.git/*`, `.github/*`, `.claude/*`, `terraform/*`, `tests/*`, `node_modules/*`, plus `*.md`/`LICENSE`/`.gitignore`. `.claude/*` was **missing** and briefly exposed `/.claude/settings.json` + `/.claude/hooks/guardrail.py`; adding it plus the existing `--delete` purges those objects on the next `main` deploy. **When you add a new top-level dir that shouldn't ship, add its `--exclude` in the same PR.** +- **`security.txt` is the one path deliberately excluded from the sync *and* still published (RFC 9116).** `.well-known/security.txt` is in the sync's `--exclude` list yet is uploaded by its own `aws s3 cp` right after, with `--content-type "text/plain; charset=utf-8"` and `--cache-control "public, max-age=3600"`. Two reasons it cannot just ride the sync: the sync stamps `immutable, max-age=1yr`, and the file carries an **`Expires` date (currently `2027-08-31T00:00:00.000Z`) that must be refreshed before it lapses** — an expired security.txt is treated as invalid by scanners, and a year-long immutable cache would outlive the fix. **Renew it before 2027-08-31**, in lockstep with the copies on the other estate domains (matrix + shared date live in `mountain-infra/AGENTS.md`). Before this existed the path returned CloudFront's SPA fallback — HTTP 200 with `index.html` — so **verifying it means asserting `text/plain` and a `Contact:` line, not just a 200**. - **⚠️ `enable_custom_domain` MUST stay `true` (fixed 2026-07-06):** this two-phase toggle gates the CloudFront custom cert + alias + `TLSv1.2_2021`. It now **defaults to `true`** — the launched steady state. It was previously `false`, and because there is **no committed `terraform.tfvars`**, every `terraform plan/apply` silently wanted to revert the live site to the default `*.cloudfront.net` cert + `TLSv1` + no alias (i.e. take HTTPS down on `edulab.appalachiancloud.co`). The launch had set it `true` only *transiently* (a `-var`), which never persisted. **Guardrail: never hold a persistent infra state in a transient `-var` or an un-committed tfvars — bake the steady state into the committed default.** Only flip to `false` for a genuine from-scratch re-bootstrap (ACM cert not yet Issued). ## 📝 Documentation & change discipline diff --git a/CHANGELOG.md b/CHANGELOG.md index beb6586..381f20b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- **`security.txt` (RFC 9116)** — `.well-known/security.txt` now publishes a + security contact for `edulab.appalachiancloud.co`. Before this the path + returned CloudFront's SPA fallback (HTTP **200** with `index.html`), so a + scanner saw a "success" that was really HTML — a real object is what fixes + that. `ci.yml` excludes `.well-known/*` from the immutable `aws s3 sync` and + copies the file separately with `text/plain; charset=utf-8` and + `max-age=3600`, because the file carries an **`Expires` date that must be + refreshed before 2027-08-31** and must not be pinned behind a one-year + immutable cache. Estate-wide rollout — see `mountain-infra/AGENTS.md` for the + full domain matrix and the shared renewal date. - **Contact page (`contact.html` + `contact.js`)** — Turnstile-protected form POSTing (urlencoded, no preflight) to the shared Appalachian Cloud contact relay in mountain-infra (honeypot + server-side `siteverify` + SES; inbox diff --git a/DEPLOY.md b/DEPLOY.md index bae9b0f..52f4d76 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -224,6 +224,14 @@ The push to `main` triggers `.github/workflows/ci.yml`: > Keep asset references as bare local filenames (`name.css`/`name.js`) so the > `sed` in the deploy step can version them. +> **`security.txt` (RFC 9116):** `.well-known/security.txt` is `--exclude`d from +> the sync and uploaded by its own `aws s3 cp` with `text/plain; charset=utf-8` +> and `max-age=3600` — it must stay updatable because it carries an `Expires` +> date (**renew before 2027-08-31**), which a one-year immutable cache would +> outlive. Verify with `curl -sI` **and** check the body: the path returned a +> 200 even before the file existed (CloudFront's SPA error mapping serves +> `index.html`), so status alone is a false pass. + Watch it: ```bash