From 2d332feb4f2631754d3b0b179646490e1f7dddd2 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Mon, 3 Aug 2026 14:04:07 +0400 Subject: [PATCH] test(website): fail CI before security.txt expires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9116 makes Expires mandatory, and past that date the file is invalid rather than merely stale — it keeps serving 200 while scanners treat it as unusable. Nothing renewed it: website/ has no build step and the repo has no scheduled workflow, so the date would have passed in silence and left a worse signal than shipping no security.txt at all. Graduated alarm instead of relying on memory: > 90 days passes silently 90-30 days passes, warns in CI output < 30 days fails, naming the file and the fix expired fails, reporting how many days ago > 1 year fails — blocks "renewing" it by setting a date in 2099 Verified against all four future states by moving the date, not just the current one. Also corrects the README: production confirmed Cloudflare Workers assets does upload dot-directories, so the "verify .well-known deploys" caveat is resolved. Notes the residual gap honestly — the guard only fires when CI runs, so a long-quiet repo could still let the date slip. --- .../__tests__/websiteSecurityTxt.test.ts | 82 +++++++++++++++++++ website/README.md | 41 +++++++--- 2 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 frontend/src/config/__tests__/websiteSecurityTxt.test.ts diff --git a/frontend/src/config/__tests__/websiteSecurityTxt.test.ts b/frontend/src/config/__tests__/websiteSecurityTxt.test.ts new file mode 100644 index 00000000..434f642c --- /dev/null +++ b/frontend/src/config/__tests__/websiteSecurityTxt.test.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * RFC 9116 makes `Expires` mandatory, and a security.txt past that date is + * *invalid* — not merely stale. It keeps serving 200 while every scanner and + * researcher tooling treats it as unusable, so the failure is silent and the + * file ends up worse than not shipping one at all. + * + * website/ has no build step and the repo has no scheduled workflow, so nothing + * else would ever notice the date passing. These assertions are the alarm: they + * fail loudly while there is still plenty of time to act. + * + * To renew: bump `Expires` in website/.well-known/security.txt to one year out + * and re-check that the Contact URL still accepts reports. + */ +const here = path.dirname(fileURLToPath(import.meta.url)); +const securityTxt = path.resolve(here, '../../../../website/.well-known/security.txt'); + +const DAY_MS = 24 * 60 * 60 * 1000; +const WARN_WITHIN_DAYS = 90; +const FAIL_WITHIN_DAYS = 30; + +const fields = (): Record => { + const out: Record = {}; + for (const line of readFileSync(securityTxt, 'utf8').split('\n')) { + const m = line.match(/^([A-Za-z-]+):\s*(.+?)\s*$/); + if (m) out[m[1]!] = m[2]!; + } + return out; +}; + +const daysUntilExpiry = (): number => + Math.floor((new Date(fields().Expires!).getTime() - Date.now()) / DAY_MS); + +describe('website security.txt', () => { + it('carries the fields RFC 9116 requires', () => { + const f = fields(); + expect(f.Contact, 'Contact is mandatory').toBeTruthy(); + expect(f.Expires, 'Expires is mandatory').toBeTruthy(); + expect(new Date(f.Expires!).toString()).not.toBe('Invalid Date'); + }); + + it('points Canonical at the path it is actually served from', () => { + expect(fields().Canonical).toBe( + 'https://accessflow.bablsoft.com/.well-known/security.txt', + ); + }); + + it('has not expired', () => { + const days = daysUntilExpiry(); + expect( + days, + `security.txt EXPIRED ${Math.abs(days)} days ago — it is invalid, not just old. ` + + `Bump Expires in website/.well-known/security.txt to one year out.`, + ).toBeGreaterThan(0); + }); + + it(`does not expire within ${FAIL_WITHIN_DAYS} days`, () => { + const days = daysUntilExpiry(); + if (days <= WARN_WITHIN_DAYS && days > FAIL_WITHIN_DAYS) { + // Early nudge: still passing, but the clock is visible in CI output. + console.warn( + `[security.txt] expires in ${days} days — renew it soon ` + + `(this test starts failing at ${FAIL_WITHIN_DAYS} days).`, + ); + } + expect( + days, + `security.txt expires in ${days} days. Renew it now: set Expires in ` + + `website/.well-known/security.txt to one year from today, and confirm the ` + + `Contact URL still accepts reports.`, + ).toBeGreaterThan(FAIL_WITHIN_DAYS); + }); + + it('does not claim validity more than a year out', () => { + // RFC 9116 §2.5.5 recommends under a year; scanners flag longer windows. + expect(daysUntilExpiry()).toBeLessThanOrEqual(366); + }); +}); diff --git a/website/README.md b/website/README.md index dfeccd23..f6049618 100644 --- a/website/README.md +++ b/website/README.md @@ -235,21 +235,38 @@ maintainer is the strongest version of this signal — security reviewers look f ### security.txt `.well-known/security.txt` (RFC 9116) points researchers at GitHub private vulnerability -reporting. Two things about it need a human: +reporting. -1. **`Expires` is mandatory and self-destructs.** It is set to `2027-08-03`. Past that date - the file is *invalid*, not merely old — a stale security.txt is a worse signal than none. - Push the date out (and re-check the contact URL still works) at least annually. -2. **Confirm it actually deploys.** `.well-known` is a dot-directory, and static hosts vary - in whether they upload hidden paths. Cloudflare's docs do not state their behaviour - either way, so after the next deploy verify: +Cloudflare Workers assets **does** upload dot-directories — verified in production, the file +returns 200 with `content-type: text/plain`. No workaround needed. - ```bash - curl -sSI https://accessflow.bablsoft.com/.well-known/security.txt - ``` +### Renewing it - If that 404s, the file is being skipped at upload and needs a non-hidden workaround - (or an explicit include) — it is not doing anything until that returns 200. +`Expires` is mandatory under RFC 9116, and once that date passes the file is *invalid*, not +merely old: it keeps serving 200 while scanners and researcher tooling treat it as unusable. +That failure is completely silent, which makes an expired security.txt a worse signal than +none at all. + +Nothing here renews it automatically, so +[`frontend/src/config/__tests__/websiteSecurityTxt.test.ts`](../frontend/src/config/__tests__/websiteSecurityTxt.test.ts) +is the alarm: + +| Time to expiry | Behaviour | +|---|---| +| > 90 days | passes silently | +| 90 → 30 days | passes, prints a renewal warning in CI output | +| < 30 days | **fails CI** with the file path and what to change | +| expired | **fails CI**, reporting how many days ago | +| set > 1 year out | **fails CI** — RFC 9116 §2.5.5 recommends under a year, and this blocks "fixing" it by setting a date in 2099 | + +To renew: set `Expires` to one year from today **and** confirm the `Contact` URL still +accepts reports — the date is a claim that the contact information is current, so bumping it +without checking is the thing this guard exists to discourage. + +**Residual gap:** the guard only fires when CI runs. That is often enough for an active +repo, but if development goes quiet for months the date could pass unnoticed. A scheduled +workflow that opens an issue would close that gap; the repo has no cron workflows today, so +this was left as a deliberate trade rather than new machinery. `robots.txt` allows all crawlers and points to `sitemap.xml`. `sitemap.xml` lists the two HTML pages (`/` and `/docs/`).