@fabasoad/sarif-to-slack 0.2.5 → 1.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.
- package/.github/workflows/release.yml +1 -1
- package/.github/workflows/security.yml +0 -1
- package/.github/workflows/send-sarif-to-slack.yml +145 -73
- package/.gitleaksignore +8 -0
- package/.pre-commit-config.yaml +3 -3
- package/.tool-versions +1 -1
- package/dist/Logger.js +4 -1
- package/dist/SarifToSlackClient.d.ts +33 -0
- package/dist/SarifToSlackClient.d.ts.map +1 -0
- package/dist/SarifToSlackClient.js +178 -0
- package/dist/SlackMessageBuilder.js +34 -82
- package/dist/System.d.ts +1 -3
- package/dist/System.d.ts.map +1 -1
- package/dist/System.js +10 -3
- package/dist/index.cjs +826 -472
- package/dist/index.d.ts +35 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -12
- package/dist/model/Color.d.ts +80 -0
- package/dist/model/Color.d.ts.map +1 -0
- package/dist/model/Color.js +106 -0
- package/dist/model/Finding.d.ts +2 -0
- package/dist/model/Finding.d.ts.map +1 -0
- package/dist/model/Finding.js +93 -0
- package/dist/model/FindingsArray.d.ts +2 -0
- package/dist/model/FindingsArray.d.ts.map +1 -0
- package/dist/model/FindingsArray.js +24 -0
- package/dist/processors/CodeQLProcessor.d.ts +2 -0
- package/dist/processors/CodeQLProcessor.d.ts.map +1 -0
- package/dist/processors/CodeQLProcessor.js +17 -0
- package/dist/processors/CommonProcessor.d.ts +2 -0
- package/dist/processors/CommonProcessor.d.ts.map +1 -0
- package/dist/processors/CommonProcessor.js +84 -0
- package/dist/processors/ProcessorFactory.d.ts +2 -0
- package/dist/processors/ProcessorFactory.d.ts.map +1 -0
- package/dist/processors/ProcessorFactory.js +22 -0
- package/dist/processors/SnykProcessor.d.ts +2 -0
- package/dist/processors/SnykProcessor.d.ts.map +1 -0
- package/dist/processors/SnykProcessor.js +18 -0
- package/dist/representations/CompactGroupByRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupByRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupByRepresentation.js +58 -0
- package/dist/representations/CompactGroupByRunPerLevelRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupByRunPerLevelRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupByRunPerLevelRepresentation.js +13 -0
- package/dist/representations/CompactGroupByRunPerSeverityRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupByRunPerSeverityRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupByRunPerSeverityRepresentation.js +13 -0
- package/dist/representations/CompactGroupByRunRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupByRunRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupByRunRepresentation.js +39 -0
- package/dist/representations/CompactGroupBySarifPerLevelRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupBySarifPerLevelRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupBySarifPerLevelRepresentation.js +13 -0
- package/dist/representations/CompactGroupBySarifPerSeverityRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupBySarifPerSeverityRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupBySarifPerSeverityRepresentation.js +13 -0
- package/dist/representations/CompactGroupBySarifRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupBySarifRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupBySarifRepresentation.js +40 -0
- package/dist/representations/CompactGroupByToolNamePerLevelRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupByToolNamePerLevelRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupByToolNamePerLevelRepresentation.js +13 -0
- package/dist/representations/CompactGroupByToolNamePerSeverityRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupByToolNamePerSeverityRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupByToolNamePerSeverityRepresentation.js +13 -0
- package/dist/representations/CompactGroupByToolNameRepresentation.d.ts +2 -0
- package/dist/representations/CompactGroupByToolNameRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactGroupByToolNameRepresentation.js +39 -0
- package/dist/representations/CompactTotalPerLevelRepresentation.d.ts +2 -0
- package/dist/representations/CompactTotalPerLevelRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactTotalPerLevelRepresentation.js +13 -0
- package/dist/representations/CompactTotalPerSeverityRepresentation.d.ts +2 -0
- package/dist/representations/CompactTotalPerSeverityRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactTotalPerSeverityRepresentation.js +13 -0
- package/dist/representations/CompactTotalRepresentation.d.ts +2 -0
- package/dist/representations/CompactTotalRepresentation.d.ts.map +1 -0
- package/dist/representations/CompactTotalRepresentation.js +25 -0
- package/dist/representations/Representation.d.ts +2 -0
- package/dist/representations/Representation.d.ts.map +1 -0
- package/dist/representations/Representation.js +28 -0
- package/dist/representations/RepresentationFactory.d.ts +2 -0
- package/dist/representations/RepresentationFactory.d.ts.map +1 -0
- package/dist/representations/RepresentationFactory.js +37 -0
- package/dist/sarif-to-slack.d.ts +347 -85
- package/dist/tsdoc-metadata.json +1 -1
- package/dist/types.d.ts +215 -51
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +225 -33
- package/dist/utils/Comparators.d.ts +2 -0
- package/dist/utils/Comparators.d.ts.map +1 -0
- package/dist/utils/Comparators.js +18 -0
- package/dist/utils/ExtendedArray.d.ts +2 -0
- package/dist/utils/ExtendedArray.d.ts.map +1 -0
- package/dist/utils/ExtendedArray.js +11 -0
- package/dist/utils/FileUtils.d.ts +2 -0
- package/dist/utils/FileUtils.d.ts.map +1 -0
- package/dist/utils/FileUtils.js +51 -0
- package/dist/utils/SarifUtils.js +20 -54
- package/etc/sarif-to-slack.api.md +162 -99
- package/jest.config.json +2 -2
- package/package.json +7 -7
- package/scripts/save-metadata.sh +12 -10
- package/src/Logger.ts +4 -0
- package/src/SarifToSlackClient.ts +202 -0
- package/src/SlackMessageBuilder.ts +35 -115
- package/src/System.ts +9 -2
- package/src/index.ts +47 -20
- package/src/model/Color.ts +195 -0
- package/src/model/Finding.ts +137 -0
- package/src/model/FindingsArray.ts +27 -0
- package/src/processors/CodeQLProcessor.ts +19 -0
- package/src/processors/CommonProcessor.ts +103 -0
- package/src/processors/ProcessorFactory.ts +23 -0
- package/src/processors/SnykProcessor.ts +19 -0
- package/src/representations/CompactGroupByRepresentation.ts +67 -0
- package/src/representations/CompactGroupByRunPerLevelRepresentation.ts +14 -0
- package/src/representations/CompactGroupByRunPerSeverityRepresentation.ts +14 -0
- package/src/representations/CompactGroupByRunRepresentation.ts +44 -0
- package/src/representations/CompactGroupBySarifPerLevelRepresentation.ts +15 -0
- package/src/representations/CompactGroupBySarifPerSeverityRepresentation.ts +15 -0
- package/src/representations/CompactGroupBySarifRepresentation.ts +45 -0
- package/src/representations/CompactGroupByToolNamePerLevelRepresentation.ts +15 -0
- package/src/representations/CompactGroupByToolNamePerSeverityRepresentation.ts +15 -0
- package/src/representations/CompactGroupByToolNameRepresentation.ts +44 -0
- package/src/representations/CompactTotalPerLevelRepresentation.ts +14 -0
- package/src/representations/CompactTotalPerSeverityRepresentation.ts +14 -0
- package/src/representations/CompactTotalRepresentation.ts +27 -0
- package/src/representations/Representation.ts +35 -0
- package/src/representations/RepresentationFactory.ts +49 -0
- package/src/types.ts +270 -53
- package/src/utils/Comparators.ts +19 -0
- package/src/utils/ExtendedArray.ts +11 -0
- package/src/utils/FileUtils.ts +60 -0
- package/src/utils/SarifUtils.ts +20 -72
- package/test-data/sarif/codeql-python.sarif +1448 -1
- package/test-data/sarif/codeql-typescript.sarif +3474 -1
- package/test-data/sarif/grype-github-actions.sarif +65 -0
- package/test-data/sarif/osv-scanner-composer.sarif +972 -0
- package/test-data/sarif/osv-scanner-container.sarif +2278 -0
- package/test-data/sarif/osv-scanner-gomodules.sarif +813 -0
- package/test-data/sarif/osv-scanner-hex.sarif +147 -0
- package/test-data/sarif/osv-scanner-maven.sarif +171 -0
- package/test-data/sarif/osv-scanner-npm.sarif +627 -0
- package/test-data/sarif/osv-scanner-pip.sarif +206 -0
- package/test-data/sarif/osv-scanner-pipenv.sarif +243 -0
- package/test-data/sarif/osv-scanner-pnpm.sarif +174 -0
- package/test-data/sarif/osv-scanner-poetry.sarif +1893 -0
- package/test-data/sarif/osv-scanner-rubygems.sarif +402 -0
- package/test-data/sarif/osv-scanner-uv.sarif +206 -0
- package/test-data/sarif/osv-scanner-yarn.sarif +5207 -0
- package/test-data/sarif/runs-0.sarif +5 -0
- package/test-data/sarif/runs-2-tools-2-results-0.sarif +1 -1
- package/test-data/sarif/runs-2-tools-2.sarif +1 -1
- package/test-data/sarif/runs-3-tools-2-results-0.sarif +1 -1
- package/test-data/sarif/runs-3-tools-2.sarif +1 -1
- package/test-data/sarif/tmp/codeql-csharp.sarif +1 -0
- package/test-data/sarif/tmp/grype-container.sarif +1774 -0
- package/test-data/sarif/tmp/runs-1-tools-1-results-0.sarif +18 -0
- package/test-data/sarif/tmp/runs-2-tools-2.sarif +686 -0
- package/test-data/sarif/trivy-iac.sarif +1 -1
- package/tests/integration/SendSarifToSlack.spec.ts +95 -27
- package/tsconfig.json +2 -0
- package/dist/Processors.d.ts +0 -2
- package/dist/Processors.d.ts.map +0 -1
- package/dist/Processors.js +0 -61
- package/dist/SarifToSlackService.d.ts +0 -39
- package/dist/SarifToSlackService.d.ts.map +0 -1
- package/dist/SarifToSlackService.js +0 -104
- package/dist/metadata.d.ts +0 -2
- package/dist/metadata.d.ts.map +0 -1
- package/dist/metadata.js +0 -11
- package/dist/model/SarifModelPerRun.d.ts +0 -2
- package/dist/model/SarifModelPerRun.d.ts.map +0 -1
- package/dist/model/SarifModelPerRun.js +0 -90
- package/dist/model/SarifModelPerSarif.d.ts +0 -2
- package/dist/model/SarifModelPerSarif.d.ts.map +0 -1
- package/dist/model/SarifModelPerSarif.js +0 -102
- package/dist/model/types.d.ts +0 -2
- package/dist/model/types.d.ts.map +0 -1
- package/dist/model/types.js +0 -49
- package/dist/utils/SortUtils.d.ts +0 -2
- package/dist/utils/SortUtils.d.ts.map +0 -1
- package/dist/utils/SortUtils.js +0 -20
- package/src/Processors.ts +0 -68
- package/src/SarifToSlackService.ts +0 -117
- package/src/metadata.ts +0 -10
- package/src/model/SarifModelPerRun.ts +0 -120
- package/src/model/SarifModelPerSarif.ts +0 -126
- package/src/model/types.ts +0 -50
- package/src/utils/SortUtils.ts +0 -33
- package/tests/Processors.spec.ts +0 -76
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { SecurityLevel, SecuritySeverity } from '../types'
|
|
2
|
+
import { Finding } from './Finding'
|
|
3
|
+
import FindingsArray from './FindingsArray'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* This class represents a color in hex format.
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export class Color {
|
|
10
|
+
private readonly _color?: string
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates an instance of {@link Color} class. Before creating an instance of
|
|
14
|
+
* {@link Color} class, it (if applicable) maps CI status into the hex color,
|
|
15
|
+
* and also validates {@param color} to be a valid string that represents a
|
|
16
|
+
* color in hex format.
|
|
17
|
+
* @param color Can be either undefined, valid color in hex format or GitHub
|
|
18
|
+
* CI status (one of: success, failure, cancelled, skipped)
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
public constructor(color?: string) {
|
|
22
|
+
this._color = this.mapColor(color)
|
|
23
|
+
this.validateHexColor()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns a valid string that represents a color in hex format, or undefined.
|
|
28
|
+
*/
|
|
29
|
+
public get value(): string | undefined {
|
|
30
|
+
return this._color
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private validateHexColor(): void {
|
|
34
|
+
if (this._color != null) {
|
|
35
|
+
const hexColorRegex = /^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
|
36
|
+
|
|
37
|
+
if (!hexColorRegex.test(this._color)) {
|
|
38
|
+
throw new Error(`Invalid hex color: "${this._color}"`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private mapColor(from?: string): string | undefined {
|
|
44
|
+
switch (from) {
|
|
45
|
+
case 'success':
|
|
46
|
+
return '#008000'
|
|
47
|
+
case 'failure':
|
|
48
|
+
return '#ff0000'
|
|
49
|
+
case 'cancelled':
|
|
50
|
+
return '#0047ab'
|
|
51
|
+
case 'skipped':
|
|
52
|
+
return '#808080'
|
|
53
|
+
default:
|
|
54
|
+
return from
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Base type that has common fields for both {@link ColorGroupByLevel} and
|
|
61
|
+
* {@link ColorGroupBySeverity}.
|
|
62
|
+
* @private
|
|
63
|
+
*/
|
|
64
|
+
type ColorGroupCommon = {
|
|
65
|
+
none?: Color,
|
|
66
|
+
unknown?: Color,
|
|
67
|
+
empty?: Color,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Color schema for the findings with the certain level. Color is used by the
|
|
72
|
+
* level importance, i.e. if at least 1 error finding exists then
|
|
73
|
+
* {@link ColorGroupByLevel.error} color is used, then if at least 1 warning
|
|
74
|
+
* finding exists then {@link ColorGroupByLevel.warning} color is used, etc.
|
|
75
|
+
* @public
|
|
76
|
+
*/
|
|
77
|
+
export type ColorGroupByLevel = ColorGroupCommon & {
|
|
78
|
+
error?: Color,
|
|
79
|
+
warning?: Color,
|
|
80
|
+
note?: Color,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Color schema for the findings with the certain severity. Color is used by the
|
|
85
|
+
* severity importance, i.e. if at least 1 critical finding exists then
|
|
86
|
+
* {@link ColorGroupBySeverity.critical} color is used, then if at least 1 high
|
|
87
|
+
* finding exists then {@link ColorGroupBySeverity.high} color is used, etc.
|
|
88
|
+
* @public
|
|
89
|
+
*/
|
|
90
|
+
export type ColorGroupBySeverity = ColorGroupCommon & {
|
|
91
|
+
critical?: Color,
|
|
92
|
+
high?: Color,
|
|
93
|
+
medium?: Color,
|
|
94
|
+
low?: Color,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Represents configuration of the color scheme. If both {@link ColorOptions.byLevel}
|
|
99
|
+
* and {@link ColorOptions.bySeverity} are defined, then {@link ColorOptions.bySeverity}
|
|
100
|
+
* takes precedence.
|
|
101
|
+
* @public
|
|
102
|
+
*/
|
|
103
|
+
export type ColorOptions = {
|
|
104
|
+
/**
|
|
105
|
+
* Default color if specific color was not found. It is a fallback option.
|
|
106
|
+
*/
|
|
107
|
+
default?: Color,
|
|
108
|
+
/**
|
|
109
|
+
* Color scheme for the findings where certain level is presented.
|
|
110
|
+
*/
|
|
111
|
+
byLevel?: ColorGroupByLevel,
|
|
112
|
+
/**
|
|
113
|
+
* Color scheme for the findings where certain severity is presented.
|
|
114
|
+
*/
|
|
115
|
+
bySeverity?: ColorGroupBySeverity,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function identifyColorCommon<K extends keyof Finding>(
|
|
119
|
+
findings: FindingsArray,
|
|
120
|
+
prop: K,
|
|
121
|
+
none: Finding[K],
|
|
122
|
+
unknown: Finding[K],
|
|
123
|
+
color: ColorGroupCommon,
|
|
124
|
+
defaultColor?: Color
|
|
125
|
+
): string | undefined {
|
|
126
|
+
if (color.none != null && findings.findByProperty(prop, none) != null) {
|
|
127
|
+
return color.none.value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (color.unknown != null && findings.findByProperty(prop, unknown) != null) {
|
|
131
|
+
return color.unknown.value
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (color.empty != null && findings.length === 0) {
|
|
135
|
+
return color.empty.value
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return defaultColor?.value
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function identifyColorBySeverity(findings: FindingsArray, color: ColorGroupBySeverity, defaultColor?: Color): string | undefined {
|
|
142
|
+
if (color.critical != null && findings.findByProperty('severity', SecuritySeverity.Critical) != null) {
|
|
143
|
+
return color.critical.value
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (color.high != null && findings.findByProperty('severity', SecuritySeverity.High) != null) {
|
|
147
|
+
return color.high.value
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (color.medium != null && findings.findByProperty('severity', SecuritySeverity.Medium) != null) {
|
|
151
|
+
return color.medium.value
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (color.low != null && findings.findByProperty('severity', SecuritySeverity.Low) != null) {
|
|
155
|
+
return color.low.value
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return identifyColorCommon(findings, 'severity', SecuritySeverity.None, SecuritySeverity.Unknown, color, defaultColor)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function identifyColorByLevel(findings: FindingsArray, color: ColorGroupByLevel, defaultColor?: Color): string | undefined {
|
|
162
|
+
if (color.error != null && findings.findByProperty('level', SecurityLevel.Error) != null) {
|
|
163
|
+
return color.error.value
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (color.warning != null && findings.findByProperty('level', SecurityLevel.Warning) != null) {
|
|
167
|
+
return color.warning.value
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (color.note != null && findings.findByProperty('level', SecurityLevel.Note) != null) {
|
|
171
|
+
return color.note.value
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return identifyColorCommon(findings, 'level', SecurityLevel.None, SecurityLevel.Unknown, color, defaultColor)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Makes an ultimate decision on what color should be Slack message. The decision
|
|
179
|
+
* is based on the provided {@param colorOpts} parameter and {@param findings}
|
|
180
|
+
* list.
|
|
181
|
+
* @param findings An instance of {@link FindingsArray} object.
|
|
182
|
+
* @param colorOpts An instance of {@link ColorOptions} type.
|
|
183
|
+
* @internal
|
|
184
|
+
*/
|
|
185
|
+
export function identifyColor(findings: FindingsArray, colorOpts?: ColorOptions): string | undefined {
|
|
186
|
+
if (colorOpts?.bySeverity != null) {
|
|
187
|
+
return identifyColorBySeverity(findings, colorOpts.bySeverity, colorOpts.default)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (colorOpts?.byLevel != null) {
|
|
191
|
+
return identifyColorByLevel(findings, colorOpts.byLevel, colorOpts.default)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return colorOpts?.default?.value
|
|
195
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { ReportingDescriptor, Result } from 'sarif'
|
|
2
|
+
import { RunData, SecurityLevel, SecuritySeverity } from '../types'
|
|
3
|
+
import Logger from '../Logger'
|
|
4
|
+
import { CommonProcessor } from '../processors/CommonProcessor'
|
|
5
|
+
import { createProcessor } from '../processors/ProcessorFactory'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parameters that are needed for the new {@link Finding} instance creation.
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
export type FindingOptions = {
|
|
12
|
+
sarifPath: string,
|
|
13
|
+
runMetadata: RunData,
|
|
14
|
+
result: Result,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* This interface represents a finding from SARIF file.
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export interface Finding {
|
|
22
|
+
get sarifPath(): string,
|
|
23
|
+
get runId(): number,
|
|
24
|
+
get toolName(): string,
|
|
25
|
+
get cvssScore(): number | undefined,
|
|
26
|
+
get level(): SecurityLevel,
|
|
27
|
+
get severity(): SecuritySeverity,
|
|
28
|
+
clone(): Finding,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new instance of {@link Finding} class.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export function createFinding(opts: FindingOptions): Finding {
|
|
36
|
+
return new SarifFinding(opts)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The only implementation of {@link Finding} interface. This class is private
|
|
41
|
+
* and is not supposed to be exposed. {@link createFinding} should be used to
|
|
42
|
+
* create a new {@link Finding}.
|
|
43
|
+
* @private
|
|
44
|
+
*/
|
|
45
|
+
class SarifFinding implements Finding {
|
|
46
|
+
private readonly _runMetadata: RunData
|
|
47
|
+
private readonly _result: Result
|
|
48
|
+
private readonly _sarifPath: string
|
|
49
|
+
private readonly _rule?: ReportingDescriptor
|
|
50
|
+
private readonly _processor: CommonProcessor
|
|
51
|
+
|
|
52
|
+
private _cvssScoreCacheProcessed: boolean = false
|
|
53
|
+
private _cvssScoreCache: number | undefined = undefined
|
|
54
|
+
|
|
55
|
+
private _levelCacheProcessed: boolean = false
|
|
56
|
+
private _levelCache: Result.level | undefined = undefined
|
|
57
|
+
|
|
58
|
+
constructor(opts: FindingOptions) {
|
|
59
|
+
this._processor = createProcessor(opts.runMetadata.run, opts.result)
|
|
60
|
+
this._sarifPath = opts.sarifPath
|
|
61
|
+
this._runMetadata = opts.runMetadata
|
|
62
|
+
this._result = opts.result
|
|
63
|
+
this._rule = this._processor.tryFindRule()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
clone(): Finding {
|
|
67
|
+
return createFinding({
|
|
68
|
+
sarifPath: this._sarifPath,
|
|
69
|
+
runMetadata: this._runMetadata,
|
|
70
|
+
result: this._result
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public get sarifPath(): string {
|
|
75
|
+
return this._sarifPath
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public get runId(): number {
|
|
79
|
+
return this._runMetadata.id
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public get toolName(): string {
|
|
83
|
+
return this._processor.findToolComponent().name
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public get cvssScore(): number | undefined {
|
|
87
|
+
if (!this._cvssScoreCacheProcessed) {
|
|
88
|
+
this._cvssScoreCacheProcessed = true
|
|
89
|
+
this._cvssScoreCache = this._processor.tryFindCvssScore()
|
|
90
|
+
}
|
|
91
|
+
return this._cvssScoreCache
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public get level(): SecurityLevel {
|
|
95
|
+
if (!this._levelCacheProcessed) {
|
|
96
|
+
this._levelCacheProcessed = true
|
|
97
|
+
this._levelCache = this._processor.tryFindLevel()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (this._levelCache === undefined) {
|
|
101
|
+
Logger.debug(`Unknown level of ${this._rule?.id} rule`)
|
|
102
|
+
return SecurityLevel.Unknown
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
switch (this._levelCache) {
|
|
106
|
+
case 'error': return SecurityLevel.Error
|
|
107
|
+
case 'warning': return SecurityLevel.Warning
|
|
108
|
+
case 'note': return SecurityLevel.Note
|
|
109
|
+
default: return SecurityLevel.None
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public get severity(): SecuritySeverity {
|
|
114
|
+
if (this.cvssScore == null || this.cvssScore < 0 || this.cvssScore > 10) {
|
|
115
|
+
Logger.debug(`Unsupported CVSS score ${this.cvssScore} in ${this._rule?.id} rule`)
|
|
116
|
+
return SecuritySeverity.Unknown
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (this.cvssScore >= 9) {
|
|
120
|
+
return SecuritySeverity.Critical
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (this.cvssScore >= 7) {
|
|
124
|
+
return SecuritySeverity.High
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (this.cvssScore >= 4) {
|
|
128
|
+
return SecuritySeverity.Medium
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.cvssScore >= 0.1) {
|
|
132
|
+
return SecuritySeverity.Low
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return SecuritySeverity.None
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Finding } from './Finding'
|
|
2
|
+
import ExtendedArray from '../utils/ExtendedArray'
|
|
3
|
+
import { SecurityLevel, SecuritySeverity } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* This class represents an array of {@link Finding} objects and adds additional
|
|
7
|
+
* useful methods to it.
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export default class FindingsArray extends ExtendedArray<Finding> {
|
|
11
|
+
|
|
12
|
+
public hasSeverityOrHigher(severity: SecuritySeverity): boolean {
|
|
13
|
+
return Object
|
|
14
|
+
.values(SecuritySeverity)
|
|
15
|
+
.filter((v: string | SecuritySeverity): v is SecuritySeverity => typeof v === 'number')
|
|
16
|
+
.filter((v: SecuritySeverity): boolean => v >= severity)
|
|
17
|
+
.some((v: SecuritySeverity): boolean => this.findByProperty('severity', v) != null)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public hasLevelOrHigher(level: SecurityLevel): boolean {
|
|
21
|
+
return Object
|
|
22
|
+
.values(SecurityLevel)
|
|
23
|
+
.filter((v: string | SecurityLevel): v is SecurityLevel => typeof v === 'number')
|
|
24
|
+
.filter((v: SecurityLevel): boolean => v >= level)
|
|
25
|
+
.some((v: SecurityLevel): boolean => this.findByProperty('level', v) != null)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CommonProcessor } from './CommonProcessor'
|
|
2
|
+
import { Result } from 'sarif';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* This class has extra logic for processing SARIF files produced by CodeQL tool.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export class CodeQLProcessor extends CommonProcessor {
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rules in SARIF files produced by CodeQL has additional "problem.severity"
|
|
12
|
+
* property where level is also defined. This method tries to get level in a
|
|
13
|
+
* common way but if it fails to do so, then it tries to get level from
|
|
14
|
+
* "problem.severity" property.
|
|
15
|
+
*/
|
|
16
|
+
public override tryFindLevel(): Result.level | undefined {
|
|
17
|
+
return super.tryFindLevel() ?? this.tryFindRuleProperty('problem.severity')
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ReportingDescriptor, Result, Run, ToolComponent } from 'sarif'
|
|
2
|
+
import * as sarifUtils from '../utils/SarifUtils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* This class has logic of the SARIF file processing, such as finding rule,
|
|
6
|
+
* finding tool component, etc. It is used by default for all SARIF files.
|
|
7
|
+
* Derived classes from this class can implement extra logic for the specific
|
|
8
|
+
* use cases, such as SARIF files produced by specific tools. For example,
|
|
9
|
+
* {@link CodeQLProcessor} handles additional logic for processing SARIF files
|
|
10
|
+
* produced by CodeQL.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export class CommonProcessor {
|
|
14
|
+
protected readonly _run: Run
|
|
15
|
+
protected readonly _result: Result
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates an instance of {@link CommonProcessor} class.
|
|
19
|
+
* @param run An instance of {@link Run} object.
|
|
20
|
+
* @param result An instance of {@link Result} object.
|
|
21
|
+
*/
|
|
22
|
+
public constructor(run: Run, result: Result) {
|
|
23
|
+
this._run = run
|
|
24
|
+
this._result = result
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public tryFindCvssScore(): number | undefined {
|
|
28
|
+
return this.tryFindRuleProperty('security-severity')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public tryFindLevel(): Result.level | undefined {
|
|
32
|
+
return this._result.level ?? this.tryFindRule()?.defaultConfiguration?.level
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public findToolComponentDriver(): ToolComponent {
|
|
36
|
+
return sarifUtils.findToolComponentDriver(this._run)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public tryFindToolComponentExtension(): ToolComponent | undefined {
|
|
40
|
+
return sarifUtils.tryFindToolComponentExtension(this._run, this._result)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public findToolComponent(): ToolComponent {
|
|
44
|
+
return sarifUtils.findToolComponent(this._run, this._result)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* This function tries to find the respective rule for the given result.
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
public tryFindRule(): ReportingDescriptor | undefined {
|
|
52
|
+
const ruleData: { id?: string, index?: number } = {}
|
|
53
|
+
|
|
54
|
+
if (this._result.rule) {
|
|
55
|
+
if (this._result.rule?.index != null) {
|
|
56
|
+
ruleData.index = this._result.rule.index
|
|
57
|
+
}
|
|
58
|
+
if (this._result.rule?.id) {
|
|
59
|
+
ruleData.id = this._result.rule.id
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (ruleData.index == null && this._result.ruleIndex != null) {
|
|
64
|
+
ruleData.index = this._result.ruleIndex
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!ruleData.id && this._result.ruleId) {
|
|
68
|
+
ruleData.id = this._result.ruleId
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tool: ToolComponent = this.findToolComponent()
|
|
72
|
+
|
|
73
|
+
if (ruleData.index != null
|
|
74
|
+
&& tool?.rules
|
|
75
|
+
&& ruleData.index < tool.rules.length) {
|
|
76
|
+
return tool.rules[ruleData.index]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// If failed to find rule by index then try to find by ruleId
|
|
80
|
+
if (ruleData.id && tool?.rules) {
|
|
81
|
+
return tool.rules.find(
|
|
82
|
+
(r: ReportingDescriptor): boolean => r.id === ruleData.id
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* This function searches respective rule for the given result, and then gets
|
|
91
|
+
* the property of interest from it.
|
|
92
|
+
* @param propertyName The property name that you want to get the value from.
|
|
93
|
+
* @protected
|
|
94
|
+
*/
|
|
95
|
+
protected tryFindRuleProperty<T>(propertyName: string): T | undefined {
|
|
96
|
+
const rule: ReportingDescriptor | undefined = this.tryFindRule()
|
|
97
|
+
if (rule?.properties && propertyName in rule.properties) {
|
|
98
|
+
return rule.properties[propertyName] as T
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CommonProcessor } from './CommonProcessor'
|
|
2
|
+
import type { Result, Run, ToolComponent } from 'sarif'
|
|
3
|
+
import { findToolComponent } from '../utils/SarifUtils'
|
|
4
|
+
import { SnykProcessor } from './SnykProcessor'
|
|
5
|
+
import { CodeQLProcessor } from './CodeQLProcessor'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates a new instance of {@link CommonProcessor} class. It tries to find specific
|
|
9
|
+
* processor based on the tool component for the given {@param run} and
|
|
10
|
+
* {@param result} and if no specific processors exist, then it returns an
|
|
11
|
+
* instance of {@link CommonProcessor} class.
|
|
12
|
+
* @param run An instance of {@link Run} class.
|
|
13
|
+
* @param result An instance of {@link Result} class.
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export function createProcessor(run: Run, result: Result): CommonProcessor {
|
|
17
|
+
const toolComponent: ToolComponent = findToolComponent(run, result)
|
|
18
|
+
switch (toolComponent.name) {
|
|
19
|
+
case 'CodeQL': return new CodeQLProcessor(run, result)
|
|
20
|
+
case 'Snyk Open Source': return new SnykProcessor(run, result)
|
|
21
|
+
default: return new CommonProcessor(run, result)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CommonProcessor } from './CommonProcessor'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This class has extra logic for processing SARIF files produced by Snyk Open
|
|
5
|
+
* Source tool.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export class SnykProcessor extends CommonProcessor {
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rules in SARIF files produced by Snyk Open Source has additional "cvssv3_baseScore"
|
|
12
|
+
* property where CVSS score is also defined. This method tries to get level
|
|
13
|
+
* from this "cvssv3_baseScore" property and if it fails to do so, then it tries
|
|
14
|
+
* to get CVSS score in a common way.
|
|
15
|
+
*/
|
|
16
|
+
public override tryFindCvssScore(): number | undefined {
|
|
17
|
+
return this.tryFindRuleProperty<number>('cvssv3_baseScore') ?? super.tryFindCvssScore()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import Representation from './Representation'
|
|
2
|
+
import { Finding } from '../model/Finding'
|
|
3
|
+
import { findingsComparatorByKey } from '../utils/Comparators'
|
|
4
|
+
import { SecurityLevel, SecuritySeverity } from '../types';
|
|
5
|
+
|
|
6
|
+
const NO_VULNS_FOUND_TEXT = 'No vulnerabilities found'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Base class of all compact representation types. By "compact" means that it
|
|
10
|
+
* groups findings by the {@link Finding} property, such as "severity" or "level".
|
|
11
|
+
* So, in fact it already prepares this in case of "severity":
|
|
12
|
+
* @example
|
|
13
|
+
* ```text
|
|
14
|
+
* Critical: 1, High: 5, Medium: 2, Low: 20, None: 1, Unknown: 120
|
|
15
|
+
* ```
|
|
16
|
+
* or this in case of "level":
|
|
17
|
+
* @example
|
|
18
|
+
* ```text
|
|
19
|
+
* Error: 6, Warning: 2, Note: 20, None: 1, Unknown: 120
|
|
20
|
+
* ```
|
|
21
|
+
* It is an abstract class, so the only question that derived classes should
|
|
22
|
+
* "answer" is how to group finding to show the compact representation.
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export default abstract class CompactGroupByRepresentation extends Representation {
|
|
26
|
+
|
|
27
|
+
protected abstract groupFindings(): Map<string, Finding[]>
|
|
28
|
+
|
|
29
|
+
protected composeByProperty<K extends keyof Pick<Finding, 'level' | 'severity'>>(key: K): string {
|
|
30
|
+
const grouped: Map<string, Finding[]> = this.groupFindings()
|
|
31
|
+
if (grouped.size === 0) {
|
|
32
|
+
return NO_VULNS_FOUND_TEXT
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Array.from(grouped)
|
|
36
|
+
.map(([title, findings]: [string, Finding[]]): string => {
|
|
37
|
+
findings.sort(findingsComparatorByKey(key))
|
|
38
|
+
const summary: string =
|
|
39
|
+
findings.length === 0
|
|
40
|
+
? NO_VULNS_FOUND_TEXT
|
|
41
|
+
: this.composeCompactReport(findings, key)
|
|
42
|
+
return `${title}\n${summary}`
|
|
43
|
+
})
|
|
44
|
+
.join('\n\n')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private composeCompactReport<K extends keyof Pick<Finding, 'level' | 'severity'>>(findings: Finding[], key: K): string {
|
|
48
|
+
return Object
|
|
49
|
+
.entries(Object.groupBy(findings, (f: Finding): PropertyKey => f[key] as PropertyKey))
|
|
50
|
+
.map(([prop, findings2]: [string, Finding[] | undefined]): string | undefined => {
|
|
51
|
+
if (findings2 == null) {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
return `${this.bold(this.extractEnumValue(key, prop))}: ${findings2.length}`
|
|
55
|
+
})
|
|
56
|
+
.filter((v: string | undefined): v is string => v != null)
|
|
57
|
+
.join(', ')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private extractEnumValue<K extends keyof Pick<Finding, 'level' | 'severity'>>(key: K, prop: string): string {
|
|
61
|
+
switch (key) {
|
|
62
|
+
case 'level': return SecurityLevel[Number(prop)]
|
|
63
|
+
case 'severity': return SecuritySeverity[Number(prop)]
|
|
64
|
+
default: throw new Error('Unknown property:', key)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import CompactGroupByRunRepresentation from './CompactGroupByRunRepresentation';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Since {@link CompactGroupByRunRepresentation} is an abstract class, the only
|
|
5
|
+
* question that this class should "answer" is what property should be used in
|
|
6
|
+
* the compact representation. In this case it is "level".
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export default class CompactGroupByRunPerLevelRepresentation extends CompactGroupByRunRepresentation {
|
|
10
|
+
|
|
11
|
+
public override compose(): string {
|
|
12
|
+
return this.composeByProperty('level')
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import CompactGroupByRunRepresentation from './CompactGroupByRunRepresentation';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Since {@link CompactGroupByRunRepresentation} is an abstract class, the only
|
|
5
|
+
* question that this class should "answer" is what property should be used in
|
|
6
|
+
* the compact representation. In this case it is "severity".
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export default class CompactGroupByRunPerSeverityRepresentation extends CompactGroupByRunRepresentation {
|
|
10
|
+
|
|
11
|
+
public override compose(): string {
|
|
12
|
+
return this.composeByProperty('severity')
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Finding } from '../model/Finding'
|
|
2
|
+
import CompactGroupByRepresentation from './CompactGroupByRepresentation'
|
|
3
|
+
import { SarifModel } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Since {@link CompactGroupByRepresentation} already prepares compact representation
|
|
7
|
+
* of findings, this class defines a grouping rule. In this case it groups
|
|
8
|
+
* findings by run. Every run will be grouped separately, such as:
|
|
9
|
+
* @example
|
|
10
|
+
* ```text
|
|
11
|
+
* [Run 1] Grype
|
|
12
|
+
* Error: 1, Warning: 4
|
|
13
|
+
* [Run 2] Grype
|
|
14
|
+
* Warning: 1, Note: 20
|
|
15
|
+
* ```
|
|
16
|
+
* @internal
|
|
17
|
+
* It is an abstract class, so the only question that derived classes should
|
|
18
|
+
* "answer" is what property should be used in the compact representation, such
|
|
19
|
+
* as "level" and "severity".
|
|
20
|
+
*/
|
|
21
|
+
export default abstract class CompactGroupByRunRepresentation extends CompactGroupByRepresentation {
|
|
22
|
+
|
|
23
|
+
public constructor(model: SarifModel) {
|
|
24
|
+
super(model, 'runId')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected override groupFindings(): Map<string, Finding[]> {
|
|
28
|
+
const result = new Map<string, Finding[]>()
|
|
29
|
+
for (const run of this._model.runs) {
|
|
30
|
+
const key: string = this.composeGroupTitle(run.id, run.toolName)
|
|
31
|
+
if (result.get(key) == null) {
|
|
32
|
+
result.set(key, [])
|
|
33
|
+
}
|
|
34
|
+
this._model.findings
|
|
35
|
+
.filter((f: Finding): boolean => f.runId === run.id)
|
|
36
|
+
.forEach((f: Finding): number | undefined => result.get(key)?.push(f))
|
|
37
|
+
}
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private composeGroupTitle(runId: number, toolName: string): string {
|
|
42
|
+
return `${this.italic(`[Run ${runId}]`)} ${this.bold(toolName)}`
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import CompactGroupBySarifRepresentation
|
|
2
|
+
from './CompactGroupBySarifRepresentation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Since {@link CompactGroupBySarifRepresentation} is an abstract class, the only
|
|
6
|
+
* question that this class should "answer" is what property should be used in
|
|
7
|
+
* the compact representation. In this case it is "level".
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export default class CompactGroupBySarifPerLevelRepresentation extends CompactGroupBySarifRepresentation {
|
|
11
|
+
|
|
12
|
+
public override compose(): string {
|
|
13
|
+
return this.composeByProperty('level')
|
|
14
|
+
}
|
|
15
|
+
}
|