From b1966be051b34ecaba2a36b350431628eb4df1fb Mon Sep 17 00:00:00 2001 From: don-petry <36422719+don-petry@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:38:09 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20implement=20issue=20#552=20?= =?UTF-8?q?=E2=80=94=20SonarCloud:=20GitHub=20Actions=20/=20dependency=20h?= =?UTF-8?q?ardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 +- scripts/test-ci-workflow.sh | 28 ++++++++++++ scripts/test-ci-workflow.test.sh | 76 +++++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fb7711e..ab9f3d9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,10 +78,10 @@ jobs: # this workflow's failure rate. .gitleaksignore is still honoured as a # fallback. Guarded by scripts/test-ci-workflow.sh Check 8. run: | - curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors \ + curl -sSfL --proto '=https' --retry 3 --retry-delay 2 --retry-all-errors \ "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz" \ -o /tmp/gitleaks.tar.gz - EXPECTED=$(curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors \ + EXPECTED=$(curl -sSfL --proto '=https' --retry 3 --retry-delay 2 --retry-all-errors \ "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_checksums.txt" \ | grep "gitleaks_8.30.1_linux_x64.tar.gz" | awk '{print $1}') echo "${EXPECTED} /tmp/gitleaks.tar.gz" | sha256sum --check diff --git a/scripts/test-ci-workflow.sh b/scripts/test-ci-workflow.sh index 1f1425f0..1f543305 100755 --- a/scripts/test-ci-workflow.sh +++ b/scripts/test-ci-workflow.sh @@ -29,6 +29,9 @@ # first failing matrix leg cancels its siblings, which land as cancelled # runs and discard the other OSes' results — inflating the Fleet Monitor # cancelled/failure metrics and hiding per-OS signal. See Check 9. +# 7. (#552) every `curl` download passes `--proto '=https'`, so a followed +# redirect cannot downgrade it to plain HTTP (SonarCloud +# githubactions:S6506). See Check 10. # # Accepts an optional workflow path (default: .github/workflows/ci.yml) so the # checks can be exercised against fixtures — see test-ci-workflow.test.sh. @@ -382,6 +385,31 @@ if [[ "$any_matrix" == "false" ]]; then echo "PASS: no matrix strategy in $WORKFLOW — fail-fast check not applicable" fi +# ── Check 10: every `curl` download is restricted to HTTPS ───────────────── +# SonarCloud githubactions:S6506 (#552): `curl -L` follows redirects to any +# protocol, so an https:// URL can be redirected to plain http:// and the +# download fetched in cleartext. `--proto '=https'` restricts every request — +# including followed redirects — to HTTPS. Reuses Check 6's folded, split +# commands so each chained curl is evaluated independently. No-op when the +# workflow contains no curl. +curl_missing_proto=false +while IFS= read -r line; do + if grep -Eq '(^|[^[:alnum:]_-])curl([[:space:]]|$)' <<< "$line" \ + && ! grep -Eq -- "--proto[[:space:]=]+['\"]?=https['\"]?([[:space:]]|$)" <<< "$line"; then + curl_missing_proto=true + fi +done <<< "$split_commands" + +if [[ "$curl_missing_proto" == "true" ]]; then + echo "FAIL: a 'curl' download in $WORKFLOW does not pass \"--proto '=https'\"." + echo " Following a redirect could then downgrade the download to plain" + echo " HTTP (SonarCloud githubactions:S6506). Add \"--proto '=https'\" to" + echo " every curl." + PASS=false +else + echo "PASS: every 'curl' download is restricted to HTTPS in $WORKFLOW" +fi + echo "" if [[ "$PASS" == "true" ]]; then echo "All checks passed." diff --git a/scripts/test-ci-workflow.test.sh b/scripts/test-ci-workflow.test.sh index c8c1bfd3..53ae89e6 100644 --- a/scripts/test-ci-workflow.test.sh +++ b/scripts/test-ci-workflow.test.sh @@ -92,8 +92,8 @@ append_correct_yq() { env: YQ_VERSION: v4.44.6 run: | - curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o /tmp/yq - EXPECTED=$(curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums" | grep "^yq_linux_amd64 " | awk '{print $19}') + curl -sSfL --proto '=https' --retry 3 --retry-delay 2 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o /tmp/yq + EXPECTED=$(curl -sSfL --proto '=https' --retry 3 --retry-delay 2 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums" | grep "^yq_linux_amd64 " | awk '{print $19}') echo "${EXPECTED} /tmp/yq" | sha256sum --check YAML } @@ -218,10 +218,10 @@ append_curl_with_retry_multiline() { env: YQ_VERSION: v4.44.6 run: | - curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors \ + curl -sSfL --proto '=https' --retry 3 --retry-delay 2 --retry-all-errors \ "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \ -o /tmp/yq - EXPECTED=$(curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors \ + EXPECTED=$(curl -sSfL --proto '=https' --retry 3 --retry-delay 2 --retry-all-errors \ "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums" \ | grep "^yq_linux_amd64 " | awk '{print $19}') echo "${EXPECTED} /tmp/yq" | sha256sum --check @@ -241,7 +241,7 @@ append_curl_without_retry_chained() { steps: - name: Download tools run: | - curl -sSfL "https://example.com/tool" -o /tmp/tool && curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors "https://example.com/checksums" -o /tmp/checksums + curl -sSfL "https://example.com/tool" -o /tmp/tool && curl -sSfL --proto '=https' --retry 3 --retry-delay 2 --retry-all-errors "https://example.com/checksums" -o /tmp/checksums YAML } @@ -259,8 +259,8 @@ append_correct_yq_with_unnamed_step() { env: YQ_VERSION: v4.44.6 run: | - curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o /tmp/yq - EXPECTED=$(curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums" | grep "^yq_linux_amd64 " | awk '{print $19}') + curl -sSfL --proto '=https' --retry 3 --retry-delay 2 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o /tmp/yq + EXPECTED=$(curl -sSfL --proto '=https' --retry 3 --retry-delay 2 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums" | grep "^yq_linux_amd64 " | awk '{print $19}') echo "${EXPECTED} /tmp/yq" | sha256sum --check - run: | # unnamed step with suspicious content — guard must ignore it @@ -268,6 +268,48 @@ append_correct_yq_with_unnamed_step() { YAML } +# A job whose curl downloads retry but do not restrict protocols to HTTPS. A +# redirect could then downgrade the download to plain HTTP (SonarCloud +# githubactions:S6506, #552). ONLY the missing --proto '=https' (Check 10) +# differentiates this fixture from append_curl_with_retry_multiline. +append_curl_without_proto() { + local file="$1" + cat >> "$file" <<'YAML' + workflow-tests: + name: Workflow regression guards + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Install yq + env: + YQ_VERSION: v4.44.6 + run: | + curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors \ + "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \ + -o /tmp/yq + EXPECTED=$(curl -sSfL --retry 3 --retry-delay 2 --retry-all-errors \ + "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums" \ + | grep "^yq_linux_amd64 " | awk '{print $19}') + echo "${EXPECTED} /tmp/yq" | sha256sum --check +YAML +} + +# A step where a curl without --proto '=https' is chained (&&) with one that has +# it. The guard must evaluate each chained curl independently. +append_curl_without_proto_chained() { + local file="$1" + cat >> "$file" <<'YAML' + workflow-tests: + name: Workflow regression guards + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download tools + run: | + curl -sSfL --retry 3 "https://example.com/tool" -o /tmp/tool && curl -sSfL --proto '=https' --retry 3 "https://example.com/checksums" -o /tmp/checksums +YAML +} + # A gitleaks CLI enforcement step that runs a full-history scan but passes NO # --config. Root cause of Fleet Monitor #474: without a committed --config the # scan suppresses false positives only via .gitleaksignore commit-SHA @@ -807,6 +849,26 @@ else fail "workflow without a matrix strategy should pass" fi +# ── Case 29: curl without --proto '=https' is rejected (S6506, #552) ────────── +noproto="${TMP}/ci-noproto.yml" +write_header "$noproto" +append_curl_without_proto "$noproto" +if run_guard "$noproto"; then + fail "curl download without --proto '=https' should be REJECTED" +else + pass "curl download without --proto '=https' is rejected" +fi + +# ── Case 30: curl without --proto chained (&&) with one that has it — rejected ─ +noproto_chained="${TMP}/ci-noproto-chained.yml" +write_header "$noproto_chained" +append_curl_without_proto_chained "$noproto_chained" +if run_guard "$noproto_chained"; then + fail "curl without --proto '=https' chained with one that has it should be REJECTED" +else + pass "curl without --proto '=https' chained with one that has it is rejected" +fi + echo "" if [[ "$fails" -eq 0 ]]; then echo "All tests passed." From 9e5042ff0ae04d8af5fd421aa2ec5c077febe136 Mon Sep 17 00:00:00 2001 From: don-petry <36422719+don-petry@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:43:48 +0000 Subject: [PATCH 2/2] fix(reviews): tokenize curl commands in Check 10 so comments and earlier --proto cannot satisfy it Co-Authored-By: Claude Sonnet 5.5 --- scripts/test-ci-workflow.sh | 92 +++++++++++++++++++++++++++++--- scripts/test-ci-workflow.test.sh | 29 ++++++++++ 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/scripts/test-ci-workflow.sh b/scripts/test-ci-workflow.sh index 1f543305..643c85ef 100755 --- a/scripts/test-ci-workflow.sh +++ b/scripts/test-ci-workflow.sh @@ -389,16 +389,92 @@ fi # SonarCloud githubactions:S6506 (#552): `curl -L` follows redirects to any # protocol, so an https:// URL can be redirected to plain http:// and the # download fetched in cleartext. `--proto '=https'` restricts every request — -# including followed redirects — to HTTPS. Reuses Check 6's folded, split -# commands so each chained curl is evaluated independently. No-op when the +# including followed redirects — to HTTPS. Unlike Check 6, this one tokenizes +# the shell (quotes honoured, `#` comments dropped when they begin an unquoted +# word, `&&`/`||`/`;`/`|` split commands) so that text inside a comment cannot +# satisfy the check, and curl uses the LAST `--proto` given, so only the final +# value is judged. `curl` must be an exact executable token. No-op when the # workflow contains no curl. -curl_missing_proto=false -while IFS= read -r line; do - if grep -Eq '(^|[^[:alnum:]_-])curl([[:space:]]|$)' <<< "$line" \ - && ! grep -Eq -- "--proto[[:space:]=]+['\"]?=https['\"]?([[:space:]]|$)" <<< "$line"; then - curl_missing_proto=true +curl_proto_bad=false + +# Judge one tokenized command (args: tokens). Sets curl_proto_bad when it runs +# curl without an effective final `--proto =https`. +judge_curl_command() { + local i tok proto="" found=false + for ((i = 1; i <= $#; i++)); do + tok="${!i}" + if [[ "$found" == "false" ]]; then + tok="${tok##*\$(}" + tok="${tok##*\`}" + tok="${tok#(}" + [[ "$tok" == "curl" ]] && found=true + continue + fi + if [[ "$tok" == "--proto" ]]; then + i=$((i + 1)) + proto="${!i:-}" + elif [[ "$tok" == --proto=* ]]; then + proto="${tok#--proto=}" + fi + done + if [[ "$found" == "true" && "$proto" != "=https" ]]; then + curl_proto_bad=true fi -done <<< "$split_commands" +} + +# Tokenize each line of $1 and judge every command found in it. +scan_curl_proto() { + local text="$1" line c i quote word has_word + local -a toks + while IFS= read -r line; do + toks=() + quote="" + word="" + has_word=false + for ((i = 0; i < ${#line}; i++)); do + c="${line:i:1}" + if [[ "$quote" == "'" ]]; then + if [[ "$c" == "'" ]]; then quote=""; else word+="$c"; fi + elif [[ "$quote" == '"' ]]; then + if [[ "$c" == '"' ]]; then + quote="" + elif [[ "$c" == "\\" && $((i + 1)) -lt ${#line} ]]; then + i=$((i + 1)) + word+="${line:i:1}" + else + word+="$c" + fi + elif [[ "$c" == "'" || "$c" == '"' ]]; then + quote="$c" + has_word=true + elif [[ "$c" == "\\" && $((i + 1)) -lt ${#line} ]]; then + i=$((i + 1)) + word+="${line:i:1}" + has_word=true + elif [[ "$c" == "#" && "$has_word" == "false" ]]; then + break + elif [[ "$c" == [[:space:]] ]]; then + if [[ "$has_word" == "true" ]]; then toks+=("$word"); fi + word="" + has_word=false + elif [[ "$c" == "&" || "$c" == "|" || "$c" == ";" ]]; then + if [[ "$has_word" == "true" ]]; then toks+=("$word"); fi + word="" + has_word=false + if [[ ${#toks[@]} -gt 0 ]]; then judge_curl_command "${toks[@]}"; fi + toks=() + else + word+="$c" + has_word=true + fi + done + if [[ "$has_word" == "true" ]]; then toks+=("$word"); fi + if [[ ${#toks[@]} -gt 0 ]]; then judge_curl_command "${toks[@]}"; fi + done <<< "$text" +} + +scan_curl_proto "$folded" +curl_missing_proto="$curl_proto_bad" if [[ "$curl_missing_proto" == "true" ]]; then echo "FAIL: a 'curl' download in $WORKFLOW does not pass \"--proto '=https'\"." diff --git a/scripts/test-ci-workflow.test.sh b/scripts/test-ci-workflow.test.sh index 53ae89e6..c23eda0f 100644 --- a/scripts/test-ci-workflow.test.sh +++ b/scripts/test-ci-workflow.test.sh @@ -310,6 +310,17 @@ append_curl_without_proto_chained() { YAML } +# A single-step job whose run body is the curl line given as $2. +append_curl_line() { + local file="$1" line="$2" + { + printf '%s\n' ' workflow-tests:' ' name: Workflow regression guards' \ + ' runs-on: ubuntu-latest' ' timeout-minutes: 10' ' steps:' \ + ' - name: Download tools' ' run: |' + printf ' %s\n' "$line" + } >> "$file" +} + # A gitleaks CLI enforcement step that runs a full-history scan but passes NO # --config. Root cause of Fleet Monitor #474: without a committed --config the # scan suppresses false positives only via .gitleaksignore commit-SHA @@ -869,6 +880,24 @@ else pass "curl without --proto '=https' chained with one that has it is rejected" fi +# ── Cases 31-33: --proto must be real and effective (Check 10) ─────────────── +proto_case() { + local label="$1" expect="$2" line="$3" f + f="${TMP}/ci-proto-${label}.yml" + write_header "$f" + append_curl_line "$f" "$line" + if run_guard "$f"; then + [[ "$expect" == "pass" ]] && pass "$label accepted" || fail "$label should be REJECTED" + else + [[ "$expect" == "fail" ]] && pass "$label rejected" || fail "$label should be accepted" + fi +} +proto_case comment-only fail "curl -sSfL --retry 3 https://example.com/tool -o tool # --proto '=https'" +proto_case last-wins fail "curl -sSfL --retry 3 --proto '=https' --proto '=all' https://example.com/tool" +proto_case last-https pass "curl -sSfL --retry 3 --proto '=all' --proto '=https' https://example.com/tool" +proto_case equals-form pass "curl -sSfL --retry 3 --proto='=https' https://example.com/tool" +proto_case hash-in-url pass "curl -sSfL --retry 3 --proto '=https' 'https://example.com/a#b' -o t" + echo "" if [[ "$fails" -eq 0 ]]; then echo "All tests passed."