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.tsBlame473 lines · 2 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 ` 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)`,
91b054eccanty labs238 ` CONTENTS_TMP=$(mktemp)`,
ec39211Claude239 ` 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 "",
91b054eccanty labs242 // 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.
ec39211Claude246 ` 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"`,
91b054eccanty labs252 ` 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`,
ec39211Claude256 ` done < "${D}PATHS_TMP" > "${D}SIZES_TMP"`,
257 "",
91b054eccanty labs258 ` RESULT=$(bun run "${D}EVAL_SCRIPT" -- "${D}RULES_JSON" "${D}COMMITS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP" 2>/dev/null || true)`,
ec39211Claude259 "",
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 "",
91b054eccanty labs267 ` rm -f "${D}COMMITS_TMP" "${D}PATHS_TMP" "${D}SIZES_TMP" "${D}CONTENTS_TMP"`,
ec39211Claude268 `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 */
284export async function installPackInspectionHook(
91b054eccanty labs285 rulesets: ActiveRuleset[],
286 opts: { secretScan?: boolean } = {}
ec39211Claude287): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> } | null> {
91b054eccanty labs288 const secretScan = opts.secretScan !== false;
289
ec39211Claude290 // 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
91b054eccanty labs307 // No pack-content rules AND secret scan disabled → skip hook entirely.
308 if (!commitMsgRules.length && !blockedPathRules.length && !maxSizeRules.length && !secretScan) {
ec39211Claude309 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
5dc83edClaude343const 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 */
350function 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 */
360export 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
ec39211Claude443/**
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 */
448export 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 {
91b054eccanty labs455 rulesets = [];
ec39211Claude456 }
91b054eccanty labs457 // 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 });
ec39211Claude464}
465
5dc83edClaude466/** Build a human-readable error body for the 403 response. */
467export 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}