Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions scripts/test-ci-workflow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -382,6 +385,107 @@ 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. 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_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
}

# 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'\"."
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."
Expand Down
105 changes: 98 additions & 7 deletions scripts/test-ci-workflow.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -259,15 +259,68 @@ 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
echo "grep 'yq_linux_amd64$' | awk '{print $1}'"
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 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
Expand Down Expand Up @@ -807,6 +860,44 @@ 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

# ── 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."
Expand Down
Loading