Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

push-policy.tsBlame478 lines · 3 contributors
5dc83edClaude1/**
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 *
91b054eccanty labs23 * 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 *
5dc83edClaude32 * 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
ec39211Claude38import { mkdtemp, writeFile, rm } from "fs/promises";
39import { join } from "path";
40import { tmpdir } from "os";
5dc83edClaude41import {
42 matchProtectedTag,
43 canBypassProtectedTag,
44} from "./protected-tags";
45import {
46 listRulesetsForRepo,
47 evaluatePush,
ec39211Claude48 parseParams,
5dc83edClaude49 type PushContext,
50} from "./rulesets";
91b054eccanty labs51import { config } from "./config";
ec39211Claude52import type { RulesetRule, RepoRuleset } from "../db/schema";
5dc83edClaude53
54export type RefUpdate = {
55 oldSha: string;
56 newSha: string;
57 refName: string;
58};
59
60export type PushPolicyArgs = {
61 repositoryId: string;
62 refs: RefUpdate[];
63 pusherUserId: string | null;
ec39211Claude64 /** Absolute path to the bare repo dir; enables pack-content inspection. */
65 repoPath?: string;
5dc83edClaude66};
67
68export type PushPolicyResult = {
69 allowed: boolean;
70 violations: string[];
71};
72
73/** "0000000000000000000000000000000000000000" — 40 zeros. */
74export const ZERO_SHA = "0".repeat(40);
75
ec39211Claude76// ----------------------------------------------------------------------------
77// Pack-content inspection via pre-receive hook
78// ----------------------------------------------------------------------------
79
80type ActiveRuleset = RepoRuleset & { rules: RulesetRule[] };
81
91b054eccanty labs82/**
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 */
92const 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. */
103export function hookSecretPatternTypes(): string[] {
104 return HOOK_SECRET_PATTERNS.map((p) => p.type);
105}
106
107const 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)$";
109const HOOK_PLACEHOLDER_RE = "example|placeholder|fake|dummy|your[-_]?api|xxxxx|testkey|changeme";
110
ec39211Claude111/**
112 * JS source for the per-push evaluator that the pre-receive hook calls once
91b054eccanty labs113 * 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)
ec39211Claude118 *
119 * Writes one line per violation to stdout: "<enforcement>\x00<message>".
120 * Exits 0 always — the shell hook interprets the output.
121 */
122function 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 "}",
91b054eccanty labs177 // 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 "}",
ec39211Claude206 "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 */
215function 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 "",
227 ` COMMITS_TMP=$(mktemp)`,
228 ` SIZES_TMP=$(mktemp)`,
229 ` PATHS_TMP=$(mktemp)`,
91b054eccanty labs230 ` CONTENTS_TMP=$(mktemp)`,
74202f0ccantynz-alt231 ` if [[ "${D}OLD" =~ ^0+${D} ]]; then`,
232 // New branch/ref: inspect ONLY the commits and paths that are not already
233 // reachable from an existing ref (--not --all). The previous empty-tree
234 // diff base ("git diff <empty-tree> NEW") reported EVERY file in the whole
235 // repo as changed, so pushing any new branch re-scanned pre-existing files
236 // the push never touched — a clean feature branch was rejected because an
237 // unrelated committed fixture matched a secret pattern. On a genuinely
238 // empty repo (no existing refs) --not --all excludes nothing, so the very
239 // first push is still fully scanned, which is correct.
240 ` git log --format="%H %s" "${D}NEW" --not --all 2>/dev/null > "${D}COMMITS_TMP" || true`,
241 ` git log --name-only --format="" "${D}NEW" --not --all 2>/dev/null | sort -u > "${D}PATHS_TMP" || true`,
242 ` else`,
243 ` git log --format="%H %s" "${D}OLD..${D}NEW" 2>/dev/null > "${D}COMMITS_TMP" || true`,
244 ` git diff --name-only "${D}OLD" "${D}NEW" 2>/dev/null > "${D}PATHS_TMP" || true`,
245 ` fi`,
ec39211Claude246 "",
91b054eccanty labs247 // Build sizes file: "<bytes> <path>" per changed file. Also dump
248 // base64 content (capped at 256KB/file) for the secret scan below —
249 // a side write to CONTENTS_TMP, independent of this loop's own
250 // stdout redirect into SIZES_TMP.
ec39211Claude251 ` while IFS= read -r FP; do`,
252 ` [ -z "${D}FP" ] && continue`,
253 ` BLOB=$(git ls-tree "${D}NEW" -- "${D}FP" 2>/dev/null | awk '{print ${D}3}')`,
254 ` SZ=0`,
255 ` [ -n "${D}BLOB" ] && SZ=$(git cat-file -s "${D}BLOB" 2>/dev/null || echo 0)`,
256 ` printf '%s %s\\n' "${D}SZ" "${D}FP"`,
91b054eccanty labs257 ` if [ -n "${D}BLOB" ] && [ "${D}SZ" -gt 0 ] && [ "${D}SZ" -le 262144 ]; then`,
258 ` 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')`,
259 ` [ -n "${D}B64" ] && printf '%s\\t%s\\n' "${D}FP" "${D}B64" >> "${D}CONTENTS_TMP"`,
260 ` fi`,
ec39211Claude261 ` done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`,
262 "",
91b054eccanty labs263 ` RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP" 2>/dev/null || true)`,
ec39211Claude264 "",
265 // Each output line is "<enforcement>\t<message>"; split on tab.
266 ` while IFS=$'\\t' read -r ENFORCE MSG_OUT; do`,
267 ` [ -z "${D}MSG_OUT" ] && continue`,
268 ` echo "remote: ${D}MSG_OUT" >&2`,
269 ` [ "${D}ENFORCE" = "active" ] && FAILED=1`,
270 ` done <<< "${D}RESULT"`,
271 "",
91b054eccanty labs272 ` rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP"`,
ec39211Claude273 `done`,
274 "",
275 `exit ${D}FAILED`,
276 ].join("\n") + "\n";
277}
278
279/**
280 * Write a pre-receive hook + companion rules JSON to a temp directory and
281 * return the git env vars that redirect git to use that hooks dir, plus a
282 * cleanup function.
283 *
284 * Callers must always invoke cleanup() — even on error — to remove the temp
285 * directory. Failure to set up the hook dir is non-fatal: we return null so
286 * the caller can proceed without pack-content inspection rather than wedging
287 * the push.
288 */
289export async function installPackInspectionHook(
91b054eccanty labs290 rulesets: ActiveRuleset[],
291 opts: { secretScan?: boolean } = {}
ec39211Claude292): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
91b054eccanty labs293 const secretScan = opts.secretScan !== false;
294
ec39211Claude295 // Collect pack-content rules from non-disabled rulesets.
296 type RuleEntry = { rulesetName: string; enforcement: string; params: Record<string, unknown> };
297 const commitMsgRules: RuleEntry[] = [];
298 const blockedPathRules: RuleEntry[] = [];
299 const maxSizeRules: RuleEntry[] = [];
300
301 for (const rs of rulesets) {
302 if (rs.enforcement === "disabled") continue;
303 for (const r of rs.rules) {
304 const p = parseParams(r.params);
305 const entry: RuleEntry = { rulesetName: rs.name, enforcement: rs.enforcement, params: p };
306 if (r.ruleType === "commit_message_pattern") commitMsgRules.push(entry);
307 else if (r.ruleType === "blocked_file_paths") blockedPathRules.push(entry);
308 else if (r.ruleType === "max_file_size") maxSizeRules.push(entry);
309 }
310 }
311
91b054eccanty labs312 // No pack-content rules AND secret scan disabled → skip hook entirely.
313 if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length && !secretScan) {
ec39211Claude314 return null;
315 }
316
317 try {
318 const dir = await mkdtemp(join(tmpdir(), "gluecron-hooks-"));
319 const rulesJsonPath = join(dir, "rules.json");
320 await writeFile(
321 rulesJsonPath,
322 JSON.stringify({ commitMsgRules, blockedPathRules, maxSizeRules }),
323 { mode: 0o644 }
324 );
325 const evalScriptPath = join(dir, "eval.js");
326 await writeFile(evalScriptPath, buildEvalScript(), { mode: 0o644 });
327 const hookPath = join(dir, "pre-receive");
328 await writeFile(hookPath, buildPreReceiveScript(evalScriptPath, rulesJsonPath), { mode: 0o755 });
329 return {
330 env: {
331 GIT_CONFIG_COUNT: "1",
332 GIT_CONFIG_KEY_0: "core.hooksPath",
333 GIT_CONFIG_VALUE_0: dir,
334 },
335 cleanup: async () => {
336 try {
337 await rm(dir, { recursive: true, force: true });
338 } catch {
339 // best-effort
340 }
341 },
342 };
343 } catch {
344 return null;
345 }
346}
347
5dc83edClaude348const ALLOW: PushPolicyResult = { allowed: true, violations: [] };
349
350/**
351 * Classify a ref name into "branch" / "tag" for the ruleset evaluator.
352 * Heads = branch, tags = tag, anything else (e.g. `refs/notes/*`) we treat
353 * as a branch since the evaluator's tag-only rules will gracefully no-op.
354 */
355function refType(refName: string): "branch" | "tag" {
356 return refName.startsWith("refs/tags/") ? "tag" : "branch";
357}
358
359/**
360 * Evaluate every ref in `refs` against the repo's protected-tags + rulesets
361 * and return the aggregated decision. Multiple violations across refs are
362 * concatenated so the user sees every problem in one push attempt rather
363 * than one-at-a-time.
364 */
365export async function evaluatePushPolicy(
366 args: PushPolicyArgs
367): Promise<PushPolicyResult> {
368 const { repositoryId, refs, pusherUserId } = args;
369 if (!repositoryId || !refs || refs.length === 0) return ALLOW;
370
371 const violations: string[] = [];
372
373 // Protected tags — runs once per ref, only fires for tag refs.
374 for (const ref of refs) {
375 if (!ref.refName.startsWith("refs/tags/")) continue;
376 const tagName = ref.refName.slice("refs/tags/".length);
377 let protectedRule: Awaited<ReturnType<typeof matchProtectedTag>> = null;
378 try {
379 protectedRule = await matchProtectedTag(repositoryId, tagName);
380 } catch {
381 protectedRule = null;
382 }
383 if (!protectedRule) continue;
384
385 // Anonymous pusher → never bypasses. Authenticated pusher must be the
386 // owner (or future tag-admin) for this repo.
387 let canBypass = false;
388 try {
389 canBypass = await canBypassProtectedTag(repositoryId, pusherUserId);
390 } catch {
391 canBypass = false;
392 }
393 if (canBypass) continue;
394
395 const action =
396 ref.newSha === ZERO_SHA
397 ? "delete"
398 : ref.oldSha === ZERO_SHA
399 ? "create"
400 : "update";
401 violations.push(
402 `tag "${tagName}" is protected (pattern: ${protectedRule.pattern}); ${action} requires bypass`
403 );
404 }
405
406 // Rulesets — single DB call, evaluator runs purely on names.
407 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
408 try {
409 rulesets = await listRulesetsForRepo(repositoryId);
410 } catch {
411 rulesets = [];
412 }
413
414 if (rulesets && rulesets.length > 0) {
415 for (const ref of refs) {
416 const ctx: PushContext = {
417 kind: "push",
418 refType: refType(ref.refName),
419 refName: ref.refName,
420 commits: [],
421 // forcePush is left false — true detection requires a reachability
422 // check on the new commit, which is in the pack we haven't unpacked.
423 forcePush: false,
424 };
425 let result;
426 try {
427 result = evaluatePush(rulesets, ctx);
428 } catch {
429 result = { allowed: true, violations: [] as Array<{ rulesetName: string; ruleType: string; message: string; enforcement: string }> };
430 }
431 if (!result.allowed && result.violations.length > 0) {
432 for (const v of result.violations) {
433 // Only "active" enforcement blocks; "evaluate" is dry-run.
434 if (v.enforcement !== "active") continue;
435 violations.push(
436 `ruleset "${v.rulesetName}" rule ${v.ruleType}: ${v.message} (ref ${ref.refName})`
437 );
438 }
439 }
440 }
441 }
442
443 return violations.length === 0
444 ? ALLOW
445 : { allowed: false, violations };
446}
447
ec39211Claude448/**
449 * Convenience wrapper: fetch rulesets for `repositoryId` from the DB and
450 * call `installPackInspectionHook`. Returns null on DB error or when there
451 * are no pack-content rules to enforce. Never throws.
452 */
453export async function installPackInspectionHookForRepo(
454 repositoryId: string
455): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
456 let rulesets: Awaited<ReturnType<typeof listRulesetsForRepo>> = [];
457 try {
458 rulesets = await listRulesetsForRepo(repositoryId);
459 } catch {
91b054eccanty labs460 rulesets = [];
ec39211Claude461 }
91b054eccanty labs462 // Always install — even with zero rulesets — so the unconditional secret
463 // scan still runs. Only a listRulesetsForRepo throw plus secret-scan being
464 // disabled entirely skips the hook (handled by installPackInspectionHook's
465 // own early-return when both inputs are empty).
466 return installPackInspectionHook(rulesets, {
467 secretScan: !config.secretScanOnPushDisabled,
468 });
ec39211Claude469}
470
5dc83edClaude471/** Build a human-readable error body for the 403 response. */
472export function formatPolicyError(violations: string[]): string {
473 if (!violations || violations.length === 0) {
474 return "Push rejected by Gluecron policy.";
475 }
476 const lines = violations.map((v) => ` - ${v}`);
477 return `Push rejected by Gluecron policy:\n${lines.join("\n")}\n`;
478}