From 3fef13f75e94aae07d3e9721f4e0e50f295683f5 Mon Sep 17 00:00:00 2001 From: "Dylan Mordaunt (ISLHD)" Date: Sun, 9 Aug 2026 23:24:58 +1000 Subject: [PATCH 1/6] ci(security): enforce immutable supply-chain checks --- .github/workflows/ci.yml | 20 ++--- .github/workflows/hardening.yml | 45 ++++++----- .github/workflows/pages.yml | 10 +-- .github/workflows/publish.yml | 4 +- .github/workflows/release-drafter.yml | 2 +- .github/workflows/security.yml | 111 ++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 803e730c..f4510f4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -51,7 +51,7 @@ jobs: run: npm run test:coverage - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v7 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info @@ -71,8 +71,8 @@ jobs: timeout-minutes: 5 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -102,8 +102,8 @@ jobs: needs: quality steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -112,7 +112,7 @@ jobs: run: npm run test:mutation - name: Upload Mutation Report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mutation-report path: reports/mutation @@ -126,8 +126,8 @@ jobs: if: github.event_name == 'workflow_dispatch' steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm diff --git a/.github/workflows/hardening.yml b/.github/workflows/hardening.yml index d3f65373..d7f3d159 100644 --- a/.github/workflows/hardening.yml +++ b/.github/workflows/hardening.yml @@ -21,8 +21,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -34,8 +34,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -58,7 +58,7 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" exit 0 - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: advisory-dependency-types @@ -70,8 +70,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -94,7 +94,7 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" exit 0 - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: advisory-index-signatures @@ -106,13 +106,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm - run: npm ci - run: npm run audit:prod + - run: npm run audit:signatures + - run: npm run licenses:check - run: npm run scan:secrets node-compatibility: @@ -124,8 +126,8 @@ jobs: matrix: node-version: [20, 22, 24] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ matrix.node-version }} cache: npm @@ -139,17 +141,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm - run: npm ci - run: npm run sbom - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: package-sbom - path: reports/sbom/package.spdx.json + path: reports/sbom + if-no-files-found: error experimental-dependencies: name: Experimental Dependency Lane @@ -169,8 +172,8 @@ jobs: - zod-canary - prettier-alpha steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 cache: npm @@ -186,8 +189,8 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -199,7 +202,7 @@ jobs: run: | mkdir -p reports/frontier-drift npm run frontier:drift -- --out reports/frontier-drift/report.json --issue-body-out reports/frontier-drift/issue.md - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: frontier-drift diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 82ea6777..d4bb1fd8 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -11,10 +11,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/configure-pages@v6 - - uses: actions/jekyll-build-pages@v1 - - uses: actions/upload-pages-artifact@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 + - uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 deploy: needs: build runs-on: ubuntu-latest @@ -23,4 +23,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c18c8a3f..df352c4c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 4d89f745..bbd5b855 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: release-drafter/release-drafter@v7 + - uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7 with: config-name: release-drafter.yml env: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..c2a13c8e --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,111 @@ +name: Security + +on: + pull_request: + push: + branches: [master, main] + schedule: + - cron: "41 2 * * 2" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + osv-pr: + name: OSV PR Scan + if: github.event_name == 'pull_request' + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1 # v2.5.0 + permissions: + contents: read + security-events: write + + osv-full: + name: OSV Full Scan + if: github.event_name != 'pull_request' + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1 # v2.5.0 + permissions: + contents: read + security-events: write + + workflow-policy: + name: Workflow Policy + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 + + dependency-review: + name: Dependency Review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: low + deny-licenses: GPL-2.0, GPL-3.0, AGPL-1.0, AGPL-3.0 + + codeql: + name: CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + security-events: write + packages: read + strategy: + fail-fast: false + matrix: + language: [actions, javascript-typescript] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + category: /language:${{ matrix.language }} + + scorecard: + name: OpenSSF Scorecard + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - run: mkdir -p reports/scorecard + - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: reports/scorecard/results.sarif + results_format: sarif + publish_results: true + - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: reports/scorecard/results.sarif + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: scorecard + path: reports/scorecard/results.sarif + if-no-files-found: error From 58d374f77cc3bd9a7794836e97857d8238868588 Mon Sep 17 00:00:00 2001 From: "Dylan Mordaunt (ISLHD)" Date: Sun, 9 Aug 2026 23:25:09 +1000 Subject: [PATCH 2/6] feat(security): add SBOM and threat-model evidence --- SECURITY.md | 4 +- conductor/tracks/T09-02/design.md | 19 ++++++ conductor/tracks/T09-02/implementation.md | 7 +++ conductor/tracks/T09-02/metadata.json | 4 +- conductor/tracks/T09-02/plan.md | 8 +-- conductor/tracks/T09-02/requirements.md | 26 ++++++++ conductor/tracks/T09-02/spec.md | 7 ++- conductor/tracks/T09-02/verification.md | 11 +++- docs/security/threat-model.md | 19 ++++++ package.json | 4 +- scripts/package-sbom.mjs | 73 ++++++++++++++++++++--- scripts/validate-licenses.mjs | 17 ++++++ scripts/validate-sbom.mjs | 29 +++++++++ 13 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 conductor/tracks/T09-02/design.md create mode 100644 conductor/tracks/T09-02/implementation.md create mode 100644 conductor/tracks/T09-02/requirements.md create mode 100644 docs/security/threat-model.md create mode 100644 scripts/validate-licenses.mjs create mode 100644 scripts/validate-sbom.mjs diff --git a/SECURITY.md b/SECURITY.md index d682f190..0bb50d28 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ ## Reporting a Vulnerability -If you discover a security vulnerability in `substack-cli`, please report it privately by emailing edithatogo@users.noreply.github.com. +Use GitHub's **Report a vulnerability** control on the repository Security tab. Private vulnerability reporting is enabled and is the preferred channel. If GitHub private reporting is unavailable, email edithatogo@users.noreply.github.com. **Do not open a public GitHub issue for security vulnerabilities.** @@ -31,6 +31,6 @@ The repository includes `scripts/secret-scan.mjs` for automated secret scanning. ## Disclosure Policy -- Vulnerabilities are fixed in a private fork and released as a patch version. +- Vulnerabilities are coordinated through a private GitHub security advisory and released as a patch version. - After the fix is released, a security advisory may be published describing the issue and the fix. - We aim to release fixes within 14 days of confirmation for moderate-severity issues, and sooner for high-severity issues. diff --git a/conductor/tracks/T09-02/design.md b/conductor/tracks/T09-02/design.md new file mode 100644 index 00000000..e113694b --- /dev/null +++ b/conductor/tracks/T09-02/design.md @@ -0,0 +1,19 @@ +# Design + +```mermaid +flowchart LR + PR[Pull request] --> WP[Actionlint and zizmor] + PR --> DR[Dependency review and OSV] + PR --> CQ[CodeQL] + MAIN[Default branch or schedule] --> SC[OpenSSF Scorecard] + MAIN --> OSV[Full OSV scan] + LOCK[package-lock.json] --> SB[SPDX and CycloneDX generator] + SB --> SV[SBOM validator] + LOCK --> LP[Licence and signature policy] + SC --> SARIF[Security SARIF] + CQ --> SARIF + OSV --> SARIF + SV --> ART[Versioned workflow evidence] +``` + +Workflow permissions default to `contents: read`. Security-event and identity-token permissions are granted only to jobs that publish SARIF or Scorecard results. Pull requests are read-only analysis surfaces; release attestations remain in T09-03 so untrusted changes cannot acquire signing authority. diff --git a/conductor/tracks/T09-02/implementation.md b/conductor/tracks/T09-02/implementation.md new file mode 100644 index 00000000..5210f725 --- /dev/null +++ b/conductor/tracks/T09-02/implementation.md @@ -0,0 +1,7 @@ +# T09-02 implementation ledger + +- Verified current upstream action releases before pinning. +- Added immutable workflow policy, dependency review, OSV, CodeQL, and OpenSSF Scorecard jobs. +- Added SPDX and CycloneDX generation/validation plus production licence and npm-signature policy. +- Documented the repository threat model and enabled private vulnerability-reporting route. +- Deferred structural runtime redaction and release signing to their owning tracks without marking T09-02 complete. diff --git a/conductor/tracks/T09-02/metadata.json b/conductor/tracks/T09-02/metadata.json index c9d8b0db..a12d7969 100644 --- a/conductor/tracks/T09-02/metadata.json +++ b/conductor/tracks/T09-02/metadata.json @@ -3,7 +3,7 @@ "phaseId": "P09", "phaseTitle": "CI/CD, security, supply chain and repository governance", "title": "Security and supply-chain engineering", - "status": "planned", + "status": "in_progress", "contractIds": [ "SEC-STRUCTURED-REDACTION-010", "SEC-THREAT-MODEL-011", @@ -12,5 +12,5 @@ "requiredTaskCount": 4, "existingIssues": [], "riskLevel": "high", - "issue": null + "issue": 408 } diff --git a/conductor/tracks/T09-02/plan.md b/conductor/tracks/T09-02/plan.md index 590b2098..759fb9db 100644 --- a/conductor/tracks/T09-02/plan.md +++ b/conductor/tracks/T09-02/plan.md @@ -1,10 +1,10 @@ # Plan T09-02 ## Tasks -- [ ] T09-02-TASK-01 Pin action SHAs and add actionlint/zizmor. -- [ ] T09-02-TASK-02 Add CodeQL, dependency review, Scorecard, secret and OSV scanning. -- [ ] T09-02-TASK-03 Generate/validate SPDX and CycloneDX SBOMs, licences and attestations. -- [ ] T09-02-TASK-04 Implement private vulnerability reporting and update SECURITY.md. +- [x] T09-02-TASK-01 Pin action SHAs and add actionlint/zizmor. +- [x] T09-02-TASK-02 Add CodeQL, dependency review, Scorecard, secret and OSV scanning. +- [~] T09-02-TASK-03 Generate/validate SPDX and CycloneDX SBOMs and enforce licence/signature policy; attestations remain dependent on T09-03. +- [~] T09-02-TASK-04 Implement private vulnerability reporting, threat modelling, and update SECURITY.md; structural redaction remains dependent on runtime remediation. ## Contract IDs - SEC-STRUCTURED-REDACTION-010 diff --git a/conductor/tracks/T09-02/requirements.md b/conductor/tracks/T09-02/requirements.md new file mode 100644 index 00000000..4bdfe431 --- /dev/null +++ b/conductor/tracks/T09-02/requirements.md @@ -0,0 +1,26 @@ +# Requirements + +## Must have + +- **MUST-01 Immutable workflows:** Every third-party Action reference is pinned to a full commit SHA with a readable release annotation. +- **MUST-02 Workflow policy:** Actionlint and zizmor fail pull requests on invalid or unsafe workflow changes. +- **MUST-03 Vulnerability coverage:** CodeQL, dependency review, OSV, maintained secret scanning, npm audit, and npm signature checks run with least privilege. +- **MUST-04 Scorecard:** OpenSSF Scorecard publishes SARIF from a scheduled or default-branch run. +- **MUST-05 SBOMs:** Generate and validate SPDX 2.3 and CycloneDX 1.6 inventories with package relationships. +- **MUST-06 Licence policy:** Reject prohibited copyleft licences in production dependencies. +- **MUST-07 Reporting:** Private vulnerability reporting is enabled and documented as the preferred channel. +- **MUST-08 Threat model:** Cover credential exfiltration, SSRF, injection, traversal, confused deputy, replay, duplicate mutation, supply-chain compromise, PII leakage, and malicious repository content. +- **MUST-09 No false closure:** Keep runtime redaction and signed release attestations open until their owning tracks provide evidence. + +## Should have + +- **SHOULD-01 Minimal tokens:** Default workflow permissions are read-only; elevated permissions are job-local. +- **SHOULD-02 Machine evidence:** Upload SARIF and SBOM artifacts with explicit missing-file failure. + +## Could have + +- **COULD-01 Offline OSV mirror:** Add a pinned offline vulnerability database if network reliability requires it. + +## Won't have in this increment + +- Runtime redaction implementation, release provenance signing, dependency upgrades, or fuzz harnesses owned by other tracks. diff --git a/conductor/tracks/T09-02/spec.md b/conductor/tracks/T09-02/spec.md index 0ecc8b75..21d84868 100644 --- a/conductor/tracks/T09-02/spec.md +++ b/conductor/tracks/T09-02/spec.md @@ -4,6 +4,7 @@ Phase: P09 CI/CD, security, supply chain and repository governance Release target: 1.0.0-rc ## Scope -- Planning-only work for bootstrap and planning artifacts. -- Native hierarchy, contract, and traceability preparation. - +- Immutable and least-privilege GitHub Actions security controls. +- Vulnerability, workflow, dependency, secret, licence, signature, SBOM, and Scorecard assurance. +- Threat-model and private vulnerability-reporting governance. +- Structural redaction and release attestations remain tracked dependencies until their owning runtime and release tracks merge. diff --git a/conductor/tracks/T09-02/verification.md b/conductor/tracks/T09-02/verification.md index 4aac698e..275fc204 100644 --- a/conductor/tracks/T09-02/verification.md +++ b/conductor/tracks/T09-02/verification.md @@ -1,5 +1,10 @@ # Verification -- [ ] Contract generated and validated -- [ ] Traceability map generated -- [ ] All phase/track/task artefacts in Conductor +- [x] Contract and traceability validation passed. +- [x] All third-party Action references use immutable SHAs. +- [x] Workflow YAML and Biome validation passed. +- [x] SPDX 2.3 and CycloneDX 1.6 validation passed for 950 package records. +- [x] Production licence policy, secret scan, and production audit passed. +- [x] npm verified 836 registry signatures and observed 165 attestations. +- [ ] Hosted security workflows and OpenSSF Scorecard produce green evidence. +- [ ] Structural redaction and signed release attestations merge from their owning tracks. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md new file mode 100644 index 00000000..37dd6e44 --- /dev/null +++ b/docs/security/threat-model.md @@ -0,0 +1,19 @@ +# Threat model + +## Boundaries + +The CLI crosses local Markdown, browser automation, Substack APIs, MCP stdio/HTTP, local state, npm packaging, CI, and external registries. Credentials and publication mutations remain user-owned and local-first. + +| Threat | Control | Evidence | Residual risk | +| --- | --- | --- | --- | +| Credential exfiltration | Ignored secret files, structural redaction, no-export telemetry | Secret scan and privacy tests | User-hosted browser/session compromise | +| SSRF and malicious URLs | URL policy and explicit publication targets | URL/policy tests | Upstream redirect behaviour | +| Prompt or argument injection | Typed command boundaries and safe parser output | CLI/parser tests | Malicious local content still requires user authorization | +| Path traversal | Resolved path checks and bounded fixture roots | Parser/state tests | Platform-specific filesystem semantics | +| Confused deputy | Authority, approval, and mutation guards | P01/P04 tests | External provider authorization semantics | +| Replay or duplicate mutation | Idempotency keys, state records, and guarded execution | Mutation/state tests | Outcome-unknown provider responses | +| Supply-chain compromise | Lockfile, audit, SBOM, provenance, immutable release artifact policy | CI and release receipts | Hosted service compromise | +| PII leakage | Redaction, fingerprinting, minimal logs, no-export default | Privacy and telemetry tests | User-supplied secrets in third-party tools | +| Malicious repository content | Secret scan, parser sanitization, bounded generated artifacts | Fuzz/property and scan receipts | Novel parser vulnerabilities | + +Each release must re-evaluate this table and record new controls or residual risk in its receipt. diff --git a/package.json b/package.json index 462ef072..fb594ead 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,9 @@ "contracts:check": "npm run build && node scripts/contracts.mjs check", "quality": "npm run test:taxonomy && npm run ci && npm run knip && npm run typecheck && npm run build && npm run test:coverage && npm run test:assurance && npm run test:mutation && npm run scan:secrets && npm run audit:prod", "audit:prod": "npm audit --omit=dev", - "sbom": "node scripts/package-sbom.mjs", + "audit:signatures": "npm audit signatures", + "licenses:check": "node scripts/validate-licenses.mjs", + "sbom": "node scripts/package-sbom.mjs && node scripts/validate-sbom.mjs", "knip": "knip", "completions": "node dist/cli.js completion bash > scripts/completions.bash && node dist/cli.js completion zsh > scripts/completions.zsh && node dist/cli.js completion powershell > scripts/completions.ps1", "bench": "node --experimental-vm-modules node_modules/.bin/vitest run src/benchmark", diff --git a/scripts/package-sbom.mjs b/scripts/package-sbom.mjs index 0af11cff..e66db6ff 100644 --- a/scripts/package-sbom.mjs +++ b/scripts/package-sbom.mjs @@ -1,11 +1,13 @@ #!/usr/bin/env node import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; +import { dirname, resolve } from "node:path"; -const outFile = process.argv[2] || "reports/sbom/package.spdx.json"; +const outputDirectory = resolve(process.argv[2] || "reports/sbom"); +const spdxFile = resolve(outputDirectory, "package.spdx.json"); +const cyclonedxFile = resolve(outputDirectory, "package.cdx.json"); const packageJson = JSON.parse(await readFile("package.json", "utf8")); const packageLock = JSON.parse(await readFile("package-lock.json", "utf8")); -const packages = Object.entries(packageLock.packages ?? {}) +const lockPackages = Object.entries(packageLock.packages ?? {}) .filter(([path]) => path.startsWith("node_modules/")) .map(([path, meta]) => { const name = packageNameFromLockPath(path); @@ -22,7 +24,7 @@ const packages = Object.entries(packageLock.packages ?? {}) .sort((a, b) => a.name.localeCompare(b.name)); const rootPackageId = `SPDXRef-Package-${safeSpdxId(packageJson.name)}`; -const document = { +const spdxDocument = { spdxVersion: "SPDX-2.3", dataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT", @@ -42,18 +44,61 @@ const document = { licenseDeclared: packageJson.license ?? "NOASSERTION", supplier: "NOASSERTION", }, - ...packages, + ...lockPackages, ], - relationships: packages.map((pkg) => ({ + relationships: lockPackages.map((pkg) => ({ spdxElementId: rootPackageId, relationshipType: "DEPENDS_ON", relatedSpdxElement: pkg.SPDXID, })), }; -await mkdir(dirname(outFile), { recursive: true }); -await writeFile(outFile, `${JSON.stringify(document, null, 2)}\n`, "utf8"); -console.log(JSON.stringify({ operation: "sbom.generate", outputFile: outFile, packages: packages.length + 1 })); +const cyclonedxDocument = { + bomFormat: "CycloneDX", + specVersion: "1.6", + serialNumber: `urn:uuid:${deterministicUuid(`${packageJson.name}@${packageJson.version}`)}`, + version: 1, + metadata: { + timestamp: spdxDocument.creationInfo.created, + tools: { components: [{ type: "application", name: "package-sbom.mjs" }] }, + component: { + type: "application", + "bom-ref": `${packageJson.name}@${packageJson.version}`, + name: packageJson.name, + version: packageJson.version, + licenses: packageJson.license ? [{ license: { id: packageJson.license } }] : [], + purl: packagePurl(packageJson.name, packageJson.version), + }, + }, + components: lockPackages.map((pkg) => ({ + type: "library", + "bom-ref": `${pkg.name}@${pkg.versionInfo}`, + name: pkg.name, + version: pkg.versionInfo, + purl: packagePurl(pkg.name, pkg.versionInfo), + licenses: + pkg.licenseDeclared === "NOASSERTION" + ? [] + : [{ license: { id: pkg.licenseDeclared } }], + })), + dependencies: [ + { + ref: `${packageJson.name}@${packageJson.version}`, + dependsOn: lockPackages.map((pkg) => `${pkg.name}@${pkg.versionInfo}`), + }, + ], +}; + +await mkdir(dirname(spdxFile), { recursive: true }); +await writeFile(spdxFile, `${JSON.stringify(spdxDocument, null, 2)}\n`, "utf8"); +await writeFile(cyclonedxFile, `${JSON.stringify(cyclonedxDocument, null, 2)}\n`, "utf8"); +console.log( + JSON.stringify({ + operation: "sbom.generate", + outputs: [spdxFile, cyclonedxFile], + packages: lockPackages.length + 1, + }), +); function safeSpdxId(value) { return String(value).replace(/[^A-Za-z0-9.-]/g, "-"); @@ -63,3 +108,13 @@ function packageNameFromLockPath(path) { const parts = path.replace(/^node_modules\//, "").split("/node_modules/"); return parts.at(-1) ?? path; } + +function packagePurl(name, version) { + return `pkg:npm/${encodeURIComponent(name).replace("%40", "@").replace("%2F", "/")}@${version}`; +} + +function deterministicUuid(value) { + const bytes = new TextEncoder().encode(value); + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8).padEnd(8, "0")}-${hex.slice(8, 12).padEnd(4, "0")}-5${hex.slice(13, 16).padEnd(3, "0")}-8${hex.slice(17, 20).padEnd(3, "0")}-${hex.slice(20, 32).padEnd(12, "0")}`; +} diff --git a/scripts/validate-licenses.mjs b/scripts/validate-licenses.mjs new file mode 100644 index 00000000..7163cad6 --- /dev/null +++ b/scripts/validate-licenses.mjs @@ -0,0 +1,17 @@ +import { readFile } from "node:fs/promises"; + +const lock = JSON.parse(await readFile("package-lock.json", "utf8")); +const prohibited = /(^|\s|\()A?GPL-(?:1\.0|2\.0|3\.0)(?:-only|-or-later)?($|\s|\))/i; +const failures = []; + +for (const [path, metadata] of Object.entries(lock.packages ?? {})) { + if (!path.startsWith("node_modules/") || metadata.dev === true) continue; + const license = metadata.license; + if (typeof license === "string" && prohibited.test(license)) failures.push(`${path}: ${license}`); +} + +if (failures.length) { + console.error(`Prohibited production licences:\n${failures.join("\n")}`); + process.exit(1); +} +console.log("Production dependency licence policy passed."); diff --git a/scripts/validate-sbom.mjs b/scripts/validate-sbom.mjs new file mode 100644 index 00000000..9898292b --- /dev/null +++ b/scripts/validate-sbom.mjs @@ -0,0 +1,29 @@ +import { readFile } from "node:fs/promises"; + +const spdx = JSON.parse(await readFile("reports/sbom/package.spdx.json", "utf8")); +const cyclonedx = JSON.parse(await readFile("reports/sbom/package.cdx.json", "utf8")); +const failures = []; + +if (spdx.spdxVersion !== "SPDX-2.3") failures.push("SPDX version must be 2.3."); +if (spdx.dataLicense !== "CC0-1.0") failures.push("SPDX data license must be CC0-1.0."); +if (!Array.isArray(spdx.packages) || spdx.packages.length < 2) { + failures.push("SPDX package inventory is missing or empty."); +} +if (cyclonedx.bomFormat !== "CycloneDX" || cyclonedx.specVersion !== "1.6") { + failures.push("CycloneDX document must use specification 1.6."); +} +if (!Array.isArray(cyclonedx.components) || cyclonedx.components.length < 1) { + failures.push("CycloneDX component inventory is missing or empty."); +} +if (spdx.packages.length !== cyclonedx.components.length + 1) { + failures.push("SPDX and CycloneDX package inventories do not agree."); +} + +if (failures.length > 0) { + console.error(failures.join("\n")); + process.exitCode = 1; +} else { + console.log( + `SBOM validation passed: ${spdx.packages.length} SPDX packages and ${cyclonedx.components.length} CycloneDX components.`, + ); +} From c178c828c36377837caa09ef1ea0bdc691d21dec Mon Sep 17 00:00:00 2001 From: "Dylan Mordaunt (ISLHD)" Date: Sun, 9 Aug 2026 23:29:54 +1000 Subject: [PATCH 3/6] fix(security): encode complete SBOM package names --- scripts/package-sbom.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/package-sbom.mjs b/scripts/package-sbom.mjs index e66db6ff..10ad9ccb 100644 --- a/scripts/package-sbom.mjs +++ b/scripts/package-sbom.mjs @@ -110,7 +110,7 @@ function packageNameFromLockPath(path) { } function packagePurl(name, version) { - return `pkg:npm/${encodeURIComponent(name).replace("%40", "@").replace("%2F", "/")}@${version}`; + return `pkg:npm/${encodeURIComponent(name).replaceAll("%40", "@").replaceAll("%2F", "/")}@${version}`; } function deterministicUuid(value) { From 85d3177a337547f106e70e0d622af73dce1b37a2 Mon Sep 17 00:00:00 2001 From: "Dylan Mordaunt (ISLHD)" Date: Sun, 9 Aug 2026 23:55:41 +1000 Subject: [PATCH 4/6] fix(security): grant OSV reusable workflow read scope --- .github/workflows/security.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c2a13c8e..88a62a71 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -21,6 +21,7 @@ jobs: if: github.event_name == 'pull_request' uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1 # v2.5.0 permissions: + actions: read contents: read security-events: write @@ -29,6 +30,7 @@ jobs: if: github.event_name != 'pull_request' uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1 # v2.5.0 permissions: + actions: read contents: read security-events: write From 62ab72f6503a4243b7d4cc966c8910c26f602947 Mon Sep 17 00:00:00 2001 From: "Dylan Mordaunt (ISLHD)" Date: Sun, 9 Aug 2026 23:57:54 +1000 Subject: [PATCH 5/6] fix(security): retain CodeQL default setup --- .github/workflows/security.yml | 23 ----------------------- conductor/tracks/T09-02/implementation.md | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 88a62a71..4a088a98 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -61,29 +61,6 @@ jobs: fail-on-severity: low deny-licenses: GPL-2.0, GPL-3.0, AGPL-1.0, AGPL-3.0 - codeql: - name: CodeQL (${{ matrix.language }}) - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - security-events: write - packages: read - strategy: - fail-fast: false - matrix: - language: [actions, javascript-typescript] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 - with: - languages: ${{ matrix.language }} - - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 - with: - category: /language:${{ matrix.language }} - scorecard: name: OpenSSF Scorecard if: github.event_name != 'pull_request' diff --git a/conductor/tracks/T09-02/implementation.md b/conductor/tracks/T09-02/implementation.md index 5210f725..16a2b4a4 100644 --- a/conductor/tracks/T09-02/implementation.md +++ b/conductor/tracks/T09-02/implementation.md @@ -1,7 +1,7 @@ # T09-02 implementation ledger - Verified current upstream action releases before pinning. -- Added immutable workflow policy, dependency review, OSV, CodeQL, and OpenSSF Scorecard jobs. +- Added immutable workflow policy, dependency review, OSV, and OpenSSF Scorecard jobs; retained GitHub CodeQL default setup as the sole CodeQL owner because GitHub rejects simultaneous advanced configuration. - Added SPDX and CycloneDX generation/validation plus production licence and npm-signature policy. - Documented the repository threat model and enabled private vulnerability-reporting route. - Deferred structural runtime redaction and release signing to their owning tracks without marking T09-02 complete. From 6ab276342e76e869ea27c42f43aa727879c8ffb2 Mon Sep 17 00:00:00 2001 From: "Dylan Mordaunt (ISLHD)" Date: Mon, 10 Aug 2026 00:24:59 +1000 Subject: [PATCH 6/6] fix(ci): close workflow security findings --- .github/workflows/ci.yml | 10 ++++++++++ .github/workflows/github-programme.yml | 2 ++ .github/workflows/hardening.yml | 16 ++++++++++++++++ .github/workflows/pages.yml | 8 ++++++-- .github/workflows/publish.yml | 7 +++++-- .github/workflows/release-drafter.yml | 2 +- .github/workflows/substack-api-drift-canary.yml | 2 ++ 7 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4510f4d..a9bbac0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 @@ -72,6 +74,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 @@ -87,6 +91,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 26 @@ -103,6 +109,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 @@ -127,6 +135,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 diff --git a/.github/workflows/github-programme.yml b/.github/workflows/github-programme.yml index 3c0a52f6..ba27b910 100644 --- a/.github/workflows/github-programme.yml +++ b/.github/workflows/github-programme.yml @@ -27,6 +27,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 26 diff --git a/.github/workflows/hardening.yml b/.github/workflows/hardening.yml index d7f3d159..6762d410 100644 --- a/.github/workflows/hardening.yml +++ b/.github/workflows/hardening.yml @@ -22,6 +22,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 @@ -35,6 +37,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 @@ -71,6 +75,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 @@ -107,6 +113,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 @@ -127,6 +135,8 @@ jobs: node-version: [20, 22, 24] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: ${{ matrix.node-version }} @@ -142,6 +152,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 @@ -173,6 +185,8 @@ jobs: - prettier-alpha steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 @@ -190,6 +204,8 @@ jobs: issues: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d4bb1fd8..1dfb1d98 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -5,19 +5,23 @@ on: workflow_dispatch: permissions: contents: read - pages: write - id-token: write jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 - uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1 - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 deploy: needs: build runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index df352c4c..bbf14010 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,13 +19,15 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 registry-url: "https://registry.npmjs.org" - cache: npm + package-manager-cache: false - name: Install run: npm ci @@ -47,9 +49,10 @@ jobs: - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REF_NAME: ${{ github.ref_name }} run: | VERSION="${GITHUB_REF#refs/tags/v}" - gh release create "${{ github.ref_name }}" \ + gh release create "${REF_NAME}" \ --title "v${VERSION}" \ --generate-notes \ --verify-tag diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index bbd5b855..6cba8277 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -19,4 +19,4 @@ jobs: with: config-name: release-drafter.yml env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/substack-api-drift-canary.yml b/.github/workflows/substack-api-drift-canary.yml index e806a4b1..13dffd0e 100644 --- a/.github/workflows/substack-api-drift-canary.yml +++ b/.github/workflows/substack-api-drift-canary.yml @@ -19,6 +19,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 26