master
sh 198 lines 7.39 KB
Raw
1 #!/usr/bin/env bash
2 # Apply per-finding triage decisions on SonarCloud:
3 # issues -> falsepositive | wontfix | confirm
4 # hotspots -> REVIEWED + (SAFE | ACKNOWLEDGED | FIXED)
5 #
6 # AUTHENTICATION:
7 # Reads SONAR_TOKEN, SONAR_HOST_URL, SONAR_PROJECT, SONAR_ORG from <repo-root>/.env.
8 # Token is sent as basic-auth username with empty password.
9 #
10 # COMMENTS MUST BE ASCII-ONLY:
11 # Cloudflare's WAF in front of api.sonarcloud.io blocks non-ASCII bodies
12 # (em-dashes, smart quotes, accented characters). Stick to "--", '"', etc.
13 #
14 # USAGE:
15 # sonar-mark.sh fp <ISSUE_KEY> <COMMENT> # Bug/Vuln -> False Positive
16 # sonar-mark.sh wontfix <ISSUE_KEY> <COMMENT> # Bug/Vuln -> Won't Fix
17 # sonar-mark.sh confirm <ISSUE_KEY> [COMMENT] # Bug/Vuln -> Confirmed (real, will fix)
18 # sonar-mark.sh safe <HOTSPOT_KEY> <COMMENT> # Hotspot -> REVIEWED + SAFE
19 # sonar-mark.sh ack <HOTSPOT_KEY> <COMMENT> # Hotspot -> REVIEWED + ACKNOWLEDGED
20 # sonar-mark.sh fixed <HOTSPOT_KEY> <COMMENT> # Hotspot -> REVIEWED + FIXED
21 #
22 # FAMILY MODE (acts on every open finding for a rule):
23 # sonar-mark.sh family-fp <RULE_ID> <COMMENT> # e.g. go:S2077
24 # sonar-mark.sh family-safe <RULE_ID> <COMMENT> # e.g. c:S5443
25 # sonar-mark.sh family-ack <RULE_ID> <COMMENT> # e.g. c:S5443
26 #
27 # Family mode prints the matched keys and prompts before acting unless
28 # SONAR_MARK_YES=1 is set in the environment.
29 #
30 # DRY RUN:
31 # Set SONAR_DRY_RUN=1 to print the curl commands without executing.
32
33 set -euo pipefail
34
35 # shellcheck source=./_lib.sh
36 # shellcheck disable=SC1091
37 source "$(dirname "$0")/_lib.sh"
38 sq_load_env
39
40 api_post() {
41 local path="$1"; shift
42 sq_run curl --fail --silent --show-error \
43 -u "${SONAR_TOKEN}:" \
44 -X POST "${SONAR_HOST_URL}${path}" "$@"
45 }
46
47 issue_add_comment() {
48 local key="$1" text="$2"
49 sq_require_ascii "${text}"
50 api_post "/api/issues/add_comment" \
51 --data-urlencode "issue=${key}" \
52 --data-urlencode "text=${text}" \
53 -o /dev/null
54 }
55
56 issue_transition() {
57 local key="$1" transition="$2"
58 api_post "/api/issues/do_transition" \
59 --data-urlencode "issue=${key}" \
60 --data-urlencode "transition=${transition}" \
61 -o /dev/null
62 }
63
64 mark_issue() {
65 local transition="$1" key="$2" comment="${3:-}"
66 if [[ -n "${comment}" ]]; then
67 issue_add_comment "${key}" "${comment}"
68 fi
69 issue_transition "${key}" "${transition}"
70 echo -e "${SQ_GREEN}[OK]${SQ_NC} issue ${key} -> ${transition}" >&2
71 }
72
73 hotspot_change_status() {
74 local key="$1" resolution="$2" comment="$3"
75 sq_require_ascii "${comment}"
76 # Add the comment first so it persists even if the transition fails.
77 api_post "/api/hotspots/add_comment" \
78 --data-urlencode "hotspot=${key}" \
79 --data-urlencode "comment=${comment}" \
80 -o /dev/null
81 api_post "/api/hotspots/change_status" \
82 --data-urlencode "hotspot=${key}" \
83 --data-urlencode "status=REVIEWED" \
84 --data-urlencode "resolution=${resolution}" \
85 -o /dev/null
86 echo -e "${SQ_GREEN}[OK]${SQ_NC} hotspot ${key} -> REVIEWED/${resolution}" >&2
87 }
88
89 list_open_issues_for_rule() {
90 # Sonar caps page size at 500; sq_paginate walks every page until
91 # paging.total. Token is masked in transparency log.
92 local rule="$1" rule_enc
93 rule_enc="$(sq_url_encode "${rule}")"
94 sq_paginate "/api/issues/search?componentKeys=${SONAR_PROJECT}&rules=${rule_enc}&resolved=false" \
95 | jq -r '.issues[].key'
96 }
97
98 list_open_hotspots_for_rule() {
99 # Sonar's hotspot search does not accept a rule filter -- we have to
100 # fetch all TO_REVIEW hotspots and filter client-side. Pass the rule
101 # via jq's --arg so values containing colons / quotes / shell
102 # metacharacters cannot inject into the filter.
103 local rule="$1"
104 sq_paginate "/api/hotspots/search?projectKey=${SONAR_PROJECT}&status=TO_REVIEW" \
105 | jq -r --arg rule "${rule}" '.hotspots[] | select(.ruleKey == $rule) | .key'
106 }
107
108 confirm_family() {
109 local rule="$1" count="$2" action="$3"
110 if [[ "${SONAR_MARK_YES:-0}" == "1" ]]; then
111 return 0
112 fi
113 echo -e "${SQ_YELLOW}About to ${action} ${count} finding(s) for rule ${rule}.${SQ_NC}" >&2
114 echo -en "${SQ_YELLOW}Proceed? [y/N] ${SQ_NC}" >&2
115 local ans
116 read -r ans
117 # Lowercase via tr -- bash 4+ has ${var,,} but macOS ships bash 3.2.
118 local ans_lc
119 ans_lc=$(printf '%s' "${ans}" | tr '[:upper:]' '[:lower:]')
120 [[ "${ans_lc}" == "y" || "${ans_lc}" == "yes" ]]
121 }
122
123 family_fp() {
124 local rule="$1" comment="$2"
125 sq_require_ascii "${comment}"
126 local keys
127 keys="$(list_open_issues_for_rule "${rule}")"
128 local count
129 count="$(printf '%s\n' "${keys}" | grep -c . || true)"
130 if [[ "${count}" == "0" ]]; then
131 echo -e "${SQ_YELLOW}No open issues for rule ${rule}.${SQ_NC}" >&2
132 return 0
133 fi
134 echo "${keys}" >&2
135 confirm_family "${rule}" "${count}" "mark as False Positive" || { echo "Aborted." >&2; return 1; }
136 while IFS= read -r key; do
137 [[ -z "${key}" ]] && continue
138 mark_issue "falsepositive" "${key}" "${comment}"
139 done <<< "${keys}"
140 }
141
142 family_safe() {
143 local rule="$1" comment="$2"
144 sq_require_ascii "${comment}"
145 local keys
146 keys="$(list_open_hotspots_for_rule "${rule}")"
147 local count
148 count="$(printf '%s\n' "${keys}" | grep -c . || true)"
149 if [[ "${count}" == "0" ]]; then
150 echo -e "${SQ_YELLOW}No open hotspots for rule ${rule}.${SQ_NC}" >&2
151 return 0
152 fi
153 echo "${keys}" >&2
154 confirm_family "${rule}" "${count}" "mark REVIEWED/SAFE" || { echo "Aborted." >&2; return 1; }
155 while IFS= read -r key; do
156 [[ -z "${key}" ]] && continue
157 hotspot_change_status "${key}" "SAFE" "${comment}"
158 done <<< "${keys}"
159 }
160
161 family_ack() {
162 local rule="$1" comment="$2"
163 sq_require_ascii "${comment}"
164 local keys
165 keys="$(list_open_hotspots_for_rule "${rule}")"
166 local count
167 count="$(printf '%s\n' "${keys}" | grep -c . || true)"
168 if [[ "${count}" == "0" ]]; then
169 echo -e "${SQ_YELLOW}No open hotspots for rule ${rule}.${SQ_NC}" >&2
170 return 0
171 fi
172 echo "${keys}" >&2
173 confirm_family "${rule}" "${count}" "mark REVIEWED/ACKNOWLEDGED" || { echo "Aborted." >&2; return 1; }
174 while IFS= read -r key; do
175 [[ -z "${key}" ]] && continue
176 hotspot_change_status "${key}" "ACKNOWLEDGED" "${comment}"
177 done <<< "${keys}"
178 }
179
180 usage() {
181 sed -n '4,33p' "$0"
182 exit 2
183 }
184
185 cmd="${1:-}"; shift || true
186 case "${cmd}" in
187 fp) mark_issue "falsepositive" "${1:?key required}" "${2:?comment required}" ;;
188 wontfix) mark_issue "wontfix" "${1:?key required}" "${2:?comment required}" ;;
189 confirm) mark_issue "confirm" "${1:?key required}" "${2:-}" ;;
190 safe) hotspot_change_status "${1:?key required}" "SAFE" "${2:?comment required}" ;;
191 ack) hotspot_change_status "${1:?key required}" "ACKNOWLEDGED" "${2:?comment required}" ;;
192 fixed) hotspot_change_status "${1:?key required}" "FIXED" "${2:?comment required}" ;;
193 family-fp) family_fp "${1:?rule id required (e.g. go:S2077)}" "${2:?comment required}" ;;
194 family-safe) family_safe "${1:?rule id required (e.g. c:S5443)}" "${2:?comment required}" ;;
195 family-ack) family_ack "${1:?rule id required (e.g. c:S5443)}" "${2:?comment required}" ;;
196 ""|-h|--help|help) usage ;;
197 *) echo -e "${SQ_RED}[ERROR]${SQ_NC} Unknown subcommand: ${cmd}" >&2; usage ;;
198 esac