@docupace/pipeline 1.0.122 → 1.0.123-xray.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/.gitignore +103 -0
- package/CHANGELOG.md +6 -0
- package/bitbucket-pipelines.yml +267 -0
- package/package.json +4 -2
- package/pnpm-workspace.yaml +3 -0
- package/src/changeset/validate.js +10 -0
- package/src/next/util.js +32 -3
- package/src/tenable/args.js +152 -0
- package/src/tenable/args.test.js +226 -0
- package/src/tenable/cli.js +24 -0
- package/src/tenable/docker.js +38 -0
- package/src/tenable/fsops.js +15 -0
- package/src/tenable/scan.js +249 -0
- package/src/tenable/scan.test.js +284 -0
- package/src/xray/cli.js +37 -0
- package/src/xray/notify.js +107 -0
- package/src/xray/notify.test.js +161 -0
- package/src/xray/report.js +194 -0
- package/src/xray/report.test.js +278 -0
- package/src/xray/slack.js +14 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, expect, jest, test } from '@jest/globals';
|
|
2
|
+
import { scanOne, runTenableScan } from './scan.js';
|
|
3
|
+
|
|
4
|
+
function makeFakeDocker(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
buildxAvailable: jest.fn().mockResolvedValue(true),
|
|
7
|
+
build: jest.fn().mockResolvedValue({ ok: true }),
|
|
8
|
+
save: jest.fn().mockResolvedValue({ ok: true }),
|
|
9
|
+
run: jest.fn().mockResolvedValue(0),
|
|
10
|
+
rmi: jest.fn().mockResolvedValue(undefined),
|
|
11
|
+
...overrides,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeFakeFsops(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
mkdtemp: jest.fn().mockReturnValue('/work/.tenable-work.abc'),
|
|
18
|
+
mkdirp: jest.fn(),
|
|
19
|
+
exists: jest.fn().mockReturnValue(false),
|
|
20
|
+
listDir: jest.fn().mockReturnValue([]),
|
|
21
|
+
copyReports: jest.fn(),
|
|
22
|
+
readFile: jest.fn().mockReturnValue(''),
|
|
23
|
+
removeSync: jest.fn(),
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const baseCtx = (overrides = {}) => ({
|
|
29
|
+
reportDir: 'tenable-report',
|
|
30
|
+
scannerImage: 'tenable/cloud-security-scanner:latest',
|
|
31
|
+
shortSha: 'abc1234',
|
|
32
|
+
imageNamePrefix: 'my-repo',
|
|
33
|
+
codeBranch: 'main',
|
|
34
|
+
commit: 'abc1234567',
|
|
35
|
+
codeUser: 'Matt Fields',
|
|
36
|
+
codeRepoUrl: 'https://bitbucket.org/dp-cloud/my-repo',
|
|
37
|
+
minSeverity: 'Medium',
|
|
38
|
+
runInfo: { includeInScanArgs: false },
|
|
39
|
+
failOnMinSeverity: '',
|
|
40
|
+
docker: makeFakeDocker(),
|
|
41
|
+
fsops: makeFakeFsops(),
|
|
42
|
+
log: jest.fn(),
|
|
43
|
+
...overrides,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('scanOne', () => {
|
|
47
|
+
test('builds, saves, scans and cleans up a normal target', async () => {
|
|
48
|
+
const docker = makeFakeDocker({ run: jest.fn().mockResolvedValue(0) });
|
|
49
|
+
const fsops = makeFakeFsops({
|
|
50
|
+
exists: jest.fn((p) => p === 'services/web/Dockerfile'),
|
|
51
|
+
listDir: jest.fn().mockReturnValue(['web-tenable.json']),
|
|
52
|
+
});
|
|
53
|
+
const ctx = baseCtx({ docker, fsops });
|
|
54
|
+
|
|
55
|
+
const result = await scanOne(
|
|
56
|
+
{
|
|
57
|
+
label: 'web',
|
|
58
|
+
slug: 'web',
|
|
59
|
+
buildContext: 'services/web',
|
|
60
|
+
dockerfile: 'services/web/Dockerfile',
|
|
61
|
+
},
|
|
62
|
+
ctx,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual({ slug: 'web', exitCode: 0 });
|
|
66
|
+
expect(docker.build).toHaveBeenCalledWith(
|
|
67
|
+
expect.arrayContaining([
|
|
68
|
+
'services/web',
|
|
69
|
+
'--file',
|
|
70
|
+
'services/web/Dockerfile',
|
|
71
|
+
'--tag',
|
|
72
|
+
'my-repo-web:tenable-abc1234',
|
|
73
|
+
]),
|
|
74
|
+
);
|
|
75
|
+
expect(docker.save).toHaveBeenCalledWith(
|
|
76
|
+
'my-repo-web:tenable-abc1234',
|
|
77
|
+
expect.stringContaining('image.tar'),
|
|
78
|
+
);
|
|
79
|
+
expect(docker.run).toHaveBeenCalled();
|
|
80
|
+
expect(fsops.copyReports).toHaveBeenCalled();
|
|
81
|
+
expect(docker.rmi).toHaveBeenCalledWith('my-repo-web:tenable-abc1234');
|
|
82
|
+
expect(fsops.removeSync).toHaveBeenCalledWith('/work/.tenable-work.abc');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('scans a pre-built image without building or removing anything', async () => {
|
|
86
|
+
const docker = makeFakeDocker();
|
|
87
|
+
const fsops = makeFakeFsops();
|
|
88
|
+
const ctx = baseCtx({ docker, fsops });
|
|
89
|
+
|
|
90
|
+
const result = await scanOne(
|
|
91
|
+
{ label: 'image', slug: 'image', prebuiltImage: 'my-image:tag' },
|
|
92
|
+
ctx,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(result.exitCode).toBe(0);
|
|
96
|
+
expect(docker.build).not.toHaveBeenCalled();
|
|
97
|
+
expect(docker.save).toHaveBeenCalledWith(
|
|
98
|
+
'my-image:tag',
|
|
99
|
+
expect.any(String),
|
|
100
|
+
);
|
|
101
|
+
expect(docker.rmi).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('skips a target with no Dockerfile at the expected path', async () => {
|
|
105
|
+
const docker = makeFakeDocker();
|
|
106
|
+
const fsops = makeFakeFsops({ exists: jest.fn().mockReturnValue(false) });
|
|
107
|
+
const ctx = baseCtx({ docker, fsops });
|
|
108
|
+
|
|
109
|
+
const result = await scanOne(
|
|
110
|
+
{
|
|
111
|
+
label: 'web',
|
|
112
|
+
slug: 'web',
|
|
113
|
+
buildContext: 'services/web',
|
|
114
|
+
dockerfile: 'services/web/Dockerfile',
|
|
115
|
+
},
|
|
116
|
+
ctx,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(result).toEqual({ slug: 'web', exitCode: 0, skipped: true });
|
|
120
|
+
expect(docker.build).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('returns exitCode 1 and skips the scan when the docker build fails', async () => {
|
|
124
|
+
const docker = makeFakeDocker({
|
|
125
|
+
build: jest.fn().mockResolvedValue({ ok: false }),
|
|
126
|
+
});
|
|
127
|
+
const fsops = makeFakeFsops({ exists: jest.fn().mockReturnValue(true) });
|
|
128
|
+
const ctx = baseCtx({ docker, fsops });
|
|
129
|
+
|
|
130
|
+
const result = await scanOne(
|
|
131
|
+
{
|
|
132
|
+
label: 'web',
|
|
133
|
+
slug: 'web',
|
|
134
|
+
buildContext: 'services/web',
|
|
135
|
+
dockerfile: 'services/web/Dockerfile',
|
|
136
|
+
},
|
|
137
|
+
ctx,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(result).toEqual({ slug: 'web', exitCode: 1 });
|
|
141
|
+
expect(docker.save).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('cleans up and returns exitCode 1 when docker save fails', async () => {
|
|
145
|
+
const docker = makeFakeDocker({
|
|
146
|
+
save: jest.fn().mockResolvedValue({ ok: false }),
|
|
147
|
+
});
|
|
148
|
+
const fsops = makeFakeFsops({ exists: jest.fn().mockReturnValue(true) });
|
|
149
|
+
const ctx = baseCtx({ docker, fsops });
|
|
150
|
+
|
|
151
|
+
const result = await scanOne(
|
|
152
|
+
{
|
|
153
|
+
label: 'web',
|
|
154
|
+
slug: 'web',
|
|
155
|
+
buildContext: 'services/web',
|
|
156
|
+
dockerfile: 'services/web/Dockerfile',
|
|
157
|
+
},
|
|
158
|
+
ctx,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(result).toEqual({ slug: 'web', exitCode: 1 });
|
|
162
|
+
expect(docker.run).not.toHaveBeenCalled();
|
|
163
|
+
expect(fsops.removeSync).toHaveBeenCalledWith('/work/.tenable-work.abc');
|
|
164
|
+
expect(docker.rmi).toHaveBeenCalledWith('my-repo-web:tenable-abc1234');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('propagates a non-zero scanner exit code (e.g. fail-on-min-severity threshold)', async () => {
|
|
168
|
+
const docker = makeFakeDocker({ run: jest.fn().mockResolvedValue(3) });
|
|
169
|
+
const fsops = makeFakeFsops({ exists: jest.fn().mockReturnValue(true) });
|
|
170
|
+
const ctx = baseCtx({ docker, fsops });
|
|
171
|
+
|
|
172
|
+
const result = await scanOne(
|
|
173
|
+
{
|
|
174
|
+
label: 'web',
|
|
175
|
+
slug: 'web',
|
|
176
|
+
buildContext: 'services/web',
|
|
177
|
+
dockerfile: 'services/web/Dockerfile',
|
|
178
|
+
},
|
|
179
|
+
ctx,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(result.exitCode).toBe(3);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('runTenableScan', () => {
|
|
187
|
+
test('skips entirely when credentials are missing', async () => {
|
|
188
|
+
const log = jest.fn();
|
|
189
|
+
const result = await runTenableScan({
|
|
190
|
+
tenableApiToken: '',
|
|
191
|
+
tenableApiUrl: '',
|
|
192
|
+
log,
|
|
193
|
+
});
|
|
194
|
+
expect(result).toEqual({ exitCode: 0, skipped: true, results: [] });
|
|
195
|
+
expect(log).toHaveBeenCalledWith(
|
|
196
|
+
expect.stringContaining('not set — skipping'),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('scans TENABLE_IMAGE directly, ignoring SCAN_TARGETS', async () => {
|
|
201
|
+
const docker = makeFakeDocker();
|
|
202
|
+
const fsops = makeFakeFsops();
|
|
203
|
+
const result = await runTenableScan({
|
|
204
|
+
tenableApiToken: 'token',
|
|
205
|
+
tenableApiUrl: 'https://tenable.example',
|
|
206
|
+
tenableImage: 'prebuilt:tag',
|
|
207
|
+
scanTargetsEnv: 'web:deps:services/web:Dockerfile',
|
|
208
|
+
docker,
|
|
209
|
+
fsops,
|
|
210
|
+
log: jest.fn(),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(result.exitCode).toBe(0);
|
|
214
|
+
expect(result.results).toHaveLength(1);
|
|
215
|
+
expect(docker.save).toHaveBeenCalledWith(
|
|
216
|
+
'prebuilt:tag',
|
|
217
|
+
expect.any(String),
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('scans every target in SCAN_TARGETS and reports the worst exit code', async () => {
|
|
222
|
+
const fsops = makeFakeFsops({ exists: jest.fn().mockReturnValue(true) });
|
|
223
|
+
const docker = makeFakeDocker({
|
|
224
|
+
run: jest.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(2),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const result = await runTenableScan({
|
|
228
|
+
tenableApiToken: 'token',
|
|
229
|
+
tenableApiUrl: 'https://tenable.example',
|
|
230
|
+
scanTargetsEnv:
|
|
231
|
+
'web:deps:services/web:Dockerfile;worker:deps:services/worker:Dockerfile',
|
|
232
|
+
docker,
|
|
233
|
+
fsops,
|
|
234
|
+
log: jest.fn(),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(result.results.map((r) => r.slug)).toEqual(['web', 'worker']);
|
|
238
|
+
expect(result.exitCode).toBe(2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('logs and skips a target with no build context, without failing the run', async () => {
|
|
242
|
+
const log = jest.fn();
|
|
243
|
+
const fsops = makeFakeFsops({ exists: jest.fn().mockReturnValue(true) });
|
|
244
|
+
const docker = makeFakeDocker();
|
|
245
|
+
|
|
246
|
+
const result = await runTenableScan({
|
|
247
|
+
tenableApiToken: 'token',
|
|
248
|
+
tenableApiUrl: 'https://tenable.example',
|
|
249
|
+
scanTargetsEnv: 'web:deps::Dockerfile',
|
|
250
|
+
docker,
|
|
251
|
+
fsops,
|
|
252
|
+
log,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(result.exitCode).toBe(0);
|
|
256
|
+
expect(result.results).toHaveLength(0);
|
|
257
|
+
expect(log).toHaveBeenCalledWith(
|
|
258
|
+
expect.stringContaining('no build context'),
|
|
259
|
+
);
|
|
260
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining('nothing to do'));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('falls back to a single default target when SCAN_TARGETS is unset', async () => {
|
|
264
|
+
const log = jest.fn();
|
|
265
|
+
const fsops = makeFakeFsops({ exists: jest.fn().mockReturnValue(true) });
|
|
266
|
+
const docker = makeFakeDocker();
|
|
267
|
+
|
|
268
|
+
const result = await runTenableScan({
|
|
269
|
+
tenableApiToken: 'token',
|
|
270
|
+
tenableApiUrl: 'https://tenable.example',
|
|
271
|
+
scanTargetsEnv: '',
|
|
272
|
+
buildContextFallback: '.',
|
|
273
|
+
dockerfileFallback: './Dockerfile',
|
|
274
|
+
docker,
|
|
275
|
+
fsops,
|
|
276
|
+
log,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(result.results).toEqual([{ slug: 'app', exitCode: 0 }]);
|
|
280
|
+
expect(log).toHaveBeenCalledWith(
|
|
281
|
+
expect.stringContaining('SCAN_TARGETS not set'),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
});
|
package/src/xray/cli.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { notify } from './notify.js';
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.command('notify')
|
|
10
|
+
.description(
|
|
11
|
+
'Post Xray findings to Slack (parent summary + one thread reply per finding)',
|
|
12
|
+
)
|
|
13
|
+
.option(
|
|
14
|
+
'-f, --report-args-file <path>',
|
|
15
|
+
'Path to the "label:file" report-args file',
|
|
16
|
+
)
|
|
17
|
+
.option(
|
|
18
|
+
'-m, --min-severity <severity>',
|
|
19
|
+
'Lowest severity to thread (Critical|High|Medium|Low)',
|
|
20
|
+
)
|
|
21
|
+
.option('-c, --notify-clean', 'Post even when there are zero findings')
|
|
22
|
+
.action(async (options) => {
|
|
23
|
+
try {
|
|
24
|
+
await notify({
|
|
25
|
+
...(options.reportArgsFile && {
|
|
26
|
+
reportArgsFile: options.reportArgsFile,
|
|
27
|
+
}),
|
|
28
|
+
...(options.minSeverity && { minSeverity: options.minSeverity }),
|
|
29
|
+
...(options.notifyClean && { notifyClean: true }),
|
|
30
|
+
});
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(error.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import {
|
|
3
|
+
extractFindings,
|
|
4
|
+
countBySeverity,
|
|
5
|
+
buildBuildLink,
|
|
6
|
+
buildParentPayload,
|
|
7
|
+
buildThreadPayload,
|
|
8
|
+
parseReportArgs,
|
|
9
|
+
rankOf,
|
|
10
|
+
sortBySeverity,
|
|
11
|
+
} from './report.js';
|
|
12
|
+
import { postMessage } from './slack.js';
|
|
13
|
+
|
|
14
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
|
|
16
|
+
// Orchestrates the full bin/xray.sh flow: read the report-args file, collect
|
|
17
|
+
// findings from each simple-json scan file, post a Slack summary, then thread
|
|
18
|
+
// one message per finding at or above minSeverity. Dependencies are
|
|
19
|
+
// injectable so this can be unit tested without touching fs or the network.
|
|
20
|
+
export async function notify({
|
|
21
|
+
reportArgsFile = process.env.XRAY_REPORT_ARGS_FILE || '/tmp/xray-report-args',
|
|
22
|
+
minSeverity = process.env.XRAY_SLACK_MIN_SEVERITY || 'High',
|
|
23
|
+
notifyClean = process.env.XRAY_SLACK_NOTIFY_CLEAN === 'true',
|
|
24
|
+
channel = process.env.XRAY_SLACK_CHANNEL,
|
|
25
|
+
token = process.env.XRAY_SLACK_TOKEN,
|
|
26
|
+
project = process.env.BITBUCKET_REPO_SLUG || 'unknown',
|
|
27
|
+
branch = process.env.BITBUCKET_BRANCH || '',
|
|
28
|
+
buildLink = buildBuildLink({
|
|
29
|
+
workspace: process.env.BITBUCKET_WORKSPACE,
|
|
30
|
+
repoSlug: process.env.BITBUCKET_REPO_SLUG,
|
|
31
|
+
buildNumber: process.env.BITBUCKET_BUILD_NUMBER,
|
|
32
|
+
}),
|
|
33
|
+
readFile = (path) => fs.readFileSync(path, 'utf8'),
|
|
34
|
+
post = postMessage,
|
|
35
|
+
log = console.log,
|
|
36
|
+
delay = sleep,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const minRank = rankOf(minSeverity);
|
|
39
|
+
|
|
40
|
+
let reportArgsContent;
|
|
41
|
+
try {
|
|
42
|
+
reportArgsContent = readFile(reportArgsFile);
|
|
43
|
+
} catch {
|
|
44
|
+
log(
|
|
45
|
+
`No scan results file at ${reportArgsFile} — skipping Slack notification.`,
|
|
46
|
+
);
|
|
47
|
+
return { posted: 0, skipped: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const findings = [];
|
|
51
|
+
for (const { label, file } of parseReportArgs(reportArgsContent)) {
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = readFile(file);
|
|
55
|
+
} catch {
|
|
56
|
+
log(`Skipping ${label} (${file}): not produced or empty.`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let data;
|
|
60
|
+
try {
|
|
61
|
+
data = JSON.parse(raw);
|
|
62
|
+
} catch {
|
|
63
|
+
log(`Skipping ${label} (${file}): not produced or empty.`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
findings.push(...extractFindings(data, label));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const counts = countBySeverity(findings);
|
|
70
|
+
log(
|
|
71
|
+
`📊 Counts: Critical=${counts.Critical}, High=${counts.High}, Medium=${counts.Medium}, Low=${counts.Low}, Total=${counts.total}`,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (counts.total === 0 && !notifyClean) {
|
|
75
|
+
log('✅ No vulnerabilities found, skipping Slack notification.');
|
|
76
|
+
return { posted: 0, skipped: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parentPayload = buildParentPayload({
|
|
80
|
+
channel,
|
|
81
|
+
project,
|
|
82
|
+
branch,
|
|
83
|
+
buildLink,
|
|
84
|
+
counts,
|
|
85
|
+
});
|
|
86
|
+
const parentResponse = await post(parentPayload, token);
|
|
87
|
+
const threadTs = parentResponse?.ts;
|
|
88
|
+
|
|
89
|
+
if (!threadTs) {
|
|
90
|
+
log('❌ Failed to create parent Slack message:');
|
|
91
|
+
log(JSON.stringify(parentResponse));
|
|
92
|
+
throw new Error('Failed to create parent Slack message');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
log(`✅ Parent message created. thread_ts=${threadTs}`);
|
|
96
|
+
|
|
97
|
+
let posted = 0;
|
|
98
|
+
for (const finding of sortBySeverity(findings)) {
|
|
99
|
+
if (finding.rank > minRank) continue;
|
|
100
|
+
await post(buildThreadPayload({ channel, threadTs, finding }), token);
|
|
101
|
+
posted += 1;
|
|
102
|
+
await delay(100);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
log(`✅ Posted ${posted} finding(s) to thread.`);
|
|
106
|
+
return { posted, skipped: false, threadTs };
|
|
107
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, jest, test } from '@jest/globals';
|
|
2
|
+
import { notify } from './notify.js';
|
|
3
|
+
|
|
4
|
+
function makeReadFile(files) {
|
|
5
|
+
return (path) => {
|
|
6
|
+
if (!(path in files)) {
|
|
7
|
+
const err = new Error(`ENOENT: ${path}`);
|
|
8
|
+
err.code = 'ENOENT';
|
|
9
|
+
throw err;
|
|
10
|
+
}
|
|
11
|
+
return files[path];
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('notify', () => {
|
|
16
|
+
test('skips Slack entirely when the report-args file is missing', async () => {
|
|
17
|
+
const log = jest.fn();
|
|
18
|
+
const post = jest.fn();
|
|
19
|
+
const result = await notify({
|
|
20
|
+
reportArgsFile: '/tmp/does-not-exist',
|
|
21
|
+
readFile: makeReadFile({}),
|
|
22
|
+
post,
|
|
23
|
+
log,
|
|
24
|
+
delay: jest.fn(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(result).toEqual({ posted: 0, skipped: true });
|
|
28
|
+
expect(post).not.toHaveBeenCalled();
|
|
29
|
+
expect(log).toHaveBeenCalledWith(
|
|
30
|
+
expect.stringContaining('No scan results file'),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('skips a scan file that is missing or invalid JSON, continues with the rest', async () => {
|
|
35
|
+
const files = {
|
|
36
|
+
'/tmp/report-args':
|
|
37
|
+
'bad:xray-report/bad.json\ngood:xray-report/good.json\n',
|
|
38
|
+
'xray-report/bad.json': 'not json',
|
|
39
|
+
'xray-report/good.json': JSON.stringify({
|
|
40
|
+
vulnerabilities: [{ severity: 'Critical', issueId: 'X-1' }],
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
const post = jest.fn().mockResolvedValue({ ts: '111.222' });
|
|
44
|
+
const log = jest.fn();
|
|
45
|
+
|
|
46
|
+
const result = await notify({
|
|
47
|
+
reportArgsFile: '/tmp/report-args',
|
|
48
|
+
readFile: makeReadFile(files),
|
|
49
|
+
post,
|
|
50
|
+
log,
|
|
51
|
+
delay: jest.fn(),
|
|
52
|
+
channel: '#c',
|
|
53
|
+
token: 'xoxb-x',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining('Skipping bad'));
|
|
57
|
+
expect(result.posted).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('does not notify Slack when there are zero findings and notifyClean is false', async () => {
|
|
61
|
+
const files = {
|
|
62
|
+
'/tmp/report-args': 'clean:xray-report/clean.json\n',
|
|
63
|
+
'xray-report/clean.json': JSON.stringify({}),
|
|
64
|
+
};
|
|
65
|
+
const post = jest.fn();
|
|
66
|
+
|
|
67
|
+
const result = await notify({
|
|
68
|
+
reportArgsFile: '/tmp/report-args',
|
|
69
|
+
readFile: makeReadFile(files),
|
|
70
|
+
post,
|
|
71
|
+
log: jest.fn(),
|
|
72
|
+
delay: jest.fn(),
|
|
73
|
+
notifyClean: false,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result).toEqual({ posted: 0, skipped: true });
|
|
77
|
+
expect(post).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('posts a clean parent message when notifyClean is true even with zero findings', async () => {
|
|
81
|
+
const files = {
|
|
82
|
+
'/tmp/report-args': 'clean:xray-report/clean.json\n',
|
|
83
|
+
'xray-report/clean.json': JSON.stringify({}),
|
|
84
|
+
};
|
|
85
|
+
const post = jest.fn().mockResolvedValue({ ts: '111.222' });
|
|
86
|
+
|
|
87
|
+
const result = await notify({
|
|
88
|
+
reportArgsFile: '/tmp/report-args',
|
|
89
|
+
readFile: makeReadFile(files),
|
|
90
|
+
post,
|
|
91
|
+
log: jest.fn(),
|
|
92
|
+
delay: jest.fn(),
|
|
93
|
+
notifyClean: true,
|
|
94
|
+
channel: '#c',
|
|
95
|
+
token: 'xoxb-x',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(result).toEqual({ posted: 0, skipped: false, threadTs: '111.222' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('throws when Slack fails to create the parent message', async () => {
|
|
103
|
+
const files = {
|
|
104
|
+
'/tmp/report-args': 'scan:xray-report/x.json\n',
|
|
105
|
+
'xray-report/x.json': JSON.stringify({
|
|
106
|
+
vulnerabilities: [{ severity: 'Critical' }],
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
const post = jest
|
|
110
|
+
.fn()
|
|
111
|
+
.mockResolvedValue({ ok: false, error: 'invalid_auth' });
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
notify({
|
|
115
|
+
reportArgsFile: '/tmp/report-args',
|
|
116
|
+
readFile: makeReadFile(files),
|
|
117
|
+
post,
|
|
118
|
+
log: jest.fn(),
|
|
119
|
+
delay: jest.fn(),
|
|
120
|
+
}),
|
|
121
|
+
).rejects.toThrow('Failed to create parent Slack message');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('only threads findings at or above minSeverity, sorted Critical -> Low', async () => {
|
|
125
|
+
const files = {
|
|
126
|
+
'/tmp/report-args': 'scan:xray-report/x.json\n',
|
|
127
|
+
'xray-report/x.json': JSON.stringify({
|
|
128
|
+
vulnerabilities: [
|
|
129
|
+
{ severity: 'Low', issueId: 'L-1' },
|
|
130
|
+
{ severity: 'Critical', issueId: 'C-1' },
|
|
131
|
+
{ severity: 'Medium', issueId: 'M-1' },
|
|
132
|
+
],
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
const post = jest
|
|
136
|
+
.fn()
|
|
137
|
+
.mockResolvedValueOnce({ ts: '111.222' }) // parent
|
|
138
|
+
.mockResolvedValue({ ts: '111.222' }); // thread replies
|
|
139
|
+
const delay = jest.fn().mockResolvedValue();
|
|
140
|
+
|
|
141
|
+
const result = await notify({
|
|
142
|
+
reportArgsFile: '/tmp/report-args',
|
|
143
|
+
readFile: makeReadFile(files),
|
|
144
|
+
post,
|
|
145
|
+
log: jest.fn(),
|
|
146
|
+
delay,
|
|
147
|
+
minSeverity: 'Medium',
|
|
148
|
+
channel: '#c',
|
|
149
|
+
token: 'xoxb-x',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// parent + Critical + Medium, Low excluded
|
|
153
|
+
expect(post).toHaveBeenCalledTimes(3);
|
|
154
|
+
expect(result.posted).toBe(2);
|
|
155
|
+
const threadedIds = post.mock.calls
|
|
156
|
+
.slice(1)
|
|
157
|
+
.map(([payload]) => payload.blocks[0].text.text);
|
|
158
|
+
expect(threadedIds[0]).toContain('*Critical*');
|
|
159
|
+
expect(threadedIds[1]).toContain('*Medium*');
|
|
160
|
+
});
|
|
161
|
+
});
|