@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.
@@ -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