Initial commit

This commit is contained in:
2025-09-30 22:34:56 +03:00
commit fafd5e89d4
28 changed files with 6481 additions and 0 deletions

4
.biomeignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
coverage
*.d.ts

16
.editorconfig Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
lint-staged

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.18

21
LICENSE Normal file
View 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
View File

@@ -0,0 +1,323 @@
# gh-codeql-report
[![CI](https://github.com/ivuorinen/gh-codeql-report/actions/workflows/ci.yml/badge.svg)](https://github.com/ivuorinen/gh-codeql-report/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/@ivuorinen/gh-codeql-report.svg)](https://www.npmjs.com/package/@ivuorinen/gh-codeql-report)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
View 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

File diff suppressed because it is too large Load Diff

63
package.json Normal file
View 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"
]
}
}

View 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
View 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)...');
});
});
});

View 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();
});
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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'],
},
},
});