From b8abd63153bc9be1e63b12b56e111fa241b60677 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sat, 12 Sep 2026 13:10:41 +0700 Subject: [PATCH] fix(security): scan for secrets with the version the project pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitleaks-action builds its scan range as ^... On the first push of any repository that first commit is the root commit, which has no parent, so git fails, the scan covers zero bytes, and the action still reports 'no leaks found'. Reproduced deterministically: first push red having scanned nothing, second push green. immich runs no secret scanner in CI — it relies on GitHub's own scanning and push protection — and runs every other tool through mise rather than a wrapper action. Both apply here: publish turns on GitHub's scanning where the plan allows it, and CI runs the gitleaks the project already pins for its commit hook, over the whole history. The action installed 8.24.3 while projects pin 8.30.0. --- common/mise.root.toml | 4 ++++ lib/publish.sh | 25 +++++++++++++++++++++++++ scaffold | 9 +++++++++ tests/publish.bats | 39 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/common/mise.root.toml b/common/mise.root.toml index c270a9e..150a63e 100644 --- a/common/mise.root.toml +++ b/common/mise.root.toml @@ -34,6 +34,10 @@ lockfile = true interactive = true run = "docker compose -f compose.dev.yaml up --remove-orphans" +[tasks.secrets] +description = "scan the whole history for secrets" +run = "gitleaks git --redact --no-banner" + [tasks."dev-down"] run = "docker compose -f compose.dev.yaml down --remove-orphans" diff --git a/lib/publish.sh b/lib/publish.sh index a09955d..9061957 100644 --- a/lib/publish.sh +++ b/lib/publish.sh @@ -120,6 +120,31 @@ main_is_protected() { gh api "repos/${1}/rulesets" --jq '.[].name' 2>/dev/null | grep -qx main } +# enable_secret_scanning +# GitHub scans and blocks the push itself. Free on a public repository; on a +# private one it needs Advanced Security, which answers 422 — the same shape +# protect_main handles, and reported the same way. +enable_secret_scanning() { + local response status=0 + + response="$(gh api -X PATCH "repos/${1}" --input - 2>&1 <<'EOF' +{ + "security_and_analysis": { + "secret_scanning": { "status": "enabled" }, + "secret_scanning_push_protection": { "status": "enabled" } + } +} +EOF +)" || status=$? + + [ "$status" -eq 0 ] && return 0 + case "$response" in + *"Advanced Security"*|*"not available"*|*"upgrade"*|*"Upgrade"*) return 2 ;; + esac + printf '%s\n' "$response" >&2 + return 1 +} + # set_release_secrets # Optional on both sides: the release workflow declares them optional and falls # back to GITHUB_TOKEN. What the fallback costs is a release pull request whose diff --git a/scaffold b/scaffold index 5793c61..2ed44b3 100755 --- a/scaffold +++ b/scaffold @@ -712,6 +712,7 @@ cmd_publish() { fi log "would allow Actions to open pull requests on ${slug}" + log "would enable secret scanning and push protection" if [ "$protect" -eq 1 ]; then if main_is_protected "$slug"; then @@ -739,6 +740,14 @@ cmd_publish() { allow_actions_to_open_pull_requests "$slug" log "Actions may open pull requests (Release Please needs this)" + local scanning=0 + enable_secret_scanning "$slug" || scanning=$? + case "$scanning" in + 0) log "secret scanning and push protection are on" ;; + 2) warn "no secret scanning: it needs Advanced Security on a private repository. The CI scan still runs on every push" ;; + *) die "could not enable secret scanning on ${slug}" ;; + esac + if [ "$protect" -eq 1 ]; then if main_is_protected "$slug"; then log "main already has a ruleset — left alone" diff --git a/tests/publish.bats b/tests/publish.bats index b328395..209feff 100644 --- a/tests/publish.bats +++ b/tests/publish.bats @@ -17,8 +17,8 @@ teardown() { # creating a repository on anybody's account. Same technique as the curl and # docker stubs in tests/install.bats. # -# GH_SCENARIO: `absent` (no such repository), `exists`, or `plan-limit` (a -# repository that exists on an account whose plan refuses rulesets). +# GH_SCENARIO: `absent` (no such repository), `exists`, `plan-limit` (an +# account whose plan refuses rulesets), or `no-advanced-security`. _stub_gh() { local bin="${WORKDIR}/stub" mkdir -p "$bin" @@ -38,6 +38,12 @@ esac # `gh api repos//rulesets` with no -X is the listing; with -X POST it is # the create. case "$*" in + *"-X PATCH"*) + if [ "${GH_SCENARIO}" = no-advanced-security ]; then + echo 'gh: Advanced Security is not available for this repository (HTTP 422)' >&2 + exit 1 + fi + exit 0 ;; *"-X POST"*rulesets*) if [ "${GH_SCENARIO}" = plan-limit ]; then echo 'gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)' >&2 @@ -125,7 +131,7 @@ _project() { [[ "$output" == *"would allow Actions to open pull requests"* ]] [[ "$output" == *"would protect main"* ]] - run grep -cE 'repo create|-X PUT|-X POST|secret set' "$GH_LOG" + run grep -cE 'repo create|-X PUT|-X POST|-X PATCH|secret set' "$GH_LOG" [ "$output" = 0 ] || { echo "a dry run called:"; cat "$GH_LOG"; false; } } @@ -170,6 +176,33 @@ _project() { [ "$output" = 1 ] } +@test "publish turns on GitHub's own secret scanning" { + # The control that blocks a secret at push time, before it lands. Free on a + # public repository; the CI scan covers the private case. + _stub_gh + _project + + GH_SCENARIO=exists run scaffold publish "$PROJECT" + assert_ok + [[ "$output" == *"secret scanning and push protection are on"* ]] + run grep -c -- '-X PATCH repos/acme/demo' "$GH_LOG" + [ "$output" = 1 ] + + # The payload travels on stdin, so it is checked where it is written. + run bash -c "sed -n '/\"security_and_analysis\"/,/^EOF\$/p' '${SCAFFOLD_ROOT}/lib/publish.sh' \ + | grep -c 'secret_scanning_push_protection'" + [ "$output" = 1 ] +} + +@test "a plan without Advanced Security is a warning, not a failed publish" { + _stub_gh + _project + + GH_SCENARIO=no-advanced-security run scaffold publish "$PROJECT" + assert_ok + [[ "$output" == *"no secret scanning"* ]] +} + @test "a plan that refuses rulesets is a warning, not a failed publish" { # Measured against a real repository: a private repository on a free account # answers 403 "Upgrade to GitHub Pro". Everything before it succeeded, and