master
sh 188 lines 7.02 KB
Raw
1 #!/usr/bin/env bash
2 # Common helpers for sonarqube-audit scripts.
3 # Sourced from the per-action scripts; not executed directly.
4
5 set -euo pipefail
6
7 # IMPORTANT: define with $'...' so the variables contain real ESC bytes,
8 # not the literal four-character string "\033". This way both `echo -e
9 # "${SQ_RED}..."` and `printf '%s' "${SQ_RED}..."` render correctly --
10 # without forcing every printf format string to be the variable itself
11 # (which trips shellcheck SC2059) or %b (which adds inconsistency).
12 #
13 # Color vars are referenced by sourcing scripts; shellcheck cannot see that.
14 # shellcheck disable=SC2034
15 SQ_RED=$'\033[0;31m'
16 # shellcheck disable=SC2034
17 SQ_GREEN=$'\033[0;32m'
18 # shellcheck disable=SC2034
19 SQ_YELLOW=$'\033[1;33m'
20 # shellcheck disable=SC2034
21 SQ_GRAY=$'\033[0;90m'
22 # shellcheck disable=SC2034
23 SQ_NC=$'\033[0m'
24
25 sq_repo_root() {
26 git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel
27 }
28
29 sq_load_env() {
30 local root env
31 root="$(sq_repo_root)"
32 env="${root}/.env"
33 if [[ ! -f "${env}" || ! -r "${env}" ]]; then
34 echo -e "${SQ_RED}[ERROR]${SQ_NC} Missing ${env}. See SKILL.md for the .env template." >&2
35 return 1
36 fi
37 set -a
38 # shellcheck disable=SC1090
39 source "${env}"
40 set +a
41
42 : "${SONAR_TOKEN:?SONAR_TOKEN is empty in .env}"
43 : "${SONAR_HOST_URL:=https://sonarcloud.io}"
44 : "${SONAR_PROJECT:?SONAR_PROJECT is empty in .env (e.g. netdata_netdata)}"
45 # SONAR_ORG is optional today -- the existing scripts don't pass it to
46 # the API, but qualityprofile management calls (documented in SKILL.md)
47 # require it. Default empty; consumers should fail loudly if they need it.
48 : "${SONAR_ORG:=}"
49 export SONAR_TOKEN SONAR_HOST_URL SONAR_PROJECT SONAR_ORG
50 }
51
52 sq_audit_dir() {
53 local root dir
54 root="$(sq_repo_root)"
55 dir="${root}/.local/audits/sonarqube"
56 mkdir -p "${dir}"
57 echo "${dir}"
58 }
59
60 # Cloudflare in front of api.sonarcloud.io rejects non-ASCII bodies.
61 # Fail before the network round-trip rather than debug a 403 challenge.
62 #
63 # `tr -d '\000-\177'` deletes ALL ASCII bytes; anything left is non-ASCII.
64 # This is portable across GNU and BSD/macOS (unlike `grep -P`, which is GNU-only).
65 sq_require_ascii() {
66 local s="$1"
67 if LC_ALL=C printf '%s' "${s}" | LC_ALL=C tr -d '\000-\177' | grep -q .; then
68 echo -e "${SQ_RED}[ERROR]${SQ_NC} Comment contains non-ASCII characters. Cloudflare blocks them. Replace em-dashes with '--' and curly quotes with straight quotes." >&2
69 return 1
70 fi
71 }
72
73 # Print a curl invocation with the token masked (for transparency without leaking).
74 # In SONAR_DRY_RUN mode the command is printed but not executed -- this only
75 # affects calls routed through sq_run, which is the WRITE path (api_post).
76 # Read-only API calls (issue/hotspot search used to enumerate findings) still
77 # run in dry-run so the caller can see what would be acted on.
78 sq_run() {
79 local arg
80 # Color vars contain real ESC bytes (defined with $'...'), so '%s' is
81 # both safe (no SC2059) and correctly renders the colors.
82 printf >&2 '%s> %s' "${SQ_GRAY}" "${SQ_YELLOW}"
83 for arg in "$@"; do
84 if [[ "${arg}" == "${SONAR_TOKEN}:" ]]; then
85 printf >&2 '%q ' '<TOKEN>:'
86 else
87 printf >&2 '%q ' "${arg}"
88 fi
89 done
90 printf >&2 '%s\n' "${SQ_NC}"
91 if [[ "${SONAR_DRY_RUN:-0}" == "1" ]]; then
92 return 0
93 fi
94 "$@"
95 }
96
97 # URL-encode a string for inclusion in a Sonar API query parameter.
98 # Sonar rule IDs (`c:S2245`, `shelldre:S131`, etc.) contain `:` which must
99 # become `%3A`; some rule namespaces also contain other reserved chars.
100 # Limitation: emits the codepoint, not UTF-8 bytes, for non-ASCII -- safe
101 # for Sonar rule IDs (always ASCII) but do NOT use this for arbitrary
102 # user-supplied strings without auditing.
103 sq_url_encode() {
104 local s="$1" out="" i ch
105 for (( i=0; i<${#s}; i++ )); do
106 ch="${s:$i:1}"
107 case "${ch}" in
108 [a-zA-Z0-9._~-]) out+="${ch}" ;;
109 *) out+="$(printf '%%%02X' "'${ch}")" ;;
110 esac
111 done
112 printf '%s' "${out}"
113 }
114
115 # Read-only Sonar API call: prints the masked curl line (transparency)
116 # but always executes (dry-run only suppresses WRITE calls). Use this for
117 # enumeration calls that drive subsequent decisions (issue/hotspot search).
118 sq_run_read() {
119 local arg
120 printf >&2 '%s> %s' "${SQ_GRAY}" "${SQ_YELLOW}"
121 for arg in "$@"; do
122 if [[ "${arg}" == "${SONAR_TOKEN}:" ]]; then
123 printf >&2 '%q ' '<TOKEN>:'
124 else
125 printf >&2 '%q ' "${arg}"
126 fi
127 done
128 printf >&2 '%s\n' "${SQ_NC}"
129 "$@"
130 }
131
132 # Paginate a Sonar API listing endpoint until paging.total is reached.
133 # Emits each page's body to stdout (one JSON value per page); the caller
134 # composes them with `jq -s 'reduce .[] as $p (...)'` to sum or merge.
135 #
136 # Args:
137 # $1 = path with all query params EXCEPT ps and p (e.g.
138 # "/api/issues/search?componentKeys=...&resolved=false")
139 #
140 # Sonar's max page size is 500; we use it. The function uses sq_run_read
141 # so the masked curl is logged but execution is not skipped in dry-run
142 # (read-only enumeration should still happen so the caller sees what
143 # would be acted on).
144 sq_paginate() {
145 local path="$1"
146 local sep
147 if [[ "${path}" == *\?* ]]; then sep='&'; else sep='?'; fi
148 local page=1 total=-1 fetched=0
149 while :; do
150 local resp
151 resp="$(sq_run_read curl --fail --silent --show-error -u "${SONAR_TOKEN}:" \
152 "${SONAR_HOST_URL}${path}${sep}ps=500&p=${page}")"
153
154 # Validate the response is a JSON object with a .paging field --
155 # otherwise we can't decide when to stop and would loop forever
156 # (or terminate prematurely on 0). Bail loudly.
157 if ! jq -e 'type=="object" and has("paging")' >/dev/null 2>&1 <<< "${resp}"; then
158 echo -e "${SQ_RED}[sq_paginate]${SQ_NC} response missing .paging on ${path} page ${page}; first 200 chars:" >&2
159 head -c 200 <<< "${resp}" >&2; echo >&2
160 return 1
161 fi
162
163 printf '%s\n' "${resp}"
164 # Sonar wraps results either under .issues / .hotspots / .components
165 # / .rules / .users; .paging is consistent across endpoints. Reject
166 # any response whose array key we don't recognise so the caller is
167 # forced to add support rather than silently get zero rows.
168 local array_key in_page
169 array_key=$(jq -r '
170 (.issues|values|"issues") //
171 (.hotspots|values|"hotspots") //
172 (.components|values|"components") //
173 (.rules|values|"rules") //
174 (.users|values|"users") //
175 empty
176 ' <<< "${resp}")
177 if [[ -z "${array_key}" ]]; then
178 echo -e "${SQ_RED}[sq_paginate]${SQ_NC} unrecognized payload from ${path}; expected one of issues/hotspots/components/rules/users." >&2
179 return 1
180 fi
181 in_page=$(jq -r --arg k "${array_key}" '.[$k] | length' <<< "${resp}")
182 total=$(jq -r '.paging.total' <<< "${resp}")
183 fetched=$(( fetched + in_page ))
184 (( fetched >= total || in_page == 0 )) && break
185 page=$(( page + 1 ))
186 done
187 }
188