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 | * | |
| 91b054e | 23 | * Pack-content inspection also carries an unconditional secret scan (2026-07-16): |
| 24 | * previously a critical secret (AWS/GitHub/Anthropic/Stripe key, PEM private | |
| 25 | * key) landed in the object store on any direct push and was only caught | |
| 26 | * after the fact by the async post-receive scan in security-scan.ts, which | |
| 27 | * can only react with a follow-up remediation commit — the secret was | |
| 28 | * already pushed. The pre-receive hook now also scans changed-file content | |
| 29 | * and rejects the push before objects are promoted, regardless of whether | |
| 30 | * the repo has any rulesets configured. Kill switch: config.secretScanOnPushDisabled. | |
| 31 | * | |
| 5dc83ed | 32 | * Pure helpers + DB callers; never throws into the request path. On any |
| 33 | * unexpected failure we return {allowed:true} (fail-open) to preserve the | |
| 34 | * existing no-policy behaviour rather than wedging legitimate pushes when | |
| 35 | * Postgres hiccups. | |
| 36 | */ | |
| 37 | ||
| ec39211 | 38 | import { mkdtemp, writeFile, rm } from "fs/promises"; |
| 39 | import { join } from "path"; | |
| 40 | import { tmpdir } from "os"; | |
| 5dc83ed | 41 | import { |
| 42 | matchProtectedTag, | |
| 43 | canBypassProtectedTag, | |
| 44 | } from "./protected-tags"; | |
| 45 | import { | |
| 46 | listRulesetsForRepo, | |
| 47 | evaluatePush, | |
| ec39211 | 48 | parseParams, |
| 5dc83ed | 49 | type PushContext, |
| 50 | } from "./rulesets"; | |
| 91b054e | 51 | import { config } from "./config"; |
| ec39211 | 52 | import type { RulesetRule, RepoRuleset } from "../db/schema"; |
| 5dc83ed | 53 | |
| 54 | export type RefUpdate = { | |
| 55 | oldSha: string; | |
| 56 | newSha: string; | |
| 57 | refName: string; | |
| 58 | }; | |
| 59 | ||
| 60 | export type PushPolicyArgs = { | |
| 61 | repositoryId: string; | |
| 62 | refs: RefUpdate[]; | |
| 63 | pusherUserId: string | null; | |
| ec39211 | 64 | /** Absolute path to the bare repo dir; enables pack-content inspection. */ |
| 65 | repoPath?: string; | |
| 5dc83ed | 66 | }; |
| 67 | ||
| 68 | export type PushPolicyResult = { | |
| 69 | allowed: boolean; | |
| 70 | violations: string[]; | |
| 71 | }; | |
| 72 | ||
| 73 | /** "0000000000000000000000000000000000000000" — 40 zeros. */ | |
| 74 | export const ZERO_SHA = "0".repeat(40); | |
| 75 | ||
| ec39211 | 76 | // ---------------------------------------------------------------------------- |
| 77 | // Pack-content inspection via pre-receive hook | |
| 78 | // ---------------------------------------------------------------------------- | |
| 79 | ||
| 80 | type ActiveRuleset = RepoRuleset & { rules: RulesetRule[] }; | |
| 81 | ||
| 91b054e | 82 | /** |
| 83 | * High-signal, no-imports secret detectors used only inside the pre-receive | |
| 84 | * hook sandbox (see buildEvalScript() below — that script runs standalone | |
| 85 | * via `bun run` with zero project imports by design, so these patterns are | |
| 86 | * intentionally a trimmed duplicate of security-scan.ts's SECRET_PATTERNS, | |
| 87 | * restricted to the "critical" subset — the same threshold runAllGateChecks | |
| 88 | * uses to hard-block a PR merge (gate.ts: criticalSecrets === 0). Kept in | |
| 89 | * sync by src/__tests__/push-policy-secret-scan.test.ts, which asserts every | |
| 90 | * critical pattern in security-scan.ts has a corresponding regex here. | |
| 91 | */ | |
| 92 | const HOOK_SECRET_PATTERNS: Array<{ type: string; source: string; flags: string }> = [ | |
| 93 | { type: "AWS Access Key", source: "\\b(AKIA|ASIA|AIDA|AROA)[0-9A-Z]{16}\\b", flags: "" }, | |
| 94 | { type: "AWS Secret Key", source: "aws(.{0,20})?(secret|access)?(.{0,20})?['\\\"]([A-Za-z0-9/+=]{40})['\\\"]", flags: "i" }, | |
| 95 | { type: "GitHub Token", source: "\\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,251}\\b", flags: "" }, | |
| 96 | { type: "Anthropic API Key", source: "\\bsk-ant-(api03|admin01)-[A-Za-z0-9_-]{80,}\\b", flags: "" }, | |
| 97 | { type: "OpenAI API Key", source: "\\bsk-(proj-|live-)?[A-Za-z0-9_-]{32,}\\b", flags: "" }, | |
| 98 | { type: "Stripe Key", source: "\\b(sk_live_|rk_live_|pk_live_)[A-Za-z0-9]{24,}\\b", flags: "" }, | |
| 99 | { type: "Private Key (PEM)", source: "-----BEGIN (RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----", flags: "" }, | |
| 100 | ]; | |
| 101 | ||
| 102 | /** Exported for the sync test — the trimmed duplicate list above. */ | |
| 103 | export function hookSecretPatternTypes(): string[] { | |
| 104 | return HOOK_SECRET_PATTERNS.map((p) => p.type); | |
| 105 | } | |
| 106 | ||
| 107 | const HOOK_SKIP_PATH_RE = | |
| 108 | "(^|/)(\\.git|node_modules|vendor|dist|build|\\.next|\\.cache)/|\\.(png|jpe?g|gif|webp|ico|svg|pdf|mp4|mov|wasm|woff2?|ttf|eot|map)$|(^|/)(bun\\.lockb?|package-lock\\.json|yarn\\.lock|pnpm-lock\\.yaml)$"; | |
| 109 | const HOOK_PLACEHOLDER_RE = "example|placeholder|fake|dummy|your[-_]?api|xxxxx|testkey|changeme"; | |
| 110 | ||
| ec39211 | 111 | /** |
| 112 | * JS source for the per-push evaluator that the pre-receive hook calls once | |
| 91b054e | 113 | * per ref. Receives four file-path arguments: |
| 114 | * argv[2] = rules.json path | |
| 115 | * argv[3] = commits file (lines: "<sha> <subject>") | |
| 116 | * argv[4] = sizes file (lines: "<bytes> <path>") | |
| 117 | * argv[5] = contents file (lines: "<path>\t<base64 content>", size-capped) | |
| ec39211 | 118 | * |
| 119 | * Writes one line per violation to stdout: "<enforcement>\x00<message>". | |
| 120 | * Exits 0 always — the shell hook interprets the output. | |
| 121 | */ | |
| 122 | function buildEvalScript(): string { | |
| 123 | // Written as a plain JS string so no TypeScript template interpolation occurs. | |
| 124 | // Uses only Node/Bun built-ins; no imports from the Gluecron codebase. | |
| 125 | return [ | |
| 126 | "import { readFileSync } from 'fs';", | |
| 127 | "const rules = JSON.parse(readFileSync(process.argv[2], 'utf8'));", | |
| 128 | "const commits = readFileSync(process.argv[3], 'utf8').split('\\n').filter(Boolean);", | |
| 129 | "const sizes = readFileSync(process.argv[4], 'utf8').split('\\n').filter(Boolean);", | |
| 130 | "const SEP = '\\t';", | |
| 131 | "const out = [];", | |
| 132 | "for (const line of commits) {", | |
| 133 | " const sp = line.indexOf(' ');", | |
| 134 | " if (sp < 0) continue;", | |
| 135 | " const sha = line.slice(0, sp);", | |
| 136 | " const msg = line.slice(sp + 1);", | |
| 137 | " for (const r of rules.commitMsgRules) {", | |
| 138 | " const pattern = String(r.params.pattern || '');", | |
| 139 | " if (!pattern) continue;", | |
| 140 | " let re;", | |
| 141 | " try { re = new RegExp(pattern, String(r.params.flags || '') || undefined); } catch { continue; }", | |
| 142 | " const req = r.params.require !== false;", | |
| 143 | " const ok = re.test(msg);", | |
| 144 | " if (req && !ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message does not match /' + pattern + '/');", | |
| 145 | " if (!req && ok) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" commit ' + sha.slice(0,7) + ' message matches forbidden /' + pattern + '/');", | |
| 146 | " }", | |
| 147 | "}", | |
| 148 | "for (const line of sizes) {", | |
| 149 | " const sp = line.indexOf(' ');", | |
| 150 | " if (sp < 0) continue;", | |
| 151 | " const sz = Number(line.slice(0, sp));", | |
| 152 | " const fp = line.slice(sp + 1);", | |
| 153 | " for (const r of rules.blockedPathRules) {", | |
| 154 | " const globs = Array.isArray(r.params.paths) ? r.params.paths : [];", | |
| 155 | " for (const g of globs) {", | |
| 156 | " const parts = [];", | |
| 157 | " let i = 0;", | |
| 158 | " while (i < g.length) {", | |
| 159 | " const ch = g[i];", | |
| 160 | " if (ch === '*') {", | |
| 161 | " if (g[i+1] === '*') { parts.push('.*'); i += 2; }", | |
| 162 | " else { parts.push('[^/]*'); i++; }", | |
| 163 | " } else if (/[.+?^${}()|[\\]\\\\]/.test(ch)) {", | |
| 164 | " parts.push('\\\\' + ch); i++;", | |
| 165 | " } else { parts.push(ch); i++; }", | |
| 166 | " }", | |
| 167 | " const re = new RegExp('^' + parts.join('') + '$');", | |
| 168 | " if (re.test(fp)) out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" modifies blocked path \"' + fp + '\" (' + g + ')');", | |
| 169 | " }", | |
| 170 | " }", | |
| 171 | " for (const r of rules.maxSizeRules) {", | |
| 172 | " const limit = Number(r.params.bytes || 0);", | |
| 173 | " if (limit && sz > limit)", | |
| 174 | " out.push(r.enforcement + SEP + 'ruleset \"' + r.rulesetName + '\" file \"' + fp + '\" is ' + sz + 'B > limit ' + limit + 'B');", | |
| 175 | " }", | |
| 176 | "}", | |
| 91b054e | 177 | // Unconditional secret scan — independent of rules.json / rulesets. |
| 178 | // Only runs when argv[5] (contents file) was provided by the caller. | |
| 179 | `const SECRET_PATTERNS = ${JSON.stringify(HOOK_SECRET_PATTERNS)}.map(p => ({ type: p.type, re: new RegExp(p.source, p.flags) }));`, | |
| 180 | `const SKIP_RE = new RegExp(${JSON.stringify(HOOK_SKIP_PATH_RE)}, 'i');`, | |
| 181 | `const PLACEHOLDER_RE = new RegExp(${JSON.stringify(HOOK_PLACEHOLDER_RE)}, 'i');`, | |
| 182 | "if (process.argv[5]) {", | |
| 183 | " let contentLines = [];", | |
| 184 | " try { contentLines = readFileSync(process.argv[5], 'utf8').split('\\n').filter(Boolean); } catch { contentLines = []; }", | |
| 185 | " for (const line of contentLines) {", | |
| 186 | " const tab = line.indexOf('\\t');", | |
| 187 | " if (tab < 0) continue;", | |
| 188 | " const fp = line.slice(0, tab);", | |
| 189 | " const b64 = line.slice(tab + 1);", | |
| 190 | " if (SKIP_RE.test(fp)) continue;", | |
| 191 | " let text;", | |
| 192 | " try { text = Buffer.from(b64, 'base64').toString('utf8'); } catch { continue; }", | |
| 193 | " const fileLines = text.split('\\n');", | |
| 194 | " for (let i = 0; i < fileLines.length; i++) {", | |
| 195 | " const l = fileLines[i];", | |
| 196 | " if (PLACEHOLDER_RE.test(l)) continue;", | |
| 197 | " for (const p of SECRET_PATTERNS) {", | |
| 198 | " if (p.re.test(l)) {", | |
| 199 | " out.push('active' + SEP + 'Secret scan: possible ' + p.type + ' in ' + fp + ':' + (i + 1) + ' — push rejected. Remove the secret and use environment variables or a secrets manager, then push again.');", | |
| 200 | " break;", | |
| 201 | " }", | |
| 202 | " }", | |
| 203 | " }", | |
| 204 | " }", | |
| 205 | "}", | |
| ec39211 | 206 | "process.stdout.write(out.join('\\n'));", |
| 207 | ].join("\n") + "\n"; | |
| 208 | } | |
| 209 | ||
| 210 | /** | |
| 211 | * Generate a bash pre-receive hook that calls the companion eval.js once per | |
| 212 | * pushed ref. Both file paths are embedded literals so the script is | |
| 213 | * fully self-contained. | |
| 214 | */ | |
| 215 | function buildPreReceiveScript(evalScriptPath: string, rulesJsonPath: string): string { | |
| 216 | const D = "$"; | |
| 217 | return [ | |
| 218 | "#!/bin/bash", | |
| 219 | "set -uo pipefail", | |
| 220 | `EVAL_SCRIPT='${evalScriptPath}'`, | |
| 221 | `RULES_JSON='${rulesJsonPath}'`, | |
| 222 | "FAILED=0", | |
| 223 | "", | |
| 224 | `while IFS=' ' read -r OLD NEW REF; do`, | |
| 225 | ` [[ "${D}NEW" =~ ^0+${D} ]] && continue`, | |
| 226 | ` if [[ "${D}OLD" =~ ^0+${D} ]]; then`, | |
| 227 | ` LOG_RANGE="${D}NEW"`, | |
| 228 | // Empty-tree SHA — safe diff base for new branches with no parent. | |
| 229 | ` DIFF_BASE=4b825dc642cb6eb9a060e54bf8d69288fbee4904`, | |
| 230 | ` else`, | |
| 231 | ` LOG_RANGE="${D}OLD..${D}NEW"`, | |
| 232 | ` DIFF_BASE="${D}OLD"`, | |
| 233 | ` fi`, | |
| 234 | "", | |
| 235 | ` COMMITS_TMP=$(mktemp)`, | |
| 236 | ` SIZES_TMP=$(mktemp)`, | |
| 237 | ` PATHS_TMP=$(mktemp)`, | |
| 91b054e | 238 | ` CONTENTS_TMP=$(mktemp)`, |
| ec39211 | 239 | ` git log --format="%H %s" "${D}LOG_RANGE" 2>/dev/null > "${D}COMMITS_TMP" || true`, |
| 240 | ` git diff --name-only "${D}DIFF_BASE" "${D}NEW" 2>/dev/null > "${D}PATHS_TMP" || true`, | |
| 241 | "", | |
| 91b054e | 242 | // Build sizes file: "<bytes> <path>" per changed file. Also dump |
| 243 | // base64 content (capped at 256KB/file) for the secret scan below — | |
| 244 | // a side write to CONTENTS_TMP, independent of this loop's own | |
| 245 | // stdout redirect into SIZES_TMP. | |
| ec39211 | 246 | ` while IFS= read -r FP; do`, |
| 247 | ` [ -z "${D}FP" ] && continue`, | |
| 248 | ` BLOB=$(git ls-tree "${D}NEW" -- "${D}FP" 2>/dev/null | awk '{print ${D}3}')`, | |
| 249 | ` SZ=0`, | |
| 250 | ` [ -n "${D}BLOB" ] && SZ=$(git cat-file -s "${D}BLOB" 2>/dev/null || echo 0)`, | |
| 251 | ` printf '%s %s\\n' "${D}SZ" "${D}FP"`, | |
| 91b054e | 252 | ` if [ -n "${D}BLOB" ] && [ "${D}SZ" -gt 0 ] && [ "${D}SZ" -le 262144 ]; then`, |
| 253 | ` B64=$(git cat-file -p "${D}BLOB" 2>/dev/null | base64 -w0 2>/dev/null || git cat-file -p "${D}BLOB" 2>/dev/null | base64 | tr -d '\\n')`, | |
| 254 | ` [ -n "${D}B64" ] && printf '%s\\t%s\\n' "${D}FP" "${D}B64" >> "${D}CONTENTS_TMP"`, | |
| 255 | ` fi`, | |
| ec39211 | 256 | ` done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`, |
| 257 | "", | |
| 91b054e | 258 | ` RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP" 2>/dev/null || true)`, |
| ec39211 | 259 | "", |
| 260 | // Each output line is "<enforcement>\t<message>"; split on tab. | |
| 261 | ` while IFS=$'\\t' read -r ENFORCE MSG_OUT; do`, | |
| 262 | ` [ -z "${D}MSG_OUT" ] && continue`, | |
| 263 | ` echo "remote: ${D}MSG_OUT" >&2`, | |
| 264 | ` [ "${D}ENFORCE" = "active" ] && FAILED=1`, | |
| 265 | ` done <<< "${D}RESULT"`, | |
| 266 | "", | |
| 91b054e | 267 | ` rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP"`, |
| ec39211 | 268 | `done`, |
| 269 | "", | |
| 270 | `exit ${D}FAILED`, | |
| 271 | ].join("\n") + "\n"; | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * Write a pre-receive hook + companion rules JSON to a temp directory and | |
| 276 | * return the git env vars that redirect git to use that hooks dir, plus a | |
| 277 | * cleanup function. | |
| 278 | * | |
| 279 | * Callers must always invoke cleanup() — even on error — to remove the temp | |
| 280 | * directory. Failure to set up the hook dir is non-fatal: we return null so | |
| 281 | * the caller can proceed without pack-content inspection rather than wedging | |
| 282 | * the push. | |
| 283 | */ | |
| 284 | export async function installPackInspectionHook( | |
| 91b054e | 285 | rulesets: ActiveRuleset[], |
| 286 | opts: { secretScan?: boolean } = {} | |
| ec39211 | 287 | ): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> { |
| 91b054e | 288 | const secretScan = opts.secretScan !== false; |
| 289 | ||
| ec39211 | 290 | // Collect pack-content rules from non-disabled rulesets. |
| 291 | type RuleEntry = { rulesetName: string; enforcement: string; params: Record<string, unknown> }; | |
| 292 | const commitMsgRules: RuleEntry[] = []; | |
| 293 | const blockedPathRules: RuleEntry[] = []; | |
| 294 | const maxSizeRules: RuleEntry[] = []; | |
| 295 | ||
| 296 | for (const rs of rulesets) { | |
| 297 | if (rs.enforcement === "disabled") continue; | |
| 298 | for (const r of rs.rules) { | |
| 299 | const p = parseParams(r.params); | |
| 300 | const entry: RuleEntry = { rulesetName: rs.name, enforcement: rs.enforcement, params: p }; | |
| 301 | if (r.ruleType === "commit_message_pattern") commitMsgRules.push(entry); | |
| 302 | else if (r.ruleType === "blocked_file_paths") blockedPathRules.push(entry); | |
| 303 | else if (r.ruleType === "max_file_size") maxSizeRules.push(entry); | |
| 304 | } | |
| 305 | } | |
| 306 | ||
| 91b054e | 307 | // No pack-content rules AND secret scan disabled → skip hook entirely. |
| 308 | if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length && !secretScan) { | |
| ec39211 | 309 | return null; |
| 310 | } | |
| 311 | ||
| 312 | try { | |
| 313 | const dir = await mkdtemp(join(tmpdir(), "gluecron-hooks-")); | |
| 314 | const rulesJsonPath = join(dir, "rules.json"); | |
| 315 | await writeFile( | |
| 316 | rulesJsonPath, | |
| 317 | JSON.stringify({ commitMsgRules, blockedPathRules, maxSizeRules }), | |
| 318 | { mode: 0o644 } | |
| 319 | ); | |
| 320 | const evalScriptPath = join(dir, "eval.js"); | |
| 321 | await writeFile(evalScriptPath, buildEvalScript(), { mode: 0o644 }); | |
| 322 | const hookPath = join(dir, "pre-receive"); | |
| 323 | await writeFile(hookPath, buildPreReceiveScript(evalScriptPath, rulesJsonPath), { mode: 0o755 }); | |
| 324 | return { | |
| 325 | env: { | |
| 326 | GIT_CONFIG_COUNT: "1", | |
| 327 | GIT_CONFIG_KEY_0: "core.hooksPath", | |
| 328 | GIT_CONFIG_VALUE_0: dir, | |
| 329 | }, | |
| 330 | cleanup: async () => { | |
| 331 | try { | |
| 332 | await rm(dir, { recursive: true, force: true }); | |
| 333 | } catch { | |
| 334 | // best-effort | |
| 335 | } | |
| 336 | }, | |
| 337 | }; | |
| 338 | } catch { | |
| 339 | return null; | |
| 340 | } | |
| 341 | } | |
| 342 | ||
| 5dc83ed | 343 | const ALLOW: PushPolicyResult = { allowed: true, violations: [] }; |
| 344 | ||
| 345 | /** | |
| 346 | * Classify a ref name into "branch" / "tag" for the ruleset evaluator. | |
| 347 | * Heads = branch, tags = tag, anything else (e.g. `refs/notes/*`) we treat | |
| 348 | * as a branch since the evaluator's tag-only rules will gracefully no-op. | |
| 349 | */ | |
| 350 | function refType(refName: string): "branch" | "tag" { | |
| 351 | return refName.startsWith("refs/tags/") ? "tag" : "branch"; | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * Evaluate every ref in `refs` against the repo's protected-tags + rulesets | |
| 356 | * and return the aggregated decision. Multiple violations across refs are | |
| 357 | * concatenated so the user sees every problem in one push attempt rather | |
| 358 | * than one-at-a-time. | |
| 359 | */ | |
| 360 | export async function evaluatePushPolicy( | |
| 361 | args: PushPolicyArgs | |
| 362 | ): Promise<PushPolicyResult> { | |
| 363 | const { repositoryId, refs, pusherUserId } = args; | |
| 364 | if (!repositoryId || !refs || refs.length === 0) return ALLOW; | |
| 365 | ||
| 366 | const violations: string[] = []; | |
| 367 | ||
| 368 | // Protected tags — runs once per ref, only fires for tag refs. | |
| 369 | for (const ref of refs) { | |
| 370 | if (!ref.refName.startsWith("refs/tags/")) continue; | |
| 371 | const tagName = ref.refName.slice("refs/tags/".length); | |
| 372 | let protectedRule: Awaited<ReturnType<typeof matchProtectedTag>> = null; | |
| 373 | try { | |
| 374 | protectedRule = await matchProtectedTag(repositoryId, tagName); | |
| 375 | } catch { | |
| 376 | protectedRule = null; | |
| 377 | } | |
| 378 | if (!protectedRule) continue; | |
| 379 | ||
| 380 | // Anonymous pusher → never bypasses. Authenticated pusher must be the | |
| 381 | // owner (or future tag-admin) for this repo. | |
| 382 | let canBypass = false; | |
| 383 | try { | |
| 384 | canBypass = await canBypassProtectedTag(repositoryId, pusherUserId); | |
| 385 | } catch { | |
| 386 | canBypass = false; | |
| 387 | } | |
| 388 | if (canBypass) continue; | |
| 389 | ||
| 390 | const action = | |
| 391 | ref.newSha === ZERO_SHA | |
| 392 | ? "delete" | |
| 393 | : ref.oldSha === ZERO_SHA | |
| 394 | ? "create" | |
| 395 | : "update"; | |
| 396 | violations.push( | |
| 397 | `tag "${tagName}" is protected (pattern: ${protectedRule.pattern}); ${action} requires bypass` | |
| 398 | ); | |
| 399 | } | |
| 400 | ||
| 401 | // Rulesets — single DB call, evaluator runs purely on names. | |
| 402 | let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = []; | |
| 403 | try { | |
| 404 | rulesets = await listRulesetsForRepo(repositoryId); | |
| 405 | } catch { | |
| 406 | rulesets = []; | |
| 407 | } | |
| 408 | ||
| 409 | if (rulesets && rulesets.length > 0) { | |
| 410 | for (const ref of refs) { | |
| 411 | const ctx: PushContext = { | |
| 412 | kind: "push", | |
| 413 | refType: refType(ref.refName), | |
| 414 | refName: ref.refName, | |
| 415 | commits: [], | |
| 416 | // forcePush is left false — true detection requires a reachability | |
| 417 | // check on the new commit, which is in the pack we haven't unpacked. | |
| 418 | forcePush: false, | |
| 419 | }; | |
| 420 | let result; | |
| 421 | try { | |
| 422 | result = evaluatePush(rulesets, ctx); | |
| 423 | } catch { | |
| 424 | result = { allowed: true, violations: [] as Array<{ rulesetName: string; ruleType: string; message: string; enforcement: string }> }; | |
| 425 | } | |
| 426 | if (!result.allowed && result.violations.length > 0) { | |
| 427 | for (const v of result.violations) { | |
| 428 | // Only "active" enforcement blocks; "evaluate" is dry-run. | |
| 429 | if (v.enforcement !== "active") continue; | |
| 430 | violations.push( | |
| 431 | `ruleset "${v.rulesetName}" rule ${v.ruleType}: ${v.message} (ref ${ref.refName})` | |
| 432 | ); | |
| 433 | } | |
| 434 | } | |
| 435 | } | |
| 436 | } | |
| 437 | ||
| 438 | return violations.length === 0 | |
| 439 | ? ALLOW | |
| 440 | : { allowed: false, violations }; | |
| 441 | } | |
| 442 | ||
| ec39211 | 443 | /** |
| 444 | * Convenience wrapper: fetch rulesets for `repositoryId` from the DB and | |
| 445 | * call `installPackInspectionHook`. Returns null on DB error or when there | |
| 446 | * are no pack-content rules to enforce. Never throws. | |
| 447 | */ | |
| 448 | export async function installPackInspectionHookForRepo( | |
| 449 | repositoryId: string | |
| 450 | ): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> { | |
| 451 | let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = []; | |
| 452 | try { | |
| 453 | rulesets = await listRulesetsForRepo(repositoryId); | |
| 454 | } catch { | |
| 91b054e | 455 | rulesets = []; |
| ec39211 | 456 | } |
| 91b054e | 457 | // Always install — even with zero rulesets — so the unconditional secret |
| 458 | // scan still runs. Only a listRulesetsForRepo throw plus secret-scan being | |
| 459 | // disabled entirely skips the hook (handled by installPackInspectionHook's | |
| 460 | // own early-return when both inputs are empty). | |
| 461 | return installPackInspectionHook(rulesets, { | |
| 462 | secretScan: !config.secretScanOnPushDisabled, | |
| 463 | }); | |
| ec39211 | 464 | } |
| 465 | ||
| 5dc83ed | 466 | /** Build a human-readable error body for the 403 response. */ |
| 467 | export function formatPolicyError(violations: string[]): string { | |
| 468 | if (!violations || violations.length === 0) { | |
| 469 | return "Push rejected by Gluecron policy."; | |
| 470 | } | |
| 471 | const lines = violations.map((v) => ` - ${v}`); | |
| 472 | return `Push rejected by Gluecron policy:\n${lines.join("\n")}\n`; | |
| 473 | } |