@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,226 @@
|
|
|
1
|
+
import { describe, expect, test } from '@jest/globals';
|
|
2
|
+
import {
|
|
3
|
+
slugify,
|
|
4
|
+
parseScanTargets,
|
|
5
|
+
defaultScanTargets,
|
|
6
|
+
buildCodeMetadata,
|
|
7
|
+
buildPipelineRunInfo,
|
|
8
|
+
buildScanArgs,
|
|
9
|
+
} from './args.js';
|
|
10
|
+
|
|
11
|
+
describe('slugify', () => {
|
|
12
|
+
test('replaces every non [A-Za-z0-9._-] character with a dash', () => {
|
|
13
|
+
expect(slugify('my app!')).toBe('my-app-');
|
|
14
|
+
expect(slugify('my/app')).toBe('my-app');
|
|
15
|
+
expect(slugify('already-safe_v1.2')).toBe('already-safe_v1.2');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('parseScanTargets', () => {
|
|
20
|
+
test('parses label:auditDir:context:dockerfile entries, ignoring auditDir', () => {
|
|
21
|
+
const targets = parseScanTargets(
|
|
22
|
+
'web:deps:services/web:services/web/Dockerfile.prod;worker::services/worker:',
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(targets).toEqual([
|
|
26
|
+
{
|
|
27
|
+
label: 'web',
|
|
28
|
+
slug: 'web',
|
|
29
|
+
buildContext: 'services/web',
|
|
30
|
+
dockerfile: 'services/web/Dockerfile.prod',
|
|
31
|
+
skipped: false,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: 'worker',
|
|
35
|
+
slug: 'worker',
|
|
36
|
+
buildContext: 'services/worker',
|
|
37
|
+
dockerfile: 'services/worker/Dockerfile',
|
|
38
|
+
skipped: false,
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('defaults an empty label to "app" and slugifies it', () => {
|
|
44
|
+
const [target] = parseScanTargets(':deps:ctx:Dockerfile');
|
|
45
|
+
expect(target.label).toBe('app');
|
|
46
|
+
expect(target.slug).toBe('app');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('marks a target with no build context as skipped', () => {
|
|
50
|
+
const [target] = parseScanTargets('web:deps::Dockerfile');
|
|
51
|
+
expect(target).toEqual({
|
|
52
|
+
label: 'web',
|
|
53
|
+
slug: 'web',
|
|
54
|
+
buildContext: '',
|
|
55
|
+
dockerfile: '',
|
|
56
|
+
skipped: true,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('ignores blank entries between semicolons', () => {
|
|
61
|
+
expect(parseScanTargets('web:deps:ctx:Dockerfile;;')).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('empty string produces no targets', () => {
|
|
65
|
+
expect(parseScanTargets('')).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('defaultScanTargets', () => {
|
|
70
|
+
test('builds a single app target from context + dockerfile', () => {
|
|
71
|
+
expect(defaultScanTargets('.', './Dockerfile')).toBe('app::.:./Dockerfile');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('defaults the dockerfile path to <context>/Dockerfile when unset', () => {
|
|
75
|
+
expect(defaultScanTargets('services/web', '')).toBe(
|
|
76
|
+
'app::services/web:services/web/Dockerfile',
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('defaults the context to "." when unset', () => {
|
|
81
|
+
expect(defaultScanTargets('', '')).toBe('app::.:./Dockerfile');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('buildCodeMetadata', () => {
|
|
86
|
+
test('derives a 7-char short SHA and falls back branch/user', () => {
|
|
87
|
+
const meta = buildCodeMetadata({
|
|
88
|
+
commit: 'abcdef1234567',
|
|
89
|
+
branch: '',
|
|
90
|
+
prDestinationBranch: 'main',
|
|
91
|
+
workspace: 'dp-cloud',
|
|
92
|
+
repoSlug: 'my-repo',
|
|
93
|
+
gitAuthorName: '',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(meta).toEqual({
|
|
97
|
+
shortSha: 'abcdef1',
|
|
98
|
+
codeBranch: 'main',
|
|
99
|
+
codeRepoUrl: 'https://bitbucket.org/dp-cloud/my-repo',
|
|
100
|
+
codeUser: 'bitbucket-pipelines',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('prefers branch over PR destination branch, and a real author name', () => {
|
|
105
|
+
const meta = buildCodeMetadata({
|
|
106
|
+
commit: 'abcdef1234567',
|
|
107
|
+
branch: 'feature/x',
|
|
108
|
+
prDestinationBranch: 'main',
|
|
109
|
+
gitAuthorName: 'Matt Fields',
|
|
110
|
+
});
|
|
111
|
+
expect(meta.codeBranch).toBe('feature/x');
|
|
112
|
+
expect(meta.codeUser).toBe('Matt Fields');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('buildPipelineRunInfo', () => {
|
|
117
|
+
test('defaults trigger to push and builds a run URL from the build number', () => {
|
|
118
|
+
const info = buildPipelineRunInfo({
|
|
119
|
+
buildNumber: '42',
|
|
120
|
+
workspace: 'dp-cloud',
|
|
121
|
+
repoSlug: 'my-repo',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(info).toEqual({
|
|
125
|
+
runId: '42',
|
|
126
|
+
runTrigger: 'push',
|
|
127
|
+
runUrl: 'https://bitbucket.org/dp-cloud/my-repo/pipelines/results/42',
|
|
128
|
+
includeInScanArgs: true,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('defaults trigger to pull_request when a PR id is present', () => {
|
|
133
|
+
const info = buildPipelineRunInfo({ buildNumber: '42', prId: '7' });
|
|
134
|
+
expect(info.runTrigger).toBe('pull_request');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('explicit TENABLE_PIPELINE_RUN_* values win over the Bitbucket-derived ones', () => {
|
|
138
|
+
const info = buildPipelineRunInfo({
|
|
139
|
+
runId: 'custom-1',
|
|
140
|
+
runTrigger: 'manual',
|
|
141
|
+
runUrl: 'https://example.com/run/1',
|
|
142
|
+
buildNumber: '42',
|
|
143
|
+
});
|
|
144
|
+
expect(info).toEqual({
|
|
145
|
+
runId: 'custom-1',
|
|
146
|
+
runTrigger: 'manual',
|
|
147
|
+
runUrl: 'https://example.com/run/1',
|
|
148
|
+
includeInScanArgs: true,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('does not include the trio in scan args when no build number/id is available', () => {
|
|
153
|
+
const info = buildPipelineRunInfo({});
|
|
154
|
+
expect(info.includeInScanArgs).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('buildScanArgs', () => {
|
|
159
|
+
const base = {
|
|
160
|
+
slug: 'web',
|
|
161
|
+
scanTarget: 'my-repo-web:tenable-abc1234',
|
|
162
|
+
codeBranch: 'main',
|
|
163
|
+
commit: 'abcdef1234567',
|
|
164
|
+
codeUser: 'Matt Fields',
|
|
165
|
+
codeRepoUrl: 'https://bitbucket.org/dp-cloud/my-repo',
|
|
166
|
+
minSeverity: 'Medium',
|
|
167
|
+
runInfo: { includeInScanArgs: false },
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
test('builds the base scanner CLI args', () => {
|
|
171
|
+
expect(buildScanArgs(base)).toEqual([
|
|
172
|
+
'container-image',
|
|
173
|
+
'scan',
|
|
174
|
+
'--archive-path',
|
|
175
|
+
'/tmp/tenable/image.tar',
|
|
176
|
+
'--name',
|
|
177
|
+
'my-repo-web:tenable-abc1234',
|
|
178
|
+
'--code-branch',
|
|
179
|
+
'main',
|
|
180
|
+
'--code-commit-hash',
|
|
181
|
+
'abcdef1234567',
|
|
182
|
+
'--code-commit-user',
|
|
183
|
+
'Matt Fields',
|
|
184
|
+
'--code-repository-url',
|
|
185
|
+
'https://bitbucket.org/dp-cloud/my-repo',
|
|
186
|
+
'--min-severity',
|
|
187
|
+
'Medium',
|
|
188
|
+
'--output-file-formats',
|
|
189
|
+
'Json,Md,JUnit',
|
|
190
|
+
'--output-path',
|
|
191
|
+
'/tmp/tenable/out',
|
|
192
|
+
'--output-file-name',
|
|
193
|
+
'web-tenable',
|
|
194
|
+
'--no-color',
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('appends the pipeline-run trio only when includeInScanArgs is true', () => {
|
|
199
|
+
const args = buildScanArgs({
|
|
200
|
+
...base,
|
|
201
|
+
runInfo: {
|
|
202
|
+
includeInScanArgs: true,
|
|
203
|
+
runId: '42',
|
|
204
|
+
runTrigger: 'push',
|
|
205
|
+
runUrl: 'https://bitbucket.org/dp-cloud/my-repo/pipelines/results/42',
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
expect(args).toEqual(
|
|
209
|
+
expect.arrayContaining([
|
|
210
|
+
'--pipeline-run-id',
|
|
211
|
+
'42',
|
|
212
|
+
'--pipeline-run-trigger',
|
|
213
|
+
'push',
|
|
214
|
+
'--pipeline-run-url',
|
|
215
|
+
'https://bitbucket.org/dp-cloud/my-repo/pipelines/results/42',
|
|
216
|
+
]),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('appends --fail-on-min-severity only when set', () => {
|
|
221
|
+
expect(buildScanArgs(base)).not.toContain('--fail-on-min-severity');
|
|
222
|
+
expect(buildScanArgs({ ...base, failOnMinSeverity: 'High' })).toEqual(
|
|
223
|
+
expect.arrayContaining(['--fail-on-min-severity', 'High']),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { $ } from 'zx';
|
|
5
|
+
import { runTenableScan } from './scan.js';
|
|
6
|
+
|
|
7
|
+
const program = new Command();
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.command('scan')
|
|
11
|
+
.description('Run the Tenable Cloud Security container image scan')
|
|
12
|
+
.action(async () => {
|
|
13
|
+
let gitAuthorName = '';
|
|
14
|
+
const result = await $({
|
|
15
|
+
nothrow: true,
|
|
16
|
+
quiet: true,
|
|
17
|
+
})`git log -1 --pretty=format:%an`;
|
|
18
|
+
if (result.exitCode === 0) gitAuthorName = result.stdout.trim();
|
|
19
|
+
|
|
20
|
+
const { exitCode } = await runTenableScan({ gitAuthorName });
|
|
21
|
+
process.exit(exitCode);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { $ } from 'zx';
|
|
2
|
+
|
|
3
|
+
// Thin wrappers around the docker CLI so scan.js can inject a fake in tests.
|
|
4
|
+
// Every call uses nothrow: a non-zero exit is meaningful data here (a failed
|
|
5
|
+
// build, or the scanner itself reporting findings), not an exceptional case.
|
|
6
|
+
export const docker = {
|
|
7
|
+
async buildxAvailable() {
|
|
8
|
+
const result = await $({
|
|
9
|
+
nothrow: true,
|
|
10
|
+
quiet: true,
|
|
11
|
+
})`docker buildx version`;
|
|
12
|
+
return result.exitCode === 0;
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
async build(args) {
|
|
16
|
+
const result = await $({ nothrow: true })`docker build ${args}`;
|
|
17
|
+
return { ok: result.exitCode === 0 };
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
async save(image, tarPath) {
|
|
21
|
+
const result = await $({
|
|
22
|
+
nothrow: true,
|
|
23
|
+
})`docker save ${image} -o ${tarPath}`;
|
|
24
|
+
return { ok: result.exitCode === 0 };
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async run({ image, args, volume, workdir, env }) {
|
|
28
|
+
const envFlags = env.flatMap((name) => ['--env', name]);
|
|
29
|
+
const result = await $({
|
|
30
|
+
nothrow: true,
|
|
31
|
+
})`docker run --rm --workdir ${workdir} --volume ${volume} ${envFlags} --pull=always ${image} ${args}`;
|
|
32
|
+
return result.exitCode;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async rmi(image) {
|
|
36
|
+
await $({ nothrow: true, quiet: true })`docker rmi -f ${image}`;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
// The work dir is created inside the current directory (not os.tmpdir())
|
|
5
|
+
// deliberately: it must be bind-mountable for docker, and on some hosts the
|
|
6
|
+
// system temp dir is a symlink docker won't follow.
|
|
7
|
+
export const fsOps = {
|
|
8
|
+
mkdtemp: () => fs.mkdtempSync(path.join(process.cwd(), '.tenable-work.')),
|
|
9
|
+
mkdirp: (dir) => fs.mkdirpSync(dir),
|
|
10
|
+
exists: (p) => fs.existsSync(p),
|
|
11
|
+
listDir: (dir) => (fs.existsSync(dir) ? fs.readdirSync(dir) : []),
|
|
12
|
+
copyReports: (from, to) => fs.copySync(from, to),
|
|
13
|
+
readFile: (p) => fs.readFileSync(p, 'utf8'),
|
|
14
|
+
removeSync: (p) => fs.removeSync(p),
|
|
15
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildCodeMetadata,
|
|
3
|
+
buildPipelineRunInfo,
|
|
4
|
+
buildScanArgs,
|
|
5
|
+
defaultScanTargets,
|
|
6
|
+
parseScanTargets,
|
|
7
|
+
SEPARATOR,
|
|
8
|
+
} from './args.js';
|
|
9
|
+
import { docker as defaultDocker } from './docker.js';
|
|
10
|
+
import { fsOps as defaultFsOps } from './fsops.js';
|
|
11
|
+
|
|
12
|
+
// Scans one target: builds (or reuses a pre-built) image, exports it to a
|
|
13
|
+
// tar, and runs it through the scanner container, which has no access to the
|
|
14
|
+
// host docker daemon so it can't resolve an image by name alone.
|
|
15
|
+
export async function scanOne(target, ctx) {
|
|
16
|
+
const {
|
|
17
|
+
reportDir,
|
|
18
|
+
scannerImage,
|
|
19
|
+
shortSha,
|
|
20
|
+
imageNamePrefix,
|
|
21
|
+
codeBranch,
|
|
22
|
+
commit,
|
|
23
|
+
codeUser,
|
|
24
|
+
codeRepoUrl,
|
|
25
|
+
minSeverity,
|
|
26
|
+
runInfo,
|
|
27
|
+
failOnMinSeverity,
|
|
28
|
+
docker,
|
|
29
|
+
fsops,
|
|
30
|
+
log,
|
|
31
|
+
} = ctx;
|
|
32
|
+
const { label, slug, buildContext, dockerfile, prebuiltImage } = target;
|
|
33
|
+
|
|
34
|
+
let scanTarget;
|
|
35
|
+
let builtImage = '';
|
|
36
|
+
|
|
37
|
+
if (prebuiltImage) {
|
|
38
|
+
scanTarget = prebuiltImage;
|
|
39
|
+
log(`Scanning pre-existing image ${scanTarget}`);
|
|
40
|
+
} else if (fsops.exists(dockerfile)) {
|
|
41
|
+
scanTarget = `${imageNamePrefix}-${slug}:tenable-${shortSha}`;
|
|
42
|
+
log(
|
|
43
|
+
`Building ${scanTarget} from ${dockerfile} (context ${buildContext})...`,
|
|
44
|
+
);
|
|
45
|
+
const useLoad = await docker.buildxAvailable();
|
|
46
|
+
const built = await docker.build([
|
|
47
|
+
buildContext,
|
|
48
|
+
'--file',
|
|
49
|
+
dockerfile,
|
|
50
|
+
'--tag',
|
|
51
|
+
scanTarget,
|
|
52
|
+
...(useLoad ? ['--load'] : []),
|
|
53
|
+
]);
|
|
54
|
+
if (!built.ok) {
|
|
55
|
+
log(`Docker build failed for '${slug}' — skipping its Tenable scan.`);
|
|
56
|
+
return { slug, exitCode: 1 };
|
|
57
|
+
}
|
|
58
|
+
builtImage = scanTarget;
|
|
59
|
+
} else {
|
|
60
|
+
log(`No Dockerfile at ${dockerfile} — nothing to scan for '${label}'.`);
|
|
61
|
+
return { slug, exitCode: 0, skipped: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const work = fsops.mkdtemp();
|
|
65
|
+
|
|
66
|
+
log(`Exporting ${scanTarget} to an archive...`);
|
|
67
|
+
const saved = await docker.save(scanTarget, `${work}/image.tar`);
|
|
68
|
+
if (!saved.ok) {
|
|
69
|
+
log(`docker save failed for '${slug}' — skipping its Tenable scan.`);
|
|
70
|
+
fsops.removeSync(work);
|
|
71
|
+
if (builtImage) await docker.rmi(builtImage);
|
|
72
|
+
return { slug, exitCode: 1 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --output-path must already exist; the scanner validates it before scanning.
|
|
76
|
+
fsops.mkdirp(`${work}/out`);
|
|
77
|
+
|
|
78
|
+
const scanArgs = buildScanArgs({
|
|
79
|
+
slug,
|
|
80
|
+
scanTarget,
|
|
81
|
+
codeBranch,
|
|
82
|
+
commit,
|
|
83
|
+
codeUser,
|
|
84
|
+
codeRepoUrl,
|
|
85
|
+
minSeverity,
|
|
86
|
+
runInfo,
|
|
87
|
+
failOnMinSeverity,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
log(
|
|
91
|
+
`Running Tenable scan for '${slug}' (min-severity=${minSeverity}${
|
|
92
|
+
failOnMinSeverity ? `, fail-on=${failOnMinSeverity}` : ''
|
|
93
|
+
})...`,
|
|
94
|
+
);
|
|
95
|
+
const exitCode = await docker.run({
|
|
96
|
+
image: scannerImage,
|
|
97
|
+
args: scanArgs,
|
|
98
|
+
volume: `${work}:/tmp/tenable`,
|
|
99
|
+
workdir: '/tmp/tenable',
|
|
100
|
+
env: ['TENABLE_API_TOKEN', 'TENABLE_API_URL'],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Collect reports even on a non-zero exit — a threshold failure still produces them.
|
|
104
|
+
if (fsops.listDir(`${work}/out`).length > 0) {
|
|
105
|
+
fsops.mkdirp(reportDir);
|
|
106
|
+
fsops.copyReports(`${work}/out`, reportDir);
|
|
107
|
+
} else {
|
|
108
|
+
log(`Tenable produced no report files for '${slug}'.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const summaryPath = `${reportDir}/${slug}-tenable.md`;
|
|
112
|
+
if (fsops.exists(summaryPath)) {
|
|
113
|
+
log(`--- Tenable summary: ${slug} ---`);
|
|
114
|
+
log(fsops.readFile(summaryPath).split('\n').slice(0, 60).join('\n'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fsops.removeSync(work);
|
|
118
|
+
if (builtImage) await docker.rmi(builtImage);
|
|
119
|
+
|
|
120
|
+
return { slug, exitCode };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runTenableScan({
|
|
124
|
+
tenableApiToken = process.env.TENABLE_API_TOKEN,
|
|
125
|
+
tenableApiUrl = process.env.TENABLE_API_URL,
|
|
126
|
+
reportDir = process.env.TENABLE_REPORT_DIR || 'tenable-report',
|
|
127
|
+
scannerImage = process.env.TENABLE_SCANNER_IMAGE ||
|
|
128
|
+
'tenable/cloud-security-scanner:latest',
|
|
129
|
+
minSeverity = process.env.TENABLE_MIN_SEVERITY || 'Medium',
|
|
130
|
+
failOnMinSeverity = process.env.TENABLE_FAIL_ON_MIN_SEVERITY || '',
|
|
131
|
+
tenableImage = process.env.TENABLE_IMAGE || '',
|
|
132
|
+
scanTargetsEnv = process.env.SCAN_TARGETS || '',
|
|
133
|
+
buildContextFallback = process.env.XRAY_BUILD_CONTEXT || '.',
|
|
134
|
+
dockerfileFallback = process.env.XRAY_DOCKERFILE_PATH || '',
|
|
135
|
+
commit = process.env.BITBUCKET_COMMIT || 'local',
|
|
136
|
+
branch = process.env.BITBUCKET_BRANCH || '',
|
|
137
|
+
prDestinationBranch = process.env.BITBUCKET_PR_DESTINATION_BRANCH || '',
|
|
138
|
+
workspace = process.env.BITBUCKET_WORKSPACE || 'unknown',
|
|
139
|
+
repoSlug = process.env.BITBUCKET_REPO_SLUG || 'unknown',
|
|
140
|
+
gitAuthorName = '',
|
|
141
|
+
prId = process.env.BITBUCKET_PR_ID || '',
|
|
142
|
+
buildNumber = process.env.BITBUCKET_BUILD_NUMBER || '',
|
|
143
|
+
runId = process.env.TENABLE_PIPELINE_RUN_ID || '',
|
|
144
|
+
runTrigger = process.env.TENABLE_PIPELINE_RUN_TRIGGER || '',
|
|
145
|
+
runUrl = process.env.TENABLE_PIPELINE_RUN_URL || '',
|
|
146
|
+
docker = defaultDocker,
|
|
147
|
+
fsops = defaultFsOps,
|
|
148
|
+
log = console.log,
|
|
149
|
+
} = {}) {
|
|
150
|
+
if (!tenableApiToken || !tenableApiUrl) {
|
|
151
|
+
log('TENABLE_API_TOKEN / TENABLE_API_URL not set — skipping Tenable scan.');
|
|
152
|
+
return { exitCode: 0, skipped: true, results: [] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { shortSha, codeBranch, codeRepoUrl, codeUser } = buildCodeMetadata({
|
|
156
|
+
commit,
|
|
157
|
+
branch,
|
|
158
|
+
prDestinationBranch,
|
|
159
|
+
workspace,
|
|
160
|
+
repoSlug,
|
|
161
|
+
gitAuthorName,
|
|
162
|
+
});
|
|
163
|
+
const runInfo = buildPipelineRunInfo({
|
|
164
|
+
runId,
|
|
165
|
+
runTrigger,
|
|
166
|
+
runUrl,
|
|
167
|
+
prId,
|
|
168
|
+
buildNumber,
|
|
169
|
+
workspace,
|
|
170
|
+
repoSlug,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const scanCtx = {
|
|
174
|
+
reportDir,
|
|
175
|
+
scannerImage,
|
|
176
|
+
shortSha,
|
|
177
|
+
imageNamePrefix: repoSlug,
|
|
178
|
+
codeBranch,
|
|
179
|
+
commit,
|
|
180
|
+
codeUser,
|
|
181
|
+
codeRepoUrl,
|
|
182
|
+
minSeverity,
|
|
183
|
+
runInfo,
|
|
184
|
+
failOnMinSeverity,
|
|
185
|
+
docker,
|
|
186
|
+
fsops,
|
|
187
|
+
log,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const results = [];
|
|
191
|
+
|
|
192
|
+
// TENABLE_IMAGE names an already-built image, which is inherently single-target.
|
|
193
|
+
if (tenableImage) {
|
|
194
|
+
results.push(
|
|
195
|
+
await scanOne(
|
|
196
|
+
{ label: 'image', slug: 'image', prebuiltImage: tenableImage },
|
|
197
|
+
scanCtx,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
} else {
|
|
201
|
+
const targetsStr =
|
|
202
|
+
scanTargetsEnv ||
|
|
203
|
+
defaultScanTargets(buildContextFallback, dockerfileFallback);
|
|
204
|
+
if (!scanTargetsEnv) {
|
|
205
|
+
log(`SCAN_TARGETS not set — scanning a single target: ${targetsStr}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let scanned = 0;
|
|
209
|
+
for (const target of parseScanTargets(targetsStr)) {
|
|
210
|
+
if (target.skipped) {
|
|
211
|
+
log(
|
|
212
|
+
`Target '${target.label}' has no build context — skipping its image scan.`,
|
|
213
|
+
);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
log(SEPARATOR);
|
|
217
|
+
log(`Tenable target '${target.label}'`);
|
|
218
|
+
log(SEPARATOR);
|
|
219
|
+
results.push(await scanOne(target, scanCtx));
|
|
220
|
+
scanned += 1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (scanned === 0) {
|
|
224
|
+
log('No scannable targets in SCAN_TARGETS — nothing to do.');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Report the worst outcome across targets, so one clean app can't mask
|
|
229
|
+
// another's failure.
|
|
230
|
+
const exitCode = results.reduce(
|
|
231
|
+
(worst, r) => (r.exitCode !== 0 ? r.exitCode : worst),
|
|
232
|
+
0,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (fsops.listDir(reportDir).length > 0) {
|
|
236
|
+
log(`Reports written to ${reportDir}/:`);
|
|
237
|
+
log(fsops.listDir(reportDir).join('\n'));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (exitCode !== 0) {
|
|
241
|
+
log(
|
|
242
|
+
failOnMinSeverity
|
|
243
|
+
? `Tenable scan exited ${exitCode} — findings at or above ${failOnMinSeverity}, or a scan itself failed.`
|
|
244
|
+
: `Tenable scan exited ${exitCode}.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { exitCode, skipped: false, results };
|
|
249
|
+
}
|