fix(git-clone): accept HTTPS repo URLs on any host, not just GitHub

scanners/lib/git-clone.mjs accepted only github.com, so the README's
Forgejo example (and /security scan on any Forgejo, Codeberg or self-hosted
URL) failed validation; scan.md and plugin-audit.md also routed only
https://github.com/ to the clone path.

Chose generic HTTPS over adding one host to an allowlist because there is
no documented security reason for the host check. Read before choosing:
git log --follow (import + E12 only), the file's comments, CHANGELOG v2.4.0
("GitHub repo URL support", feature framing), security-hardening-guide §2
and §7 (the sandbox is the defence against filter/smudge drivers), and
review-2026-06-20, which lists "vsix-fetch (HTTPS host allowlist)" as a
protection but no host check for git-clone. What protects a clone is the
OS sandbox, GIT_SANDBOX_CONFIG and GIT_SANDBOX_ENV, for every host alike.

Measured: with GIT_SANDBOX_ENV, git reads 0 config lines outside a repo;
without it, osxkeychain comes from Xcode's system gitconfig. So no stored
credential is offered to an unknown host.

The shape stays strict: https only, no userinfo, host starts and ends
alphanumeric (no leading -), optional port, exactly owner/repo, no query
or fragment. SSH stays GitHub-only: ssh uses the user's own keys and
~/.ssh/config, which the git environment does not isolate.

Tests (red first): Forgejo and any-host accept 2 failing -> green; guards
for http, userinfo, leading -, extra path, query, ext:: and file:: and
non-GitHub SSH hold. The old "rejects non-GitHub URL" case still passes
(it has no owner/repo) and is renamed to say so. Regex timed linear
(< 1 ms at 200k chars). Live: a sandboxed clone of
https://git.fromaitochitta.com/open/llm-security.git exits 0 at 5208420,
cleaned up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-22 14:14:39 +02:00
commit 35359b7d8c
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 48 additions and 11 deletions

View file

@ -7,12 +7,12 @@ model: sonnet
# /security plugin-audit [path|url] # /security plugin-audit [path|url]
Audit a Claude Code plugin for security before installation. Accepts local paths or GitHub URLs. Audit a Claude Code plugin for security before installation. Accepts local paths or remote git URLs (HTTPS on any host, SSH on GitHub).
## Step 1: Resolve Target ## Step 1: Resolve Target
- If `$ARGUMENTS` contains `--branch <name>` → strip it, set `branch = <name>` - If `$ARGUMENTS` contains `--branch <name>` → strip it, set `branch = <name>`
- If `$ARGUMENTS` starts with `https://github.com/` or `git@github.com:` → - If `$ARGUMENTS` starts with `https://` or `git@github.com:` →
Run: `node <plugin-root>/scanners/lib/git-clone.mjs clone "<url>" [--branch <branch>]` Run: `node <plugin-root>/scanners/lib/git-clone.mjs clone "<url>" [--branch <branch>]`
If exit code != 0 → show error to user and **STOP** If exit code != 0 → show error to user and **STOP**
Set `clone_path` = stdout (trimmed), `target = clone_path` Set `clone_path` = stdout (trimmed), `target = clone_path`

View file

@ -7,14 +7,14 @@ model: sonnet
# /security scan [path|url] # /security scan [path|url]
Scan target for security issues. Accepts local paths or GitHub URLs. Delegates to specialized agents sequentially. Scan target for security issues. Accepts local paths or remote git URLs (HTTPS on any host, SSH on GitHub). Delegates to specialized agents sequentially.
## Step 1: Resolve Target ## Step 1: Resolve Target
- If `$ARGUMENTS` contains `--deep` → strip it, set `run_deep_scan = true` - If `$ARGUMENTS` contains `--deep` → strip it, set `run_deep_scan = true`
- If `$ARGUMENTS` contains `--branch <name>` → strip it, set `branch = <name>` - If `$ARGUMENTS` contains `--branch <name>` → strip it, set `branch = <name>`
- If `$ARGUMENTS` is empty → `target = "."`, `clone_path = null` - If `$ARGUMENTS` is empty → `target = "."`, `clone_path = null`
- If `$ARGUMENTS` starts with `https://github.com/` or `git@github.com:` → - If `$ARGUMENTS` starts with `https://` or `git@github.com:` →
Run: `node <plugin-root>/scanners/lib/git-clone.mjs clone "<url>" [--branch <branch>]` Run: `node <plugin-root>/scanners/lib/git-clone.mjs clone "<url>" [--branch <branch>]`
If exit code != 0 → show error to user and **STOP** If exit code != 0 → show error to user and **STOP**
Set `clone_path` = stdout (trimmed), `target = clone_path` Set `clone_path` = stdout (trimmed), `target = clone_path`

View file

@ -1,21 +1,30 @@
#!/usr/bin/env node #!/usr/bin/env node
// git-clone.mjs — Clone GitHub repos to temp dirs for security scanning // git-clone.mjs — Clone remote git repos to temp dirs for security scanning
// Usage: // Usage:
// node git-clone.mjs clone <url> [--branch <name>] → sandboxed shallow clone, prints tmpdir path // node git-clone.mjs clone <url> [--branch <name>] → sandboxed shallow clone, prints tmpdir path
// node git-clone.mjs cleanup <dir> → removes temp directory // node git-clone.mjs cleanup <dir> → removes temp directory
// node git-clone.mjs validate <url> → exits 0 if valid GitHub URL, 1 if not // node git-clone.mjs validate <url> → exits 0 if valid repo URL, 1 if not
import { mkdtempSync, rmSync, existsSync, realpathSync, readFileSync } from 'node:fs'; import { mkdtempSync, rmSync, existsSync, realpathSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
const GITHUB_URL_RE = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(\.git)?\/?$/; // HTTPS on any host (v8.1.0). The GitHub-only host check was feature scope, not
// a boundary: what protects the scan is the OS sandbox, GIT_SANDBOX_CONFIG and
// GIT_SANDBOX_ENV, and they apply to every host alike. GIT_CONFIG_NOSYSTEM and
// GIT_CONFIG_GLOBAL=/dev/null also drop credential helpers, so no stored
// credential is offered to an unknown host. The shape stays strict: https only,
// no userinfo, a host that starts and ends alphanumeric (no leading `-`), an
// optional port, exactly owner/repo, no query or fragment.
// SSH stays GitHub-only: ssh uses the user's own keys and ~/.ssh/config, which
// the git environment does not isolate.
const HTTPS_REPO_URL_RE = /^https:\/\/[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::\d{1,5})?\/[\w.-]+\/[\w.-]+(\.git)?\/?$/;
const GITHUB_SSH_RE = /^git@github\.com:[\w.-]+\/[\w.-]+(\.git)?$/; const GITHUB_SSH_RE = /^git@github\.com:[\w.-]+\/[\w.-]+(\.git)?$/;
const MAX_CLONE_SIZE_MB = 100; const MAX_CLONE_SIZE_MB = 100;
function isValidUrl(url) { function isValidUrl(url) {
return GITHUB_URL_RE.test(url) || GITHUB_SSH_RE.test(url); return HTTPS_REPO_URL_RE.test(url) || GITHUB_SSH_RE.test(url);
} }
function parseArgs(argv) { function parseArgs(argv) {
@ -185,8 +194,8 @@ switch (command) {
} }
if (!isValidUrl(url)) { if (!isValidUrl(url)) {
console.error(`clone: invalid GitHub URL: ${url}`); console.error(`clone: invalid repository URL: ${url}`);
console.error('Supported: https://github.com/user/repo or git@github.com:user/repo.git'); console.error('Supported: https://<host>/<owner>/<repo>[.git] or git@github.com:<owner>/<repo>.git');
process.exit(1); process.exit(1);
} }

View file

@ -245,13 +245,41 @@ describe('git-clone validate', () => {
assert.equal(result.status, 0); assert.equal(result.status, 0);
}); });
it('rejects non-GitHub URL', () => { it('rejects an HTTPS URL without an owner/repo path', () => {
const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://evil.com/repo'], { const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://evil.com/repo'], {
encoding: 'utf8', encoding: 'utf8',
}); });
assert.equal(result.status, 1); assert.equal(result.status, 1);
}); });
// v8.1.0 S3: any HTTPS host. The host was never the boundary — the sandbox,
// the git config flags and the isolated environment are, for every host.
const validate = (url) => spawnSync('node', [GIT_CLONE, 'validate', url], { encoding: 'utf8' }).status;
it('accepts an HTTPS Forgejo URL', () => {
assert.equal(validate('https://git.fromaitochitta.com/open/llm-security.git'), 0);
assert.equal(validate('https://git.fromaitochitta.com/open/llm-security'), 0);
});
it('accepts an HTTPS URL on any host, with or without a port', () => {
assert.equal(validate('https://codeberg.org/org/repo'), 0);
assert.equal(validate('https://git.example.com:3000/org/repo.git'), 0);
});
it('keeps rejecting shapes that are not a plain HTTPS owner/repo URL', () => {
assert.equal(validate('http://git.example.com/org/repo'), 1, 'plain http');
assert.equal(validate('https://user:pass@git.example.com/org/repo'), 1, 'userinfo');
assert.equal(validate('https://-oProxyCommand=x/org/repo'), 1, 'host starting with -');
assert.equal(validate('https://git.example.com/org/repo/src/branch/main'), 1, 'extra path');
assert.equal(validate('https://git.example.com/org/repo?x=1'), 1, 'query');
assert.equal(validate('ext::sh -c touch% /tmp/x'), 1, 'ext transport');
assert.equal(validate('file:///etc/org/repo'), 1, 'file transport');
});
it('keeps SSH GitHub-only (ssh uses the user\'s own keys and config)', () => {
assert.equal(validate('git@git.example.com:org/repo.git'), 1);
});
it('rejects URL with tree path', () => { it('rejects URL with tree path', () => {
const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://github.com/org/repo/tree/main/dir'], { const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://github.com/org/repo/tree/main/dir'], {
encoding: 'utf8', encoding: 'utf8',