diff --git a/.github/scripts/pnpm-bulk-audit.mjs b/.github/scripts/pnpm-bulk-audit.mjs new file mode 100644 index 0000000..d514ac8 --- /dev/null +++ b/.github/scripts/pnpm-bulk-audit.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process' +import { createRequire } from 'node:module' + +const severityRank = new Map([ + ['info', 0], + ['low', 1], + ['moderate', 2], + ['high', 3], + ['critical', 4], +]) +const level = (process.env.AUDIT_LEVEL || 'high').toLowerCase() +const threshold = severityRank.get(level) +if (threshold === undefined) { + throw new Error(`Unsupported AUDIT_LEVEL: ${level}`) +} + +const productionOnly = process.env.PRODUCTION_ONLY === 'true' +const args = ['list', '-r', ...(productionOnly ? ['--prod'] : []), '--json', '--depth', 'Infinity'] +const projects = JSON.parse(execFileSync('pnpm', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) || '[]') +const versionsByName = new Map() + +function collect(dependencies) { + if (!dependencies || typeof dependencies !== 'object') return + for (const [name, info] of Object.entries(dependencies)) { + if (!info || typeof info !== 'object') continue + const version = info.version + if (typeof version === 'string' && version && !version.startsWith('link:') && !version.startsWith('workspace:')) { + if (!versionsByName.has(name)) versionsByName.set(name, new Set()) + versionsByName.get(name).add(version) + } + collect(info.dependencies) + } +} + +for (const project of Array.isArray(projects) ? projects : [projects]) { + collect(project.dependencies) +} +if (versionsByName.size === 0) { + throw new Error('No dependencies found in the pnpm dependency graph') +} + +const dependencyVersions = Object.fromEntries( + [...versionsByName].map(([name, versions]) => [name, [...versions].sort()]), +) +const response = await fetch('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'user-agent': 'agentskit-pnpm-bulk-audit', + }, + body: JSON.stringify(dependencyVersions), +}) +if (!response.ok) { + throw new Error(`Bulk advisory endpoint HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`) +} + +const advisoriesByPackage = await response.json() +let semver +try { + semver = createRequire(`${process.cwd()}/package.json`)('semver') +} catch { + semver = { satisfies: (version, range) => range === version || range === `=${version}` } +} + +const findings = [] +for (const [name, versions] of Object.entries(dependencyVersions)) { + for (const version of versions) { + for (const advisory of advisoriesByPackage[name] ?? []) { + const severity = String(advisory.severity ?? '').toLowerCase() + const vulnerableRange = advisory.vulnerable_versions ?? advisory.vulnerableVersionRange + if ( + severityRank.has(severity) && severityRank.get(severity) >= threshold && + typeof vulnerableRange === 'string' && + semver.satisfies(version, vulnerableRange, { includePrerelease: true }) + ) { + findings.push({ name, version, severity, title: advisory.title ?? 'unknown', url: advisory.url ?? '' }) + } + } + } +} + +if (findings.length) { + for (const finding of findings) { + process.stderr.write(`${finding.severity}: ${finding.name}@${finding.version}: ${finding.title} ${finding.url}\n`) + } + process.exitCode = 1 +} else { + process.stdout.write(`No ${level}+ advisories found across ${versionsByName.size} packages.\n`) +} diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..382025a --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,87 @@ +name: Package Audit + +on: + workflow_call: + inputs: + package-manager: + description: Package manager used by the selected working directories + required: true + type: string + working-directories: + description: JSON array of package directories, such as [".","apps/docs"] + required: true + type: string + audit-level: + description: Minimum npm or pnpm audit severity that fails the check + required: false + type: string + default: high + production-only: + description: Audit production dependencies only + required: false + type: boolean + default: false + use-pnpm-bulk-api: + description: Use npm's supported bulk advisory API for a pnpm dependency graph + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + audit: + if: (inputs.package-manager == 'npm' || inputs.package-manager == 'pnpm') && fromJSON(inputs.working-directories)[0] != null + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + working-directory: ${{ fromJSON(inputs.working-directories) }} + defaults: + run: + working-directory: ${{ matrix.working-directory }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Set up pnpm + if: inputs.package-manager == 'pnpm' + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - name: Install pnpm dependencies for bulk advisory inventory + if: inputs.package-manager == 'pnpm' && inputs.use-pnpm-bulk-api + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Run pnpm bulk advisory audit + if: inputs.package-manager == 'pnpm' && inputs.use-pnpm-bulk-api + shell: bash + env: + AUDIT_LEVEL: ${{ inputs.audit-level }} + PRODUCTION_ONLY: ${{ inputs.production-only }} + run: node "$GITHUB_WORKSPACE/.github/scripts/pnpm-bulk-audit.mjs" + - name: Run npm audit + if: inputs.package-manager == 'npm' + shell: bash + env: + AUDIT_LEVEL: ${{ inputs.audit-level }} + PRODUCTION_ONLY: ${{ inputs.production-only }} + run: | + if [[ "$PRODUCTION_ONLY" == 'true' ]]; then + npm audit --omit=dev --audit-level="$AUDIT_LEVEL" + else + npm audit --audit-level="$AUDIT_LEVEL" + fi + - name: Run pnpm audit + if: inputs.package-manager == 'pnpm' && !inputs.use-pnpm-bulk-api + shell: bash + env: + AUDIT_LEVEL: ${{ inputs.audit-level }} + PRODUCTION_ONLY: ${{ inputs.production-only }} + run: | + if [[ "$PRODUCTION_ONLY" == 'true' ]]; then + pnpm audit --prod --audit-level="$AUDIT_LEVEL" + else + pnpm audit --audit-level="$AUDIT_LEVEL" + fi diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..ce59723 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: CodeQL + +on: + workflow_call: + inputs: + languages: + description: Comma-separated CodeQL languages, for example javascript-typescript + required: true + type: string + queries: + description: CodeQL query suites or query paths + required: false + type: string + default: security-extended + build-mode: + description: CodeQL build mode (none, autobuild, or manual) + required: false + type: string + default: autobuild + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ inputs.languages }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Initialize CodeQL + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + languages: ${{ inputs.languages }} + queries: ${{ inputs.queries }} + build-mode: ${{ inputs.build-mode }} + - name: Analyze + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..6752bcf --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,38 @@ +name: Dependency Review + +on: + workflow_call: + inputs: + fail-on-severity: + description: Minimum vulnerability severity that fails the check + required: false + type: string + default: critical + deny-licenses: + description: Comma-separated SPDX license expressions to deny + required: false + type: string + default: '' + allow-licenses: + description: Comma-separated SPDX license expressions to allow + required: false + type: string + default: '' + +permissions: + contents: read + +jobs: + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: ${{ inputs.fail-on-severity }} + deny-licenses: ${{ inputs.deny-licenses }} + allow-licenses: ${{ inputs.allow-licenses }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..b1f6514 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,32 @@ +name: OpenSSF Scorecard + +on: + workflow_call: + +permissions: + contents: read + +jobs: + scorecard: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + security-events: write + steps: + - name: Run Scorecard + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Scorecard + path: results.sarif + if-no-files-found: error + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + sarif_file: results.sarif diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml new file mode 100644 index 0000000..e090164 --- /dev/null +++ b/.github/workflows/self-test.yml @@ -0,0 +1,51 @@ +name: Reusable Workflow Self-Test + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Lint workflows + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + with: + version: 1.7.12 + cache: false + shellcheck: false + pyflakes: false + + codeql: + uses: ./.github/workflows/codeql.yml + permissions: + actions: read + contents: read + security-events: write + with: + languages: actions + queries: security-extended + build-mode: none + + scorecard: + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + uses: ./.github/workflows/scorecard.yml + permissions: + contents: read + id-token: write + security-events: write + + audit: + uses: ./.github/workflows/audit.yml + permissions: + contents: read + with: + package-manager: npm + working-directories: '[]' + audit-level: high + production-only: true diff --git a/README.md b/README.md index 728210a..1034f50 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,143 @@ # .github Shared reusable workflows and dependency-update presets for AgentsKit-io repositories +# AgentsKit shared GitHub configuration + +This public repository provides reusable security checks and an organization +Renovate preset. Each calling repository keeps its own triggers, build matrix, +release workflow, and package-specific checks. + +## Reusable security workflows + +Each caller should pin a reusable workflow to a full commit SHA. The examples +below use reviewed workflow revision +`a92994b7ee9271d6124414bd7e3709265d7ecea5`; update that reference only after +reviewing a newer revision. + +| Workflow | What it checks | Inputs | +| --- | --- | --- | +| `codeql.yml` | CodeQL static analysis and SARIF upload. Uses the selected language set, query suite, and build mode. | `languages` (required, comma-separated); `queries` (default `security-extended`); `build-mode` (default `autobuild`) | +| `scorecard.yml` | OpenSSF Scorecard and publishes its SARIF results. | None | +| `dependency-review.yml` | New or changed dependencies on pull requests. Does not write PR comments. | `fail-on-severity` (`critical` by default; use `high` to match stricter current callers); `deny-licenses`; `allow-licenses` (comma-separated SPDX values, both default empty) | +| `audit.yml` | npm or pnpm advisory audit in each supplied directory. Production-only mode omits development dependencies. | `package-manager` (`npm` or `pnpm`, required); `working-directories` (required JSON array); `audit-level` (default `high`); `production-only` (default `false`); `use-pnpm-bulk-api` (default `false`, for pnpm's Bulk Advisory API path) | + +The five-repository inventory informed these interfaces: CodeQL covers the +four repositories that currently run it; Scorecard and dependency review cover +those same four; package audit supports the npm root plus `apps/docs` shape and +the pnpm production audits used by the workspaces. Set `use-pnpm-bulk-api: true` +for AgentsKit to preserve its lockfile-resolved production graph check against +npm's supported Bulk Advisory API; other pnpm callers can keep using native +`pnpm audit`. Existing callers can retain their severity and license policies +through inputs. The central workflows do not replace repository-specific CI, +release, publishing, or build steps. + +### Caller examples + +CodeQL, matching the `javascript-typescript` repositories: + +```yaml +name: CodeQL +on: + push: + pull_request: + schedule: + - cron: '23 5 * * 1' +permissions: + contents: read +jobs: + analyze: + uses: AgentsKit-io/.github/.github/workflows/codeql.yml@a92994b7ee9271d6124414bd7e3709265d7ecea5 + with: + languages: javascript-typescript + queries: security-extended + build-mode: autobuild + permissions: + actions: read + contents: read + security-events: write +``` + +Scorecard: + +```yaml +name: Scorecard +on: + branch_protection_rule: + schedule: + - cron: '17 4 * * 1' + push: + branches: [main] +permissions: + contents: read +jobs: + scorecard: + uses: AgentsKit-io/.github/.github/workflows/scorecard.yml@a92994b7ee9271d6124414bd7e3709265d7ecea5 + permissions: + contents: read + id-token: write + security-events: write +``` + +Dependency review, preserving the GPL/AGPL deny list used by AgentsKit: + +```yaml +name: Dependency Review +on: + pull_request: +permissions: + contents: read +jobs: + review: + uses: AgentsKit-io/.github/.github/workflows/dependency-review.yml@a92994b7ee9271d6124414bd7e3709265d7ecea5 + with: + fail-on-severity: high + deny-licenses: GPL-2.0,GPL-3.0,AGPL-1.0,AGPL-3.0 + permissions: + contents: read + pull-requests: read +``` + +npm audit for Code Review's production dependencies in both package roots: + +```yaml +name: Dependency Audit +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + audit: + uses: AgentsKit-io/.github/.github/workflows/audit.yml@a92994b7ee9271d6124414bd7e3709265d7ecea5 + with: + package-manager: npm + working-directories: '[".","apps/docs"]' + audit-level: high + production-only: true + permissions: + contents: read +``` + +For a pnpm workspace, use `package-manager: pnpm`, pass its lockfile root (or +each audited workspace directory) in `working-directories`, and set +`audit-level: critical` to preserve the current Chat and Playbook threshold. +For the AgentsKit audit, also set `use-pnpm-bulk-api: true`, +`audit-level: high`, and `production-only: true`. +Do not use `secrets: inherit`; these security workflows need no caller secrets. + +### Pin updates + +External actions are pinned to full commit SHAs. The adjacent version comments +record the upstream release represented by each SHA. When updating an action, +select the newest version already exercised in the five-repository inventory, +verify the release SHA against the upstream repository, update the comment, +and run the self-test. Callers pin this repository's reusable workflows to a +reviewed full commit SHA; Renovate can propose those pin updates for review. +The self-test runs on pushes and pull requests, invokes each reusable workflow +on this repository where applicable, and runs actionlint v1.7.12. The central +repository has no package manifests, so the audit call uses an empty directory +list and is intentionally skipped. Dependency review needs GitHub's dependency +graph, which is disabled here; that reusable workflow is validated by +actionlint and should be exercised by a caller repository with the graph +enabled. Scorecard runs on PRs and on pushes to the default branch because the +upstream action only accepts the default branch for push events.