From 5c3950290975153084d264ec5a782c57453548c8 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 10 Sep 2026 11:40:42 -0700 Subject: [PATCH 1/3] Add an organization-wide security policy Tracked as T91. Details in the private security repository. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D5xdKhPXJT4HMWwUyENiu1 --- SECURITY.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cb0de5e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,48 @@ +# Reporting a security issue + +This is the organization-wide policy. It applies to every Morelitea repository +that does not carry its own `SECURITY.md`. + +## How to report + +Email **security@morelitea.com**. Please do not open a public issue. + +Include what you can: + +- what the issue is, and where +- how to reproduce it +- what an attacker gets — the part that decides how fast we move +- a suggested fix, if you have one + +## What to expect + +- Acknowledgement within 48 hours. +- An estimated timeline once we have reproduced it. +- Notice when it is resolved. +- Credit in the release notes, unless you would rather stay anonymous. + +## Which repositories this covers + +GitHub applies this policy to every Morelitea repository that has no policy of +its own. A repository-local `SECURITY.md` takes precedence and may provide a +more specific reporting route or support window. + +Reports about any non-archived repository are supported. Reports about an +archived repository are still welcome, but the resolution may be an archive +notice rather than a patch. + +## What we ask + +- Give us a chance to fix it before disclosing publicly. +- Do not access, change or keep data that is not yours while testing. +- Do not run denial-of-service tests or automated scanners against our hosted + services. Test against your own deployment — everything under `initiative` is + self-hostable, which makes that straightforward. + +## Scope + +First-party code, our deployment configuration, and our CI workflows. + +Vulnerabilities in third-party dependencies are out of scope as issues in their +own right, but we want to hear about a vulnerable transitive dependency reaching +one of our releases — that is our problem to fix even when the bug is not ours. From bb81667bd27d09f8c1fef4ba70fc8e3e98f7fcee Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 13 Sep 2026 19:33:34 -0700 Subject: [PATCH 2/3] Give this repository something that runs This pull request had zero checks. Nothing in the repository ran on a change, so the file it adds could have merged broken and nobody would have seen it -- and this file is the one GitHub shows on every repository in the organization that has no policy of its own, so broken here is broken everywhere at once. The check asserts the things that make it a policy rather than a page: a reporting route, a section telling a reporter what happens next, a scope, and relative links that resolve. Nothing about wording. Demonstrated failing rather than assumed: removing the address, the "What to expect" heading or the "Scope" heading each exits 1 naming the cause, and so does a link to a file that is not here. The unmodified file passes. Actions pinned to commits rather than tags, for the reason T59 gives, reusing the SHAs the initiative workflows already carry. No package.json: the check is one script against one file, and a dependency would be a supply chain for something that reads Markdown. Tracked as T91. --- .github/workflows/checks.yml | 26 +++++++++++++++++ scripts/check-security-policy.mjs | 46 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 .github/workflows/checks.yml create mode 100644 scripts/check-security-policy.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..25cd65b --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,26 @@ +name: Checks + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + security-policy: + name: Disclosure policy + runs-on: ubuntu-latest + steps: + # Pinned to a commit, not a tag, for the reason T59 gives: a tag moves and + # the reviewed build is not the published one. Same SHAs the initiative + # workflows already use. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 24 + # This repository has no package.json and needs none — the check is one + # script against one file, and a dependency would be a supply chain for a + # thing that reads Markdown. + - run: node scripts/check-security-policy.mjs diff --git a/scripts/check-security-policy.mjs b/scripts/check-security-policy.mjs new file mode 100644 index 0000000..aedfd61 --- /dev/null +++ b/scripts/check-security-policy.mjs @@ -0,0 +1,46 @@ +/** + * The organization-wide disclosure policy has to stay usable. + * + * This file is what GitHub shows on every repository that has no policy of its + * own, so a broken link or a missing reporting route is broken everywhere at + * once and shows up nowhere — no build consumes it, and nothing else reads it. + * + * Checks the things that make it a policy rather than a page: somewhere to + * report, a promise about what happens next, and links that resolve. + */ +import {readFileSync} from 'node:fs'; + +const text = readFileSync('SECURITY.md', 'utf8'); +const problems = []; + +// A reporting route. Without one the document is an essay. +if (!/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/.test(text) && !/security\/advisories\/new/.test(text)) { + problems.push('no reporting route: neither an email address nor a private advisory link'); +} + +// What a reporter is told to expect. The policy makes a commitment; if the +// section naming it disappears, the commitment disappears silently. +if (!/##\s*What to expect/i.test(text)) { + problems.push('no "What to expect" section — a reporter is told nothing about what follows'); +} + +// Scope. A policy without one invites testing nobody wants. +if (!/##\s*Scope/i.test(text)) { + problems.push('no "Scope" section'); +} + +// Relative links must resolve. An absolute one is somebody else's uptime. +for (const [, label, target] of text.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)) { + if (/^(https?:|mailto:|#)/.test(target)) continue; + try { + readFileSync(target.split('#')[0], 'utf8'); + } catch { + problems.push(`link "${label}" points at ${target}, which is not in this repository`); + } +} + +if (problems.length) { + console.error('SECURITY.md problems:\n' + problems.map((p) => ` - ${p}`).join('\n')); + process.exit(1); +} +console.log('SECURITY.md: reporting route, expectations, scope and links all present'); From 8e36c8424485d9eb3873d3904ad9d75719c961b1 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 17 Sep 2026 07:04:46 -0700 Subject: [PATCH 3/3] test: bind reporting route to its section A security address elsewhere in the policy does not replace the instructions a reporter needs. Validate the route inside the reporting section and exercise the check in CI. --- .github/workflows/checks.yml | 1 + scripts/check-security-policy.mjs | 20 ++++++- scripts/check-security-policy.test.mjs | 80 ++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 scripts/check-security-policy.test.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 25cd65b..8642a77 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -23,4 +23,5 @@ jobs: # This repository has no package.json and needs none — the check is one # script against one file, and a dependency would be a supply chain for a # thing that reads Markdown. + - run: node --test scripts/check-security-policy.test.mjs - run: node scripts/check-security-policy.mjs diff --git a/scripts/check-security-policy.mjs b/scripts/check-security-policy.mjs index aedfd61..235fa3a 100644 --- a/scripts/check-security-policy.mjs +++ b/scripts/check-security-policy.mjs @@ -13,8 +13,24 @@ import {readFileSync} from 'node:fs'; const text = readFileSync('SECURITY.md', 'utf8'); const problems = []; -// A reporting route. Without one the document is an essay. -if (!/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/.test(text) && !/security\/advisories\/new/.test(text)) { +function sectionBody(heading) { + const headings = [...text.matchAll(/^##\s+(.+?)\s*$/gm)]; + const index = headings.findIndex((match) => match[1].toLowerCase() === heading.toLowerCase()); + if (index === -1) return null; + const start = headings[index].index + headings[index][0].length; + const end = headings[index + 1]?.index ?? text.length; + return text.slice(start, end); +} + +// The route belongs in the reporting section. An address in background text +// does not tell a reporter where to send a vulnerability. +const reporting = sectionBody('How to report'); +if (reporting === null) { + problems.push('no "How to report" section'); +} else if ( + !/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/.test(reporting) && + !/security\/advisories\/new/.test(reporting) +) { problems.push('no reporting route: neither an email address nor a private advisory link'); } diff --git a/scripts/check-security-policy.test.mjs b/scripts/check-security-policy.test.mjs new file mode 100644 index 0000000..6ab6c46 --- /dev/null +++ b/scripts/check-security-policy.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {resolve} from 'node:path'; +import {spawnSync} from 'node:child_process'; +import test from 'node:test'; + +const CHECK = resolve('scripts/check-security-policy.mjs'); + +function check(policy) { + const root = mkdtempSync(resolve(tmpdir(), 'security-policy-')); + try { + writeFileSync(resolve(root, 'SECURITY.md'), policy); + return spawnSync(process.execPath, [CHECK], { + cwd: root, + encoding: 'utf8', + }); + } finally { + rmSync(root, {recursive: true, force: true}); + } +} + +test('accepts a reporting route inside the reporting section', () => { + const result = check(`# Security + +## How to report + +Email security@example.com. + +## What to expect + +We will acknowledge the report. + +## Scope + +First-party code. +`); + + assert.equal(result.status, 0, result.stderr); +}); + +test('rejects a policy whose reporting section was deleted', () => { + const result = check(`# Security + +The security@example.com address is used by the security team. + +## What to expect + +We will acknowledge the report. + +## Scope + +First-party code. +`); + + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stderr, /How to report/); +}); + +test('rejects a reporting section with no route of its own', () => { + const result = check(`# Security + +Contact information elsewhere: security@example.com. + +## How to report + +Please report vulnerabilities privately. + +## What to expect + +We will acknowledge the report. + +## Scope + +First-party code. +`); + + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stderr, /reporting route/); +});