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