From fb6637f0d3bc788b248b3acfadbad924a37fb0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Fri, 14 Aug 2026 01:22:53 +0000 Subject: [PATCH] chore(ci): Add security hygiene: CODEOWNERS for release workflows, least-privilege permissions, security checks and Scorecard (#9008) * chore(ci): add security hygiene: CODEOWNERS for release workflows, least-privilege permissions, security checks and scorecard workflows * chore(ci): pin TruffleHog scanner version and drop invalid path input * fix(ci): close security workflow review gaps * fix(ci): fail package audit on install errors * test(ci): pin security workflow guardrails * fix(ci): pin security workflow test assertions for SHA refs, status edges, and push trigger * test(ci): pin security workflow edge guards * test(ci): pin security workflow contracts * test(ci): pin secret-scan push guard * fix(ci): quote secret-scan condition * fix(ci): audit workspace package locks directly * fix(ci): scope security checks concurrency * docs(ci): explain mobile audit skip * test(ci): link trufflehog version pin --- .github/CODEOWNERS | 4 + .github/workflows/e2e.yml | 3 + .github/workflows/scorecard-monthly.yml | 43 +++++++++ .github/workflows/sdk-python.yml | 3 + .github/workflows/security-checks.yml | 96 +++++++++++++++++++ .gitignore | 2 - scripts/tests/security-workflows.test.js | 112 +++++++++++++++++++++++ 7 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/scorecard-monthly.yml create mode 100644 .github/workflows/security-checks.yml create mode 100644 scripts/tests/security-workflows.test.js diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 31f6b92af2..f754ea98d6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,6 +5,10 @@ # --- CODEOWNERS file itself --- /.github/CODEOWNERS @pomelo-nwu @wenshao +# --- Primary npm release workflows require core maintainer approval --- +/.github/workflows/release.yml @pomelo-nwu @wenshao +/.github/workflows/finalize-release.yml @pomelo-nwu @wenshao + # --- Core package --- /packages/core/ @wenshao @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 744ebbbe51..c3bf7c069a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,5 +1,8 @@ name: 'E2E Tests' +permissions: + contents: 'read' + on: # E2E is slow and currently flaky, so it is NOT in the merge queue (gating the # serial queue on it would stall every merge). It runs post-merge on `main`, diff --git a/.github/workflows/scorecard-monthly.yml b/.github/workflows/scorecard-monthly.yml new file mode 100644 index 0000000000..152a914971 --- /dev/null +++ b/.github/workflows/scorecard-monthly.yml @@ -0,0 +1,43 @@ +# .github/workflows/scorecard-monthly.yml + +name: 'Scorecard Monthly' + +on: + schedule: + # 02:00 UTC on the first day of each month. + - cron: '0 2 1 * *' + workflow_dispatch: {} + +permissions: + contents: 'read' + +defaults: + run: + shell: 'bash' + +jobs: + scorecard: + name: 'OpenSSF Scorecard' + runs-on: 'ubuntu-latest' + timeout-minutes: 20 + steps: + - name: 'Checkout' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + with: + persist-credentials: false + + - name: 'Run Scorecard' + uses: 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' # v2.4.4 + with: + results_file: 'results.json' + results_format: 'json' + publish_results: false + env: + GITHUB_AUTH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + + - name: 'Upload results' + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # v4.6.2 + with: + name: 'scorecard-${{ github.run_id }}' + path: 'results.json' + retention-days: 90 diff --git a/.github/workflows/sdk-python.yml b/.github/workflows/sdk-python.yml index ce53710e7d..33b8b60aa7 100644 --- a/.github/workflows/sdk-python.yml +++ b/.github/workflows/sdk-python.yml @@ -1,5 +1,8 @@ name: 'SDK Python' +permissions: + contents: 'read' + on: pull_request: branches: diff --git a/.github/workflows/security-checks.yml b/.github/workflows/security-checks.yml new file mode 100644 index 0000000000..539503dca6 --- /dev/null +++ b/.github/workflows/security-checks.yml @@ -0,0 +1,96 @@ +# .github/workflows/security-checks.yml + +name: 'Security Checks' + +on: + pull_request: + branches: + - 'main' + - 'release/**' + push: + branches: + - 'main' + - 'release/**' + +concurrency: + group: '${{ github.workflow }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref }}' + cancel-in-progress: "${{ github.event_name == 'pull_request' }}" + +permissions: + contents: 'read' + +defaults: + run: + shell: 'bash' + +jobs: + dependency-cve: + name: 'Dependency CVE audit' + runs-on: 'ubuntu-latest' + timeout-minutes: 15 + steps: + - name: 'Checkout' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + with: + persist-credentials: false + + - name: 'Set up Node' + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + # --ignore-scripts: CI must not run dependency install hooks. The audit + # only needs the resolved dependency tree, so skipping postinstall + # (patch-package) is safe here. + - name: 'Install dependencies' + run: 'npm ci --ignore-scripts --no-audit --progress=false' + + # Reporting-only for now: the current baseline already contains high + # severity findings, so failing the check would block every PR. Remove + # continue-on-error once the baseline is clean to make this a hard gate, + # and update scripts/tests/security-workflows.test.js with that policy. + - name: 'Audit production dependencies' + continue-on-error: true + run: | + status=0 + npm audit --omit=dev --audit-level=high || status=$? + for lockfile in packages/*/package-lock.json; do + [ -f "$lockfile" ] || continue + # Covered by the root workspace audit; this vendored lockfile is not installed directly. + [ "$lockfile" != "packages/mobile-mcp/package-lock.json" ] || continue + package_dir="${lockfile%/package-lock.json}" + ( + cd "$package_dir" + npm ci --ignore-scripts --no-audit --progress=false --workspaces=false && + npm audit --omit=dev --audit-level=high --workspaces=false + ) || status=$? + done + exit "$status" + + secret-scan: + name: 'Secret scan (TruffleHog)' + runs-on: 'ubuntu-latest' + timeout-minutes: 15 + steps: + - name: 'Checkout' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + with: + persist-credentials: false + fetch-depth: 0 + + # Incremental gate: the action scans commits introduced by the PR or + # push and only fails on secrets TruffleHog could actively verify, + # keeping false positives out. Branch-creating pushes have no base + # commit to diff from, so they are skipped explicitly. Reporting-only + # for the first runs; remove continue-on-error once the history has been + # triaged, and update scripts/tests/security-workflows.test.js with + # that policy. + # `version` pins the scanner image; without it the action runs `latest`. + - name: 'Scan for verified secrets' + if: "github.event_name == 'pull_request' || github.event.before != '0000000000000000000000000000000000000000'" + uses: 'trufflesecurity/trufflehog@6f3c981e7b77f235fd2702dd74af25fc4b72bf11' # v3.96.0 + continue-on-error: true + with: + version: '3.96.0' + extra_args: '--only-verified' diff --git a/.gitignore b/.gitignore index 493d7b8afe..d86a31bfb6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,6 @@ # Dependency directory node_modules bower_components -package-lock.json -!packages/desktop-shell/package-lock.json # Editors .idea diff --git a/scripts/tests/security-workflows.test.js b/scripts/tests/security-workflows.test.js new file mode 100644 index 0000000000..95764842ea --- /dev/null +++ b/scripts/tests/security-workflows.test.js @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); + +const readWorkflow = (name) => + readFileSync(path.join(repoRoot, `.github/workflows/${name}`), 'utf8'); + +describe('security workflows', () => { + it('keeps Scorecard monthly and reporting-only', () => { + const workflow = readWorkflow('scorecard-monthly.yml'); + + expect(workflow).toContain("- cron: '0 2 1 * *'"); + expect(workflow).toContain('workflow_dispatch: {}'); + expect(workflow).not.toContain('pull_request'); + expect(workflow).toContain('publish_results: false'); + expect(workflow).toContain('retention-days: 90'); + expect(workflow).toContain( + 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc', + ); + expect(workflow).toContain( + 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10', + ); + expect(workflow).toContain( + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02', + ); + expect(workflow).toContain('persist-credentials: false'); + }); + + it('keeps Security Checks reporting-only and audits package locks', () => { + const workflow = readWorkflow('security-checks.yml'); + const dependencyJob = getWorkflowJob(workflow, 'dependency-cve'); + const dependencyCheckoutStep = getWorkflowStep(dependencyJob, 'Checkout'); + const installStep = getWorkflowStep(dependencyJob, 'Install dependencies'); + const auditStep = getWorkflowStep( + dependencyJob, + 'Audit production dependencies', + ); + const secretScanJob = getWorkflowJob(workflow, 'secret-scan'); + const checkoutStep = getWorkflowStep(secretScanJob, 'Checkout'); + const trufflehogStep = getWorkflowStep( + secretScanJob, + 'Scan for verified secrets', + ); + + expect(workflow).toContain('pull_request:'); + expect(workflow).toContain('push:'); + expect(workflow).toContain( + "group: '${{ github.workflow }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref }}'", + ); + expect(workflow).toContain( + 'cancel-in-progress: "${{ github.event_name == \'pull_request\' }}"', + ); + expect(workflow).toContain( + 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10', + ); + expect(workflow).toContain( + 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e', + ); + expect(dependencyCheckoutStep).toContain('persist-credentials: false'); + expect(checkoutStep).toContain('persist-credentials: false'); + expect(installStep).toContain( + "run: 'npm ci --ignore-scripts --no-audit --progress=false'", + ); + expect(auditStep).toContain('continue-on-error: true'); + expect(auditStep).toContain('status=0'); + expect(auditStep).toContain('exit "$status"'); + expect(auditStep).toContain('npm audit --omit=dev --audit-level=high'); + expect(auditStep).toContain( + 'npm audit --omit=dev --audit-level=high || status=$?', + ); + expect(auditStep).toContain(') || status=$?'); + expect(auditStep).toContain('for lockfile in packages/*/package-lock.json'); + expect(auditStep).toContain('[ -f "$lockfile" ] || continue'); + expect(auditStep).toContain( + '[ "$lockfile" != "packages/mobile-mcp/package-lock.json" ] || continue', + ); + expect(auditStep).toContain('cd "$package_dir"'); + expect(auditStep).toContain( + 'npm ci --ignore-scripts --no-audit --progress=false --workspaces=false &&', + ); + expect(auditStep).toContain( + 'npm audit --omit=dev --audit-level=high --workspaces=false', + ); + expect(trufflehogStep).toContain('continue-on-error: true'); + const trufflehogPin = trufflehogStep.match( + /trufflesecurity\/trufflehog@[0-9a-f]{40}' # v([\d.]+)/, + ); + expect(trufflehogPin).not.toBeNull(); + expect(trufflehogStep).toContain(`version: '${trufflehogPin?.[1]}'`); + expect(trufflehogStep).toContain( + "if: \"github.event_name == 'pull_request' || github.event.before != '0000000000000000000000000000000000000000'\"", + ); + expect(trufflehogStep).toContain("extra_args: '--only-verified'"); + expect(trufflehogStep).toContain( + 'trufflesecurity/trufflehog@6f3c981e7b77f235fd2702dd74af25fc4b72bf11', + ); + expect(checkoutStep).toContain('fetch-depth: 0'); + }); +});