Skip to content

fix(yield): harden clean-slate developer experience #194

fix(yield): harden clean-slate developer experience

fix(yield): harden clean-slate developer experience #194

name: Boundary identifier scan
# Non-bypassable gate for PRIVATE IDENTIFIERS in the public repo.
#
# A generic secret scanner (see secret-scan.yml) catches credential *material* by shape, but it
# cannot catch bare infrastructure *identifiers* (project names, pool paths, a private repo name):
# those have no generic shape and must not be named in any public file. So the specific terms live
# only in a private denylist, and this workflow reads that denylist AT RUNTIME — the workflow itself
# names nothing private. It scans the added lines of each PR / push for a denied term and fails
# closed, reporting only the offending file and a generic category (never the matched text, which
# would reprint the very identifier into a public log).
#
# Access is via the org's existing GitHub App (same one the publish workflows use). At runtime the
# job mints a token DOWNSCOPED to contents:read on the single source repo — even though the App can
# do more — so the gate has the least privilege it needs and depends on no personal PAT.
#
# Setup (one-time, by a maintainer): configure secrets/vars so the gate can reach the denylist:
# BOUNDARY_DENYLIST_REPO <org>/<private-repo> holding the denylist (secret; keeps the name out of source)
# BOUNDARY_DENYLIST_PATH path to the denylist within it, e.g. boundary/deny-content.txt (secret)
# plus the App credentials already present for the publishers (client-id var + private-key secret).
# Until they are set the job fails closed (an unconfigured gate is treated as an unenforced one).
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: boundary-identifier-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
identifier-scan:
name: Private identifier scan
runs-on: ubuntu-latest
env:
DENYLIST_REPO: ${{ secrets.BOUNDARY_DENYLIST_REPO }}
DENYLIST_PATH: ${{ secrets.BOUNDARY_DENYLIST_PATH }}
steps:
- name: Preflight — resolve source coordinates (fail-closed)
id: coords
run: |
set -euo pipefail
[ -n "${DENYLIST_REPO:-}" ] || {
echo "::error::BOUNDARY_DENYLIST_REPO is not set — failing closed (an unenforced gate is not a gate)."
exit 1
}
# Split <owner>/<repo> so the App token can be scoped to exactly this repo. Derived from the
# secret at runtime, so the private repo NAME never appears in this workflow's source.
echo "owner=${DENYLIST_REPO%%/*}" >> "$GITHUB_OUTPUT"
echo "repo=${DENYLIST_REPO##*/}" >> "$GITHUB_OUTPUT"
echo "coordinates resolved."
- name: Mint a read-only token scoped to the source repo
id: denylist-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.OPERATOR_STACK_PUBLISHER_APP_CLIENT_ID || vars.BOATSTACK_APP_CLIENT_ID }}
private-key: ${{ secrets.OPERATOR_STACK_PUBLISHER_APP_PRIVATE_KEY || secrets.BOATSTACK_APP_PRIVATE_KEY }}
owner: ${{ steps.coords.outputs.owner }}
repositories: ${{ steps.coords.outputs.repo }}
permission-contents: read
- name: Checkout public repo (full history for range diff)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch private denylist at runtime
uses: actions/checkout@v4
with:
repository: ${{ env.DENYLIST_REPO }}
token: ${{ steps.denylist-token.outputs.token }}
path: .boundary-private
persist-credentials: false
- name: Scan introduced lines for denied identifiers (redacted, fail-closed)
run: |
set -euo pipefail
DENY="${DENYLIST_PATH:-boundary/deny-content.txt}"
SRC=".boundary-private/${DENY}"
if [ ! -f "$SRC" ]; then
echo "::error::denylist not found at ${DENY} in the private repo — failing closed."
exit 1
fi
# Strip comments/blank lines so grep -f never sees an empty pattern (matches everything).
PATS="$(mktemp)"
grep -vE '^[[:space:]]*(#|$)' "$SRC" > "$PATS" || true
if [ ! -s "$PATS" ]; then
echo "::error::denylist is empty after stripping comments — failing closed."
exit 1
fi
ZERO="0000000000000000000000000000000000000000"
if [ "${{ github.event_name }}" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
elif [ "${{ github.event.before }}" != "$ZERO" ] && [ -n "${{ github.event.before }}" ]; then
RANGE="${{ github.event.before }}..${{ github.sha }}"
else
RANGE=""
fi
echo "scan range: ${RANGE:-<full working tree>}"
# Files changed in the range (or all tracked files when there is no range).
if [ -n "$RANGE" ]; then
mapfile -t FILES < <(git diff --name-only --diff-filter=ACMR "$RANGE" -- . ':!.boundary-private/**')
else
mapfile -t FILES < <(git ls-files -- . ':!.boundary-private/**')
fi
fail=0
for f in "${FILES[@]:-}"; do
[ -n "$f" ] || continue
[ -f "$f" ] || continue
if [ -n "$RANGE" ]; then
added="$(git diff --unified=0 --diff-filter=ACMR "$RANGE" -- "$f" | grep -E '^\+' | grep -vE '^\+\+\+' || true)"
else
added="$(cat "$f")"
fi
# Redacted: we only report THAT a file matched and a generic category, never the match.
if printf '%s' "$added" | grep -qE -f "$PATS"; then
echo "::error file=$f::private identifier detected (redacted). Move the value into the private repo and reference cloud coordinates via runtime resolution / CI variables."
fail=1
fi
done
rm -f "$PATS"
if [ "$fail" -ne 0 ]; then
echo "::error::boundary identifier scan failed — one or more public files contain a private identifier (see annotations above)."
exit 1
fi
echo "no denied identifiers in the introduced changes."