@fabasoad/sarif-to-slack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ import { AnyBlock } from '@slack/types'
2
+ import { HeaderBlock, ContextBlock } from '@slack/types/dist/block-kit/blocks'
3
+ import { IncomingWebhook } from '@slack/webhook'
4
+ import type { Run, Result, ReportingDescriptor } from 'sarif'
5
+ import { Sarif, SlackMessage } from './types'
6
+
7
+ /**
8
+ * Options for the SlackMessageBuilder.
9
+ * @internal
10
+ */
11
+ export type SlackMessageBuilderOptions = {
12
+ username?: string
13
+ iconUrl?: string
14
+ color?: string
15
+ sarif: Sarif
16
+ }
17
+
18
+ type RuleData = { id?: string, index?: number }
19
+
20
+ /**
21
+ * Class for building and sending Slack messages based on SARIF logs.
22
+ * @internal
23
+ */
24
+ export class SlackMessageBuilder implements SlackMessage {
25
+ private readonly webhook: IncomingWebhook
26
+ private readonly gitHubServerUrl: string
27
+ private readonly color?: string
28
+
29
+ private header?: HeaderBlock
30
+ private footer?: ContextBlock
31
+ private actor?: string
32
+ private runId?: string
33
+
34
+ public readonly sarif: Sarif
35
+
36
+ constructor(url: string, opts: SlackMessageBuilderOptions) {
37
+ this.webhook = new IncomingWebhook(url, {
38
+ username: opts.username || 'SARIF results',
39
+ icon_url: opts.iconUrl
40
+ })
41
+ this.color = opts.color
42
+ this.sarif = opts.sarif
43
+ this.gitHubServerUrl = process.env.GITHUB_SERVER_URL || 'https://github.com'
44
+ }
45
+
46
+ withHeader(header?: string): void {
47
+ this.header = {
48
+ type: 'header',
49
+ text: {
50
+ type: 'plain_text',
51
+ text: header || process.env.GITHUB_REPOSITORY || 'SARIF results'
52
+ }
53
+ }
54
+ }
55
+
56
+ withActor(actor?: string): void {
57
+ this.actor = actor || process.env.GITHUB_ACTOR
58
+ }
59
+
60
+ withRun(): void {
61
+ this.runId = process.env.GITHUB_RUN_ID
62
+ }
63
+
64
+ withFooter(footer?: string): void {
65
+ const repoName = 'fabasoad/sarif-to-slack-action'
66
+ this.footer = {
67
+ type: 'context',
68
+ elements: [{
69
+ type: footer ? 'plain_text' : 'mrkdwn',
70
+ text: footer || `Generated by <${this.gitHubServerUrl}/${repoName}|${repoName}>`
71
+ }],
72
+ }
73
+ }
74
+
75
+ async send(): Promise<string> {
76
+ const blocks: AnyBlock[] = []
77
+ if (this.header) {
78
+ blocks.push(this.header)
79
+ }
80
+ blocks.push({
81
+ type: 'section',
82
+ text: {
83
+ type: 'mrkdwn',
84
+ text: this.buildText()
85
+ }
86
+ })
87
+ if (this.footer) {
88
+ blocks.push(this.footer)
89
+ }
90
+ const { text } = await this.webhook.send({
91
+ attachments: [{ color: this.color, blocks }]
92
+ })
93
+ return text
94
+ }
95
+
96
+ private buildText(): string {
97
+ const text: string[] = []
98
+ if (this.actor) {
99
+ const actorUrl = `${this.gitHubServerUrl}/${this.actor}`
100
+ text.push(`_Triggered by <${actorUrl}|${this.actor}>_`)
101
+ }
102
+ text.push(this.composeSummary())
103
+ if (this.runId) {
104
+ let runText: string = 'Job '
105
+ if (process.env.GITHUB_REPOSITORY) {
106
+ runText += `<${this.gitHubServerUrl}/${process.env.GITHUB_REPOSITORY}/actions/runs/${this.runId}|#${this.runId}>`
107
+ } else {
108
+ runText += `#${this.runId}`
109
+ }
110
+ text.push(runText)
111
+ }
112
+ return text.join('\n')
113
+ }
114
+
115
+ private composeRunSummary(toolName: string, map: Map<string, number>): string {
116
+ const levelsText: string[] = []
117
+ for (const [level, count] of map.entries()) {
118
+ const levelCapitalized = level.charAt(0).toUpperCase() + level.slice(1)
119
+ levelsText.push(`*${levelCapitalized}*: ${count}`)
120
+ }
121
+ return `*${toolName}*\n${levelsText.join(', ')}`
122
+ }
123
+
124
+ private composeSummary(): string {
125
+ const data = new Map<string, Map<string, number>>()
126
+ for (const run of this.sarif.runs) {
127
+ const toolName = run.tool.driver.name
128
+ if (!data.has(toolName)) {
129
+ data.set(toolName, new Map<string, number>())
130
+ }
131
+ const results: Result[] = run.results ?? []
132
+ for (const result of results) {
133
+ const level: string = this.tryGetLevel(run, result)
134
+ const count: number = data.get(toolName)?.get(level) || 0
135
+ data.get(toolName)?.set(level, count + 1)
136
+ }
137
+ }
138
+ const summaries: string[] = []
139
+ for (const [toolName, map] of data.entries()) {
140
+ summaries.push(this.composeRunSummary(toolName, map))
141
+ }
142
+ return summaries.join('\n')
143
+ }
144
+
145
+ private tryGetLevel(run: Run, result: Result): string {
146
+ if (result.level) {
147
+ return result.level
148
+ }
149
+
150
+ const ruleData: RuleData = {}
151
+
152
+ if (result.rule) {
153
+ if (result.rule?.index) {
154
+ ruleData.index = result.rule.index
155
+ }
156
+ if (result.rule?.id) {
157
+ ruleData.id = result.rule.id
158
+ }
159
+ }
160
+
161
+ if (!ruleData.index && result.ruleIndex) {
162
+ ruleData.index = result.ruleIndex
163
+ }
164
+
165
+ if (ruleData.index
166
+ && run.tool.driver?.rules
167
+ && ruleData.index < run.tool.driver.rules.length) {
168
+ const rule: ReportingDescriptor = run.tool.driver.rules[ruleData.index]
169
+ if (rule.properties && 'problem.severity' in rule.properties) {
170
+ return rule.properties['problem.severity'] as string
171
+ }
172
+ }
173
+
174
+ return 'unknown'
175
+ }
176
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ // Copyright (c) Yevhen Fabizhevskyi. All rights reserved. Licensed under the MIT license.
2
+
3
+ /**
4
+ * Sarif to Slack message converter library.
5
+ *
6
+ * @remarks
7
+ * This library provides a service to send a Slack messages based on the provided
8
+ * SARIF (Static Analysis Results Interchange Format) files.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { SarifToSlackService } from 'sarif-to-slack';
13
+ *
14
+ * const service = new SarifToSlackService({
15
+ * webhookUrl: 'https://hooks.slack.com/services/your/webhook/url',
16
+ * sarifPath: 'path/to/your/sarif/file.sarif',
17
+ * logLevel: 'info',
18
+ * username: 'SARIF Bot',
19
+ * iconUrl: 'https://example.com/icon.png',
20
+ * color: '#36a64f',
21
+ * header: {
22
+ * include: true,
23
+ * value: 'SARIF Analysis Results'
24
+ * },
25
+ * footer: {
26
+ * include: true,
27
+ * value: 'Generated by @fabasoad/sarif-to-slack'
28
+ * },
29
+ * actor: {
30
+ * include: true,
31
+ * value: 'fabasoad'
32
+ * },
33
+ * run: {
34
+ * include: true
35
+ * },
36
+ * });
37
+ * await service.sendAll();
38
+ * ```
39
+ *
40
+ * @see {@link SarifToSlackService}
41
+ *
42
+ * @packageDocumentation
43
+ */
44
+ export { SarifToSlackService } from './SarifToSlackService'
45
+ export {
46
+ IncludeAwareProps,
47
+ IncludeAwareWithValueProps,
48
+ LogLevel,
49
+ Sarif,
50
+ SarifToSlackServiceOptions,
51
+ SlackMessage,
52
+ } from './types'
package/src/types.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { Log } from 'sarif'
2
+
3
+ /**
4
+ * Type representing a SARIF log.
5
+ * @public
6
+ */
7
+ export type Sarif = Log
8
+
9
+ /**
10
+ * Interface for a Slack message that can be sent.
11
+ * @public
12
+ */
13
+ export interface SlackMessage {
14
+ /**
15
+ * Sends the Slack message.
16
+ * @returns A promise that resolves to the response from the Slack webhook.
17
+ */
18
+ send: () => Promise<string>
19
+ /**
20
+ * The SARIF log associated with this Slack message.
21
+ */
22
+ sarif: Sarif
23
+ }
24
+
25
+ /**
26
+ * Enum representing log levels for the service.
27
+ * @public
28
+ */
29
+ export enum LogLevel {
30
+ /**
31
+ * Represents the most verbose logging level, typically used for detailed debugging information.
32
+ */
33
+ Silly = 0,
34
+ /**
35
+ * Represents a logging level for tracing the flow of the application.
36
+ */
37
+ Trace = 1,
38
+ /**
39
+ * Represents a logging level for debugging information that is less verbose than silly.
40
+ */
41
+ Debug = 2,
42
+ /**
43
+ * Represents a logging level for general informational messages that highlight the progress of the application.
44
+ */
45
+ Info = 3,
46
+ /**
47
+ * Represents a logging level for potentially harmful situations that require attention.
48
+ */
49
+ Warning = 4,
50
+ /**
51
+ * Represents a logging level for error conditions that do not require immediate action but should be noted.
52
+ */
53
+ Error = 5,
54
+ /**
55
+ * Represents a logging level for critical errors that require immediate attention and may cause the application to terminate.
56
+ */
57
+ Fatal = 6
58
+ }
59
+
60
+ /**
61
+ * Type representing properties that indicate whether to include certain information
62
+ * in the Slack message.
63
+ * @public
64
+ */
65
+ export type IncludeAwareProps = {
66
+ include: boolean
67
+ }
68
+
69
+ /**
70
+ * Type representing properties that indicate whether to include certain information
71
+ * in the Slack message, along with an optional value.
72
+ * @public
73
+ */
74
+ export type IncludeAwareWithValueProps = IncludeAwareProps & {
75
+ value?: string
76
+ }
77
+
78
+ /**
79
+ * Options for the SarifToSlackService.
80
+ * @public
81
+ */
82
+ export type SarifToSlackServiceOptions = {
83
+ // The Slack webhook URL to send messages to.
84
+ webhookUrl: string,
85
+ sarifPath: string,
86
+ username?: string,
87
+ iconUrl?: string,
88
+ color?: string,
89
+ logLevel?: LogLevel | string,
90
+ header?: IncludeAwareWithValueProps,
91
+ footer?: IncludeAwareWithValueProps,
92
+ actor?: IncludeAwareWithValueProps,
93
+ run?: IncludeAwareProps,
94
+ }
@@ -0,0 +1,115 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import { processColor, processSarifPath, processLogLevel } from '../src/Processors'
4
+
5
+ jest.mock('fs')
6
+ const mockedFs = fs as jest.Mocked<typeof fs>
7
+
8
+ jest.mock('../src/Logger', () => ({
9
+ __esModule: true,
10
+ default: { info: jest.fn(), debug: jest.fn() }
11
+ }))
12
+
13
+ describe('processColor', () => {
14
+ test('returns correct hex for success', () => {
15
+ expect(processColor('success')).toBe('#008000')
16
+ })
17
+
18
+ test('returns correct hex for failure', () => {
19
+ expect(processColor('failure')).toBe('#ff0000')
20
+ })
21
+
22
+ test('returns correct hex for cancelled', () => {
23
+ expect(processColor('cancelled')).toBe('#0047ab')
24
+ })
25
+
26
+ test('returns correct hex for skipped', () => {
27
+ expect(processColor('skipped')).toBe('#808080')
28
+ })
29
+
30
+ test('returns input for unknown color', () => {
31
+ expect(processColor('other')).toBe('other')
32
+ })
33
+
34
+ test('returns undefined for undefined input', () => {
35
+ expect(processColor(undefined)).toBeUndefined()
36
+ })
37
+ })
38
+
39
+ describe('processSarifPath', () => {
40
+ const fakeDir = '/fake/dir'
41
+ const fakeFile = '/fake/file.sarif'
42
+
43
+ afterEach(() => {
44
+ jest.resetAllMocks()
45
+ })
46
+
47
+ test('throws if path does not exist', () => {
48
+ mockedFs.existsSync.mockReturnValue(false)
49
+ expect(() => processSarifPath(fakeFile)).toThrow(/does not exist/)
50
+ })
51
+
52
+ test('returns .sarif files in directory', () => {
53
+ mockedFs.existsSync.mockReturnValue(true)
54
+ mockedFs.statSync.mockReturnValue({ isDirectory: () => true, isFile: () => false } as any)
55
+ // @ts-ignore: mocking readdirSync with a specific return value
56
+ mockedFs.readdirSync.mockReturnValue(['a.sarif', 'b.SARIF', 'c.txt'])
57
+ const result: string[] = processSarifPath(fakeDir)
58
+ expect(result).toEqual(
59
+ ['a.sarif', 'b.SARIF'].map((file: string) => path.join(fakeDir, file))
60
+ )
61
+ })
62
+
63
+ test('returns file path if it is a file', () => {
64
+ mockedFs.existsSync.mockReturnValue(true)
65
+ mockedFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true } as any)
66
+ const result: string[] = processSarifPath(fakeFile)
67
+ expect(result).toHaveLength(1)
68
+ expect(result[0]).toEqual(fakeFile)
69
+ })
70
+
71
+ test('throws if path is neither file nor directory', () => {
72
+ mockedFs.existsSync.mockReturnValue(true)
73
+ mockedFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => false } as any)
74
+ expect(() => processSarifPath('/weird/path')).toThrow(/neither a file nor a directory/)
75
+ })
76
+ })
77
+
78
+ describe('processLogLevel', () => {
79
+ test('returns 0 for silly', () => {
80
+ expect(processLogLevel('silly')).toBe(0)
81
+ })
82
+
83
+ test('returns 1 for trace', () => {
84
+ expect(processLogLevel('trace')).toBe(1)
85
+ })
86
+
87
+ test('returns 2 for debug', () => {
88
+ expect(processLogLevel('debug')).toBe(2)
89
+ })
90
+
91
+ test('returns 3 for info', () => {
92
+ expect(processLogLevel('info')).toBe(3)
93
+ })
94
+
95
+ test('returns 4 for warning', () => {
96
+ expect(processLogLevel('warning')).toBe(4)
97
+ })
98
+
99
+ test('returns 5 for error', () => {
100
+ expect(processLogLevel('error')).toBe(5)
101
+ })
102
+
103
+ test('returns 6 for fatal', () => {
104
+ expect(processLogLevel('fatal')).toBe(6)
105
+ })
106
+
107
+ test('is case-insensitive', () => {
108
+ expect(processLogLevel('ERROR')).toBe(5)
109
+ expect(processLogLevel('Info')).toBe(3)
110
+ })
111
+
112
+ test('throws for unknown log level', () => {
113
+ expect(() => processLogLevel('unknown')).toThrow(/Unknown log level/)
114
+ })
115
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "http://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "target": "es2024",
5
+ "module": "commonjs",
6
+ "declaration": true,
7
+ "sourceMap": true,
8
+ "declarationMap": true,
9
+ "stripInternal": true,
10
+ "types": ["node", "jest"],
11
+ "newLine": "lf",
12
+ "lib": [
13
+ "es2024"
14
+ ],
15
+ "outDir": "lib"
16
+ },
17
+ "include": [
18
+ "src/**/*.ts"
19
+ ],
20
+ "exclude": ["node_modules", "tests"]
21
+ }