@docupace/pipeline 1.0.127 → 1.0.130
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/.gitleaksignore +9 -0
- package/CHANGELOG.md +8 -0
- package/bin/tenable-scan.sh +12 -3
- package/bitbucket-pipelines.yml +91 -0
- package/package.json +6 -3
- package/pnpm-workspace.yaml +3 -0
- package/src/changeset/validate.js +18 -0
- package/src/next/config.js +1 -1
- package/src/next/deploy.test.js +5 -0
- package/src/next/util.js +45 -3
- package/src/tenable/args.js +157 -0
- package/src/tenable/args.test.js +226 -0
- package/src/tenable/cli.js +24 -0
- package/src/tenable/docker.js +45 -0
- package/src/tenable/fsops.js +15 -0
- package/src/tenable/scan.js +284 -0
- package/src/tenable/scan.test.js +355 -0
- package/src/veracode/args.js +67 -0
- package/src/veracode/args.test.js +137 -0
- package/src/veracode/cli.js +16 -0
- package/src/veracode/fsops.js +7 -0
- package/src/veracode/scan.js +145 -0
- package/src/veracode/scan.test.js +293 -0
- package/src/veracode/veracode.js +45 -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
- package/.gitignore +0 -103
- package/testCanny.sh +0 -4
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import {
|
|
3
|
+
buildUploadArgs,
|
|
4
|
+
buildVersion,
|
|
5
|
+
buildZipFilename,
|
|
6
|
+
parseVeracodeTargets,
|
|
7
|
+
shortSha as computeShortSha,
|
|
8
|
+
} from './args.js';
|
|
9
|
+
import { veracode as defaultVeracode } from './veracode.js';
|
|
10
|
+
import { fsOps as defaultFsOps } from './fsops.js';
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_WRAPPER_JAR = 'veracode-wrapper/VeracodeJavaAPI.jar';
|
|
13
|
+
export const DEFAULT_WRAPPER_URL =
|
|
14
|
+
'https://repo1.maven.org/maven2/com/veracode/vosp/api/wrappers/vosp-api-wrappers-java/26.3.19.0/vosp-api-wrappers-java-26.3.19.0.jar';
|
|
15
|
+
|
|
16
|
+
// Zips one target's paths and submits them as their own uploadandscan call,
|
|
17
|
+
// so one repo with several apps (e.g. a frontend and a backend) gets separate
|
|
18
|
+
// Veracode profiles instead of one scan covering everything.
|
|
19
|
+
export async function scanOne(target, ctx) {
|
|
20
|
+
const { vid, vkey, shortSha, wrapperJar, veracode, fsops, log } = ctx;
|
|
21
|
+
const { label, appname, paths, slug, skipped } = target;
|
|
22
|
+
|
|
23
|
+
if (skipped) {
|
|
24
|
+
log(
|
|
25
|
+
`Target '${label}' is missing an appname or paths in VERACODE_TARGETS — skipping.`,
|
|
26
|
+
);
|
|
27
|
+
return { label, slug, exitCode: 1 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const zipFile = buildZipFilename(slug);
|
|
31
|
+
fsops.removeSync(zipFile);
|
|
32
|
+
|
|
33
|
+
const zipped = await veracode.zip(zipFile, paths);
|
|
34
|
+
if (!zipped.ok) {
|
|
35
|
+
log(`Failed to zip paths for '${label}' — skipping its Veracode scan.`);
|
|
36
|
+
fsops.removeSync(zipFile);
|
|
37
|
+
return { label, slug, exitCode: 1 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const version = buildVersion(shortSha, label);
|
|
41
|
+
const args = buildUploadArgs({
|
|
42
|
+
vid,
|
|
43
|
+
vkey,
|
|
44
|
+
appname,
|
|
45
|
+
filepath: zipFile,
|
|
46
|
+
version,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const uploaded = await veracode.uploadAndScan(wrapperJar, args);
|
|
50
|
+
fsops.removeSync(zipFile);
|
|
51
|
+
|
|
52
|
+
if (!uploaded.ok) {
|
|
53
|
+
log(`ERROR: Veracode scan submission failed for '${label}'.`);
|
|
54
|
+
return { label, slug, exitCode: 1 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { label, slug, exitCode: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Commands the step shells out to on the host, rather than inside a container.
|
|
61
|
+
export const REQUIRED_COMMANDS = ['java', 'zip'];
|
|
62
|
+
|
|
63
|
+
export async function runVeracodeScan({
|
|
64
|
+
vid = process.env.VERACODE_API_ID || '',
|
|
65
|
+
vkey = process.env.VERACODE_API_KEY || '',
|
|
66
|
+
targetsEnv = process.env.VERACODE_TARGETS || '',
|
|
67
|
+
requireCredentials = process.env.VERACODE_REQUIRE_CREDENTIALS === 'true',
|
|
68
|
+
buildCmd = process.env.VERACODE_BUILD_CMD || '',
|
|
69
|
+
commit = process.env.BITBUCKET_COMMIT || 'local',
|
|
70
|
+
wrapperJar = process.env.VERACODE_WRAPPER_JAR || DEFAULT_WRAPPER_JAR,
|
|
71
|
+
wrapperUrl = process.env.VERACODE_WRAPPER_URL || DEFAULT_WRAPPER_URL,
|
|
72
|
+
veracode = defaultVeracode,
|
|
73
|
+
fsops = defaultFsOps,
|
|
74
|
+
log = console.log,
|
|
75
|
+
} = {}) {
|
|
76
|
+
// Absent credentials mean the repo or runner was never set up for Veracode,
|
|
77
|
+
// which is not the same thing as a broken configuration: skip, the way the
|
|
78
|
+
// Tenable step does, so a consumer can add the step before the credentials
|
|
79
|
+
// exist. VERACODE_REQUIRE_CREDENTIALS=true turns it back into a hard failure
|
|
80
|
+
// once a repo expects the scan to run on every build.
|
|
81
|
+
if (!vid || !vkey) {
|
|
82
|
+
if (requireCredentials) {
|
|
83
|
+
log(
|
|
84
|
+
'ERROR: VERACODE_API_ID / VERACODE_API_KEY not set and VERACODE_REQUIRE_CREDENTIALS=true — failing.',
|
|
85
|
+
);
|
|
86
|
+
return { exitCode: 1, skipped: false, results: [] };
|
|
87
|
+
}
|
|
88
|
+
log('VERACODE_API_ID / VERACODE_API_KEY not set — skipping Veracode scan.');
|
|
89
|
+
return { exitCode: 0, skipped: true, results: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Credentials present but no targets is a broken configuration, so this stays
|
|
93
|
+
// a hard failure however the credentials were supplied.
|
|
94
|
+
if (!targetsEnv) {
|
|
95
|
+
log('ERROR: VERACODE_TARGETS not set — cannot run Veracode scan.');
|
|
96
|
+
log(
|
|
97
|
+
' Expected: label:appname:path1,path2,...[;label2:appname2:path1,...]',
|
|
98
|
+
);
|
|
99
|
+
return { exitCode: 1, skipped: false, results: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const cmd of REQUIRED_COMMANDS) {
|
|
103
|
+
if (!(await veracode.commandExists(cmd))) {
|
|
104
|
+
log(
|
|
105
|
+
`ERROR: ${cmd} is not on PATH — the Veracode step packages and uploads with it.`,
|
|
106
|
+
);
|
|
107
|
+
return { exitCode: 1, skipped: false, results: [] };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// VERACODE_BUILD_CMD lets a consumer produce whatever build artifacts
|
|
112
|
+
// Veracode's SAST needs (compiled bundles, etc.) without a separate
|
|
113
|
+
// pre-build step in the consumer's own pipeline. Its exit code isn't
|
|
114
|
+
// gated on here, matching the pipeline step it's ported from — a failure
|
|
115
|
+
// will simply surface as a failed upload for every target below.
|
|
116
|
+
if (buildCmd) {
|
|
117
|
+
log('Running VERACODE_BUILD_CMD...');
|
|
118
|
+
await veracode.runBuildCmd(buildCmd);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Cached across builds on self-hosted runners; downloaded once.
|
|
122
|
+
if (!fsops.exists(wrapperJar)) {
|
|
123
|
+
fsops.mkdirp(path.dirname(wrapperJar));
|
|
124
|
+
await veracode.download(wrapperUrl, wrapperJar);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const sha = computeShortSha(commit);
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const target of parseVeracodeTargets(targetsEnv)) {
|
|
130
|
+
results.push(
|
|
131
|
+
await scanOne(target, {
|
|
132
|
+
vid,
|
|
133
|
+
vkey,
|
|
134
|
+
shortSha: sha,
|
|
135
|
+
wrapperJar,
|
|
136
|
+
veracode,
|
|
137
|
+
fsops,
|
|
138
|
+
log,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const exitCode = results.some((r) => r.exitCode !== 0) ? 1 : 0;
|
|
144
|
+
return { exitCode, skipped: false, results };
|
|
145
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, expect, jest, test } from '@jest/globals';
|
|
2
|
+
import { scanOne, runVeracodeScan, DEFAULT_WRAPPER_JAR } from './scan.js';
|
|
3
|
+
|
|
4
|
+
function makeFakeVeracode(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
commandExists: jest.fn().mockResolvedValue(true),
|
|
7
|
+
download: jest.fn().mockResolvedValue({ ok: true }),
|
|
8
|
+
runBuildCmd: jest.fn().mockResolvedValue({ ok: true }),
|
|
9
|
+
zip: jest.fn().mockResolvedValue({ ok: true }),
|
|
10
|
+
uploadAndScan: jest.fn().mockResolvedValue({ ok: true }),
|
|
11
|
+
...overrides,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeFakeFsops(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
exists: jest.fn().mockReturnValue(true),
|
|
18
|
+
mkdirp: jest.fn(),
|
|
19
|
+
removeSync: jest.fn(),
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const baseCtx = (overrides = {}) => ({
|
|
25
|
+
vid: 'vid-1',
|
|
26
|
+
vkey: 'vkey-1',
|
|
27
|
+
shortSha: 'abc1234',
|
|
28
|
+
wrapperJar: DEFAULT_WRAPPER_JAR,
|
|
29
|
+
veracode: makeFakeVeracode(),
|
|
30
|
+
fsops: makeFakeFsops(),
|
|
31
|
+
log: jest.fn(),
|
|
32
|
+
...overrides,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('scanOne', () => {
|
|
36
|
+
test('zips, uploads and cleans up a normal target', async () => {
|
|
37
|
+
const veracode = makeFakeVeracode();
|
|
38
|
+
const fsops = makeFakeFsops();
|
|
39
|
+
const ctx = baseCtx({ veracode, fsops });
|
|
40
|
+
|
|
41
|
+
const result = await scanOne(
|
|
42
|
+
{
|
|
43
|
+
label: 'web',
|
|
44
|
+
slug: 'web',
|
|
45
|
+
appname: 'my-app',
|
|
46
|
+
paths: ['services/web'],
|
|
47
|
+
skipped: false,
|
|
48
|
+
},
|
|
49
|
+
ctx,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({ label: 'web', slug: 'web', exitCode: 0 });
|
|
53
|
+
expect(veracode.zip).toHaveBeenCalledWith('veracode-web.zip', [
|
|
54
|
+
'services/web',
|
|
55
|
+
]);
|
|
56
|
+
expect(veracode.uploadAndScan).toHaveBeenCalledWith(
|
|
57
|
+
DEFAULT_WRAPPER_JAR,
|
|
58
|
+
expect.arrayContaining([
|
|
59
|
+
'-appname',
|
|
60
|
+
'my-app',
|
|
61
|
+
'-version',
|
|
62
|
+
'sha-abc1234-web',
|
|
63
|
+
]),
|
|
64
|
+
);
|
|
65
|
+
expect(fsops.removeSync).toHaveBeenCalledWith('veracode-web.zip');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('skips a target missing an appname or paths', async () => {
|
|
69
|
+
const veracode = makeFakeVeracode();
|
|
70
|
+
const ctx = baseCtx({ veracode });
|
|
71
|
+
|
|
72
|
+
const result = await scanOne(
|
|
73
|
+
{ label: 'web', slug: 'web', appname: '', paths: [], skipped: true },
|
|
74
|
+
ctx,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual({ label: 'web', slug: 'web', exitCode: 1 });
|
|
78
|
+
expect(veracode.zip).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('returns exitCode 1 and skips upload when zipping fails', async () => {
|
|
82
|
+
const veracode = makeFakeVeracode({
|
|
83
|
+
zip: jest.fn().mockResolvedValue({ ok: false }),
|
|
84
|
+
});
|
|
85
|
+
const fsops = makeFakeFsops();
|
|
86
|
+
const ctx = baseCtx({ veracode, fsops });
|
|
87
|
+
|
|
88
|
+
const result = await scanOne(
|
|
89
|
+
{
|
|
90
|
+
label: 'web',
|
|
91
|
+
slug: 'web',
|
|
92
|
+
appname: 'my-app',
|
|
93
|
+
paths: ['services/web'],
|
|
94
|
+
skipped: false,
|
|
95
|
+
},
|
|
96
|
+
ctx,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(result).toEqual({ label: 'web', slug: 'web', exitCode: 1 });
|
|
100
|
+
expect(veracode.uploadAndScan).not.toHaveBeenCalled();
|
|
101
|
+
expect(fsops.removeSync).toHaveBeenCalledWith('veracode-web.zip');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('returns exitCode 1 when the upload fails, but still cleans up the zip', async () => {
|
|
105
|
+
const veracode = makeFakeVeracode({
|
|
106
|
+
uploadAndScan: jest.fn().mockResolvedValue({ ok: false }),
|
|
107
|
+
});
|
|
108
|
+
const fsops = makeFakeFsops();
|
|
109
|
+
const ctx = baseCtx({ veracode, fsops });
|
|
110
|
+
|
|
111
|
+
const result = await scanOne(
|
|
112
|
+
{
|
|
113
|
+
label: 'web',
|
|
114
|
+
slug: 'web',
|
|
115
|
+
appname: 'my-app',
|
|
116
|
+
paths: ['services/web'],
|
|
117
|
+
skipped: false,
|
|
118
|
+
},
|
|
119
|
+
ctx,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(result).toEqual({ label: 'web', slug: 'web', exitCode: 1 });
|
|
123
|
+
expect(fsops.removeSync).toHaveBeenCalledWith('veracode-web.zip');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('runVeracodeScan', () => {
|
|
128
|
+
test('skips without failing when credentials are missing', async () => {
|
|
129
|
+
const log = jest.fn();
|
|
130
|
+
const veracode = makeFakeVeracode();
|
|
131
|
+
const result = await runVeracodeScan({
|
|
132
|
+
vid: '',
|
|
133
|
+
vkey: '',
|
|
134
|
+
targetsEnv: 'web:my-app:src',
|
|
135
|
+
veracode,
|
|
136
|
+
log,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(result).toEqual({ exitCode: 0, skipped: true, results: [] });
|
|
140
|
+
expect(veracode.zip).not.toHaveBeenCalled();
|
|
141
|
+
expect(log).toHaveBeenCalledWith(
|
|
142
|
+
expect.stringContaining('VERACODE_API_ID / VERACODE_API_KEY not set'),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('fails on missing credentials when VERACODE_REQUIRE_CREDENTIALS is set', async () => {
|
|
147
|
+
const log = jest.fn();
|
|
148
|
+
const result = await runVeracodeScan({
|
|
149
|
+
vid: '',
|
|
150
|
+
vkey: '',
|
|
151
|
+
targetsEnv: 'web:my-app:src',
|
|
152
|
+
requireCredentials: true,
|
|
153
|
+
veracode: makeFakeVeracode(),
|
|
154
|
+
log,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result).toEqual({ exitCode: 1, skipped: false, results: [] });
|
|
158
|
+
expect(log).toHaveBeenCalledWith(
|
|
159
|
+
expect.stringContaining('VERACODE_REQUIRE_CREDENTIALS=true'),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('fails fast when VERACODE_TARGETS is missing', async () => {
|
|
164
|
+
const log = jest.fn();
|
|
165
|
+
const result = await runVeracodeScan({
|
|
166
|
+
vid: 'vid-1',
|
|
167
|
+
vkey: 'vkey-1',
|
|
168
|
+
targetsEnv: '',
|
|
169
|
+
veracode: makeFakeVeracode(),
|
|
170
|
+
log,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(result).toEqual({ exitCode: 1, skipped: false, results: [] });
|
|
174
|
+
expect(log).toHaveBeenCalledWith(
|
|
175
|
+
expect.stringContaining('VERACODE_TARGETS not set'),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test.each(['java', 'zip'])(
|
|
180
|
+
'fails before packaging when %s is not on PATH',
|
|
181
|
+
async (missing) => {
|
|
182
|
+
const log = jest.fn();
|
|
183
|
+
const veracode = makeFakeVeracode({
|
|
184
|
+
commandExists: jest.fn(async (cmd) => cmd !== missing),
|
|
185
|
+
});
|
|
186
|
+
const result = await runVeracodeScan({
|
|
187
|
+
vid: 'vid-1',
|
|
188
|
+
vkey: 'vkey-1',
|
|
189
|
+
targetsEnv: 'web:my-app:src',
|
|
190
|
+
veracode,
|
|
191
|
+
fsops: makeFakeFsops(),
|
|
192
|
+
log,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(result).toEqual({ exitCode: 1, skipped: false, results: [] });
|
|
196
|
+
expect(veracode.zip).not.toHaveBeenCalled();
|
|
197
|
+
expect(veracode.download).not.toHaveBeenCalled();
|
|
198
|
+
expect(log).toHaveBeenCalledWith(
|
|
199
|
+
expect.stringContaining(`${missing} is not on PATH`),
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
test('runs VERACODE_BUILD_CMD before packaging when set', async () => {
|
|
205
|
+
const veracode = makeFakeVeracode();
|
|
206
|
+
const fsops = makeFakeFsops();
|
|
207
|
+
|
|
208
|
+
await runVeracodeScan({
|
|
209
|
+
vid: 'vid-1',
|
|
210
|
+
vkey: 'vkey-1',
|
|
211
|
+
targetsEnv: 'web:my-app:src',
|
|
212
|
+
buildCmd: 'npm run build',
|
|
213
|
+
veracode,
|
|
214
|
+
fsops,
|
|
215
|
+
log: jest.fn(),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(veracode.runBuildCmd).toHaveBeenCalledWith('npm run build');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('downloads the wrapper jar only when not already cached', async () => {
|
|
222
|
+
const veracode = makeFakeVeracode();
|
|
223
|
+
const fsops = makeFakeFsops({ exists: jest.fn().mockReturnValue(false) });
|
|
224
|
+
|
|
225
|
+
await runVeracodeScan({
|
|
226
|
+
vid: 'vid-1',
|
|
227
|
+
vkey: 'vkey-1',
|
|
228
|
+
targetsEnv: 'web:my-app:src',
|
|
229
|
+
veracode,
|
|
230
|
+
fsops,
|
|
231
|
+
log: jest.fn(),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(veracode.download).toHaveBeenCalledWith(
|
|
235
|
+
expect.stringContaining('vosp-api-wrappers-java'),
|
|
236
|
+
DEFAULT_WRAPPER_JAR,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('skips the download when the wrapper jar already exists', async () => {
|
|
241
|
+
const veracode = makeFakeVeracode();
|
|
242
|
+
const fsops = makeFakeFsops({ exists: jest.fn().mockReturnValue(true) });
|
|
243
|
+
|
|
244
|
+
await runVeracodeScan({
|
|
245
|
+
vid: 'vid-1',
|
|
246
|
+
vkey: 'vkey-1',
|
|
247
|
+
targetsEnv: 'web:my-app:src',
|
|
248
|
+
veracode,
|
|
249
|
+
fsops,
|
|
250
|
+
log: jest.fn(),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(veracode.download).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('scans every target and reports the worst outcome', async () => {
|
|
257
|
+
const veracode = makeFakeVeracode({
|
|
258
|
+
uploadAndScan: jest
|
|
259
|
+
.fn()
|
|
260
|
+
.mockResolvedValueOnce({ ok: true })
|
|
261
|
+
.mockResolvedValueOnce({ ok: false }),
|
|
262
|
+
});
|
|
263
|
+
const fsops = makeFakeFsops();
|
|
264
|
+
|
|
265
|
+
const result = await runVeracodeScan({
|
|
266
|
+
vid: 'vid-1',
|
|
267
|
+
vkey: 'vkey-1',
|
|
268
|
+
targetsEnv: 'web:my-app:apps/web;worker:my-worker:apps/worker',
|
|
269
|
+
veracode,
|
|
270
|
+
fsops,
|
|
271
|
+
log: jest.fn(),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(result.results.map((r) => r.label)).toEqual(['web', 'worker']);
|
|
275
|
+
expect(result.exitCode).toBe(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('exits 0 when every target scans successfully', async () => {
|
|
279
|
+
const veracode = makeFakeVeracode();
|
|
280
|
+
const fsops = makeFakeFsops();
|
|
281
|
+
|
|
282
|
+
const result = await runVeracodeScan({
|
|
283
|
+
vid: 'vid-1',
|
|
284
|
+
vkey: 'vkey-1',
|
|
285
|
+
targetsEnv: 'web:my-app:apps/web',
|
|
286
|
+
veracode,
|
|
287
|
+
fsops,
|
|
288
|
+
log: jest.fn(),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result.exitCode).toBe(0);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { $ } from 'zx';
|
|
2
|
+
|
|
3
|
+
// Thin wrappers around the external commands the Veracode step shells out to,
|
|
4
|
+
// so scan.js can inject a fake in tests. Every call uses nothrow: a non-zero
|
|
5
|
+
// exit is meaningful data here (a failed build/zip/upload), not an
|
|
6
|
+
// exceptional case.
|
|
7
|
+
export const veracode = {
|
|
8
|
+
// The wrapper is a jar and packaging is done by `zip`, so this step — alone
|
|
9
|
+
// among the scan steps — depends on host tooling rather than a container
|
|
10
|
+
// image. Checked up front so a missing JRE reads as a missing JRE instead of
|
|
11
|
+
// surfacing as a failed upload several minutes later.
|
|
12
|
+
async commandExists(cmd) {
|
|
13
|
+
const result = await $({
|
|
14
|
+
nothrow: true,
|
|
15
|
+
quiet: true,
|
|
16
|
+
})`command -v ${cmd}`;
|
|
17
|
+
return result.exitCode === 0;
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
async download(url, dest) {
|
|
21
|
+
const result = await $({
|
|
22
|
+
nothrow: true,
|
|
23
|
+
quiet: true,
|
|
24
|
+
})`curl -sSL ${url} -o ${dest}`;
|
|
25
|
+
return { ok: result.exitCode === 0 };
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
// Runs the consumer-supplied VERACODE_BUILD_CMD via `sh -c` so its own
|
|
29
|
+
// shell operators (&&, |, ...) are honored, mirroring the pipeline's
|
|
30
|
+
// `eval "${VERACODE_BUILD_CMD}"`.
|
|
31
|
+
async runBuildCmd(cmd) {
|
|
32
|
+
const result = await $({ nothrow: true })`sh -c ${cmd}`;
|
|
33
|
+
return { ok: result.exitCode === 0 };
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async zip(zipFile, paths) {
|
|
37
|
+
const result = await $({ nothrow: true })`zip -r ${zipFile} ${paths}`;
|
|
38
|
+
return { ok: result.exitCode === 0 };
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async uploadAndScan(jarPath, args) {
|
|
42
|
+
const result = await $({ nothrow: true })`java -jar ${jarPath} ${args}`;
|
|
43
|
+
return { ok: result.exitCode === 0 };
|
|
44
|
+
},
|
|
45
|
+
};
|
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
|
+
}
|