GitProxy is susceptible to a hidden commits injection attack
Description
GitProxy is an application that stands between developers and a Git remote endpoint. In versions 1.19.1 and below, attackers can inject extra commits into the pack sent to GitHub, commits that aren’t pointed to by any branch. Although these “hidden” commits never show up in the repository’s visible history, GitHub still serves them at their direct commit URLs. This lets an attacker exfiltrate sensitive data without ever leaving a trace in the branch view. We rate this a High‑impact vulnerability because it completely compromises repository confidentiality. This is fixed in version 1.19.2.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
An attacker can inject commits not pointed by any branch into GitProxy, exfiltrating data via GitHub direct commit URLs without leaving a trace.
What the
Vulnerability Is
CVE-2025-54586 is a hidden commit injection vulnerability in GitProxy, a proxy that sits between developers and a Git remote endpoint like GitHub. In versions 1.19.1 and below, the proxy only validates the ref-update line (oldOid → newOid) and does not inspect the actual contents of the packfile being pushed. This allows a malicious client to append extra commits to the pack that are not referenced by any branch. Although these “hidden” commits never appear in the repository’s visible branch history, GitHub still stores them and serves them via their direct commit SHA URLs [1][2].
How
It’s Exploited
The vulnerability is exploited by crafting a push that includes both a legitimate commit (that updates a branch ref) and one or more hidden commits. The proxy runs git rev-list oldOid..newOid to compute the set of introduced commits, but because it never cross-references this set against the commits actually present in the pack, the hidden commits pass through unsanctioned. An attacker only needs a GitHub Personal Access Token and push rights to a test repository registered with GitProxy. The PoC in the advisory shows how to create and push a visible commit on branch foo while also including a hidden commit from a separate branch [2].
Impact
An attacker can exfiltrate sensitive data by injecting hidden commits that contain arbitrary content (e.g., credentials, secrets). Because these commits are never tied to any branch, they leave no trace in the repository’s branch view or standard auditing logs. However, anyone who knows the commit SHA can view and fetch the data. This completely compromises repository confidentiality, as sensitive information can be injected and later retrieved without detection [1][2].
Mitigation
The vulnerability is fixed in GitProxy version 1.19.2. The fix introduces a commit check that compares the set of commits in the pack (obtained via git verify-pack) against the set of introduced commits, rejecting the push if any pack commits are not referenced [4]. All users should upgrade immediately. No workaround is provided for unpatched versions [2].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@finos/git-proxynpm | < 1.19.2 | 1.19.2 |
Affected products
2Patches
2a620a2f33c39Merge commit from fork
16 files changed · +1460 −140
src/proxy/actions/Action.ts+1 −0 modified@@ -48,6 +48,7 @@ class Action { attestation?: string; lastStep?: Step; proxyGitPath?: string; + newIdxFiles?: string[]; /** * Create an action.
src/proxy/chain.ts+3 −1 modified@@ -9,9 +9,11 @@ const pushActionChain: ((req: any, action: Action) => Promise<Action>)[] = [ proc.push.checkCommitMessages, proc.push.checkAuthorEmails, proc.push.checkUserPushPermission, - proc.push.checkIfWaitingAuth, proc.push.pullRemote, proc.push.writePack, + proc.push.checkHiddenCommits, + proc.push.checkIfWaitingAuth, + proc.push.getMissingData, proc.push.preReceive, proc.push.getDiff, // run before clear remote
src/proxy/processors/constants.ts+6 −0 added@@ -0,0 +1,6 @@ +export const BRANCH_PREFIX = 'refs/heads/'; +export const EMPTY_COMMIT_HASH = '0000000000000000000000000000000000000000'; +export const FLUSH_PACKET = '0000'; +export const PACK_SIGNATURE = 'PACK'; +export const PACKET_SIZE = 4; +export const GIT_OBJECT_TYPE_COMMIT = 1;
src/proxy/processors/push-action/checkHiddenCommits.ts+82 −0 added@@ -0,0 +1,82 @@ +import path from 'path'; +import { Action, Step } from '../../actions'; +import { spawnSync } from 'child_process'; + +const exec = async (req: any, action: Action): Promise<Action> => { + const step = new Step('checkHiddenCommits'); + + try { + const repoPath = `${action.proxyGitPath}/${action.repoName}`; + + const oldOid = action.commitFrom; + const newOid = action.commitTo; + if (!oldOid || !newOid) { + throw new Error('Both action.commitFrom and action.commitTo must be defined'); + } + + // build introducedCommits set + const introducedCommits = new Set<string>(); + const revRange = + oldOid === '0000000000000000000000000000000000000000' ? newOid : `${oldOid}..${newOid}`; + const revList = spawnSync('git', ['rev-list', revRange], { cwd: repoPath, encoding: 'utf-8' }) + .stdout.trim() + .split('\n') + .filter(Boolean); + revList.forEach((sha) => introducedCommits.add(sha)); + step.log(`Total introduced commits: ${introducedCommits.size}`); + + // build packCommits set + const packPath = path.join('.git', 'objects', 'pack'); + const packCommits = new Set<string>(); + (action.newIdxFiles || []).forEach((idxFile) => { + const idxPath = path.join(packPath, idxFile); + const out = spawnSync('git', ['verify-pack', '-v', idxPath], { + cwd: repoPath, + encoding: 'utf-8', + }) + .stdout.trim() + .split('\n'); + out.forEach((line) => { + const [sha, type] = line.split(/\s+/); + if (type === 'commit') packCommits.add(sha); + }); + }); + step.log(`Total commits in the pack: ${packCommits.size}`); + + // subset check + const isSubset = [...packCommits].every((sha) => introducedCommits.has(sha)); + if (!isSubset) { + // build detailed lists + const [referenced, unreferenced] = [...packCommits].reduce<[string[], string[]]>( + ([ref, unref], sha) => + introducedCommits.has(sha) ? [[...ref, sha], unref] : [ref, [...unref, sha]], + [[], []], + ); + + step.log(`Referenced commits: ${referenced.length}`); + step.log(`Unreferenced commits: ${unreferenced.length}`); + + step.setError( + `Unreferenced commits in pack (${unreferenced.length}): ${unreferenced.join(', ')}.\n` + + `This usually happens when a branch was made from a commit that hasn't been approved and pushed to the remote.\n` + + `Please get approval on the commits, push them and try again.`, + ); + action.error = true; + step.setContent(`Referenced: ${referenced.length}, Unreferenced: ${unreferenced.length}`); + } else { + // all good, no logging of individual SHAs needed + step.log('All pack commits are referenced in the introduced range.'); + step.setContent(`All ${packCommits.size} pack commits are within introduced commits.`); + } + } catch (e: any) { + step.setError(e.message); + throw e; + } finally { + action.addStep(step); + } + + return action; +}; + +exec.displayName = 'checkHiddenCommits.exec'; +export { exec };
src/proxy/processors/push-action/checkUserPushPermission.ts+20 −2 modified@@ -5,7 +5,26 @@ import { trimTrailingDotGit } from '../../../db/helper'; // Execute if the repo is approved const exec = async (req: any, action: Action): Promise<Action> => { const step = new Step('checkUserPushPermission'); + const user = action.user; + if (!user) { + console.log('Action has no user set. This may be due to a fast-forward ref update. Deferring to getMissingData action.'); + return action; + } + + return await validateUser(user, action, step); +}; + +/** + * Helper that validates the user's push permission. + * This can be used by other actions that need it. For example, when the user is missing from the commit data, + * validation is deferred to getMissingData, but the logic is the same. + * @param {string} user The user to validate + * @param {Action} action The action object + * @param {Step} step The step object + * @return {Promise<Action>} The action object + */ +const validateUser = async (user: string, action: Action, step: Step): Promise<Action> => { const repoSplit = trimTrailingDotGit(action.repo.toLowerCase()).split('/'); // we expect there to be exactly one / separating org/repoName if (repoSplit.length != 2) { @@ -16,7 +35,6 @@ const exec = async (req: any, action: Action): Promise<Action> => { // pull the 2nd value of the split for repoName const repoName = repoSplit[1]; let isUserAllowed = false; - let user = action.user; // Find the user associated with this Git Account const list = await getUsers({ gitAccount: action.user }); @@ -53,4 +71,4 @@ const exec = async (req: any, action: Action): Promise<Action> => { exec.displayName = 'checkUserPushPermission.exec'; -export { exec }; +export { exec, validateUser };
src/proxy/processors/push-action/getDiff.ts+9 −3 modified@@ -1,6 +1,8 @@ import { Action, Step } from '../../actions'; import simpleGit from 'simple-git'; +import { EMPTY_COMMIT_HASH } from '../constants'; + const exec = async (req: any, action: Action): Promise<Action> => { const step = new Step('diff'); @@ -11,11 +13,15 @@ const exec = async (req: any, action: Action): Promise<Action> => { let commitFrom = `4b825dc642cb6eb9a060e54bf8d69288fbee4904`; if (!action.commitData || action.commitData.length === 0) { - throw new Error('No commit data found'); + step.error = true; + step.log('No commitData found'); + step.setError('Your push has been blocked because no commit data was found.'); + action.addStep(step); + return action; } - if (action.commitFrom === '0000000000000000000000000000000000000000') { - if (action.commitData[0].parent !== '0000000000000000000000000000000000000000') { + if (action.commitFrom === EMPTY_COMMIT_HASH) { + if (action.commitData[0].parent !== EMPTY_COMMIT_HASH) { commitFrom = `${action.commitData[action.commitData.length - 1].parent}`; } } else {
src/proxy/processors/push-action/getMissingData.ts+76 −0 added@@ -0,0 +1,76 @@ +import { Action, Step } from '../../actions'; +import { validateUser } from './checkUserPushPermission'; +import simpleGit from 'simple-git'; +import { EMPTY_COMMIT_HASH } from '../constants'; + +const isEmptyBranch = async (action: Action) => { + const git = simpleGit(`${action.proxyGitPath}/${action.repoName}`); + + if (action.commitFrom === EMPTY_COMMIT_HASH) { + try { + const type = await git.raw(['cat-file', '-t', action.commitTo || '']); + const known = type.trim() === 'commit'; + if (known) { + return true; + } + } catch (err) { + console.log(`Commit ${action.commitTo} not found: ${err}`); + } + } + + return false; +}; + +const exec = async (req: any, action: Action): Promise<Action> => { + const step = new Step('getMissingData'); + + if (action.commitData && action.commitData.length > 0) { + console.log('getMissingData', action); + return action; + } + + if (await isEmptyBranch(action)) { + step.setError('Push blocked: Empty branch. Please make a commit before pushing a new branch.'); + action.addStep(step); + step.error = true; + return action; + } + console.log(`commitData not found, fetching missing commits from git...`); + + try { + const path = `${action.proxyGitPath}/${action.repoName}`; + const git = simpleGit(path); + const log = await git.log({ from: action.commitFrom, to: action.commitTo }); + + action.commitData = [...log.all].reverse().map((entry, i, array) => { + const parent = i === 0 ? action.commitFrom : array[i - 1].hash; + const timestamp = Math.floor(new Date(entry.date).getTime() / 1000).toString(); + return { + message: entry.message || '', + committer: entry.author_name || '', + tree: entry.hash || '', + parent: parent || EMPTY_COMMIT_HASH, + author: entry.author_name || '', + authorEmail: entry.author_email || '', + commitTimestamp: timestamp, + } + }); + console.log(`Updated commitData:`, { commitData: action.commitData }); + + if (action.commitFrom === EMPTY_COMMIT_HASH) { + action.commitFrom = action.commitData[action.commitData.length - 1].parent; + } + + const user = action.commitData[action.commitData.length - 1].committer; + action.user = user; + } catch (e: any) { + step.setError(e.toString('utf-8')); + } finally { + action.addStep(step); + } + return await validateUser(action.user || '', action, step); +}; + +exec.displayName = 'getMissingData.exec'; + +export { exec };
src/proxy/processors/push-action/index.ts+4 −0 modified@@ -5,6 +5,7 @@ import { exec as audit } from './audit'; import { exec as pullRemote } from './pullRemote'; import { exec as writePack } from './writePack'; import { exec as getDiff } from './getDiff'; +import { exec as checkHiddenCommits } from './checkHiddenCommits'; import { exec as gitleaks } from './gitleaks'; import { exec as scanDiff } from './scanDiff'; import { exec as blockForAuth } from './blockForAuth'; @@ -13,6 +14,7 @@ import { exec as checkCommitMessages } from './checkCommitMessages'; import { exec as checkAuthorEmails } from './checkAuthorEmails'; import { exec as checkUserPushPermission } from './checkUserPushPermission'; import { exec as clearBareClone } from './clearBareClone'; +import { exec as getMissingData } from './getMissingData'; export { parsePush, @@ -22,6 +24,7 @@ export { pullRemote, writePack, getDiff, + checkHiddenCommits, gitleaks, scanDiff, blockForAuth, @@ -30,4 +33,5 @@ export { checkAuthorEmails, checkUserPushPermission, clearBareClone, + getMissingData, };
src/proxy/processors/push-action/parsePush.ts+290 −104 modified@@ -1,44 +1,102 @@ import { Action, Step } from '../../actions'; import zlib from 'zlib'; -import fs from 'fs'; -import path from 'path'; import lod from 'lodash'; -import { CommitContent } from '../types'; -const BitMask = require('bit-mask') as any; - -const dir = path.resolve(__dirname, './.tmp'); +import { + CommitContent, + CommitData, + CommitHeader, + PackMeta, + PersonLine, +} from '../types'; +import { + BRANCH_PREFIX, + EMPTY_COMMIT_HASH, + PACK_SIGNATURE, + PACKET_SIZE, + GIT_OBJECT_TYPE_COMMIT, +} from '../constants'; -if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); -} +const BitMask = require('bit-mask') as any; +/** + * Executes the parsing of a push request. + * @param {*} req - The request object containing the push data. + * @param {Action} action - The action object to be modified. + * @return {Promise<Action>} The modified action object. + */ async function exec(req: any, action: Action): Promise<Action> { const step = new Step('parsePackFile'); try { if (!req.body || req.body.length === 0) { - throw new Error('No body found in request'); + step.log('No data received in request body.'); + step.setError('Your push has been blocked. No data received in request body.'); + action.addStep(step); + return action; } - const messageParts = req.body.toString('utf8').split(' '); + const [packetLines, packDataOffset] = parsePacketLines(req.body); + const refUpdates = packetLines.filter((line) => line.includes(BRANCH_PREFIX)); + + if (refUpdates.length !== 1) { + step.log('Invalid number of branch updates.'); + step.log(`Expected 1, but got ${refUpdates.length}`); + step.setError( + 'Your push has been blocked. Please make sure you are pushing to a single branch.', + ); + action.addStep(step); + return action; + } + + const [commitParts, capParts] = refUpdates[0].split('\0'); + const parts = commitParts.split(' '); + console.log({ commitParts, capParts, parts }); + if (parts.length !== 3) { + step.log('Invalid number of parts in ref update.'); + step.log(`Expected 3, but got ${parts.length}`); + step.setError('Your push has been blocked. Invalid ref update format.'); + action.addStep(step); + return action; + } + + const [oldCommit, newCommit, ref] = parts; - action.branch = messageParts[2].trim().replace('\u0000', ''); - action.setCommit(messageParts[0].substr(4), messageParts[1]); + // Strip everything after NUL, which is cap-list from + // https://git-scm.com/docs/http-protocol#_smart_server_response + action.branch = ref.replace(/\0.*/, '').trim(); + action.setCommit(oldCommit, newCommit); + + // Check if the offset is valid and if there's data after it + if (packDataOffset >= req.body.length) { + step.log('No PACK data found after packet lines.'); + step.setError('Your push has been blocked. PACK data is missing.'); + action.addStep(step); + return action; + } + + const buf = req.body.slice(packDataOffset); + + // Verify that data actually starts with PACK signature + if (buf.length < PACKET_SIZE || buf.toString('utf8', 0, PACKET_SIZE) !== PACK_SIGNATURE) { + step.log(`Expected PACK signature at offset ${packDataOffset}, but found something else.`); + step.setError('Your push has been blocked. Invalid PACK data structure.'); + action.addStep(step); + return action; + } - const index = req.body.lastIndexOf('PACK'); - const buf = req.body.slice(index); const [meta, contentBuff] = getPackMeta(buf); const contents = getContents(contentBuff as any, meta.entries as number); action.commitData = getCommitData(contents as any); - - if (action.commitFrom === '0000000000000000000000000000000000000000') { - action.commitFrom = action.commitData[action.commitData.length - 1].parent; + if (action.commitData.length === 0) { + step.log('No commit data found when parsing push.'); + } else { + if (action.commitFrom === EMPTY_COMMIT_HASH) { + action.commitFrom = action.commitData[action.commitData.length - 1].parent; + } + const user = action.commitData[action.commitData.length - 1].committer; + action.user = user; } - const user = action.commitData[action.commitData.length - 1].committer; - console.log(`Push Request received from user ${user}`); - action.user = user; - step.content = { meta: meta, }; @@ -52,120 +110,195 @@ async function exec(req: any, action: Action): Promise<Action> { return action; } -const getCommitData = (contents: CommitContent[]) => { - console.log({ contents }); - return lod - .chain(contents) - .filter({ type: 1 }) - .map((x) => { - console.log({ x }); +/** + * Parses the name, email, and timestamp from an author or committer line. + * + * Timestamp including timezone offset is required. + * @param {string} line - The line to parse. + * @return {Object} An object containing the name, email, and timestamp. + */ +const parsePersonLine = (line: string): PersonLine => { + const personRegex = /^(.*?) <(.*?)> (\d+) ([+-]\d+)$/; + const match = line.match(personRegex); + if (!match) { + throw new Error( + `Failed to parse person line: ${line}. Make sure to include a name, email, timestamp and timezone offset.`, + ); + } + return { name: match[1], email: match[2], timestamp: match[3] }; +}; - const formattedContent = x.content.split('\n'); - console.log({ formattedContent }); +/** + * Parses the header lines of a commit. + * @param {string[]} headerLines - The header lines of a commit. + * @return {CommitHeader} An object containing the parsed commit header. + */ +const getParsedData = (headerLines: string[]): CommitHeader => { + const parsedData: CommitHeader = { + parents: [], + tree: '', + author: { name: '', email: '', timestamp: '' }, + committer: { name: '', email: '', timestamp: '' }, + }; - const parts = formattedContent.filter((part) => part.length > 0); - console.log({ parts }); + for (const line of headerLines) { + const firstSpaceIndex = line.indexOf(' '); + if (firstSpaceIndex === -1) { + // No spaces + continue; + } - if (!parts || parts.length < 5) { - throw new Error('Invalid commit data'); - } + const key = line.substring(0, firstSpaceIndex); + const value = line.substring(firstSpaceIndex + 1); + + switch (key) { + case 'tree': + if (parsedData.tree !== '') { + throw new Error('Multiple tree lines found in commit.'); + } + parsedData.tree = value.trim(); + break; + case 'parent': + parsedData.parents.push(value.trim()); + break; + case 'author': + if (!isBlankPersonLine(parsedData.author)) { + throw new Error('Multiple author lines found in commit.'); + } + parsedData.author = parsePersonLine(value); + break; + case 'committer': + if (!isBlankPersonLine(parsedData.committer)) { + throw new Error('Multiple committer lines found in commit.'); + } + parsedData.committer = parsePersonLine(value); + break; + } + } + validateParsedData(parsedData); + return parsedData; +}; - const tree = parts - .find((t) => t.split(' ')[0] === 'tree') - ?.replace('tree', '') - .trim(); - console.log({ tree }); +/** + * Validates the parsed commit header. + * @param {CommitHeader} parsedData - The parsed commit header. + * @return {void} + * @throws {Error} If the commit header is invalid. + */ +const validateParsedData = (parsedData: CommitHeader): void => { + const missing = []; + if (parsedData.tree === '') { + missing.push('tree'); + } + if (isBlankPersonLine(parsedData.author)) { + missing.push('author'); + } + if (isBlankPersonLine(parsedData.committer)) { + missing.push('committer'); + } + if (missing.length > 0) { + throw new Error(`Invalid commit data: Missing ${missing.join(', ')}`); + } +} - const parentValue = parts.find((t) => t.split(' ')[0] === 'parent'); - console.log({ parentValue }); +/** + * Checks if a person line is blank. + * @param {PersonLine} personLine - The person line to check. + * @return {boolean} True if the person line is blank, false otherwise. + */ +const isBlankPersonLine = (personLine: PersonLine): boolean => { + return personLine.name === '' && personLine.email === '' && personLine.timestamp === ''; +} - const parent = parentValue - ? parentValue.replace('parent', '').trim() - : '0000000000000000000000000000000000000000'; - console.log({ parent }); +/** + * Parses the commit data from the contents of a pack file. + * + * Filters out all objects except for commits. + * @param {CommitContent[]} contents - The contents of the pack file. + * @return {CommitData[]} An array of commit data objects. + * @see https://git-scm.com/docs/pack-format#_object_types + */ +const getCommitData = (contents: CommitContent[]): CommitData[] => { + console.log({ contents }); + return lod + .chain(contents) + .filter({ type: GIT_OBJECT_TYPE_COMMIT }) + .map((x: CommitContent) => { + console.log({ x }); - const author = parts - .find((t) => t.split(' ')[0] === 'author') - ?.replace('author', '') - .trim(); - console.log({ author }); + const allLines = x.content.split('\n'); + let headerEndIndex = -1; - const committer = parts - .find((t) => t.split(' ')[0] === 'committer') - ?.replace('committer', '') - .trim(); - console.log({ committer }); + // First empty line marks end of header + for (let i = 0; i < allLines.length; i++) { + if (allLines[i] === '') { + headerEndIndex = i; + break; + } + } - const indexOfMessages = formattedContent.indexOf(''); - console.log({ indexOfMessages }); + // Commit has no message body or may be malformed + if (headerEndIndex === -1) { + // Treat as commit with no message body, header format is checked later + headerEndIndex = allLines.length; + } - const message = formattedContent - .slice(indexOfMessages + 1) - .join(' ') + const headerLines = allLines.slice(0, headerEndIndex); + const message = allLines + .slice(headerEndIndex + 1) + .join('\n') .trim(); - console.log({ message }); - - const commitTimestamp = committer?.split(' ').reverse()[1]; - console.log({ commitTimestamp }); + console.log({ headerLines, message }); - const authorEmail = author?.split(' ').reverse()[2].slice(1, -1); - console.log({ authorEmail }); - - console.log({ - tree, - parent, - author: author?.split('<')[0].trim(), - committer: committer?.split('<')[0].trim(), - commitTimestamp, - message, - authorEmail, - }); - - if ( - !tree || - !parent || - !author || - !committer || - !commitTimestamp || - !message || - !authorEmail - ) { - throw new Error('Invalid commit data'); - } + const { tree, parents, author, committer } = getParsedData(headerLines); + // No parent headers -> zero hash + const parent = parents.length > 0 ? parents[0] : EMPTY_COMMIT_HASH; return { tree, parent, - author: author.split('<')[0].trim(), - committer: committer.split('<')[0].trim(), - commitTimestamp, + author: author.name, + committer: committer.name, + authorEmail: author.email, + commitTimestamp: committer.timestamp, message, - authorEmail: authorEmail, }; }) .value(); }; -const getPackMeta = (buffer: Buffer) => { - const sig = buffer.slice(0, 4).toString('utf-8'); - const version = buffer.readUIntBE(4, 4); - const entries = buffer.readUIntBE(8, 4); +/** + * Gets the metadata from a pack file. + * @param {Buffer} buffer - The buffer containing the pack file data. + * @return {[PackMeta, Buffer]} An array containing the metadata and the remaining buffer. + */ +const getPackMeta = (buffer: Buffer): [PackMeta, Buffer] => { + const sig = buffer.subarray(0, PACKET_SIZE).toString('utf-8'); + const version = buffer.readUIntBE(PACKET_SIZE, PACKET_SIZE); + const entries = buffer.readUIntBE(PACKET_SIZE * 2, PACKET_SIZE); const meta = { sig: sig, version: version, entries: entries, }; - return [meta, buffer.slice(12)]; + return [meta, buffer.subarray(PACKET_SIZE * 3)]; }; -const getContents = (buffer: Buffer | CommitContent[], entries: number) => { +/** + * Gets the contents of a pack file. + * @param {Buffer} buffer - The buffer containing the pack file data. + * @param {number} entries - The number of entries in the pack file. + * @return {Array} An array of commit content objects. + */ +const getContents = (buffer: Buffer | CommitContent[], entries: number): CommitContent[] => { const contents = []; for (let i = 0; i < entries; i++) { try { const [content, nextBuffer] = getContent(i, buffer as Buffer); + console.log({ content, nextBuffer }); buffer = nextBuffer as Buffer; contents.push(content); } catch (e) { @@ -175,7 +308,12 @@ const getContents = (buffer: Buffer | CommitContent[], entries: number) => { return contents; }; -const getInt = (bits: boolean[]) => { +/** + * Converts an array of bits to an integer. + * @param {boolean[]} bits - The array of bits. + * @return {number} The integer value. + */ +const getInt = (bits: boolean[]): number => { let strBits = ''; // eslint-disable-next-line guard-for-in @@ -186,7 +324,13 @@ const getInt = (bits: boolean[]) => { return parseInt(strBits, 2); }; -const getContent = (item: number, buffer: Buffer) => { +/** + * Gets the content of a pack file entry. + * @param {number} item - The index of the entry. + * @param {Buffer} buffer - The buffer containing the pack file data. + * @return {Array} An array containing the content object and the next buffer. + */ +const getContent = (item: number, buffer: Buffer): [CommitContent, Buffer] => { // FIRST byte contains the type and some of the size of the file // a MORE flag -8th byte tells us if there is a subsequent byte // which holds the file size @@ -252,7 +396,12 @@ const getContent = (item: number, buffer: Buffer) => { return [result, nextBuffer]; }; -const unpack = (buf: Buffer) => { +/** + * Unzips the content of a buffer. + * @param {Buffer} buf - The buffer containing the zipped content. + * @return {Array} An array containing the unzipped content and the size of the deflated content. + */ +const unpack = (buf: Buffer): [string, number] => { // Unzip the content const inflated = zlib.inflateSync(buf); @@ -263,6 +412,43 @@ const unpack = (buf: Buffer) => { return [inflated.toString('utf8'), deflated.length]; }; +/** + * Parses the packet lines from a buffer into an array of strings. + * Also returns the offset immediately following the parsed lines (including the flush packet). + * @param {Buffer} buffer - The buffer containing the packet data. + * @return {[string[], number]} An array containing the parsed lines and the offset after the last parsed line/flush packet. + */ +const parsePacketLines = (buffer: Buffer): [string[], number] => { + const lines: string[] = []; + let offset = 0; + + while (offset + PACKET_SIZE <= buffer.length) { + const lengthHex = buffer.toString('utf8', offset, offset + PACKET_SIZE); + const length = Number(`0x${lengthHex}`); + + // Prevent non-hex characters from causing issues + if (isNaN(length) || length < 0) { + throw new Error(`Invalid packet line length ${lengthHex} at offset ${offset}`); + } + + // length of 0 indicates flush packet (0000) + if (length === 0) { + offset += PACKET_SIZE; // Include length of the flush packet + break; + } + + // Make sure we don't read past the end of the buffer + if (offset + length > buffer.length) { + throw new Error(`Invalid packet line length ${lengthHex} at offset ${offset}`); + } + + const line = buffer.toString('utf8', offset + PACKET_SIZE, offset + length); + lines.push(line); + offset += length; // Move offset to the start of the next line's length prefix + } + return [lines, offset]; +}; + exec.displayName = 'parsePush.exec'; -export { exec, getPackMeta, unpack }; +export { exec, getCommitData, getPackMeta, parsePacketLines, unpack };
src/proxy/processors/push-action/writePack.ts+19 −5 modified@@ -1,19 +1,33 @@ +import path from 'path'; import { Action, Step } from '../../actions'; import { spawnSync } from 'child_process'; +import fs from 'fs'; const exec = async (req: any, action: Action) => { const step = new Step('writePack'); try { - const cmd = `git receive-pack ${action.repoName}`; - step.log(`executing ${cmd}`); + if (!action.proxyGitPath || !action.repoName) { + throw new Error('proxyGitPath and repoName must be defined'); + } + const repoPath = path.join(action.proxyGitPath, action.repoName); + const packDir = path.join(repoPath, '.git', 'objects', 'pack'); + + spawnSync('git', ['config', 'receive.unpackLimit', '0'], { + cwd: repoPath, + encoding: 'utf-8', + }); + const before = new Set(fs.readdirSync(packDir).filter((f) => f.endsWith('.idx'))); const content = spawnSync('git', ['receive-pack', action.repoName], { cwd: action.proxyGitPath, input: req.body, - encoding: 'utf-8', - }).stdout; + }); + const newIdxFiles = [ + ...new Set(fs.readdirSync(packDir).filter((f) => f.endsWith('.idx') && !before.has(f))), + ]; + action.newIdxFiles = newIdxFiles; + step.log(`new idx files: ${newIdxFiles}`); - step.log(content); step.setContent(content); } catch (e: any) { step.setError(e.toString('utf-8'));
src/proxy/processors/types.ts+30 −1 modified@@ -17,4 +17,33 @@ export type CommitContent = { deflatedSize: number; objectRef: any; content: string; -}; +} + +export type PersonLine = { + name: string; + email: string; + timestamp: string; +} + +export type CommitHeader = { + tree: string; + parents: string[]; + author: PersonLine; + committer: PersonLine; +} + +export type CommitData = { + tree: string; + parent: string; + author: string; + committer: string; + authorEmail: string; + commitTimestamp: string; + message: string; +} + +export type PackMeta = { + sig: string; + version: number; + entries: number; +}
test/chain.test.js+33 −5 modified@@ -24,6 +24,7 @@ const initMockPushProcessors = (sinon) => { checkAuthorEmails: sinon.stub(), checkUserPushPermission: sinon.stub(), checkIfWaitingAuth: sinon.stub(), + checkHiddenCommits: sinon.stub(), pullRemote: sinon.stub(), writePack: sinon.stub(), preReceive: sinon.stub(), @@ -32,6 +33,7 @@ const initMockPushProcessors = (sinon) => { clearBareClone: sinon.stub(), scanDiff: sinon.stub(), blockForAuth: sinon.stub(), + getMissingData: sinon.stub(), }; mockPushProcessors.parsePush.displayName = 'parsePush'; mockPushProcessors.audit.displayName = 'audit'; @@ -40,6 +42,7 @@ const initMockPushProcessors = (sinon) => { mockPushProcessors.checkAuthorEmails.displayName = 'checkAuthorEmails'; mockPushProcessors.checkUserPushPermission.displayName = 'checkUserPushPermission'; mockPushProcessors.checkIfWaitingAuth.displayName = 'checkIfWaitingAuth'; + mockPushProcessors.checkHiddenCommits.displayName = 'checkHiddenCommits'; mockPushProcessors.pullRemote.displayName = 'pullRemote'; mockPushProcessors.writePack.displayName = 'writePack'; mockPushProcessors.preReceive.displayName = 'preReceive'; @@ -48,13 +51,16 @@ const initMockPushProcessors = (sinon) => { mockPushProcessors.clearBareClone.displayName = 'clearBareClone'; mockPushProcessors.scanDiff.displayName = 'scanDiff'; mockPushProcessors.blockForAuth.displayName = 'blockForAuth'; + mockPushProcessors.getMissingData.displayName = 'getMissingData'; return mockPushProcessors; }; - const mockPreProcessors = { parseAction: sinon.stub(), }; +// eslint-disable-next-line no-unused-vars +let mockPushProcessors; + const clearCache = (sandbox) => { delete require.cache[require.resolve('../src/proxy/processors')]; delete require.cache[require.resolve('../src/proxy/chain')]; @@ -125,7 +131,9 @@ describe('proxy chain', function () { mockPushProcessors.checkCommitMessages.resolves(continuingAction); mockPushProcessors.checkAuthorEmails.resolves(continuingAction); mockPushProcessors.checkUserPushPermission.resolves(continuingAction); - + mockPushProcessors.checkHiddenCommits.resolves(continuingAction); + mockPushProcessors.pullRemote.resolves(continuingAction); + mockPushProcessors.writePack.resolves(continuingAction); // this stops the chain from further execution mockPushProcessors.checkIfWaitingAuth.resolves({ type: 'push', @@ -141,7 +149,10 @@ describe('proxy chain', function () { expect(mockPushProcessors.checkAuthorEmails.called).to.be.true; expect(mockPushProcessors.checkUserPushPermission.called).to.be.true; expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true; - expect(mockPushProcessors.pullRemote.called).to.be.false; + expect(mockPushProcessors.pullRemote.called).to.be.true; + expect(mockPushProcessors.checkHiddenCommits.called).to.be.true; + expect(mockPushProcessors.writePack.called).to.be.true; + expect(mockPushProcessors.getMissingData.called).to.be.false; expect(mockPushProcessors.audit.called).to.be.true; expect(result.type).to.equal('push'); @@ -158,6 +169,9 @@ describe('proxy chain', function () { mockPushProcessors.checkCommitMessages.resolves(continuingAction); mockPushProcessors.checkAuthorEmails.resolves(continuingAction); mockPushProcessors.checkUserPushPermission.resolves(continuingAction); + mockPushProcessors.checkHiddenCommits.resolves(continuingAction); + mockPushProcessors.pullRemote.resolves(continuingAction); + mockPushProcessors.writePack.resolves(continuingAction); // this stops the chain from further execution mockPushProcessors.checkIfWaitingAuth.resolves({ type: 'push', @@ -173,7 +187,10 @@ describe('proxy chain', function () { expect(mockPushProcessors.checkAuthorEmails.called).to.be.true; expect(mockPushProcessors.checkUserPushPermission.called).to.be.true; expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true; - expect(mockPushProcessors.pullRemote.called).to.be.false; + expect(mockPushProcessors.pullRemote.called).to.be.true; + expect(mockPushProcessors.checkHiddenCommits.called).to.be.true; + expect(mockPushProcessors.writePack.called).to.be.true; + expect(mockPushProcessors.getMissingData.called).to.be.false; expect(mockPushProcessors.audit.called).to.be.true; expect(result.type).to.equal('push'); @@ -193,12 +210,14 @@ describe('proxy chain', function () { mockPushProcessors.checkIfWaitingAuth.resolves(continuingAction); mockPushProcessors.pullRemote.resolves(continuingAction); mockPushProcessors.writePack.resolves(continuingAction); + mockPushProcessors.checkHiddenCommits.resolves(continuingAction); mockPushProcessors.preReceive.resolves(continuingAction); mockPushProcessors.getDiff.resolves(continuingAction); mockPushProcessors.gitleaks.resolves(continuingAction); mockPushProcessors.clearBareClone.resolves(continuingAction); mockPushProcessors.scanDiff.resolves(continuingAction); mockPushProcessors.blockForAuth.resolves(continuingAction); + mockPushProcessors.getMissingData.resolves(continuingAction); const result = await chain.executeChain(req); @@ -210,6 +229,7 @@ describe('proxy chain', function () { expect(mockPushProcessors.checkUserPushPermission.called).to.be.true; expect(mockPushProcessors.checkIfWaitingAuth.called).to.be.true; expect(mockPushProcessors.pullRemote.called).to.be.true; + expect(mockPushProcessors.checkHiddenCommits.called).to.be.true; expect(mockPushProcessors.writePack.called).to.be.true; expect(mockPushProcessors.preReceive.called).to.be.true; expect(mockPushProcessors.getDiff.called).to.be.true; @@ -218,6 +238,7 @@ describe('proxy chain', function () { expect(mockPushProcessors.scanDiff.called).to.be.true; expect(mockPushProcessors.blockForAuth.called).to.be.true; expect(mockPushProcessors.audit.called).to.be.true; + expect(mockPushProcessors.getMissingData.called).to.be.true; expect(result.type).to.equal('push'); expect(result.allowPush).to.be.false; @@ -285,6 +306,7 @@ describe('proxy chain', function () { mockPushProcessors.checkIfWaitingAuth.resolves(action); mockPushProcessors.pullRemote.resolves(action); mockPushProcessors.writePack.resolves(action); + mockPushProcessors.checkHiddenCommits.resolves(action); mockPushProcessors.preReceive.resolves({ ...action, @@ -298,7 +320,7 @@ describe('proxy chain', function () { mockPushProcessors.clearBareClone.resolves(action); mockPushProcessors.scanDiff.resolves(action); mockPushProcessors.blockForAuth.resolves(action); - + mockPushProcessors.getMissingData.resolves(action); const dbStub = sinon.stub(db, 'authorise').resolves(true); const result = await chain.executeChain(req); @@ -332,6 +354,7 @@ describe('proxy chain', function () { mockPushProcessors.checkIfWaitingAuth.resolves(action); mockPushProcessors.pullRemote.resolves(action); mockPushProcessors.writePack.resolves(action); + mockPushProcessors.checkHiddenCommits.resolves(action); mockPushProcessors.preReceive.resolves({ ...action, @@ -345,6 +368,7 @@ describe('proxy chain', function () { mockPushProcessors.clearBareClone.resolves(action); mockPushProcessors.scanDiff.resolves(action); mockPushProcessors.blockForAuth.resolves(action); + mockPushProcessors.getMissingData.resolves(action); const dbStub = sinon.stub(db, 'reject').resolves(true); @@ -379,6 +403,7 @@ describe('proxy chain', function () { mockPushProcessors.checkIfWaitingAuth.resolves(action); mockPushProcessors.pullRemote.resolves(action); mockPushProcessors.writePack.resolves(action); + mockPushProcessors.checkHiddenCommits.resolves(action); mockPushProcessors.preReceive.resolves({ ...action, @@ -392,6 +417,7 @@ describe('proxy chain', function () { mockPushProcessors.clearBareClone.resolves(action); mockPushProcessors.scanDiff.resolves(action); mockPushProcessors.blockForAuth.resolves(action); + mockPushProcessors.getMissingData.resolves(action); const error = new Error('Database error'); @@ -425,6 +451,7 @@ describe('proxy chain', function () { mockPushProcessors.checkIfWaitingAuth.resolves(action); mockPushProcessors.pullRemote.resolves(action); mockPushProcessors.writePack.resolves(action); + mockPushProcessors.checkHiddenCommits.resolves(action); mockPushProcessors.preReceive.resolves({ ...action, @@ -438,6 +465,7 @@ describe('proxy chain', function () { mockPushProcessors.clearBareClone.resolves(action); mockPushProcessors.scanDiff.resolves(action); mockPushProcessors.blockForAuth.resolves(action); + mockPushProcessors.getMissingData.resolves(action); const error = new Error('Database error');
test/checkHiddenCommit.test.js+118 −0 added@@ -0,0 +1,118 @@ +const fs = require('fs'); +const childProcess = require('child_process'); +const sinon = require('sinon'); +const { expect } = require('chai'); + +const { exec: checkHidden } = require('../src/proxy/processors/push-action/checkHiddenCommits'); +const { Action } = require('../src/proxy/actions'); + +describe('checkHiddenCommits.exec', () => { + let action; + let sandbox; + let spawnSyncStub; + let readdirSyncStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // stub spawnSync and fs.readdirSync + spawnSyncStub = sandbox.stub(childProcess, 'spawnSync'); + readdirSyncStub = sandbox.stub(fs, 'readdirSync'); + + // prepare a fresh Action + action = new Action('some-id', 'push', 'POST', Date.now(), 'repo.git'); + action.proxyGitPath = '/fake'; + action.commitFrom = '0000000000000000000000000000000000000000'; + action.commitTo = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + action.newIdxFiles = ['pack-test.idx']; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('reports all commits unreferenced and sets error=true', async () => { + const COMMIT_1 = 'deadbeef'; + const COMMIT_2 = 'cafebabe'; + + // 1) rev-list → no introduced commits + // 2) verify-pack → two commits in pack + spawnSyncStub + .onFirstCall() + .returns({ stdout: '' }) + .onSecondCall() + .returns({ + stdout: `${COMMIT_1} commit 100 1\n${COMMIT_2} commit 100 2\n`, + }); + + readdirSyncStub.returns(['pack-test.idx']); + + await checkHidden({ body: '' }, action); + + const step = action.steps.find((s) => s.stepName === 'checkHiddenCommits'); + expect(step.logs).to.include(`checkHiddenCommits - Referenced commits: 0`); + expect(step.logs).to.include(`checkHiddenCommits - Unreferenced commits: 2`); + expect(step.logs).to.include( + `checkHiddenCommits - Unreferenced commits in pack (2): ${COMMIT_1}, ${COMMIT_2}`, + ); + expect(action.error).to.be.true; + }); + + it('mixes referenced & unreferenced correctly', async () => { + const COMMIT_1 = 'deadbeef'; + const COMMIT_2 = 'cafebabe'; + + // 1) git rev-list → introduces one commit “deadbeef” + // 2) git verify-pack → the pack contains two commits + spawnSyncStub + .onFirstCall() + .returns({ stdout: `${COMMIT_1}\n` }) + .onSecondCall() + .returns({ + stdout: `${COMMIT_1} commit 100 1\n${COMMIT_2} commit 100 2\n`, + }); + + readdirSyncStub.returns(['pack-test.idx']); + + await checkHidden({ body: '' }, action); + + const step = action.steps.find((s) => s.stepName === 'checkHiddenCommits'); + expect(step.logs).to.include('checkHiddenCommits - Referenced commits: 1'); + expect(step.logs).to.include('checkHiddenCommits - Unreferenced commits: 1'); + expect(step.logs).to.include( + `checkHiddenCommits - Unreferenced commits in pack (1): ${COMMIT_2}`, + ); + expect(action.error).to.be.true; + }); + + it('reports all commits referenced and sets error=false', async () => { + // 1) rev-list → introduces both commits + // 2) verify-pack → the pack contains the same two commits + spawnSyncStub.onFirstCall().returns({ stdout: 'deadbeef\ncafebabe\n' }).onSecondCall().returns({ + stdout: 'deadbeef commit 100 1\ncafebabe commit 100 2\n', + }); + + readdirSyncStub.returns(['pack-test.idx']); + + await checkHidden({ body: '' }, action); + const step = action.steps.find((s) => s.stepName === 'checkHiddenCommits'); + + expect(step.logs).to.include('checkHiddenCommits - Total introduced commits: 2'); + expect(step.logs).to.include('checkHiddenCommits - Total commits in the pack: 2'); + expect(step.logs).to.include( + 'checkHiddenCommits - All pack commits are referenced in the introduced range.', + ); + expect(action.error).to.be.false; + }); + + it('throws if commitFrom or commitTo is missing', async () => { + delete action.commitFrom; + + try { + await checkHidden({ body: '' }, action); + throw new Error('Expected checkHidden to throw'); + } catch (err) { + expect(err.message).to.match(/Both action.commitFrom and action.commitTo must be defined/); + } + }); +});
test/processors/getDiff.test.js+2 −2 modified@@ -95,7 +95,7 @@ describe('getDiff', () => { const result = await exec({}, action); expect(result.steps[0].error).to.be.true; - expect(result.steps[0].errorMessage).to.contain('No commit data found'); + expect(result.steps[0].errorMessage).to.contain('Your push has been blocked because no commit data was found'); }); it('should throw an error if no commit data is provided', async () => { @@ -114,7 +114,7 @@ describe('getDiff', () => { const result = await exec({}, action); expect(result.steps[0].error).to.be.true; - expect(result.steps[0].errorMessage).to.contain('No commit data found'); + expect(result.steps[0].errorMessage).to.contain('Your push has been blocked because no commit data was found'); }); it('should handle empty commit hash in commitFrom', async () => {
test/processors/writePack.test.js+25 −17 modified@@ -8,24 +8,26 @@ const expect = chai.expect; describe('writePack', () => { let exec; + let readdirSyncStub; let spawnSyncStub; let stepLogSpy; let stepSetContentSpy; let stepSetErrorSpy; beforeEach(() => { - spawnSyncStub = sinon.stub().returns({ - stdout: 'git receive-pack output', - stderr: '', - status: 0 - }); + spawnSyncStub = sinon.stub(); + readdirSyncStub = sinon.stub(); + + readdirSyncStub.onFirstCall().returns(['old1.idx']); + readdirSyncStub.onSecondCall().returns(['old1.idx', 'new1.idx']); stepLogSpy = sinon.spy(Step.prototype, 'log'); stepSetContentSpy = sinon.spy(Step.prototype, 'setContent'); stepSetErrorSpy = sinon.spy(Step.prototype, 'setError'); const writePack = proxyquire('../../src/proxy/processors/push-action/writePack', { - 'child_process': { spawnSync: spawnSyncStub } + 'child_process': { spawnSync: spawnSyncStub }, + 'fs': { readdirSync: readdirSyncStub }, }); exec = writePack.exec; @@ -50,28 +52,34 @@ describe('writePack', () => { 1234567890, 'test/repo' ); - action.proxyGitPath = '/path/to/repo'; + action.proxyGitPath = '/path/to'; + action.repoName = 'repo'; }); it('should execute git receive-pack with correct parameters', async () => { + const dummySpawnOutput = { stdout: 'git receive-pack output', stderr: '', status: 0 }; + spawnSyncStub.returns(dummySpawnOutput); + const result = await exec(req, action); - expect(spawnSyncStub.calledOnce).to.be.true; + expect(spawnSyncStub.callCount).to.equal(2); expect(spawnSyncStub.firstCall.args[0]).to.equal('git'); - expect(spawnSyncStub.firstCall.args[1]).to.deep.equal(['receive-pack', 'repo']); - expect(spawnSyncStub.firstCall.args[2]).to.deep.equal({ - cwd: '/path/to/repo', - input: 'pack data', - encoding: 'utf-8' + expect(spawnSyncStub.firstCall.args[1]).to.deep.equal(['config', 'receive.unpackLimit', '0']); + expect(spawnSyncStub.firstCall.args[2]).to.include({ cwd: '/path/to/repo' }); + + expect(spawnSyncStub.secondCall.args[0]).to.equal('git'); + expect(spawnSyncStub.secondCall.args[1]).to.deep.equal(['receive-pack', 'repo']); + expect(spawnSyncStub.secondCall.args[2]).to.include({ + cwd: '/path/to', + input: 'pack data' }); - expect(stepLogSpy.calledWith('executing git receive-pack repo')).to.be.true; - expect(stepLogSpy.calledWith('git receive-pack output')).to.be.true; - - expect(stepSetContentSpy.calledWith('git receive-pack output')).to.be.true; + expect(stepLogSpy.calledWith('new idx files: new1.idx')).to.be.true; + expect(stepSetContentSpy.calledWith(dummySpawnOutput)).to.be.true; expect(result.steps).to.have.lengthOf(1); expect(result.steps[0].error).to.be.false; + expect(result.newIdxFiles).to.deep.equal(['new1.idx']); }); it('should handle errors from git receive-pack', async () => {
test/testParsePush.test.js+742 −0 added@@ -0,0 +1,742 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const zlib = require('zlib'); + +const { + exec, + getCommitData, + getPackMeta, + parsePacketLines, + unpack +} = require('../src/proxy/processors/push-action/parsePush'); + +import { FLUSH_PACKET, PACK_SIGNATURE } from '../src/proxy/processors/constants'; + +/** + * Creates a simplified sample PACK buffer for testing. + * @param {number} numEntries - Number of entries in the PACK file. + * @param {string} commitContent - Content of the commit object. + * @param {number} type - Type of the object (1 for commit). + * @return {Buffer} - The generated PACK buffer. + */ +function createSamplePackBuffer( + numEntries = 1, + commitContent = 'tree 123\nparent 456\nauthor A <a@a> 123 +0000\ncommitter C <c@c> 456 +0000\n\nmessage', + type = 1, +) { + const header = Buffer.alloc(12); + header.write(PACK_SIGNATURE, 0, 4, 'utf-8'); // Signature + header.writeUInt32BE(2, 4); // Version + header.writeUInt32BE(numEntries, 8); // Number of entries + + const originalContent = Buffer.from(commitContent, 'utf8'); + const compressedContent = zlib.deflateSync(originalContent); // actual zlib for setup + + // Basic type/size encoding (assumes small sizes for simplicity) + // Real PACK files use variable-length encoding for size + let typeAndSize = (type << 4) | (compressedContent.length & 0x0f); // Lower 4 bits of size + if (compressedContent.length >= 16) { + typeAndSize |= 0x80; + } + const objectHeader = Buffer.from([typeAndSize]); // Placeholder, actual size encoding is complex + + // Combine parts and append checksum + const packContent = Buffer.concat([objectHeader, compressedContent]); + const checksum = Buffer.alloc(20); + + return Buffer.concat([header, packContent, checksum]); +} + +/** + * Creates a packet line buffer from an array of lines. + * Each line is prefixed with its length in hex format, and the last line is a flush packet. + * @param {string[]} lines - Array of lines to be included in the buffer. + * @return {Buffer} - The generated buffer containing the packet lines. + */ +function createPacketLineBuffer(lines) { + let buffer = Buffer.alloc(0); + lines.forEach(line => { + const lengthInHex = (line.length + 4).toString(16).padStart(4, '0'); + buffer = Buffer.concat([buffer, Buffer.from(lengthInHex, 'ascii'), Buffer.from(line, 'ascii')]); + }); + buffer = Buffer.concat([buffer, Buffer.from(FLUSH_PACKET, 'ascii')]); + + return buffer; +} + +describe('parsePackFile', () => { + let action; + let req; + let sandbox; + let zlibInflateStub; // No deflate stub used due to complexity of PACK encoding + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Mock Action and Step and spy on methods + action = { + branch: null, + commitFrom: null, + commitTo: null, + commitData: [], + user: null, + steps: [], + addStep: sandbox.spy(function (step) { + this.steps.push(step); // eslint-disable-line no-invalid-this + }), + setCommit: sandbox.spy(function (from, to) { + this.commitFrom = from; // eslint-disable-line no-invalid-this + this.commitTo = to; // eslint-disable-line no-invalid-this + }), + }; + + req = { + body: null, + }; + + zlibInflateStub = sandbox.stub(zlib, 'inflateSync'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('exec', () => { + it('should add error step if req.body is missing', async () => { + req.body = undefined; + const result = await exec(req, action); + + expect(result).to.equal(action); + const step = action.steps[0]; + expect(step.stepName).to.equal('parsePackFile'); + expect(step.error).to.be.true; + expect(step.errorMessage).to.include('No data received'); + }); + + it('should add error step if req.body is empty', async () => { + req.body = Buffer.alloc(0); + const result = await exec(req, action); + + expect(result).to.equal(action); + const step = action.steps[0]; + expect(step.stepName).to.equal('parsePackFile'); + expect(step.error).to.be.true; + expect(step.errorMessage).to.include('No data received'); + }); + + it('should add error step if no ref updates found', async () => { + const packetLines = ['some other line\n', 'another line\n']; + req.body = createPacketLineBuffer(packetLines); // We don't include PACK data (only testing ref updates) + const result = await exec(req, action); + + expect(result).to.equal(action); + const step = action.steps[0]; + expect(step.stepName).to.equal('parsePackFile'); + expect(step.error).to.be.true; + expect(step.errorMessage).to.include('pushing to a single branch'); + expect(step.logs[0]).to.include('Invalid number of branch updates'); + }); + + it('should add error step if multiple ref updates found', async () => { + const packetLines = [ + 'oldhash1 newhash1 refs/heads/main\0caps\n', + 'oldhash2 newhash2 refs/heads/develop\0caps\n', + ]; + req.body = createPacketLineBuffer(packetLines); + const result = await exec(req, action); + + expect(result).to.equal(action); + const step = action.steps[0]; + expect(step.stepName).to.equal('parsePackFile'); + expect(step.error).to.be.true; + expect(step.errorMessage).to.include('pushing to a single branch'); + expect(step.logs[0]).to.include('Invalid number of branch updates'); + expect(step.logs[1]).to.include('Expected 1, but got 2'); + }); + + it('should add error step if PACK data is missing', async () => { + const oldCommit = 'a'.repeat(40); + const newCommit = 'b'.repeat(40); + const ref = 'refs/heads/feature/test'; + const packetLines = [`${oldCommit} ${newCommit} ${ref}\0capa\n`]; + + req.body = createPacketLineBuffer(packetLines); + + const result = await exec(req, action); + + expect(result).to.equal(action); + const step = action.steps[0]; + expect(step.stepName).to.equal('parsePackFile'); + expect(step.error).to.be.true; + expect(step.errorMessage).to.include('PACK data is missing'); + + expect(action.branch).to.equal(ref); + expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; + }); + + it('should successfully parse a valid push request', async () => { + const oldCommit = 'a'.repeat(40); + const newCommit = 'b'.repeat(40); + const ref = 'refs/heads/main'; + const packetLine = `${oldCommit} ${newCommit} ${ref}\0capabilities\n`; + + const commitContent = "tree 1234567890abcdef1234567890abcdef12345678\n" + + "parent abcdef1234567890abcdef1234567890abcdef12\n" + + "author Test Author <author@example.com> 1234567890 +0000\n" + + "committer Test Committer <committer@example.com> 1234567890 +0000\n\n" + + "feat: Add new feature\n\n" + + "This is the commit body."; + const commitContentBuffer = Buffer.from(commitContent, 'utf8'); + + zlibInflateStub.returns(commitContentBuffer); + + const numEntries = 1; + const packBuffer = createSamplePackBuffer(numEntries, commitContent, 1); // Use real zlib + req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); + + const result = await exec(req, action); + expect(result).to.equal(action); + + // Check step and action properties + const step = action.steps.find(s => s.stepName === 'parsePackFile'); + expect(step).to.exist; + expect(step.error).to.be.false; + expect(step.errorMessage).to.be.null; + + expect(action.branch).to.equal(ref); + expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; + expect(action.commitFrom).to.equal(oldCommit); + expect(action.commitTo).to.equal(newCommit); + expect(action.user).to.equal('Test Committer'); + + // Check parsed commit data + const commitMessages = action.commitData.map(commit => commit.message); + expect(action.commitData).to.be.an('array').with.lengthOf(1); + expect(commitMessages[0]).to.equal('feat: Add new feature\n\nThis is the commit body.'); + + const parsedCommit = action.commitData[0]; + expect(parsedCommit.tree).to.equal('1234567890abcdef1234567890abcdef12345678'); + expect(parsedCommit.parent).to.equal('abcdef1234567890abcdef1234567890abcdef12'); + expect(parsedCommit.author).to.equal('Test Author'); + expect(parsedCommit.committer).to.equal('Test Committer'); + expect(parsedCommit.commitTimestamp).to.equal('1234567890'); + expect(parsedCommit.message).to.equal('feat: Add new feature\n\nThis is the commit body.'); + expect(parsedCommit.authorEmail).to.equal('author@example.com'); + + expect(step.content.meta).to.deep.equal({ + sig: PACK_SIGNATURE, + version: 2, + entries: numEntries, + }); + }); + + it('should handle initial commit (zero hash oldCommit)', async () => { + const oldCommit = '0'.repeat(40); // Zero hash + const newCommit = 'b'.repeat(40); + const ref = 'refs/heads/main'; + const packetLine = `${oldCommit} ${newCommit} ${ref}\0capabilities\n`; + + // Commit content without a parent line + const commitContent = "tree 1234567890abcdef1234567890abcdef12345678\n" + + "author Test Author <test@example.com> 1234567890 +0000\n" + + "committer Test Committer <committer@example.com> 1234567890 +0100\n\n" + + "feat: Initial commit"; + const parentFromCommit = '0'.repeat(40); // Expected parent hash + + const commitContentBuffer = Buffer.from(commitContent, 'utf8'); + zlibInflateStub.returns(commitContentBuffer); + + const packBuffer = createSamplePackBuffer(1, commitContent, 1); // Use real zlib + req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); + + const result = await exec(req, action); + + expect(result).to.equal(action); + const step = action.steps.find(s => s.stepName === 'parsePackFile'); + expect(step).to.exist; + expect(step.error).to.be.false; + + expect(action.branch).to.equal(ref); + expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; + + // commitFrom should still be the zero hash + expect(action.commitFrom).to.equal(oldCommit); + expect(action.commitTo).to.equal(newCommit); + expect(action.user).to.equal('Test Committer'); + + // Check parsed commit data reflects no parent (zero hash) + expect(action.commitData[0].parent).to.equal(parentFromCommit); + }); + + it('should handle commit with multiple parents (merge commit)', async () => { + const oldCommit = 'a'.repeat(40); + const newCommit = 'c'.repeat(40); // Merge commit hash + const ref = 'refs/heads/main'; + const packetLine = `${oldCommit} ${newCommit} ${ref}\0capabilities\n`; + + const parent1 = 'b1'.repeat(20); + const parent2 = 'b2'.repeat(20); + const commitContent = "tree 1234567890abcdef1234567890abcdef12345678\n" + + `parent ${parent1}\n` + + `parent ${parent2}\n` + + "author Test Author <test@example.com> 1234567890 +0000\n" + + "committer Test Committer <committer@example.com> 1234567890 +0100\n\n" + + "Merge branch 'feature'"; + + const commitContentBuffer = Buffer.from(commitContent, 'utf8'); + zlibInflateStub.returns(commitContentBuffer); + + const packBuffer = createSamplePackBuffer(1, commitContent, 1); // Use real zlib + req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); + + const result = await exec(req, action); + expect(result).to.equal(action); + + // Check step and action properties + const step = action.steps.find(s => s.stepName === 'parsePackFile'); + expect(step).to.exist; + expect(step.error).to.be.false; + + expect(action.branch).to.equal(ref); + expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; + expect(action.commitFrom).to.equal(oldCommit); + expect(action.commitTo).to.equal(newCommit); + + // Parent should be the FIRST parent in the commit content + expect(action.commitData[0].parent).to.equal(parent1); + }); + + it('should add error step if getCommitData throws error', async () => { + const oldCommit = 'a'.repeat(40); + const newCommit = 'b'.repeat(40); + const ref = 'refs/heads/main'; + const packetLine = `${oldCommit} ${newCommit} ${ref}\0capabilities\n`; + + // Malformed commit content - missing tree line + const commitContent = "parent abcdef1234567890abcdef1234567890abcdef12\n" + + "author Test Author <author@example.com> 1678886400 +0000\n" + + "committer Test Committer <committer@example.com> 1678886460 +0100\n\n" + + "feat: Missing tree"; + const commitContentBuffer = Buffer.from(commitContent, 'utf8'); + zlibInflateStub.returns(commitContentBuffer); + + const packBuffer = createSamplePackBuffer(1, commitContent, 1); + req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); + + const result = await exec(req, action); + expect(result).to.equal(action); + + const step = action.steps.find(s => s.stepName === 'parsePackFile'); + expect(step).to.exist; + expect(step.error).to.be.true; + expect(step.errorMessage).to.include('Invalid commit data: Missing tree'); + }); + + it('should add error step if data after flush packet does not start with "PACK"', async () => { + const oldCommit = 'a'.repeat(40); + const newCommit = 'b'.repeat(40); + const ref = 'refs/heads/main'; + const packetLines = [`${oldCommit} ${newCommit} ${ref}\0capa\n`]; + + const packetLineBuffer = createPacketLineBuffer(packetLines); + const garbageData = Buffer.from('NOT PACK DATA'); + req.body = Buffer.concat([packetLineBuffer, garbageData]); + + const result = await exec(req, action); + expect(result).to.equal(action); + + const step = action.steps[0]; + expect(step.stepName).to.equal('parsePackFile'); + expect(step.error).to.be.true; + expect(step.errorMessage).to.include('Invalid PACK data structure'); + expect(step.errorMessage).to.not.include('PACK data is missing'); + + expect(action.branch).to.equal(ref); + expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; + }); + + it('should correctly identify PACK data even if "PACK" appears in packet lines', async () => { + const oldCommit = 'a'.repeat(40); + const newCommit = 'b'.repeat(40); + const ref = 'refs/heads/develop'; + const packetLines = [ + `${oldCommit} ${newCommit} ${ref}\0capa\n`, + 'some other data containing PACK keyword', // Include "PACK" within a packet line's content + ]; + + const commitContent = "tree 1234567890abcdef1234567890abcdef12345678\n" + + `parent ${oldCommit}\n` + + "author Test Author <author@example.com> 1234567890 +0000\n" + + "committer Test Committer <committer@example.com> 1234567890 +0000\n\n" + + "Test commit message with PACK inside"; + const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); + + zlibInflateStub.returns(Buffer.from(commitContent, 'utf8')); + + const packetLineBuffer = createPacketLineBuffer(packetLines); + req.body = Buffer.concat([packetLineBuffer, samplePackBuffer]); + + const result = await exec(req, action); + expect(result).to.equal(action); + expect(action.steps.length).to.equal(1); + + // Check that the step was added correctly, and no error present + const step = action.steps[0]; + expect(step.stepName).to.equal('parsePackFile'); + expect(step.error).to.be.false; + expect(step.errorMessage).to.be.null; + + // Verify action properties were parsed correctly + expect(action.branch).to.equal(ref); + expect(action.setCommit.calledOnceWith(oldCommit, newCommit)).to.be.true; + expect(action.commitFrom).to.equal(oldCommit); + expect(action.commitTo).to.equal(newCommit); + expect(action.commitData).to.be.an('array').with.lengthOf(1); + expect(action.commitData[0].message).to.equal('Test commit message with PACK inside'); + expect(action.commitData[0].committer).to.equal('Test Committer'); + expect(action.user).to.equal('Test Committer'); + }); + + it('should handle PACK data starting immediately after flush packet', async () => { + const oldCommit = 'a'.repeat(40); + const newCommit = 'b'.repeat(40); + const ref = 'refs/heads/master'; + const packetLines = [`${oldCommit} ${newCommit} ${ref}\0`]; + + const commitContent = "tree 1234567890abcdef1234567890abcdef12345678\n" + + `parent ${oldCommit}\n` + + "author Test Author <author@example.com> 1234567890 +0000\n" + + "committer Test Committer <committer@example.com> 1234567890 +0000\n\n" + + "Commit A"; + const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); + zlibInflateStub.returns(Buffer.from(commitContent, 'utf8')); + + const packetLineBuffer = createPacketLineBuffer(packetLines); + req.body = Buffer.concat([packetLineBuffer, samplePackBuffer]); + + const result = await exec(req, action); + + expect(result).to.equal(action); + const step = action.steps[0]; + expect(step.error).to.be.false; + expect(action.commitData[0].message).to.equal('Commit A'); + }); + + it('should add error step if PACK header parsing fails (getPackMeta with wrong signature)', async () => { + const oldCommit = 'a'.repeat(40); + const newCommit = 'b'.repeat(40); + const ref = 'refs/heads/fix'; + const packetLines = [`${oldCommit} ${newCommit} ${ref}\0capa\n`]; + + const packetLineBuffer = createPacketLineBuffer(packetLines); + const badPackBuffer = createSamplePackBuffer(); + badPackBuffer.write('AAAA', 0, 4, 'utf-8'); // Invalid signature, should be 'PACK' + + req.body = Buffer.concat([packetLineBuffer, badPackBuffer]); + + const result = await exec(req, action); + expect(result).to.equal(action); + + const step = action.steps[0]; + expect(step.stepName).to.equal('parsePackFile'); + expect(step.error).to.be.true; + expect(step.errorMessage).to.include('Invalid PACK data structure'); + }); + }); + + describe('getPackMeta', () => { + it('should correctly parse PACK header', () => { + const buffer = createSamplePackBuffer(5); // 5 entries + const [meta, contentBuff] = getPackMeta(buffer); + + expect(meta).to.deep.equal({ + sig: PACK_SIGNATURE, + version: 2, + entries: 5, + }); + expect(contentBuff).to.be.instanceOf(Buffer); + expect(contentBuff.length).to.equal(buffer.length - 12); // Remaining buffer after header + }); + + it('should handle buffer exactly 12 bytes long', () => { + const buffer = createSamplePackBuffer(1).slice(0, 12); // Only header + const [meta, contentBuff] = getPackMeta(buffer); + + expect(meta).to.deep.equal({ + sig: PACK_SIGNATURE, + version: 2, + entries: 1, + }); + expect(contentBuff.length).to.equal(0); // No content left + }); + }); + + describe('unpack', () => { + let deflateStub; + + beforeEach(() => { + // Need to stub deflate for unpack tests + deflateStub = sandbox.stub(zlib, 'deflateSync'); + }); + + it('should call zlib.inflateSync and zlib.deflateSync', () => { + const inputBuf = Buffer.from('compressed data'); + const inflatedBuffer = Buffer.from('uncompressed data', 'utf8'); + const deflatedResult = Buffer.from('re-deflated'); // Mock deflated buffer + + zlibInflateStub.withArgs(inputBuf).returns(inflatedBuffer); + deflateStub.withArgs(inflatedBuffer).returns(deflatedResult); + + const [resultString, resultLength] = unpack(inputBuf); + + expect(zlibInflateStub.calledOnceWith(inputBuf)).to.be.true; + expect(deflateStub.calledOnceWith(inflatedBuffer)).to.be.true; // Check local stub + expect(resultString).to.equal(inflatedBuffer.toString('utf8')); + expect(resultLength).to.equal(deflatedResult.length); // unpack returns length of the deflated buffer + }); + + it('should return inflated string and deflated length', () => { + const inputBuf = Buffer.from('dummy compressed'); + const inflatedBuffer = Buffer.from('real uncompressed text', 'utf8'); + const deflatedResult = Buffer.from('tiny'); // Different length + + zlibInflateStub.withArgs(inputBuf).returns(inflatedBuffer); + deflateStub.withArgs(inflatedBuffer).returns(deflatedResult); + + const [content, size] = unpack(inputBuf); + + expect(content).to.equal(inflatedBuffer.toString('utf8')); + expect(size).to.equal(deflatedResult.length); + }); + }); + + describe('getCommitData', () => { + it('should return empty array if no type 1 contents', () => { + const contents = [{ type: 2, content: 'blob' }, { type: 3, content: 'tree' }]; + expect(getCommitData(contents)).to.deep.equal([]); + }); + + it('should parse a single valid commit object', () => { + const commitContent = `tree 123\nparent 456\nauthor Au Thor <a@e.com> 111 +0000\ncommitter Com Itter <c@e.com> 222 +0100\n\nCommit message here`; + const contents = [{ type: 1, content: commitContent }]; + const result = getCommitData(contents); + + expect(result).to.be.an('array').with.lengthOf(1); + expect(result[0]).to.deep.equal({ + tree: '123', + parent: '456', + author: 'Au Thor', + committer: 'Com Itter', + commitTimestamp: '222', + message: 'Commit message here', + authorEmail: 'a@e.com', + }); + }); + + it('should parse multiple valid commit objects', () => { + const commit1 = `tree 111\nparent 000\nauthor A1 <a1@e.com> 1678880001 +0000\ncommitter C1 <c1@e.com> 1678880002 +0000\n\nMsg1`; + const commit2 = `tree 222\nparent 111\nauthor A2 <a2@e.com> 1678880003 +0100\ncommitter C2 <c2@e.com> 1678880004 +0100\n\nMsg2`; + const contents = [ + { type: 1, content: commit1 }, + { type: 3, content: 'tree data' }, // non-commit types must be ignored + { type: 1, content: commit2 }, + ]; + + const result = getCommitData(contents); + expect(result).to.be.an('array').with.lengthOf(2); + + // Check first commit data + expect(result[0].message).to.equal('Msg1'); + expect(result[0].parent).to.equal('000'); + expect(result[0].author).to.equal('A1'); + expect(result[0].committer).to.equal('C1'); + expect(result[0].authorEmail).to.equal('a1@e.com'); + expect(result[0].commitTimestamp).to.equal('1678880002'); + + // Check second commit data + expect(result[1].message).to.equal('Msg2'); + expect(result[1].parent).to.equal('111'); + expect(result[1].author).to.equal('A2'); + expect(result[1].committer).to.equal('C2'); + expect(result[1].authorEmail).to.equal('a2@e.com'); + expect(result[1].commitTimestamp).to.equal('1678880004'); + }); + + it('should default parent to zero hash if not present', () => { + const commitContent = `tree 123\nauthor Au Thor <a@e.com> 111 +0000\ncommitter Com Itter <c@e.com> 222 +0100\n\nCommit message here`; + const contents = [{ type: 1, content: commitContent }]; + const result = getCommitData(contents); + expect(result[0].parent).to.equal('0'.repeat(40)); + }); + + it('should handle commit messages with multiple lines', () => { + const commitContent = `tree 123\nparent 456\nauthor A <a@e.com> 111 +0000\ncommitter C <c@e.com> 222 +0100\n\nLine one\nLine two\n\nLine four`; + const contents = [{ type: 1, content: commitContent }]; + const result = getCommitData(contents); + expect(result[0].message).to.equal('Line one\nLine two\n\nLine four'); + }); + + it('should handle commits without a message body', () => { + const commitContent = `tree 123\nparent 456\nauthor A <a@e.com> 111 +0000\ncommitter C <c@e.com> 222 +0100\n`; + const contents = [{ type: 1, content: commitContent }]; + const result = getCommitData(contents); + expect(result[0].message).to.equal(''); + }); + + it('should throw error for invalid commit data (missing tree)', () => { + const commitContent = `parent 456\nauthor A <a@e.com> 1234567890 +0000\ncommitter C <c@e.com> 1234567890 +0000\n\nMsg`; + const contents = [{ type: 1, content: commitContent }]; + expect(() => getCommitData(contents)).to.throw('Invalid commit data: Missing tree'); + }); + + it('should throw error for invalid commit data (missing author)', () => { + const commitContent = `tree 123\nparent 456\ncommitter C <c@e.com> 1234567890 +0000\n\nMsg`; + const contents = [{ type: 1, content: commitContent }]; + expect(() => getCommitData(contents)).to.throw('Invalid commit data: Missing author'); + }); + + it('should throw error for invalid commit data (missing committer)', () => { + const commitContent = `tree 123\nparent 456\nauthor A <a@e.com> 1234567890 +0000\n\nMsg`; + const contents = [{ type: 1, content: commitContent }]; + expect(() => getCommitData(contents)).to.throw('Invalid commit data: Missing committer'); + }); + + it('should throw error for invalid author line (missing timezone offset)', () => { + const commitContent = `tree 123\nparent 456\nauthor A <a@e.com> 1234567890\ncommitter C <c@e.com> 1234567890 +0000\n\nMsg`; + const contents = [{ type: 1, content: commitContent }]; + expect(() => getCommitData(contents)).to.throw('Failed to parse person line'); + }); + + it('should correctly parse a commit with a GPG signature header', () => { + const gpgSignedCommit = "tree b4d3c0ffee1234567890abcdef1234567890aabbcc\n" + + "parent 01dbeef9876543210fedcba9876543210fedcba\n" + + "author Test Author <test.author@example.com> 1744814600 +0100\n" + + "committer Test Committer <test.committer@example.com> 1744814610 +0200\n" + + "gpgsig -----BEGIN PGP SIGNATURE-----\n \n" + + " wsFcBAABCAAQBQJn/8ISCRC1aQ7uu5UhlAAAntAQACeyQd6IykNXiN6m9DfVp8DJ\n" + + " UsY64ws+Td0inrEee+cHXVI9uJn15RJYQkICwlM4TZsVGav7nYaVqO+gfAg2ORAH\n" + + " ghUnwSFFs7ucN/p0a47ItkJmt04+jQIFlZIC+wy1u2H3aKJwqaF+kGP5SA33ahgV\n" + + " ZWviKodXFki8/G+sKB63q1qrDw6aELtftEgeAPQUcuLzj+vu/m3dWrDbatfUXMkC\n" + + " JC6PbFajqrJ5pEtFwBqqRE+oIsOM9gkNAti1yDD5eoS+bNXACe0hT0+UoIzn5a34\n" + + " xcElXTSdAK/MRjGiLN91G2nWvlbpM5wAEqr5Bl5ealCc6BbWfPxbP46slaE5DfkD\n" + + " u0+RkVX06MSSPqzOmEV14ZWKap5C19FpF9o/rY8vtLlCxjWMhtUvvdR4OQfQpEDY\n" + + " eTqzCHRnM3+7r3ABAWt9v7cG99bIMEs3sGcMy11HMeaoBpye6vCIP4ghNnoB1hUJ\n" + + " D7MD77jzk4Kbf4IzS5omExyMu3AiNZecZX4+1w/527yPhv3s/HB1Gfz0oCUned+6\n" + + " b9Kkle+krsQ/EK/4gPcb/Kb1cTcm3HhjaOSYwA+JpApJQ0mrduH34AT5MZJuIPFe\n" + + " QheLzQI1d2jmFs11GRC5hc0HBk1WmGm6U8+FBuxCX0ECZPdYeQJjUeWjnNeUoE6a\n" + + " 5lytZU4Onk57nUhIMSrx\n" + + " =IxZr\n" + + " -----END PGP SIGNATURE-----\n\n" + + "This is the commit message.\n" + + "It can span multiple lines.\n\n" + + "And include blank lines internally."; + + const contents = [ + { type: 1, content: gpgSignedCommit }, + { type: 1, content: `tree 111\nparent 000\nauthor A1 <a1@e.com> 1744814600 +0200\ncommitter C1 <c1@e.com> 1744814610 +0200\n\nMsg1` } + ]; + + const result = getCommitData(contents); + expect(result).to.be.an('array').with.lengthOf(2); + + // Check the GPG signed commit data + const gpgResult = result[0]; + expect(gpgResult.tree).to.equal('b4d3c0ffee1234567890abcdef1234567890aabbcc'); + expect(gpgResult.parent).to.equal('01dbeef9876543210fedcba9876543210fedcba'); + expect(gpgResult.author).to.equal('Test Author'); + expect(gpgResult.committer).to.equal('Test Committer'); + expect(gpgResult.authorEmail).to.equal('test.author@example.com'); + expect(gpgResult.commitTimestamp).to.equal('1744814610'); + expect(gpgResult.message).to.equal(`This is the commit message.\nIt can span multiple lines.\n\nAnd include blank lines internally.`); + + // Sanity check: the second commit should be the simple commit + const simpleResult = result[1]; + expect(simpleResult.message).to.equal('Msg1'); + expect(simpleResult.parent).to.equal('000'); + expect(simpleResult.author).to.equal('A1'); + expect(simpleResult.committer).to.equal('C1'); + expect(simpleResult.authorEmail).to.equal('a1@e.com'); + expect(simpleResult.commitTimestamp).to.equal('1744814610'); + }); + }); + + describe('parsePacketLines', () => { + it('should parse multiple valid packet lines correctly and return the correct offset', () => { + const lines = [ + 'line1 content', + 'line2 more content\nwith newline', + 'line3', + ]; + const buffer = createPacketLineBuffer(lines); // Helper adds "0000" at the end + const expectedOffset = buffer.length; // Should indicate the end of the buffer after flush packet + const [parsedLines, offset] = parsePacketLines(buffer); + + expect(parsedLines).to.deep.equal(lines); + expect(offset).to.equal(expectedOffset); + }); + + it('should handle an empty input buffer', () => { + const buffer = Buffer.alloc(0); + const [parsedLines, offset] = parsePacketLines(buffer); + + expect(parsedLines).to.deep.equal([]); + expect(offset).to.equal(0); + }); + + it('should handle a buffer only with a flush packet', () => { + const buffer = Buffer.from(FLUSH_PACKET); + const [parsedLines, offset] = parsePacketLines(buffer); + + expect(parsedLines).to.deep.equal([]); + expect(offset).to.equal(4); + }); + + it('should handle lines with null characters correctly', () => { + const lines = ['line1\0capability=value', 'line2']; + const buffer = createPacketLineBuffer(lines); + const expectedOffset = buffer.length; + const [parsedLines, offset] = parsePacketLines(buffer); + + expect(parsedLines).to.deep.equal(lines); + expect(offset).to.equal(expectedOffset); + }); + + it('should stop parsing at the first flush packet', () => { + const lines = ['line1', 'line2']; + let buffer = createPacketLineBuffer(lines); + + // Add extra data after the flush packet + const extraData = Buffer.from('extradataafterflush'); + buffer = Buffer.concat([buffer, extraData]); + + const expectedOffset = buffer.length - extraData.length; + const [parsedLines, offset] = parsePacketLines(buffer); + + expect(parsedLines).to.deep.equal(lines); + expect(offset).to.equal(expectedOffset); + }); + + it('should throw an error if a packet line length exceeds buffer bounds', () => { + // 000A -> length 10, but actual line length is only 3 bytes + const invalidLengthBuffer = Buffer.from('000Aabc'); + expect(() => parsePacketLines(invalidLengthBuffer)).to.throw(/Invalid packet line length 000A/); + }); + + it('should throw an error for non-hex length prefix (all non-hex)', () => { + const invalidHexBuffer = Buffer.from('XXXXline'); + expect(() => parsePacketLines(invalidHexBuffer)).to.throw(/Invalid packet line length XXXX/); + }); + + it('should throw an error for non-hex length prefix (non-hex at the end)', () => { + // Cover the quirk of parseInt returning 0 instead of NaN + const invalidHexBuffer = Buffer.from('000zline'); + expect(() => parsePacketLines(invalidHexBuffer)).to.throw(/Invalid packet line length 000z/); + }); + + it('should handle buffer ending exactly after a valid line length without content', () => { + // 0008 -> length 8, but buffer ends after header (no content) + const incompleteBuffer = Buffer.from('0008'); + expect(() => parsePacketLines(incompleteBuffer)).to.throw(/Invalid packet line length 0008/); + }); + }); +});
9c1449f4ec37feat: add check for hidden commits
4 files changed · +105 −7
src/proxy/chain.ts+8 −3 modified@@ -9,9 +9,10 @@ const pushActionChain: ((req: any, action: Action) => Promise<Action>)[] = [ proc.push.checkCommitMessages, proc.push.checkAuthorEmails, proc.push.checkUserPushPermission, - proc.push.checkIfWaitingAuth, proc.push.pullRemote, proc.push.writePack, + proc.push.checkHiddenCommits, + proc.push.checkIfWaitingAuth, proc.push.getMissingData, proc.push.preReceive, proc.push.getDiff, @@ -20,7 +21,9 @@ const pushActionChain: ((req: any, action: Action) => Promise<Action>)[] = [ proc.push.blockForAuth, ]; -const pullActionChain: ((req: any, action: Action) => Promise<Action>)[] = [proc.push.checkRepoInAuthorisedList]; +const pullActionChain: ((req: any, action: Action) => Promise<Action>)[] = [ + proc.push.checkRepoInAuthorisedList, +]; let pluginsInserted = false; @@ -58,7 +61,9 @@ export const executeChain = async (req: any, res: any): Promise<Action> => { */ let chainPluginLoader: PluginLoader; -const getChain = async (action: Action): Promise<((req: any, action: Action) => Promise<Action>)[]> => { +const getChain = async ( + action: Action, +): Promise<((req: any, action: Action) => Promise<Action>)[]> => { if (chainPluginLoader === undefined) { console.error( 'Plugin loader was not initialized! This is an application error. Please report it to the GitProxy maintainers. Skipping plugins...',
src/proxy/processors/push-action/checkHiddenCommits.ts+85 −0 added@@ -0,0 +1,85 @@ +import path from 'path'; +import { Action, Step } from '../../actions'; +import { spawnSync } from 'child_process'; + +const exec = async (req: any, action: Action): Promise<Action> => { + const step = new Step('checkHiddenCommits'); + + try { + const repoPath = `${action.proxyGitPath}/${action.repoName}`; + console.log(`repoPath: ${repoPath}`); + + const introducedCommits = new Set<string>(); + + action.updatedRefs?.forEach(({ ref, oldOid, newOid }) => { + const revRange = + oldOid === '0000000000000000000000000000000000000000' ? newOid : `${oldOid}..${newOid}`; + + const result = spawnSync('git', ['rev-list', revRange], { + cwd: repoPath, + encoding: 'utf-8', + }); + + result.stdout + .trim() + .split('\n') + .forEach((c) => { + if (c) introducedCommits.add(c); + }); + }); + + step.log(`Total introduced commits: ${introducedCommits.size}`); + step.log(`Introduced commits: ${[...introducedCommits].join(', ')}`); + + const packPath = path.join('.git', 'objects', 'pack'); + + const packCommits = new Set<string>(); + + (action.newIdxFiles || []).forEach((idxFile) => { + const idxPath = path.join(packPath, idxFile); + const out = spawnSync('git', ['verify-pack', '-v', idxPath], { + cwd: repoPath, + encoding: 'utf-8', + }).stdout; + + out + .trim() + .split('\n') + .forEach((line) => { + const [sha, type] = line.split(/\s+/); + if (type === 'commit') packCommits.add(sha); + }); + }); + step.log(`Commits nel pack: ${packCommits.size}`); + console.log('Pack commits:', packCommits); + console.log('Introduced commits:', introducedCommits); + + const referenced: string[] = []; + const unreferenced: string[] = []; + [...packCommits].forEach((sha) => { + if (introducedCommits.has(sha)) referenced.push(sha); + else unreferenced.push(sha); + }); + + step.log(`✅ Referenced commits: ${referenced.length}`); + step.log(`❌ Unreferenced commits: ${unreferenced.length}`); + + if (unreferenced.length > 0) { + step.setError( + `Unreferenced commits in pack (${unreferenced.length}): ${unreferenced.join(', ')}`, + ); + action.error = true; + } + step.setContent(`Referenced: ${referenced.length}, Unreferenced: ${unreferenced.length}`); + } catch (e: any) { + step.setError(e.message); + throw e; + } finally { + action.addStep(step); + } + + return action; +}; + +exec.displayName = 'checkHiddenCommits.exec'; +export { exec };
src/proxy/processors/push-action/index.ts+2 −0 modified@@ -5,6 +5,7 @@ import { exec as audit } from './audit'; import { exec as pullRemote } from './pullRemote'; import { exec as writePack } from './writePack'; import { exec as getDiff } from './getDiff'; +import { exec as checkHiddenCommits } from './checkHiddenCommits'; import { exec as scanDiff } from './scanDiff'; import { exec as blockForAuth } from './blockForAuth'; import { exec as checkIfWaitingAuth } from './checkIfWaitingAuth'; @@ -22,6 +23,7 @@ export { pullRemote, writePack, getDiff, + checkHiddenCommits, scanDiff, blockForAuth, checkIfWaitingAuth,
src/proxy/processors/push-action/parsePush.ts+10 −4 modified@@ -31,11 +31,17 @@ async function exec(req: any, action: Action): Promise<Action> { return action; } - const parts = refUpdates[0].split(' '); - const [oldCommit, newCommit, ref] = parts; + const cleanedUpdates = refUpdates.map((line) => { + const [oldOid, newOid, refWithNull] = line.split(' '); + const ref = refWithNull.replace(/\0.*/, '').trim(); + return { oldOid, newOid, ref }; + }); - action.branch = ref.replace(/\0.*/, '').trim(); - action.setCommit(oldCommit, newCommit); + action.updatedRefs = cleanedUpdates; + + const { oldOid, newOid, ref } = cleanedUpdates[0]; + action.branch = ref; + action.setCommit(oldOid, newOid); // Check if the offset is valid and if there's data after it if (packDataOffset >= req.body.length) {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-v98g-8rqx-g93gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-54586ghsaADVISORY
- github.com/finos/git-proxy/commit/9c1449f4ec37d2d1f3edf4328bc3757e8dba2110ghsax_refsource_MISCWEB
- github.com/finos/git-proxy/commit/a620a2f33c39c78e01783a274580bf822af3cc3aghsax_refsource_MISCWEB
- github.com/finos/git-proxy/releases/tag/v1.19.2ghsax_refsource_MISCWEB
- github.com/finos/git-proxy/security/advisories/GHSA-v98g-8rqx-g93gghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.