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: 4 additions & 0 deletions common/mise.root.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
25 changes: 25 additions & 0 deletions lib/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,31 @@ main_is_protected() {
gh api "repos/${1}/rulesets" --jq '.[].name' 2>/dev/null | grep -qx main
}

# enable_secret_scanning <slug>
# 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 <slug>
# 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
Expand Down
9 changes: 9 additions & 0 deletions scaffold
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
39 changes: 36 additions & 3 deletions tests/publish.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -38,6 +38,12 @@ esac
# `gh api repos/<slug>/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
Expand Down Expand Up @@ -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; }
}

Expand Down Expand Up @@ -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
Expand Down
Loading