From 5465a146842d7eb4425d25a1b388ff8619b768ac Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 27 Mar 2026 21:15:12 +0530 Subject: [PATCH] chore: add GitHub DevSecOps workflows --- .github/dependabot.yml | 24 ++++++ .github/workflows/ci.yml | 27 +++++++ .github/workflows/codeql.yml | 46 ++++++++++++ .github/workflows/prepare-release.yml | 102 +++++++++++++++++++++----- .github/workflows/release.yml | 36 ++++++++- .github/workflows/secrets.yml | 40 ++++++++++ .github/workflows/security.yml | 84 +++++++++++++++++++++ docs/devsecops.md | 30 ++++++++ package.json | 2 +- 9 files changed, 367 insertions(+), 24 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/secrets.yml create mode 100644 .github/workflows/security.yml create mode 100644 docs/devsecops.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3e159e3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + npm-non-major: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d608659..35258d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,15 @@ on: branches: - main workflow_dispatch: + inputs: + base_ref: + description: "Optional base ref for dependency review when dispatched manually" + required: false + type: string + head_ref: + description: "Optional head ref for dependency review when dispatched manually" + required: false + type: string permissions: contents: read @@ -15,9 +24,27 @@ concurrency: cancel-in-progress: true jobs: + dependency-review: + if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.base_ref != '' && inputs.head_ref != '') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + + - name: Run dependency review + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 + with: + base-ref: ${{ inputs.base_ref || '' }} + head-ref: ${{ inputs.head_ref || '' }} + validate: runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read steps: - name: Check out repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9801c49 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,46 @@ +name: CodeQL + +on: + pull_request: + paths-ignore: + - dist/** + - test/fixtures/** + - .cstack/** + push: + branches: + - main + paths-ignore: + - dist/** + - test/fixtures/** + - .cstack/** + schedule: + - cron: "17 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: CodeQL + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + fetch-depth: 0 + + - name: Initialize CodeQL + uses: github/codeql-action/init@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 + with: + languages: javascript-typescript + build-mode: none + + - name: Analyze + uses: github/codeql-action/analyze@ebcb5b36ded6beda4ceefea6a8bc4cc885255bb3 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index ed3d3aa..6aff47a 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -8,10 +8,6 @@ on: required: true type: string -permissions: - actions: write - contents: write - concurrency: group: prepare-release-${{ inputs.version }} cancel-in-progress: false @@ -19,10 +15,14 @@ concurrency: jobs: prepare-release: runs-on: ubuntu-latest + permissions: + actions: write + contents: write env: VERSION: ${{ inputs.version }} TAG: v${{ inputs.version }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REPOSITORY: ${{ github.repository }} steps: - name: Check out default branch uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd @@ -62,7 +62,9 @@ jobs: run: npm ci - name: Check current package version - run: echo "CURRENT_VERSION=$(node -p "require('./package.json').version")" >> "$GITHUB_ENV" + run: | + echo "CURRENT_VERSION=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_ENV" + echo "BASE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" - name: Bump package version shell: bash @@ -110,30 +112,92 @@ jobs: if: steps.commit.outputs.created == 'true' env: GITHUB_TOKEN: ${{ github.token }} - run: gh workflow run ci.yml --ref "${DEFAULT_BRANCH}" + run: | + RELEASE_SHA="$(git rev-parse HEAD)" + gh workflow run ci.yml \ + --ref "${DEFAULT_BRANCH}" \ + -f base_ref="${BASE_SHA}" \ + -f head_ref="${RELEASE_SHA}" + + - name: Dispatch CodeQL for pushed release commit + if: steps.commit.outputs.created == 'true' + env: + GITHUB_TOKEN: ${{ github.token }} + run: gh workflow run codeql.yml --ref "${DEFAULT_BRANCH}" + + - name: Dispatch Gitleaks for pushed release commit + if: steps.commit.outputs.created == 'true' + env: + GITHUB_TOKEN: ${{ github.token }} + run: gh workflow run secrets.yml --ref "${DEFAULT_BRANCH}" - - name: Wait for required CI on pushed release commit + - name: Wait for required checks on pushed release commit if: steps.commit.outputs.created == 'true' env: GITHUB_TOKEN: ${{ github.token }} shell: bash run: | + REQUIRED_CHECKS=("validate" "dependency-review" "CodeQL" "Gitleaks") RELEASE_SHA="$(git rev-parse HEAD)" - RUN_ID="" - for attempt in $(seq 1 30); do - RUN_ID="$(gh run list --workflow ci.yml --commit "${RELEASE_SHA}" --json databaseId --jq '.[0].databaseId // empty' || true)" - if [ -n "${RUN_ID}" ]; then - break + MAX_ATTEMPTS=180 + + get_check_state() { + local check_name="$1" + gh api "repos/${REPOSITORY}/commits/${RELEASE_SHA}/check-runs?per_page=100" \ + | jq -r --arg name "${check_name}" ' + [.check_runs[] + | select(.name == $name) + | {id, status, conclusion}] + | sort_by(.id) + | last + | if . == null then + "missing\t" + else + "\(.status)\t\(.conclusion // "")" + end + ' + } + + for _ in $(seq 1 "${MAX_ATTEMPTS}"); do + pending_checks=() + failed_checks=() + + for check_name in "${REQUIRED_CHECKS[@]}"; do + check_state="$(get_check_state "${check_name}" || true)" + check_status="${check_state%%$'\t'*}" + check_conclusion="${check_state#*$'\t'}" + + if [ -z "${check_state}" ] || [ "${check_status}" = "missing" ]; then + pending_checks+=("${check_name}") + continue + fi + + if [ "${check_status}" != "completed" ]; then + pending_checks+=("${check_name}") + continue + fi + + if [ "${check_conclusion}" != "success" ]; then + failed_checks+=("${check_name}:${check_conclusion:-unknown}") + fi + done + + if [ "${#failed_checks[@]}" -gt 0 ]; then + printf 'Required check failure(s): %s\n' "${failed_checks[*]}" + exit 1 fi - sleep 3 - done - if [ -z "${RUN_ID}" ]; then - echo "Required CI run was not found for ${RELEASE_SHA}" - exit 1 - fi + if [ "${#pending_checks[@]}" -eq 0 ]; then + echo "All required checks passed for ${RELEASE_SHA}." + exit 0 + fi + + echo "Waiting for required checks: ${pending_checks[*]}" + sleep 10 + done - gh run watch "${RUN_ID}" --exit-status + echo "Timed out waiting for required checks on ${RELEASE_SHA}." + exit 1 - name: Create release tag run: git tag "${TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c30504..2cdad5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,6 @@ on: required: true type: string -permissions: - contents: write - concurrency: group: release-${{ github.ref_name }} cancel-in-progress: false @@ -21,6 +18,11 @@ concurrency: jobs: release: runs-on: ubuntu-latest + permissions: + contents: write + attestations: write + artifact-metadata: write + id-token: write steps: - name: Check out repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd @@ -48,7 +50,7 @@ jobs: - name: Validate package version matches tag shell: bash run: | - PACKAGE_VERSION="$(node -p "require('./package.json').version")" + PACKAGE_VERSION="$(node -p 'require(\"./package.json\").version')" EXPECTED_TAG="v${PACKAGE_VERSION}" if [ "${EXPECTED_TAG}" != "${RELEASE_TAG}" ]; then echo "package.json version ${PACKAGE_VERSION} does not match tag ${RELEASE_TAG}" @@ -76,6 +78,13 @@ jobs: cp "release/${PACKAGE_FILE}" "release/cstack-latest.tgz" echo "PACKAGE_FILE=${PACKAGE_FILE}" >> "$GITHUB_ENV" + - name: Record release artifact paths + id: release_paths + shell: bash + run: | + echo "package_file=${PACKAGE_FILE}" >> "$GITHUB_OUTPUT" + echo "sbom_file=release/cstack-${PACKAGE_VERSION}.sbom.spdx.json" >> "$GITHUB_OUTPUT" + - name: Smoke-test packaged install shell: bash run: | @@ -85,6 +94,22 @@ jobs: npm install -g "./release/${PACKAGE_FILE}" --prefix "${INSTALL_ROOT}" "${INSTALL_ROOT}/bin/cstack" --help >/dev/null + - name: Generate SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 + with: + file: release/${{ steps.release_paths.outputs.package_file }} + format: spdx-json + output-file: ${{ steps.release_paths.outputs.sbom_file }} + upload-artifact: false + upload-release-assets: false + + - name: Create provenance attestation + uses: actions/attest-build-provenance@b3e506e8c389afc651c5bacf2b8f2a1ea0557215 + with: + subject-path: | + release/${{ steps.release_paths.outputs.package_file }} + release/cstack-latest.tgz + - name: Generate checksums shell: bash run: | @@ -132,6 +157,7 @@ jobs: - packaged CLI entrypoint - compiled \`dist/\` runtime - current README and install guidance + - SBOM and provenance attestation for the published tarball ## Quick start @@ -151,6 +177,7 @@ jobs: "release/${PACKAGE_FILE}" \ "release/cstack-latest.tgz" \ "release/SHA256SUMS.txt" \ + "${{ steps.release_paths.outputs.sbom_file }}" \ --clobber gh release edit "${RELEASE_TAG}" \ --title "cstack ${RELEASE_TAG}" \ @@ -160,6 +187,7 @@ jobs: "release/${PACKAGE_FILE}" \ "release/cstack-latest.tgz" \ "release/SHA256SUMS.txt" \ + "${{ steps.release_paths.outputs.sbom_file }}" \ --title "cstack ${RELEASE_TAG}" \ --notes-file release/RELEASE_NOTES.md fi diff --git a/.github/workflows/secrets.yml b/.github/workflows/secrets.yml new file mode 100644 index 0000000..f5dc1ab --- /dev/null +++ b/.github/workflows/secrets.yml @@ -0,0 +1,40 @@ +name: Gitleaks + +on: + pull_request: + paths-ignore: + - dist/** + - test/fixtures/** + - .cstack/** + push: + branches: + - main + paths-ignore: + - dist/** + - test/fixtures/** + - .cstack/** + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gitleaks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + name: Gitleaks + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + fetch-depth: 0 + + - name: Scan for secrets + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 + env: + GITHUB_TOKEN: ${{ github.token }} + GITLEAKS_ENABLE_COMMENTS: false diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..3e50abe --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,84 @@ +name: Security Intelligence + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-intelligence + cancel-in-progress: false + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 + with: + node-version: 24 + cache: npm + + - name: Prepare report directory + run: mkdir -p reports + + - name: Run npm audit + shell: bash + continue-on-error: true + run: | + npm audit --json > reports/npm-audit.json + + - name: Install OSV-Scanner + shell: bash + run: | + set -euo pipefail + version="v2.3.5" + base_url="https://github.com/google/osv-scanner/releases/download/${version}" + curl -fsSLo /tmp/osv-scanner "${base_url}/osv-scanner_linux_amd64" + curl -fsSLo /tmp/osv-scanner_SHA256SUMS "${base_url}/osv-scanner_SHA256SUMS" + expected="$(grep 'osv-scanner_linux_amd64$' /tmp/osv-scanner_SHA256SUMS | awk '{print $1}')" + actual="$(sha256sum /tmp/osv-scanner | awk '{print $1}')" + if [ "${expected}" != "${actual}" ]; then + echo "OSV-Scanner checksum mismatch" + exit 1 + fi + install -m 0755 /tmp/osv-scanner "${RUNNER_TEMP}/osv-scanner" + + - name: Run OSV-Scanner + shell: bash + continue-on-error: true + run: | + "${RUNNER_TEMP}/osv-scanner" --format sarif -L package-lock.json > reports/osv-scanner.sarif + + - name: Generate SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 + with: + path: . + format: spdx-json + output-file: reports/sbom.spdx.json + upload-artifact: false + + - name: Run OSSF Scorecard + uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2 + with: + results_file: reports/scorecard.sarif + results_format: sarif + publish_results: false + repo_token: ${{ secrets.GITHUB_TOKEN }} + file_mode: archive + + - name: Upload security reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: security-reports + path: reports/ + if-no-files-found: error + retention-days: 14 diff --git a/docs/devsecops.md b/docs/devsecops.md new file mode 100644 index 0000000..7c7ee42 --- /dev/null +++ b/docs/devsecops.md @@ -0,0 +1,30 @@ +# DevSecOps Baseline + +This repository's package-manager contract is `npm` with the committed `package-lock.json`. +Security workflows, release automation, and update automation should target `npm` and must not assume `pnpm`. + +## Required Pull Request Checks + +- `validate` +- `dependency-review` +- `CodeQL` +- `Gitleaks` + +`Live Codex Smoke` is informational and must remain non-blocking. + +## Required Repository Settings + +- Protect `main` and require the checks listed above before merge. +- Enable Dependabot alerts and security updates. +- Enable GitHub secret scanning if available for the repository. +- Keep release tagging and GitHub Release publishing limited to the release workflows. + +## Release Expectations + +The release pipeline publishes GitHub Release assets only. A successful release should publish: + +- the versioned package tarball +- `cstack-latest.tgz` +- `SHA256SUMS.txt` +- `cstack-.sbom.spdx.json` +- build provenance attestation for the release artifacts diff --git a/package.json b/package.json index e3ea06d..0206447 100644 --- a/package.json +++ b/package.json @@ -49,5 +49,5 @@ "typescript": "^5.8.3", "vitest": "^3.2.4" }, - "packageManager": "pnpm@9.12.0+sha512.4abf725084d7bcbafbd728bfc7bee61f2f791f977fd87542b3579dcb23504d170d46337945e4c66485cd12d588a0c0e570ed9c477e7ccdd8507cf05f3f92eaca" + "packageManager": "npm@11.7.0" }