Sync this repo to Diffinery, with an audited push-protection bypass and quarantined pushes (#13)
* Sync this repository to Diffinery on every push and check its Actions there The move workflow becomes a sync: each push copies branches, tags, secrets and variables to diffinery.com/codician-team/diffinery (creating it the first time) and, for main, waits for the Actions runs that start there and fails with their logs if any fail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Mark the e2e test's fake AWS key so the test source passes push protection Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL * Push protection: quarantine pushed objects and add an audited admin bypass Objects from a pushed pack are only stored once push protection passes, so a blocked push no longer leaves objects that a retry (with push negotiation) could reuse to slip the secret past the scanner. Repository admins who are people, never agents, can push anyway with git push -o allow-secrets=<reason>, which is recorded in the audit log. The sync workflow can pass such a reason on a manual run, for importing history with fake test keys. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL --------- Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 014d046commit cb1fcca5ce1947598517b6255b345c6a7fa1f93aBrowse files
8 files changed+225−110
Files (8)
- .github/workflows/move-to-diffinery.yml+0−95
- .github/workflows/sync-to-diffinery.yml+103−0
- README.md+5−2
- apps/diffinery/src/git/secret-scan.ts+9−0
- apps/diffinery/src/repo/RepoDO.ts+31−13
- apps/diffinery/test/e2e/e2e.test.mjs+9−0
- apps/diffinery/test/unit/secret-scan.test.ts+20−0
- scripts/diffinery-wait-runs.sh+48−0
deleted.github/workflows/move-to-diffinery.yml+0−95
| @@ -1,95 +1,0 @@ | ||
| 1 | -name: Move to Diffinery | |
| 2 | - | |
| 3 | -# Copies this repository to diffinery.com/codician-team/diffinery: every branch | |
| 4 | -# and tag, plus the Actions secrets and variables our workflows use. Safe to | |
| 5 | -# re-run; each run brings Diffinery up to date. | |
| 6 | -# | |
| 7 | -# Secrets go straight from GitHub's secret store to Diffinery's over HTTPS and are | |
| 8 | -# never printed. Needs DIFFINERY_LIVE_TOKEN (an admin token on diffinery.com). | |
| 9 | -on: | |
| 10 | - workflow_dispatch: | |
| 11 | - | |
| 12 | -permissions: | |
| 13 | - contents: read | |
| 14 | - | |
| 15 | -concurrency: | |
| 16 | - group: move-to-diffinery | |
| 17 | - cancel-in-progress: false | |
| 18 | - | |
| 19 | -jobs: | |
| 20 | - move: | |
| 21 | - name: Copy code, secrets and variables | |
| 22 | - runs-on: ubuntu-latest | |
| 23 | - timeout-minutes: 20 | |
| 24 | - environment: production | |
| 25 | - env: | |
| 26 | - DIFFINERY: https://diffinery.com | |
| 27 | - TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} | |
| 28 | - ORG: codician-team | |
| 29 | - NAME: diffinery | |
| 30 | - steps: | |
| 31 | - - name: Check the token | |
| 32 | - run: | | |
| 33 | - [ -n "$TOKEN" ] || { echo "::error::Add DIFFINERY_LIVE_TOKEN (an admin token from $DIFFINERY/settings/tokens) as a repository secret first."; exit 1; } | |
| 34 | - curl -fsS "$DIFFINERY/api/v3/user" -H "Authorization: Bearer $TOKEN" | jq '{login, site_admin}' | |
| 35 | - | |
| 36 | - - name: Create the organization and repository | |
| 37 | - env: | |
| 38 | - DESC: ${{ github.event.repository.description }} | |
| 39 | - run: | | |
| 40 | - set -euo pipefail | |
| 41 | - api() { curl -sS -o /tmp/out -w '%{http_code}' -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; } | |
| 42 | - if [ "$(api GET "/users/$ORG")" = 404 ]; then | |
| 43 | - code=$(api POST /user/orgs "{\"login\":\"$ORG\",\"name\":\"Codician\"}"); cat /tmp/out; echo | |
| 44 | - [ "$code" = 201 ] || { echo "::error::could not create the $ORG organization ($code)"; exit 1; } | |
| 45 | - fi | |
| 46 | - if [ "$(api GET "/repos/$ORG/$NAME")" = 404 ]; then | |
| 47 | - desc=$(jq -n --arg d "$DESC" '$d') | |
| 48 | - code=$(api POST "/orgs/$ORG/repos" "{\"name\":\"$NAME\",\"private\":true,\"default_branch\":\"main\",\"description\":$desc}"); cat /tmp/out; echo | |
| 49 | - [ "$code" = 201 ] || { echo "::error::could not create $ORG/$NAME ($code)"; exit 1; } | |
| 50 | - fi | |
| 51 | - echo "$DIFFINERY/$ORG/$NAME is ready" | |
| 52 | - | |
| 53 | - - name: Copy Actions secrets and variables | |
| 54 | - env: | |
| 55 | - S_CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 56 | - S_CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 57 | - S_DIFFINERY_LIVE_TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} | |
| 58 | - V_ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }} | |
| 59 | - run: | | |
| 60 | - set -euo pipefail | |
| 61 | - put() { curl -fsS -o /dev/null -X PUT "$DIFFINERY/api/v3/repos/$ORG/$NAME/actions/$1/$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @-; } | |
| 62 | - for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID DIFFINERY_LIVE_TOKEN; do | |
| 63 | - v="S_$name" | |
| 64 | - if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put secrets "$name"; echo "secret $name copied"; else echo "::warning::secret $name is empty here, so it was not copied"; fi | |
| 65 | - done | |
| 66 | - for name in ACCESS_TEAM_DOMAIN; do | |
| 67 | - v="V_$name" | |
| 68 | - if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put variables "$name"; echo "variable $name = ${!v}"; fi | |
| 69 | - done | |
| 70 | - | |
| 71 | - - uses: actions/checkout@v4 | |
| 72 | - with: | |
| 73 | - fetch-depth: 0 | |
| 74 | - lfs: true | |
| 75 | - | |
| 76 | - - name: Push every branch and tag | |
| 77 | - run: | | |
| 78 | - set -euo pipefail | |
| 79 | - git fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*' | |
| 80 | - remote="https://x-token:${TOKEN}@${DIFFINERY#https://}/$ORG/$NAME.git" | |
| 81 | - refs=$(git for-each-ref --format='+%(refname):refs/heads/%(refname:lstrip=3)' refs/remotes/origin | grep -v ':refs/heads/HEAD$') | |
| 82 | - git push -q "$remote" $refs '+refs/tags/*:refs/tags/*' | |
| 83 | - if git lfs ls-files | grep -q .; then git lfs push --all "$remote"; fi | |
| 84 | - echo "branches on Diffinery:" | |
| 85 | - curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | " \(.name) \(.commit.sha)"' | |
| 86 | - want=$(git rev-parse refs/remotes/origin/main) | |
| 87 | - got=$(curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.name=="main") | .commit.sha') | |
| 88 | - [ "$want" = "$got" ] || { echo "::error::main on Diffinery is $got, expected $want"; exit 1; } | |
| 89 | - echo "main matches GitHub at $want" | |
| 90 | - | |
| 91 | - - name: Check Actions on Diffinery | |
| 92 | - run: | | |
| 93 | - set -euo pipefail | |
| 94 | - curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/actions/workflows" -H "Authorization: Bearer $TOKEN" | jq -r '.workflows[] | "\(.path): \(.state) \(.errors | join("; "))"' | |
| 95 | - curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/actions/secrets" -H "Authorization: Bearer $TOKEN" | jq -r '.secrets[] | "secret \(.name)"' |
added.github/workflows/sync-to-diffinery.yml+103−0View file
| @@ -1,0 +1,103 @@ | ||
| 1 | +name: Sync to Diffinery | |
| 2 | + | |
| 3 | +# Keeps diffinery.com/codician-team/diffinery in step with this repository: every | |
| 4 | +# branch and tag, plus the Actions secrets and variables our workflows use. On the | |
| 5 | +# first run it creates the organization and repository. For main it then waits for | |
| 6 | +# the Actions runs on Diffinery and fails if any of them fail. | |
| 7 | +# | |
| 8 | +# Secrets go straight from GitHub's secret store to Diffinery's over HTTPS and are | |
| 9 | +# never printed. Needs DIFFINERY_LIVE_TOKEN (an admin token on diffinery.com). | |
| 10 | +on: | |
| 11 | + push: | |
| 12 | + workflow_dispatch: | |
| 13 | + inputs: | |
| 14 | + allow_secrets: | |
| 15 | + description: Reason for pushing past Diffinery's push protection (admins only, audited). Leave empty normally. | |
| 16 | + type: string | |
| 17 | + default: "" | |
| 18 | + | |
| 19 | +permissions: | |
| 20 | + contents: read | |
| 21 | + | |
| 22 | +concurrency: | |
| 23 | + group: sync-to-diffinery | |
| 24 | + cancel-in-progress: false | |
| 25 | + | |
| 26 | +jobs: | |
| 27 | + move: | |
| 28 | + name: Copy code, secrets and variables, then check Actions on Diffinery | |
| 29 | + runs-on: ubuntu-latest | |
| 30 | + timeout-minutes: 90 | |
| 31 | + environment: production | |
| 32 | + env: | |
| 33 | + DIFFINERY: https://diffinery.com | |
| 34 | + TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} | |
| 35 | + ORG: codician-team | |
| 36 | + NAME: diffinery | |
| 37 | + steps: | |
| 38 | + - name: Check the token | |
| 39 | + run: | | |
| 40 | + [ -n "$TOKEN" ] || { echo "::error::Add DIFFINERY_LIVE_TOKEN (an admin token from $DIFFINERY/settings/tokens) as a repository secret first."; exit 1; } | |
| 41 | + curl -fsS "$DIFFINERY/api/v3/user" -H "Authorization: Bearer $TOKEN" | jq '{login, site_admin}' | |
| 42 | + | |
| 43 | + - name: Create the organization and repository | |
| 44 | + env: | |
| 45 | + DESC: ${{ github.event.repository.description }} | |
| 46 | + run: | | |
| 47 | + set -euo pipefail | |
| 48 | + api() { curl -sS -o /tmp/out -w '%{http_code}' -X "$1" "$DIFFINERY/api/v3$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" ${3:+--data "$3"}; } | |
| 49 | + if [ "$(api GET "/users/$ORG")" = 404 ]; then | |
| 50 | + code=$(api POST /user/orgs "{\"login\":\"$ORG\",\"name\":\"Codician\"}"); cat /tmp/out; echo | |
| 51 | + [ "$code" = 201 ] || { echo "::error::could not create the $ORG organization ($code)"; exit 1; } | |
| 52 | + fi | |
| 53 | + if [ "$(api GET "/repos/$ORG/$NAME")" = 404 ]; then | |
| 54 | + desc=$(jq -n --arg d "$DESC" '$d') | |
| 55 | + code=$(api POST "/orgs/$ORG/repos" "{\"name\":\"$NAME\",\"private\":true,\"default_branch\":\"main\",\"description\":$desc}"); cat /tmp/out; echo | |
| 56 | + [ "$code" = 201 ] || { echo "::error::could not create $ORG/$NAME ($code)"; exit 1; } | |
| 57 | + fi | |
| 58 | + echo "$DIFFINERY/$ORG/$NAME is ready" | |
| 59 | + | |
| 60 | + - name: Copy Actions secrets and variables | |
| 61 | + env: | |
| 62 | + S_CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| 63 | + S_CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | |
| 64 | + S_DIFFINERY_LIVE_TOKEN: ${{ secrets.DIFFINERY_LIVE_TOKEN }} | |
| 65 | + V_ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }} | |
| 66 | + run: | | |
| 67 | + set -euo pipefail | |
| 68 | + put() { curl -fsS -o /dev/null -X PUT "$DIFFINERY/api/v3/repos/$ORG/$NAME/actions/$1/$2" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @-; } | |
| 69 | + for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID DIFFINERY_LIVE_TOKEN; do | |
| 70 | + v="S_$name" | |
| 71 | + if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put secrets "$name"; echo "secret $name copied"; else echo "::warning::secret $name is empty here, so it was not copied"; fi | |
| 72 | + done | |
| 73 | + for name in ACCESS_TEAM_DOMAIN; do | |
| 74 | + v="V_$name" | |
| 75 | + if [ -n "${!v}" ]; then jq -n --arg v "${!v}" '{value: $v}' | put variables "$name"; echo "variable $name = ${!v}"; fi | |
| 76 | + done | |
| 77 | + | |
| 78 | + - uses: actions/checkout@v4 | |
| 79 | + with: | |
| 80 | + fetch-depth: 0 | |
| 81 | + lfs: true | |
| 82 | + | |
| 83 | + - name: Push every branch and tag | |
| 84 | + env: | |
| 85 | + ALLOW_SECRETS: ${{ inputs.allow_secrets }} | |
| 86 | + run: | | |
| 87 | + set -euo pipefail | |
| 88 | + git fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*' | |
| 89 | + remote="https://x-token:${TOKEN}@${DIFFINERY#https://}/$ORG/$NAME.git" | |
| 90 | + refs=$(git for-each-ref --format='+%(refname):refs/heads/%(refname:lstrip=3)' refs/remotes/origin | grep -v ':refs/heads/HEAD$') | |
| 91 | + git push -q ${ALLOW_SECRETS:+-o "allow-secrets=$ALLOW_SECRETS"} "$remote" $refs '+refs/tags/*:refs/tags/*' | |
| 92 | + if git lfs ls-files | grep -q .; then git lfs push --all "$remote"; fi | |
| 93 | + echo "branches on Diffinery:" | |
| 94 | + curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | " \(.name) \(.commit.sha)"' | |
| 95 | + want=$(git rev-parse refs/remotes/origin/main) | |
| 96 | + got=$(curl -fsS "$DIFFINERY/api/v3/repos/$ORG/$NAME/branches" -H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.name=="main") | .commit.sha') | |
| 97 | + [ "$want" = "$got" ] || { echo "::error::main on Diffinery is $got, expected $want"; exit 1; } | |
| 98 | + echo "main matches GitHub at $want" | |
| 99 | + | |
| 100 | + - name: Wait for Actions on Diffinery | |
| 101 | + # Pushes to main start CI (and, once Diffinery deploys, the deploy) there. | |
| 102 | + if: github.ref == 'refs/heads/main' | |
| 103 | + run: scripts/diffinery-wait-runs.sh "$ORG/$NAME" "$(git rev-parse refs/remotes/origin/main)" 75 |
modifiedREADME.md+5−2View file
| @@ -15,8 +15,11 @@ | ||
| 15 | 15 | (`/mcp`) or the REST API, and can start sandboxes to build and test. |
| 16 | 16 | - **Secure by default.** People sign in with passkeys, with no passwords to |
| 17 | 17 | leak or phish. Invites, roles and branch protection decide what they can do. Pushes are scanned for |
| 18 | - leaked credentials. Secrets use envelope encryption, and every sensitive | |
| 19 | - action goes to an append-only audit log. | |
| 18 | + leaked credentials and blocked if they contain any; nothing from a blocked | |
| 19 | + push is kept. A repository admin (a person, never an agent) can push anyway | |
| 20 | + with `git push -o allow-secrets=<reason>`, which is audited. A file can opt | |
| 21 | + out with a `diffinery:allow-secret` comment, for test fixtures. Secrets use | |
| 22 | + envelope encryption, and every sensitive action goes to an append-only audit log. | |
| 20 | 23 | |
| 21 | 24 | ## How it fits on Cloudflare |
| 22 | 25 |
modifiedapps/diffinery/src/git/secret-scan.ts+9−0View file
| @@ -28,3 +28,12 @@ | ||
| 28 | 28 | } |
| 29 | 29 | return hits; |
| 30 | 30 | } |
| 31 | + | |
| 32 | +/** The reason given with `git push -o allow-secrets=<reason>`, if any. */ | |
| 33 | +export function secretBypassReason(pushOptions: string[]): string | null { | |
| 34 | + for (const o of pushOptions) { | |
| 35 | + const m = /^allow-secrets=(.+)$/.exec(o.trim()); | |
| 36 | + if (m && m[1]!.trim()) return m[1]!.trim().slice(0, 200); | |
| 37 | + } | |
| 38 | + return null; | |
| 39 | +} |
modifiedapps/diffinery/src/repo/RepoDO.ts+31−13View file
| @@ -35,7 +35,7 @@ | ||
| 35 | 35 | type RefEntry, |
| 36 | 36 | type RefResult, |
| 37 | 37 | } from "../git/protocol.js"; |
| 38 | -import { scanForSecrets } from "../git/secret-scan.js"; | |
| 38 | +import { scanForSecrets, secretBypassReason } from "../git/secret-scan.js"; | |
| 39 | 39 | import { HttpError, isValidRefName, now } from "../lib/util.js"; |
| 40 | 40 | import { Actions } from "./actions.js"; |
| 41 | 41 | import { Collab } from "./collab.js"; |
| @@ -209,18 +209,17 @@ | ||
| 209 | 209 | const req = parseReceivePack(body); |
| 210 | 210 | const messages: string[] = []; |
| 211 | 211 | let unpackError: string | null = null; |
| 212 | - const newBlobs: Array<{ oid: string; content: Uint8Array }> = []; | |
| 212 | + // Like git's quarantine: nothing from the pack is stored until push protection passes, | |
| 213 | + // so a blocked push leaves no objects behind for a retry to build on. | |
| 214 | + let objects: Awaited<ReturnType<typeof parsePack>> = []; | |
| 213 | 215 | if (req.pack && req.pack.length > 0) { |
| 214 | 216 | try { |
| 215 | - const objects = await parsePack(req.pack, (oid) => this.store.read(oid)); | |
| 216 | - for (const o of objects) { | |
| 217 | - await this.store.writeKnown(o.oid, o.type, o.content); | |
| 218 | - if (o.type === "blob" && o.content.length < 1024 * 1024) newBlobs.push({ oid: o.oid, content: o.content }); | |
| 219 | - } | |
| 217 | + objects = await parsePack(req.pack, (oid) => this.store.read(oid)); | |
| 220 | 218 | } catch (e) { |
| 221 | 219 | unpackError = (e as Error).message; |
| 222 | 220 | } |
| 223 | 221 | } |
| 222 | + const newBlobs = objects.filter((o) => o.type === "blob" && o.content.length < 1024 * 1024).map((o) => ({ oid: o.oid, content: o.content })); | |
| 224 | 223 | const results: RefResult[] = []; |
| 225 | 224 | if (unpackError) { |
| 226 | 225 | for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" }); |
| @@ -229,14 +228,33 @@ | ||
| 229 | 228 | |
| 230 | 229 | const leaks = scanForSecrets(newBlobs); |
| 231 | 230 | if (leaks.length) { |
| 232 | - messages.push("Push blocked: possible secrets detected."); | |
| 233 | - for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`); | |
| 234 | - messages.push("Remove the secret from your commits, or rotate it and contact an admin."); | |
| 235 | - for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" }); | |
| 236 | - this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target: `${this.meta.owner}/${this.meta.name}`, detail: { kinds: leaks.map((l) => l.kind) }, at: now() }); | |
| 237 | - return reportStatus(req, null, results, messages); | |
| 231 | + const kinds = leaks.map((l) => l.kind); | |
| 232 | + const target = `${this.meta.owner}/${this.meta.name}`; | |
| 233 | + // Like GitHub's push protection bypass: a human repository admin may push anyway by | |
| 234 | + // giving a reason (`git push -o allow-secrets=<reason>`), and every bypass is audited. | |
| 235 | + const reason = secretBypassReason(req.pushOptions); | |
| 236 | + if (reason && caller.role === "admin" && caller.kind === "user") { | |
| 237 | + messages.push(`Push protection bypassed by ${caller.login}: ${reason}`); | |
| 238 | + for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`); | |
| 239 | + this.emit({ type: "audit", action: "push.secret_bypassed", actor: caller, target, detail: { kinds, reason }, at: now() }); | |
| 240 | + } else { | |
| 241 | + messages.push("Push blocked: possible secrets detected."); | |
| 242 | + for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`); | |
| 243 | + messages.push("Remove the secret from your commits, or rotate it and contact an admin."); | |
| 244 | + if (reason) messages.push("Only repository admins (people, not agents) can bypass push protection."); | |
| 245 | + for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" }); | |
| 246 | + this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target, detail: { kinds }, at: now() }); | |
| 247 | + return reportStatus(req, null, results, messages); | |
| 248 | + } | |
| 238 | 249 | } |
| 239 | 250 | |
| 251 | + try { | |
| 252 | + for (const o of objects) await this.store.writeKnown(o.oid, o.type, o.content); | |
| 253 | + } catch (e) { | |
| 254 | + for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" }); | |
| 255 | + return reportStatus(req, (e as Error).message, results); | |
| 256 | + } | |
| 257 | + | |
| 240 | 258 | // Validate every command before applying any, so `atomic` is honoured. |
| 241 | 259 | const checks: Array<RefResult & { cmd: (typeof req.commands)[number] }> = []; |
| 242 | 260 | for (const cmd of req.commands) checks.push({ cmd, ...(await this.checkRefUpdate(cmd.ref, cmd.oldOid, cmd.newOid, caller)) }); |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+9−0View file
| @@ -227,10 +227,19 @@ | ||
| 227 | 227 | assert.equal(readFileSync(join(clone, "app.txt"), "utf8"), "one\ntwo\nthree\n"); |
| 228 | 228 | |
| 229 | 229 | // ------------------------------------------------------------ secrets are blocked on push |
| 230 | + // The key below is a fake that push protection must block in the pushed file; this | |
| 231 | + // marker exempts only the test source itself: diffinery:allow-secret | |
| 230 | 232 | writeFileSync(join(clone, "config.env"), "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPL3\n"); |
| 231 | 233 | git(clone, ["add", "-A"]); |
| 232 | 234 | git(clone, ["commit", "-q", "-m", "oops"]); |
| 233 | 235 | assert.match(gitFails(clone, ["push", "origin", "main"]), /secret|AWS/i); |
| 236 | + // Objects from a blocked push are not kept, so retrying (even with push negotiation) is blocked too. | |
| 237 | + assert.match(gitFails(clone, ["-c", "push.negotiate=true", "push", "origin", "HEAD:refs/heads/fixture"]), /secret|AWS/i); | |
| 238 | + // A repository admin can push anyway by giving a reason; the bypass is audited. | |
| 239 | + assert.match(gitFails(clone, ["push", "-o", "allow-secrets=", "origin", "HEAD:refs/heads/fixture"]), /secret|AWS/i); | |
| 240 | + git(clone, ["push", "-q", "-o", "allow-secrets=fake key used by this test", "origin", "HEAD:refs/heads/fixture"]); | |
| 241 | + assert.ok((await ok("GET", `/repos/${owner}/demo/branches`)).some((b) => b.name === "fixture")); | |
| 242 | + git(clone, ["push", "-q", "origin", ":refs/heads/fixture"]); | |
| 234 | 243 | git(clone, ["reset", "-q", "--hard", "origin/main"]); |
| 235 | 244 | |
| 236 | 245 | // ------------------------------------------------------------ protect main: PR + green CI + review |
addedapps/diffinery/test/unit/secret-scan.test.ts+20−0View file
| @@ -1,0 +1,20 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { scanForSecrets, secretBypassReason } from "../../src/git/secret-scan.js"; | |
| 3 | + | |
| 4 | +const blob = (text: string) => ({ oid: "a".repeat(40), content: new TextEncoder().encode(text) }); | |
| 5 | + | |
| 6 | +describe("push protection", () => { | |
| 7 | + it("flags credentials, except in files that opt out", () => { | |
| 8 | + // diffinery:allow-secret (this test file holds fake keys on purpose) | |
| 9 | + expect(scanForSecrets([blob("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPL3")]).map((h) => h.kind)).toEqual(["AWS access key"]); | |
| 10 | + expect(scanForSecrets([blob("key = AKIAIOSFODNN7EXAMPL3 # diffinery:allow-secret")])).toEqual([]); | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it("reads the bypass reason from push options", () => { | |
| 14 | + expect(secretBypassReason([])).toBeNull(); | |
| 15 | + expect(secretBypassReason(["allow-secrets="])).toBeNull(); | |
| 16 | + expect(secretBypassReason(["allow-secrets= "])).toBeNull(); | |
| 17 | + expect(secretBypassReason(["ci.skip", "allow-secrets= test fixture "])).toBe("test fixture"); | |
| 18 | + expect(secretBypassReason([`allow-secrets=${"x".repeat(300)}`])).toHaveLength(200); | |
| 19 | + }); | |
| 20 | +}); |
addedscripts/diffinery-wait-runs.sh+48−0View file
| @@ -1,0 +1,48 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Waits for every Diffinery Actions run on a commit (including runs that other runs | |
| 3 | +# start, like workflow_run followers) and prints each run and job. Prints the logs of | |
| 4 | +# failed jobs and exits non-zero if any run did not succeed. | |
| 5 | +# | |
| 6 | +# Usage: DIFFINERY=https://diffinery.com TOKEN=... diffinery-wait-runs.sh owner/repo sha [minutes] | |
| 7 | +set -euo pipefail | |
| 8 | +repo=$1 sha=$2 minutes=${3:-60} | |
| 9 | +api() { curl -fsS --retry 3 "$DIFFINERY/api/v3/repos/$repo$1" -H "Authorization: Bearer $TOKEN"; } | |
| 10 | + | |
| 11 | +deadline=$(( $(date +%s) + minutes * 60 )) | |
| 12 | +settled=0 | |
| 13 | +while :; do | |
| 14 | + runs=$(api "/actions/runs?head_sha=$sha&per_page=100") | |
| 15 | + total=$(jq '.workflow_runs | length' <<<"$runs") | |
| 16 | + pending=$(jq '[.workflow_runs[] | select(.status != "completed")] | length' <<<"$runs") | |
| 17 | + if [ "$total" -gt 0 ] && [ "$pending" = 0 ]; then | |
| 18 | + # Finished runs can start followers (workflow_run), so make sure nothing new appears. | |
| 19 | + settled=$((settled + 1)) | |
| 20 | + [ "$settled" -ge 3 ] && break | |
| 21 | + else | |
| 22 | + settled=0 | |
| 23 | + fi | |
| 24 | + if [ "$(date +%s)" -ge "$deadline" ]; then | |
| 25 | + echo "::error::Diffinery runs for $sha were not finished after $minutes minutes" | |
| 26 | + jq -r '.workflow_runs[] | " \(.name) [\(.event)]: \(.status)"' <<<"$runs" | |
| 27 | + exit 1 | |
| 28 | + fi | |
| 29 | + sleep 15 | |
| 30 | +done | |
| 31 | + | |
| 32 | +failed=0 | |
| 33 | +for id in $(jq -r '.workflow_runs[].id' <<<"$runs"); do | |
| 34 | + run=$(api "/actions/runs/$id") | |
| 35 | + jq -r '"\(.name) [\(.event)]: \(.conclusion) \(.html_url)"' <<<"$run" | |
| 36 | + jobs=$(api "/actions/runs/$id/jobs") | |
| 37 | + jq -r '.jobs[] | " \(.name): \(.conclusion)\(if .failure then " - " + (.failure.message // "") else "" end)"' <<<"$jobs" | |
| 38 | + if [ "$(jq -r .conclusion <<<"$run")" != success ]; then | |
| 39 | + failed=1 | |
| 40 | + for job in $(jq -r '.jobs[] | select(.conclusion == "failure" or .conclusion == "timed_out") | .id' <<<"$jobs"); do | |
| 41 | + echo "::group::Last log lines of job $job" | |
| 42 | + api "/actions/jobs/$job/logs" | tail -n 120 | |
| 43 | + echo "::endgroup::" | |
| 44 | + done | |
| 45 | + fi | |
| 46 | +done | |
| 47 | +[ "$failed" = 0 ] && echo "every Diffinery run on $sha succeeded" | |
| 48 | +exit "$failed" |