security: harden IPv6 origin gate + Trusted Types (trusted-types 'none') + close dev-tooling audit advisories #103
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # security-ci — enforce the APP-01 static security foundation. | |
| # | |
| # Evidence is bound to both the source head and the actual checkout SHA. For | |
| # pull_request events the latter is GitHub's ephemeral merge commit. | |
| name: security-ci | |
| on: | |
| push: | |
| branches: ["main"] | |
| pull_request: | |
| branches: ["main"] | |
| permissions: {} | |
| concurrency: | |
| group: security-ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| csp-enforcement: | |
| name: hardened policy gate + security tests | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| env: | |
| CI: "true" | |
| SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} | |
| TESTED_SHA: ${{ github.sha }} | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: "22.23.1" | |
| cache: npm | |
| - name: verify exact Node, npm, registry and lifecycle policy | |
| run: npm run toolchain:check | |
| - name: verify frozen lockfile invariants | |
| run: npm run lockfile:check | |
| - name: deterministic install with lifecycle scripts disabled | |
| run: npm ci --ignore-scripts --audit=false --fund=false | |
| - name: dependency vulnerability gate and snapshot | |
| id: audit | |
| run: npm run audit:ci | |
| - name: preserve dependency audit snapshot even when the gate fails | |
| if: ${{ !cancelled() && steps.audit.conclusion != 'skipped' }} | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: app-01-dependency-audit-${{ env.SOURCE_SHA }} | |
| path: npm-audit.json | |
| if-no-files-found: error | |
| retention-days: 90 | |
| - name: CSP, endpoint and security-header fail-closed gate | |
| run: npm run csp:check | |
| - name: serializer, served-header and regression tests | |
| run: npm test | |
| - name: generate SPDX SBOM and evidence manifest | |
| run: | | |
| set -euo pipefail | |
| npm sbom --sbom-format=spdx > sbom.spdx.json | |
| node <<'NODE' | |
| const { execFileSync } = require('node:child_process'); | |
| const { createHash } = require('node:crypto'); | |
| const { readFileSync, writeFileSync } = require('node:fs'); | |
| const hash = (path) => { | |
| const bytes = readFileSync(path); | |
| return { | |
| path, | |
| bytes, | |
| sha256: createHash('sha256').update(bytes).digest('hex'), | |
| }; | |
| }; | |
| const sbomFile = hash('sbom.spdx.json'); | |
| const auditFile = hash('npm-audit.json'); | |
| const packageFile = hash('package.json'); | |
| const lockFile = hash('package-lock.json'); | |
| const npmrcFile = hash('.npmrc'); | |
| const sbom = JSON.parse(sbomFile.bytes.toString('utf8')); | |
| const audit = JSON.parse(auditFile.bytes.toString('utf8')); | |
| const vulnerabilities = audit.metadata?.vulnerabilities; | |
| const evidence = { | |
| schema_version: '1.2', | |
| repository: process.env.GITHUB_REPOSITORY, | |
| event_name: process.env.GITHUB_EVENT_NAME, | |
| source_sha: process.env.SOURCE_SHA, | |
| tested_sha: process.env.TESTED_SHA, | |
| workflow: process.env.GITHUB_WORKFLOW, | |
| run_id: process.env.GITHUB_RUN_ID, | |
| run_attempt: process.env.GITHUB_RUN_ATTEMPT, | |
| generated_at: new Date().toISOString(), | |
| runner: { | |
| os: process.env.RUNNER_OS, | |
| arch: process.env.RUNNER_ARCH, | |
| image_os: process.env.ImageOS || null, | |
| image_version: process.env.ImageVersion || null, | |
| kernel: execFileSync('uname', ['-srmo'], { encoding: 'utf8' }).trim(), | |
| git: execFileSync('git', ['--version'], { encoding: 'utf8' }).trim(), | |
| }, | |
| tools: { | |
| node: process.version, | |
| npm: execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(), | |
| registry: execFileSync('npm', ['config', 'get', 'registry'], { encoding: 'utf8' }).trim(), | |
| }, | |
| inputs: { | |
| package_json_sha256: packageFile.sha256, | |
| package_lock_sha256: lockFile.sha256, | |
| npmrc_sha256: npmrcFile.sha256, | |
| }, | |
| audit: { | |
| path: auditFile.path, | |
| sha256: auditFile.sha256, | |
| report_version: audit.auditReportVersion, | |
| vulnerabilities, | |
| }, | |
| sbom: { | |
| path: sbomFile.path, | |
| sha256: sbomFile.sha256, | |
| spdx_version: sbom.spdxVersion, | |
| document_name: sbom.name, | |
| package_count: Array.isArray(sbom.packages) ? sbom.packages.length : null, | |
| }, | |
| }; | |
| if (!evidence.source_sha || !evidence.tested_sha) { | |
| throw new Error('source/tested commit identity is missing'); | |
| } | |
| if (evidence.tools.node !== 'v22.23.1' || evidence.tools.npm !== '10.9.8') { | |
| throw new Error(`unexpected evidence toolchain: ${JSON.stringify(evidence.tools)}`); | |
| } | |
| if (evidence.tools.registry !== 'https://registry.npmjs.org/') { | |
| throw new Error(`unexpected npm registry: ${evidence.tools.registry}`); | |
| } | |
| if (evidence.runner.os !== 'Linux' || evidence.runner.arch !== 'X64') { | |
| throw new Error(`unexpected runner platform: ${JSON.stringify(evidence.runner)}`); | |
| } | |
| if (audit.auditReportVersion !== 2 || !vulnerabilities) { | |
| throw new Error('npm audit snapshot is missing expected schema metadata'); | |
| } | |
| if ((vulnerabilities.high ?? 0) > 0 || (vulnerabilities.critical ?? 0) > 0) { | |
| throw new Error(`high/critical vulnerabilities present: ${JSON.stringify(vulnerabilities)}`); | |
| } | |
| if (evidence.sbom.spdx_version !== 'SPDX-2.3') { | |
| throw new Error(`unexpected SPDX version: ${evidence.sbom.spdx_version}`); | |
| } | |
| if (!Number.isInteger(evidence.sbom.package_count) || evidence.sbom.package_count < 1) { | |
| throw new Error('SBOM package inventory is empty or malformed'); | |
| } | |
| for (const file of [sbomFile, auditFile, packageFile, lockFile, npmrcFile]) { | |
| delete file.bytes; | |
| } | |
| writeFileSync('security-evidence.json', `${JSON.stringify(evidence, null, 2)}\n`); | |
| NODE | |
| - name: upload immutable security evidence | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: app-01-security-evidence-${{ env.SOURCE_SHA }} | |
| path: | | |
| npm-audit.json | |
| sbom.spdx.json | |
| security-evidence.json | |
| if-no-files-found: error | |
| retention-days: 90 |