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