@docupace/pipeline 1.0.128 → 1.0.131
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 +5 -2
- package/src/auth0/metadata.js +5 -2
- package/src/auth0/metadata.test.js +5 -2
- package/src/changeset/cli.js +26 -0
- package/src/changeset/validate.js +18 -0
- package/src/next/config.js +3 -1
- package/src/next/connect.js +3 -2
- package/src/next/deploy.js +2 -1
- package/src/next/deploy.test.js +1 -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/testCanny.sh +0 -4
|
@@ -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,45 @@
|
|
|
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
|
+
// The entrypoint is overridden rather than trusted. Tenable's own GitHub action
|
|
28
|
+
// builds FROM this image purely to replace the entrypoint with a script whose
|
|
29
|
+
// first line is `tenable container-image scan "$@"`, which means the base image's
|
|
30
|
+
// own entrypoint is not that binary. Passing the subcommand as bare arguments
|
|
31
|
+
// hands them to whatever the image does start, and the failure is silent: exit 0,
|
|
32
|
+
// no output, no reports.
|
|
33
|
+
async run({ image, args, volume, workdir, env, entrypoint }) {
|
|
34
|
+
const envFlags = env.flatMap((name) => ['--env', name]);
|
|
35
|
+
const entrypointFlags = entrypoint ? ['--entrypoint', entrypoint] : [];
|
|
36
|
+
const result = await $({
|
|
37
|
+
nothrow: true,
|
|
38
|
+
})`docker run --rm --workdir ${workdir} --volume ${volume} ${envFlags} ${entrypointFlags} --pull=always ${image} ${args}`;
|
|
39
|
+
return result.exitCode;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async rmi(image) {
|
|
43
|
+
await $({ nothrow: true, quiet: true })`docker rmi -f ${image}`;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -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,284 @@
|
|
|
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
|
+
scannerEntrypoint,
|
|
20
|
+
shortSha,
|
|
21
|
+
imageNamePrefix,
|
|
22
|
+
codeBranch,
|
|
23
|
+
commit,
|
|
24
|
+
codeUser,
|
|
25
|
+
codeRepoUrl,
|
|
26
|
+
minSeverity,
|
|
27
|
+
runInfo,
|
|
28
|
+
failOnMinSeverity,
|
|
29
|
+
docker,
|
|
30
|
+
fsops,
|
|
31
|
+
log,
|
|
32
|
+
} = ctx;
|
|
33
|
+
const { label, slug, buildContext, dockerfile, prebuiltImage } = target;
|
|
34
|
+
|
|
35
|
+
let scanTarget;
|
|
36
|
+
let builtImage = '';
|
|
37
|
+
|
|
38
|
+
if (prebuiltImage) {
|
|
39
|
+
scanTarget = prebuiltImage;
|
|
40
|
+
log(`Scanning pre-existing image ${scanTarget}`);
|
|
41
|
+
} else if (fsops.exists(dockerfile)) {
|
|
42
|
+
scanTarget = `${imageNamePrefix}-${slug}:tenable-${shortSha}`;
|
|
43
|
+
log(
|
|
44
|
+
`Building ${scanTarget} from ${dockerfile} (context ${buildContext})...`,
|
|
45
|
+
);
|
|
46
|
+
const useLoad = await docker.buildxAvailable();
|
|
47
|
+
const built = await docker.build([
|
|
48
|
+
buildContext,
|
|
49
|
+
'--file',
|
|
50
|
+
dockerfile,
|
|
51
|
+
'--tag',
|
|
52
|
+
scanTarget,
|
|
53
|
+
...(useLoad ? ['--load'] : []),
|
|
54
|
+
]);
|
|
55
|
+
if (!built.ok) {
|
|
56
|
+
log(`Docker build failed for '${slug}' — skipping its Tenable scan.`);
|
|
57
|
+
return { slug, exitCode: 1 };
|
|
58
|
+
}
|
|
59
|
+
builtImage = scanTarget;
|
|
60
|
+
} else {
|
|
61
|
+
log(`No Dockerfile at ${dockerfile} — nothing to scan for '${label}'.`);
|
|
62
|
+
return { slug, exitCode: 0, skipped: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const work = fsops.mkdtemp();
|
|
66
|
+
|
|
67
|
+
log(`Exporting ${scanTarget} to an archive...`);
|
|
68
|
+
const saved = await docker.save(scanTarget, `${work}/image.tar`);
|
|
69
|
+
if (!saved.ok) {
|
|
70
|
+
log(`docker save failed for '${slug}' — skipping its Tenable scan.`);
|
|
71
|
+
fsops.removeSync(work);
|
|
72
|
+
if (builtImage) await docker.rmi(builtImage);
|
|
73
|
+
return { slug, exitCode: 1 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --output-path must already exist; the scanner validates it before scanning.
|
|
77
|
+
fsops.mkdirp(`${work}/out`);
|
|
78
|
+
|
|
79
|
+
const scanArgs = buildScanArgs({
|
|
80
|
+
slug,
|
|
81
|
+
scanTarget,
|
|
82
|
+
codeBranch,
|
|
83
|
+
commit,
|
|
84
|
+
codeUser,
|
|
85
|
+
codeRepoUrl,
|
|
86
|
+
minSeverity,
|
|
87
|
+
runInfo,
|
|
88
|
+
failOnMinSeverity,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
log(
|
|
92
|
+
`Running Tenable scan for '${slug}' (min-severity=${minSeverity}${
|
|
93
|
+
failOnMinSeverity ? `, fail-on=${failOnMinSeverity}` : ''
|
|
94
|
+
})...`,
|
|
95
|
+
);
|
|
96
|
+
const exitCode = await docker.run({
|
|
97
|
+
image: scannerImage,
|
|
98
|
+
args: scanArgs,
|
|
99
|
+
volume: `${work}:/tmp/tenable`,
|
|
100
|
+
workdir: '/tmp/tenable',
|
|
101
|
+
entrypoint: scannerEntrypoint,
|
|
102
|
+
// The output destination goes over BOTH transports on purpose. Tenable's own
|
|
103
|
+
// GitHub action passes output-path and output-file-name only as TENABLE_*
|
|
104
|
+
// env vars, never as flags, so a build of the scanner that ignores the flags
|
|
105
|
+
// would silently fall back to its default path (the working directory) and
|
|
106
|
+
// leave the reports where nothing looks for them.
|
|
107
|
+
env: [
|
|
108
|
+
'TENABLE_API_TOKEN',
|
|
109
|
+
'TENABLE_API_URL',
|
|
110
|
+
'TENABLE_OUTPUT_PATH=/tmp/tenable/out',
|
|
111
|
+
`TENABLE_OUTPUT_FILE_NAME=${slug}-tenable`,
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Collect reports even on a non-zero exit — a threshold failure still produces them.
|
|
116
|
+
if (fsops.listDir(`${work}/out`).length > 0) {
|
|
117
|
+
fsops.mkdirp(reportDir);
|
|
118
|
+
fsops.copyReports(`${work}/out`, reportDir);
|
|
119
|
+
} else {
|
|
120
|
+
// Before declaring the scan reportless, look in the work root: that is the
|
|
121
|
+
// scanner's default output path, so anything it wrote while ignoring the
|
|
122
|
+
// requested one lands beside the archive — and the work dir is deleted a few
|
|
123
|
+
// lines below, taking the reports with it.
|
|
124
|
+
const stray = fsops
|
|
125
|
+
.listDir(work)
|
|
126
|
+
.filter((name) => name !== 'image.tar' && name !== 'out');
|
|
127
|
+
if (stray.length > 0) {
|
|
128
|
+
log(
|
|
129
|
+
`Tenable wrote to the work root rather than the requested output path: ${stray.join(', ')}`,
|
|
130
|
+
);
|
|
131
|
+
fsops.mkdirp(reportDir);
|
|
132
|
+
for (const name of stray) {
|
|
133
|
+
fsops.copyReports(`${work}/${name}`, `${reportDir}/${name}`);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
// The scanner exits 0 and prints nothing when it rejects an output format,
|
|
137
|
+
// so an empty work dir is worth reporting with the exit code attached.
|
|
138
|
+
log(
|
|
139
|
+
`Tenable produced no report files for '${slug}' (scanner exit ${exitCode}).`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const summaryPath = `${reportDir}/${slug}-tenable.md`;
|
|
145
|
+
if (fsops.exists(summaryPath)) {
|
|
146
|
+
log(`--- Tenable summary: ${slug} ---`);
|
|
147
|
+
log(fsops.readFile(summaryPath).split('\n').slice(0, 60).join('\n'));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fsops.removeSync(work);
|
|
151
|
+
if (builtImage) await docker.rmi(builtImage);
|
|
152
|
+
|
|
153
|
+
return { slug, exitCode };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function runTenableScan({
|
|
157
|
+
tenableApiToken = process.env.TENABLE_API_TOKEN,
|
|
158
|
+
tenableApiUrl = process.env.TENABLE_API_URL,
|
|
159
|
+
reportDir = process.env.TENABLE_REPORT_DIR || 'tenable-report',
|
|
160
|
+
scannerImage = process.env.TENABLE_SCANNER_IMAGE ||
|
|
161
|
+
'tenable/cloud-security-scanner:latest',
|
|
162
|
+
scannerEntrypoint = process.env.TENABLE_SCANNER_ENTRYPOINT || 'tenable',
|
|
163
|
+
minSeverity = process.env.TENABLE_MIN_SEVERITY || 'Medium',
|
|
164
|
+
failOnMinSeverity = process.env.TENABLE_FAIL_ON_MIN_SEVERITY || '',
|
|
165
|
+
tenableImage = process.env.TENABLE_IMAGE || '',
|
|
166
|
+
scanTargetsEnv = process.env.SCAN_TARGETS || '',
|
|
167
|
+
buildContextFallback = process.env.XRAY_BUILD_CONTEXT || '.',
|
|
168
|
+
dockerfileFallback = process.env.XRAY_DOCKERFILE_PATH || '',
|
|
169
|
+
commit = process.env.BITBUCKET_COMMIT || 'local',
|
|
170
|
+
branch = process.env.BITBUCKET_BRANCH || '',
|
|
171
|
+
prDestinationBranch = process.env.BITBUCKET_PR_DESTINATION_BRANCH || '',
|
|
172
|
+
workspace = process.env.BITBUCKET_WORKSPACE || 'unknown',
|
|
173
|
+
repoSlug = process.env.BITBUCKET_REPO_SLUG || 'unknown',
|
|
174
|
+
gitAuthorName = '',
|
|
175
|
+
prId = process.env.BITBUCKET_PR_ID || '',
|
|
176
|
+
buildNumber = process.env.BITBUCKET_BUILD_NUMBER || '',
|
|
177
|
+
runId = process.env.TENABLE_PIPELINE_RUN_ID || '',
|
|
178
|
+
runTrigger = process.env.TENABLE_PIPELINE_RUN_TRIGGER || '',
|
|
179
|
+
runUrl = process.env.TENABLE_PIPELINE_RUN_URL || '',
|
|
180
|
+
docker = defaultDocker,
|
|
181
|
+
fsops = defaultFsOps,
|
|
182
|
+
log = console.log,
|
|
183
|
+
} = {}) {
|
|
184
|
+
if (!tenableApiToken || !tenableApiUrl) {
|
|
185
|
+
log('TENABLE_API_TOKEN / TENABLE_API_URL not set — skipping Tenable scan.');
|
|
186
|
+
return { exitCode: 0, skipped: true, results: [] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { shortSha, codeBranch, codeRepoUrl, codeUser } = buildCodeMetadata({
|
|
190
|
+
commit,
|
|
191
|
+
branch,
|
|
192
|
+
prDestinationBranch,
|
|
193
|
+
workspace,
|
|
194
|
+
repoSlug,
|
|
195
|
+
gitAuthorName,
|
|
196
|
+
});
|
|
197
|
+
const runInfo = buildPipelineRunInfo({
|
|
198
|
+
runId,
|
|
199
|
+
runTrigger,
|
|
200
|
+
runUrl,
|
|
201
|
+
prId,
|
|
202
|
+
buildNumber,
|
|
203
|
+
workspace,
|
|
204
|
+
repoSlug,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const scanCtx = {
|
|
208
|
+
reportDir,
|
|
209
|
+
scannerImage,
|
|
210
|
+
scannerEntrypoint,
|
|
211
|
+
shortSha,
|
|
212
|
+
imageNamePrefix: repoSlug,
|
|
213
|
+
codeBranch,
|
|
214
|
+
commit,
|
|
215
|
+
codeUser,
|
|
216
|
+
codeRepoUrl,
|
|
217
|
+
minSeverity,
|
|
218
|
+
runInfo,
|
|
219
|
+
failOnMinSeverity,
|
|
220
|
+
docker,
|
|
221
|
+
fsops,
|
|
222
|
+
log,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const results = [];
|
|
226
|
+
|
|
227
|
+
// TENABLE_IMAGE names an already-built image, which is inherently single-target.
|
|
228
|
+
if (tenableImage) {
|
|
229
|
+
results.push(
|
|
230
|
+
await scanOne(
|
|
231
|
+
{ label: 'image', slug: 'image', prebuiltImage: tenableImage },
|
|
232
|
+
scanCtx,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
} else {
|
|
236
|
+
const targetsStr =
|
|
237
|
+
scanTargetsEnv ||
|
|
238
|
+
defaultScanTargets(buildContextFallback, dockerfileFallback);
|
|
239
|
+
if (!scanTargetsEnv) {
|
|
240
|
+
log(`SCAN_TARGETS not set — scanning a single target: ${targetsStr}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let scanned = 0;
|
|
244
|
+
for (const target of parseScanTargets(targetsStr)) {
|
|
245
|
+
if (target.skipped) {
|
|
246
|
+
log(
|
|
247
|
+
`Target '${target.label}' has no build context — skipping its image scan.`,
|
|
248
|
+
);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
log(SEPARATOR);
|
|
252
|
+
log(`Tenable target '${target.label}'`);
|
|
253
|
+
log(SEPARATOR);
|
|
254
|
+
results.push(await scanOne(target, scanCtx));
|
|
255
|
+
scanned += 1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (scanned === 0) {
|
|
259
|
+
log('No scannable targets in SCAN_TARGETS — nothing to do.');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Report the worst outcome across targets, so one clean app can't mask
|
|
264
|
+
// another's failure.
|
|
265
|
+
const exitCode = results.reduce(
|
|
266
|
+
(worst, r) => (r.exitCode !== 0 ? r.exitCode : worst),
|
|
267
|
+
0,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (fsops.listDir(reportDir).length > 0) {
|
|
271
|
+
log(`Reports written to ${reportDir}/:`);
|
|
272
|
+
log(fsops.listDir(reportDir).join('\n'));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (exitCode !== 0) {
|
|
276
|
+
log(
|
|
277
|
+
failOnMinSeverity
|
|
278
|
+
? `Tenable scan exited ${exitCode} — findings at or above ${failOnMinSeverity}, or a scan itself failed.`
|
|
279
|
+
: `Tenable scan exited ${exitCode}.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { exitCode, skipped: false, results };
|
|
284
|
+
}
|