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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+19
View File
@@ -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"
}
}
}
+46
View File
@@ -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 }}
+25
View File
@@ -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
```
+48
View File
@@ -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))
+92
View File
@@ -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
+50
View File
@@ -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'
+1397
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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"
}
}
+23
View File
@@ -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)}`,
);
}
}
+50
View File
@@ -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;
}
+193
View File
@@ -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);
}
+870
View File
@@ -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();
+18
View File
@@ -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"]
}
+10
View File
@@ -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"]
}