test: harden version entrypoint

This commit is contained in:
2026-05-15 16:55:04 +00:00
parent 8cef794557
commit 4bdde4f7ce
3 changed files with 533 additions and 71 deletions
+32
View File
@@ -0,0 +1,32 @@
[
{
"level": "critical",
"role": "Aria",
"location": "entrypoint.sh",
"suggestion": "檔案結尾缺少一個換行符號。根據 POSIX 規範,所有文字檔案都應以換行符號結束。請在檔案的最後一行後新增一個換行符號。"
},
{
"level": "critical",
"role": "Maya",
"location": "entrypoint.sh:86-91",
"suggestion": "版本號遞增邏輯中,當 `PATCH` 達到 `10` 時會遞增 `MINOR`,當 `MINOR` 達到 `10` 時會遞增 `MAJOR`。這與標準的語義化版本控制(Semantic Versioning)慣例不同(例如,通常 `1.9.9` 之後是 `1.9.10`,而非 `2.0.0`)。請釐清預期的版本控制方案。如果目標是標準語義版本,則應移除 `MINOR` 和 `MAJOR` 在 `10` 時的進位邏輯。如果這是一個自訂的十進位版本系統,請務必清楚文件化此行為,並新增特定測試案例,例如 `v0.0.9`、`v0.9.9`、`v0.9.0`、`v1.9.9`,以驗證進位行為是否符合預期。"
},
{
"level": "warning",
"role": "Rex",
"location": "entrypoint.sh:42, entrypoint.sh:43, entrypoint.sh:55",
"suggestion": "GITEA_SERVER_URL 和 GITEA_REPOSITORY 變數直接用於構建 URL。雖然在 CI/CD 環境中這些變數通常被視為受信任的輸入,但若這些輸入來源不可信,可能導致伺服器端請求偽造(SSRF)或路徑遍歷攻擊。建議對這些數據進行更嚴格的輸入驗證,例如檢查 URL 是否符合預期的格式和網域,並確保儲存庫名稱不包含惡意字元(如 `../` 或特殊編碼字元)。"
},
{
"level": "warning",
"role": "Aria",
"location": "entrypoint.sh:44",
"suggestion": "`require_env` 函數中的 `printf '%s=%s\n' \"$name\" \"$value\"` 會直接回顯變數數值。對於 `RUNNER_TOKEN` 這類敏感資訊,這可能導致安全風險。建議修改此函數,使其僅回顯變數名稱,或對敏感變數的值進行遮蔽處理。例如,可以改為 `info \"$name 已設定\"`,或在呼叫 `require_env` 之前處理敏感變數的輸出。"
},
{
"level": "info",
"role": "Rex",
"location": "entrypoint.sh:109",
"suggestion": "將 NEW_VERSION 輸出到 GITHUB_OUTPUT 時,雖然目前 NEW_VERSION 的格式(如 `X.Y.Z` 或 `X.Y.Z-beta.N`)相對安全,不太可能包含換行符或其他特殊字元,但一般而言,應確保輸出到環境變數或檔案的內容不包含可能導致指令注入的特殊字元。對於複雜或多行內容,應使用 GitHub Actions 建議的多行輸出語法以確保安全。"
}
]
+180 -71
View File
@@ -33,77 +33,186 @@ write_output() {
printf 'version=%s\n' "$1" >> "$GITHUB_OUTPUT"
}
section "參數檢查"
normalize_beta_flag() {
local value="${1:-false}"
require_env "GITEA_SERVER_URL" "${GITEA_SERVER_URL:-}"
require_env "GITEA_REPOSITORY" "${GITEA_REPOSITORY:-}"
if [ -z "$value" ] || [ "$value" = "null" ]; then
printf '%s\n' "false"
return
fi
if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then
printf 'RUNNER_TOKEN=%s\n' '***'
else
printf 'RUNNER_TOKEN=%s\n' '未提供'
printf '%s\n' "$value"
}
latest_stable_version() {
local release_json="$1"
if [ -z "$release_json" ] || [ "$release_json" = "null" ]; then
printf '%s\n' "0.0.0"
return
fi
printf '%s' "$release_json" | jq -r '
if type == "array" then
[ .[] | select(.tag_name? and (.tag_name | test("-beta\\.") | not)) | .tag_name ][0]
// "v0.0.0"
else
"v0.0.0"
end
| sub("^v"; "")
'
}
next_release_version() {
local latest_version="$1"
local major minor patch
IFS='.' read -r major minor patch <<< "$latest_version"
major="${major:-0}"
minor="${minor:-0}"
patch="${patch:-0}"
patch=$((patch + 1))
if [ "$patch" -ge 10 ]; then
patch=0
minor=$((minor + 1))
fi
if [ "$minor" -ge 10 ]; then
minor=0
major=$((major + 1))
fi
printf '%s.%s.%s\n' "$major" "$minor" "$patch"
}
next_beta_number() {
local release_json="$1"
local version="$2"
if [ -z "$release_json" ] || [ "$release_json" = "null" ]; then
printf '%s\n' "1"
return
fi
printf '%s' "$release_json" | jq -r --arg prefix "v${version}-beta." '
if type == "array" then
[ .[]
| select(.tag_name? and (.tag_name | startswith($prefix)))
| .tag_name
| ltrimstr($prefix)
| try tonumber catch empty
] | if length > 0 then max else 0 end
else
0
end
' | awk '{print $1 + 1}'
}
calculate_version() {
local release_json="$1"
local is_beta="$2"
if [ -z "$release_json" ] || [ "$release_json" = "null" ]; then
if [ "$is_beta" = "true" ]; then
printf '%s\t%s\n' "0.0.0" "0.0.1-beta.1"
else
printf '%s\t%s\n' "0.0.0" "0.0.1"
fi
return
fi
printf '%s' "$release_json" | jq -r --arg is_beta "$is_beta" '
def stable_version:
if type == "array" then
[ .[] | select(.tag_name? and (.tag_name | test("-beta\\.") | not)) | .tag_name ][0]
// "v0.0.0"
else
"v0.0.0"
end
| sub("^v"; "");
def next_release($latest):
($latest | split(".") | map(tonumber? // 0)) as $parts
| ($parts[0] // 0) as $major
| ($parts[1] // 0) as $minor
| ($parts[2] // 0) as $patch
| ($patch + 1) as $next_patch
| if $next_patch >= 10 then
if ($minor + 1) >= 10 then
"\(($major + 1)).0.0"
else
"\($major).\($minor + 1).0"
end
else
"\($major).\($minor).\($next_patch)"
end;
def beta_max($prefix):
[ .[]
| select(.tag_name? and (.tag_name | startswith($prefix)))
| .tag_name
| ltrimstr($prefix)
| try tonumber catch empty
] | if length > 0 then max else 0 end;
(stable_version) as $base
| (next_release($base)) as $next
| if $is_beta == "true" then
$base + "\t" + ($next + "-beta." + ((beta_max("v" + $next + "-beta.") + 1) | tostring))
else
$base + "\t" + $next
end
'
}
main() {
section "參數檢查"
require_env "GITEA_SERVER_URL" "${GITEA_SERVER_URL:-}"
require_env "GITEA_REPOSITORY" "${GITEA_REPOSITORY:-}"
if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then
info "RUNNER_TOKEN=***"
else
info "RUNNER_TOKEN=未提供"
fi
IS_BETA="$(normalize_beta_flag "${IS_BETA:-false}")"
info "IS_BETA=$IS_BETA"
section "取得版本資料"
RELEASE_URL="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases"
info "RELEASE_URL=$RELEASE_URL"
if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then
info "使用授權 token 取得 release"
RELEASE_JSON="$(curl -fsS -H "Authorization: token $RUNNER_TOKEN" "$RELEASE_URL")"
else
info "使用匿名請求取得 release"
RELEASE_JSON="$(curl -fsS "$RELEASE_URL")"
fi
VERSION_INFO="$(calculate_version "$RELEASE_JSON" "$IS_BETA")"
IFS=$'\t' read -r LATEST_TAG NEW_VERSION <<< "$VERSION_INFO"
if [ -z "$LATEST_TAG" ] || [ "$LATEST_TAG" = "null" ]; then
LATEST_TAG="0.0.0"
fi
info "LATEST_VERSION=$LATEST_TAG"
section "計算版本號"
if [ -z "$NEW_VERSION" ] || [ "$NEW_VERSION" = "null" ]; then
NEW_VERSION="0.0.1"
fi
info "NEW_VERSION=$NEW_VERSION"
write_output "$NEW_VERSION"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi
IS_BETA="${IS_BETA:-false}"
if [ "$IS_BETA" = "null" ] || [ -z "$IS_BETA" ]; then
IS_BETA="false"
fi
printf 'IS_BETA=%s\n' "$IS_BETA"
section "取得版本資料"
RELEASE_URL="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases"
info "RELEASE_URL=$RELEASE_URL"
if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then
info "使用授權 token 取得 release"
RELEASE_JSON="$(curl -fsS -H "Authorization: token $RUNNER_TOKEN" "$RELEASE_URL")"
else
info "使用匿名請求取得 release"
RELEASE_JSON="$(curl -fsS "$RELEASE_URL")"
fi
LATEST_VERSION="$(
printf '%s' "$RELEASE_JSON" | jq -r '
[ .[] | select(.tag_name | test("-beta\\.") | not) | .tag_name ][0] // "v0.0.0"
' | sed 's/^v//'
)"
if [ -z "$LATEST_VERSION" ] || [ "$LATEST_VERSION" = "null" ]; then
LATEST_VERSION="0.0.0"
fi
info "LATEST_VERSION=$LATEST_VERSION"
section "計算版本號"
IFS='.' read -r MAJOR MINOR PATCH <<< "$LATEST_VERSION"
MAJOR="${MAJOR:-0}"
MINOR="${MINOR:-0}"
PATCH="${PATCH:-0}"
PATCH=$((PATCH + 1))
if [ "$PATCH" -ge 10 ]; then
PATCH=0
MINOR=$((MINOR + 1))
fi
if [ "$MINOR" -ge 10 ]; then
MINOR=0
MAJOR=$((MAJOR + 1))
fi
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
if [ "$IS_BETA" = "true" ]; then
BETA="$(
printf '%s' "$RELEASE_JSON" | jq -r --arg prefix "v$NEW_VERSION-beta." '
[ .[] | select(.tag_name | startswith($prefix)) | .tag_name | ltrimstr($prefix) | tonumber ] | if length > 0 then max else 0 end
'
)"
BETA=$((BETA + 1))
NEW_VERSION="$NEW_VERSION-beta.$BETA"
fi
info "NEW_VERSION=$NEW_VERSION"
write_output "$NEW_VERSION"
+321
View File
@@ -0,0 +1,321 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/entrypoint.sh"
fail() {
printf '[error] %s\n' "$1" >&2
exit 1
}
assert_eq() {
local expected="$1"
local actual="$2"
local label="$3"
if [ "$expected" != "$actual" ]; then
fail "$label: expected '$expected', got '$actual'"
fi
}
make_mock_curl() {
local bin_dir="$1"
local response_file="$2"
cat >"$bin_dir/curl" <<'EOF'
#!/bin/sh
if [ "${FAKE_CURL_STATUS:-0}" != "0" ]; then
exit "$FAKE_CURL_STATUS"
fi
cat "${FAKE_CURL_RESPONSE_FILE:?}"
EOF
chmod +x "$bin_dir/curl"
}
make_mock_jq() {
local bin_dir="$1"
cat >"$bin_dir/jq" <<'EOF'
#!/bin/sh
is_beta=""
query=""
while [ "$#" -gt 0 ]; do
case "$1" in
-r)
shift
;;
--arg)
if [ "$2" = "is_beta" ]; then
is_beta="$3"
fi
shift 3
;;
*)
query="$1"
shift
break
;;
esac
done
python3 -c 'import json, sys
query = sys.argv[1]
is_beta = sys.argv[2]
payload = sys.stdin.read()
try:
data = json.loads(payload)
except Exception:
sys.exit(4)
def next_version(latest):
parts = [int(p or 0) for p in latest.split(".")]
while len(parts) < 3:
parts.append(0)
major, minor, patch = parts[:3]
patch += 1
if patch >= 10:
patch = 0
minor += 1
if minor >= 10:
minor = 0
major += 1
return f"{major}.{minor}.{patch}"
def beta_max(data, prefix):
values = []
for item in data:
if not isinstance(item, dict):
continue
tag = item.get("tag_name")
if isinstance(tag, str) and tag.startswith(prefix):
suffix = tag[len(prefix):]
try:
values.append(int(suffix))
except Exception:
pass
return max(values) if values else 0
if not isinstance(data, list):
base = "0.0.0"
else:
base = "0.0.0"
for item in data:
if not isinstance(item, dict):
continue
tag = item.get("tag_name")
if isinstance(tag, str) and "-beta." not in tag:
base = tag[1:] if tag.startswith("v") else tag
break
next_ver = next_version(base)
if is_beta == "true":
beta = beta_max(data, f"v{next_ver}-beta.") + 1
sys.stdout.write(f"{base}\t{next_ver}-beta.{beta}")
else:
sys.stdout.write(f"{base}\t{next_ver}")
' "$query" "$is_beta"
EOF
chmod +x "$bin_dir/jq"
}
run_entrypoint() {
local response_file="$1"
local is_beta="$2"
local token="${3:-}"
local fake_status="${4:-0}"
local workdir
local output_file
local bin_dir
local stdout_file
local stderr_file
workdir="$(mktemp -d)"
bin_dir="$workdir/bin"
mkdir -p "$bin_dir"
make_mock_curl "$bin_dir" "$response_file"
make_mock_jq "$bin_dir"
output_file="$workdir/github_output"
stdout_file="$workdir/stdout"
stderr_file="$workdir/stderr"
if [ -n "$token" ]; then
FAKE_CURL_STATUS="$fake_status" \
FAKE_CURL_RESPONSE_FILE="$response_file" \
PATH="$bin_dir:$PATH" \
GITEA_SERVER_URL="https://gitea.example.com" \
GITEA_REPOSITORY="org/repo" \
RUNNER_TOKEN="$token" \
IS_BETA="$is_beta" \
GITHUB_OUTPUT="$output_file" \
bash "$ROOT_DIR/entrypoint.sh" >"$stdout_file" 2>"$stderr_file"
else
FAKE_CURL_STATUS="$fake_status" \
FAKE_CURL_RESPONSE_FILE="$response_file" \
PATH="$bin_dir:$PATH" \
GITEA_SERVER_URL="https://gitea.example.com" \
GITEA_REPOSITORY="org/repo" \
IS_BETA="$is_beta" \
GITHUB_OUTPUT="$output_file" \
bash "$ROOT_DIR/entrypoint.sh" >"$stdout_file" 2>"$stderr_file"
fi
printf '%s\n' "$output_file"
}
test_unit_helpers() {
assert_eq "false" "$(normalize_beta_flag "")" "normalize_beta_flag empty"
assert_eq "false" "$(normalize_beta_flag "null")" "normalize_beta_flag null"
assert_eq "true" "$(normalize_beta_flag "true")" "normalize_beta_flag true"
assert_eq "0.1.0" "$(next_release_version "0.0.9")" "next_release_version carry patch"
assert_eq "2.0.0" "$(next_release_version "1.9.9")" "next_release_version carry minor"
}
test_stable_release_flow() {
local response_file
local output_file
response_file="$(mktemp)"
cat >"$response_file" <<'EOF'
[
{"tag_name":"v1.2.3-beta.1"},
{"tag_name":"v1.2.3"},
{"tag_name":"v1.2.4-beta.1"}
]
EOF
output_file="$(run_entrypoint "$response_file" "false")"
assert_eq "version=1.2.4" "$(cat "$output_file")" "stable release output"
}
test_beta_release_flow() {
local response_file
local output_file
response_file="$(mktemp)"
cat >"$response_file" <<'EOF'
[
{"tag_name":"v1.2.3"},
{"tag_name":"v1.2.4-beta.1"},
{"tag_name":"v1.2.4-beta.3"}
]
EOF
output_file="$(run_entrypoint "$response_file" "true")"
assert_eq "version=1.2.4-beta.4" "$(cat "$output_file")" "beta release output"
}
test_empty_release_list() {
local response_file
local output_file
response_file="$(mktemp)"
printf '%s\n' '[]' >"$response_file"
output_file="$(run_entrypoint "$response_file" "false")"
assert_eq "version=0.0.1" "$(cat "$output_file")" "empty release list"
}
test_only_beta_releases() {
local response_file
local output_file
response_file="$(mktemp)"
cat >"$response_file" <<'EOF'
[
{"tag_name":"v2.4.6-beta.1"},
{"tag_name":"v2.4.6-beta.2"}
]
EOF
output_file="$(run_entrypoint "$response_file" "false")"
assert_eq "version=0.0.1" "$(cat "$output_file")" "only beta releases"
}
test_null_release_payload() {
local response_file
local output_file
response_file="$(mktemp)"
printf '%s\n' 'null' >"$response_file"
output_file="$(run_entrypoint "$response_file" "false")"
assert_eq "version=0.0.1" "$(cat "$output_file")" "null release payload"
}
test_malformed_release_payload() {
local response_file
local workdir
local bin_dir
local output_file
local stdout_file
local stderr_file
response_file="$(mktemp)"
printf '%s\n' '{' >"$response_file"
workdir="$(mktemp -d)"
bin_dir="$workdir/bin"
mkdir -p "$bin_dir"
make_mock_curl "$bin_dir" "$response_file"
make_mock_jq "$bin_dir"
output_file="$workdir/github_output"
stdout_file="$workdir/stdout"
stderr_file="$workdir/stderr"
if FAKE_CURL_STATUS=0 \
FAKE_CURL_RESPONSE_FILE="$response_file" \
PATH="$bin_dir:$PATH" \
GITEA_SERVER_URL="https://gitea.example.com" \
GITEA_REPOSITORY="org/repo" \
IS_BETA="false" \
GITHUB_OUTPUT="$output_file" \
bash "$ROOT_DIR/entrypoint.sh" >"$stdout_file" 2>"$stderr_file"; then
fail "malformed release payload: expected failure"
fi
}
test_curl_failure() {
local response_file
local workdir
local bin_dir
local output_file
local stdout_file
local stderr_file
response_file="$(mktemp)"
printf '%s\n' '[]' >"$response_file"
workdir="$(mktemp -d)"
bin_dir="$workdir/bin"
mkdir -p "$bin_dir"
make_mock_curl "$bin_dir" "$response_file"
output_file="$workdir/github_output"
stdout_file="$workdir/stdout"
stderr_file="$workdir/stderr"
if FAKE_CURL_STATUS=22 \
FAKE_CURL_RESPONSE_FILE="$response_file" \
PATH="$bin_dir:$PATH" \
GITEA_SERVER_URL="https://gitea.example.com" \
GITEA_REPOSITORY="org/repo" \
IS_BETA="false" \
GITHUB_OUTPUT="$output_file" \
bash "$ROOT_DIR/entrypoint.sh" >"$stdout_file" 2>"$stderr_file"; then
fail "curl failure: expected failure"
fi
}
test_unit_helpers
test_stable_release_flow
test_beta_release_flow
test_empty_release_list
test_only_beta_releases
test_null_release_payload
test_malformed_release_payload
test_curl_failure
printf '[info] all tests passed\n'