chore: import upstream snapshot with attribution
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"pull_request": {
|
||||
"number": 1,
|
||||
"head": {
|
||||
"ref": "test-branch",
|
||||
"sha": "abc123def456"
|
||||
},
|
||||
"base": {
|
||||
"ref": "main",
|
||||
"sha": "def456abc123"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"name": "test-repo",
|
||||
"owner": {
|
||||
"login": "test-owner"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Test Code Scan Action
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for accurate diff
|
||||
|
||||
- name: Setup git for testing (act only)
|
||||
if: ${{ env.ACT }}
|
||||
run: |
|
||||
git config --global user.email "test@example.com"
|
||||
git config --global user.name "Test User"
|
||||
git init
|
||||
git add -A
|
||||
git commit -m "Initial commit"
|
||||
git checkout -b main
|
||||
git checkout -b test-branch
|
||||
echo "test change" >> README.md
|
||||
git add README.md
|
||||
git commit -m "Test change"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: '24.17.0'
|
||||
|
||||
- name: Run Promptfoo Code Scan
|
||||
uses: ./ # Action is in root directory
|
||||
with:
|
||||
api-host: 'http://host.docker.internal:2095'
|
||||
minimum-severity: 'medium'
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Code Scan GitHub Action
|
||||
|
||||
This package contains the GitHub Action wrapper for Promptfoo code scan.
|
||||
|
||||
## Rules
|
||||
|
||||
- Treat GitHub event fields, changed paths, `guidance`, and `guidance-file` contents as
|
||||
untrusted. Pass PR-controlled values through `@actions/exec` argument arrays, not
|
||||
shell interpolation.
|
||||
- Keep `github-token` out of package installs, scanner subprocesses, and PR-controlled
|
||||
code. Preserve sanitized npm env handling, and keep fork-PR/OIDC fallback explicit.
|
||||
- Keep `action.yml`, `src/main.ts`, tests, `site/docs/code-scanning/github-action.md`,
|
||||
and `code-scan-action/README.md` aligned when inputs or setup behavior change.
|
||||
- This directory has its own package and lockfile. Update them only for action
|
||||
dependency changes, and add `dist/` only when packaging an action release.
|
||||
|
||||
## Validation
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
npx vitest run test/code-scan-action
|
||||
npm --prefix code-scan-action run tsc
|
||||
npm --prefix code-scan-action run build
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this package will be documented in this file.
|
||||
|
||||
## [0.1.8](https://github.com/promptfoo/promptfoo/compare/code-scan-action-0.1.7...code-scan-action-0.1.8) (2026-06-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **code-scan:** process findings in mixed skip responses ([#9525](https://github.com/promptfoo/promptfoo/issues/9525)) ([aeae790](https://github.com/promptfoo/promptfoo/commit/aeae7904701f861a0a7ce8bfcdd16f67e74deec3))
|
||||
- **code-scan:** run GitHub Action on Node 24 runtime ([#9772](https://github.com/promptfoo/promptfoo/issues/9772)) ([c25974e](https://github.com/promptfoo/promptfoo/commit/c25974e8e74a5ba923c0318cb6576e2b4dfc9ad6))
|
||||
|
||||
## [0.1.7](https://github.com/promptfoo/promptfoo/compare/code-scan-action-0.1.6...code-scan-action-0.1.7) (2026-05-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **code-scan:** emit structured fork PR skip output ([#9426](https://github.com/promptfoo/promptfoo/issues/9426)) ([61c624c](https://github.com/promptfoo/promptfoo/commit/61c624c7f91808a6f59d8b837dbb3896dd9a74c0))
|
||||
- **code-scan:** honor minimum-severity alias when min-severity is unset ([#9433](https://github.com/promptfoo/promptfoo/issues/9433)) ([ea5ea9e](https://github.com/promptfoo/promptfoo/commit/ea5ea9e741da0968431de25c0d67337f2e0e6e20))
|
||||
- **eval:** show test descriptions in HTML output ([#9390](https://github.com/promptfoo/promptfoo/issues/9390)) ([206f1dd](https://github.com/promptfoo/promptfoo/commit/206f1dd9848607ed9d4eba1f7998f90b8f87f0ff))
|
||||
- **providers:** honor Vertex default host overrides ([#9389](https://github.com/promptfoo/promptfoo/issues/9389)) ([11a8d9b](https://github.com/promptfoo/promptfoo/commit/11a8d9b1c4edf214e8e15dd6794690ab49d24430))
|
||||
|
||||
## [0.1.6](https://github.com/promptfoo/promptfoo/compare/code-scan-action-0.1.5...code-scan-action-0.1.6) (2026-05-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **code-scan:** add SARIF output support ([#9161](https://github.com/promptfoo/promptfoo/issues/9161)) ([4da26e9](https://github.com/promptfoo/promptfoo/commit/4da26e95e4837ad9fd3363dfb52a86e5e1ceb66d))
|
||||
- **code-scan:** refine SARIF output ergonomics ([#9159](https://github.com/promptfoo/promptfoo/issues/9159)) ([ea3a655](https://github.com/promptfoo/promptfoo/commit/ea3a65521c55a7360cc315efa5f971673fb1f981))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **code-scan:** honor enable-fork-prs ([#8938](https://github.com/promptfoo/promptfoo/issues/8938)) ([517ec9d](https://github.com/promptfoo/promptfoo/commit/517ec9d3a0589be726bf024ea7d38bc28bb46702))
|
||||
- **code-scan:** isolate action OIDC token env ([#9309](https://github.com/promptfoo/promptfoo/issues/9309)) ([30c99e9](https://github.com/promptfoo/promptfoo/commit/30c99e924e02160db90385eff6f668c0ee551bc3))
|
||||
- **code-scan:** scope OIDC token to scan subprocess ([#9308](https://github.com/promptfoo/promptfoo/issues/9308)) ([178b57a](https://github.com/promptfoo/promptfoo/commit/178b57adb39d96d50012afd92a5e1b8e8e12ff75))
|
||||
- **deps:** update dependency undici to ^7.25.0 ([#9017](https://github.com/promptfoo/promptfoo/issues/9017)) ([5be6015](https://github.com/promptfoo/promptfoo/commit/5be6015ed51bccbaad03fc3c5a48e099f1f552cd))
|
||||
|
||||
## [0.1.5](https://github.com/promptfoo/promptfoo/compare/code-scan-action-0.1.4...code-scan-action-0.1.5) (2026-04-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **app:** clarify attack success rate label ([#8387](https://github.com/promptfoo/promptfoo/issues/8387)) ([7482eff](https://github.com/promptfoo/promptfoo/commit/7482eff88f193e857822b43da040638eb4ae1565))
|
||||
- **code-scan:** avoid npm before env for MCP npx ([#8515](https://github.com/promptfoo/promptfoo/issues/8515)) ([7d2eacd](https://github.com/promptfoo/promptfoo/commit/7d2eacd7820a33de24f8253b1ebe14e23b25faf1))
|
||||
- **deps:** update dependency undici to ^7.24.5 ([#8411](https://github.com/promptfoo/promptfoo/issues/8411)) ([3d8a24d](https://github.com/promptfoo/promptfoo/commit/3d8a24dcdb06cc729cc9b4d94f7b3f8763d03b9a))
|
||||
|
||||
## [0.1.4](https://github.com/promptfoo/code-scan-action/releases/tag/v0.1.4) (2026-01-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- pass base branch to CLI for stacked PRs ([#6892](https://github.com/promptfoo/promptfoo/issues/6892)) ([642a409](https://github.com/promptfoo/promptfoo/commit/642a4095dc65e6e875625d5b8ce664a00f7f5835))
|
||||
- support fork PR auth and comment-triggered scans ([#7038](https://github.com/promptfoo/promptfoo/issues/7038)) ([4eebb81](https://github.com/promptfoo/promptfoo/commit/4eebb81792bb09f44cbb9b67c4f850d223675c05))
|
||||
@@ -0,0 +1,92 @@
|
||||
# Promptfoo Code Scan GitHub Action
|
||||
|
||||
Automatically scan pull requests for LLM security vulnerabilities using AI-powered analysis.
|
||||
|
||||
## About Code Scanning
|
||||
|
||||
Promptfoo Code Scanning uses AI agents to find LLM-related vulnerabilities in your codebase and helps you fix them before you merge. By focusing specifically on LLM-related vulnerabilities, it finds issues that more general security scanners might miss.
|
||||
|
||||
The scanner examines code changes for common LLM security risks including prompt injection, PII exposure, and excessive agency. Rather than just analyzing the surface-level diff, it traces data flows deep into your codebase to understand how user inputs reach LLM prompts, how outputs are used, and what capabilities your LLM has access to.
|
||||
|
||||
After scanning, the action posts findings with severity levels and suggested fixes as PR review comments.
|
||||
|
||||
To also surface findings in GitHub Code Scanning, configure `sarif-output-path` and upload the generated file with `github/codeql-action/upload-sarif`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Recommended:** Install the [Promptfoo Scanner GitHub App](https://github.com/apps/promptfoo-scanner) for the easiest setup:
|
||||
|
||||
1. Go to [github.com/apps/promptfoo-scanner](https://github.com/apps/promptfoo-scanner) and install the app
|
||||
2. Select which repositories to enable scanning for
|
||||
3. Submit your email or sign in (no account required—just a valid email address)
|
||||
4. Review and merge the setup PR that's automatically opened in your repository
|
||||
|
||||
Once merged, the scanner will automatically run on future pull requests. Authentication is handled automatically with GitHub OIDC—no API key needed.
|
||||
|
||||
**[Read the full documentation →](https://promptfoo.dev/docs/code-scanning/github-action)** for configuration options, manual installation, and more.
|
||||
|
||||
## Fork Pull Requests
|
||||
|
||||
Fork pull request scanning is disabled by default for `pull_request` workflows. A maintainer can trigger a fork PR scan through the Promptfoo Scanner comment flow, or you can opt in to scanning fork PRs automatically:
|
||||
|
||||
```yaml
|
||||
- name: Run Promptfoo Code Scan
|
||||
id: promptfoo-code-scan
|
||||
uses: promptfoo/code-scan-action@v0
|
||||
with:
|
||||
enable-fork-prs: true
|
||||
```
|
||||
|
||||
## SARIF Output
|
||||
|
||||
Grant `security-events: write` in the workflow job permissions, then upload the generated file.
|
||||
The action sets `sarif-path` only when a scan actually completes, so keep the upload step conditional:
|
||||
|
||||
```yaml
|
||||
- name: Run Promptfoo Code Scan
|
||||
id: promptfoo-code-scan
|
||||
uses: promptfoo/code-scan-action@v0
|
||||
with:
|
||||
sarif-output-path: promptfoo-code-scan.sarif
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
if: ${{ steps.promptfoo-code-scan.outputs.sarif-path != '' }}
|
||||
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
sarif_file: ${{ steps.promptfoo-code-scan.outputs.sarif-path }}
|
||||
category: promptfoo-code-scan
|
||||
```
|
||||
|
||||
## Supply Chain Security
|
||||
|
||||
The hardening below applies to releases after v0.1.8; earlier releases resolve `promptfoo@latest` at runtime and predate the provenance attestation.
|
||||
|
||||
- **Pinned scanner install.** The action installs an exact, release-pinned version of the `promptfoo` CLI with npm lifecycle scripts disabled (`--ignore-scripts`); it does not resolve `promptfoo@latest` at runtime. Use the `promptfoo-version` input (exact versions only) to override the pin.
|
||||
- **Pin by commit SHA for maximum assurance.** Version tags like `v0` and `v0.1.8` are managed by release automation and, like all git tags, are not cryptographically immutable — only a full commit SHA is. Resolve a release tag to its commit and pin that:
|
||||
|
||||
```bash
|
||||
gh api repos/promptfoo/code-scan-action/commits/<tag> --jq .sha
|
||||
```
|
||||
|
||||
```yaml
|
||||
uses: promptfoo/code-scan-action@<full-commit-sha> # <tag>
|
||||
```
|
||||
|
||||
- **Verify build provenance.** The committed `dist/` bundle and the `action.yml` that selects the entrypoint are built and exported by the [promptfoo monorepo release workflow](https://github.com/promptfoo/promptfoo/blob/main/.github/workflows/release-please.yml), which publishes a signed build-provenance attestation for the exact artifact bytes. Verify a checkout of this repository with:
|
||||
|
||||
```bash
|
||||
gh attestation verify dist/index.js --repo promptfoo/promptfoo
|
||||
gh attestation verify action.yml --repo promptfoo/promptfoo
|
||||
```
|
||||
|
||||
Additionally, every release PR in this repository is validated by a workflow that rebuilds `dist/` from the pinned monorepo source commit and fails on any byte difference.
|
||||
|
||||
- **Don't run untrusted PR code before the scan in the same job.** The scanner install strips npm config and `NODE_OPTIONS` from its environment and isolates its npm config files, but a step that executes pull-request-controlled code earlier in the same job (for example `npm ci` or a build) can persist state — `$GITHUB_PATH`, `$GITHUB_ENV`, or `$HOME` writes — that later steps inherit, and such a step already runs with the job's token. Keep the scan in a job that only checks out the PR and scans it, or run untrusted build steps in a separate job.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please note that this is a release-only repository. To contribute, refer to the [associated directory](https://github.com/promptfoo/promptfoo/tree/main/code-scan-action) in the main promptfoo repository.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,50 @@
|
||||
name: 'Promptfoo Code Scan'
|
||||
description: 'Scan pull requests for LLM security vulnerabilities'
|
||||
author: 'Promptfoo, Inc.'
|
||||
|
||||
branding:
|
||||
icon: 'shield'
|
||||
color: 'blue'
|
||||
|
||||
inputs:
|
||||
api-host:
|
||||
description: 'Promptfoo API host url'
|
||||
required: false
|
||||
default: https://api.promptfoo.app
|
||||
min-severity:
|
||||
description: 'Minimum severity level to report (low|medium|high|critical). Defaults to medium when neither min-severity nor minimum-severity is set.'
|
||||
required: false
|
||||
minimum-severity:
|
||||
description: 'Alias for min-severity (low|medium|high|critical). Has no default; takes effect only when min-severity is not set.'
|
||||
required: false
|
||||
config-path:
|
||||
description: 'Path to YAML configuration file'
|
||||
required: false
|
||||
guidance:
|
||||
description: 'Custom guidance for the security scan'
|
||||
required: false
|
||||
guidance-file:
|
||||
description: 'Path to file containing custom guidance'
|
||||
required: false
|
||||
github-token:
|
||||
description: 'GitHub token for authentication (default: github.token)'
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
enable-fork-prs:
|
||||
description: 'Enable scanning PRs from forked repositories'
|
||||
required: false
|
||||
default: 'false'
|
||||
promptfoo-version:
|
||||
description: 'Exact promptfoo CLI version to install for scanning (e.g. 0.121.0). Defaults to the version pinned when this action release was built.'
|
||||
required: false
|
||||
sarif-output-path:
|
||||
description: 'Optional path to write SARIF output for upload to GitHub Code Scanning'
|
||||
required: false
|
||||
|
||||
outputs:
|
||||
sarif-path:
|
||||
description: 'Path to the SARIF file when a scan completes and sarif-output-path is configured'
|
||||
|
||||
runs:
|
||||
using: 'node24'
|
||||
main: 'dist/index.js'
|
||||
Generated
+1397
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@promptfoo/code-scan-action",
|
||||
"version": "0.1.8",
|
||||
"license": "MIT",
|
||||
"description": "GitHub Action for scanning PRs with promptfoo code-scan",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --target=node20 --outfile=dist/index.js --sourcemap --format=esm --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"",
|
||||
"package": "npm run build && git add dist/",
|
||||
"test": "echo \"Action tests run via parent jest config\" && exit 0",
|
||||
"dev": "npm run build && act pull_request -W .github/workflows/test-scan.yml -e .github/workflows/test-event.json --container-architecture linux/amd64",
|
||||
"tsc": "tsc --noEmit && tsc --noEmit --project tsconfig.tests.json",
|
||||
"tsc:watch": "tsc --noEmit --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.20.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^3.0.0",
|
||||
"@actions/exec": "^3.0.0",
|
||||
"@actions/github": "^9.1.0",
|
||||
"@octokit/auth-app": "^8.2.0",
|
||||
"@octokit/rest": "^22.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"undici": "^7.28.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* GitHub OIDC Authentication
|
||||
*
|
||||
* Handles OIDC token generation for server authentication
|
||||
*/
|
||||
|
||||
import * as core from '@actions/core';
|
||||
|
||||
/**
|
||||
* Get GitHub OIDC token for authenticating with the scan server
|
||||
* @param audience The audience for the OIDC token (scan server URL)
|
||||
* @returns OIDC token
|
||||
*/
|
||||
export async function getGitHubOIDCToken(audience: string): Promise<string> {
|
||||
try {
|
||||
const token = await core.getIDToken(audience);
|
||||
return token;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get GitHub OIDC token: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Configuration Generator
|
||||
*
|
||||
* Generates YAML config file from action inputs
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { type ScanConfig, ScanConfigSchema, validateSeverity } from '../../src/types/codeScan';
|
||||
|
||||
/**
|
||||
* Generate a temporary YAML config file from action inputs
|
||||
* @param minimumSeverity Minimum severity level
|
||||
* @param guidance Optional custom guidance text
|
||||
* @returns Path to temporary config file
|
||||
*/
|
||||
export function generateConfigFile(minimumSeverity: string, guidance?: string): string {
|
||||
// Validate severity input (throws ZodError if invalid)
|
||||
const validatedSeverity = validateSeverity(minimumSeverity);
|
||||
|
||||
const config: ScanConfig = {
|
||||
minimumSeverity: validatedSeverity,
|
||||
diffsOnly: false, // Always enable full repo exploration for GitHub Actions (never diffs-only)
|
||||
guidance,
|
||||
};
|
||||
|
||||
// Validate the entire config object for additional safety
|
||||
const validatedConfig = ScanConfigSchema.parse(config);
|
||||
|
||||
// Create temp file
|
||||
const tempDir = os.tmpdir();
|
||||
const configPath = path.join(tempDir, `code-scan-config-${randomUUID()}.yaml`);
|
||||
|
||||
// Write YAML
|
||||
let yamlContent = `minimumSeverity: ${validatedConfig.minimumSeverity}\ndiffsOnly: ${validatedConfig.diffsOnly}\n`;
|
||||
if (guidance) {
|
||||
// Properly escape YAML string using literal block scalar
|
||||
const guidanceYaml = guidance.includes('\n')
|
||||
? `guidance: |\n ${guidance.split('\n').join('\n ')}\n`
|
||||
: `guidance: ${JSON.stringify(guidance)}\n`;
|
||||
yamlContent += guidanceYaml;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf8');
|
||||
|
||||
return configPath;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* GitHub API Client
|
||||
*
|
||||
* Handles posting review comments via Octokit
|
||||
*/
|
||||
|
||||
import * as core from '@actions/core';
|
||||
import * as github from '@actions/github';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import {
|
||||
clampCommentLines,
|
||||
extractValidLineRanges,
|
||||
type FileLineRanges,
|
||||
} from '../../src/codeScan/util/diffLineRanges';
|
||||
import {
|
||||
type Comment,
|
||||
type FileChange,
|
||||
FileChangeStatus,
|
||||
type PullRequestContext,
|
||||
} from '../../src/types/codeScan';
|
||||
|
||||
/**
|
||||
* Get GitHub context from the current workflow.
|
||||
* Supports both pull_request events and workflow_dispatch (with pr_number input).
|
||||
* @param token GitHub token (required for workflow_dispatch to fetch PR details)
|
||||
* @returns GitHub PR context
|
||||
*/
|
||||
export async function getGitHubContext(token: string): Promise<PullRequestContext> {
|
||||
const context = github.context;
|
||||
|
||||
// For workflow_dispatch, read pr_number from event inputs
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
const prNumberInput = (context.payload.inputs as Record<string, string> | undefined)?.pr_number;
|
||||
if (!prNumberInput) {
|
||||
throw new Error(
|
||||
'workflow_dispatch requires a pr_number input. Add inputs: { pr_number: { required: true } } to your workflow.',
|
||||
);
|
||||
}
|
||||
|
||||
const prNumber = parseInt(prNumberInput, 10);
|
||||
if (isNaN(prNumber)) {
|
||||
throw new Error(`Invalid pr_number input: "${prNumberInput}"`);
|
||||
}
|
||||
|
||||
const octokit = new Octokit({ auth: token });
|
||||
const { data: pr } = await octokit.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
return {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
number: pr.number,
|
||||
sha: pr.head.sha,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, get context from pull_request event
|
||||
if (!context.payload.pull_request) {
|
||||
throw new Error(
|
||||
'This action requires a pull_request event or workflow_dispatch with pr_number input',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
number: context.payload.pull_request.number,
|
||||
sha: context.payload.pull_request.head.sha,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of files changed in the PR
|
||||
* @param token GitHub token
|
||||
* @param context GitHub PR context
|
||||
* @returns Array of file changes
|
||||
*/
|
||||
export async function getPRFiles(
|
||||
token: string,
|
||||
context: PullRequestContext,
|
||||
): Promise<FileChange[]> {
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
const { data: files } = await octokit.pulls.listFiles({
|
||||
owner: context.owner,
|
||||
repo: context.repo,
|
||||
pull_number: context.number,
|
||||
});
|
||||
|
||||
return files.map((file) => ({
|
||||
path: file.filename,
|
||||
status: file.status as FileChangeStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PR diff and extract valid line ranges for each file.
|
||||
* This is used to validate and clamp comment line numbers.
|
||||
*/
|
||||
async function getPRDiffRanges(
|
||||
octokit: Octokit,
|
||||
context: PullRequestContext,
|
||||
): Promise<FileLineRanges> {
|
||||
try {
|
||||
const { data: diff } = await octokit.pulls.get({
|
||||
owner: context.owner,
|
||||
repo: context.repo,
|
||||
pull_number: context.number,
|
||||
mediaType: { format: 'diff' },
|
||||
});
|
||||
|
||||
// The diff is returned as a string when using mediaType: { format: 'diff' }
|
||||
return extractValidLineRanges(diff as unknown as string);
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Failed to fetch PR diff for line validation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a comment's line numbers to valid diff ranges.
|
||||
* Returns the adjusted comment, or null if lines cannot be clamped.
|
||||
*/
|
||||
function clampCommentToValidRange(comment: Comment, validRanges: FileLineRanges): Comment | null {
|
||||
if (!comment.file || comment.line == null) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
const clamped = clampCommentLines(comment.file, comment.startLine, comment.line, validRanges);
|
||||
|
||||
if (!clamped) {
|
||||
// File not in diff - return null to convert to general comment
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...comment,
|
||||
startLine: clamped.startLine,
|
||||
line: clamped.line,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate review-comment locations against the current PR diff.
|
||||
* Comments that cannot be placed inline are returned separately for general posting.
|
||||
*/
|
||||
async function partitionReviewCommentsWithOctokit(
|
||||
octokit: Octokit,
|
||||
context: PullRequestContext,
|
||||
comments: Comment[],
|
||||
): Promise<{
|
||||
lineComments: Comment[];
|
||||
generalComments: Comment[];
|
||||
invalidLineComments: Comment[];
|
||||
}> {
|
||||
const validRanges = await getPRDiffRanges(octokit, context);
|
||||
const lineComments: Comment[] = [];
|
||||
const generalComments: Comment[] = [];
|
||||
const invalidLineComments: Comment[] = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
if (!comment.file || comment.line == null) {
|
||||
generalComments.push(comment);
|
||||
continue;
|
||||
}
|
||||
|
||||
const clamped = clampCommentToValidRange(comment, validRanges);
|
||||
if (clamped) {
|
||||
lineComments.push(clamped);
|
||||
} else {
|
||||
core.warning(
|
||||
`Comment on ${comment.file}:${comment.line} could not be placed in diff - converting to general comment`,
|
||||
);
|
||||
invalidLineComments.push(comment);
|
||||
}
|
||||
}
|
||||
|
||||
return { lineComments, generalComments, invalidLineComments };
|
||||
}
|
||||
|
||||
export async function partitionReviewCommentsByDiff(
|
||||
token: string,
|
||||
context: PullRequestContext,
|
||||
comments: Comment[],
|
||||
) {
|
||||
const octokit = new Octokit({ auth: token });
|
||||
return partitionReviewCommentsWithOctokit(octokit, context, comments);
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
/**
|
||||
* GitHub Action Entry Point
|
||||
*
|
||||
* Main entry point for the promptfoo code-scan GitHub Action
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as core from '@actions/core';
|
||||
import * as exec from '@actions/exec';
|
||||
import * as github from '@actions/github';
|
||||
// esbuild inlines (and tree-shakes) this named JSON import at build time, so each
|
||||
// action release ships with the promptfoo CLI version that was current in the
|
||||
// monorepo when the release was built — the runtime install below is pinned to it
|
||||
// instead of resolving a mutable dist-tag like `latest`.
|
||||
import { version as defaultPromptfooVersion } from '../../package.json';
|
||||
import { hasPrPostableFindings, prepareComments } from '../../src/codeScan/util/github';
|
||||
import { hasSarifReportableFindings, scanResponseToSarif } from '../../src/codeScan/util/sarif';
|
||||
import {
|
||||
CodeScanSeverity,
|
||||
type Comment,
|
||||
type FileChange,
|
||||
FileChangeStatus,
|
||||
formatSeverity,
|
||||
type PullRequestContext,
|
||||
type ScanResponse,
|
||||
} from '../../src/types/codeScan';
|
||||
import { getGitHubOIDCToken } from './auth';
|
||||
import { generateConfigFile } from './config';
|
||||
import { getGitHubContext, getPRFiles, partitionReviewCommentsByDiff } from './github';
|
||||
|
||||
interface ActionInputs {
|
||||
apiHost: string;
|
||||
minimumSeverity: string;
|
||||
configPath: string;
|
||||
guidanceText: string;
|
||||
guidanceFile: string;
|
||||
githubToken: string;
|
||||
enableForkPrs: boolean;
|
||||
sarifOutputPath: string | undefined;
|
||||
promptfooVersion: string;
|
||||
}
|
||||
|
||||
interface PullRequestForkPayload {
|
||||
head?: {
|
||||
repo?: {
|
||||
full_name?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
base?: {
|
||||
repo?: {
|
||||
full_name?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const FORK_PR_AUTH_SKIP_REASON =
|
||||
'Fork PR scanning requires maintainer approval. See PR comment for options.';
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
const DEFAULT_MINIMUM_SEVERITY = 'medium';
|
||||
|
||||
/**
|
||||
* Resolve the effective minimum severity from the two supported inputs.
|
||||
*
|
||||
* The two inputs do NOT carry defaults in `action.yml`. That keeps
|
||||
* `core.getInput()` returning `''` when a workflow has not set the input,
|
||||
* which is what lets a workflow that only sets the alias (`minimum-severity`)
|
||||
* actually take effect. Precedence is:
|
||||
*
|
||||
* 1. `min-severity` if set
|
||||
* 2. `minimum-severity` if set
|
||||
* 3. fallback to `DEFAULT_MINIMUM_SEVERITY`
|
||||
*
|
||||
* If both are set to different values, `min-severity` wins and a warning is
|
||||
* emitted so the workflow author can collapse the inputs.
|
||||
*/
|
||||
function resolveMinimumSeverityInput(): string {
|
||||
const primary = core.getInput('min-severity').trim();
|
||||
const alias = core.getInput('minimum-severity').trim();
|
||||
|
||||
if (primary && alias && primary !== alias) {
|
||||
core.warning(
|
||||
`Both min-severity (${primary}) and minimum-severity (${alias}) are set; using min-severity. minimum-severity is an alias and should only be set when min-severity is unset.`,
|
||||
);
|
||||
}
|
||||
|
||||
return primary || alias || DEFAULT_MINIMUM_SEVERITY;
|
||||
}
|
||||
|
||||
// Exact versions only (optionally with a prerelease suffix). Anything looser — a range,
|
||||
// a dist-tag, a git/URL spec, or an extra npm flag — must be rejected because the value
|
||||
// is passed straight into `npm install` and controls which code scans the repository.
|
||||
// Numeric components are capped at 15 digits so they always stay below
|
||||
// Number.MAX_SAFE_INTEGER, node-semver's actual component limit: an oversized
|
||||
// component makes the spec invalid semver, which npm reclassifies as a mutable
|
||||
// dist-tag lookup, defeating the exact-version contract. Leading zeros are rejected
|
||||
// as invalid strict semver (npm loose-parses them rather than resolving exactly).
|
||||
const SEMVER_NUMERIC = String.raw`(?:0|[1-9]\d{0,14})`;
|
||||
const SEMVER_PRERELEASE_ID = String.raw`(?:0|[1-9]\d{0,14}|\d*[A-Za-z-][0-9A-Za-z-]*)`;
|
||||
const EXACT_SEMVER_PATTERN = new RegExp(
|
||||
`^${SEMVER_NUMERIC}\\.${SEMVER_NUMERIC}\\.${SEMVER_NUMERIC}` +
|
||||
`(?:-${SEMVER_PRERELEASE_ID}(?:\\.${SEMVER_PRERELEASE_ID})*)?$`,
|
||||
);
|
||||
// semver's own MAX_LENGTH; also bounds regex work on hostile input.
|
||||
const MAX_VERSION_LENGTH = 256;
|
||||
|
||||
function resolvePromptfooVersionInput(): string {
|
||||
const override = core.getInput('promptfoo-version').trim();
|
||||
if (!override) {
|
||||
return defaultPromptfooVersion;
|
||||
}
|
||||
if (override.length > MAX_VERSION_LENGTH || !EXACT_SEMVER_PATTERN.test(override)) {
|
||||
throw new Error(
|
||||
`Invalid promptfoo-version "${override}": expected an exact version like 0.121.0`,
|
||||
);
|
||||
}
|
||||
return override;
|
||||
}
|
||||
|
||||
function getActionInputs(): ActionInputs {
|
||||
return {
|
||||
apiHost: core.getInput('api-host'),
|
||||
minimumSeverity: resolveMinimumSeverityInput(),
|
||||
configPath: core.getInput('config-path'),
|
||||
guidanceText: core.getInput('guidance'),
|
||||
guidanceFile: core.getInput('guidance-file'),
|
||||
githubToken: core.getInput('github-token', { required: true }),
|
||||
enableForkPrs: core.getBooleanInput('enable-fork-prs'),
|
||||
// core.getInput returns '' when unset; normalize so a falsy check at the call site
|
||||
// doesn't have to special-case the empty-string sentinel.
|
||||
sarifOutputPath: core.getInput('sarif-output-path').trim() || undefined,
|
||||
promptfooVersion: resolvePromptfooVersionInput(),
|
||||
};
|
||||
}
|
||||
|
||||
const SUBPROCESS_ENV_EXCLUSIONS = [
|
||||
'ACTIONS_ID_TOKEN_REQUEST_TOKEN',
|
||||
'ACTIONS_ID_TOKEN_REQUEST_URL',
|
||||
'GH_TOKEN',
|
||||
'GITHUB_OIDC_TOKEN',
|
||||
'GITHUB_TOKEN',
|
||||
'INPUT_GITHUB-TOKEN',
|
||||
'INPUT_GITHUB_TOKEN',
|
||||
] as const;
|
||||
|
||||
function createSubprocessEnv(): Record<string, string> {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => {
|
||||
return typeof entry[1] === 'string';
|
||||
}),
|
||||
);
|
||||
|
||||
delete env.NPM_CONFIG_BEFORE;
|
||||
delete env.npm_config_before;
|
||||
|
||||
// A PR-controlled step running earlier in the workflow can persist
|
||||
// NODE_OPTIONS=--require=/path/to/payload.cjs via $GITHUB_ENV; Node preloads that
|
||||
// module into every child node process — npm during the install and promptfoo during
|
||||
// the scan (when the OIDC token / API key are in scope). Strip it from all
|
||||
// subprocesses; the action does not rely on caller-provided NODE_OPTIONS.
|
||||
delete env.NODE_OPTIONS;
|
||||
|
||||
for (const key of SUBPROCESS_ENV_EXCLUSIONS) {
|
||||
delete env[key];
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function createScanEnv(oidcToken: string | undefined): Record<string, string> {
|
||||
const env = createSubprocessEnv();
|
||||
if (oidcToken) {
|
||||
env.GITHUB_OIDC_TOKEN = oidcToken;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function loadGuidance(inputs: ActionInputs): string | undefined {
|
||||
if (inputs.guidanceText && inputs.guidanceFile) {
|
||||
throw new Error('Cannot specify both guidance and guidance-file inputs');
|
||||
}
|
||||
|
||||
if (inputs.guidanceText) {
|
||||
return inputs.guidanceText;
|
||||
}
|
||||
|
||||
if (!inputs.guidanceFile) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const guidance = fs.readFileSync(inputs.guidanceFile, 'utf-8');
|
||||
core.info(`📖 Loaded guidance from: ${inputs.guidanceFile}`);
|
||||
return guidance;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read guidance file: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isSetupPR(files: FileChange[]): boolean {
|
||||
const setupWorkflowPath = '.github/workflows/promptfoo-code-scan.yml';
|
||||
return (
|
||||
files.length === 1 &&
|
||||
files[0].path === setupWorkflowPath &&
|
||||
files[0].status === FileChangeStatus.ADDED
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentRepositoryFullName(): string {
|
||||
const repository = github.context.payload.repository as { full_name?: string } | undefined;
|
||||
return repository?.full_name || `${github.context.repo.owner}/${github.context.repo.repo}`;
|
||||
}
|
||||
|
||||
function isPullRequestFromFork(): boolean {
|
||||
const pullRequest = github.context.payload.pull_request as PullRequestForkPayload | undefined;
|
||||
if (!pullRequest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headRepoFullName = pullRequest?.head?.repo?.full_name;
|
||||
const baseRepoFullName = pullRequest?.base?.repo?.full_name || getCurrentRepositoryFullName();
|
||||
|
||||
if (!headRepoFullName || !baseRepoFullName) {
|
||||
core.warning(
|
||||
'Unable to determine PR source repository from GitHub event payload; treating it as a fork PR',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return headRepoFullName !== baseRepoFullName;
|
||||
}
|
||||
|
||||
function shouldSkipForkPullRequest(enableForkPrs: boolean): boolean {
|
||||
return !enableForkPrs && isPullRequestFromFork();
|
||||
}
|
||||
|
||||
async function authenticateWithOidc(): Promise<string | undefined> {
|
||||
try {
|
||||
const oidcToken = await getGitHubOIDCToken('promptfoo');
|
||||
core.info('🔐 Got OIDC token for server authentication');
|
||||
return oidcToken;
|
||||
} catch (error) {
|
||||
// OIDC tokens are not available for fork PRs (GitHub security restriction)
|
||||
// For fork PRs, the server will use PR-based authentication instead
|
||||
core.info(`OIDC token not available: ${formatError(error)}`);
|
||||
core.info('For fork PRs, this is expected. Authentication will use PR context instead.');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPath(configPath: string, minimumSeverity: string, guidance?: string): string {
|
||||
if (configPath) {
|
||||
return configPath;
|
||||
}
|
||||
|
||||
const generatedConfigPath = generateConfigFile(minimumSeverity, guidance);
|
||||
core.info(`📝 Generated temporary config at ${generatedConfigPath}`);
|
||||
return generatedConfigPath;
|
||||
}
|
||||
|
||||
async function getBaseBranch(githubToken: string, context: PullRequestContext): Promise<string> {
|
||||
if (process.env.GITHUB_BASE_REF) {
|
||||
return process.env.GITHUB_BASE_REF;
|
||||
}
|
||||
|
||||
// For workflow_dispatch, fetch PR details to get actual base branch
|
||||
core.info('📥 Fetching PR details to determine base branch...');
|
||||
const octokit = github.getOctokit(githubToken);
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner: context.owner,
|
||||
repo: context.repo,
|
||||
pull_number: context.number,
|
||||
});
|
||||
|
||||
core.info(`✅ PR targets base branch: ${pr.base.ref}`);
|
||||
return pr.base.ref;
|
||||
}
|
||||
|
||||
async function fetchBaseBranch(baseBranch: string): Promise<void> {
|
||||
core.info(`📥 Fetching base branch: ${baseBranch}...`);
|
||||
|
||||
try {
|
||||
await exec.exec('git', ['fetch', 'origin', `${baseBranch}:${baseBranch}`]);
|
||||
core.info(`✅ Base branch ${baseBranch} fetched successfully`);
|
||||
} catch (error) {
|
||||
core.warning(`Failed to fetch base branch ${baseBranch}: ${formatError(error)}`);
|
||||
core.warning('Git diff may fail if base branch is not available');
|
||||
}
|
||||
}
|
||||
|
||||
function buildCliArgs(
|
||||
apiHost: string,
|
||||
configPath: string,
|
||||
baseBranch: string,
|
||||
context: PullRequestContext,
|
||||
): string[] {
|
||||
const repoPath = process.env.GITHUB_WORKSPACE || process.cwd();
|
||||
return [
|
||||
'code-scans',
|
||||
'run',
|
||||
repoPath,
|
||||
...(apiHost ? ['--api-host', apiHost] : []),
|
||||
'--config',
|
||||
configPath,
|
||||
'--base',
|
||||
baseBranch,
|
||||
'--compare',
|
||||
'HEAD',
|
||||
'--json',
|
||||
'--github-pr',
|
||||
`${context.owner}/${context.repo}#${context.number}`,
|
||||
];
|
||||
}
|
||||
|
||||
function createMockScanResponse(): ScanResponse {
|
||||
core.info('🧪 Running in ACT mode - using mock scan data for testing');
|
||||
core.info('📊 Mock scan simulates finding 2 security issues');
|
||||
|
||||
const scanResponse: ScanResponse = {
|
||||
success: true,
|
||||
comments: [
|
||||
{
|
||||
file: 'src/example.ts',
|
||||
line: 42,
|
||||
finding: 'Potential security issue: API key hardcoded in source code',
|
||||
severity: CodeScanSeverity.HIGH,
|
||||
fix: 'Move API key to environment variable and use process.env.API_KEY instead',
|
||||
aiAgentPrompt: 'Review the API key storage and suggest secure alternatives',
|
||||
},
|
||||
{
|
||||
file: 'src/auth.ts',
|
||||
line: 15,
|
||||
startLine: 10,
|
||||
finding: 'SQL injection vulnerability: User input not sanitized before query',
|
||||
severity: CodeScanSeverity.CRITICAL,
|
||||
fix: 'Use parameterized queries or an ORM to prevent SQL injection',
|
||||
},
|
||||
],
|
||||
commentsPosted: false,
|
||||
review:
|
||||
'🔍 **Security Scan Results**\n\nFound 2 potential security issues. Please review the inline comments for details.',
|
||||
};
|
||||
|
||||
core.info('✅ Mock scan completed successfully');
|
||||
return scanResponse;
|
||||
}
|
||||
|
||||
function parseScanOutput(scanOutput: string): ScanResponse {
|
||||
try {
|
||||
return JSON.parse(scanOutput);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse CLI output as JSON: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Owns every hardening decision for the scanner install as one unit so a future npm
|
||||
// invocation cannot accidentally drop one of them:
|
||||
// - exact release-pinned version, never a mutable spec like `latest`
|
||||
// - --ignore-scripts: the scanner tree must not execute arbitrary code before the
|
||||
// scan starts (promptfoo and its dependency tree work without lifecycle scripts)
|
||||
// - sanitized env (createSubprocessEnv) with tokens stripped, plus every env-level
|
||||
// npm config override removed (see below)
|
||||
// - cwd outside the checked-out workspace: npm's global mode documents (and testing
|
||||
// confirms) that it ignores the per-project .npmrc, but the workspace holds the
|
||||
// untrusted PR being scanned — no cwd-derived npm config (registry, proxy,
|
||||
// strict-ssl, ignore-scripts…) may ever be in scope
|
||||
async function installPromptfooCli(promptfooVersion: string): Promise<void> {
|
||||
const installCwd = process.env.RUNNER_TEMP || os.tmpdir();
|
||||
|
||||
// npm reads its registry (and other config) from both env vars and user/global
|
||||
// .npmrc files. A PR-controlled step running before this action can poison either:
|
||||
// set npm_config_registry via $GITHUB_ENV, or write `registry=https://attacker/` to
|
||||
// $HOME/.npmrc — redirecting this exact install to an attacker registry whose
|
||||
// promptfoo tarball then runs as the scanner. Close both channels for the install:
|
||||
// - strip every npm_config_*/NPM_CONFIG_* env var, and
|
||||
// - point --userconfig/--globalconfig at fresh empty files so no on-disk .npmrc is
|
||||
// consulted (two distinct paths: npm rejects loading one file as both).
|
||||
// The pinned version therefore resolves from the runner's default (public) registry.
|
||||
// This deliberately bypasses runner-admin npm mirrors configured via env or .npmrc
|
||||
// for this one install (the SaaS scan already requires public egress); the scan
|
||||
// subprocess keeps workflow-provided npm config because its nested npx (MCP)
|
||||
// invocations rely on it.
|
||||
const env = createSubprocessEnv();
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.toLowerCase().startsWith('npm_config_')) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
const npmrcDir = fs.mkdtempSync(path.join(installCwd, 'promptfoo-npmrc-'));
|
||||
const emptyUserConfig = path.join(npmrcDir, 'user');
|
||||
const emptyGlobalConfig = path.join(npmrcDir, 'global');
|
||||
|
||||
core.info(`📦 Installing promptfoo@${promptfooVersion}...`);
|
||||
await exec.exec(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'-g',
|
||||
`promptfoo@${promptfooVersion}`,
|
||||
'--ignore-scripts',
|
||||
'--userconfig',
|
||||
emptyUserConfig,
|
||||
'--globalconfig',
|
||||
emptyGlobalConfig,
|
||||
],
|
||||
{ env, cwd: installCwd },
|
||||
);
|
||||
core.info('✅ Promptfoo installed successfully');
|
||||
}
|
||||
|
||||
async function runPromptfooScan(
|
||||
cliArgs: string[],
|
||||
oidcToken: string | undefined,
|
||||
promptfooVersion: string,
|
||||
): Promise<ScanResponse> {
|
||||
await installPromptfooCli(promptfooVersion);
|
||||
|
||||
core.info('🚀 Running promptfoo code-scans run...');
|
||||
|
||||
let scanOutput = '';
|
||||
let scanError = '';
|
||||
const scanEnv = createScanEnv(oidcToken);
|
||||
|
||||
const exitCode = await exec.exec('promptfoo', cliArgs, {
|
||||
env: scanEnv,
|
||||
listeners: {
|
||||
stdout: (data: Buffer) => {
|
||||
scanOutput += data.toString();
|
||||
},
|
||||
stderr: (data: Buffer) => {
|
||||
scanError += data.toString();
|
||||
},
|
||||
},
|
||||
ignoreReturnCode: true,
|
||||
});
|
||||
|
||||
if (exitCode === 0) {
|
||||
core.info('✅ Scan completed successfully');
|
||||
return parseScanOutput(scanOutput);
|
||||
}
|
||||
|
||||
// Keep compatibility with CLI releases that reported an authorized fork skip as text
|
||||
// while the action and CLI roll out independently.
|
||||
if (`${scanOutput}\n${scanError}`.includes('Fork PR scanning not authorized')) {
|
||||
return {
|
||||
success: true,
|
||||
comments: [],
|
||||
skipReason: FORK_PR_AUTH_SKIP_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
core.error(`CLI exited with code ${exitCode}`);
|
||||
core.error(`Error output: ${scanError}`);
|
||||
throw new Error(`Code scan failed with exit code ${exitCode}`);
|
||||
}
|
||||
|
||||
function getScanResponse(
|
||||
cliArgs: string[],
|
||||
oidcToken: string | undefined,
|
||||
promptfooVersion: string,
|
||||
): Promise<ScanResponse> {
|
||||
if (process.env.ACT === 'true') {
|
||||
return Promise.resolve(createMockScanResponse());
|
||||
}
|
||||
return runPromptfooScan(cliArgs, oidcToken, promptfooVersion);
|
||||
}
|
||||
|
||||
function buildCommentBody(comment: Comment): string {
|
||||
let body = formatSeverity(comment.severity) + comment.finding;
|
||||
|
||||
if (comment.fix) {
|
||||
body += `\n\n<details>\n<summary>💡 Suggested Fix</summary>\n\n${comment.fix}\n</details>`;
|
||||
}
|
||||
|
||||
if (comment.aiAgentPrompt) {
|
||||
body += `\n\n<details>\n<summary>🤖 AI Agent Prompt</summary>\n\n${comment.aiAgentPrompt}\n</details>`;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function buildGeneralCommentBody(comment: Comment): string {
|
||||
const body = buildCommentBody(comment);
|
||||
const location =
|
||||
comment.file && comment.line
|
||||
? comment.startLine && comment.startLine !== comment.line
|
||||
? `${comment.file}:${comment.startLine}-${comment.line}`
|
||||
: `${comment.file}:${comment.line}`
|
||||
: comment.file;
|
||||
return location ? `**${location}**\n\n${body}` : body;
|
||||
}
|
||||
|
||||
function toReviewComment(comment: Comment) {
|
||||
// GitHub's createReview API requires start_line < line for multi-line comments and
|
||||
// rejects the entire review (422) otherwise. Comments routed here are clamped upstream
|
||||
// by partitionReviewCommentsByDiff, but guard explicitly so this never depends on a
|
||||
// caller having run that clamp. The ternary also narrows startLine to `number`,
|
||||
// dropping the null/undefined the API type rejects.
|
||||
const startLine =
|
||||
comment.startLine && comment.line && comment.startLine < comment.line
|
||||
? comment.startLine
|
||||
: undefined;
|
||||
return {
|
||||
path: comment.file!,
|
||||
line: comment.line || undefined,
|
||||
start_line: startLine,
|
||||
side: 'RIGHT' as const,
|
||||
start_side: startLine ? ('RIGHT' as const) : undefined,
|
||||
body: buildCommentBody(comment),
|
||||
};
|
||||
}
|
||||
|
||||
async function postReview(
|
||||
octokit: ReturnType<typeof github.getOctokit>,
|
||||
context: PullRequestContext,
|
||||
lineComments: Comment[],
|
||||
reviewBody: string,
|
||||
): Promise<void> {
|
||||
if (lineComments.length === 0 && !reviewBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(
|
||||
`📌 Posting PR review${lineComments.length > 0 ? ` with ${lineComments.length} line-specific comments` : ''}...`,
|
||||
);
|
||||
|
||||
await octokit.rest.pulls.createReview({
|
||||
owner: context.owner,
|
||||
repo: context.repo,
|
||||
pull_number: context.number,
|
||||
event: 'COMMENT',
|
||||
body: reviewBody || undefined,
|
||||
comments: lineComments.length > 0 ? lineComments.map(toReviewComment) : undefined,
|
||||
});
|
||||
|
||||
core.info('✅ PR review posted successfully');
|
||||
}
|
||||
|
||||
async function postGeneralComments(
|
||||
octokit: ReturnType<typeof github.getOctokit>,
|
||||
context: PullRequestContext,
|
||||
generalComments: Comment[],
|
||||
): Promise<void> {
|
||||
if (generalComments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`💬 Posting ${generalComments.length} general comments...`);
|
||||
|
||||
for (const comment of generalComments) {
|
||||
await octokit.rest.issues.createComment({
|
||||
owner: context.owner,
|
||||
repo: context.repo,
|
||||
issue_number: context.number,
|
||||
body: buildGeneralCommentBody(comment),
|
||||
});
|
||||
}
|
||||
|
||||
core.info('✅ General comments posted successfully');
|
||||
}
|
||||
|
||||
async function postFallbackComments(
|
||||
githubToken: string,
|
||||
context: PullRequestContext,
|
||||
comments: Comment[],
|
||||
review: string | undefined,
|
||||
minimumSeverity: string,
|
||||
): Promise<void> {
|
||||
core.info('📝 Server could not post comments - posting as fallback...');
|
||||
|
||||
try {
|
||||
const octokit = github.getOctokit(githubToken);
|
||||
const {
|
||||
lineComments: preparedLineComments,
|
||||
generalComments,
|
||||
reviewBody,
|
||||
} = prepareComments(comments, review, minimumSeverity);
|
||||
const { lineComments, invalidLineComments } = await partitionReviewCommentsByDiff(
|
||||
githubToken,
|
||||
context,
|
||||
preparedLineComments,
|
||||
);
|
||||
|
||||
await postReview(octokit, context, lineComments, reviewBody);
|
||||
await postGeneralComments(octokit, context, [...generalComments, ...invalidLineComments]);
|
||||
|
||||
core.info('✅ All comments posted to PR by action');
|
||||
} catch (error) {
|
||||
core.error(`Failed to post comments: ${formatError(error)}`);
|
||||
core.warning('Comments could not be posted to PR');
|
||||
}
|
||||
}
|
||||
|
||||
// The shared symlink-safe containment check lives at src/util/isPathWithinDir.ts, but
|
||||
// importing it transitively pulls src/logger.ts (and the winston stack) into this bundle,
|
||||
// adding ~800KB to every action download. The inline check below covers the same threat
|
||||
// model — path-traversal via `..` plus symlink escape via post-mkdir realpath — without
|
||||
// the bundle cost.
|
||||
function isPathWithinOrEqualTo(child: string, parent: string): boolean {
|
||||
// Case-sensitive comparison. Both `child` and `parent` are produced by path.resolve on
|
||||
// the same workspace prefix in real use, so casing always matches. An earlier version
|
||||
// lowercased on darwin/win32 to handle hypothetical case-mismatched user input, but
|
||||
// that opened a bypass on case-sensitive APFS volumes (where /Path/A and /path/a are
|
||||
// distinct on disk yet would compare equal here). The post-mkdir realpath check in
|
||||
// writeSarifFile canonicalizes against the actual filesystem, which is the right place
|
||||
// to handle case folding if the underlying volume does it.
|
||||
const pWithSep = parent.endsWith(path.sep) ? parent : parent + path.sep;
|
||||
return child === parent || child.startsWith(pWithSep);
|
||||
}
|
||||
|
||||
function resolveSarifOutputPath(rawPath: string): string {
|
||||
// getActionInputs normalizes empty/whitespace input to undefined and the call site
|
||||
// skips emitSarifOutput entirely in that case, so we don't repeat the empty check here.
|
||||
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
|
||||
const resolved = path.resolve(workspace, rawPath);
|
||||
if (resolved === workspace || !isPathWithinOrEqualTo(resolved, workspace)) {
|
||||
throw new Error(
|
||||
`sarif-output-path "${rawPath}" resolves outside GITHUB_WORKSPACE; refusing to write`,
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function serializeSarif(scanResponse: ScanResponse): string {
|
||||
// Trailing newline for POSIX-text-file friendliness; some downstream tools require it.
|
||||
return `${JSON.stringify(scanResponseToSarif(scanResponse), null, 2)}\n`;
|
||||
}
|
||||
|
||||
// Walk up from `p` until realpathSync resolves successfully, returning the canonical
|
||||
// path of the deepest existing ancestor. Used to detect symlinks anywhere in the parent
|
||||
// chain *before* mkdir -p has a chance to follow them out of the workspace.
|
||||
function realpathDeepestExisting(p: string): string {
|
||||
let current = p;
|
||||
while (true) {
|
||||
try {
|
||||
return fs.realpathSync(current);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
const next = path.dirname(current);
|
||||
if (next === current) {
|
||||
throw new Error(`No existing ancestor for "${p}"`);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lstatOrNull(p: string): fs.Stats | null {
|
||||
try {
|
||||
return fs.lstatSync(p);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSarifFile(resolved: string, body: string): void {
|
||||
const parent = path.dirname(resolved);
|
||||
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
|
||||
|
||||
// Defense before mkdir: if any existing ancestor of `parent` is a symlink that
|
||||
// resolves outside the workspace, mkdir -p would follow it and create directories
|
||||
// outside the sandbox. Refuse before mutating the filesystem.
|
||||
const realWorkspace = fs.realpathSync(workspace);
|
||||
const realAncestor = realpathDeepestExisting(parent);
|
||||
if (!isPathWithinOrEqualTo(realAncestor, realWorkspace)) {
|
||||
throw new Error(
|
||||
`sarif-output-path "${resolved}" resolves outside GITHUB_WORKSPACE via symlink; refusing to write`,
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
|
||||
// Defense before write: if `resolved` already exists as a symlink, refuse —
|
||||
// writeFileSync follows symlinks and would write through to the link target.
|
||||
if (lstatOrNull(resolved)?.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`sarif-output-path "${resolved}" is an existing symlink; refusing to overwrite`,
|
||||
);
|
||||
}
|
||||
|
||||
// Close the TOCTOU window between the lstat check and the write: on POSIX, opening with
|
||||
// O_NOFOLLOW makes the call fail atomically if `resolved` was raced into a symlink. On
|
||||
// Windows O_NOFOLLOW is not exposed, so we fall back to plain writeFileSync (the lstat
|
||||
// check above is the only defense there — acceptable given the Actions threat model).
|
||||
const noFollow = (fs.constants as { O_NOFOLLOW?: number }).O_NOFOLLOW;
|
||||
if (typeof noFollow === 'number') {
|
||||
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | noFollow;
|
||||
const fd = fs.openSync(resolved, flags, 0o644);
|
||||
try {
|
||||
fs.writeSync(fd, body);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(resolved, body);
|
||||
}
|
||||
}
|
||||
|
||||
function emitSarifOutput(scanResponse: ScanResponse, rawPath: string): void {
|
||||
// SARIF output is supplementary — never sink an otherwise-successful scan over a write failure.
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = resolveSarifOutputPath(rawPath);
|
||||
} catch (error) {
|
||||
core.warning(formatError(error));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
writeSarifFile(resolved, serializeSarif(scanResponse));
|
||||
core.setOutput('sarif-path', resolved);
|
||||
core.info(`📝 Wrote SARIF output to ${resolved}`);
|
||||
} catch (error) {
|
||||
core.warning(`Failed to write SARIF output to "${resolved}": ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function emitConfiguredSarifOutput(scanResponse: ScanResponse, inputs: ActionInputs): void {
|
||||
if (inputs.sarifOutputPath) {
|
||||
emitSarifOutput(scanResponse, inputs.sarifOutputPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScanResponse(
|
||||
scanResponse: ScanResponse,
|
||||
inputs: ActionInputs,
|
||||
context: PullRequestContext,
|
||||
): Promise<void> {
|
||||
const { comments, commentsPosted, review, skipReason } = scanResponse;
|
||||
const hasSarifFindings = hasSarifReportableFindings(scanResponse);
|
||||
const hasPrFindings = hasPrPostableFindings(comments);
|
||||
|
||||
// A skipped scan is not a clean scan. Do not upload empty SARIF results that could clear
|
||||
// existing Code Scanning findings or imply that authorization-gated work ran. Mixed
|
||||
// responses still need processing when a finding can be surfaced through SARIF or PR
|
||||
// comments, because those output channels intentionally support different locations.
|
||||
if (skipReason && !hasSarifFindings && !hasPrFindings) {
|
||||
core.info(`🔀 Scan skipped: ${skipReason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipReason) {
|
||||
// Carry the skipReason into the warning: a contradictory response (skip + real
|
||||
// findings) signals a server-side bug, and the reason text is the operator's only
|
||||
// clue to which path produced it.
|
||||
core.warning(
|
||||
`Scan response included findings alongside a skipReason ("${skipReason}"); processing findings.`,
|
||||
);
|
||||
}
|
||||
|
||||
core.info(`📊 Found ${comments.length} comments${review ? ' and review summary' : ''}`);
|
||||
|
||||
// A mixed skip with only PR-postable findings must not upload an empty SARIF run.
|
||||
if (!skipReason || hasSarifFindings) {
|
||||
emitConfiguredSarifOutput(scanResponse, inputs);
|
||||
}
|
||||
|
||||
if ((hasPrFindings || review) && commentsPosted === false) {
|
||||
await postFallbackComments(
|
||||
inputs.githubToken,
|
||||
context,
|
||||
comments,
|
||||
review,
|
||||
inputs.minimumSeverity,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (comments.length > 0 && commentsPosted === true) {
|
||||
core.info('✅ Comments posted to PR by scan server');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comments.length > 0) {
|
||||
// commentsPosted is undefined - old server version
|
||||
core.info('✅ Comments returned (server version does not indicate if posted)');
|
||||
return;
|
||||
}
|
||||
|
||||
core.info('✨ No vulnerabilities found!');
|
||||
}
|
||||
|
||||
function logActCommentPreview(comments: Comment[]): void {
|
||||
if (process.env.ACT !== 'true' || comments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.info('🧪 Running in act - showing comment preview:');
|
||||
comments.forEach((comment: Comment, index: number) => {
|
||||
core.info(` ${index + 1}. ${comment.file}:${comment.line}`);
|
||||
const preview = comment.fix
|
||||
? `${comment.finding.substring(0, 80)}... [+ suggested fix]`
|
||||
: comment.finding.substring(0, 100);
|
||||
core.info(` ${preview}${comment.finding.length > 100 ? '...' : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupConfig(configPath: string, finalConfigPath: string): void {
|
||||
if (!configPath) {
|
||||
fs.unlinkSync(finalConfigPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCodeScan(): Promise<void> {
|
||||
const inputs = getActionInputs();
|
||||
|
||||
if (shouldSkipForkPullRequest(inputs.enableForkPrs)) {
|
||||
core.info('🔀 Fork PR detected and enable-fork-prs is false; skipping Promptfoo Code Scan');
|
||||
core.info(
|
||||
'A maintainer can trigger a scan by commenting @promptfoo-scanner, or enable fork PR scans with enable-fork-prs: true',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const guidance = loadGuidance(inputs);
|
||||
|
||||
core.info('🔍 Starting Promptfoo Code Scan...');
|
||||
|
||||
const context = await getGitHubContext(inputs.githubToken);
|
||||
core.info(`📋 Scanning PR #${context.number} in ${context.owner}/${context.repo}`);
|
||||
|
||||
core.info('🔎 Checking if this is a setup PR...');
|
||||
const files = await getPRFiles(inputs.githubToken, context);
|
||||
|
||||
if (isSetupPR(files)) {
|
||||
core.info('✅ Setup PR detected - workflow file will be added on merge');
|
||||
return;
|
||||
}
|
||||
|
||||
core.info('✅ Not a setup PR - proceeding with security scan');
|
||||
|
||||
const oidcToken = await authenticateWithOidc();
|
||||
|
||||
const finalConfigPath = resolveConfigPath(inputs.configPath, inputs.minimumSeverity, guidance);
|
||||
|
||||
try {
|
||||
const baseBranch = await getBaseBranch(inputs.githubToken, context);
|
||||
await fetchBaseBranch(baseBranch);
|
||||
|
||||
const cliArgs = buildCliArgs(inputs.apiHost, finalConfigPath, baseBranch, context);
|
||||
const scanResponse = await getScanResponse(cliArgs, oidcToken, inputs.promptfooVersion);
|
||||
|
||||
await handleScanResponse(scanResponse, inputs, context);
|
||||
logActCommentPreview(scanResponse.comments);
|
||||
} finally {
|
||||
cleanupConfig(inputs.configPath, finalConfigPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
await runCodeScan();
|
||||
} catch (error) {
|
||||
core.setFailed(formatError(error));
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/nursery/noFloatingPromises: FIXME
|
||||
run();
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "..",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@actions/github": ["./node_modules/@actions/github/lib/github.d.ts"],
|
||||
"@octokit/rest": ["./node_modules/@octokit/rest/dist-types/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "../test/code-scan-action/**/*", "../test/util/utils.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user