diff --git a/.github/workflows/update-owasp.yml b/.github/workflows/update-owasp.yml new file mode 100644 index 0000000..4f5742d --- /dev/null +++ b/.github/workflows/update-owasp.yml @@ -0,0 +1,42 @@ +# Keeps the bundled OWASP secure headers data (json/owasp.json) up to date by +# fetching the latest upstream data on a schedule and opening a PR with the diff. +# This replaces the old runtime fetch, keeping document generation deterministic. + +name: Update OWASP Headers + +on: + schedule: + # Every Monday at 06:00 UTC + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - name: Fetch latest OWASP headers + run: npm run update:owasp + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + commit-message: "chore: update bundled OWASP secure headers" + branch: chore/update-owasp-headers + delete-branch: true + title: "chore: update bundled OWASP secure headers" + body: | + Automated update of the bundled OWASP Secure Headers data + (`json/owasp.json`) from the upstream + [www-project-secure-headers](https://github.com/OWASP/www-project-secure-headers) + project. + + Source provenance is recorded in `json/owasp.source.json`. + Please review the diff before merging. + labels: dependencies diff --git a/json/owasp.json b/json/owasp.json index 7b59a4c..ea677d4 100644 --- a/json/owasp.json +++ b/json/owasp.json @@ -1,5 +1,5 @@ { - "last_update_utc": "2026-06-23 16:23:09", + "last_update_utc": "2026-07-19 05:44:10", "headers": [ { "name": "Cache-Control", diff --git a/json/owasp.source.json b/json/owasp.source.json new file mode 100644 index 0000000..f2f37f1 --- /dev/null +++ b/json/owasp.source.json @@ -0,0 +1,8 @@ +{ + "url": "https://raw.githubusercontent.com/OWASP/www-project-secure-headers/refs/heads/master/ci/headers_add.json", + "repo": "OWASP/www-project-secure-headers", + "path": "ci/headers_add.json", + "branch": "master", + "commit": "0bdb31eeda228b04a2fc712a6aa89105cc3efa84", + "last_update_utc": "2026-07-19 05:44:10" +} diff --git a/package.json b/package.json index 8727e24..b55ac82 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "serverless openapi" ], "scripts": { - "test": "mocha --config './test/.mocharc.js'" + "test": "mocha --config './test/.mocharc.js'", + "update:owasp": "node scripts/update-owasp-headers.js" }, "author": { "name": "Jared Evans" diff --git a/scripts/update-owasp-headers.js b/scripts/update-owasp-headers.js new file mode 100644 index 0000000..5190583 --- /dev/null +++ b/scripts/update-owasp-headers.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +"use strict"; + +/** + * Refreshes the bundled OWASP Secure Headers data at `json/owasp.json`. + * + * This runs out-of-band (locally via `npm run update:owasp` or on a schedule in + * CI) rather than at document-generation time, so that the plugin never depends + * on a live third-party URL when a user builds their docs. The fetched commit + * SHA is recorded in `json/owasp.source.json` for provenance. + * + * Usage: node scripts/update-owasp-headers.js + */ + +const fs = require("fs"); +const path = require("path"); +const https = require("https"); + +const OWNER = "OWASP"; +const REPO = "www-project-secure-headers"; +const BRANCH = "master"; +const FILE_PATH = "ci/headers_add.json"; + +const RAW_URL = `https://raw.githubusercontent.com/${OWNER}/${REPO}/refs/heads/${BRANCH}/${FILE_PATH}`; +const COMMITS_API = `https://api.github.com/repos/${OWNER}/${REPO}/commits?path=${encodeURIComponent( + FILE_PATH +)}&sha=${BRANCH}&per_page=1`; + +const OUTPUT_FILE = path.join(__dirname, "..", "json", "owasp.json"); +const SOURCE_FILE = path.join(__dirname, "..", "json", "owasp.source.json"); + +/** + * @param {string} url + * @returns {Promise} + */ +function get(url) { + return new Promise((resolve, reject) => { + https + .get( + url, + { + headers: { + // GitHub's API rejects requests without a User-Agent. + "User-Agent": "serverless-openapi-documenter-owasp-updater", + Accept: "application/vnd.github+json", + }, + }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + reject( + new Error(`Request to ${url} failed with status ${res.statusCode}`) + ); + return; + } + + const data = []; + res.on("data", (chunk) => data.push(chunk)); + res.on("end", () => resolve(Buffer.concat(data).toString())); + } + ) + .on("error", reject); + }); +} + +async function main() { + console.log(`Fetching OWASP secure headers from ${RAW_URL}`); + const raw = await get(RAW_URL); + + // Validate it parses before we overwrite the bundled copy. + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed.headers)) { + throw new Error( + "Unexpected upstream format: expected a `headers` array in the response" + ); + } + + let sha = null; + try { + const commits = JSON.parse(await get(COMMITS_API)); + sha = commits[0]?.sha ?? null; + } catch (err) { + console.warn(`Could not resolve source commit SHA: ${err.message}`); + } + + // Re-serialize so the on-disk formatting is stable regardless of upstream + // whitespace, which keeps CI diffs meaningful. + fs.writeFileSync(OUTPUT_FILE, `${JSON.stringify(parsed, null, 2)}\n`); + + const source = { + url: RAW_URL, + repo: `${OWNER}/${REPO}`, + path: FILE_PATH, + branch: BRANCH, + commit: sha, + last_update_utc: parsed.last_update_utc ?? null, + }; + fs.writeFileSync(SOURCE_FILE, `${JSON.stringify(source, null, 2)}\n`); + + console.log(`Wrote ${OUTPUT_FILE}`); + console.log(`Wrote ${SOURCE_FILE} (commit: ${sha ?? "unknown"})`); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/src/owasp.js b/src/owasp.js index 9012223..1d61729 100644 --- a/src/owasp.js +++ b/src/owasp.js @@ -1,7 +1,5 @@ "use strict"; -const https = require("https"); - const defaultOWASP = require("../json/owasp.json"); /** @@ -91,39 +89,17 @@ class OWASP { }; } + /** + * Populates the OWASP header defaults from the bundled `json/owasp.json`. + * + * The bundled file is kept up to date out-of-band by the `update:owasp` + * script and its scheduled GitHub Action, which fetch the latest upstream + * OWASP Secure Headers project data and open a PR with the diff. This keeps + * document generation deterministic and offline-capable rather than + * depending on a live third-party URL at runtime. + */ async getLatest() { - const headerJSON = await new Promise((resolve, reject) => { - const req = https - .get( - "https://raw.githubusercontent.com/OWASP/www-project-secure-headers/refs/heads/master/ci/headers_add.json", - (res) => { - let data = []; - - if (res.statusCode !== 200) { - resolve(defaultOWASP); - } - - res.on("error", (err) => { - resolve(defaultOWASP); - }); - - res.on("data", (chunk) => { - data.push(chunk); - }); - - res.on("end", () => { - resolve(JSON.parse(Buffer.concat(data).toString())); - }); - } - ) - .on("error", (err) => { - resolve(defaultOWASP); - }); - - req.end(); - }); - - this.populateDefaults(headerJSON); + this.populateDefaults(defaultOWASP); } /** diff --git a/test/unit/owasp.spec.js b/test/unit/owasp.spec.js index a7e73e7..4ae385b 100644 --- a/test/unit/owasp.spec.js +++ b/test/unit/owasp.spec.js @@ -1,7 +1,6 @@ "use strict"; const expect = require("chai").expect; -const nock = require("nock"); const owasp = require("../../src/owasp"); @@ -10,11 +9,7 @@ const newOWASPJSON = require("../json/newOWASP.json"); describe(`owasp`, function () { describe(`getLatest`, function () { - it(`populates the defaults from the included OWASP release when the online version can not be reached`, async function () { - nock("https://raw.githubusercontent.com") - .get("/OWASP/www-project-secure-headers/refs/heads/master/ci/headers_add.json") - .reply(404, {}); - + it(`populates the defaults from the bundled OWASP release`, async function () { await owasp.getLatest().catch((err) => { console.error(err); expect(err).to.be.undefined; @@ -31,16 +26,11 @@ describe(`owasp`, function () { ).to.be.equal(permissionsPolicyDefault[0].value); expect(Object.keys(owasp.DEFAULT_OWASP_HEADERS).length).to.be.equal(13); }); + }); - it(`populates the defaults with information from a new OWASP release`, async function () { - nock("https://raw.githubusercontent.com") - .get("/OWASP/www-project-secure-headers/refs/heads/master/ci/headers_add.json") - .reply(200, newOWASPJSON); - - await owasp.getLatest().catch((err) => { - console.error(err); - expect(err).to.be.undefined; - }); + describe(`populateDefaults`, function () { + it(`populates the defaults with information from an OWASP release`, function () { + owasp.populateDefaults(newOWASPJSON); expect( owasp.DEFAULT_OWASP_HEADERS["Cross-Origin-Embedder-Policy"] @@ -55,18 +45,11 @@ describe(`owasp`, function () { expect(Object.keys(owasp.DEFAULT_OWASP_HEADERS).length).to.be.equal(13); }); - it(`adds any properties contained in a new release`, async function () { + it(`adds any properties contained in a new release`, function () { const newOWASPJSONAdded = structuredClone(newOWASPJSON); newOWASPJSONAdded.headers.push({ name: "x-added", value: "true" }); - nock("https://raw.githubusercontent.com") - .get("/OWASP/www-project-secure-headers/refs/heads/master/ci/headers_add.json") - .reply(200, newOWASPJSONAdded); - - await owasp.getLatest().catch((err) => { - console.error(err); - expect(err).to.be.undefined; - }); + owasp.populateDefaults(newOWASPJSONAdded); expect(owasp.DEFAULT_OWASP_HEADERS).to.have.property("x-added"); expect(owasp.DEFAULT_OWASP_HEADERS["x-added"]).to.have.property("schema");