@docupace/pipeline 1.0.121 → 1.0.122-connect.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/bin/tenable-scan.sh +224 -0
- package/bin/xray-bitbucket-insights.sh +287 -0
- package/bin/xray-html-report.sh +278 -0
- package/bin/xray.sh +204 -221
- package/bitbucket-pipelines.yml +291 -0
- package/package.json +1 -1
- package/src/changeset/cli.js +59 -33
- package/src/changeset/convert.js +7 -0
- package/src/changeset/validate.js +10 -0
- package/src/changeset/version.js +11 -7
- package/src/next/deploy.js +20 -2
- package/src/next/util.js +10 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Tenable Cloud Security container image scan.
|
|
4
|
+
#
|
|
5
|
+
# Builds the image, exports it to a tar, and scans the tar with
|
|
6
|
+
# tenable/cloud-security-scanner. The archive route is used deliberately: the
|
|
7
|
+
# scanner container has no access to the host docker daemon, so --name alone
|
|
8
|
+
# would make it try to pull the image from a registry — which fails for an
|
|
9
|
+
# image we've only just built locally.
|
|
10
|
+
#
|
|
11
|
+
# A repo can hold more than one app, so this scans every image listed in
|
|
12
|
+
# SCAN_TARGETS (shared with the Xray step) rather than a single build context.
|
|
13
|
+
#
|
|
14
|
+
# Required (set as Bitbucket pipeline variables):
|
|
15
|
+
# TENABLE_API_TOKEN, TENABLE_API_URL
|
|
16
|
+
# Optional:
|
|
17
|
+
# SCAN_TARGETS label:auditDir:buildContext:dockerfile[;...]
|
|
18
|
+
# auditDir is ignored here; a target with an
|
|
19
|
+
# empty buildContext is skipped
|
|
20
|
+
# TENABLE_FAIL_ON_MIN_SEVERITY Low|Medium|High|Critical — fail the step at or
|
|
21
|
+
# above this severity (default: report only)
|
|
22
|
+
# TENABLE_MIN_SEVERITY lowest severity to include (default Medium)
|
|
23
|
+
# XRAY_BUILD_CONTEXT single-target fallback when SCAN_TARGETS unset
|
|
24
|
+
# XRAY_DOCKERFILE_PATH Dockerfile (default <context>/Dockerfile)
|
|
25
|
+
# TENABLE_IMAGE scan this image instead of building one
|
|
26
|
+
# TENABLE_SCANNER_IMAGE override the scanner image/tag
|
|
27
|
+
|
|
28
|
+
set -uo pipefail
|
|
29
|
+
|
|
30
|
+
REPORT_DIR="${TENABLE_REPORT_DIR:-tenable-report}"
|
|
31
|
+
SCANNER_IMAGE="${TENABLE_SCANNER_IMAGE:-tenable/cloud-security-scanner:latest}"
|
|
32
|
+
MIN_SEVERITY="${TENABLE_MIN_SEVERITY:-Medium}"
|
|
33
|
+
|
|
34
|
+
if [[ -z "${TENABLE_API_TOKEN:-}" || -z "${TENABLE_API_URL:-}" ]]; then
|
|
35
|
+
echo "TENABLE_API_TOKEN / TENABLE_API_URL not set — skipping Tenable scan."
|
|
36
|
+
exit 0
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# These runners inherit DOCKER_HOST=tcp://localhost:2375 with no daemon there,
|
|
40
|
+
# and DOCKER_CONTEXT is reserved by the docker CLI for selecting a named context.
|
|
41
|
+
export DOCKER_HOST=""
|
|
42
|
+
unset DOCKER_CONTEXT
|
|
43
|
+
|
|
44
|
+
COMMIT="${BITBUCKET_COMMIT:-local}"
|
|
45
|
+
SHORT_SHA="${COMMIT:0:7}"
|
|
46
|
+
|
|
47
|
+
# Git metadata is auto-detected from a mounted repo; pass it explicitly so the
|
|
48
|
+
# scan is attributed correctly regardless of clone depth or mount layout.
|
|
49
|
+
CODE_BRANCH="${BITBUCKET_BRANCH:-${BITBUCKET_PR_DESTINATION_BRANCH:-unknown}}"
|
|
50
|
+
CODE_REPO_URL="https://bitbucket.org/${BITBUCKET_WORKSPACE:-unknown}/${BITBUCKET_REPO_SLUG:-unknown}"
|
|
51
|
+
CODE_USER="$(git log -1 --pretty=format:'%an' 2>/dev/null || echo 'bitbucket-pipelines')"
|
|
52
|
+
[[ -z "${CODE_USER}" ]] && CODE_USER="bitbucket-pipelines"
|
|
53
|
+
|
|
54
|
+
# Pipeline attribution. The scanner requires --pipeline-run-url as soon as any
|
|
55
|
+
# pipeline flag is supplied, so default it to this run's URL rather than letting
|
|
56
|
+
# a half-set trio fail validation. The TENABLE_PIPELINE_RUN_* variables from the
|
|
57
|
+
# docs are honoured when present.
|
|
58
|
+
RUN_ID="${TENABLE_PIPELINE_RUN_ID:-${BITBUCKET_BUILD_NUMBER:-}}"
|
|
59
|
+
RUN_TRIGGER="${TENABLE_PIPELINE_RUN_TRIGGER:-${BITBUCKET_PR_ID:+pull_request}}"
|
|
60
|
+
RUN_TRIGGER="${RUN_TRIGGER:-push}"
|
|
61
|
+
RUN_URL="${TENABLE_PIPELINE_RUN_URL:-}"
|
|
62
|
+
if [[ -z "${RUN_URL}" && -n "${BITBUCKET_BUILD_NUMBER:-}" ]]; then
|
|
63
|
+
RUN_URL="https://bitbucket.org/${BITBUCKET_WORKSPACE:-unknown}/${BITBUCKET_REPO_SLUG:-unknown}/pipelines/results/${BITBUCKET_BUILD_NUMBER}"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
mkdir -p "${REPORT_DIR}"
|
|
67
|
+
|
|
68
|
+
# Scans one image and writes its reports to ${REPORT_DIR}/<slug>-tenable.*
|
|
69
|
+
# Args: slug, build context, dockerfile, pre-built image ("" to build)
|
|
70
|
+
# Returns the scanner's exit code.
|
|
71
|
+
scan_one() {
|
|
72
|
+
local slug="$1" build_context="$2" dockerfile="$3" prebuilt="$4"
|
|
73
|
+
local scan_target built_image="" work
|
|
74
|
+
|
|
75
|
+
if [[ -n "${prebuilt}" ]]; then
|
|
76
|
+
scan_target="${prebuilt}"
|
|
77
|
+
echo "Scanning pre-existing image ${scan_target}"
|
|
78
|
+
elif [[ -f "${dockerfile}" ]]; then
|
|
79
|
+
scan_target="${BITBUCKET_REPO_SLUG:-image}-${slug}:tenable-${SHORT_SHA}"
|
|
80
|
+
echo "Building ${scan_target} from ${dockerfile} (context ${build_context})..."
|
|
81
|
+
# --load is buildx-only; fall back to the legacy builder, which loads into
|
|
82
|
+
# the local image store anyway.
|
|
83
|
+
local build_args=("${build_context}" "--file" "${dockerfile}" "--tag" "${scan_target}")
|
|
84
|
+
if docker buildx version >/dev/null 2>&1; then
|
|
85
|
+
build_args+=("--load")
|
|
86
|
+
fi
|
|
87
|
+
if ! docker build "${build_args[@]}"; then
|
|
88
|
+
echo "Docker build failed for '${slug}' — skipping its Tenable scan."
|
|
89
|
+
return 1
|
|
90
|
+
fi
|
|
91
|
+
built_image="${scan_target}"
|
|
92
|
+
else
|
|
93
|
+
echo "No Dockerfile at ${dockerfile} — nothing to scan for '${slug}'."
|
|
94
|
+
return 0
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
# Keep the work dir inside the build directory: it must be bind-mountable, and
|
|
98
|
+
# on some hosts the system temp dir is a symlink that docker won't follow.
|
|
99
|
+
work="$(mktemp -d "$(pwd)/.tenable-work.XXXXXX")"
|
|
100
|
+
|
|
101
|
+
# The scanner runs in its own container with no access to the host daemon, so
|
|
102
|
+
# hand it the image as a tar rather than a name to resolve.
|
|
103
|
+
echo "Exporting ${scan_target} to an archive..."
|
|
104
|
+
if ! docker save "${scan_target}" -o "${work}/image.tar"; then
|
|
105
|
+
echo "docker save failed for '${slug}' — skipping its Tenable scan."
|
|
106
|
+
rm -rf "${work}"
|
|
107
|
+
[[ -n "${built_image}" ]] && docker rmi -f "${built_image}" >/dev/null 2>&1
|
|
108
|
+
return 1
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
# --output-path must already exist, and the scanner validates it before scanning.
|
|
112
|
+
mkdir -p "${work}/out"
|
|
113
|
+
|
|
114
|
+
local scan_args=(
|
|
115
|
+
"container-image" "scan"
|
|
116
|
+
"--archive-path" "/tmp/tenable/image.tar"
|
|
117
|
+
"--name" "${scan_target}"
|
|
118
|
+
"--code-branch" "${CODE_BRANCH}"
|
|
119
|
+
"--code-commit-hash" "${COMMIT}"
|
|
120
|
+
"--code-commit-user" "${CODE_USER}"
|
|
121
|
+
"--code-repository-url" "${CODE_REPO_URL}"
|
|
122
|
+
"--min-severity" "${MIN_SEVERITY}"
|
|
123
|
+
"--output-file-formats" "Json,Md,JUnit"
|
|
124
|
+
"--output-path" "/tmp/tenable/out"
|
|
125
|
+
"--output-file-name" "${slug}-tenable"
|
|
126
|
+
"--no-color"
|
|
127
|
+
)
|
|
128
|
+
# All three travel together, or none of them do.
|
|
129
|
+
if [[ -n "${RUN_ID}" && -n "${RUN_URL}" ]]; then
|
|
130
|
+
scan_args+=(
|
|
131
|
+
"--pipeline-run-id" "${RUN_ID}"
|
|
132
|
+
"--pipeline-run-trigger" "${RUN_TRIGGER}"
|
|
133
|
+
"--pipeline-run-url" "${RUN_URL}"
|
|
134
|
+
)
|
|
135
|
+
fi
|
|
136
|
+
if [[ -n "${TENABLE_FAIL_ON_MIN_SEVERITY:-}" ]]; then
|
|
137
|
+
scan_args+=("--fail-on-min-severity" "${TENABLE_FAIL_ON_MIN_SEVERITY}")
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
echo "Running Tenable scan for '${slug}' (min-severity=${MIN_SEVERITY}${TENABLE_FAIL_ON_MIN_SEVERITY:+, fail-on=${TENABLE_FAIL_ON_MIN_SEVERITY}})..."
|
|
141
|
+
docker run --rm \
|
|
142
|
+
--workdir /tmp/tenable \
|
|
143
|
+
--volume "${work}:/tmp/tenable" \
|
|
144
|
+
--env TENABLE_API_TOKEN \
|
|
145
|
+
--env TENABLE_API_URL \
|
|
146
|
+
--pull=always \
|
|
147
|
+
"${SCANNER_IMAGE}" "${scan_args[@]}"
|
|
148
|
+
local scan_exit=$?
|
|
149
|
+
|
|
150
|
+
# Collect reports even on a non-zero exit — a threshold failure still produces them.
|
|
151
|
+
if compgen -G "${work}/out/*" > /dev/null; then
|
|
152
|
+
cp -R "${work}/out/." "${REPORT_DIR}/"
|
|
153
|
+
else
|
|
154
|
+
echo "Tenable produced no report files for '${slug}'."
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
# Surface the summary in the build log.
|
|
158
|
+
if [[ -f "${REPORT_DIR}/${slug}-tenable.md" ]]; then
|
|
159
|
+
echo "--- Tenable summary: ${slug} ---"
|
|
160
|
+
head -60 "${REPORT_DIR}/${slug}-tenable.md"
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
rm -rf "${work}"
|
|
164
|
+
[[ -n "${built_image}" ]] && docker rmi -f "${built_image}" >/dev/null 2>&1
|
|
165
|
+
return "${scan_exit}"
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# TENABLE_IMAGE names an already-built image, which is inherently single-target.
|
|
169
|
+
if [[ -n "${TENABLE_IMAGE:-}" ]]; then
|
|
170
|
+
scan_one "image" "" "" "${TENABLE_IMAGE}"
|
|
171
|
+
EXIT_CODE=$?
|
|
172
|
+
else
|
|
173
|
+
if [[ -z "${SCAN_TARGETS:-}" ]]; then
|
|
174
|
+
CTX="${XRAY_BUILD_CONTEXT:-.}"
|
|
175
|
+
SCAN_TARGETS="app::${CTX}:${XRAY_DOCKERFILE_PATH:-${CTX}/Dockerfile}"
|
|
176
|
+
echo "SCAN_TARGETS not set — scanning a single target: ${SCAN_TARGETS}"
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
EXIT_CODE=0
|
|
180
|
+
SCANNED=0
|
|
181
|
+
# Split on ';' into targets, then on ':' into label:auditDir:context:dockerfile.
|
|
182
|
+
# auditDir belongs to the Xray step and is ignored here.
|
|
183
|
+
IFS=';' read -r -a TARGETS <<< "${SCAN_TARGETS}"
|
|
184
|
+
for target in "${TARGETS[@]}"; do
|
|
185
|
+
[[ -z "${target}" ]] && continue
|
|
186
|
+
IFS=':' read -r t_label _t_audit t_context t_dockerfile <<< "${target}"
|
|
187
|
+
[[ -z "${t_label}" ]] && t_label="app"
|
|
188
|
+
# Labels reach filenames and image tags, so keep them tame.
|
|
189
|
+
t_slug="$(printf '%s' "${t_label}" | tr -c 'A-Za-z0-9._-' '-')"
|
|
190
|
+
if [[ -z "${t_context}" ]]; then
|
|
191
|
+
echo "Target '${t_label}' has no build context — skipping its image scan."
|
|
192
|
+
continue
|
|
193
|
+
fi
|
|
194
|
+
[[ -z "${t_dockerfile}" ]] && t_dockerfile="${t_context}/Dockerfile"
|
|
195
|
+
|
|
196
|
+
echo "=============================================================="
|
|
197
|
+
echo "Tenable target '${t_label}'"
|
|
198
|
+
echo "=============================================================="
|
|
199
|
+
scan_one "${t_slug}" "${t_context}" "${t_dockerfile}" ""
|
|
200
|
+
rc=$?
|
|
201
|
+
SCANNED=$((SCANNED + 1))
|
|
202
|
+
# Report the worst outcome across targets, so one clean app can't mask
|
|
203
|
+
# another's failure.
|
|
204
|
+
[[ "${rc}" -ne 0 ]] && EXIT_CODE="${rc}"
|
|
205
|
+
done
|
|
206
|
+
|
|
207
|
+
if [[ "${SCANNED}" -eq 0 ]]; then
|
|
208
|
+
echo "No scannable targets in SCAN_TARGETS — nothing to do."
|
|
209
|
+
fi
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
if compgen -G "${REPORT_DIR}/*" > /dev/null; then
|
|
213
|
+
echo "Reports written to ${REPORT_DIR}/:"
|
|
214
|
+
ls -1 "${REPORT_DIR}"
|
|
215
|
+
fi
|
|
216
|
+
|
|
217
|
+
if [[ "${EXIT_CODE}" -ne 0 ]]; then
|
|
218
|
+
if [[ -n "${TENABLE_FAIL_ON_MIN_SEVERITY:-}" ]]; then
|
|
219
|
+
echo "Tenable scan exited ${EXIT_CODE} — findings at or above ${TENABLE_FAIL_ON_MIN_SEVERITY}, or a scan itself failed."
|
|
220
|
+
else
|
|
221
|
+
echo "Tenable scan exited ${EXIT_CODE}."
|
|
222
|
+
fi
|
|
223
|
+
fi
|
|
224
|
+
exit "${EXIT_CODE}"
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Publishes Xray findings to Bitbucket Code Insights, so results show up on the
|
|
4
|
+
# commit and in the pull request instead of only inside a downloaded artifact.
|
|
5
|
+
#
|
|
6
|
+
# Usage: xray-bitbucket-insights.sh <label:file.json> [label:file.json ...]
|
|
7
|
+
#
|
|
8
|
+
# Auth, in order of preference:
|
|
9
|
+
# 1. the pipelines auth proxy on localhost:29418, which signs requests for us.
|
|
10
|
+
# It only exists on Atlassian-hosted runners, and the API must then be
|
|
11
|
+
# called over http:// (not https://) so the proxy can inject the header.
|
|
12
|
+
# 2. BITBUCKET_ACCESS_TOKEN / BITBUCKET_STEP_OAUTH_TOKEN as a bearer token.
|
|
13
|
+
# 3. BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD as basic auth.
|
|
14
|
+
# Self-hosted runners (runs-on: self.hosted) have no proxy, so one of 2 or 3 is
|
|
15
|
+
# required there — otherwise this exits without publishing.
|
|
16
|
+
#
|
|
17
|
+
# Behaviour:
|
|
18
|
+
# - one report per commit (ID: xray-security), result PASSED/FAILED
|
|
19
|
+
# - annotations for findings that have a file path (secrets/SAST/IaC), so they
|
|
20
|
+
# appear inline in the PR diff
|
|
21
|
+
# - dependency findings have no file, so they are summarised in the report card
|
|
22
|
+
# - never fails the build: a reporting problem shouldn't mask a scan result
|
|
23
|
+
#
|
|
24
|
+
# XRAY_INSIGHTS_FAIL_ON: lowest severity that marks the report FAILED.
|
|
25
|
+
# critical | high (default) | medium | low | never
|
|
26
|
+
|
|
27
|
+
set -uo pipefail
|
|
28
|
+
|
|
29
|
+
if [[ -z "${BITBUCKET_COMMIT:-}" ]]; then
|
|
30
|
+
echo "Not running in Bitbucket Pipelines (BITBUCKET_COMMIT unset) — skipping Code Insights."
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
34
|
+
echo "jq not available — skipping Code Insights."
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
WORKSPACE="${BITBUCKET_WORKSPACE:-${BITBUCKET_REPO_OWNER:-}}"
|
|
39
|
+
REPO="${BITBUCKET_REPO_SLUG:-}"
|
|
40
|
+
COMMIT="${BITBUCKET_COMMIT}"
|
|
41
|
+
REPORT_ID="xray-security"
|
|
42
|
+
PROXY="${BITBUCKET_API_PROXY:-http://localhost:29418}"
|
|
43
|
+
FAIL_ON="${XRAY_INSIGHTS_FAIL_ON:-high}"
|
|
44
|
+
API_PATH="2.0/repositories/${WORKSPACE}/${REPO}/commit/${COMMIT}/reports/${REPORT_ID}"
|
|
45
|
+
|
|
46
|
+
api() {
|
|
47
|
+
# $1 method, $2 url, $3 optional body file
|
|
48
|
+
local method="$1" url="$2" body="${3:-}"
|
|
49
|
+
if [[ -n "$body" ]]; then
|
|
50
|
+
curl -sS "${CURL_AUTH[@]}" -X "$method" "$url" \
|
|
51
|
+
-H 'Content-Type: application/json' --data-binary "@${body}" \
|
|
52
|
+
-w '\n%{http_code}' 2>&1
|
|
53
|
+
else
|
|
54
|
+
curl -sS "${CURL_AUTH[@]}" -X "$method" "$url" -w '\n%{http_code}' 2>&1
|
|
55
|
+
fi
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
TMP_ALL="$(mktemp)"
|
|
59
|
+
TMP_BODY="$(mktemp)"
|
|
60
|
+
TMP_ANN="$(mktemp)"
|
|
61
|
+
trap 'rm -f "$TMP_ALL" "$TMP_BODY" "$TMP_ANN"' EXIT
|
|
62
|
+
# JSONL, one finding per line: a real scan's findings exceed Linux's 128 KB
|
|
63
|
+
# per-argument limit, so they can't be passed through --argjson.
|
|
64
|
+
: > "$TMP_ALL"
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Collect findings from every simple-json input.
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
PARSED_SCANS=0
|
|
70
|
+
SKIPPED_SCANS=""
|
|
71
|
+
for arg in "$@"; do
|
|
72
|
+
label="${arg%%:*}"
|
|
73
|
+
file="${arg#*:}"
|
|
74
|
+
if [[ ! -s "$file" ]] || ! jq empty "$file" >/dev/null 2>&1; then
|
|
75
|
+
SKIPPED_SCANS="${SKIPPED_SCANS}${SKIPPED_SCANS:+, }${label}"
|
|
76
|
+
continue
|
|
77
|
+
fi
|
|
78
|
+
PARSED_SCANS=$((PARSED_SCANS + 1))
|
|
79
|
+
|
|
80
|
+
jq -c --arg scan "$label" '
|
|
81
|
+
def sev: (.severity // "Unknown");
|
|
82
|
+
def sca(rows; kind): (rows // [])[] | {
|
|
83
|
+
scan: $scan, kind: kind, severity: sev,
|
|
84
|
+
id: (((.cves // []) | map(.cve // empty) | join(", ")) as $c
|
|
85
|
+
| if $c == "" then (.issueId // "N/A") else $c end),
|
|
86
|
+
package: ((.impactedPackageName // "N/A") + ":" + (.impactedPackageVersion // "")),
|
|
87
|
+
fixed: ((.fixedVersions // []) | join(", ")),
|
|
88
|
+
summary: (.summary // ""),
|
|
89
|
+
applicable: (.applicable // ""),
|
|
90
|
+
path: "", line: 0
|
|
91
|
+
};
|
|
92
|
+
def code(rows; kind): (rows // [])[] | {
|
|
93
|
+
scan: $scan, kind: kind, severity: sev,
|
|
94
|
+
id: (.ruleId // "N/A"),
|
|
95
|
+
package: (.finding // .scannerShortDescription // "N/A"),
|
|
96
|
+
fixed: "",
|
|
97
|
+
summary: (.scannerDescription // .scannerShortDescription // .finding // ""),
|
|
98
|
+
applicable: "",
|
|
99
|
+
path: (.file // ""), line: (.startLine // 0)
|
|
100
|
+
};
|
|
101
|
+
[ sca(.vulnerabilities; "Vulnerability")
|
|
102
|
+
, sca(.securityViolations; "Security violation")
|
|
103
|
+
, sca(.licensesViolations; "License violation")
|
|
104
|
+
, sca(.operationalRiskViolations; "Operational risk")
|
|
105
|
+
, code(.secrets; "Secret")
|
|
106
|
+
, code(.secretsViolations; "Secret violation")
|
|
107
|
+
, code(.sast; "SAST")
|
|
108
|
+
, code(.sastViolations; "SAST violation")
|
|
109
|
+
, code(.iac; "IaC")
|
|
110
|
+
, code(.iacViolations; "IaC violation")
|
|
111
|
+
, code(.maliciousCode; "Malicious code")
|
|
112
|
+
] | flatten | .[]
|
|
113
|
+
' "$file" 2>/dev/null >> "$TMP_ALL"
|
|
114
|
+
done
|
|
115
|
+
|
|
116
|
+
if [[ "$PARSED_SCANS" -eq 0 ]]; then
|
|
117
|
+
echo "No parseable scan output — publishing a PENDING Code Insights report."
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
count_sev() { jq -s --arg s "$1" '[.[]|select(.severity==$s)]|length' "$TMP_ALL"; }
|
|
121
|
+
CRITICAL=$(count_sev Critical); HIGH=$(count_sev High)
|
|
122
|
+
MEDIUM=$(count_sev Medium); LOW=$(count_sev Low)
|
|
123
|
+
TOTAL=$(jq -s 'length' "$TMP_ALL")
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Pass/fail decision.
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
case "$FAIL_ON" in
|
|
129
|
+
critical) BLOCKING=$((CRITICAL)) ;;
|
|
130
|
+
high) BLOCKING=$((CRITICAL + HIGH)) ;;
|
|
131
|
+
medium) BLOCKING=$((CRITICAL + HIGH + MEDIUM)) ;;
|
|
132
|
+
low) BLOCKING=$((CRITICAL + HIGH + MEDIUM + LOW)) ;;
|
|
133
|
+
never) BLOCKING=0 ;;
|
|
134
|
+
*) BLOCKING=$((CRITICAL + HIGH)) ;;
|
|
135
|
+
esac
|
|
136
|
+
|
|
137
|
+
if [[ "$PARSED_SCANS" -eq 0 ]]; then
|
|
138
|
+
RESULT="PENDING"
|
|
139
|
+
DETAILS="Xray produced no parseable output — the scan did not complete. Check the pipeline log."
|
|
140
|
+
elif [[ "$BLOCKING" -gt 0 ]]; then
|
|
141
|
+
RESULT="FAILED"
|
|
142
|
+
DETAILS="${BLOCKING} finding(s) at or above ${FAIL_ON} severity. ${TOTAL} total across ${PARSED_SCANS} scan(s)."
|
|
143
|
+
else
|
|
144
|
+
RESULT="PASSED"
|
|
145
|
+
DETAILS="No findings at or above ${FAIL_ON} severity. ${TOTAL} total across ${PARSED_SCANS} scan(s)."
|
|
146
|
+
fi
|
|
147
|
+
if [[ -n "$SKIPPED_SCANS" ]]; then
|
|
148
|
+
DETAILS="${DETAILS} Incomplete: ${SKIPPED_SCANS}."
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
# Log the tally before anything can bail out, so the numbers are in the build
|
|
152
|
+
# log even when there's no way to publish them.
|
|
153
|
+
echo "Xray findings: critical=${CRITICAL} high=${HIGH} medium=${MEDIUM} low=${LOW} total=${TOTAL} → ${RESULT}"
|
|
154
|
+
|
|
155
|
+
LINK=""
|
|
156
|
+
if [[ -n "${BITBUCKET_BUILD_NUMBER:-}" ]]; then
|
|
157
|
+
LINK="https://bitbucket.org/${WORKSPACE}/${REPO}/pipelines/results/${BITBUCKET_BUILD_NUMBER}"
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
# data is capped at 10 elements by the API.
|
|
161
|
+
jq -n \
|
|
162
|
+
--arg details "$DETAILS" --arg result "$RESULT" --arg link "$LINK" \
|
|
163
|
+
--argjson critical "$CRITICAL" --argjson high "$HIGH" \
|
|
164
|
+
--argjson medium "$MEDIUM" --argjson low "$LOW" --argjson total "$TOTAL" \
|
|
165
|
+
'{
|
|
166
|
+
title: "JFrog Xray Security Scan",
|
|
167
|
+
details: $details,
|
|
168
|
+
report_type: "SECURITY",
|
|
169
|
+
reporter: "JFrog Xray",
|
|
170
|
+
result: $result,
|
|
171
|
+
data: [
|
|
172
|
+
{title:"Critical", type:"NUMBER", value:$critical},
|
|
173
|
+
{title:"High", type:"NUMBER", value:$high},
|
|
174
|
+
{title:"Medium", type:"NUMBER", value:$medium},
|
|
175
|
+
{title:"Low", type:"NUMBER", value:$low},
|
|
176
|
+
{title:"Total", type:"NUMBER", value:$total}
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
| if $link != "" then . + {link:$link} else . end' > "$TMP_BODY"
|
|
180
|
+
|
|
181
|
+
# Pick an auth method. The proxy is probed rather than assumed: on a self-hosted
|
|
182
|
+
# runner nothing listens on 29418 and every call fails with "connection refused".
|
|
183
|
+
# Any HTTP response means the proxy is there; we only care about connectivity.
|
|
184
|
+
CURL_AUTH=()
|
|
185
|
+
if curl -s -o /dev/null --max-time 5 --proxy "$PROXY" \
|
|
186
|
+
"http://api.bitbucket.org/2.0/repositories/${WORKSPACE}/${REPO}" 2>/dev/null; then
|
|
187
|
+
BASE="http://api.bitbucket.org/${API_PATH}"
|
|
188
|
+
CURL_AUTH=(--proxy "$PROXY")
|
|
189
|
+
echo "Publishing via the Bitbucket Pipelines auth proxy."
|
|
190
|
+
elif [[ -n "${BITBUCKET_ACCESS_TOKEN:-}" ]]; then
|
|
191
|
+
BASE="https://api.bitbucket.org/${API_PATH}"
|
|
192
|
+
CURL_AUTH=(-H "Authorization: Bearer ${BITBUCKET_ACCESS_TOKEN}")
|
|
193
|
+
echo "Publishing with BITBUCKET_ACCESS_TOKEN (no auth proxy on this runner)."
|
|
194
|
+
elif [[ -n "${BITBUCKET_STEP_OAUTH_TOKEN:-}" ]]; then
|
|
195
|
+
BASE="https://api.bitbucket.org/${API_PATH}"
|
|
196
|
+
CURL_AUTH=(-H "Authorization: Bearer ${BITBUCKET_STEP_OAUTH_TOKEN}")
|
|
197
|
+
echo "Publishing with the step OAuth token (no auth proxy on this runner)."
|
|
198
|
+
elif [[ -n "${BITBUCKET_USERNAME:-}" && -n "${BITBUCKET_APP_PASSWORD:-}" ]]; then
|
|
199
|
+
BASE="https://api.bitbucket.org/${API_PATH}"
|
|
200
|
+
# -u keeps the credential out of the URL, so it can't leak via -w or an error.
|
|
201
|
+
CURL_AUTH=(-u "${BITBUCKET_USERNAME}:${BITBUCKET_APP_PASSWORD}")
|
|
202
|
+
echo "Publishing with BITBUCKET_USERNAME/APP_PASSWORD (no auth proxy on this runner)."
|
|
203
|
+
else
|
|
204
|
+
echo "No auth proxy on this runner and no BITBUCKET_ACCESS_TOKEN, step OAuth token,"
|
|
205
|
+
echo "or BITBUCKET_USERNAME+BITBUCKET_APP_PASSWORD set — skipping Code Insights."
|
|
206
|
+
exit 0
|
|
207
|
+
fi
|
|
208
|
+
|
|
209
|
+
echo "Publishing Code Insights report (${RESULT}: crit=${CRITICAL} high=${HIGH} med=${MEDIUM} low=${LOW})..."
|
|
210
|
+
resp="$(api PUT "$BASE" "$TMP_BODY")"
|
|
211
|
+
code="$(tail -n1 <<<"$resp")"
|
|
212
|
+
if [[ "$code" != "200" && "$code" != "201" ]]; then
|
|
213
|
+
echo "Could not publish Code Insights report (HTTP ${code}):"
|
|
214
|
+
sed '$d' <<<"$resp" | head -5
|
|
215
|
+
exit 0
|
|
216
|
+
fi
|
|
217
|
+
echo "Report published: ${BASE}"
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Annotations. Only findings with a file path can be placed in the diff; the API
|
|
221
|
+
# accepts at most 1000 per report and 100 per bulk POST.
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
ANN_TOTAL=$(jq -s '[.[]|select(.path != "")]|length' "$TMP_ALL")
|
|
224
|
+
if [[ "$ANN_TOTAL" -eq 0 ]]; then
|
|
225
|
+
echo "No file-scoped findings to annotate (dependency findings are summarised in the report)."
|
|
226
|
+
exit 0
|
|
227
|
+
fi
|
|
228
|
+
|
|
229
|
+
jq -s -c '
|
|
230
|
+
# Chunk into batches of 100 (the bulk-POST limit). _nwise is jq-internal and
|
|
231
|
+
# absent in newer builds, so slice explicitly.
|
|
232
|
+
def chunks($n): . as $a
|
|
233
|
+
| [range(0; ($a|length); $n)] | map($a[. : . + $n]);
|
|
234
|
+
def sev_map: if . == "Critical" then "CRITICAL"
|
|
235
|
+
elif . == "High" then "HIGH"
|
|
236
|
+
elif . == "Medium" then "MEDIUM"
|
|
237
|
+
elif . == "Low" then "LOW"
|
|
238
|
+
else "LOW" end;
|
|
239
|
+
def ann_type: if . == "Secret" or . == "Secret violation" or . == "Malicious code"
|
|
240
|
+
then "VULNERABILITY"
|
|
241
|
+
elif . == "SAST" or . == "SAST violation" then "BUG"
|
|
242
|
+
else "VULNERABILITY" end;
|
|
243
|
+
[ .[] | select(.path != "") ]
|
|
244
|
+
| .[:1000]
|
|
245
|
+
| to_entries
|
|
246
|
+
| map(
|
|
247
|
+
# Derive the ID from the finding itself, not its position, so re-running a
|
|
248
|
+
# scan updates the same annotation instead of leaving orphans behind.
|
|
249
|
+
# The index is a tiebreaker for genuine duplicates.
|
|
250
|
+
("xray-" + (.value.id + "-" + .value.path + "-" + (.value.line|tostring)
|
|
251
|
+
| gsub("[^A-Za-z0-9._-]"; "-") | .[:230])
|
|
252
|
+
+ "-" + (.key|tostring)) as $eid
|
|
253
|
+
| {
|
|
254
|
+
external_id: $eid,
|
|
255
|
+
annotation_type: (.value.kind | ann_type),
|
|
256
|
+
severity: (.value.severity | sev_map),
|
|
257
|
+
summary: ("[" + .value.severity + "] " + .value.id + " — " + .value.package),
|
|
258
|
+
details: (.value.summary | .[:2000]),
|
|
259
|
+
path: .value.path
|
|
260
|
+
}
|
|
261
|
+
+ (if .value.line > 0 then {line: .value.line} else {} end))
|
|
262
|
+
| chunks(100)[]
|
|
263
|
+
' "$TMP_ALL" > "$TMP_ANN"
|
|
264
|
+
|
|
265
|
+
BATCH=0
|
|
266
|
+
POSTED=0
|
|
267
|
+
while IFS= read -r batch; do
|
|
268
|
+
[[ -z "$batch" ]] && continue
|
|
269
|
+
BATCH=$((BATCH + 1))
|
|
270
|
+
printf '%s' "$batch" > "${TMP_ANN}.batch"
|
|
271
|
+
resp="$(api POST "${BASE}/annotations" "${TMP_ANN}.batch")"
|
|
272
|
+
code="$(tail -n1 <<<"$resp")"
|
|
273
|
+
n="$(jq 'length' <<<"$batch")"
|
|
274
|
+
if [[ "$code" == "200" || "$code" == "201" ]]; then
|
|
275
|
+
POSTED=$((POSTED + n))
|
|
276
|
+
else
|
|
277
|
+
echo "Annotation batch ${BATCH} rejected (HTTP ${code}):"
|
|
278
|
+
sed '$d' <<<"$resp" | head -5
|
|
279
|
+
fi
|
|
280
|
+
done < "$TMP_ANN"
|
|
281
|
+
rm -f "${TMP_ANN}.batch"
|
|
282
|
+
|
|
283
|
+
echo "Published ${POSTED}/${ANN_TOTAL} annotation(s) in ${BATCH} batch(es)."
|
|
284
|
+
if [[ "$ANN_TOTAL" -gt 1000 ]]; then
|
|
285
|
+
echo "Note: capped at the API limit of 1000 annotations; $((ANN_TOTAL - 1000)) not shown in the diff."
|
|
286
|
+
fi
|
|
287
|
+
exit 0
|