mirror of
https://github.com/ivuorinen/gh-codeql-report.git
synced 2026-01-26 11:44:01 +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