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:
parent
d5ff537599
commit
35359b7d8c
4 changed files with 48 additions and 11 deletions
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue