diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..f5227451b --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,19 @@ +name: ts-stack security-extended analysis + +queries: + - uses: security-extended + +# Every ignored path is a checked-in generated boundary owned in +# governance/repository-health/projects.json. Repository controls require this +# list to match that registry exactly. +paths-ignore: + - conformance/generated/** + - docs-site/public/assets/asyncapi/** + - packages/verifast/src/wasm/bdk-core.* + - packages/wallet/wallet-toolbox/docs/open-rpc/index.html + - infra/uhrp-server-basic/src/security/edgePolicy.ts + - infra/uhrp-server-cloud-bucket/src/security/edgePolicy.ts + - infra/message-box-server/src/security/edgePolicy.ts + - infra/chaintracks-server/src/security/edgePolicy.ts + - packages/overlays/overlay-express/src/security/edgePolicy.ts + - packages/wallet/wallet-toolbox/src/storage/remoting/edgePolicy.ts diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..d43d510e9 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,53 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '17 8 * * 1' + workflow_dispatch: + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Keep default setup authoritative while this workflow is reviewed. The + # repository variable is enabled only after merge, immediately before + # default setup is disabled and this workflow is dispatched on main. + if: vars.CODEQL_ADVANCED_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: read + contents: read + packages: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - actions + - javascript-typescript + - python + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + with: + languages: ${{ matrix.language }} + build-mode: none + config-file: ./.github/codeql/codeql-config.yml + + - name: Analyze + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + with: + category: /language:${{ matrix.language }} diff --git a/docs/reference/repository-health.md b/docs/reference/repository-health.md index 55be7517d..ad4ee16a1 100644 --- a/docs/reference/repository-health.md +++ b/docs/reference/repository-health.md @@ -48,6 +48,16 @@ and analysis treatment. Generated output can be excluded narrowly only when this relationship is explicit. Product source and tests must not be reclassified as generated to reduce quality findings. +`.github/codeql/codeql-config.yml` is the executable CodeQL boundary. Its +`paths-ignore` entries must exactly match the owned generated artifacts above. +The advanced workflow keeps JavaScript/TypeScript, Python, and GitHub Actions +analysis on pull requests, `main` pushes, and a weekly schedule with the +`security-extended` suite. A repository-control test rejects any loss of those +languages, events, permissions, query coverage, or required check names. +`CODEQL_ADVANCED_ENABLED` is the repository-level cutover gate: keep it `true` +after advanced setup is activated. It exists only to prevent default and +advanced analysis from uploading concurrently during the migration PR. + `governance/repository-health/exceptions.json` is the only registry for temporary exceptions. Its schema is in `exception.schema.json`. Every entry requires an owner, rationale, evidence, creation date, review deadline, and objective @@ -90,8 +100,8 @@ checks: 1. discovery matches the exact 37-project inventory; 2. names, owners, profiles, criticality, targets, privacy, and release methods are internally consistent; -3. generated artifact boundaries have valid owners, sources, generators, and - narrow review/analysis policies; +3. generated artifact boundaries have valid owners, sources, generators, narrow + review/analysis policies, and exact CodeQL exclusion coverage; 4. published package versions match the recorded baseline; 5. exception records are owned, structurally valid, and unexpired; 6. current package-contract findings exactly match the ratcheted snapshot; and diff --git a/governance/repository-health/projects.json b/governance/repository-health/projects.json index ba7361f78..f27e886b7 100644 --- a/governance/repository-health/projects.json +++ b/governance/repository-health/projects.json @@ -51,7 +51,7 @@ "reviewPolicy": "Review the specification and renderer changes; verify regeneration is deterministic in the docs build." }, { - "path": "packages/verifast/src/wasm/bdk-core.{mjs,browser.mjs,umd.js,wasm,umd.wasm,d.ts,browser.d.ts}", + "path": "packages/verifast/src/wasm/bdk-core.*", "owner": "ts-stack-maintainers", "sourceInputs": [ "packages/verifast/README.md#reproducible-wasm-build" diff --git a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransport.test.ts b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransport.test.ts index 95abd20ea..ab8ba1bcd 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransport.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransport.test.ts @@ -27,6 +27,70 @@ describe('ExpressTransport configuration', () => { expect(res.status).not.toHaveBeenCalled() }) + it('dispatches only the exact BRC-104 path to the public handshake', async () => { + const transport = new ExpressTransport() + transport.peer = {} as any + const handshake = jest + .spyOn(transport as any, 'handleWellKnownAuth') + .mockResolvedValue(undefined) + const general = jest + .spyOn(transport as any, 'handleGeneralMessage') + .mockImplementation(() => {}) + const unauthenticated = jest + .spyOn(transport as any, 'handleUnauthenticated') + .mockImplementation(() => {}) + const res = {} as any + const next = jest.fn() + + await transport.handleIncomingRequest( + { path: '/.well-known/auth', headers: {} } as any, + res, + next + ) + expect(handshake).toHaveBeenCalledTimes(1) + expect(general).not.toHaveBeenCalled() + expect(unauthenticated).not.toHaveBeenCalled() + + for (const path of [ + '/.well-known/auth/extra', + '/.well-known/authentication', + '/.well-known/Auth', + '/protected' + ]) { + await transport.handleIncomingRequest({ path, headers: {} } as any, res, next) + } + expect(handshake).toHaveBeenCalledTimes(1) + expect(general).not.toHaveBeenCalled() + expect(unauthenticated).toHaveBeenCalledTimes(4) + }) + + it('requires an auth request ID before using signed general-message dispatch', async () => { + const transport = new ExpressTransport() + transport.peer = {} as any + const handshake = jest + .spyOn(transport as any, 'handleWellKnownAuth') + .mockResolvedValue(undefined) + const general = jest + .spyOn(transport as any, 'handleGeneralMessage') + .mockImplementation(() => {}) + const unauthenticated = jest + .spyOn(transport as any, 'handleUnauthenticated') + .mockImplementation(() => {}) + + await transport.handleIncomingRequest( + { + path: '/protected', + headers: { 'x-bsv-auth-request-id': 'signed-request-id' } + } as any, + {} as any, + jest.fn() + ) + + expect(general).toHaveBeenCalledTimes(1) + expect(handshake).not.toHaveBeenCalled() + expect(unauthenticated).not.toHaveBeenCalled() + }) + it('stores attacker-controlled request IDs in prototype-safe maps', async () => { const transport = new ExpressTransport() transport.peer = { diff --git a/packages/middleware/auth-express-middleware/src/index.ts b/packages/middleware/auth-express-middleware/src/index.ts index 5bd223f98..87dbbf9b9 100644 --- a/packages/middleware/auth-express-middleware/src/index.ts +++ b/packages/middleware/auth-express-middleware/src/index.ts @@ -29,6 +29,8 @@ export type { LogLevel } from './authMiddlewareHelpers.js' export { isLogLevelEnabled, getLogMethod } from './authMiddlewareHelpers.js' export { writeBodyToWriter } from './authMiddlewareHelpers.js' +const WELL_KNOWN_AUTH_PATH = '/.well-known/auth' + export interface AuthRequest extends Request { auth?: { identityKey: PubKeyHex @@ -421,7 +423,11 @@ export class ExpressTransport implements Transport { this.log('error', 'No Peer set in ExpressTransport! Cannot handle request.') throw new Error('You must set a Peer before you can handle incoming requests!') } - if (req.path === '/.well-known/auth') { + // BRC-104 authentication begins on this intentionally public handshake + // endpoint, before a session can exist. This is protocol dispatch, not a + // protected-route authorization check. Every other request still takes + // the signed general-message path or the explicit unauthenticated policy. + if (req.path === WELL_KNOWN_AUTH_PATH) { await this.handleWellKnownAuth(req, res, next, onCertificatesReceived) } else if (req.headers['x-bsv-auth-request-id']) { this.handleGeneralMessage(req, res, next) diff --git a/scripts/codeql-config.test.mjs b/scripts/codeql-config.test.mjs new file mode 100644 index 000000000..ff82f8016 --- /dev/null +++ b/scripts/codeql-config.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' +import { REPOSITORY_ROOT } from './repository-health.mjs' + +const CODEQL_CONFIG_PATH = join(REPOSITORY_ROOT, '.github/codeql/codeql-config.yml') +const CODEQL_WORKFLOW_PATH = join(REPOSITORY_ROOT, '.github/workflows/codeql.yml') +const PROJECTS_PATH = join(REPOSITORY_ROOT, 'governance/repository-health/projects.json') +const CODEQL_ACTION_SHA = 'e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81' + +function readIndentedList (source, key, indentation = 0) { + const lines = source.split('\n') + const prefix = `${' '.repeat(indentation)}${key}:` + const start = lines.findIndex(line => line === prefix) + assert.notEqual(start, -1, `${key} list is missing`) + + const values = [] + const itemIndentation = indentation + 2 + for (const line of lines.slice(start + 1)) { + if (line.trim() === '' || line.trimStart().startsWith('#')) continue + const currentIndentation = line.length - line.trimStart().length + if (currentIndentation <= indentation) break + if (currentIndentation === itemIndentation && line.trimStart().startsWith('- ')) { + values.push(line.trimStart().slice(2)) + } + } + return values +} + +test('CodeQL scans the owned source boundary with the security-extended suite', () => { + const config = readFileSync(CODEQL_CONFIG_PATH, 'utf8') + const registry = JSON.parse(readFileSync(PROJECTS_PATH, 'utf8')) + const ownedGeneratedPaths = registry.generatedArtifacts + .filter(artifact => artifact.analysisPolicy === 'exclude-generated') + .map(artifact => artifact.path) + .sort() + + assert.deepEqual(readIndentedList(config, 'queries'), ['uses: security-extended']) + assert.deepEqual(readIndentedList(config, 'paths-ignore').sort(), ownedGeneratedPaths) +}) + +test('advanced CodeQL preserves languages, events, permissions, and required check names', () => { + const workflow = readFileSync(CODEQL_WORKFLOW_PATH, 'utf8') + + assert.deepEqual(readIndentedList(workflow, 'language', 8), [ + 'actions', + 'javascript-typescript', + 'python' + ]) + assert.match(workflow, /^ push:\n branches: \[main\]$/m) + assert.match(workflow, /^ pull_request:\n branches: \[main\]$/m) + assert.match(workflow, /^ schedule:\n - cron: '.+'$/m) + assert.match(workflow, /^ workflow_dispatch:$/m) + assert.match(workflow, /^ name: Analyze \(\$\{\{ matrix\.language \}\}\)$/m) + assert.match(workflow, /^ if: vars\.CODEQL_ADVANCED_ENABLED == 'true'$/m) + assert.match(workflow, /^ security-events: write$/m) + assert.match(workflow, /^ build-mode: none$/m) + assert.match(workflow, /^ config-file: \.\/\.github\/codeql\/codeql-config\.yml$/m) + assert.match(workflow, new RegExp(`github/codeql-action/init@${CODEQL_ACTION_SHA}`)) + assert.match(workflow, new RegExp(`github/codeql-action/analyze@${CODEQL_ACTION_SHA}`)) +})