@docupace/pipeline 1.0.128 → 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 +5 -2
- package/src/changeset/validate.js +18 -0
- package/src/next/config.js +1 -1
- 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 -105
- package/testCanny.sh +0 -4
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Pure parsing/formatting logic for Xray findings, ported from bin/xray.sh's
|
|
2
|
+
// jq pipeline. Kept dependency-free (no fs/network) so it's unit-testable.
|
|
3
|
+
|
|
4
|
+
const SEVERITY_RANK = { Critical: 0, High: 1, Medium: 2, Low: 3 };
|
|
5
|
+
|
|
6
|
+
export function rankOf(severity) {
|
|
7
|
+
return SEVERITY_RANK[severity] ?? 9;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SCA_SECTIONS = [
|
|
11
|
+
['vulnerabilities', 'Vulnerability'],
|
|
12
|
+
['securityViolations', 'Security violation'],
|
|
13
|
+
['licensesViolations', 'License violation'],
|
|
14
|
+
['operationalRiskViolations', 'Operational risk'],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const CODE_SECTIONS = [
|
|
18
|
+
['secrets', 'Secret'],
|
|
19
|
+
['secretsViolations', 'Secret violation'],
|
|
20
|
+
['sast', 'SAST'],
|
|
21
|
+
['sastViolations', 'SAST violation'],
|
|
22
|
+
['iac', 'IaC'],
|
|
23
|
+
['iacViolations', 'IaC violation'],
|
|
24
|
+
['maliciousCode', 'Malicious code'],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function scaRow(row, scan, kind) {
|
|
28
|
+
const severity = row.severity ?? 'Unknown';
|
|
29
|
+
const cvesAll = (row.cves ?? []).map((c) => c.cve).filter(Boolean);
|
|
30
|
+
return {
|
|
31
|
+
scan,
|
|
32
|
+
kind,
|
|
33
|
+
severity,
|
|
34
|
+
rank: rankOf(severity),
|
|
35
|
+
cve: cvesAll[0] ?? row.issueId ?? 'N/A',
|
|
36
|
+
cvesAll,
|
|
37
|
+
pkg: `${row.impactedPackageName ?? 'N/A'} ${row.impactedPackageVersion ?? ''}`,
|
|
38
|
+
fixed: (row.fixedVersions ?? []).join(', '),
|
|
39
|
+
directs: [
|
|
40
|
+
...new Set((row.components ?? []).map((c) => c.name).filter(Boolean)),
|
|
41
|
+
].join(', '),
|
|
42
|
+
path: '',
|
|
43
|
+
line: 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function codeRow(row, scan, kind) {
|
|
48
|
+
const severity = row.severity ?? 'Unknown';
|
|
49
|
+
return {
|
|
50
|
+
scan,
|
|
51
|
+
kind,
|
|
52
|
+
severity,
|
|
53
|
+
rank: rankOf(severity),
|
|
54
|
+
cve: row.ruleId ?? 'N/A',
|
|
55
|
+
cvesAll: [],
|
|
56
|
+
pkg: row.finding ?? row.scannerShortDescription ?? 'N/A',
|
|
57
|
+
fixed: '',
|
|
58
|
+
directs: '',
|
|
59
|
+
path: row.file ?? '',
|
|
60
|
+
line: row.startLine ?? 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Flattens one simple-json scan report into the same per-finding record shape
|
|
65
|
+
// bin/xray.sh's jq filter produced (one row per vulnerability/secret/etc).
|
|
66
|
+
export function extractFindings(data, scanLabel) {
|
|
67
|
+
const findings = [];
|
|
68
|
+
for (const [key, kind] of SCA_SECTIONS) {
|
|
69
|
+
for (const row of data[key] ?? []) {
|
|
70
|
+
findings.push(scaRow(row, scanLabel, kind));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const [key, kind] of CODE_SECTIONS) {
|
|
74
|
+
for (const row of data[key] ?? []) {
|
|
75
|
+
findings.push(codeRow(row, scanLabel, kind));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Only Critical/High/Medium/Low are counted, matching the bash script's
|
|
82
|
+
// count_sev — an "Unknown" severity finding is neither counted nor totaled.
|
|
83
|
+
export function countBySeverity(findings) {
|
|
84
|
+
const counts = { Critical: 0, High: 0, Medium: 0, Low: 0 };
|
|
85
|
+
for (const f of findings) {
|
|
86
|
+
if (f.severity in counts) counts[f.severity] += 1;
|
|
87
|
+
}
|
|
88
|
+
const total = counts.Critical + counts.High + counts.Medium + counts.Low;
|
|
89
|
+
return { ...counts, total };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildBuildLink({ workspace, repoSlug, buildNumber }) {
|
|
93
|
+
if (!workspace || !repoSlug || !buildNumber) return '';
|
|
94
|
+
return `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${buildNumber}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildParentPayload({
|
|
98
|
+
channel,
|
|
99
|
+
project,
|
|
100
|
+
branch,
|
|
101
|
+
buildLink,
|
|
102
|
+
counts,
|
|
103
|
+
}) {
|
|
104
|
+
const contextText = buildLink
|
|
105
|
+
? `Detailed findings are in the thread below 👇 • <${buildLink}|Pipeline run>`
|
|
106
|
+
: 'Detailed findings are in the thread below 👇';
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
channel,
|
|
110
|
+
blocks: [
|
|
111
|
+
{
|
|
112
|
+
type: 'header',
|
|
113
|
+
text: { type: 'plain_text', text: '🚨 Xray Security Report' },
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: 'section',
|
|
117
|
+
text: {
|
|
118
|
+
type: 'mrkdwn',
|
|
119
|
+
text: `*Project:* ${project}${branch ? ` | *Branch:* ${branch}` : ''}`,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: 'section',
|
|
124
|
+
text: {
|
|
125
|
+
type: 'mrkdwn',
|
|
126
|
+
text: `🔴 *Critical:* ${counts.Critical} 🔥 *High:* ${counts.High} 🎃 *Medium:* ${counts.Medium} 👻 *Low:* ${counts.Low}`,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{ type: 'context', elements: [{ type: 'mrkdwn', text: contextText }] },
|
|
130
|
+
{ type: 'divider' },
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const SEVERITY_EMOJI = { Critical: '🔴', High: '🔥', Medium: '🎃' };
|
|
136
|
+
const DEFAULT_EMOJI = '👻';
|
|
137
|
+
const CVE_PATTERN = /^CVE-/;
|
|
138
|
+
|
|
139
|
+
function nvdLink(id) {
|
|
140
|
+
return `<https://nvd.nist.gov/vuln/detail/${id}|${id}>`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function idText(finding) {
|
|
144
|
+
if (finding.cvesAll.length > 0) {
|
|
145
|
+
return finding.cvesAll.map(nvdLink).join(', ');
|
|
146
|
+
}
|
|
147
|
+
return CVE_PATTERN.test(finding.cve) ? nvdLink(finding.cve) : finding.cve;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function buildFindingText(finding) {
|
|
151
|
+
const emoji = SEVERITY_EMOJI[finding.severity] ?? DEFAULT_EMOJI;
|
|
152
|
+
let text =
|
|
153
|
+
`${emoji} *${finding.severity}* — ${finding.scan} (${finding.kind})` +
|
|
154
|
+
`\n*ID:* ${idText(finding)}` +
|
|
155
|
+
`\n*Component:* \`${finding.pkg}\``;
|
|
156
|
+
if (finding.fixed) text += `\n*Fixed in:* ${finding.fixed}`;
|
|
157
|
+
if (finding.directs) text += `\n*Via:* ${finding.directs}`;
|
|
158
|
+
if (finding.path) {
|
|
159
|
+
text += `\n*File:* \`${finding.path}${finding.line > 0 ? `:${finding.line}` : ''}\``;
|
|
160
|
+
}
|
|
161
|
+
return text;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildThreadPayload({ channel, threadTs, finding }) {
|
|
165
|
+
return {
|
|
166
|
+
channel,
|
|
167
|
+
thread_ts: threadTs,
|
|
168
|
+
blocks: [
|
|
169
|
+
{
|
|
170
|
+
type: 'section',
|
|
171
|
+
text: { type: 'mrkdwn', text: buildFindingText(finding) },
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Parses the "label:file" lines written by the scan step (one per completed
|
|
178
|
+
// scan) into { label, file } pairs. Blank lines are skipped.
|
|
179
|
+
export function parseReportArgs(content) {
|
|
180
|
+
return content
|
|
181
|
+
.split('\n')
|
|
182
|
+
.map((line) => line.trim())
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.map((line) => {
|
|
185
|
+
const idx = line.indexOf(':');
|
|
186
|
+
return idx === -1
|
|
187
|
+
? { label: line, file: '' }
|
|
188
|
+
: { label: line.slice(0, idx), file: line.slice(idx + 1) };
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function sortBySeverity(findings) {
|
|
193
|
+
return [...findings].sort((a, b) => a.rank - b.rank);
|
|
194
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, expect, test } from '@jest/globals';
|
|
2
|
+
import {
|
|
3
|
+
rankOf,
|
|
4
|
+
extractFindings,
|
|
5
|
+
countBySeverity,
|
|
6
|
+
buildBuildLink,
|
|
7
|
+
buildParentPayload,
|
|
8
|
+
buildFindingText,
|
|
9
|
+
buildThreadPayload,
|
|
10
|
+
parseReportArgs,
|
|
11
|
+
sortBySeverity,
|
|
12
|
+
} from './report.js';
|
|
13
|
+
|
|
14
|
+
describe('rankOf', () => {
|
|
15
|
+
test('ranks known severities Critical -> Low', () => {
|
|
16
|
+
expect(rankOf('Critical')).toBe(0);
|
|
17
|
+
expect(rankOf('High')).toBe(1);
|
|
18
|
+
expect(rankOf('Medium')).toBe(2);
|
|
19
|
+
expect(rankOf('Low')).toBe(3);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('unranked/unknown severities sort last', () => {
|
|
23
|
+
expect(rankOf('Unknown')).toBe(9);
|
|
24
|
+
expect(rankOf(undefined)).toBe(9);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('extractFindings', () => {
|
|
29
|
+
test('flattens SCA rows with CVE ids, package and direct components', () => {
|
|
30
|
+
const data = {
|
|
31
|
+
vulnerabilities: [
|
|
32
|
+
{
|
|
33
|
+
severity: 'Critical',
|
|
34
|
+
cves: [{ cve: 'CVE-2024-0001' }, { cve: 'CVE-2024-0002' }],
|
|
35
|
+
impactedPackageName: 'lodash',
|
|
36
|
+
impactedPackageVersion: '4.17.15',
|
|
37
|
+
fixedVersions: ['4.17.21'],
|
|
38
|
+
components: [{ name: 'my-app' }, { name: 'my-app' }],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const [finding] = extractFindings(data, 'my-repo deps');
|
|
44
|
+
|
|
45
|
+
expect(finding).toMatchObject({
|
|
46
|
+
scan: 'my-repo deps',
|
|
47
|
+
kind: 'Vulnerability',
|
|
48
|
+
severity: 'Critical',
|
|
49
|
+
rank: 0,
|
|
50
|
+
cve: 'CVE-2024-0001',
|
|
51
|
+
cvesAll: ['CVE-2024-0001', 'CVE-2024-0002'],
|
|
52
|
+
pkg: 'lodash 4.17.15',
|
|
53
|
+
fixed: '4.17.21',
|
|
54
|
+
directs: 'my-app',
|
|
55
|
+
path: '',
|
|
56
|
+
line: 0,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('falls back to issueId when a vulnerability has no CVEs', () => {
|
|
61
|
+
const data = {
|
|
62
|
+
securityViolations: [{ severity: 'High', issueId: 'XRAY-123' }],
|
|
63
|
+
};
|
|
64
|
+
const [finding] = extractFindings(data, 'scan');
|
|
65
|
+
expect(finding.cve).toBe('XRAY-123');
|
|
66
|
+
expect(finding.cvesAll).toEqual([]);
|
|
67
|
+
expect(finding.kind).toBe('Security violation');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('flattens code rows (secrets/SAST/IaC) with file + line', () => {
|
|
71
|
+
const data = {
|
|
72
|
+
secrets: [
|
|
73
|
+
{
|
|
74
|
+
severity: 'Medium',
|
|
75
|
+
finding: 'AWS key',
|
|
76
|
+
file: 'src/config.js',
|
|
77
|
+
startLine: 12,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
sast: [
|
|
81
|
+
{
|
|
82
|
+
severity: 'Low',
|
|
83
|
+
ruleId: 'js/hardcoded-secret',
|
|
84
|
+
scannerShortDescription: 'x',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const findings = extractFindings(data, 'scan');
|
|
90
|
+
|
|
91
|
+
expect(findings[0]).toMatchObject({
|
|
92
|
+
kind: 'Secret',
|
|
93
|
+
severity: 'Medium',
|
|
94
|
+
pkg: 'AWS key',
|
|
95
|
+
path: 'src/config.js',
|
|
96
|
+
line: 12,
|
|
97
|
+
});
|
|
98
|
+
expect(findings[1]).toMatchObject({
|
|
99
|
+
kind: 'SAST',
|
|
100
|
+
cve: 'js/hardcoded-secret',
|
|
101
|
+
pkg: 'x',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('missing sections produce no findings', () => {
|
|
106
|
+
expect(extractFindings({}, 'scan')).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('countBySeverity', () => {
|
|
111
|
+
test('counts only the four named severities and totals them', () => {
|
|
112
|
+
const findings = [
|
|
113
|
+
{ severity: 'Critical' },
|
|
114
|
+
{ severity: 'High' },
|
|
115
|
+
{ severity: 'High' },
|
|
116
|
+
{ severity: 'Unknown' },
|
|
117
|
+
];
|
|
118
|
+
expect(countBySeverity(findings)).toEqual({
|
|
119
|
+
Critical: 1,
|
|
120
|
+
High: 2,
|
|
121
|
+
Medium: 0,
|
|
122
|
+
Low: 0,
|
|
123
|
+
total: 3,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('buildBuildLink', () => {
|
|
129
|
+
test('builds a pipeline results URL when all pieces are present', () => {
|
|
130
|
+
expect(
|
|
131
|
+
buildBuildLink({
|
|
132
|
+
workspace: 'dp-cloud',
|
|
133
|
+
repoSlug: 'my-repo',
|
|
134
|
+
buildNumber: '42',
|
|
135
|
+
}),
|
|
136
|
+
).toBe('https://bitbucket.org/dp-cloud/my-repo/pipelines/results/42');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('returns empty string when any piece is missing', () => {
|
|
140
|
+
expect(
|
|
141
|
+
buildBuildLink({
|
|
142
|
+
workspace: 'dp-cloud',
|
|
143
|
+
repoSlug: '',
|
|
144
|
+
buildNumber: '42',
|
|
145
|
+
}),
|
|
146
|
+
).toBe('');
|
|
147
|
+
expect(buildBuildLink({})).toBe('');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('buildParentPayload', () => {
|
|
152
|
+
test('includes branch and pipeline link when present', () => {
|
|
153
|
+
const payload = buildParentPayload({
|
|
154
|
+
channel: '#security-alerts',
|
|
155
|
+
project: 'my-repo',
|
|
156
|
+
branch: 'main',
|
|
157
|
+
buildLink: 'https://bitbucket.org/x/y/pipelines/results/1',
|
|
158
|
+
counts: { Critical: 1, High: 2, Medium: 0, Low: 0, total: 3 },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(payload.channel).toBe('#security-alerts');
|
|
162
|
+
const projectSection = payload.blocks[1].text.text;
|
|
163
|
+
expect(projectSection).toContain('*Project:* my-repo');
|
|
164
|
+
expect(projectSection).toContain('*Branch:* main');
|
|
165
|
+
expect(payload.blocks[3].elements[0].text).toContain('Pipeline run');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('omits branch and link when not provided', () => {
|
|
169
|
+
const payload = buildParentPayload({
|
|
170
|
+
channel: '#c',
|
|
171
|
+
project: 'my-repo',
|
|
172
|
+
branch: '',
|
|
173
|
+
buildLink: '',
|
|
174
|
+
counts: { Critical: 0, High: 0, Medium: 0, Low: 0, total: 0 },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(payload.blocks[1].text.text).toBe('*Project:* my-repo');
|
|
178
|
+
expect(payload.blocks[3].elements[0].text).toBe(
|
|
179
|
+
'Detailed findings are in the thread below 👇',
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('buildFindingText / buildThreadPayload', () => {
|
|
185
|
+
test('links CVE ids to NVD', () => {
|
|
186
|
+
const finding = {
|
|
187
|
+
severity: 'Critical',
|
|
188
|
+
scan: 'my-repo deps',
|
|
189
|
+
kind: 'Vulnerability',
|
|
190
|
+
cve: 'CVE-2024-0001',
|
|
191
|
+
cvesAll: ['CVE-2024-0001'],
|
|
192
|
+
pkg: 'lodash 4.17.15',
|
|
193
|
+
fixed: '4.17.21',
|
|
194
|
+
directs: 'my-app',
|
|
195
|
+
path: '',
|
|
196
|
+
line: 0,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const text = buildFindingText(finding);
|
|
200
|
+
expect(text).toContain('🔴 *Critical*');
|
|
201
|
+
expect(text).toContain(
|
|
202
|
+
'<https://nvd.nist.gov/vuln/detail/CVE-2024-0001|CVE-2024-0001>',
|
|
203
|
+
);
|
|
204
|
+
expect(text).toContain('*Fixed in:* 4.17.21');
|
|
205
|
+
expect(text).toContain('*Via:* my-app');
|
|
206
|
+
expect(text).not.toContain('*File:*');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('non-CVE rule ids are left as plain text', () => {
|
|
210
|
+
const finding = {
|
|
211
|
+
severity: 'Low',
|
|
212
|
+
scan: 'scan',
|
|
213
|
+
kind: 'SAST',
|
|
214
|
+
cve: 'js/hardcoded-secret',
|
|
215
|
+
cvesAll: [],
|
|
216
|
+
pkg: 'x',
|
|
217
|
+
fixed: '',
|
|
218
|
+
directs: '',
|
|
219
|
+
path: 'src/config.js',
|
|
220
|
+
line: 12,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const text = buildFindingText(finding);
|
|
224
|
+
expect(text).toContain('👻 *Low*');
|
|
225
|
+
expect(text).toContain('*ID:* js/hardcoded-secret');
|
|
226
|
+
expect(text).toContain('*File:* `src/config.js:12`');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('buildThreadPayload wraps the text in a Slack section block', () => {
|
|
230
|
+
const finding = {
|
|
231
|
+
severity: 'Medium',
|
|
232
|
+
scan: 'scan',
|
|
233
|
+
kind: 'IaC',
|
|
234
|
+
cve: 'N/A',
|
|
235
|
+
cvesAll: [],
|
|
236
|
+
pkg: 'x',
|
|
237
|
+
fixed: '',
|
|
238
|
+
directs: '',
|
|
239
|
+
path: '',
|
|
240
|
+
line: 0,
|
|
241
|
+
};
|
|
242
|
+
const payload = buildThreadPayload({
|
|
243
|
+
channel: '#c',
|
|
244
|
+
threadTs: '123.456',
|
|
245
|
+
finding,
|
|
246
|
+
});
|
|
247
|
+
expect(payload).toMatchObject({ channel: '#c', thread_ts: '123.456' });
|
|
248
|
+
expect(payload.blocks[0].text.text).toContain('🎃 *Medium*');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('parseReportArgs', () => {
|
|
253
|
+
test('splits "label:file" lines and ignores blanks', () => {
|
|
254
|
+
expect(
|
|
255
|
+
parseReportArgs(
|
|
256
|
+
'my-repo deps:xray-report/my-repo-audit.json\n\napp image:xray-report/app-docker.json\n',
|
|
257
|
+
),
|
|
258
|
+
).toEqual([
|
|
259
|
+
{ label: 'my-repo deps', file: 'xray-report/my-repo-audit.json' },
|
|
260
|
+
{ label: 'app image', file: 'xray-report/app-docker.json' },
|
|
261
|
+
]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('returns empty file when a line has no colon', () => {
|
|
265
|
+
expect(parseReportArgs('just-a-label')).toEqual([
|
|
266
|
+
{ label: 'just-a-label', file: '' },
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('sortBySeverity', () => {
|
|
272
|
+
test('orders Critical -> High -> Medium -> Low without mutating input', () => {
|
|
273
|
+
const findings = [{ rank: 3 }, { rank: 0 }, { rank: 1 }];
|
|
274
|
+
const sorted = sortBySeverity(findings);
|
|
275
|
+
expect(sorted.map((f) => f.rank)).toEqual([0, 1, 3]);
|
|
276
|
+
expect(findings.map((f) => f.rank)).toEqual([3, 0, 1]);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
|
|
3
|
+
const SLACK_API = 'https://slack.com/api/chat.postMessage';
|
|
4
|
+
|
|
5
|
+
// Thin wrapper so callers can mock the network boundary in tests.
|
|
6
|
+
export async function postMessage(payload, token) {
|
|
7
|
+
const { data } = await axios.post(SLACK_API, payload, {
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${token}`,
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
return data;
|
|
14
|
+
}
|