CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
push-policy.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 5dc83ed | 1 | /** |
| 2 | * Push-policy enforcement — runs at the HTTP layer before git-receive-pack | |
| 3 | * actually accepts the pack. | |
| 4 | * | |
| 5 | * Until now Gluecron's protected-tag and ruleset surfaces were advisory: | |
| 6 | * `src/hooks/post-receive.ts` would log audit entries after the push had | |
| 7 | * already landed. This module flips them to truly blocking by evaluating | |
| 8 | * a list of refs at receive time and returning {allowed:false, violations} | |
| 9 | * so the route can short-circuit with a 403. | |
| 10 | * | |
| 11 | * Refs evaluable from name+sha alone (no pack inspection): | |
| 12 | * - protected_tags — tag pushes must come from owner/bypass | |
| 13 | * - ruleset.tag_name_pattern — disallow tag names matching pattern | |
| 14 | * - ruleset.branch_name_pattern — disallow branch names matching pattern | |
| 15 | * - ruleset.forbid_force_push — heuristic: detected when oldSha was | |
| 16 | * not the zero-SHA and newSha differs. | |
| 17 | * True force-push detection requires a | |
| 18 | * reachability check we don't run here; | |
| 19 | * the existing `forcePush` boolean on | |
| 20 | * PushContext is wired to false unless | |
| 21 | * a smarter caller fills it in. | |
| 22 | * | |
| 23 | * Pure helpers + DB callers; never throws into the request path. On any | |
| 24 | * unexpected failure we return {allowed:true} (fail-open) to preserve the | |
| 25 | * existing no-policy behaviour rather than wedging legitimate pushes when | |
| 26 | * Postgres hiccups. | |
| 27 | */ | |
| 28 | ||
| ec39211 | 29 | import { mkdtemp, writeFile, rm } from "fs/promises"; |
| 30 | import { join } from "path"; | |
| 31 | import { tmpdir } from "os"; | |
| 5dc83ed | 32 | import { |
| 33 | matchProtectedTag, | |
| 34 | canBypassProtectedTag, | |
| 35 | } from "./protected-tags"; | |
| 36 | import { | |
| 37 | listRulesetsForRepo, | |
| 38 | evaluatePush, | |
| ec39211 | 39 | parseParams, |
| 5dc83ed | 40 | type PushContext, |
| 41 | } from "./rulesets"; | |
| ec39211 | 42 | import type { RulesetRule, RepoRuleset } from "../db/schema"; |
| 5dc83ed | 43 | |
| 44 | export type RefUpdate = { | |
| 45 | oldSha: string; | |
| 46 | newSha: string; | |
| 47 | refName: string; | |
| 48 | }; | |
| 49 | ||
| 50 | export type PushPolicyArgs = { | |
| 51 | repositoryId: string; | |
| 52 | refs: RefUpdate[]; | |
| 53 | pusherUserId: string | null; | |
| ec39211 | 54 | /** Absolute path to the bare repo dir; enables pack-content inspection. */ |
| 55 | repoPath?: string; | |
| 5dc83ed | 56 | }; |
| 57 | ||
| 58 | export type PushPolicyResult = { | |
| 59 | allowed: boolean; | |
| 60 | violations: string[]; | |
| 61 | }; | |
| 62 | ||
| 63 | /** "0000000000000000000000000000000000000000" — 40 zeros. */ | |
| 64 | export const ZERO_SHA = "0".repeat(40); | |
| 65 | ||
| ec39211 | 66 | // ---------------------------------------------------------------------------- |
| 67 | // Pack-content inspection via pre-receive hook | |
| 68 | // ---------------------------------------------------------------------------- | |
| 69 | ||
| 70 | type ActiveRuleset = RepoRuleset & { rules: RulesetRule[] }; | |
| 71 | ||
| 72 | /** | |
| 73 | * JS source for the per-push evaluator that the pre-receive hook calls once | |
| 74 | * per ref. Receives three file-path arguments: | |
| 75 | * argv[1] = rules.json path | |
| 76 | * argv[2] = commits file (lines: "<sha> <subject>") | |
| 77 | * argv[3] = sizes file (lines: "<bytes> <path>") | |
| 78 | * | |
| 79 | * Writes one line per violation to stdout: "<enforcement>\x00<message>". | |
| 80 | * Exits 0 always — the shell hook interprets the output. | |
| 81 | */ | |
| 82 | function buildEvalScript(): string { | |
| 83 | // Written as a plain JS string so no TypeScript template interpolation occurs. | |
| 84 | // Uses only Node/Bun built-ins; no imports from the Gluecron codebase. | |
| 85 | return [ | |
| 86 | "import { readFileSync } from 'fs';", | |
| 87 | "const rules = JSON.parse(readFileSync(process.argv[2], 'utf8'));", | |
| 88 | "const commits = readFileSync(process.argv[3], 'utf8').split('\\n').filter(Boolean);", | |
| 89 | "const sizes = readFileSync(process.argv[4], 'utf8').split('\\n').filter(Boolean);", | |
| 90 | "const SEP = '\\t';", | |
| 91 | "const out = [];", | |
| 92 | "for (const line of commits) {", | |
| 93 | " const sp = line.indexOf(' ');", | |
| 94 | " if (sp < 0) continue;", | |
| 95 | " const sha = line.slice(0, sp);", | |
| 96 | " const msg = line.slice(sp + 1);", | |
| 97 | " for (const r of rules.commitMsgRules) {", | |
| 98 | " const pattern = String(r.params.pattern || '');", | |
| 99 | " if (!pattern) continue;", | |
| 100 | " let re;", | |
| 101 | " try { re = new RegExp(pattern, String(r.params.flags || '') || undefined); } catch { continue; }", | |
| 102 | " const req = r.params.require !== false;", | |
| 103 | " const ok = re.test(msg);", | |
| 104 | " if (req && !ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message does not match /' + pattern + '/');", | |
| 105 | " if (!req && ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message matches forbidden /' + pattern + '/');", | |
| 106 | " }", | |
| 107 | "}", | |
| 108 | "for (const line of sizes) {", | |
| 109 | " const sp = line.indexOf(' ');", | |
| 110 | " if (sp < 0) continue;", | |
| 111 | " const sz = Number(line.slice(0, sp));", | |
| 112 | " const fp = line.slice(sp + 1);", | |
| 113 | " for (const r of rules.blockedPathRules) {", | |
| 114 | " const globs = Array.isArray(r.params.paths) ? r.params.paths : [];", | |
| 115 | " for (const g of globs) {", | |
| 116 | " const parts = [];", | |
| 117 | " let i = 0;", | |
| 118 | " while (i < g.length) {", | |
| 119 | " const ch = g[i];", | |
| 120 | " if (ch === '*') {", | |
| 121 | " if (g[i+1] === '*') { parts.push('.*'); i += 2; }", | |
| 122 | " else { parts.push('[^/]*'); i++; }", | |
| 123 | " } else if (/[.+?^${}()|[\\]\\\\]/.test(ch)) {", | |
| 124 | " parts.push('\\\\' + ch); i++;", | |
| 125 | " } else { parts.push(ch); i++; }", | |
| 126 | " }", | |
| 127 | " const re = new RegExp('^' + parts.join('') + '$');", | |
| 128 | " if (re.test(fp)) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" modifies blocked path \"' + fp + '\" (' + g + ')');", | |
| 129 | " }", | |
| 130 | " }", | |
| 131 | " for (const r of rules.maxSizeRules) {", | |
| 132 | " const limit = Number(r.params.bytes || 0);", | |
| 133 | " if (limit && sz > limit)", | |
| 134 | " out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" file \"' + fp + '\" is ' + sz + 'B > limit ' + limit + 'B');", | |
| 135 | " }", | |
| 136 | "}", | |
| 137 | "process.stdout.write(out.join('\\n'));", | |
| 138 | ].join("\n") + "\n"; | |
| 139 | } | |
| 140 | ||
| 141 | /** | |
| 142 | * Generate a bash pre-receive hook that calls the companion eval.js once per | |
| 143 | * pushed ref. Both file paths are embedded literals so the script is | |
| 144 | * fully self-contained. | |
| 145 | */ | |
| 146 | function buildPreReceiveScript(evalScriptPath: string, rulesJsonPath: string): string { | |
| 147 | const D = "$"; | |
| 148 | return [ | |
| 149 | "#!/bin/bash", | |
| 150 | "set -uo pipefail", | |
| 151 | `EVAL_SCRIPT='${evalScriptPath}'`, | |
| 152 | `RULES_JSON='${rulesJsonPath}'`, | |
| 153 | "FAILED=0", | |
| 154 | "", | |
| 155 | `while IFS=' ' read -r OLD NEW REF; do`, | |
| 156 | ` [[ "${D}NEW" =~ ^0+${D} ]] && continue`, | |
| 157 | ` if [[ "${D}OLD" =~ ^0+${D} ]]; then`, | |
| 158 | ` LOG_RANGE="${D}NEW"`, | |
| 159 | // Empty-tree SHA — safe diff base for new branches with no parent. | |
| 160 | ` DIFF_BASE=4b825dc642cb6eb9a060e54bf8d69288fbee4904`, | |
| 161 | ` else`, | |
| 162 | ` LOG_RANGE="${D}OLD..${D}NEW"`, | |
| 163 | ` DIFF_BASE="${D}OLD"`, | |
| 164 | ` fi`, | |
| 165 | "", | |
| 166 | ` COMMITS_TMP=$(mktemp)`, | |
| 167 | ` SIZES_TMP=$(mktemp)`, | |
| 168 | ` PATHS_TMP=$(mktemp)`, | |
| 169 | ` git log --format="%H %s" "${D}LOG_RANGE" 2>/dev/null > "${D}COMMITS_TMP" || true`, | |
| 170 | ` git diff --name-only "${D}DIFF_BASE" "${D}NEW" 2>/dev/null > "${D}PATHS_TMP" || true`, | |
| 171 | "", | |
| 172 | // Build sizes file: "<bytes> <path>" per changed file. | |
| 173 | ` while IFS= read -r FP; do`, | |
| 174 | ` [ -z "${D}FP" ] && continue`, | |
| 175 | ` BLOB=$(git ls-tree "${D}NEW" -- "${D}FP" 2>/dev/null | awk '{print ${D}3}')`, | |
| 176 | ` SZ=0`, | |
| 177 | ` [ -n "${D}BLOB" ] && SZ=$(git cat-file -s "${D}BLOB" 2>/dev/null || echo 0)`, | |
| 178 | ` printf '%s %s\\n' "${D}SZ" "${D}FP"`, | |
| 179 | ` done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`, | |
| 180 | "", | |
| 181 | ` RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" 2>/dev/null || true)`, | |
| 182 | "", | |
| 183 | // Each output line is "<enforcement>\t<message>"; split on tab. | |
| 184 | ` while IFS=$'\\t' read -r ENFORCE MSG_OUT; do`, | |
| 185 | ` [ -z "${D}MSG_OUT" ] && continue`, | |
| 186 | ` echo "remote: ${D}MSG_OUT" >&2`, | |
| 187 | ` [ "${D}ENFORCE" = "active" ] && FAILED=1`, | |
| 188 | ` done <<< "${D}RESULT"`, | |
| 189 | "", | |
| 190 | ` rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP"`, | |
| 191 | `done`, | |
| 192 | "", | |
| 193 | `exit ${D}FAILED`, | |
| 194 | ].join("\n") + "\n"; | |
| 195 | } | |
| 196 | ||
| 197 | /** | |
| 198 | * Write a pre-receive hook + companion rules JSON to a temp directory and | |
| 199 | * return the git env vars that redirect git to use that hooks dir, plus a | |
| 200 | * cleanup function. | |
| 201 | * | |
| 202 | * Callers must always invoke cleanup() — even on error — to remove the temp | |
| 203 | * directory. Failure to set up the hook dir is non-fatal: we return null so | |
| 204 | * the caller can proceed without pack-content inspection rather than wedging | |
| 205 | * the push. | |
| 206 | */ | |
| 207 | export async function installPackInspectionHook( | |
| 208 | rulesets: ActiveRuleset[] | |
| 209 | ): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> { | |
| 210 | // Collect pack-content rules from non-disabled rulesets. | |
| 211 | type RuleEntry = { rulesetName: string; enforcement: string; params: Record<string, unknown> }; | |
| 212 | const commitMsgRules: RuleEntry[] = []; | |
| 213 | const blockedPathRules: RuleEntry[] = []; | |
| 214 | const maxSizeRules: RuleEntry[] = []; | |
| 215 | ||
| 216 | for (const rs of rulesets) { | |
| 217 | if (rs.enforcement === "disabled") continue; | |
| 218 | for (const r of rs.rules) { | |
| 219 | const p = parseParams(r.params); | |
| 220 | const entry: RuleEntry = { rulesetName: rs.name, enforcement: rs.enforcement, params: p }; | |
| 221 | if (r.ruleType === "commit_message_pattern") commitMsgRules.push(entry); | |
| 222 | else if (r.ruleType === "blocked_file_paths") blockedPathRules.push(entry); | |
| 223 | else if (r.ruleType === "max_file_size") maxSizeRules.push(entry); | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | // No pack-content rules → skip hook installation entirely. | |
| 228 | if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length) { | |
| 229 | return null; | |
| 230 | } | |
| 231 | ||
| 232 | try { | |
| 233 | const dir = await mkdtemp(join(tmpdir(), "gluecron-hooks-")); | |
| 234 | const rulesJsonPath = join(dir, "rules.json"); | |
| 235 | await writeFile( | |
| 236 | rulesJsonPath, | |
| 237 | JSON.stringify({ commitMsgRules, blockedPathRules, maxSizeRules }), | |
| 238 | { mode: 0o644 } | |
| 239 | ); | |
| 240 | const evalScriptPath = join(dir, "eval.js"); | |
| 241 | await writeFile(evalScriptPath, buildEvalScript(), { mode: 0o644 }); | |
| 242 | const hookPath = join(dir, "pre-receive"); | |
| 243 | await writeFile(hookPath, buildPreReceiveScript(evalScriptPath, rulesJsonPath), { mode: 0o755 }); | |
| 244 | return { | |
| 245 | env: { | |
| 246 | GIT_CONFIG_COUNT: "1", | |
| 247 | GIT_CONFIG_KEY_0: "core.hooksPath", | |
| 248 | GIT_CONFIG_VALUE_0: dir, | |
| 249 | }, | |
| 250 | cleanup: async () => { | |
| 251 | try { | |
| 252 | await rm(dir, { recursive: true, force: true }); | |
| 253 | } catch { | |
| 254 | // best-effort | |
| 255 | } | |
| 256 | }, | |
| 257 | }; | |
| 258 | } catch { | |
| 259 | return null; | |
| 260 | } | |
| 261 | } | |
| 262 | ||
| 5dc83ed | 263 | const ALLOW: PushPolicyResult = { allowed: true, violations: [] }; |
| 264 | ||
| 265 | /** | |
| 266 | * Classify a ref name into "branch" / "tag" for the ruleset evaluator. | |
| 267 | * Heads = branch, tags = tag, anything else (e.g. `refs/notes/*`) we treat | |
| 268 | * as a branch since the evaluator's tag-only rules will gracefully no-op. | |
| 269 | */ | |
| 270 | function refType(refName: string): "branch" | "tag" { | |
| 271 | return refName.startsWith("refs/tags/") ? "tag" : "branch"; | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * Evaluate every ref in `refs` against the repo's protected-tags + rulesets | |
| 276 | * and return the aggregated decision. Multiple violations across refs are | |
| 277 | * concatenated so the user sees every problem in one push attempt rather | |
| 278 | * than one-at-a-time. | |
| 279 | */ | |
| 280 | export async function evaluatePushPolicy( | |
| 281 | args: PushPolicyArgs | |
| 282 | ): Promise<PushPolicyResult> { | |
| 283 | const { repositoryId, refs, pusherUserId } = args; | |
| 284 | if (!repositoryId || !refs || refs.length === 0) return ALLOW; | |
| 285 | ||
| 286 | const violations: string[] = []; | |
| 287 | ||
| 288 | // Protected tags — runs once per ref, only fires for tag refs. | |
| 289 | for (const ref of refs) { | |
| 290 | if (!ref.refName.startsWith("refs/tags/")) continue; | |
| 291 | const tagName = ref.refName.slice("refs/tags/".length); | |
| 292 | let protectedRule: Awaited<ReturnType<typeof matchProtectedTag>> = null; | |
| 293 | try { | |
| 294 | protectedRule = await matchProtectedTag(repositoryId, tagName); | |
| 295 | } catch { | |
| 296 | protectedRule = null; | |
| 297 | } | |
| 298 | if (!protectedRule) continue; | |
| 299 | ||
| 300 | // Anonymous pusher → never bypasses. Authenticated pusher must be the | |
| 301 | // owner (or future tag-admin) for this repo. | |
| 302 | let canBypass = false; | |
| 303 | try { | |
| 304 | canBypass = await canBypassProtectedTag(repositoryId, pusherUserId); | |
| 305 | } catch { | |
| 306 | canBypass = false; | |
| 307 | } | |
| 308 | if (canBypass) continue; | |
| 309 | ||
| 310 | const action = | |
| 311 | ref.newSha === ZERO_SHA | |
| 312 | ? "delete" | |
| 313 | : ref.oldSha === ZERO_SHA | |
| 314 | ? "create" | |
| 315 | : "update"; | |
| 316 | violations.push( | |
| 317 | `tag "${tagName}" is protected (pattern: ${protectedRule.pattern}); ${action} requires bypass` | |
| 318 | ); | |
| 319 | } | |
| 320 | ||
| 321 | // Rulesets — single DB call, evaluator runs purely on names. | |
| 322 | let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = []; | |
| 323 | try { | |
| 324 | rulesets = await listRulesetsForRepo(repositoryId); | |
| 325 | } catch { | |
| 326 | rulesets = []; | |
| 327 | } | |
| 328 | ||
| 329 | if (rulesets && rulesets.length > 0) { | |
| 330 | for (const ref of refs) { | |
| 331 | const ctx: PushContext = { | |
| 332 | kind: "push", | |
| 333 | refType: refType(ref.refName), | |
| 334 | refName: ref.refName, | |
| 335 | commits: [], | |
| 336 | // forcePush is left false — true detection requires a reachability | |
| 337 | // check on the new commit, which is in the pack we haven't unpacked. | |
| 338 | forcePush: false, | |
| 339 | }; | |
| 340 | let result; | |
| 341 | try { | |
| 342 | result = evaluatePush(rulesets, ctx); | |
| 343 | } catch { | |
| 344 | result = { allowed: true, violations: [] as Array<{ rulesetName: string; ruleType: string; message: string; enforcement: string }> }; | |
| 345 | } | |
| 346 | if (!result.allowed && result.violations.length > 0) { | |
| 347 | for (const v of result.violations) { | |
| 348 | // Only "active" enforcement blocks; "evaluate" is dry-run. | |
| 349 | if (v.enforcement !== "active") continue; | |
| 350 | violations.push( | |
| 351 | `ruleset "${v.rulesetName}" rule ${v.ruleType}: ${v.message} (ref ${ref.refName})` | |
| 352 | ); | |
| 353 | } | |
| 354 | } | |
| 355 | } | |
| 356 | } | |
| 357 | ||
| 358 | return violations.length === 0 | |
| 359 | ? ALLOW | |
| 360 | : { allowed: false, violations }; | |
| 361 | } | |
| 362 | ||
| ec39211 | 363 | /** |
| 364 | * Convenience wrapper: fetch rulesets for `repositoryId` from the DB and | |
| 365 | * call `installPackInspectionHook`. Returns null on DB error or when there | |
| 366 | * are no pack-content rules to enforce. Never throws. | |
| 367 | */ | |
| 368 | export async function installPackInspectionHookForRepo( | |
| 369 | repositoryId: string | |
| 370 | ): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> { | |
| 371 | let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = []; | |
| 372 | try { | |
| 373 | rulesets = await listRulesetsForRepo(repositoryId); | |
| 374 | } catch { | |
| 375 | return null; | |
| 376 | } | |
| 377 | if (!rulesets.length) return null; | |
| 378 | return installPackInspectionHook(rulesets); | |
| 379 | } | |
| 380 | ||
| 5dc83ed | 381 | /** Build a human-readable error body for the 403 response. */ |
| 382 | export function formatPolicyError(violations: string[]): string { | |
| 383 | if (!violations || violations.length === 0) { | |
| 384 | return "Push rejected by Gluecron policy."; | |
| 385 | } | |
| 386 | const lines = violations.map((v) => ` - ${v}`); | |
| 387 | return `Push rejected by Gluecron policy:\n${lines.join("\n")}\n`; | |
| 388 | } |