mirror of
https://github.com/ivuorinen/gh-codeql-report.git
synced 2026-01-26 03:34:05 +00:00
Initial commit
This commit is contained in:
4
.biomeignore
Normal file
4
.biomeignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.d.ts
|
||||||
16
.editorconfig
Normal file
16
.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
118
.github/workflows/ci.yml
vendored
Normal file
118
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint & Auto-fix
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run biome lint with auto-fix
|
||||||
|
run: npm run lint:fix
|
||||||
|
|
||||||
|
- name: Commit and push changes
|
||||||
|
uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v6.0.1
|
||||||
|
with:
|
||||||
|
commit_message: 'style: auto-fix biome issues [skip ci]'
|
||||||
|
commit_user_name: 'github-actions[bot]'
|
||||||
|
commit_user_email: 'github-actions[bot]@users.noreply.github.com'
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref || github.ref_name }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Upload coverage artifacts
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
path: coverage/
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref || github.ref_name }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
coverage-report:
|
||||||
|
name: Coverage Report
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Download coverage artifacts
|
||||||
|
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
path: coverage/
|
||||||
|
|
||||||
|
- name: Vitest Coverage Report
|
||||||
|
uses: davelosert/vitest-coverage-report-action@8ab049ff5a2c6e78f78af446329379b318544a1a # v2.8.3
|
||||||
|
with:
|
||||||
|
json-summary-path: coverage/coverage-summary.json
|
||||||
|
json-final-path: coverage/coverage-final.json
|
||||||
110
.github/workflows/release.yml
vendored
Normal file
110
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run biome lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Upload coverage artifacts
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
path: coverage/
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish to npm
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, test, build]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
cache: 'npm'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
run: npm publish --provenance --access public
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Reports (generated by the tool)
|
||||||
|
code-scanning-report-*.json
|
||||||
|
code-scanning-report-*.sarif
|
||||||
|
code-scanning-report-*.txt
|
||||||
|
code-scanning-report-*.md
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
lint-staged
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Ismo Vuorinen
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
323
README.md
Normal file
323
README.md
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
# gh-codeql-report
|
||||||
|
|
||||||
|
[](https://github.com/ivuorinen/gh-codeql-report/actions/workflows/ci.yml)
|
||||||
|
[](https://www.npmjs.com/package/@ivuorinen/gh-codeql-report)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
> Collect repository CodeQL findings as a LLM-friendly report for easier fixing.
|
||||||
|
|
||||||
|
A TypeScript CLI tool that fetches CodeQL security scanning results from GitHub repositories and formats them into LLM-friendly reports. Perfect for feeding security alerts to AI assistants for analysis and remediation suggestions.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔍 **Automatic Repository Detection** - Detects GitHub repository from local git remotes
|
||||||
|
- 🔐 **Multiple Authentication Methods** - Uses `GITHUB_TOKEN` environment variable or GitHub CLI (`gh`)
|
||||||
|
- 📊 **Multiple Output Formats** - JSON, SARIF, Markdown, and Plain Text
|
||||||
|
- 🎚️ **Configurable Detail Levels** - Choose from minimum, medium, full, or raw detail
|
||||||
|
- 🎉 **Clean Exit for No Alerts** - Celebrates when no security issues are found
|
||||||
|
- 📝 **Comprehensive Reports** - Includes rule details, locations, messages, and metadata
|
||||||
|
- 🚀 **Easy Integration** - Use with `npx` or install globally
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Using npx (Recommended)
|
||||||
|
|
||||||
|
No installation required:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @ivuorinen/gh-codeql-report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @ivuorinen/gh-codeql-report
|
||||||
|
gh-codeql-report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/ivuorinen/gh-codeql-report.git
|
||||||
|
cd gh-codeql-report
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** 18+ (ES Modules support)
|
||||||
|
- **GitHub repository** with CodeQL scanning enabled
|
||||||
|
- **Authentication**: Either:
|
||||||
|
- `GITHUB_TOKEN` environment variable with `security_events:read` scope, or
|
||||||
|
- GitHub CLI (`gh`) authenticated
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Option 1: Environment Variable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITHUB_TOKEN="ghp_your_token_here"
|
||||||
|
npx @ivuorinen/gh-codeql-report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: GitHub CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh auth login
|
||||||
|
npx @ivuorinen/gh-codeql-report
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool will automatically use `gh` CLI if `GITHUB_TOKEN` is not set.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
Run in your repository directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @ivuorinen/gh-codeql-report
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Detect the repository from your git remote
|
||||||
|
2. Fetch all open CodeQL alerts
|
||||||
|
3. Generate a `code-scanning-report-[timestamp].json` file with medium detail
|
||||||
|
|
||||||
|
### CLI Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh-codeql-report [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Alias | Description | Default |
|
||||||
|
|-------------|-------|--------------------------------------------------|---------------------------------------------|
|
||||||
|
| `--format` | `-f` | Output format: `json`, `sarif`, `txt`, `md` | `json` |
|
||||||
|
| `--detail` | `-d` | Detail level: `minimum`, `medium`, `full`, `raw` | `medium` |
|
||||||
|
| `--output` | `-o` | Output file path | `code-scanning-report-[timestamp].[format]` |
|
||||||
|
| `--help` | `-h` | Show help | |
|
||||||
|
| `--version` | `-v` | Show version | |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
#### Generate JSON Report with Full Detail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @ivuorinen/gh-codeql-report --format json --detail full
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Generate Markdown Report for LLM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @ivuorinen/gh-codeql-report --format md --output security-report.md
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Generate SARIF Report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @ivuorinen/gh-codeql-report --format sarif --output results.sarif
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Raw API Response
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @ivuorinen/gh-codeql-report --detail raw --output raw-alerts.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Formats
|
||||||
|
|
||||||
|
### JSON
|
||||||
|
Structured JSON output with flattened alert data. Ideal for programmatic processing and LLM consumption.
|
||||||
|
|
||||||
|
### SARIF
|
||||||
|
Standard SARIF v2.1.0 format. Compatible with many security tools and CI/CD platforms.
|
||||||
|
|
||||||
|
### Markdown
|
||||||
|
Human-readable markdown with tables and sections. Great for documentation and LLM context.
|
||||||
|
|
||||||
|
### Text
|
||||||
|
Plain text format for quick reading and terminal output.
|
||||||
|
|
||||||
|
## Detail Levels
|
||||||
|
|
||||||
|
### Minimum
|
||||||
|
Essential information only:
|
||||||
|
- Alert number and rule ID/name
|
||||||
|
- Severity and message
|
||||||
|
- File path and line numbers
|
||||||
|
- Commit SHA
|
||||||
|
|
||||||
|
### Medium (Default)
|
||||||
|
Balanced detail for most use cases:
|
||||||
|
- Everything from minimum level
|
||||||
|
- Rule description
|
||||||
|
- Column numbers
|
||||||
|
- Alert state (open, dismissed, etc.)
|
||||||
|
|
||||||
|
### Full
|
||||||
|
Complete information:
|
||||||
|
- Everything from medium level
|
||||||
|
- Git reference (branch/tag)
|
||||||
|
- Analysis key and category
|
||||||
|
- Tool name and version
|
||||||
|
- Help text (if available)
|
||||||
|
|
||||||
|
### Raw
|
||||||
|
Original API response without processing. Useful for debugging or custom processing.
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
- `0` - Success (report generated or no alerts found)
|
||||||
|
- `1` - Error (authentication failed, repository not found, API error, etc.)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Compiles TypeScript to `dist/` directory.
|
||||||
|
|
||||||
|
### Run Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using ts-node
|
||||||
|
npx tsx src/cli.ts
|
||||||
|
|
||||||
|
# Using compiled version
|
||||||
|
node dist/cli.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lint with Biome
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Lint with auto-fix
|
||||||
|
npm run lint:fix
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
npm run format
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests with coverage
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Current coverage: 98.91%
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite includes:
|
||||||
|
- Unit tests for all formatters
|
||||||
|
- Integration tests for CLI
|
||||||
|
- Error handling scenarios
|
||||||
|
- GitHub API mocking
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── cli.ts # Main CLI entry point
|
||||||
|
├── formatters/ # Output format generators
|
||||||
|
│ ├── json.ts
|
||||||
|
│ ├── sarif.ts
|
||||||
|
│ ├── markdown.ts
|
||||||
|
│ └── text.ts
|
||||||
|
├── lib/ # Core functionality
|
||||||
|
│ ├── auth.ts # GitHub authentication
|
||||||
|
│ ├── codeql.ts # CodeQL API client
|
||||||
|
│ ├── git.ts # Git remote parsing
|
||||||
|
│ └── types.ts # TypeScript types
|
||||||
|
└── __tests__/ # Test suites
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
The project uses GitHub Actions for:
|
||||||
|
- **CI**: Linting, testing, and building on every push/PR
|
||||||
|
- **Release**: Automated npm publishing on version tags
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please:
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests (`npm test`)
|
||||||
|
5. Run linting (`npm run lint:fix`)
|
||||||
|
6. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
7. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
8. Open a Pull Request
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- ES Modules (type: module)
|
||||||
|
- TypeScript with strict mode
|
||||||
|
- Biome for linting and formatting
|
||||||
|
- 2-space indentation
|
||||||
|
- LF line endings
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### For LLMs
|
||||||
|
Feed the generated reports to AI assistants for:
|
||||||
|
- Security vulnerability analysis
|
||||||
|
- Remediation suggestions
|
||||||
|
- Code review assistance
|
||||||
|
- Documentation generation
|
||||||
|
|
||||||
|
### For CI/CD
|
||||||
|
Integrate into pipelines for:
|
||||||
|
- Security gate checks
|
||||||
|
- Automated reporting
|
||||||
|
- Trend analysis
|
||||||
|
- Alert notifications
|
||||||
|
|
||||||
|
### For Security Teams
|
||||||
|
- Centralized alert collection
|
||||||
|
- Custom report formatting
|
||||||
|
- Historical data export
|
||||||
|
- Integration with ticketing systems
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No git remotes found
|
||||||
|
Ensure you're in a git repository with a GitHub remote:
|
||||||
|
```bash
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication failed
|
||||||
|
Check your token or GitHub CLI:
|
||||||
|
```bash
|
||||||
|
echo $GITHUB_TOKEN
|
||||||
|
# or
|
||||||
|
gh auth status
|
||||||
|
```
|
||||||
|
|
||||||
|
### No CodeQL alerts found
|
||||||
|
This is good news! It means your repository has no open security issues.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) © 2025 Ismo Vuorinen
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [GitHub Repository](https://github.com/ivuorinen/gh-codeql-report)
|
||||||
|
- [npm Package](https://www.npmjs.com/package/@ivuorinen/gh-codeql-report)
|
||||||
|
- [Issue Tracker](https://github.com/ivuorinen/gh-codeql-report/issues)
|
||||||
|
- [CodeQL Documentation](https://docs.github.com/en/code-security/code-scanning/introduction-to-code-scanning/about-code-scanning-with-codeql)
|
||||||
34
biome.json
Normal file
34
biome.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"trailingCommas": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3988
package-lock.json
generated
Normal file
3988
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
package.json
Normal file
63
package.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "@ivuorinen/gh-codeql-report",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Collect repository CodeQL findings as a LLM ready report for easier fixing.",
|
||||||
|
"keywords": [
|
||||||
|
"cli",
|
||||||
|
"github",
|
||||||
|
"codeql",
|
||||||
|
"security-scanning",
|
||||||
|
"llm",
|
||||||
|
"report"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/ivuorinen/gh-codeql-report#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/ivuorinen/gh-codeql-report/issues"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/ivuorinen/gh-codeql-report.git"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Ismo Vuorinen <https://github.com/ivuorinen>",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/cli.js",
|
||||||
|
"bin": {
|
||||||
|
"gh-codeql-report": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"preferGlobal": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run --coverage",
|
||||||
|
"lint": "biome check src/",
|
||||||
|
"lint:fix": "biome check --write .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/yargs": "^17.0.33",
|
||||||
|
"node-sarif-builder": "^3.2.0",
|
||||||
|
"octokit": "^5.0.3",
|
||||||
|
"simple-git": "^3.28.0",
|
||||||
|
"yargs": "^18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.2.4",
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.2.3",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [
|
||||||
|
"biome check --write --no-errors-on-unmatched",
|
||||||
|
"biome lint --write --no-errors-on-unmatched"
|
||||||
|
],
|
||||||
|
"*": [
|
||||||
|
"biome check --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/__tests__/auth.test.ts
Normal file
41
src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getGitHubToken } from '../lib/auth.js';
|
||||||
|
|
||||||
|
vi.mock('node:child_process');
|
||||||
|
|
||||||
|
describe('getGitHubToken', () => {
|
||||||
|
const originalEnv = process.env.GITHUB_TOKEN;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.GITHUB_TOKEN = originalEnv;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return token from GITHUB_TOKEN env var', () => {
|
||||||
|
process.env.GITHUB_TOKEN = 'test-token-from-env';
|
||||||
|
const token = getGitHubToken();
|
||||||
|
expect(token).toBe('test-token-from-env');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to gh CLI when GITHUB_TOKEN is not set', () => {
|
||||||
|
delete process.env.GITHUB_TOKEN;
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: mocking requires any type
|
||||||
|
vi.mocked(execSync).mockReturnValue('test-token-from-gh\n' as any);
|
||||||
|
|
||||||
|
const token = getGitHubToken();
|
||||||
|
expect(token).toBe('test-token-from-gh');
|
||||||
|
expect(execSync).toHaveBeenCalledWith('gh auth token', { encoding: 'utf-8' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when neither GITHUB_TOKEN nor gh CLI are available', () => {
|
||||||
|
delete process.env.GITHUB_TOKEN;
|
||||||
|
vi.mocked(execSync).mockImplementation(() => {
|
||||||
|
throw new Error('gh not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getGitHubToken()).toThrow(
|
||||||
|
'GitHub token not found. Please set GITHUB_TOKEN environment variable or authenticate with `gh auth login`',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
290
src/__tests__/cli.test.ts
Normal file
290
src/__tests__/cli.test.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { Octokit } from 'octokit';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { main } from '../cli.js';
|
||||||
|
import { formatAsJSON } from '../formatters/json.js';
|
||||||
|
import { formatAsMarkdown } from '../formatters/markdown.js';
|
||||||
|
import { formatAsSARIF } from '../formatters/sarif.js';
|
||||||
|
import { formatAsText } from '../formatters/text.js';
|
||||||
|
import { getGitHubToken } from '../lib/auth.js';
|
||||||
|
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||||
|
import { fetchAllAlertsWithDetails } from '../lib/codeql.js';
|
||||||
|
import { getGitHubRepoFromRemote } from '../lib/git.js';
|
||||||
|
|
||||||
|
// Mock all dependencies
|
||||||
|
vi.mock('node:fs/promises');
|
||||||
|
vi.mock('octokit');
|
||||||
|
vi.mock('../lib/auth.js');
|
||||||
|
vi.mock('../lib/git.js');
|
||||||
|
vi.mock('../lib/codeql.js');
|
||||||
|
vi.mock('../formatters/json.js');
|
||||||
|
vi.mock('../formatters/text.js');
|
||||||
|
vi.mock('../formatters/markdown.js');
|
||||||
|
vi.mock('../formatters/sarif.js');
|
||||||
|
|
||||||
|
const mockAlert: CodeQLAlert = {
|
||||||
|
number: 1,
|
||||||
|
rule: {
|
||||||
|
id: 'js/sql-injection',
|
||||||
|
severity: 'error',
|
||||||
|
description: 'SQL injection vulnerability',
|
||||||
|
name: 'SQL Injection',
|
||||||
|
},
|
||||||
|
most_recent_instance: {
|
||||||
|
ref: 'refs/heads/main',
|
||||||
|
analysis_key: 'test-analysis',
|
||||||
|
category: 'security',
|
||||||
|
state: 'open',
|
||||||
|
commit_sha: 'abc123',
|
||||||
|
message: {
|
||||||
|
text: 'Potential SQL injection detected',
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
path: 'src/database.js',
|
||||||
|
start_line: 10,
|
||||||
|
end_line: 12,
|
||||||
|
start_column: 5,
|
||||||
|
end_column: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
name: 'CodeQL',
|
||||||
|
version: '2.0.0',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CLI', () => {
|
||||||
|
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let originalArgv: string[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock console methods
|
||||||
|
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Save original argv
|
||||||
|
originalArgv = process.argv;
|
||||||
|
|
||||||
|
// Setup default mocks
|
||||||
|
vi.mocked(getGitHubToken).mockReturnValue('test-token');
|
||||||
|
vi.mocked(getGitHubRepoFromRemote).mockResolvedValue({
|
||||||
|
owner: 'test-owner',
|
||||||
|
repo: 'test-repo',
|
||||||
|
});
|
||||||
|
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(formatAsJSON).mockReturnValue('{"mock":"json"}');
|
||||||
|
vi.mocked(formatAsText).mockReturnValue('mock text');
|
||||||
|
vi.mocked(formatAsMarkdown).mockReturnValue('# Mock Markdown');
|
||||||
|
vi.mocked(formatAsSARIF).mockReturnValue('{"mock":"sarif"}');
|
||||||
|
|
||||||
|
// Mock Octokit constructor
|
||||||
|
vi.mocked(Octokit).mockImplementation(() => ({}) as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original argv
|
||||||
|
process.argv = originalArgv;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('successful alert generation', () => {
|
||||||
|
it('should generate JSON report with default settings', async () => {
|
||||||
|
process.argv = ['node', 'cli.js'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(getGitHubToken).toHaveBeenCalled();
|
||||||
|
expect(getGitHubRepoFromRemote).toHaveBeenCalled();
|
||||||
|
expect(fetchAllAlertsWithDetails).toHaveBeenCalled();
|
||||||
|
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'medium');
|
||||||
|
expect(writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/code-scanning-report-.*\.json$/),
|
||||||
|
'{"mock":"json"}',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('✅ Report saved to:'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate SARIF report when format specified', async () => {
|
||||||
|
process.argv = ['node', 'cli.js', '--format', 'sarif'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(formatAsSARIF).toHaveBeenCalledWith([mockAlert], 'test-owner/test-repo', 'medium');
|
||||||
|
expect(writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/code-scanning-report-.*\.sarif$/),
|
||||||
|
'{"mock":"sarif"}',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate text report when format specified', async () => {
|
||||||
|
process.argv = ['node', 'cli.js', '--format', 'txt'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(formatAsText).toHaveBeenCalledWith([mockAlert], 'medium');
|
||||||
|
expect(writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/code-scanning-report-.*\.txt$/),
|
||||||
|
'mock text',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate markdown report when format specified', async () => {
|
||||||
|
process.argv = ['node', 'cli.js', '--format', 'md'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(formatAsMarkdown).toHaveBeenCalledWith([mockAlert], 'test-owner/test-repo', 'medium');
|
||||||
|
expect(writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/code-scanning-report-.*\.md$/),
|
||||||
|
'# Mock Markdown',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom output path when specified', async () => {
|
||||||
|
process.argv = ['node', 'cli.js', '--output', 'custom-report.json'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(writeFile).toHaveBeenCalledWith('custom-report.json', '{"mock":"json"}', 'utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use minimum detail level when specified', async () => {
|
||||||
|
process.argv = ['node', 'cli.js', '--detail', 'minimum'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'minimum');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use full detail level when specified', async () => {
|
||||||
|
process.argv = ['node', 'cli.js', '--detail', 'full'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use raw detail level when specified', async () => {
|
||||||
|
process.argv = ['node', 'cli.js', '--detail', 'raw'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(formatAsJSON).toHaveBeenCalledWith([mockAlert], 'raw');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('no alerts found (celebration)', () => {
|
||||||
|
it('should celebrate and exit with 0 when no alerts found', async () => {
|
||||||
|
process.argv = ['node', 'cli.js'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
|
'🎉 No CodeQL alerts found! Your repository is clean!',
|
||||||
|
);
|
||||||
|
expect(writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should handle git remote error and exit with 1', async () => {
|
||||||
|
process.argv = ['node', 'cli.js'];
|
||||||
|
vi.mocked(getGitHubRepoFromRemote).mockRejectedValue(
|
||||||
|
new Error('No git remotes found. Make sure you are in a git repository.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'❌ Error: No git remotes found. Make sure you are in a git repository.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle authentication error', async () => {
|
||||||
|
process.argv = ['node', 'cli.js'];
|
||||||
|
vi.mocked(getGitHubToken).mockImplementation(() => {
|
||||||
|
throw new Error('GitHub token not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Error: GitHub token not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API error', async () => {
|
||||||
|
process.argv = ['node', 'cli.js'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockRejectedValue(new Error('API request failed'));
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Error: API request failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle file write error', async () => {
|
||||||
|
process.argv = ['node', 'cli.js'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
vi.mocked(writeFile).mockRejectedValue(new Error('Permission denied'));
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ Error: Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-Error exceptions', async () => {
|
||||||
|
process.argv = ['node', 'cli.js'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockRejectedValue('string error');
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('❌ An unexpected error occurred');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('console output', () => {
|
||||||
|
it('should log progress messages', async () => {
|
||||||
|
process.argv = ['node', 'cli.js'];
|
||||||
|
vi.mocked(fetchAllAlertsWithDetails).mockResolvedValue([mockAlert]);
|
||||||
|
|
||||||
|
const exitCode = await main();
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith('🔐 Authenticating with GitHub...');
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith('📂 Detecting repository from git remote...');
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(' Repository: test-owner/test-repo');
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith('🔍 Fetching CodeQL alerts...');
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith(' Found 1 open alert(s)');
|
||||||
|
expect(consoleLogSpy).toHaveBeenCalledWith('📝 Generating JSON report (medium detail)...');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
196
src/__tests__/codeql.test.ts
Normal file
196
src/__tests__/codeql.test.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import type { Octokit } from 'octokit';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||||
|
import { fetchAlertDetails, fetchAllAlertsWithDetails, fetchCodeQLAlerts } from '../lib/codeql.js';
|
||||||
|
import type { GitHubRepo } from '../lib/git.js';
|
||||||
|
|
||||||
|
const mockAlert: CodeQLAlert = {
|
||||||
|
number: 1,
|
||||||
|
rule: {
|
||||||
|
id: 'js/sql-injection',
|
||||||
|
severity: 'error',
|
||||||
|
description: 'SQL injection vulnerability',
|
||||||
|
name: 'SQL Injection',
|
||||||
|
},
|
||||||
|
most_recent_instance: {
|
||||||
|
ref: 'refs/heads/main',
|
||||||
|
analysis_key: 'test-analysis',
|
||||||
|
category: 'security',
|
||||||
|
state: 'open',
|
||||||
|
commit_sha: 'abc123',
|
||||||
|
message: {
|
||||||
|
text: 'Potential SQL injection detected',
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
path: 'src/database.js',
|
||||||
|
start_line: 10,
|
||||||
|
end_line: 12,
|
||||||
|
start_column: 5,
|
||||||
|
end_column: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
name: 'CodeQL',
|
||||||
|
version: '2.0.0',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRepo: GitHubRepo = {
|
||||||
|
owner: 'test-owner',
|
||||||
|
repo: 'test-repo',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CodeQL API', () => {
|
||||||
|
describe('fetchCodeQLAlerts', () => {
|
||||||
|
it('should stop pagination when empty page received', async () => {
|
||||||
|
const mockOctokit = {
|
||||||
|
rest: {
|
||||||
|
codeScanning: {
|
||||||
|
listAlertsForRepo: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: [mockAlert, { ...mockAlert, number: 2 }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Octokit;
|
||||||
|
|
||||||
|
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||||
|
|
||||||
|
expect(alerts).toHaveLength(2);
|
||||||
|
// Should stop on first call because result is less than perPage (100)
|
||||||
|
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single page of alerts', async () => {
|
||||||
|
const mockOctokit = {
|
||||||
|
rest: {
|
||||||
|
codeScanning: {
|
||||||
|
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||||
|
data: [mockAlert],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Octokit;
|
||||||
|
|
||||||
|
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||||
|
|
||||||
|
expect(alerts).toHaveLength(1);
|
||||||
|
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty results', async () => {
|
||||||
|
const mockOctokit = {
|
||||||
|
rest: {
|
||||||
|
codeScanning: {
|
||||||
|
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Octokit;
|
||||||
|
|
||||||
|
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||||
|
|
||||||
|
expect(alerts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should continue pagination until fewer than perPage results', async () => {
|
||||||
|
const mockAlerts = Array.from({ length: 100 }, (_, i) => ({ ...mockAlert, number: i + 1 }));
|
||||||
|
const mockOctokit = {
|
||||||
|
rest: {
|
||||||
|
codeScanning: {
|
||||||
|
listAlertsForRepo: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: mockAlerts,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: [{ ...mockAlert, number: 101 }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Octokit;
|
||||||
|
|
||||||
|
const alerts = await fetchCodeQLAlerts(mockOctokit, mockRepo);
|
||||||
|
|
||||||
|
expect(alerts).toHaveLength(101);
|
||||||
|
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchAlertDetails', () => {
|
||||||
|
it('should fetch details for a specific alert', async () => {
|
||||||
|
const mockOctokit = {
|
||||||
|
rest: {
|
||||||
|
codeScanning: {
|
||||||
|
getAlert: vi.fn().mockResolvedValue({
|
||||||
|
data: mockAlert,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Octokit;
|
||||||
|
|
||||||
|
const alert = await fetchAlertDetails(mockOctokit, mockRepo, 1);
|
||||||
|
|
||||||
|
expect(alert).toEqual(mockAlert);
|
||||||
|
expect(mockOctokit.rest.codeScanning.getAlert).toHaveBeenCalledWith({
|
||||||
|
owner: 'test-owner',
|
||||||
|
repo: 'test-repo',
|
||||||
|
alert_number: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchAllAlertsWithDetails', () => {
|
||||||
|
it('should fetch all alerts and their details', async () => {
|
||||||
|
const mockOctokit = {
|
||||||
|
rest: {
|
||||||
|
codeScanning: {
|
||||||
|
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ ...mockAlert, number: 1 },
|
||||||
|
{ ...mockAlert, number: 2 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getAlert: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { ...mockAlert, number: 1 },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { ...mockAlert, number: 2 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Octokit;
|
||||||
|
|
||||||
|
const alerts = await fetchAllAlertsWithDetails(mockOctokit, mockRepo);
|
||||||
|
|
||||||
|
expect(alerts).toHaveLength(2);
|
||||||
|
expect(mockOctokit.rest.codeScanning.listAlertsForRepo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOctokit.rest.codeScanning.getAlert).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty results', async () => {
|
||||||
|
const mockOctokit = {
|
||||||
|
rest: {
|
||||||
|
codeScanning: {
|
||||||
|
listAlertsForRepo: vi.fn().mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
}),
|
||||||
|
getAlert: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Octokit;
|
||||||
|
|
||||||
|
const alerts = await fetchAllAlertsWithDetails(mockOctokit, mockRepo);
|
||||||
|
|
||||||
|
expect(alerts).toHaveLength(0);
|
||||||
|
expect(mockOctokit.rest.codeScanning.getAlert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
300
src/__tests__/formatters.test.ts
Normal file
300
src/__tests__/formatters.test.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatAsJSON } from '../formatters/json.js';
|
||||||
|
import { formatAsMarkdown, generateMarkdownTable } from '../formatters/markdown.js';
|
||||||
|
import { formatAsSARIF } from '../formatters/sarif.js';
|
||||||
|
import { formatAsText } from '../formatters/text.js';
|
||||||
|
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||||
|
|
||||||
|
const mockAlert: CodeQLAlert = {
|
||||||
|
number: 1,
|
||||||
|
rule: {
|
||||||
|
id: 'js/sql-injection',
|
||||||
|
severity: 'error',
|
||||||
|
description: 'SQL injection vulnerability',
|
||||||
|
name: 'SQL Injection',
|
||||||
|
},
|
||||||
|
most_recent_instance: {
|
||||||
|
ref: 'refs/heads/main',
|
||||||
|
analysis_key: 'test-analysis',
|
||||||
|
category: 'security',
|
||||||
|
state: 'open',
|
||||||
|
commit_sha: 'abc123',
|
||||||
|
message: {
|
||||||
|
text: 'Potential SQL injection detected',
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
path: 'src/database.js',
|
||||||
|
start_line: 10,
|
||||||
|
end_line: 12,
|
||||||
|
start_column: 5,
|
||||||
|
end_column: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
name: 'CodeQL',
|
||||||
|
version: '2.0.0',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Formatters', () => {
|
||||||
|
describe('formatAsJSON', () => {
|
||||||
|
it('should format alerts as JSON with default (medium) detail', () => {
|
||||||
|
const result = formatAsJSON([mockAlert]);
|
||||||
|
expect(result).toContain('"number": 1');
|
||||||
|
expect(result).toContain('"js/sql-injection"');
|
||||||
|
expect(() => JSON.parse(result)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format alerts with minimum detail (flat structure)', () => {
|
||||||
|
const result = formatAsJSON([mockAlert], 'minimum');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed[0]).toHaveProperty('number');
|
||||||
|
expect(parsed[0]).toHaveProperty('rule_id');
|
||||||
|
expect(parsed[0]).toHaveProperty('commit_sha'); // Now in all levels
|
||||||
|
expect(parsed[0]).not.toHaveProperty('rule_description');
|
||||||
|
expect(parsed[0]).not.toHaveProperty('rule.id'); // Nested structure removed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format alerts with medium detail (flat structure)', () => {
|
||||||
|
const result = formatAsJSON([mockAlert], 'medium');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed[0]).toHaveProperty('number');
|
||||||
|
expect(parsed[0]).toHaveProperty('rule_description');
|
||||||
|
expect(parsed[0]).toHaveProperty('commit_sha');
|
||||||
|
expect(parsed[0]).toHaveProperty('state');
|
||||||
|
expect(parsed[0]).not.toHaveProperty('ref');
|
||||||
|
expect(parsed[0]).not.toHaveProperty('most_recent_instance'); // Nested structure removed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format alerts with full detail (flat structure)', () => {
|
||||||
|
const result = formatAsJSON([mockAlert], 'full');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed[0]).toHaveProperty('ref');
|
||||||
|
expect(parsed[0]).toHaveProperty('tool_name');
|
||||||
|
expect(parsed[0]).toHaveProperty('tool_version');
|
||||||
|
expect(parsed[0]).not.toHaveProperty('tool'); // Nested structure removed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include help_text in full detail when available', () => {
|
||||||
|
const alertWithHelp = {
|
||||||
|
...mockAlert,
|
||||||
|
help: 'This is a helpful guide on how to fix this issue.',
|
||||||
|
};
|
||||||
|
const result = formatAsJSON([alertWithHelp], 'full');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed[0]).toHaveProperty('help_text');
|
||||||
|
expect(parsed[0].help_text).toBe('This is a helpful guide on how to fix this issue.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format alerts with raw detail (original structure)', () => {
|
||||||
|
const result = formatAsJSON([mockAlert], 'raw');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed[0]).toHaveProperty('most_recent_instance');
|
||||||
|
expect(parsed[0]).toHaveProperty('tool');
|
||||||
|
expect(parsed[0]).toHaveProperty('rule');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const result = formatAsJSON([]);
|
||||||
|
expect(result).toBe('[]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAsText', () => {
|
||||||
|
it('should format alerts as text with default (medium) detail', () => {
|
||||||
|
const result = formatAsText([mockAlert]);
|
||||||
|
expect(result).toContain('CodeQL Security Scan Report');
|
||||||
|
expect(result).toContain('Total Alerts: 1');
|
||||||
|
expect(result).toContain('Detail Level: medium');
|
||||||
|
expect(result).toContain('Alert #1');
|
||||||
|
expect(result).toContain('js/sql-injection');
|
||||||
|
expect(result).toContain('SQL Injection');
|
||||||
|
expect(result).toContain('src/database.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format alerts with minimum detail (commit now included)', () => {
|
||||||
|
const result = formatAsText([mockAlert], 'minimum');
|
||||||
|
expect(result).toContain('Detail Level: minimum');
|
||||||
|
expect(result).toContain('Alert #1');
|
||||||
|
expect(result).toContain('Commit:'); // Now in all levels
|
||||||
|
expect(result).not.toContain('Description:');
|
||||||
|
expect(result).not.toContain('Columns:');
|
||||||
|
expect(result).not.toContain('State:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format alerts with full detail', () => {
|
||||||
|
const result = formatAsText([mockAlert], 'full');
|
||||||
|
expect(result).toContain('Detail Level: full');
|
||||||
|
expect(result).toContain('Description:');
|
||||||
|
expect(result).toContain('Columns:');
|
||||||
|
expect(result).toContain('Commit:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const result = formatAsText([]);
|
||||||
|
expect(result).toContain('Total Alerts: 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format alerts with raw detail (original structure)', () => {
|
||||||
|
const result = formatAsText([mockAlert], 'raw');
|
||||||
|
expect(result).toContain('Detail Level: raw');
|
||||||
|
expect(result).toContain('"most_recent_instance"');
|
||||||
|
expect(result).toContain('"tool"');
|
||||||
|
expect(result).toContain('"rule"');
|
||||||
|
const parsed = JSON.parse(result.split('\n').slice(4, -2).join('\n'));
|
||||||
|
expect(parsed).toHaveProperty('most_recent_instance');
|
||||||
|
expect(parsed).toHaveProperty('tool');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateMarkdownTable', () => {
|
||||||
|
it('should generate a valid markdown table', () => {
|
||||||
|
const data = [
|
||||||
|
['Name', 'Age', 'City'],
|
||||||
|
['Alice', '30', 'NYC'],
|
||||||
|
['Bob', '25', 'LA'],
|
||||||
|
];
|
||||||
|
const result = generateMarkdownTable(data);
|
||||||
|
expect(result).toContain('| Name | Age | City |');
|
||||||
|
expect(result).toContain('| ----- | --- | ---- |');
|
||||||
|
expect(result).toContain('| Alice | 30 | NYC |');
|
||||||
|
expect(result).toContain('| Bob | 25 | LA |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const result = generateMarkdownTable([]);
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle jagged arrays (rows with missing columns)', () => {
|
||||||
|
const data = [
|
||||||
|
['Name', 'Age', 'City'],
|
||||||
|
['Alice', '30'], // Missing city
|
||||||
|
['Bob', '25', 'LA'],
|
||||||
|
];
|
||||||
|
const result = generateMarkdownTable(data);
|
||||||
|
expect(result).toContain('| Name | Age | City |');
|
||||||
|
expect(result).toContain('| ----- | --- | ---- |');
|
||||||
|
expect(result).toContain('| Alice | 30 | |'); // Empty cell for missing column
|
||||||
|
expect(result).toContain('| Bob | 25 | LA |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single row (headers only)', () => {
|
||||||
|
const data = [['Header1', 'Header2']];
|
||||||
|
const result = generateMarkdownTable(data);
|
||||||
|
expect(result).toContain('| Header1 | Header2 |');
|
||||||
|
expect(result).toContain('| ------- | ------- |');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAsMarkdown', () => {
|
||||||
|
it('should format alerts as markdown with default (medium) detail', () => {
|
||||||
|
const result = formatAsMarkdown([mockAlert], 'owner/repo');
|
||||||
|
expect(result).toContain('# CodeQL Security Scan Report');
|
||||||
|
expect(result).toContain('**Repository:** owner/repo');
|
||||||
|
expect(result).toContain('**Total Alerts:** 1');
|
||||||
|
expect(result).toContain('**Detail Level:** medium');
|
||||||
|
expect(result).toContain('## Summary by Severity');
|
||||||
|
expect(result).toContain('### Alert #1: SQL Injection');
|
||||||
|
expect(result).toContain('`js/sql-injection`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format with minimum detail (commit now included)', () => {
|
||||||
|
const result = formatAsMarkdown([mockAlert], 'owner/repo', 'minimum');
|
||||||
|
expect(result).toContain('**Detail Level:** minimum');
|
||||||
|
expect(result).toContain('**Commit:**'); // Now in all levels
|
||||||
|
expect(result).toContain('#### Details'); // Now always present (for commit)
|
||||||
|
expect(result).not.toContain('**Description:**');
|
||||||
|
expect(result).not.toContain('**Columns:**');
|
||||||
|
expect(result).not.toContain('**State:**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format with full detail (includes ref)', () => {
|
||||||
|
const result = formatAsMarkdown([mockAlert], 'owner/repo', 'full');
|
||||||
|
expect(result).toContain('**Detail Level:** full');
|
||||||
|
expect(result).toContain('**Reference:**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include severity summary table', () => {
|
||||||
|
const result = formatAsMarkdown([mockAlert], 'owner/repo');
|
||||||
|
expect(result).toContain('Severity');
|
||||||
|
expect(result).toContain('Count');
|
||||||
|
expect(result).toContain('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format with raw detail (original structure as JSON)', () => {
|
||||||
|
const result = formatAsMarkdown([mockAlert], 'owner/repo', 'raw');
|
||||||
|
expect(result).toContain('**Detail Level:** raw');
|
||||||
|
expect(result).toContain('```json');
|
||||||
|
expect(result).toContain('"most_recent_instance"');
|
||||||
|
expect(result).toContain('"tool"');
|
||||||
|
expect(result).toContain('"rule"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple alerts with different severities', () => {
|
||||||
|
const alerts: CodeQLAlert[] = [
|
||||||
|
mockAlert,
|
||||||
|
{ ...mockAlert, number: 2, rule: { ...mockAlert.rule, severity: 'warning' } },
|
||||||
|
{ ...mockAlert, number: 3, rule: { ...mockAlert.rule, severity: 'warning' } },
|
||||||
|
{ ...mockAlert, number: 4, rule: { ...mockAlert.rule, severity: 'note' } },
|
||||||
|
];
|
||||||
|
const result = formatAsMarkdown(alerts, 'owner/repo');
|
||||||
|
expect(result).toContain('**Total Alerts:** 4');
|
||||||
|
expect(result).toContain('error');
|
||||||
|
expect(result).toContain('warning');
|
||||||
|
expect(result).toContain('note');
|
||||||
|
// Should have summary table with all three severities
|
||||||
|
expect(result).toContain('## Summary by Severity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAsSARIF', () => {
|
||||||
|
it('should format alerts as valid SARIF with default (medium) detail', () => {
|
||||||
|
const result = formatAsSARIF([mockAlert], 'owner/repo');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed).toHaveProperty('$schema');
|
||||||
|
expect(parsed).toHaveProperty('version');
|
||||||
|
expect(parsed.runs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format with minimum detail', () => {
|
||||||
|
const result = formatAsSARIF([mockAlert], 'owner/repo', 'minimum');
|
||||||
|
expect(() => JSON.parse(result)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format with full detail (includes tool version)', () => {
|
||||||
|
const result = formatAsSARIF([mockAlert], 'owner/repo', 'full');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.runs[0].tool.driver.version).toBe('2.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format with raw detail (returns original JSON)', () => {
|
||||||
|
const result = formatAsSARIF([mockAlert], 'owner/repo', 'raw');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(Array.isArray(parsed)).toBe(true);
|
||||||
|
expect(parsed[0]).toHaveProperty('most_recent_instance');
|
||||||
|
expect(parsed[0]).toHaveProperty('tool');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map medium severity to warning level', () => {
|
||||||
|
const mediumAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'medium' } };
|
||||||
|
const result = formatAsSARIF([mediumAlert], 'owner/repo');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.runs[0].results[0].level).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map warning severity to warning level', () => {
|
||||||
|
const warningAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'warning' } };
|
||||||
|
const result = formatAsSARIF([warningAlert], 'owner/repo');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.runs[0].results[0].level).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map unknown severity to note level', () => {
|
||||||
|
const lowAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'low' } };
|
||||||
|
const result = formatAsSARIF([lowAlert], 'owner/repo');
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.runs[0].results[0].level).toBe('note');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
148
src/__tests__/git.test.ts
Normal file
148
src/__tests__/git.test.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import type { SimpleGit } from 'simple-git';
|
||||||
|
import simpleGit from 'simple-git';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getGitHubRepoFromRemote, parseGitHubUrl } from '../lib/git.js';
|
||||||
|
|
||||||
|
vi.mock('simple-git');
|
||||||
|
|
||||||
|
describe('parseGitHubUrl', () => {
|
||||||
|
it('should parse HTTPS URL', () => {
|
||||||
|
const result = parseGitHubUrl('https://github.com/owner/repo.git');
|
||||||
|
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse HTTPS URL without .git', () => {
|
||||||
|
const result = parseGitHubUrl('https://github.com/owner/repo');
|
||||||
|
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse SSH URL', () => {
|
||||||
|
const result = parseGitHubUrl('git@github.com:owner/repo.git');
|
||||||
|
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse git:// URL', () => {
|
||||||
|
const result = parseGitHubUrl('git://github.com/owner/repo.git');
|
||||||
|
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid URL', () => {
|
||||||
|
const result = parseGitHubUrl('not-a-valid-url');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs with hyphens and underscores', () => {
|
||||||
|
const result = parseGitHubUrl('https://github.com/my-org_name/my-repo_name.git');
|
||||||
|
expect(result).toEqual({ owner: 'my-org_name', repo: 'my-repo_name' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGitHubRepoFromRemote', () => {
|
||||||
|
it('should extract repo from origin remote', async () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ name: 'origin', refs: { fetch: 'https://github.com/owner/repo.git', push: '' } },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
|
||||||
|
const result = await getGitHubRepoFromRemote();
|
||||||
|
|
||||||
|
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||||
|
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use first remote if origin not found', async () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ name: 'upstream', refs: { fetch: 'https://github.com/other/repo.git', push: '' } },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
|
||||||
|
const result = await getGitHubRepoFromRemote();
|
||||||
|
|
||||||
|
expect(result).toEqual({ owner: 'other', repo: 'repo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use push URL if fetch URL not available', async () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ name: 'origin', refs: { fetch: '', push: 'git@github.com:owner/repo.git' } },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
|
||||||
|
const result = await getGitHubRepoFromRemote();
|
||||||
|
|
||||||
|
expect(result).toEqual({ owner: 'owner', repo: 'repo' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if no remotes found', async () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
|
||||||
|
await expect(getGitHubRepoFromRemote()).rejects.toThrow('No git remotes found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if remote has no valid URL', async () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi.fn().mockResolvedValue([{ name: 'origin', refs: { fetch: '', push: '' } }]),
|
||||||
|
};
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
|
||||||
|
await expect(getGitHubRepoFromRemote()).rejects.toThrow('No valid remote URL found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if URL cannot be parsed', async () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ name: 'origin', refs: { fetch: 'not-a-github-url', push: '' } }]),
|
||||||
|
};
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
|
||||||
|
await expect(getGitHubRepoFromRemote()).rejects.toThrow('Unable to parse GitHub repository');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle git errors', async () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi.fn().mockRejectedValue(new Error('Git error')),
|
||||||
|
};
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
|
||||||
|
await expect(getGitHubRepoFromRemote()).rejects.toThrow('Git error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-Error exceptions', async () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi.fn().mockRejectedValue('string error'),
|
||||||
|
};
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
|
||||||
|
await expect(getGitHubRepoFromRemote()).rejects.toThrow('Failed to get git remote information');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass cwd parameter to simpleGit', async () => {
|
||||||
|
const mockGit = {
|
||||||
|
getRemotes: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ name: 'origin', refs: { fetch: 'https://github.com/owner/repo.git', push: '' } },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
|
||||||
|
|
||||||
|
await getGitHubRepoFromRemote('/custom/path');
|
||||||
|
|
||||||
|
expect(simpleGit).toHaveBeenCalledWith('/custom/path');
|
||||||
|
});
|
||||||
|
});
|
||||||
121
src/cli.ts
Normal file
121
src/cli.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { Octokit } from 'octokit';
|
||||||
|
import yargs from 'yargs';
|
||||||
|
import { hideBin } from 'yargs/helpers';
|
||||||
|
import { formatAsJSON } from './formatters/json.js';
|
||||||
|
import { formatAsMarkdown } from './formatters/markdown.js';
|
||||||
|
import { formatAsSARIF } from './formatters/sarif.js';
|
||||||
|
import { formatAsText } from './formatters/text.js';
|
||||||
|
import { getGitHubToken } from './lib/auth.js';
|
||||||
|
import { fetchAllAlertsWithDetails } from './lib/codeql.js';
|
||||||
|
import { getGitHubRepoFromRemote } from './lib/git.js';
|
||||||
|
import type { DetailLevel } from './lib/types.js';
|
||||||
|
|
||||||
|
interface Arguments {
|
||||||
|
format: string;
|
||||||
|
output?: string;
|
||||||
|
detail: DetailLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function main(): Promise<number> {
|
||||||
|
const argv = (await yargs(hideBin(process.argv))
|
||||||
|
.option('format', {
|
||||||
|
alias: 'f',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Output format',
|
||||||
|
choices: ['json', 'sarif', 'txt', 'md'],
|
||||||
|
default: 'json',
|
||||||
|
})
|
||||||
|
.option('detail', {
|
||||||
|
alias: 'd',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Detail level: minimum (essentials only), medium (balanced), full (everything), raw (original API response)',
|
||||||
|
choices: ['minimum', 'medium', 'full', 'raw'],
|
||||||
|
default: 'medium',
|
||||||
|
})
|
||||||
|
.option('output', {
|
||||||
|
alias: 'o',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Output file path (optional, defaults to code-scanning-report-[timestamp])',
|
||||||
|
})
|
||||||
|
.help()
|
||||||
|
.alias('help', 'h')
|
||||||
|
.version()
|
||||||
|
.alias('version', 'v')
|
||||||
|
.parse()) as Arguments;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get GitHub token
|
||||||
|
console.log('🔐 Authenticating with GitHub...');
|
||||||
|
const token = getGitHubToken();
|
||||||
|
const octokit = new Octokit({ auth: token });
|
||||||
|
|
||||||
|
// Get repository info from git remote
|
||||||
|
console.log('📂 Detecting repository from git remote...');
|
||||||
|
const repo = await getGitHubRepoFromRemote();
|
||||||
|
console.log(` Repository: ${repo.owner}/${repo.repo}`);
|
||||||
|
|
||||||
|
// Fetch CodeQL alerts
|
||||||
|
console.log('🔍 Fetching CodeQL alerts...');
|
||||||
|
const alerts = await fetchAllAlertsWithDetails(octokit, repo);
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
console.log('🎉 No CodeQL alerts found! Your repository is clean!');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Found ${alerts.length} open alert(s)`);
|
||||||
|
|
||||||
|
// Format the report
|
||||||
|
console.log(`📝 Generating ${argv.format.toUpperCase()} report (${argv.detail} detail)...`);
|
||||||
|
const repoName = `${repo.owner}/${repo.repo}`;
|
||||||
|
let content: string;
|
||||||
|
|
||||||
|
switch (argv.format) {
|
||||||
|
case 'json':
|
||||||
|
content = formatAsJSON(alerts, argv.detail);
|
||||||
|
break;
|
||||||
|
case 'sarif':
|
||||||
|
content = formatAsSARIF(alerts, repoName, argv.detail);
|
||||||
|
break;
|
||||||
|
case 'txt':
|
||||||
|
content = formatAsText(alerts, argv.detail);
|
||||||
|
break;
|
||||||
|
case 'md':
|
||||||
|
content = formatAsMarkdown(alerts, repoName, argv.detail);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported format: ${argv.format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate output filename
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[:.]/g, '-')
|
||||||
|
.replace(/T/, '-')
|
||||||
|
.split('.')[0];
|
||||||
|
const outputPath = argv.output || `code-scanning-report-${timestamp}.${argv.format}`;
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
await writeFile(outputPath, content, 'utf-8');
|
||||||
|
console.log(`✅ Report saved to: ${outputPath}`);
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(`❌ Error: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
console.error('❌ An unexpected error occurred');
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run if this is the main module (not imported for testing)
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
main().then((exitCode) => {
|
||||||
|
process.exit(exitCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
10
src/formatters/json.ts
Normal file
10
src/formatters/json.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||||
|
import { type DetailLevel, filterAlertByDetail } from '../lib/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format alerts as JSON
|
||||||
|
*/
|
||||||
|
export function formatAsJSON(alerts: CodeQLAlert[], detailLevel: DetailLevel = 'medium'): string {
|
||||||
|
const filteredAlerts = alerts.map((alert) => filterAlertByDetail(alert, detailLevel));
|
||||||
|
return JSON.stringify(filteredAlerts, null, 2);
|
||||||
|
}
|
||||||
157
src/formatters/markdown.ts
Normal file
157
src/formatters/markdown.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||||
|
import {
|
||||||
|
type DetailLevel,
|
||||||
|
type FullAlert,
|
||||||
|
filterAlertByDetail,
|
||||||
|
type MediumAlert,
|
||||||
|
type MinimumAlert,
|
||||||
|
} from '../lib/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a markdown table from 2D array data
|
||||||
|
* Exported for testing edge cases
|
||||||
|
*/
|
||||||
|
export function generateMarkdownTable(data: string[][]): string {
|
||||||
|
if (data.length === 0) return '';
|
||||||
|
|
||||||
|
const [headers, ...rows] = data;
|
||||||
|
|
||||||
|
// Calculate column widths
|
||||||
|
const columnWidths = headers.map((header, i) => {
|
||||||
|
const maxWidth = Math.max(header.length, ...rows.map((row) => row[i]?.length || 0));
|
||||||
|
return maxWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build table
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
const headerRow = headers.map((header, i) => header.padEnd(columnWidths[i])).join(' | ');
|
||||||
|
lines.push(`| ${headerRow} |`);
|
||||||
|
|
||||||
|
// Separator row
|
||||||
|
const separator = columnWidths.map((width) => '-'.repeat(width)).join(' | ');
|
||||||
|
lines.push(`| ${separator} |`);
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
for (const row of rows) {
|
||||||
|
// Pad row with empty strings if it's shorter than headers
|
||||||
|
const paddedRow = Array.from({ length: headers.length }, (_, i) => row[i] || '');
|
||||||
|
const dataRow = paddedRow.map((cell, i) => cell.padEnd(columnWidths[i])).join(' | ');
|
||||||
|
lines.push(`| ${dataRow} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format alerts as Markdown
|
||||||
|
*/
|
||||||
|
export function formatAsMarkdown(
|
||||||
|
alerts: CodeQLAlert[],
|
||||||
|
repoName: string,
|
||||||
|
detailLevel: DetailLevel = 'medium',
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(`# CodeQL Security Scan Report`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`**Repository:** ${repoName}`);
|
||||||
|
lines.push(`**Total Alerts:** ${alerts.length}`);
|
||||||
|
lines.push(`**Detail Level:** ${detailLevel}`);
|
||||||
|
lines.push(`**Generated:** ${new Date().toISOString()}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Summary table
|
||||||
|
lines.push('## Summary by Severity');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
const severityCounts = alerts.reduce(
|
||||||
|
(acc, alert) => {
|
||||||
|
const severity = alert.rule.severity.toLowerCase();
|
||||||
|
acc[severity] = (acc[severity] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryTableData = [
|
||||||
|
['Severity', 'Count'],
|
||||||
|
...Object.entries(severityCounts).map(([severity, count]) => [severity, count.toString()]),
|
||||||
|
];
|
||||||
|
|
||||||
|
lines.push(generateMarkdownTable(summaryTableData));
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Detailed alerts
|
||||||
|
lines.push('## Detailed Alerts');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const filtered = filterAlertByDetail(alert, detailLevel);
|
||||||
|
|
||||||
|
// Handle raw format - return as code block
|
||||||
|
if (detailLevel === 'raw') {
|
||||||
|
lines.push('```json');
|
||||||
|
lines.push(JSON.stringify(filtered, null, 2));
|
||||||
|
lines.push('```');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type assertion: after raw check, we know filtered is a flattened alert type
|
||||||
|
const flatAlert = filtered as MinimumAlert | MediumAlert | FullAlert;
|
||||||
|
|
||||||
|
lines.push(`### Alert #${flatAlert.number}: ${flatAlert.rule_name}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`**Rule ID:** \`${flatAlert.rule_id}\``);
|
||||||
|
lines.push(`**Severity:** ${flatAlert.severity}`);
|
||||||
|
|
||||||
|
// Description only in medium and full
|
||||||
|
if ('rule_description' in flatAlert) {
|
||||||
|
lines.push(`**Description:** ${flatAlert.rule_description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('#### Location');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`- **File:** \`${flatAlert.file_path}\``);
|
||||||
|
lines.push(`- **Lines:** ${flatAlert.start_line}-${flatAlert.end_line}`);
|
||||||
|
|
||||||
|
// Columns only in medium and full
|
||||||
|
if ('start_column' in flatAlert) {
|
||||||
|
lines.push(`- **Columns:** ${flatAlert.start_column}-${flatAlert.end_column}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('#### Message');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(flatAlert.message);
|
||||||
|
|
||||||
|
// Details section - commit is now in all levels
|
||||||
|
lines.push('');
|
||||||
|
lines.push('#### Details');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`- **Commit:** \`${flatAlert.commit_sha}\``);
|
||||||
|
|
||||||
|
// State only in medium and full
|
||||||
|
if ('state' in flatAlert) {
|
||||||
|
lines.push(`- **State:** ${flatAlert.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference only in full
|
||||||
|
if ('ref' in flatAlert) {
|
||||||
|
lines.push(`- **Reference:** ${flatAlert.ref}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
78
src/formatters/sarif.ts
Normal file
78
src/formatters/sarif.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { SarifBuilder, SarifResultBuilder, SarifRunBuilder } from 'node-sarif-builder';
|
||||||
|
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||||
|
import {
|
||||||
|
type DetailLevel,
|
||||||
|
type FullAlert,
|
||||||
|
filterAlertByDetail,
|
||||||
|
type MediumAlert,
|
||||||
|
type MinimumAlert,
|
||||||
|
} from '../lib/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format alerts as SARIF (Static Analysis Results Interchange Format)
|
||||||
|
*/
|
||||||
|
export function formatAsSARIF(
|
||||||
|
alerts: CodeQLAlert[],
|
||||||
|
_repoName: string,
|
||||||
|
detailLevel: DetailLevel = 'medium',
|
||||||
|
): string {
|
||||||
|
// For raw format, return alerts as JSON (SARIF doesn't make sense for raw)
|
||||||
|
if (detailLevel === 'raw') {
|
||||||
|
return JSON.stringify(alerts, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sarifBuilder = new SarifBuilder();
|
||||||
|
|
||||||
|
// Tool version only available in full mode
|
||||||
|
let toolVersion = '1.0.0';
|
||||||
|
if (detailLevel === 'full' && alerts.length > 0) {
|
||||||
|
const fullAlert = filterAlertByDetail(alerts[0], 'full');
|
||||||
|
if ('tool_version' in fullAlert) {
|
||||||
|
toolVersion = fullAlert.tool_version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runBuilder = new SarifRunBuilder().initSimple({
|
||||||
|
toolDriverName: 'CodeQL',
|
||||||
|
toolDriverVersion: toolVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const filtered = filterAlertByDetail(alert, detailLevel);
|
||||||
|
// Type assertion: we know filtered is a flattened alert type (not raw, checked above)
|
||||||
|
const flatAlert = filtered as MinimumAlert | MediumAlert | FullAlert;
|
||||||
|
const result = new SarifResultBuilder();
|
||||||
|
|
||||||
|
// SARIF requires certain minimum fields
|
||||||
|
// For minimum level, we use line numbers but set column to 1 if not available
|
||||||
|
const startColumn = 'start_column' in flatAlert ? flatAlert.start_column : 1;
|
||||||
|
|
||||||
|
result.initSimple({
|
||||||
|
ruleId: flatAlert.rule_id,
|
||||||
|
level: mapSeverityToLevel(flatAlert.severity),
|
||||||
|
messageText: flatAlert.message,
|
||||||
|
fileUri: flatAlert.file_path,
|
||||||
|
startLine: flatAlert.start_line,
|
||||||
|
startColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
runBuilder.addResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
sarifBuilder.addRun(runBuilder);
|
||||||
|
// buildSarifJsonString returns a JSON string
|
||||||
|
return sarifBuilder.buildSarifJsonString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSeverityToLevel(severity: string): 'error' | 'warning' | 'note' {
|
||||||
|
switch (severity.toLowerCase()) {
|
||||||
|
case 'error':
|
||||||
|
case 'critical':
|
||||||
|
return 'error';
|
||||||
|
case 'warning':
|
||||||
|
case 'medium':
|
||||||
|
return 'warning';
|
||||||
|
default:
|
||||||
|
return 'note';
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/formatters/text.ts
Normal file
71
src/formatters/text.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { CodeQLAlert } from '../lib/codeql.js';
|
||||||
|
import {
|
||||||
|
type DetailLevel,
|
||||||
|
type FullAlert,
|
||||||
|
filterAlertByDetail,
|
||||||
|
type MediumAlert,
|
||||||
|
type MinimumAlert,
|
||||||
|
} from '../lib/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format alerts as plain text
|
||||||
|
*/
|
||||||
|
export function formatAsText(alerts: CodeQLAlert[], detailLevel: DetailLevel = 'medium'): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(`CodeQL Security Scan Report`);
|
||||||
|
lines.push(`Total Alerts: ${alerts.length}`);
|
||||||
|
lines.push(`Detail Level: ${detailLevel}`);
|
||||||
|
lines.push(`${'='.repeat(80)}\n`);
|
||||||
|
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const filtered = filterAlertByDetail(alert, detailLevel);
|
||||||
|
|
||||||
|
// Handle raw format - return original JSON-like structure
|
||||||
|
if (detailLevel === 'raw') {
|
||||||
|
lines.push(JSON.stringify(filtered, null, 2));
|
||||||
|
lines.push(`${'-'.repeat(80)}\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type assertion: after raw check, we know filtered is a flattened alert type
|
||||||
|
const flatAlert = filtered as MinimumAlert | MediumAlert | FullAlert;
|
||||||
|
|
||||||
|
lines.push(`Alert #${flatAlert.number}`);
|
||||||
|
lines.push(`Rule: ${flatAlert.rule_id}`);
|
||||||
|
lines.push(`Name: ${flatAlert.rule_name}`);
|
||||||
|
lines.push(`Severity: ${flatAlert.severity}`);
|
||||||
|
|
||||||
|
// Description only in medium and full
|
||||||
|
if ('rule_description' in flatAlert) {
|
||||||
|
lines.push(`Description: ${flatAlert.rule_description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Location:');
|
||||||
|
lines.push(` File: ${flatAlert.file_path}`);
|
||||||
|
lines.push(` Lines: ${flatAlert.start_line}-${flatAlert.end_line}`);
|
||||||
|
|
||||||
|
// Columns only in medium and full
|
||||||
|
if ('start_column' in flatAlert) {
|
||||||
|
lines.push(` Columns: ${flatAlert.start_column}-${flatAlert.end_column}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Message:');
|
||||||
|
lines.push(` ${flatAlert.message}`);
|
||||||
|
|
||||||
|
// Commit is now in all levels
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Commit: ${flatAlert.commit_sha}`);
|
||||||
|
|
||||||
|
// State only in medium and full
|
||||||
|
if ('state' in flatAlert) {
|
||||||
|
lines.push(`State: ${flatAlert.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`${'-'.repeat(80)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
26
src/lib/auth.ts
Normal file
26
src/lib/auth.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get GitHub token from GITHUB_TOKEN env var, or fall back to gh CLI
|
||||||
|
*/
|
||||||
|
export function getGitHubToken(): string {
|
||||||
|
// First, try GITHUB_TOKEN environment variable
|
||||||
|
const envToken = process.env.GITHUB_TOKEN;
|
||||||
|
if (envToken) {
|
||||||
|
return envToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to gh CLI
|
||||||
|
try {
|
||||||
|
const token = execSync('gh auth token', { encoding: 'utf-8' }).trim();
|
||||||
|
if (token) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// gh CLI not available or not authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'GitHub token not found. Please set GITHUB_TOKEN environment variable or authenticate with `gh auth login`',
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/lib/codeql.ts
Normal file
108
src/lib/codeql.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Octokit } from 'octokit';
|
||||||
|
import type { GitHubRepo } from './git.js';
|
||||||
|
|
||||||
|
export interface CodeQLAlert {
|
||||||
|
number: number;
|
||||||
|
rule: {
|
||||||
|
id: string;
|
||||||
|
severity: string;
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
most_recent_instance: {
|
||||||
|
ref: string;
|
||||||
|
analysis_key: string;
|
||||||
|
category: string;
|
||||||
|
state: string;
|
||||||
|
commit_sha: string;
|
||||||
|
message: {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
location: {
|
||||||
|
path: string;
|
||||||
|
start_line: number;
|
||||||
|
end_line: number;
|
||||||
|
start_column: number;
|
||||||
|
end_column: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
help?: string;
|
||||||
|
tool: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all open CodeQL alerts for a repository with pagination
|
||||||
|
*/
|
||||||
|
export async function fetchCodeQLAlerts(
|
||||||
|
octokit: Octokit,
|
||||||
|
repo: GitHubRepo,
|
||||||
|
): Promise<CodeQLAlert[]> {
|
||||||
|
const alerts: CodeQLAlert[] = [];
|
||||||
|
let page = 1;
|
||||||
|
const perPage = 100;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const response = await octokit.rest.codeScanning.listAlertsForRepo({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
state: 'open',
|
||||||
|
per_page: perPage,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect alert numbers for detailed fetch
|
||||||
|
for (const alert of response.data) {
|
||||||
|
alerts.push(alert as CodeQLAlert);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got fewer than perPage results, we're done
|
||||||
|
if (response.data.length < perPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch detailed information for a specific alert
|
||||||
|
*/
|
||||||
|
export async function fetchAlertDetails(
|
||||||
|
octokit: Octokit,
|
||||||
|
repo: GitHubRepo,
|
||||||
|
alertNumber: number,
|
||||||
|
): Promise<CodeQLAlert> {
|
||||||
|
const response = await octokit.rest.codeScanning.getAlert({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
alert_number: alertNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data as CodeQLAlert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all alerts with full details
|
||||||
|
*/
|
||||||
|
export async function fetchAllAlertsWithDetails(
|
||||||
|
octokit: Octokit,
|
||||||
|
repo: GitHubRepo,
|
||||||
|
): Promise<CodeQLAlert[]> {
|
||||||
|
const alerts = await fetchCodeQLAlerts(octokit, repo);
|
||||||
|
|
||||||
|
// Fetch details for each alert
|
||||||
|
const detailedAlerts = await Promise.all(
|
||||||
|
alerts.map((alert) => fetchAlertDetails(octokit, repo, alert.number)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return detailedAlerts;
|
||||||
|
}
|
||||||
67
src/lib/git.ts
Normal file
67
src/lib/git.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import simpleGit from 'simple-git';
|
||||||
|
|
||||||
|
export interface GitHubRepo {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract GitHub owner and repository name from git remote URL
|
||||||
|
*/
|
||||||
|
export function parseGitHubUrl(url: string): GitHubRepo | null {
|
||||||
|
// Match various GitHub URL formats:
|
||||||
|
// - https://github.com/owner/repo.git
|
||||||
|
// - git@github.com:owner/repo.git
|
||||||
|
// - https://github.com/owner/repo
|
||||||
|
// - git://github.com/owner/repo.git
|
||||||
|
const patterns = [/github\.com[:/]([^/]+)\/([^/]+?)(\.git)?$/, /^([^/]+)\/([^/]+)(\.git)?$/];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = url.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
owner: match[1],
|
||||||
|
repo: match[2].replace(/\.git$/, ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get GitHub owner and repo from current directory's git remote
|
||||||
|
*/
|
||||||
|
export async function getGitHubRepoFromRemote(cwd?: string): Promise<GitHubRepo> {
|
||||||
|
const git = simpleGit(cwd);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remotes = await git.getRemotes(true);
|
||||||
|
|
||||||
|
if (remotes.length === 0) {
|
||||||
|
throw new Error('No git remotes found. Make sure you are in a git repository.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try origin first, then fall back to the first remote
|
||||||
|
const originRemote = remotes.find((r) => r.name === 'origin');
|
||||||
|
const remote = originRemote || remotes[0];
|
||||||
|
|
||||||
|
if (!remote.refs.fetch && !remote.refs.push) {
|
||||||
|
throw new Error('No valid remote URL found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteUrl = remote.refs.fetch || remote.refs.push;
|
||||||
|
const repoInfo = parseGitHubUrl(remoteUrl);
|
||||||
|
|
||||||
|
if (!repoInfo) {
|
||||||
|
throw new Error(`Unable to parse GitHub repository from remote URL: ${remoteUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return repoInfo;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error('Failed to get git remote information.');
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/lib/types.ts
Normal file
117
src/lib/types.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { CodeQLAlert } from './codeql.js';
|
||||||
|
|
||||||
|
export type DetailLevel = 'minimum' | 'medium' | 'full' | 'raw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattened alert structure with minimum essential fields
|
||||||
|
* All levels include commit_sha for LLM context
|
||||||
|
*/
|
||||||
|
export interface MinimumAlert {
|
||||||
|
number: number;
|
||||||
|
rule_id: string;
|
||||||
|
rule_name: string;
|
||||||
|
severity: string;
|
||||||
|
message: string;
|
||||||
|
file_path: string;
|
||||||
|
start_line: number;
|
||||||
|
end_line: number;
|
||||||
|
commit_sha: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Medium detail level adds helpful context fields
|
||||||
|
*/
|
||||||
|
export interface MediumAlert extends MinimumAlert {
|
||||||
|
rule_description: string;
|
||||||
|
start_column: number;
|
||||||
|
end_column: number;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full detail level includes all available metadata
|
||||||
|
*/
|
||||||
|
export interface FullAlert extends MediumAlert {
|
||||||
|
ref: string;
|
||||||
|
analysis_key: string;
|
||||||
|
category: string;
|
||||||
|
tool_name: string;
|
||||||
|
tool_version: string;
|
||||||
|
help_text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter alert data based on detail level
|
||||||
|
* Returns flattened structure to reduce tokens, or raw CodeQLAlert for 'raw' level
|
||||||
|
*/
|
||||||
|
export function filterAlertByDetail(
|
||||||
|
alert: CodeQLAlert,
|
||||||
|
level: DetailLevel,
|
||||||
|
): MinimumAlert | MediumAlert | FullAlert | CodeQLAlert {
|
||||||
|
if (level === 'raw') {
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 'full') {
|
||||||
|
const fullAlert: FullAlert = {
|
||||||
|
number: alert.number,
|
||||||
|
rule_id: alert.rule.id,
|
||||||
|
rule_name: alert.rule.name,
|
||||||
|
severity: alert.rule.severity,
|
||||||
|
message: alert.most_recent_instance.message.text,
|
||||||
|
file_path: alert.most_recent_instance.location.path,
|
||||||
|
start_line: alert.most_recent_instance.location.start_line,
|
||||||
|
end_line: alert.most_recent_instance.location.end_line,
|
||||||
|
commit_sha: alert.most_recent_instance.commit_sha,
|
||||||
|
rule_description: alert.rule.description,
|
||||||
|
start_column: alert.most_recent_instance.location.start_column,
|
||||||
|
end_column: alert.most_recent_instance.location.end_column,
|
||||||
|
state: alert.most_recent_instance.state,
|
||||||
|
ref: alert.most_recent_instance.ref,
|
||||||
|
analysis_key: alert.most_recent_instance.analysis_key,
|
||||||
|
category: alert.most_recent_instance.category,
|
||||||
|
tool_name: alert.tool.name,
|
||||||
|
tool_version: alert.tool.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add help_text if available
|
||||||
|
if (alert.help) {
|
||||||
|
fullAlert.help_text = alert.help;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullAlert;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 'medium') {
|
||||||
|
const mediumAlert: MediumAlert = {
|
||||||
|
number: alert.number,
|
||||||
|
rule_id: alert.rule.id,
|
||||||
|
rule_name: alert.rule.name,
|
||||||
|
severity: alert.rule.severity,
|
||||||
|
message: alert.most_recent_instance.message.text,
|
||||||
|
file_path: alert.most_recent_instance.location.path,
|
||||||
|
start_line: alert.most_recent_instance.location.start_line,
|
||||||
|
end_line: alert.most_recent_instance.location.end_line,
|
||||||
|
commit_sha: alert.most_recent_instance.commit_sha,
|
||||||
|
rule_description: alert.rule.description,
|
||||||
|
start_column: alert.most_recent_instance.location.start_column,
|
||||||
|
end_column: alert.most_recent_instance.location.end_column,
|
||||||
|
state: alert.most_recent_instance.state,
|
||||||
|
};
|
||||||
|
return mediumAlert;
|
||||||
|
}
|
||||||
|
|
||||||
|
// minimum level
|
||||||
|
const minimumAlert: MinimumAlert = {
|
||||||
|
number: alert.number,
|
||||||
|
rule_id: alert.rule.id,
|
||||||
|
rule_name: alert.rule.name,
|
||||||
|
severity: alert.rule.severity,
|
||||||
|
message: alert.most_recent_instance.message.text,
|
||||||
|
file_path: alert.most_recent_instance.location.path,
|
||||||
|
start_line: alert.most_recent_instance.location.start_line,
|
||||||
|
end_line: alert.most_recent_instance.location.end_line,
|
||||||
|
commit_sha: alert.most_recent_instance.commit_sha,
|
||||||
|
};
|
||||||
|
return minimumAlert;
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
13
vitest.config.ts
Normal file
13
vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html', 'json-summary'],
|
||||||
|
exclude: ['node_modules/', 'dist/', '**/*.test.ts', '**/*.config.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user