diff --git a/README.md b/README.md index f37bff0..259bc0a 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ Switch raw Git auth more than once in the same job: - Creates temp askpass and Git config files under `RUNNER_TEMP`. - Clears stale checkout-local GitHub `credential.helper`, `http.*.extraheader`, and `url.*.insteadOf` entries for the configured host. +- Clears checkout v6 local `includeIf.gitdir:*` entries that point to + `RUNNER_TEMP` `git-credentials-*.config` files, including worktree patterns. +- Sanitizes local `remote.origin.url` when it targets the configured GitHub host + with embedded username/password credentials. - Adds non-tokenized SSH-to-HTTPS rewrites for the configured host. - Never writes tokenized URLs or mutates user/global/system Git config. diff --git a/package-lock.json b/package-lock.json index 5896cb8..dcf4376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@actions/core": "^1.10.1", "@types/node": "^20.9.0", "execa": "^8.0.1", + "tsx": "^4.20.3", "typescript": "^5.0.0", "vite": "^6.4.3" } @@ -604,9 +605,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -621,9 +619,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -638,9 +633,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -655,9 +647,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -672,9 +661,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -689,9 +675,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +689,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -723,9 +703,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -740,9 +717,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +731,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -774,9 +745,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -791,9 +759,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -808,9 +773,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1355,6 +1317,509 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", diff --git a/package.json b/package.json index 5a30580..c3a3901 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "@actions/core": "^1.10.1", "@types/node": "^20.9.0", "execa": "^8.0.1", + "tsx": "^4.20.3", "typescript": "^5.0.0", "vite": "^6.4.3" }, "scripts": { "build": "vite build", + "test": "node --import tsx --test --test-concurrency=1 test/*.test.mjs", "typecheck": "tsc --noEmit" }, "bundleDependencies": [ diff --git a/src/main.ts b/src/main.ts index 828161c..e17d486 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,29 +1,37 @@ import * as core from "@actions/core"; -import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { chmodSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { basename, isAbsolute, join, normalize, resolve } from "node:path"; import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; import { execa } from "execa"; const gitTokenEnvName = "SETUP_GIT_CREDENTIALS_TOKEN"; const gitAuthConfigPattern = "^(credential\\.helper|http(\\..*)?\\.extraheader|url\\..*\\.insteadof)$"; +const checkoutIncludeIfPattern = "^includeif\\.gitdir:.*\\.path$"; + +const emptyScrubResult = { + authKeys: 0, + checkoutIncludeIfKeys: 0, + embeddedOriginCredentials: 0, +}; type GitConfigEntry = { key: string; value: string; }; -function createGitCredentialDir() { +export function createGitCredentialDir() { return mkdtempSync( join(process.env.RUNNER_TEMP ?? tmpdir(), "setup-git-credentials-"), ); } -function escapeGitConfigSubsection(value: string) { +export function escapeGitConfigSubsection(value: string) { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } -function createAskpassScript(gitCredentialDir: string) { +export function createAskpassScript(gitCredentialDir: string) { const askpassPath = join( gitCredentialDir, process.platform === "win32" ? "git-askpass.cmd" : "git-askpass.sh", @@ -59,7 +67,10 @@ esac return askpassPath; } -function createTempGlobalGitConfig(gitCredentialDir: string, serverUrl: URL) { +export function createTempGlobalGitConfig( + gitCredentialDir: string, + serverUrl: URL, +) { const gitConfigPath = join(gitCredentialDir, "gitconfig"); const httpsBaseUrl = `${serverUrl.protocol}//${serverUrl.host}/`; const sshUrlHost = serverUrl.host; @@ -78,7 +89,7 @@ function createTempGlobalGitConfig(gitCredentialDir: string, serverUrl: URL) { return gitConfigPath; } -function sanitizeSecretBearingText(value: string) { +export function sanitizeSecretBearingText(value: string) { return value .replace(/(https?:\/\/)(?:[^/\s@]+@)/gi, "$1***@") .replace(/(x-access-token:)[^@/\s]+/gi, "$1***") @@ -117,7 +128,9 @@ async function debugGitAuthConfigOrigins(label: string) { } } -async function getLocalGitConfigEntries(pattern: string): Promise { +async function getLocalGitConfigEntries( + pattern: string, +): Promise { const result = await execa( "git", ["config", "--local", "--get-regexp", pattern], @@ -164,7 +177,132 @@ function shouldUnsetLocalGitAuthConfig(entry: GitConfigEntry, serverUrl: URL) { return false; } -async function scrubLocalGitAuthConfig(serverUrl: URL) { +function tryRealpath(value: string) { + try { + return realpathSync(value); + } catch { + return value; + } +} + +function normalizePathForComparison(value: string) { + return normalize(isAbsolute(value) ? value : resolve(value)); +} + +function isPathUnder(parent: string, child: string) { + const normalizedParent = normalizePathForComparison(tryRealpath(parent)); + const normalizedChild = normalizePathForComparison(tryRealpath(child)); + if (normalizedChild === normalizedParent) return true; + if (!normalizedChild.startsWith(normalizedParent)) return false; + + const boundary = normalizedChild.charAt(normalizedParent.length); + return boundary === "/" || boundary === "\\"; +} + +function isClearlyRunnerTempPath(value: string) { + const normalized = normalizePathForComparison(value).replace(/\\/g, "/"); + + return ( + /\/_work\/_temp\//i.test(normalized) || + /\/actions-runner\/_work\/_temp\//i.test(normalized) || + /\/runner\/_work\/_temp\//i.test(normalized) + ); +} + +export function isCheckoutCredentialIncludePath(value: string) { + const trimmed = value.trim(); + if (!/^git-credentials-.+\.config$/i.test(basename(trimmed))) return false; + + const runnerTemp = process.env.RUNNER_TEMP; + if (runnerTemp && isPathUnder(runnerTemp, trimmed)) return true; + + return isClearlyRunnerTempPath(trimmed); +} + +function escapeGitConfigValuePattern(value: string) { + return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); +} + +async function unsetLocalGitConfigKey(key: string, exactValue?: string) { + const args = ["config", "--local", "--unset-all", key]; + if (exactValue !== undefined) { + args.push(`^${escapeGitConfigValuePattern(exactValue)}$`); + } + + const result = await execa("git", args, { + reject: false, + }); + + if (result.exitCode !== 0 && result.exitCode !== 5) { + core.debug(`Unable to unset local git config key ${sanitizeSecretBearingText(key)}`); + return false; + } + + return true; +} + +function cleanConfiguredHostRemoteUrl(value: string, serverUrl: URL) { + let parsed: URL; + + try { + parsed = new URL(value); + } catch { + return undefined; + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined; + if (parsed.hostname.toLowerCase() !== serverUrl.hostname.toLowerCase()) { + return undefined; + } + if (getComparablePort(parsed) !== getComparablePort(serverUrl)) return undefined; + if (!parsed.username && !parsed.password) return undefined; + + parsed.username = ""; + parsed.password = ""; + parsed.hash = ""; + return parsed.toString(); +} + +function getComparablePort(value: URL) { + if (value.port) return value.port; + if (value.protocol === "https:") return "443"; + if (value.protocol === "http:") return "80"; + return ""; +} + +async function scrubEmbeddedOriginCredentials(serverUrl: URL) { + const result = await execa("git", ["config", "--local", "--get", "remote.origin.url"], { + reject: false, + }); + + if (result.exitCode === 1) return false; + if (result.exitCode !== 0) { + core.debug("Unable to inspect local remote.origin.url"); + return false; + } + + const currentUrl = result.stdout.trim(); + const cleanUrl = cleanConfiguredHostRemoteUrl(currentUrl, serverUrl); + if (!cleanUrl || cleanUrl === currentUrl) return false; + + const setResult = await execa( + "git", + ["config", "--local", "remote.origin.url", cleanUrl], + { reject: false }, + ); + + if (setResult.exitCode !== 0) { + core.debug("Unable to sanitize local remote.origin.url"); + return false; + } + + core.debug( + `scrubbed embedded credentials from remote.origin.url: ${sanitizeSecretBearingText(cleanUrl)}`, + ); + return true; +} + +export async function scrubLocalGitAuthConfig(serverUrl: URL) { const workTreeResult = await execa( "git", ["rev-parse", "--is-inside-work-tree"], @@ -173,10 +311,11 @@ async function scrubLocalGitAuthConfig(serverUrl: URL) { if (workTreeResult.exitCode !== 0 || workTreeResult.stdout.trim() !== "true") { core.debug("Skipping local git auth config cleanup outside a git worktree"); - return 0; + return emptyScrubResult; } const entries = await getLocalGitConfigEntries(gitAuthConfigPattern); + const includeIfEntries = await getLocalGitConfigEntries(checkoutIncludeIfPattern); const keysToUnset = [ ...new Set( entries @@ -184,22 +323,35 @@ async function scrubLocalGitAuthConfig(serverUrl: URL) { .map((entry) => entry.key), ), ]; + const includeIfKeysToUnset = [ + ...includeIfEntries.filter((entry) => + isCheckoutCredentialIncludePath(entry.value), + ), + ]; for (const key of keysToUnset) { - const result = await execa("git", ["config", "--local", "--unset-all", key], { - reject: false, - }); - if (result.exitCode !== 0 && result.exitCode !== 5) { + await unsetLocalGitConfigKey(key); + } + + for (const entry of includeIfKeysToUnset) { + const scrubbed = await unsetLocalGitConfigKey(entry.key, entry.value); + if (scrubbed) { core.debug( - `Unable to unset local git config key ${sanitizeSecretBearingText(key)}`, + `scrubbed checkout credential includeIf ${sanitizeSecretBearingText(entry.key)} -> ${sanitizeSecretBearingText(entry.value)}`, ); } } - return keysToUnset.length; + const scrubbedOriginCredentials = await scrubEmbeddedOriginCredentials(serverUrl); + + return { + authKeys: keysToUnset.length, + checkoutIncludeIfKeys: includeIfKeysToUnset.length, + embeddedOriginCredentials: scrubbedOriginCredentials ? 1 : 0, + }; } -async function configureGitCredentials(serverUrl: URL, token: string) { +export async function configureGitCredentials(serverUrl: URL, token: string) { core.debug("setup-git-credentials: enabled"); await debugGitAuthConfigOrigins("git auth config before setup"); @@ -217,14 +369,29 @@ async function configureGitCredentials(serverUrl: URL, token: string) { const scrubbedLocalKeys = await scrubLocalGitAuthConfig(serverUrl); core.debug(`git credential temp config: ${gitConfigPath}`); core.debug(`git askpass script: ${askpassPath}`); - core.debug(`local git auth config keys scrubbed: ${scrubbedLocalKeys}`); + core.debug( + `local git auth config scrubbed: auth=${scrubbedLocalKeys.authKeys}, checkout-includeIf=${scrubbedLocalKeys.checkoutIncludeIfKeys}, remote-origin=${scrubbedLocalKeys.embeddedOriginCredentials}`, + ); await debugGitAuthConfigOrigins("git auth config after setup"); } -try { - const token = core.getInput("token", { required: true }); - const serverUrl = new URL(core.getInput("github-server-url")); - await configureGitCredentials(serverUrl, token); -} catch (error) { - core.setFailed(error instanceof Error ? error.message : String(error)); +async function run() { + try { + const token = core.getInput("token", { required: true }); + const serverUrl = new URL(core.getInput("github-server-url")); + await configureGitCredentials(serverUrl, token); + } catch (error) { + core.setFailed(error instanceof Error ? error.message : String(error)); + } +} + +function isDirectRun() { + const entrypoint = process.argv[1]; + if (!entrypoint) return false; + + return fileURLToPath(import.meta.url) === resolve(entrypoint); +} + +if (isDirectRun()) { + await run(); } diff --git a/test/main.test.mjs b/test/main.test.mjs new file mode 100644 index 0000000..c31f566 --- /dev/null +++ b/test/main.test.mjs @@ -0,0 +1,300 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; + +import { + isCheckoutCredentialIncludePath, + scrubLocalGitAuthConfig, +} from "../src/main.ts"; + +const projectRoot = process.cwd(); +const sourceMainUrl = pathToFileURL(join(projectRoot, "src/main.ts")).href; +const tsxLoaderUrl = pathToFileURL( + join(projectRoot, "node_modules/tsx/dist/loader.mjs"), +).href; +const emptyGlobalConfig = join(tmpdir(), "setup-git-credentials-empty-gitconfig"); +writeFileSync(emptyGlobalConfig, ""); + +function git(args, cwd) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_GLOBAL: emptyGlobalConfig, + GIT_CONFIG_NOSYSTEM: "1", + }, + }).trim(); +} + +function createRepo() { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "setup-git-credentials-test-"))); + git(["init"], dir); + return dir; +} + +function readConfig(key, cwd) { + try { + return git(["config", "--local", "--get-all", key], cwd); + } catch (error) { + if (error.status === 1) return ""; + throw error; + } +} + +function withRunnerTemp() { + const previousRunnerTemp = process.env.RUNNER_TEMP; + const runnerTemp = realpathSync(mkdtempSync(join(tmpdir(), "runner-temp-"))); + process.env.RUNNER_TEMP = runnerTemp; + + return { + runnerTemp, + restore() { + if (previousRunnerTemp === undefined) { + delete process.env.RUNNER_TEMP; + } else { + process.env.RUNNER_TEMP = previousRunnerTemp; + } + rmSync(runnerTemp, { force: true, recursive: true }); + }, + }; +} + +async function inRepo(callback) { + const previousCwd = process.cwd(); + const repo = createRepo(); + + process.chdir(repo); + try { + await callback(repo); + } finally { + process.chdir(previousCwd); + rmSync(repo, { force: true, recursive: true }); + } +} + +test("direct local http extraheader cleanup still works", async () => { + await inRepo(async (repo) => { + git( + [ + "config", + "--local", + "http.https://github.com/.extraheader", + "AUTHORIZATION: basic token-value", + ], + repo, + ); + + await scrubLocalGitAuthConfig(new URL("https://github.com")); + + assert.equal(readConfig("http.https://github.com/.extraheader", repo), ""); + }); +}); + +test("checkout v6 includeIf credential config is removed", async () => { + const { runnerTemp, restore } = withRunnerTemp(); + try { + await inRepo(async (repo) => { + const checkoutConfig = join(runnerTemp, "git-credentials-1234.config"); + const userConfig = join(repo, "user-include.config"); + writeFileSync(checkoutConfig, "[http \"https://github.com/\"]\n\textraheader = secret\n"); + writeFileSync(userConfig, "[user]\n\tname = Example\n"); + const key = `includeIf.gitdir:${repo}/.git.path`; + git(["config", "--local", key, checkoutConfig], repo); + git(["config", "--local", "--add", key, userConfig], repo); + + await scrubLocalGitAuthConfig(new URL("https://github.com")); + + assert.equal(readConfig(key, repo), userConfig); + }); + } finally { + restore(); + } +}); + +test("unrelated includeIf entries are preserved", async () => { + await inRepo(async (repo) => { + const configPath = join(repo, "user-include.config"); + writeFileSync(configPath, "[user]\n\tname = Example\n"); + const key = `includeIf.gitdir:${repo}/.git.path`; + git(["config", "--local", key, configPath], repo); + + await scrubLocalGitAuthConfig(new URL("https://github.com")); + + assert.equal(readConfig(key, repo), configPath); + }); +}); + +test("includeIf outside runner temp or without checkout credential filename is preserved", async () => { + const { runnerTemp, restore } = withRunnerTemp(); + try { + await inRepo(async (repo) => { + const outsidePath = join(repo, "git-credentials-outside.config"); + const prefixLookalikeDir = `${runnerTemp}-outside`; + const prefixLookalikePath = join( + prefixLookalikeDir, + "git-credentials-lookalike.config", + ); + const wrongName = join(runnerTemp, "regular-git-config.config"); + writeFileSync(outsidePath, "[user]\n\tname = Example\n"); + mkdirSync(prefixLookalikeDir); + writeFileSync(prefixLookalikePath, "[user]\n\tname = Prefix\n"); + writeFileSync(wrongName, "[user]\n\temail = example@example.com\n"); + const outsideKey = `includeIf.gitdir:${repo}/.git.path`; + const prefixLookalikeKey = `includeIf.gitdir:${repo}/prefix-lookalike.path`; + const wrongNameKey = `includeIf.gitdir:${repo}/.git/worktrees/*.path`; + git(["config", "--local", outsideKey, outsidePath], repo); + git(["config", "--local", prefixLookalikeKey, prefixLookalikePath], repo); + git(["config", "--local", wrongNameKey, wrongName], repo); + + await scrubLocalGitAuthConfig(new URL("https://github.com")); + + assert.equal(readConfig(outsideKey, repo), outsidePath); + assert.equal(readConfig(prefixLookalikeKey, repo), prefixLookalikePath); + assert.equal(readConfig(wrongNameKey, repo), wrongName); + }); + } finally { + restore(); + } +}); + +test("embedded remote.origin.url credentials for configured host are cleaned", async () => { + await inRepo(async (repo) => { + git( + [ + "config", + "--local", + "remote.origin.url", + "https://x-access-token:secret-token@github.com/owner/repo.git", + ], + repo, + ); + + await scrubLocalGitAuthConfig(new URL("https://github.com")); + + assert.equal( + readConfig("remote.origin.url", repo), + "https://github.com/owner/repo.git", + ); + }); + + await inRepo(async (repo) => { + git( + [ + "config", + "--local", + "remote.origin.url", + "https://x-access-token:secret-token@github.com:443/owner/repo.git", + ], + repo, + ); + + await scrubLocalGitAuthConfig(new URL("https://github.com")); + + assert.equal( + readConfig("remote.origin.url", repo), + "https://github.com/owner/repo.git", + ); + }); +}); + +test("clean and non-configured host remote.origin.url values are unchanged", async () => { + await inRepo(async (repo) => { + git(["config", "--local", "remote.origin.url", "https://github.com/owner/repo.git"], repo); + + await scrubLocalGitAuthConfig(new URL("https://github.com")); + + assert.equal( + readConfig("remote.origin.url", repo), + "https://github.com/owner/repo.git", + ); + }); + + await inRepo(async (repo) => { + const remoteUrl = "https://token:secret@gitlab.com/owner/repo.git"; + git(["config", "--local", "remote.origin.url", remoteUrl], repo); + + await scrubLocalGitAuthConfig(new URL("https://github.com")); + + assert.equal(readConfig("remote.origin.url", repo), remoteUrl); + }); +}); + +test("debug output does not print token or auth header values", async () => { + const { runnerTemp, restore } = withRunnerTemp(); + + try { + await inRepo(async (repo) => { + const checkoutConfig = join(runnerTemp, "git-credentials-secret.config"); + writeFileSync( + checkoutConfig, + "[http \"https://github.com/\"]\n\textraheader = AUTHORIZATION: basic very-secret-header\n", + ); + git( + [ + "config", + "--local", + `includeIf.gitdir:${repo}/.git.path`, + checkoutConfig, + ], + repo, + ); + git( + [ + "config", + "--local", + "remote.origin.url", + "https://x-access-token:very-secret-token@github.com/owner/repo.git", + ], + repo, + ); + + const script = [ + `import { scrubLocalGitAuthConfig } from ${JSON.stringify(sourceMainUrl)};`, + 'await scrubLocalGitAuthConfig(new URL("https://github.com"));', + ].join("\n"); + + const output = execFileSync( + process.execPath, + ["--import", tsxLoaderUrl, "--input-type=module", "-e", script], + { + cwd: repo, + encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_GLOBAL: emptyGlobalConfig, + GIT_CONFIG_NOSYSTEM: "1", + RUNNER_TEMP: runnerTemp, + }, + }, + ); + + assert.match(output, new RegExp(basename(runnerTemp))); + assert.doesNotMatch(output, /very-secret-token/); + assert.doesNotMatch(output, /very-secret-header/); + assert.doesNotMatch(output, /x-access-token:very-secret-token/); + }); + } finally { + restore(); + } +}); + +test("checkout include path matcher accepts clear runner temp paths without RUNNER_TEMP", () => { + const previousRunnerTemp = process.env.RUNNER_TEMP; + delete process.env.RUNNER_TEMP; + + try { + assert.equal( + isCheckoutCredentialIncludePath( + "/home/runner/_work/_temp/git-credentials-1234.config", + ), + true, + ); + } finally { + if (previousRunnerTemp !== undefined) process.env.RUNNER_TEMP = previousRunnerTemp; + } +}); diff --git a/vite.config.ts b/vite.config.ts index 8ab5498..f9aaa59 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,9 @@ export default defineConfig({ ssr: true, rollupOptions: { input: ["src/main.ts"], + output: { + entryFileNames: "main.js", + }, }, }, ssr: {