github-issue-tower-defence-management 1.74.1 → 1.76.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/umino-project.yml +5 -3
- package/CHANGELOG.md +22 -0
- package/README.md +11 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +46 -17
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/repositories/KyHttpRepository.js +16 -0
- package/bin/adapter/repositories/KyHttpRepository.js.map +1 -0
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +32 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/ChangeTargetPullRequestApprover.js +4 -3
- package/bin/domain/usecases/ChangeTargetPullRequestApprover.js.map +1 -1
- package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -1
- package/bin/domain/usecases/DailySecurityScanUseCase.js +108 -0
- package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +10 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js +35 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/HttpRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/HttpRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +11 -0
- package/src/adapter/repositories/KyHttpRepository.ts +8 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +43 -1
- package/src/domain/usecases/ChangeTargetPullRequestApprover.test.ts +39 -0
- package/src/domain/usecases/ChangeTargetPullRequestApprover.ts +4 -3
- package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +4 -0
- package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +5 -1
- package/src/domain/usecases/DailySecurityScanUseCase.test.ts +412 -0
- package/src/domain/usecases/DailySecurityScanUseCase.ts +200 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +3 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +14 -0
- package/src/domain/usecases/IssueRejectionEvaluator.test.ts +141 -0
- package/src/domain/usecases/IssueRejectionEvaluator.ts +56 -3
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +2 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +1 -0
- package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +2 -0
- package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.ts +1 -0
- package/src/domain/usecases/adapter-interfaces/HttpRepository.ts +3 -0
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +5 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/repositories/KyHttpRepository.d.ts +5 -0
- package/types/adapter/repositories/KyHttpRepository.d.ts.map +1 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/ChangeTargetPullRequestApprover.d.ts.map +1 -1
- package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts +1 -1
- package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -1
- package/types/domain/usecases/DailySecurityScanUseCase.d.ts +25 -0
- package/types/domain/usecases/DailySecurityScanUseCase.d.ts.map +1 -0
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +4 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/IssueRejectionEvaluator.d.ts +4 -2
- package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts +1 -1
- package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/HttpRepository.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/HttpRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { DailySecurityScanUseCase } from './DailySecurityScanUseCase';
|
|
2
|
+
import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
|
|
3
|
+
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
4
|
+
import { HttpRepository } from './adapter-interfaces/HttpRepository';
|
|
5
|
+
import { mock } from 'jest-mock-extended';
|
|
6
|
+
|
|
7
|
+
const KEV_CATALOG_URL =
|
|
8
|
+
'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
|
9
|
+
|
|
10
|
+
describe('DailySecurityScanUseCase', () => {
|
|
11
|
+
const buildUseCase = () => {
|
|
12
|
+
const mockLocalCommandRunner = mock<LocalCommandRunner>();
|
|
13
|
+
const mockIssueRepository = mock<IssueRepository>();
|
|
14
|
+
const mockHttpRepository = mock<HttpRepository>();
|
|
15
|
+
const useCase = new DailySecurityScanUseCase(
|
|
16
|
+
mockLocalCommandRunner,
|
|
17
|
+
mockIssueRepository,
|
|
18
|
+
mockHttpRepository,
|
|
19
|
+
);
|
|
20
|
+
return {
|
|
21
|
+
useCase,
|
|
22
|
+
mockLocalCommandRunner,
|
|
23
|
+
mockIssueRepository,
|
|
24
|
+
mockHttpRepository,
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('run', () => {
|
|
29
|
+
it('does nothing when no target date matches the configured hour', async () => {
|
|
30
|
+
const { useCase, mockLocalCommandRunner, mockIssueRepository } =
|
|
31
|
+
buildUseCase();
|
|
32
|
+
|
|
33
|
+
await useCase.run({
|
|
34
|
+
targetDates: [new Date('2024-01-01T03:30:00Z')],
|
|
35
|
+
org: 'example-org',
|
|
36
|
+
manager: 'manager-name',
|
|
37
|
+
dailySecurityScan: {
|
|
38
|
+
scanBaseDirectory: '/repos',
|
|
39
|
+
targetHourUtc: 5,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
44
|
+
expect(mockIssueRepository.createNewIssue.mock.calls).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not run when the matching hour is not at minute zero', async () => {
|
|
48
|
+
const { useCase, mockLocalCommandRunner } = buildUseCase();
|
|
49
|
+
|
|
50
|
+
await useCase.run({
|
|
51
|
+
targetDates: [new Date('2024-01-01T05:15:00Z')],
|
|
52
|
+
org: 'example-org',
|
|
53
|
+
manager: 'manager-name',
|
|
54
|
+
dailySecurityScan: {
|
|
55
|
+
scanBaseDirectory: '/repos',
|
|
56
|
+
targetHourUtc: 5,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('scans repositories and creates an issue when osv-scanner finds vulnerabilities', async () => {
|
|
64
|
+
const { useCase, mockLocalCommandRunner, mockIssueRepository } =
|
|
65
|
+
buildUseCase();
|
|
66
|
+
|
|
67
|
+
mockLocalCommandRunner.runCommand.mockImplementation(
|
|
68
|
+
async (program, args) => {
|
|
69
|
+
if (program === 'find') {
|
|
70
|
+
return {
|
|
71
|
+
stdout: '/repos/example-org/app/.git\n',
|
|
72
|
+
stderr: '',
|
|
73
|
+
exitCode: 0,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (program === 'git') {
|
|
77
|
+
return {
|
|
78
|
+
stdout: 'git@github.com:example-org/app.git\n',
|
|
79
|
+
stderr: '',
|
|
80
|
+
exitCode: 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (program === 'osv-scanner') {
|
|
84
|
+
return {
|
|
85
|
+
stdout: 'vulnerability detected in ' + args[args.length - 1],
|
|
86
|
+
stderr: '',
|
|
87
|
+
exitCode: 1,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { stdout: '', stderr: '', exitCode: 0 };
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await useCase.run({
|
|
95
|
+
targetDates: [new Date('2024-01-02T05:00:00Z')],
|
|
96
|
+
org: 'example-org',
|
|
97
|
+
manager: 'manager-name',
|
|
98
|
+
dailySecurityScan: {
|
|
99
|
+
scanBaseDirectory: '/repos',
|
|
100
|
+
targetHourUtc: 5,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(mockIssueRepository.createNewIssue.mock.calls).toHaveLength(1);
|
|
105
|
+
expect(mockIssueRepository.createNewIssue.mock.calls[0][0]).toBe(
|
|
106
|
+
'example-org',
|
|
107
|
+
);
|
|
108
|
+
expect(mockIssueRepository.createNewIssue.mock.calls[0][1]).toBe('app');
|
|
109
|
+
expect(mockIssueRepository.createNewIssue.mock.calls[0][2]).toBe(
|
|
110
|
+
'Daily security scan findings: 2024-01-02',
|
|
111
|
+
);
|
|
112
|
+
expect(mockIssueRepository.createNewIssue.mock.calls[0][4]).toEqual([
|
|
113
|
+
'manager-name',
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not create an issue when osv-scanner reports no vulnerabilities', async () => {
|
|
118
|
+
const { useCase, mockLocalCommandRunner, mockIssueRepository } =
|
|
119
|
+
buildUseCase();
|
|
120
|
+
|
|
121
|
+
mockLocalCommandRunner.runCommand.mockImplementation(async (program) => {
|
|
122
|
+
if (program === 'find') {
|
|
123
|
+
return {
|
|
124
|
+
stdout: '/repos/example-org/app/.git\n',
|
|
125
|
+
stderr: '',
|
|
126
|
+
exitCode: 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (program === 'git') {
|
|
130
|
+
return {
|
|
131
|
+
stdout: 'git@github.com:example-org/app.git\n',
|
|
132
|
+
stderr: '',
|
|
133
|
+
exitCode: 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { stdout: 'no vulnerabilities', stderr: '', exitCode: 0 };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await useCase.run({
|
|
140
|
+
targetDates: [new Date('2024-01-02T05:00:00Z')],
|
|
141
|
+
org: 'example-org',
|
|
142
|
+
manager: 'manager-name',
|
|
143
|
+
dailySecurityScan: {
|
|
144
|
+
scanBaseDirectory: '/repos',
|
|
145
|
+
targetHourUtc: 5,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(mockIssueRepository.createNewIssue.mock.calls).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('skips repositories whose origin remote does not belong to the org', async () => {
|
|
153
|
+
const { useCase, mockLocalCommandRunner, mockIssueRepository } =
|
|
154
|
+
buildUseCase();
|
|
155
|
+
|
|
156
|
+
mockLocalCommandRunner.runCommand.mockImplementation(async (program) => {
|
|
157
|
+
if (program === 'find') {
|
|
158
|
+
return {
|
|
159
|
+
stdout: '/repos/other-org/app/.git\n',
|
|
160
|
+
stderr: '',
|
|
161
|
+
exitCode: 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (program === 'git') {
|
|
165
|
+
return {
|
|
166
|
+
stdout: 'git@github.com:other-org/app.git\n',
|
|
167
|
+
stderr: '',
|
|
168
|
+
exitCode: 0,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { stdout: '', stderr: '', exitCode: 1 };
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await useCase.run({
|
|
175
|
+
targetDates: [new Date('2024-01-02T05:00:00Z')],
|
|
176
|
+
org: 'example-org',
|
|
177
|
+
manager: 'manager-name',
|
|
178
|
+
dailySecurityScan: {
|
|
179
|
+
scanBaseDirectory: '/repos',
|
|
180
|
+
targetHourUtc: 5,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(mockIssueRepository.createNewIssue.mock.calls).toHaveLength(0);
|
|
185
|
+
const osvScannerInvoked =
|
|
186
|
+
mockLocalCommandRunner.runCommand.mock.calls.some(
|
|
187
|
+
(call) => call[0] === 'osv-scanner',
|
|
188
|
+
);
|
|
189
|
+
expect(osvScannerInvoked).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('skips repositories whose origin remote cannot be resolved', async () => {
|
|
193
|
+
const { useCase, mockLocalCommandRunner, mockIssueRepository } =
|
|
194
|
+
buildUseCase();
|
|
195
|
+
|
|
196
|
+
mockLocalCommandRunner.runCommand.mockImplementation(async (program) => {
|
|
197
|
+
if (program === 'find') {
|
|
198
|
+
return {
|
|
199
|
+
stdout: '/repos/example-org/app/.git\n',
|
|
200
|
+
stderr: '',
|
|
201
|
+
exitCode: 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (program === 'git') {
|
|
205
|
+
return {
|
|
206
|
+
stdout: '',
|
|
207
|
+
stderr: 'fatal: No such remote',
|
|
208
|
+
exitCode: 2,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return { stdout: '', stderr: '', exitCode: 1 };
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await useCase.run({
|
|
215
|
+
targetDates: [new Date('2024-01-02T05:00:00Z')],
|
|
216
|
+
org: 'example-org',
|
|
217
|
+
manager: 'manager-name',
|
|
218
|
+
dailySecurityScan: {
|
|
219
|
+
scanBaseDirectory: '/repos',
|
|
220
|
+
targetHourUtc: 5,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(mockIssueRepository.createNewIssue.mock.calls).toHaveLength(0);
|
|
225
|
+
const osvScannerInvoked =
|
|
226
|
+
mockLocalCommandRunner.runCommand.mock.calls.some(
|
|
227
|
+
(call) => call[0] === 'osv-scanner',
|
|
228
|
+
);
|
|
229
|
+
expect(osvScannerInvoked).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('does not fetch the KEV catalog when KEV reporting is disabled', async () => {
|
|
233
|
+
const { useCase, mockLocalCommandRunner, mockHttpRepository } =
|
|
234
|
+
buildUseCase();
|
|
235
|
+
|
|
236
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
237
|
+
stdout: '',
|
|
238
|
+
stderr: '',
|
|
239
|
+
exitCode: 0,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await useCase.run({
|
|
243
|
+
targetDates: [new Date('2024-01-02T05:00:00Z')],
|
|
244
|
+
org: 'example-org',
|
|
245
|
+
manager: 'manager-name',
|
|
246
|
+
dailySecurityScan: {
|
|
247
|
+
scanBaseDirectory: '/repos',
|
|
248
|
+
targetHourUtc: 5,
|
|
249
|
+
enableKevNvdReport: false,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(mockHttpRepository.get.mock.calls).toHaveLength(0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('does not fetch the KEV catalog when the report repo is missing', async () => {
|
|
257
|
+
const { useCase, mockLocalCommandRunner, mockHttpRepository } =
|
|
258
|
+
buildUseCase();
|
|
259
|
+
|
|
260
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
261
|
+
stdout: '',
|
|
262
|
+
stderr: '',
|
|
263
|
+
exitCode: 0,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await useCase.run({
|
|
267
|
+
targetDates: [new Date('2024-01-02T05:00:00Z')],
|
|
268
|
+
org: 'example-org',
|
|
269
|
+
manager: 'manager-name',
|
|
270
|
+
dailySecurityScan: {
|
|
271
|
+
scanBaseDirectory: '/repos',
|
|
272
|
+
targetHourUtc: 5,
|
|
273
|
+
enableKevNvdReport: true,
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(mockHttpRepository.get.mock.calls).toHaveLength(0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('creates a KEV report issue for vulnerabilities added since yesterday', async () => {
|
|
281
|
+
const {
|
|
282
|
+
useCase,
|
|
283
|
+
mockLocalCommandRunner,
|
|
284
|
+
mockIssueRepository,
|
|
285
|
+
mockHttpRepository,
|
|
286
|
+
} = buildUseCase();
|
|
287
|
+
|
|
288
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
289
|
+
stdout: '',
|
|
290
|
+
stderr: '',
|
|
291
|
+
exitCode: 0,
|
|
292
|
+
});
|
|
293
|
+
mockHttpRepository.get.mockResolvedValue(
|
|
294
|
+
JSON.stringify({
|
|
295
|
+
vulnerabilities: [
|
|
296
|
+
{
|
|
297
|
+
cveID: 'CVE-2024-0001',
|
|
298
|
+
vulnerabilityName: 'New Vulnerability',
|
|
299
|
+
dateAdded: '2024-01-02',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
cveID: 'CVE-2023-9999',
|
|
303
|
+
vulnerabilityName: 'Old Vulnerability',
|
|
304
|
+
dateAdded: '2023-12-31',
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
await useCase.run({
|
|
311
|
+
targetDates: [new Date('2024-01-02T05:00:00Z')],
|
|
312
|
+
org: 'example-org',
|
|
313
|
+
manager: 'manager-name',
|
|
314
|
+
dailySecurityScan: {
|
|
315
|
+
scanBaseDirectory: '/repos',
|
|
316
|
+
targetHourUtc: 5,
|
|
317
|
+
enableKevNvdReport: true,
|
|
318
|
+
kevReportRepo: 'security-reports',
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(mockHttpRepository.get.mock.calls).toHaveLength(1);
|
|
323
|
+
expect(mockHttpRepository.get.mock.calls[0][0]).toBe(KEV_CATALOG_URL);
|
|
324
|
+
expect(mockIssueRepository.createNewIssue.mock.calls).toHaveLength(1);
|
|
325
|
+
expect(mockIssueRepository.createNewIssue.mock.calls[0][0]).toBe(
|
|
326
|
+
'example-org',
|
|
327
|
+
);
|
|
328
|
+
expect(mockIssueRepository.createNewIssue.mock.calls[0][1]).toBe(
|
|
329
|
+
'security-reports',
|
|
330
|
+
);
|
|
331
|
+
expect(mockIssueRepository.createNewIssue.mock.calls[0][2]).toBe(
|
|
332
|
+
'CISA KEV new additions since 2024-01-01',
|
|
333
|
+
);
|
|
334
|
+
expect(mockIssueRepository.createNewIssue.mock.calls[0][3]).toContain(
|
|
335
|
+
'CVE-2024-0001',
|
|
336
|
+
);
|
|
337
|
+
expect(mockIssueRepository.createNewIssue.mock.calls[0][3]).not.toContain(
|
|
338
|
+
'CVE-2023-9999',
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('does not create a KEV report issue when there are no new additions', async () => {
|
|
343
|
+
const {
|
|
344
|
+
useCase,
|
|
345
|
+
mockLocalCommandRunner,
|
|
346
|
+
mockIssueRepository,
|
|
347
|
+
mockHttpRepository,
|
|
348
|
+
} = buildUseCase();
|
|
349
|
+
|
|
350
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
351
|
+
stdout: '',
|
|
352
|
+
stderr: '',
|
|
353
|
+
exitCode: 0,
|
|
354
|
+
});
|
|
355
|
+
mockHttpRepository.get.mockResolvedValue(
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
vulnerabilities: [
|
|
358
|
+
{
|
|
359
|
+
cveID: 'CVE-2023-9999',
|
|
360
|
+
vulnerabilityName: 'Old Vulnerability',
|
|
361
|
+
dateAdded: '2023-12-31',
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
await useCase.run({
|
|
368
|
+
targetDates: [new Date('2024-01-02T05:00:00Z')],
|
|
369
|
+
org: 'example-org',
|
|
370
|
+
manager: 'manager-name',
|
|
371
|
+
dailySecurityScan: {
|
|
372
|
+
scanBaseDirectory: '/repos',
|
|
373
|
+
targetHourUtc: 5,
|
|
374
|
+
enableKevNvdReport: true,
|
|
375
|
+
kevReportRepo: 'security-reports',
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
expect(mockHttpRepository.get.mock.calls).toHaveLength(1);
|
|
380
|
+
expect(mockHttpRepository.get.mock.calls[0][0]).toBe(KEV_CATALOG_URL);
|
|
381
|
+
expect(mockIssueRepository.createNewIssue.mock.calls).toHaveLength(0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('throws when the KEV catalog format is unexpected', async () => {
|
|
385
|
+
const { useCase, mockLocalCommandRunner, mockHttpRepository } =
|
|
386
|
+
buildUseCase();
|
|
387
|
+
|
|
388
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
389
|
+
stdout: '',
|
|
390
|
+
stderr: '',
|
|
391
|
+
exitCode: 0,
|
|
392
|
+
});
|
|
393
|
+
mockHttpRepository.get.mockResolvedValue(
|
|
394
|
+
JSON.stringify({ unexpected: 'structure' }),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
await expect(
|
|
398
|
+
useCase.run({
|
|
399
|
+
targetDates: [new Date('2024-01-02T05:00:00Z')],
|
|
400
|
+
org: 'example-org',
|
|
401
|
+
manager: 'manager-name',
|
|
402
|
+
dailySecurityScan: {
|
|
403
|
+
scanBaseDirectory: '/repos',
|
|
404
|
+
targetHourUtc: 5,
|
|
405
|
+
enableKevNvdReport: true,
|
|
406
|
+
kevReportRepo: 'security-reports',
|
|
407
|
+
},
|
|
408
|
+
}),
|
|
409
|
+
).rejects.toThrow('Unexpected CISA KEV catalog format');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
|
|
2
|
+
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
3
|
+
import { HttpRepository } from './adapter-interfaces/HttpRepository';
|
|
4
|
+
import { Member } from '../entities/Member';
|
|
5
|
+
|
|
6
|
+
export type DailySecurityScanConfig = {
|
|
7
|
+
scanBaseDirectory: string;
|
|
8
|
+
targetHourUtc: number;
|
|
9
|
+
enableKevNvdReport?: boolean;
|
|
10
|
+
kevReportRepo?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type KevVulnerability = {
|
|
14
|
+
cveID: string;
|
|
15
|
+
vulnerabilityName: string;
|
|
16
|
+
dateAdded: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type KevCatalog = {
|
|
20
|
+
vulnerabilities: KevVulnerability[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const isKevVulnerability = (value: unknown): value is KevVulnerability => {
|
|
24
|
+
if (typeof value !== 'object' || value === null) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const record: Record<string, unknown> = { ...value };
|
|
28
|
+
return (
|
|
29
|
+
typeof record.cveID === 'string' &&
|
|
30
|
+
typeof record.vulnerabilityName === 'string' &&
|
|
31
|
+
typeof record.dateAdded === 'string'
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const isKevCatalog = (value: unknown): value is KevCatalog => {
|
|
36
|
+
if (typeof value !== 'object' || value === null) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const record: Record<string, unknown> = { ...value };
|
|
40
|
+
return (
|
|
41
|
+
Array.isArray(record.vulnerabilities) &&
|
|
42
|
+
record.vulnerabilities.every(isKevVulnerability)
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const KEV_CATALOG_URL =
|
|
47
|
+
'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
|
48
|
+
|
|
49
|
+
export class DailySecurityScanUseCase {
|
|
50
|
+
constructor(
|
|
51
|
+
readonly localCommandRunner: LocalCommandRunner,
|
|
52
|
+
readonly issueRepository: Pick<IssueRepository, 'createNewIssue'>,
|
|
53
|
+
readonly httpRepository: HttpRepository,
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
run = async (input: {
|
|
57
|
+
targetDates: Date[];
|
|
58
|
+
org: string;
|
|
59
|
+
manager: Member['name'];
|
|
60
|
+
dailySecurityScan: DailySecurityScanConfig;
|
|
61
|
+
}): Promise<void> => {
|
|
62
|
+
const shouldRun = input.targetDates.some(
|
|
63
|
+
(targetDate) =>
|
|
64
|
+
targetDate.getUTCHours() === input.dailySecurityScan.targetHourUtc &&
|
|
65
|
+
targetDate.getUTCMinutes() === 0,
|
|
66
|
+
);
|
|
67
|
+
if (!shouldRun) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lastTargetDate = input.targetDates[input.targetDates.length - 1];
|
|
72
|
+
const today = lastTargetDate.toISOString().slice(0, 10);
|
|
73
|
+
|
|
74
|
+
await this.scanRepositories(
|
|
75
|
+
input.org,
|
|
76
|
+
input.manager,
|
|
77
|
+
today,
|
|
78
|
+
input.dailySecurityScan,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await this.reportKevAdditions(
|
|
82
|
+
input.org,
|
|
83
|
+
input.manager,
|
|
84
|
+
lastTargetDate,
|
|
85
|
+
input.dailySecurityScan,
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
private scanRepositories = async (
|
|
90
|
+
org: string,
|
|
91
|
+
manager: Member['name'],
|
|
92
|
+
today: string,
|
|
93
|
+
config: DailySecurityScanConfig,
|
|
94
|
+
): Promise<void> => {
|
|
95
|
+
const { stdout: findOutput } = await this.localCommandRunner.runCommand(
|
|
96
|
+
'find',
|
|
97
|
+
[
|
|
98
|
+
config.scanBaseDirectory,
|
|
99
|
+
'-mindepth',
|
|
100
|
+
'4',
|
|
101
|
+
'-maxdepth',
|
|
102
|
+
'4',
|
|
103
|
+
'-name',
|
|
104
|
+
'.git',
|
|
105
|
+
'-type',
|
|
106
|
+
'd',
|
|
107
|
+
],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const repositoryDirectories = findOutput
|
|
111
|
+
.split('\n')
|
|
112
|
+
.filter((line) => line.length > 0)
|
|
113
|
+
.map((gitDirectory) => gitDirectory.replace(/\/\.git$/, ''));
|
|
114
|
+
|
|
115
|
+
for (const repositoryDirectory of repositoryDirectories) {
|
|
116
|
+
const { stdout: remoteUrl, exitCode: remoteExitCode } =
|
|
117
|
+
await this.localCommandRunner.runCommand('git', [
|
|
118
|
+
'-C',
|
|
119
|
+
repositoryDirectory,
|
|
120
|
+
'remote',
|
|
121
|
+
'get-url',
|
|
122
|
+
'origin',
|
|
123
|
+
]);
|
|
124
|
+
if (remoteExitCode !== 0) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const remoteMatch = remoteUrl
|
|
129
|
+
.trim()
|
|
130
|
+
.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
131
|
+
if (!remoteMatch || remoteMatch[1] !== org) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const repositoryOrg = remoteMatch[1];
|
|
135
|
+
const repositoryName = remoteMatch[2];
|
|
136
|
+
|
|
137
|
+
const { stdout: scanOutput, exitCode: scanExitCode } =
|
|
138
|
+
await this.localCommandRunner.runCommand('osv-scanner', [
|
|
139
|
+
'scan',
|
|
140
|
+
'source',
|
|
141
|
+
'-r',
|
|
142
|
+
repositoryDirectory,
|
|
143
|
+
]);
|
|
144
|
+
if (scanExitCode !== 1) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await this.issueRepository.createNewIssue(
|
|
149
|
+
repositoryOrg,
|
|
150
|
+
repositoryName,
|
|
151
|
+
`Daily security scan findings: ${today}`,
|
|
152
|
+
`## OSV-Scanner findings\n\n\`\`\`\n${scanOutput}\n\`\`\``,
|
|
153
|
+
[manager],
|
|
154
|
+
[],
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
private reportKevAdditions = async (
|
|
160
|
+
org: string,
|
|
161
|
+
manager: Member['name'],
|
|
162
|
+
lastTargetDate: Date,
|
|
163
|
+
config: DailySecurityScanConfig,
|
|
164
|
+
): Promise<void> => {
|
|
165
|
+
if (!config.enableKevNvdReport || !config.kevReportRepo) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const yesterday = new Date(lastTargetDate);
|
|
170
|
+
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
|
171
|
+
const yesterdayYmd = yesterday.toISOString().slice(0, 10);
|
|
172
|
+
|
|
173
|
+
const kevJson = await this.httpRepository.get(KEV_CATALOG_URL);
|
|
174
|
+
const parsedKev: unknown = JSON.parse(kevJson);
|
|
175
|
+
if (!isKevCatalog(parsedKev)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Unexpected CISA KEV catalog format from ${KEV_CATALOG_URL}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const newKevEntries = parsedKev.vulnerabilities
|
|
182
|
+
.filter((vulnerability) => vulnerability.dateAdded >= yesterdayYmd)
|
|
183
|
+
.map(
|
|
184
|
+
(vulnerability) =>
|
|
185
|
+
`- ${vulnerability.dateAdded} ${vulnerability.cveID} ${vulnerability.vulnerabilityName}`,
|
|
186
|
+
);
|
|
187
|
+
if (newKevEntries.length === 0) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await this.issueRepository.createNewIssue(
|
|
192
|
+
org,
|
|
193
|
+
config.kevReportRepo,
|
|
194
|
+
`CISA KEV new additions since ${yesterdayYmd}`,
|
|
195
|
+
newKevEntries.join('\n'),
|
|
196
|
+
[manager],
|
|
197
|
+
[],
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -25,6 +25,7 @@ import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUse
|
|
|
25
25
|
import { RevertNotReadyAwaitingQualityCheckUseCase } from './RevertNotReadyAwaitingQualityCheckUseCase';
|
|
26
26
|
import { SetupTowerDefenceProjectUseCase } from './SetupTowerDefenceProjectUseCase';
|
|
27
27
|
import { UpdateRateLimitCacheUseCase } from './UpdateRateLimitCacheUseCase';
|
|
28
|
+
import { DailySecurityScanUseCase } from './DailySecurityScanUseCase';
|
|
28
29
|
|
|
29
30
|
describe('HandleScheduledEventUseCase', () => {
|
|
30
31
|
describe('createTargetDateTimes', () => {
|
|
@@ -117,6 +118,7 @@ describe('HandleScheduledEventUseCase', () => {
|
|
|
117
118
|
const mockRevertNotReadyAwaitingQualityCheckUseCase =
|
|
118
119
|
mock<RevertNotReadyAwaitingQualityCheckUseCase>();
|
|
119
120
|
const mockUpdateRateLimitCacheUseCase = mock<UpdateRateLimitCacheUseCase>();
|
|
121
|
+
const mockDailySecurityScanUseCase = mock<DailySecurityScanUseCase>();
|
|
120
122
|
const mockDateRepository = mock<DateRepository>();
|
|
121
123
|
const mockSpreadsheetRepository = mock<SpreadsheetRepository>();
|
|
122
124
|
const mockProjectRepository = mock<ProjectRepository>();
|
|
@@ -142,6 +144,7 @@ describe('HandleScheduledEventUseCase', () => {
|
|
|
142
144
|
mockRevertOrphanedPreparationUseCase,
|
|
143
145
|
mockRevertNotReadyAwaitingQualityCheckUseCase,
|
|
144
146
|
mockUpdateRateLimitCacheUseCase,
|
|
147
|
+
mockDailySecurityScanUseCase,
|
|
145
148
|
mockDateRepository,
|
|
146
149
|
mockSpreadsheetRepository,
|
|
147
150
|
mockProjectRepository,
|
|
@@ -29,6 +29,10 @@ import { RevertNotReadyAwaitingQualityCheckUseCase } from './RevertNotReadyAwait
|
|
|
29
29
|
import { resolveLabelsAsLlmAgentName } from './resolveLabelsAsLlmAgentName';
|
|
30
30
|
import { SetupTowerDefenceProjectUseCase } from './SetupTowerDefenceProjectUseCase';
|
|
31
31
|
import { UpdateRateLimitCacheUseCase } from './UpdateRateLimitCacheUseCase';
|
|
32
|
+
import {
|
|
33
|
+
DailySecurityScanConfig,
|
|
34
|
+
DailySecurityScanUseCase,
|
|
35
|
+
} from './DailySecurityScanUseCase';
|
|
32
36
|
|
|
33
37
|
export class ProjectNotFoundError extends Error {
|
|
34
38
|
constructor(message: string) {
|
|
@@ -60,6 +64,7 @@ export class HandleScheduledEventUseCase {
|
|
|
60
64
|
readonly revertOrphanedPreparationUseCase: RevertOrphanedPreparationUseCase,
|
|
61
65
|
readonly revertNotReadyAwaitingQualityCheckUseCase: RevertNotReadyAwaitingQualityCheckUseCase,
|
|
62
66
|
readonly updateRateLimitCacheUseCase: UpdateRateLimitCacheUseCase | null,
|
|
67
|
+
readonly dailySecurityScanUseCase: DailySecurityScanUseCase | null,
|
|
63
68
|
readonly dateRepository: DateRepository,
|
|
64
69
|
readonly spreadsheetRepository: SpreadsheetRepository,
|
|
65
70
|
readonly projectRepository: ProjectRepository,
|
|
@@ -97,6 +102,7 @@ export class HandleScheduledEventUseCase {
|
|
|
97
102
|
labelsAsLlmAgentName?: string[] | null;
|
|
98
103
|
} | null;
|
|
99
104
|
thresholdForAutoReject?: number;
|
|
105
|
+
dailySecurityScan?: DailySecurityScanConfig | null;
|
|
100
106
|
}): Promise<{
|
|
101
107
|
project: Project;
|
|
102
108
|
issues: Issue[];
|
|
@@ -291,6 +297,14 @@ ${JSON.stringify(e)}
|
|
|
291
297
|
allowIssueCacheMinutes: input.allowIssueCacheMinutes,
|
|
292
298
|
labelsAsLlmAgentName,
|
|
293
299
|
});
|
|
300
|
+
if (this.dailySecurityScanUseCase !== null && input.dailySecurityScan) {
|
|
301
|
+
await this.dailySecurityScanUseCase.run({
|
|
302
|
+
targetDates: targetDateTimes,
|
|
303
|
+
org: input.org,
|
|
304
|
+
manager: input.manager,
|
|
305
|
+
dailySecurityScan: input.dailySecurityScan,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
294
308
|
if (input.startPreparation) {
|
|
295
309
|
if (this.updateRateLimitCacheUseCase !== null) {
|
|
296
310
|
await this.updateRateLimitCacheUseCase.run({
|