-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(reporter): github check-run reporter (#224)
- Loading branch information
Showing
25 changed files
with
376 additions
and
13 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,3 @@ | ||
export { PlainTextFormatter } from './formatters'; | ||
export { Reporter, Formatter } from './lib'; | ||
export { StdReporter } from './std'; | ||
export { GitHubCheckRunReporter, StdReporter } from './reporters'; |
67 changes: 67 additions & 0 deletions
67
packages/reporter/src/reporters/github/GitHubCheckRunReporter.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import { Reporter } from '../../lib'; | ||
import { GITHUB_CLIENT, GITHUB_CONFIG } from './api'; | ||
import type { GitHubClient } from './api'; | ||
import { SingleItemPayloadBuilder, MultiItemsPayloadBuilder } from './builders'; | ||
import type { CheckRunPayloadBuilder } from './builders'; | ||
import type { GitHubConfig } from './types'; | ||
import { inject, injectable } from 'tsyringe'; | ||
import type { Issue, Scan } from '@sectester/scan'; | ||
import path from 'node:path'; | ||
|
||
// TODO add `GitHubCheckRunReporter` description to README | ||
@injectable() | ||
export class GitHubCheckRunReporter implements Reporter { | ||
constructor( | ||
@inject(GITHUB_CONFIG) private readonly config: GitHubConfig, | ||
@inject(GITHUB_CLIENT) private readonly githubClient: GitHubClient | ||
) { | ||
if (!this.config.token) { | ||
throw new Error('GitHub token is not set'); | ||
} | ||
|
||
if (!this.config.repository) { | ||
throw new Error('GitHub repository is not set'); | ||
} | ||
|
||
if (!this.config.commitSha) { | ||
throw new Error('GitHub commitSha is not set'); | ||
} | ||
} | ||
|
||
public async report(scan: Scan): Promise<void> { | ||
const issues = await scan.issues(); | ||
if (issues.length === 0) return; | ||
|
||
const checkRunPayload = this.createCheckRunPayloadBuilder(issues).build(); | ||
await this.githubClient.createCheckRun(checkRunPayload); | ||
} | ||
|
||
private createCheckRunPayloadBuilder( | ||
issues: Issue[] | ||
): CheckRunPayloadBuilder { | ||
return issues.length === 1 | ||
? new SingleItemPayloadBuilder( | ||
issues[0], | ||
this.config.commitSha, | ||
this.getTestFilePath() | ||
) | ||
: new MultiItemsPayloadBuilder( | ||
issues, | ||
this.config.commitSha, | ||
this.getTestFilePath() | ||
); | ||
} | ||
|
||
// TODO subject to improvement | ||
private getTestFilePath(): string { | ||
const state = (global as any).expect?.getState(); | ||
if (!state) { | ||
return 'unknown'; | ||
} | ||
|
||
const testPath = state.testPath; | ||
const rootDir = state.snapshotState._rootDir; | ||
|
||
return path.join(path.basename(rootDir), path.relative(rootDir, testPath)); | ||
} | ||
} |
32 changes: 32 additions & 0 deletions
32
packages/reporter/src/reporters/github/api/GitHubApiClient.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import type { CheckRunPayload, GitHubConfig } from '../types'; | ||
import type { GitHubClient } from './GitHubClient'; | ||
import { GITHUB_CONFIG } from './GitHubConfig'; | ||
import { inject, injectable } from 'tsyringe'; | ||
|
||
@injectable() | ||
export class GitHubApiClient implements GitHubClient { | ||
constructor(@inject(GITHUB_CONFIG) private readonly config: GitHubConfig) {} | ||
|
||
public async createCheckRun(payload: CheckRunPayload): Promise<void> { | ||
const requestOptions = { | ||
method: 'POST', | ||
headers: { | ||
// eslint-disable-next-line @typescript-eslint/naming-convention | ||
'Authorization': `Bearer ${this.config.token}`, | ||
// eslint-disable-next-line @typescript-eslint/naming-convention | ||
'Accept': 'application/vnd.github.v3+json', | ||
'Content-Type': 'application/json' | ||
}, | ||
body: JSON.stringify(payload) | ||
}; | ||
|
||
const res = await fetch( | ||
`https://api.github.com/repos/${this.config.repository}/check-runs`, | ||
requestOptions | ||
); | ||
|
||
if (!res.ok) { | ||
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import type { CheckRunPayload } from '../types'; | ||
|
||
export interface GitHubClient { | ||
createCheckRun(payload: CheckRunPayload): Promise<void>; | ||
} | ||
|
||
export const GITHUB_CLIENT = Symbol('GITHUB_CLIENT'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export interface GitHubConfig { | ||
token?: string; | ||
repository?: string; | ||
commitSha?: string; | ||
} | ||
|
||
export const GITHUB_CONFIG = Symbol('GITHUB_CONFIG'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { GITHUB_CLIENT, type GitHubClient } from './GitHubClient'; | ||
export { GITHUB_CONFIG, type GitHubConfig } from './GitHubConfig'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { GITHUB_CLIENT } from './GitHubClient'; | ||
import { GITHUB_CONFIG } from './GitHubConfig'; | ||
import { GitHubApiClient } from './GitHubApiClient'; | ||
import { container } from 'tsyringe'; | ||
|
||
container.register(GITHUB_CONFIG, { | ||
useValue: { | ||
token: process.env.GITHUB_TOKEN, | ||
repository: process.env.GITHUB_REPOSITORY, | ||
commitSha: process.env.PR_COMMIT_SHA | ||
} | ||
}); | ||
container.register(GITHUB_CLIENT, { useClass: GitHubApiClient }); |
33 changes: 33 additions & 0 deletions
33
packages/reporter/src/reporters/github/builders/BasePayloadBuilder.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { CheckRunPayloadBuilder } from './CheckRunPayloadBuilder'; | ||
import { CheckRunAnnotation, CheckRunPayload } from '../types'; | ||
import type { Issue } from '@sectester/scan'; | ||
|
||
export abstract class BasePayloadBuilder implements CheckRunPayloadBuilder { | ||
protected readonly commitSha: string; | ||
|
||
constructor( | ||
commitSha: string | undefined, | ||
protected readonly testFilePath: string | ||
) { | ||
if (!commitSha) { | ||
throw new Error('Commit SHA is required'); | ||
} | ||
this.commitSha = commitSha; | ||
} | ||
|
||
public abstract build(): CheckRunPayload; | ||
|
||
protected convertIssueToAnnotation(issue: Issue): CheckRunAnnotation { | ||
const { originalRequest, name } = issue; | ||
const title = `${name} vulnerability found at ${originalRequest.method.toUpperCase()} ${originalRequest.url}`; | ||
|
||
return { | ||
path: this.testFilePath, | ||
start_line: 1, | ||
end_line: 1, | ||
annotation_level: 'failure', | ||
message: title, | ||
raw_details: JSON.stringify(issue, null, 2) | ||
}; | ||
} | ||
} |
5 changes: 5 additions & 0 deletions
5
packages/reporter/src/reporters/github/builders/CheckRunPayloadBuilder.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import type { CheckRunPayload } from '../types'; | ||
|
||
export interface CheckRunPayloadBuilder { | ||
build(): CheckRunPayload; | ||
} |
70 changes: 70 additions & 0 deletions
70
packages/reporter/src/reporters/github/builders/MultiItemsPayloadBuilder.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
import type { CheckRunPayload } from '../types'; | ||
import { BasePayloadBuilder } from './BasePayloadBuilder'; | ||
import type { Issue } from '@sectester/scan'; | ||
import { Severity } from '@sectester/scan'; | ||
|
||
export class MultiItemsPayloadBuilder extends BasePayloadBuilder { | ||
constructor( | ||
private readonly issues: Issue[], | ||
commitSha: string | undefined, | ||
testFilePath: string | ||
) { | ||
super(commitSha, testFilePath); | ||
} | ||
|
||
public build(): CheckRunPayload { | ||
return { | ||
name: `SecTester (${this.issues.length} issues)`, | ||
head_sha: this.commitSha, | ||
conclusion: 'failure', | ||
output: { | ||
title: `${this.issues.length} vulnerabilities detected in application endpoints`, | ||
summary: this.buildSummary(), | ||
text: this.buildDetails(), | ||
annotations: this.issues.map(issue => | ||
this.convertIssueToAnnotation(issue) | ||
) | ||
} | ||
}; | ||
} | ||
|
||
private buildSummary(): string { | ||
const severityCounts = this.issues.reduce( | ||
(counts, issue) => { | ||
counts[issue.severity] = (counts[issue.severity] || 0) + 1; | ||
|
||
return counts; | ||
}, | ||
{} as Record<Severity, number> | ||
); | ||
|
||
const parts = []; | ||
if (severityCounts[Severity.CRITICAL]) { | ||
parts.push(`${severityCounts[Severity.CRITICAL]} Critical`); | ||
} | ||
if (severityCounts[Severity.HIGH]) { | ||
parts.push(`${severityCounts[Severity.HIGH]} High`); | ||
} | ||
if (severityCounts[Severity.MEDIUM]) { | ||
parts.push(`${severityCounts[Severity.MEDIUM]} Medium`); | ||
} | ||
if (severityCounts[Severity.LOW]) { | ||
parts.push(`${severityCounts[Severity.LOW]} Low`); | ||
} | ||
|
||
return parts.length > 0 | ||
? `${parts.join(', ')} severity issues found` | ||
: 'No issues found'; | ||
} | ||
|
||
private buildDetails(): string { | ||
return this.issues | ||
.map(issue => { | ||
const method = issue.originalRequest.method?.toUpperCase() ?? 'GET'; | ||
const pathname = new URL(issue.originalRequest.url).pathname; | ||
|
||
return `- ${method} ${pathname}: ${issue.name}`; | ||
}) | ||
.join('\n'); | ||
} | ||
} |
80 changes: 80 additions & 0 deletions
80
packages/reporter/src/reporters/github/builders/SingleItemPayloadBuilder.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import type { CheckRunPayload } from '../types'; | ||
import { BasePayloadBuilder } from './BasePayloadBuilder'; | ||
import type { Comment, Issue } from '@sectester/scan'; | ||
|
||
export class SingleItemPayloadBuilder extends BasePayloadBuilder { | ||
constructor( | ||
private readonly issue: Issue, | ||
commitSha: string | undefined, | ||
testFilePath: string | ||
) { | ||
super(commitSha, testFilePath); | ||
} | ||
|
||
public build(): CheckRunPayload { | ||
return { | ||
name: `SecTester - ${this.buildEndpoint()}`, | ||
head_sha: this.commitSha, | ||
conclusion: 'failure', | ||
output: { | ||
title: this.buildTitle(), | ||
summary: this.buildSummary(), | ||
text: this.buildDetails(), | ||
annotations: [this.convertIssueToAnnotation(this.issue)] | ||
} | ||
}; | ||
} | ||
|
||
private buildEndpoint(): string { | ||
return `${this.issue.originalRequest.method} ${new URL(this.issue.originalRequest.url).pathname}`; | ||
} | ||
|
||
private buildTitle(): string { | ||
return `${this.issue.name} found at ${this.buildEndpoint()}`; | ||
} | ||
|
||
private buildSummary(): string { | ||
return [ | ||
`Name: ${this.issue.name}`, | ||
`Severity: ${this.issue.severity}`, | ||
`Bright UI link: ${this.issue.link}`, | ||
`\nRemediation:\n${this.issue.remedy}` | ||
].join('\n'); | ||
} | ||
|
||
private buildDetails(): string { | ||
const extraDetails = this.issue.comments?.length | ||
? this.formatList( | ||
this.issue.comments.map(x => this.formatIssueComment(x)) | ||
) | ||
: ''; | ||
|
||
const references = this.issue.resources?.length | ||
? this.formatList(this.issue.resources) | ||
: ''; | ||
|
||
return [ | ||
`${this.issue.details}`, | ||
...(extraDetails ? [`\nExtra Details:\n${extraDetails}`] : []), | ||
...(references ? [`\nReferences:\n${references}`] : []) | ||
].join('\n'); | ||
} | ||
|
||
private formatList(items: string[]): string { | ||
return items.map(x => `- ${x}`).join('\n'); | ||
} | ||
|
||
private formatIssueComment({ headline, text = '', links = [] }: Comment) { | ||
const body = [ | ||
text, | ||
...(links.length ? [`Links:\n${this.formatList(links)}`] : []) | ||
].join('\n'); | ||
|
||
const indentedBody = body | ||
.split('\n') | ||
.map(x => `\t${x}`) | ||
.join('\n'); | ||
|
||
return [headline, indentedBody].join('\n'); | ||
} | ||
} |
Oops, something went wrong.