diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml new file mode 100644 index 000000000..6db7276da --- /dev/null +++ b/.github/workflows/bandit.yml @@ -0,0 +1,35 @@ +name: bandit + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + +jobs: + bandit-scan: + name: Bandit Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.8.6" + enable-cache: false + - name: Sync Python dependencies + run: uv sync --project services/analysis-engine --group dev --frozen + - name: Run Bandit + working-directory: services/analysis-engine + run: uv run bandit -c pyproject.toml -r src diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index 13de8e648..abec57b6b 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -12,12 +12,6 @@ on: tags: - "v*" -concurrency: - group: >- - ${{ github.workflow }}-${{ github.repository }}-${{ - github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - permissions: contents: read @@ -294,11 +288,9 @@ jobs: - name: Explain non-blocking macOS amd64 artifact upload failure if: ${{ steps.upload-macos-amd64.outcome == 'failure' }} run: | - { - echo "Artifact upload failed after the macOS amd64 bundle was packaged." - echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." - echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." - } >> "$GITHUB_STEP_SUMMARY" + echo "Artifact upload failed after the macOS amd64 bundle was packaged." >> "$GITHUB_STEP_SUMMARY" + echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." >> "$GITHUB_STEP_SUMMARY" + echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." >> "$GITHUB_STEP_SUMMARY" build-macos-arm64: name: build / macos / arm64 @@ -358,11 +350,9 @@ jobs: - name: Explain non-blocking macOS arm64 artifact upload failure if: ${{ steps.upload-macos-arm64.outcome == 'failure' }} run: | - { - echo "Artifact upload failed after the macOS arm64 bundle was packaged." - echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." - echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." - } >> "$GITHUB_STEP_SUMMARY" + echo "Artifact upload failed after the macOS arm64 bundle was packaged." >> "$GITHUB_STEP_SUMMARY" + echo "Pull request builds keep artifact upload non-blocking because GitHub artifact service or DNS failures do not invalidate the build evidence." >> "$GITHUB_STEP_SUMMARY" + echo "Tag and release builds remain blocking because release publication requires uploaded artifacts." >> "$GITHUB_STEP_SUMMARY" gate-macos: name: gate / build / macos diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e743c2ff..d17468129 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,12 +10,6 @@ on: - develop - main -concurrency: - group: >- - ${{ github.workflow }}-${{ github.repository }}-${{ - github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - permissions: contents: read diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..27c5b540f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,39 @@ +name: codeql + +on: + push: + branches: + - develop + - main + workflow_dispatch: + +permissions: + actions: read + contents: read + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + +jobs: + analyze: + name: codeql + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - javascript-typescript + - python + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml index 8f5b1bc25..2a4b6eaa9 100644 --- a/.github/workflows/ossf-scorecard.yml +++ b/.github/workflows/ossf-scorecard.yml @@ -9,10 +9,6 @@ on: - develop - main -concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: false - permissions: read-all jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa69a973c..34583b414 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,10 @@ name: release on: + pull_request: + branches: + - develop + - main push: branches: - develop @@ -9,10 +13,6 @@ on: - "v*" workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: false - permissions: contents: read diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index df77ed859..38700f773 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -15,12 +15,6 @@ on: types: - published -concurrency: - group: >- - ${{ github.workflow }}-${{ github.repository }}-${{ - github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - permissions: contents: read diff --git a/.github/workflows/secret-scan-gate.yml b/.github/workflows/secret-scan-gate.yml new file mode 100644 index 000000000..88f72b419 --- /dev/null +++ b/.github/workflows/secret-scan-gate.yml @@ -0,0 +1,29 @@ +name: secret-scan-gate + +on: + pull_request: + branches: + - develop + - main + push: + branches: + - develop + - main + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + +jobs: + secret-scan: + name: secret-scan-gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Scan for common hardcoded secrets + run: | + ! git grep -nE '(g[h]p_|g[h]o_|A[K]IA[0-9A-Z]{16}|A[I]za[0-9A-Za-z\-_]{35}|BEGIN (R[S]A|E[C]|OPENS[S]H|P[G]P) PRIVATE KEY)' -- . ':(exclude)package-lock.json' ':(exclude)node_modules/**' diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 07754a782..f6737f1f6 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -1,15 +1,14 @@ -name: security-backstop +name: security-audit on: + pull_request: + branches: + - develop + - main push: branches: - develop - main - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: false permissions: contents: read @@ -20,12 +19,9 @@ env: GIT_CONFIG_VALUE_0: develop jobs: - security-backstop: - name: security-backstop + audit: + name: security-audit runs-on: ubuntu-latest - permissions: - contents: read - security-events: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -53,9 +49,6 @@ jobs: run: uv sync --project services/analysis-engine --group dev --frozen - name: Audit Python dependencies run: uv run --project services/analysis-engine --with pip-audit==2.8.0 pip-audit --local --strict - - name: Run Bandit - working-directory: services/analysis-engine - run: uv run bandit -c pyproject.toml -r src - name: Install stable Rust toolchain run: rustup toolchain install stable --profile minimal - name: Install cargo-audit @@ -63,35 +56,3 @@ jobs: - name: Audit Rust dependencies working-directory: apps/desktop/src-tauri run: cargo +stable audit - - name: Scan for common hardcoded secrets - run: | - ! git grep -nE '(g[h]p_|g[h]o_|A[K]IA[0-9A-Z]{16}|A[I]za[0-9A-Za-z\-_]{35}|BEGIN (R[S]A|E[C]|OPENS[S]H|P[G]P) PRIVATE KEY)' -- . ':(exclude)package-lock.json' ':(exclude)node_modules/**' - - name: Run Trivy filesystem scan summary - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - scan-type: fs - scan-ref: . - version: v0.71.2 - format: table - severity: CRITICAL,HIGH,MEDIUM - exit-code: "0" - skip-dirs: services/analysis-engine/.venv - trivyignores: ./.trivyignore - - name: Run Trivy filesystem scan - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - scan-type: fs - scan-ref: . - version: v0.71.2 - format: sarif - output: trivy-results.sarif - severity: CRITICAL,HIGH,MEDIUM - limit-severities-for-sarif: true - exit-code: "1" - skip-dirs: services/analysis-engine/.venv - trivyignores: ./.trivyignore - - name: Upload Trivy scan results to GitHub Security tab - if: always() - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - with: - sarif_file: trivy-results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 000000000..d79ec32e1 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,54 @@ +name: trivy + +on: + push: + branches: + - develop + - main + +permissions: + contents: read + +env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: develop + +jobs: + trivy-fs-scan: + name: trivy-fs-scan + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Run Trivy filesystem scan summary + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0; SHA pinning retained as supply-chain attack mitigation, do not replace with tag. + with: + scan-type: fs + scan-ref: . + version: v0.71.2 + format: table + severity: CRITICAL,HIGH,MEDIUM + exit-code: '0' + skip-dirs: 'services/analysis-engine/.venv' + trivyignores: ./.trivyignore + - name: Run Trivy filesystem scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0; SHA pinning retained as supply-chain attack mitigation, do not replace with tag. + with: + scan-type: fs + scan-ref: . + version: v0.71.2 + format: sarif + output: trivy-results.sarif + severity: CRITICAL,HIGH,MEDIUM + limit-severities-for-sarif: true + exit-code: '1' + skip-dirs: 'services/analysis-engine/.venv' + trivyignores: ./.trivyignore + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 peeled commit; SHA pinning retained as supply-chain attack mitigation. + if: always() + with: + sarif_file: trivy-results.sarif diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 34122c2b4..f6a87d02f 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -6,7 +6,7 @@ ## 2024-07-07 - Unsanitized Directory Input Paths API Validation **Vulnerability:** The API logic allowed user-controlled local data directory paths (`cacheRoot` and `tempRoot`) to be directly used without mitigating cross-platform path traversal vulnerabilities. **Learning:** Checking for '..' sequences in untrusted paths fails to parse cross-platform separators reliably for untrusted inputs (e.g., Windows backslashes on POSIX). Relying solely on `os.sep` or `os.altsep` is inadequate because absolute paths can bypass restrictions if not resolved correctly, or if `os.altsep` is None. -**Prevention:** Manually replace backslashes with forward slashes and split by forward slash (e.g., `if '..' in path.replace('\\', '/').split('/')`) to enforce path traversal protections explicitly for restricted directory inputs provided via the API. Do not block `~` for user-selected input files. +**Prevention:** Manually replace backslashes with forward slashes and split by forward slash (e.g. `if '..' in path.replace('\\', '/').split('/')`) to enforce path traversal protections explicitly for restricted directory inputs provided via the API. Do not block `~` for user-selected input files. ## 2024-05-20 - Python Path Traversal Mitigation bypass **Vulnerability:** Path traversal detection in Python backend APIs relied solely on checking the input path string or basic parsed parts which might not adequately catch sequences like `..` when intermixed with different path separators. @@ -28,3 +28,8 @@ **Vulnerability:** The Rust backend (`apps/desktop/src-tauri/src/main.rs`) did not enforce a maximum URL length limit when processing YouTube URLs via `import_youtube_url`. While the frontend enforced `MAX_YOUTUBE_URL_LENGTH = 2000` via the input element, this could be bypassed by an attacker sending requests directly to the Tauri backend API, potentially causing a Denial of Service (DoS) due to unbounded URL parsing and regex matching. **Learning:** Input validation must occur at the entry point of untrusted data on the backend, even if it is also validated on the frontend. Relying solely on frontend validation for constraints like string length can expose the backend to resource exhaustion vulnerabilities. **Prevention:** Always enforce constraints like maximum length, format validation, and sanitization at the earliest possible point on the backend, typically at the API boundary, regardless of frontend safeguards. + +## 2026-09-05 - CSV Formula Injection C0 Control Prefix Bypass +**Vulnerability:** CSV formula-injection mitigation was incomplete when a cell began with a C0 control character (`\x00`-`\x1F`) that could be interpreted differently by downstream spreadsheet or parser implementations before a formula token. +**Learning:** NUL is only one member of the parser-disagreement boundary. Security policy must not depend on every downstream consumer preserving leading control bytes exactly, and executable regressions must include non-whitespace controls such as ESC as well as NUL. +**Prevention:** In `escapeCsvField`, treat any leading C0 control after permitted whitespace/BOM/NBSP as dangerous, prefix the entire original field before structural CSV quoting, and retain regressions for NUL-only, repeated NUL, whitespace+control, ESC-prefixed formula-shaped values, and full-width formula operators. Keep the lint exception scoped only to the intentional control-character regular expression. diff --git a/CHANGELOG.md b/CHANGELOG.md index 34331fb86..0b6f7e784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ ### Changed -- Consolidated Bandit, dependency audits, supplemental secret checks, and Trivy into one trusted-branch security backstop, delegated CodeQL to GitHub default setup, and removed duplicate local PR security and release-preflight runs. - Pinned npm `10.9.9` as the approved lockfile generator, activated it through Node-bundled Corepack before dependency consumption, and fail closed unless its bundled `tar` is at least `7.5.19`; primary CI still consumes the committed lock only through frozen `npm ci` validation, rejects mutable npm resolution in the lock gate, requires integrity evidence for public-registry lock entries, and preserves generator-sensitive root `@esbuild/*` peer metadata. ### Fixed @@ -75,4 +74,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 67b39844c..0fed84b0c 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3563,9 +3563,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.25.0" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "getrandom 0.4.3", "js-sys", diff --git a/apps/desktop/src/lib/export.test.ts b/apps/desktop/src/lib/export.test.ts index 265e983d4..e0e97ddd6 100644 --- a/apps/desktop/src/lib/export.test.ts +++ b/apps/desktop/src/lib/export.test.ts @@ -67,6 +67,26 @@ describe("export sanitization", () => { expect(escapeCsvField("\t+SUM(A1)")).toBe("'\t+SUM(A1)"); expect(escapeCsvField("\n-100")).toBe("\"'\n-100\""); expect(escapeCsvField("\r@cmd")).toBe("\"'\r@cmd\""); + + // Prevent bypasses using NUL bytes, including a NUL-only cell. + expect(escapeCsvField("\x00=1+2")).toBe("'\x00=1+2"); + expect(escapeCsvField(" \x00@cmd")).toBe("' \x00@cmd"); + expect(escapeCsvField("\x00\x00=1+2")).toBe("'\x00\x00=1+2"); + expect(escapeCsvField(" \x00\x00@cmd")).toBe("' \x00\x00@cmd"); + expect(escapeCsvField("\x00")).toBe("'\x00"); + + // Spreadsheet/parser disagreement is not limited to NUL: fail closed on any leading C0 control. + expect(escapeCsvField("\x1B+SUM(A1)")).toBe("'\x1B+SUM(A1)"); + expect(escapeCsvField(" \x07@cmd")).toBe("' \x07@cmd"); + expect(escapeCsvField("\x1B")).toBe("'\x1B"); + }); + + it("preserves the full-width operator regression contract from PR #941", () => { + expect(escapeCsvField("=1+2")).toBe("'=1+2"); + expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)"); + expect(escapeCsvField("-100")).toBe("'-100"); + expect(escapeCsvField("@cmd")).toBe("'@cmd"); + expect(escapeCsvField(" \uFEFF=SUM(A1)")).toBe("' \uFEFF=SUM(A1)"); }); it("handles combined scenarios: formula injection with structural characters", () => { diff --git a/apps/desktop/src/lib/export.ts b/apps/desktop/src/lib/export.ts index 3d4493b1d..9d45ed6fc 100644 --- a/apps/desktop/src/lib/export.ts +++ b/apps/desktop/src/lib/export.ts @@ -11,7 +11,7 @@ import { // Security notes: // 1. Filename sanitization to prevent directory traversal or invalid characters. -// 2. CSV formula injection prevention (fields starting with =, +, -, @ must be prefixed with a single quote). +// 2. CSV formula injection prevention (dangerous ASCII/full-width formula or control initiators receive a single-quote prefix). /** Documented. */ export function sanitizeFilename(title: string): string { @@ -22,8 +22,9 @@ export function sanitizeFilename(title: string): string { /** Documented. */ export function escapeCsvField(value: string): string { let escapedValue = value; - // Prevent CSV formula injection by prefixing problematic leading characters with a single quote - if (/^[\s\uFEFF\xA0]*[=+\-@\t\r\n]/.test(value)) { + // Spreadsheet/parser disagreement can make a leading C0 control security-significant even when it precedes an operator. + // eslint-disable-next-line no-control-regex + if (/^[\s\uFEFF\xA0]*[\x00-\x1F=+\-@\uFF1D\uFF0B\uFF0D\uFF20]/.test(value)) { escapedValue = `'${value}`; } // Enclose in double quotes if there's a comma, newline, or double quote diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e7e56d311..3cf5261b9 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -41,6 +41,6 @@ GitHub is the source of truth for repository governance, PR review, CI/CD, Code ## CI/CD and release flow -- PRs into `develop` and `main` run repository CI, SBOM, and platform builds alongside organization-required OSV, dependency-review, Trivy, CodeQL/code-quality, Semgrep SAST, Strix, and Noema evidence; consolidated local security backstops run after trusted-branch pushes +- PRs into `develop` and `main` run CI, dependency review, security audit, secret-scan gate, SBOM generation, and CodeQL - release flows publish desktop artifacts plus SBOM evidence to GitHub Releases through a tag-driven draft-before-publish path - branch protection connects stable required checks after bootstrap workflows exist diff --git a/docs/repository/bootstrap-plan.md b/docs/repository/bootstrap-plan.md index 7aedb1bdd..b16f458a1 100644 --- a/docs/repository/bootstrap-plan.md +++ b/docs/repository/bootstrap-plan.md @@ -31,13 +31,12 @@ After workflows exist, require these stable checks on `main` and `develop`: - `CodeRabbit` - `ci / build-and-test` - `dependency-review` +- `security-audit` +- `CodeQL` - `sbom` +- `release-preflight` - `gate / build / windows` - `gate / build / macos` -- `trivy-fs` -- `Analyze (javascript-typescript)` -- `Analyze (python)` -- organization-required Security Scan, CodeQL/code-quality, SAST Semgrep, Strix, Noema, OpenCode, scheduler, and empty-PR workflows ## Initial README exception diff --git a/docs/security/code-security.md b/docs/security/code-security.md index 472d4d936..f9163b9c4 100644 --- a/docs/security/code-security.md +++ b/docs/security/code-security.md @@ -6,18 +6,12 @@ BandScope treats GitHub Code Security as part of bootstrap governance. ## Required controls -- organization-required CodeQL/code-quality evidence and multi-language SAST on pull requests -- organization-required Trivy filesystem and OSV vulnerability scans -- organization-required dependency review on pull requests -- repository trusted-branch security backstop for npm, Python, and Rust dependencies in scope +- CodeQL or equivalent code scanning workflow +- Trivy filesystem vulnerability scan +- dependency review on pull requests +- security audit workflow for npm, Python, and Rust dependencies in scope - Dependabot alerts and security updates -- secret scanning in GitHub plus a supplemental trusted-branch secret check - -The central Security Scan owns PR OSV, dependency-review, Trivy, and soft -Scorecard evidence. BandScope combines npm, pip, Cargo, Bandit, supplemental -secret, and Trivy checks into one trusted-branch/manual backstop. GitHub default -setup owns CodeQL, while Scorecard remains separate for its restricted publish -permissions. Central workflows own every pull-request security path. +- secret scanning in GitHub plus a supplemental secret-scan gate workflow ## Enforcement diff --git a/docs/security/github-required-checks.md b/docs/security/github-required-checks.md index ce74b1af6..eb62fdae3 100644 --- a/docs/security/github-required-checks.md +++ b/docs/security/github-required-checks.md @@ -8,18 +8,13 @@ These are the merge-gate status checks that should be required on protected bran - `ci / build-and-test` - `dependency-review` +- `security-audit` +- `CodeQL` +- `trivy-fs-scan` - `sbom` +- `release-preflight` - `gate / build / windows` - `gate / build / macos` -- `trivy-fs` -- `coverage-evidence` -- `opencode-review` -- `strix` -- `scan-pr-queue` -- `osv-scan` -- `scorecard` -- `Analyze (javascript-typescript)` -- `Analyze (python)` `gate / build / windows` must cover both Windows `amd64` and Windows `arm64`. `gate / build / macos` must cover both macOS Intel (`amd64`) and macOS `arm64`. @@ -28,28 +23,13 @@ These are the merge-gate status checks that should be required on protected bran - `ci / build-and-test` - `dependency-review` +- `security-audit` +- `CodeQL` +- `trivy-fs-scan` - `sbom` +- `release-preflight` - `gate / build / windows` - `gate / build / macos` -- `trivy-fs` -- `Analyze (javascript-typescript)` -- `Analyze (python)` - -The organization required-workflow rule is the authoritative PR owner for -`osv-scan`, `dependency-review`, `trivy-fs`, Scorecard visibility, Semgrep SAST, -Strix, and Noema. GitHub default setup owns CodeQL. One repository-local -`security-backstop` job combines dependency audits, Bandit, supplemental secret -checks, and Trivy after trusted-branch pushes or manual dispatch. Scorecard stays -separate because its publishing path has stricter permissions and SARIF handling. - -The lists above reflect the live classic required-status contexts verified on -2026-09-04. The active organization ruleset separately requires the central -`close-empty-pr.yml`, `opencode-review.yml`, `pr-review-merge-scheduler.yml`, -`security-scan.yml`, `strix.yml`, `sast-semgrep.yml`, and `noema-review.yml` -workflows on the default branch. Keep these two enforcement mechanisms distinct -when changing local triggers. The retired local `security-audit` and -`release-preflight` PR contexts were removed from classic protection with this -workflow consolidation. ## GitHub settings baseline diff --git a/docs/workflow/github-bootstrap-execution-policy.md b/docs/workflow/github-bootstrap-execution-policy.md index a88f0cddb..736b695aa 100644 --- a/docs/workflow/github-bootstrap-execution-policy.md +++ b/docs/workflow/github-bootstrap-execution-policy.md @@ -38,10 +38,12 @@ The expected sequence is: Bootstrap or setup work is not complete unless GitHub-facing supply-chain controls are both committed and, where permissions allow, enforced: - `.github/dependabot.yml` +- `.github/workflows/dependency-review.yml` - `.github/workflows/security-audit.yml` +- `.github/workflows/codeql.yml` - `.github/workflows/sbom.yml` - `.github/workflows/release.yml` -- branch protection or rulesets for `main` and `develop` that require repository CI, SBOM, platform builds, and the organization-required Security Scan, CodeQL/code-quality, SAST, Strix, and review workflows +- branch protection or rulesets for `main` and `develop` that require `ci / build-and-test`, `dependency-review`, `security-audit`, `CodeQL`, `sbom`, `release-preflight`, `gate / build / windows`, and `gate / build / macos` - PR workflow that still requests CodeRabbit review and records its result when the provider responds cleanly - release retention for the generated SBOM and supplemental inventory diff --git a/scripts/checks/verify_supply_chain.py b/scripts/checks/verify_supply_chain.py index 5b87b8bff..1cd561e5c 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -18,11 +18,13 @@ Path("apps/desktop/src-tauri/Cargo.lock"), Path(".github/dependabot.yml"), # Dependency review runs via the org-level required workflow in - # ContextualWisdomLab/.github; one repo-local security backstop and - # Scorecard stay push/schedule-only while central workflows own PR scans. + # ContextualWisdomLab/.github; repo-local CodeQL and Scorecard stay push-only + # so GitHub/Scorecard can still observe SAST and supply-chain security tabs. Path(".github/workflows/security-audit.yml"), + Path(".github/workflows/codeql.yml"), Path(".github/workflows/sbom.yml"), Path(".github/workflows/release.yml"), + Path(".github/workflows/secret-scan-gate.yml"), Path(".github/workflows/build-baseline.yml"), Path(".github/workflows/ossf-scorecard.yml"), Path(".trivyignore"), @@ -1216,7 +1218,7 @@ def _verify_dependency_review_coverage(missing: list[str]) -> None: def _verify_security_audit_coverage(missing: list[str]) -> None: audit = read_workflow(Path(".github/workflows/security-audit.yml"), "security audit", missing) - for token in ["develop", "main", "push", "bandit", "git grep", "trivy-action"]: + for token in ["develop", "main", "pull_request", "push"]: if audit and token not in audit: missing.append(f"security audit workflow missing trigger token: {token}") audit_run_commands: list[str] = [] @@ -1238,20 +1240,13 @@ def _verify_security_audit_coverage(missing: list[str]) -> None: missing.append(f"security audit workflow missing vulnerability audit token: {token}") -def _verify_bandit_coverage(missing: list[str]) -> None: - bandit = read_workflow(Path(".github/workflows/security-audit.yml"), "bandit", missing) - for token in ["develop", "main", "push", "bandit"]: - if bandit and token not in bandit: - missing.append(f"bandit workflow missing token: {token}") - if bandit and "pull_request:" in bandit: - missing.append( - "bandit workflow must stay push/manual-only; central SAST owns PR scanning" - ) - - def _verify_codeql_coverage(missing: list[str]) -> None: - if Path(".github/workflows/codeql.yml").exists(): - missing.append("repo-local codeql workflow duplicates GitHub default setup") + codeql = read_workflow( + Path(".github/workflows/codeql.yml"), "codeql", missing, optional=True + ) + for token in ["develop", "main", "push", "codeql"]: + if codeql and token not in codeql: + missing.append(f"codeql workflow missing token: {token}") def _verify_release_coverage(missing: list[str]) -> None: @@ -1259,6 +1254,7 @@ def _verify_release_coverage(missing: list[str]) -> None: for token in [ "develop", "main", + "pull_request", "push", "tags:", "release-preflight", @@ -1269,9 +1265,9 @@ def _verify_release_coverage(missing: list[str]) -> None: def _verify_secret_scan_coverage(missing: list[str]) -> None: secret_scan = read_workflow( - Path(".github/workflows/security-audit.yml"), "secret scan", missing + Path(".github/workflows/secret-scan-gate.yml"), "secret scan", missing ) - for token in ["develop", "main", "push", "git grep"]: + for token in ["develop", "main", "pull_request", "push", "secret-scan-gate"]: if secret_scan and token not in secret_scan: missing.append(f"secret scan workflow missing token: {token}") @@ -1356,7 +1352,6 @@ def verify_workflow_coverage() -> list[str]: missing: list[str] = [] _verify_ci_coverage(missing) _verify_sbom_coverage(missing) - _verify_bandit_coverage(missing) _verify_security_audit_coverage(missing) _verify_codeql_coverage(missing) _verify_release_coverage(missing) diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index 1d8224c5a..ab43df89f 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -1235,53 +1235,30 @@ def test_supply_chain_check_accepts_repo_ossf_publish_restrictions( assert not any("ossf scorecard" in violation for violation in violations) -def test_central_governance_workflows_are_consolidated_push_backstops() -> None: - """Ensure central PR governance leaves one local push security backstop.""" +def test_central_governance_workflows_are_push_only_where_local_signals_remain() -> None: + """Ensure central PR governance keeps only repo-local push security signals.""" repo_root = Path(__file__).resolve().parents[3] workflows_dir = repo_root / ".github" / "workflows" assert not (workflows_dir / "dependency-review.yml").exists() - security_backstop = workflows_dir / "security-audit.yml" - assert security_backstop.exists() - workflow = security_backstop.read_text(encoding="utf-8") - assert "pull_request:" not in workflow - for retired_workflow in ("bandit.yml", "codeql.yml", "secret-scan-gate.yml", "trivy.yml"): - assert not (workflows_dir / retired_workflow).exists() + for local_signal in ("codeql.yml", "ossf-scorecard.yml", "trivy.yml"): + workflow = workflows_dir / local_signal + assert workflow.exists(), ( + f"{local_signal} keeps repository-local security-tab/SAST signal " + "while central required workflows handle PR enforcement" + ) + assert "pull_request:" not in workflow.read_text(encoding="utf-8") supply_chain = load_module( "scripts/checks/verify_supply_chain.py", "verify_supply_chain_central" ) required = {path.as_posix() for path in supply_chain.REQUIRED_FILES} assert ".github/workflows/dependency-review.yml" not in required - assert ".github/workflows/codeql.yml" not in required - assert ".github/workflows/security-audit.yml" in required + assert ".github/workflows/codeql.yml" in required assert ".github/workflows/ossf-scorecard.yml" in required -def test_workflow_concurrency_cancels_only_superseded_pr_heads() -> None: - """Cancel same-PR stale heads without cancelling push, release, or schedule work.""" - repo_root = Path(__file__).resolve().parents[3] - workflows_dir = repo_root / ".github" / "workflows" - - for workflow_name in ("build-baseline.yml", "ci.yml", "sbom.yml"): - workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8") - assert "concurrency:" in workflow, workflow_name - assert "github.workflow }}-${{ github.repository }}" in workflow, workflow_name - assert "github.event.pull_request.number" in workflow, workflow_name - assert "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in workflow - - for workflow_name in ("ossf-scorecard.yml", "release.yml", "security-audit.yml"): - workflow = (workflows_dir / workflow_name).read_text(encoding="utf-8") - assert "concurrency:" in workflow, workflow_name - assert "cancel-in-progress: false" in workflow, workflow_name - assert "contents: read" in workflow or "permissions: read-all" in workflow, ( - workflow_name - ) - - assert "pull_request:" not in (workflows_dir / "release.yml").read_text(encoding="utf-8") - - def test_opencode_review_declares_top_level_token_permissions() -> None: """Ensure OpenCode token posture is delegated to the central required workflow.""" policy = central_required_workflow_policy_text()