Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/scripts/pnpm-bulk-audit.mjs
Original file line number Diff line number Diff line change
@@ -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`)
}
87 changes: 87 additions & 0 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
@@ -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 }}
32 changes: 32 additions & 0 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions .github/workflows/self-test.yml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading