Skip to content

Commit

Permalink
feat(reporter): github check-run reporter (#224)
Browse files Browse the repository at this point in the history
  • Loading branch information
pmstss authored Jan 23, 2025
1 parent f1858da commit 6524688
Show file tree
Hide file tree
Showing 25 changed files with 376 additions and 13 deletions.
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion packages/reporter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"dependencies": {
"chalk": "^4.1.2",
"tslib": "~2.6.3",
"tty-table": "^4.2.3"
"tsyringe": "^4.8.0",
"tty-table": "^4.2.3",
"@octokit/types": "^13.5.0"
},
"peerDependencies": {
"@sectester/scan": ">=0.16.0 <1.0.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/reporter/src/index.ts
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 packages/reporter/src/reporters/github/GitHubCheckRunReporter.ts
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 packages/reporter/src/reporters/github/api/GitHubApiClient.ts
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}`);
}
}
}
7 changes: 7 additions & 0 deletions packages/reporter/src/reporters/github/api/GitHubClient.ts
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');
7 changes: 7 additions & 0 deletions packages/reporter/src/reporters/github/api/GitHubConfig.ts
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');
2 changes: 2 additions & 0 deletions packages/reporter/src/reporters/github/api/index.ts
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';
13 changes: 13 additions & 0 deletions packages/reporter/src/reporters/github/api/register.ts
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 });
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)
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { CheckRunPayload } from '../types';

export interface CheckRunPayloadBuilder {
build(): CheckRunPayload;
}
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');
}
}
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');
}
}
Loading

0 comments on commit 6524688

Please sign in to comment.