Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ You need an agent CLI already installed and authenticated
(`--agent claude|agent|agy|gemini|codex`), or an API key with the built-in
`api` agent — see [Install](#install).

## Repository targets

`--target` accepts local directories, GitHub URLs, GitLab (including nested
namespaces), and Bitbucket Cloud repositories:

```bash
secfoo run --skill sast --target https://gitlab.com/group/subgroup/repo --agent claude
secfoo run --skill sast --target https://bitbucket.org/workspace/repo --agent claude
```

GitLab and Bitbucket support HTTPS and `git@host:namespace/repo.git` SSH URLs;
`.git` is optional. SSH uses your existing SSH authentication. For self-hosted
GitLab, set `SECFOO_GITLAB_HOST` to its hostname, optionally with an HTTPS port
(e.g. `git.example.org:8443`), then pass that host's repository URL. The default
`gitlab.com` remains supported. URL credentials, queries, fragments, HTTP, and
`ssh://` forms are rejected for these new targets.

For private HTTPS repositories, provide `SECFOO_GITLAB_TOKEN` (a GitLab token
with repository read access) or `SECFOO_BITBUCKET_TOKEN` (a Bitbucket repository,
project, or workspace access token with repository read permission) through your
environment or CI secret store. Bitbucket user API tokens/app passwords are not
supported by this token mode. Tokens are supplied to Git through a temporary
askpass helper, never placed in clone arguments, repository URLs, reports, or
Git config. Git errors omit raw server output to keep credentials out of logs.
Authenticated HTTPS clones disable redirects to avoid forwarding credentials to
another host. Without a token, Git uses your existing noninteractive authentication. Clones are shallow and
removed after the run, including failures.

## A 30-second look at the dashboard

<p align="center">
Expand Down
8 changes: 6 additions & 2 deletions src/secfoo/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from secfoo.skills.renderer import TargetContext, render_prompt
from secfoo.stack import Stack, detect_stack, language_for_ecosystem, language_for_path
from secfoo.storage.repository import RunRepository
from secfoo.targets.forge import clone_forge
from secfoo.targets.github import clone_shallow
from secfoo.targets.resolver import ResolvedTarget, TargetKind, resolve_target

Expand Down Expand Up @@ -126,11 +127,11 @@ class RunOutcome:


def _project_display_name(resolved: ResolvedTarget) -> str:
if resolved.kind is TargetKind.GITHUB:
if resolved.is_temporary:
cleaned = resolved.display_name.rstrip("/")
if cleaned.endswith(".git"):
cleaned = cleaned[: -len(".git")]
match = re.search(r"github\.com[/:]([^/]+/[^/]+)$", cleaned)
match = re.search(r"(?:https?://[^/]+/|git@[^:]+:)(.+)$", cleaned)
return match.group(1) if match else cleaned
return Path(resolved.display_name).name or resolved.display_name

Expand Down Expand Up @@ -393,6 +394,9 @@ def _target_workdir(resolved: ResolvedTarget) -> Iterator[Path]:
if resolved.kind is TargetKind.GITHUB:
with clone_shallow(resolved.display_name) as tmp_dir:
yield tmp_dir
elif resolved.kind in {TargetKind.GITLAB, TargetKind.BITBUCKET}:
with clone_forge(resolved.display_name, kind=resolved.kind) as tmp_dir:
yield tmp_dir
else:
assert resolved.path is not None
yield resolved.path
Expand Down
64 changes: 64 additions & 0 deletions src/secfoo/targets/forge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Ephemeral clones for GitLab and Bitbucket, with environment-only credentials."""
from __future__ import annotations

import os
import shlex
import subprocess
import sys
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path

from secfoo.targets.resolver import TargetKind, TargetResolutionError, forge_kind


@contextmanager
def clone_forge(url: str, *, kind: TargetKind, timeout: int = 600) -> Iterator[Path]:
if kind not in {TargetKind.GITLAB, TargetKind.BITBUCKET} or forge_kind(url) is not kind:
raise TargetResolutionError("Invalid forge target")
token_var = "SECFOO_GITLAB_TOKEN" if kind is TargetKind.GITLAB else "SECFOO_BITBUCKET_TOKEN"
username = "oauth2" if kind is TargetKind.GITLAB else "x-token-auth"
with tempfile.TemporaryDirectory(prefix="secfoo-clone-") as directory:
root = Path(directory)
checkout = root / "repo"
env = os.environ.copy()
# Limit token environment variables passed to the clone subprocess.
token = env.pop(token_var, "")
env.pop("SECFOO_GITLAB_TOKEN", None)
env.pop("SECFOO_BITBUCKET_TOKEN", None)
env["GIT_TERMINAL_PROMPT"] = "0"
cmd = ["git"]
if token and url.lower().startswith("https://"):
# The helper contains no credentials; Git sees only its temporary path.
# -c applies to this process, so credentials are not saved in .git/config.
helper = root / "askpass.py"
helper.write_text(
"import os, sys\n"
"print(os.environ['SECFOO_CLONE_USER'] if 'username' in sys.argv[1].lower() "
"else os.environ['SECFOO_CLONE_TOKEN'])\n", encoding="utf-8",
)
# Git runs askpass commands through its own shell (also bundled
# with Git for Windows); Python paths containing spaces are quoted.
askpass = shlex.quote(sys.executable) + " " + shlex.quote(str(helper))
env.update(GIT_ASKPASS=askpass, SECFOO_CLONE_TOKEN=token, SECFOO_CLONE_USER=username)
# Disable configured credential helpers so a successful clone cannot
# store the token through a user's credential manager.
cmd += ["-c", "credential.helper="]
if token and url.lower().startswith("https://"):
cmd += ["-c", "http.followRedirects=false"]
cmd += ["clone", "--depth", "1", "--", url, str(checkout)]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout, env=env)
except subprocess.TimeoutExpired:
raise TargetResolutionError(f"git clone timed out after {timeout}s") from None
except subprocess.CalledProcessError as exc:
# Do not forward git stderr: remote servers and helpers can echo secrets.
detail = " (disk full)" if "No space left on device" in (exc.stderr or "") else ""
raise TargetResolutionError(f"git clone failed{detail}; check repository access") from None
except OSError:
raise TargetResolutionError("Unable to run git clone; check that git is installed") from None
# Remove askpass material before any agent can inspect the checkout.
for helper_path in (root / "askpass.py",):
helper_path.unlink(missing_ok=True)
yield checkout
72 changes: 71 additions & 1 deletion src/secfoo/targets/resolver.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from __future__ import annotations

import os
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from urllib.parse import urlsplit

_GITHUB_URL_RE = re.compile(r"^(https?://github\.com/|git@github\.com:)", re.IGNORECASE)

Expand All @@ -15,12 +17,14 @@ class TargetResolutionError(RuntimeError):
class TargetKind(str, Enum):
LOCAL = "local"
GITHUB = "github"
GITLAB = "gitlab"
BITBUCKET = "bitbucket"


@dataclass
class ResolvedTarget:
kind: TargetKind
path: Path | None # None for GITHUB until clone_shallow() fills it in
path: Path | None # None for remote repositories until cloning
display_name: str # cwd path OR the github URL -- used as the storage project identifier
is_temporary: bool

Expand All @@ -29,6 +33,66 @@ def is_github_url(value: str) -> bool:
return bool(_GITHUB_URL_RE.match(value.strip()))


def forge_kind(value: str) -> TargetKind | None:
"""Recognize supported GitLab/Bitbucket URLs without accepting credentials."""
if not (looks_like_url(value) or value.startswith("git@")):
return None
try:
initial = urlsplit(value)
if initial.username is not None or initial.password is not None:
raise TargetResolutionError("Repository URLs must not contain credentials; use environment tokens")
except ValueError:
raise TargetResolutionError("Invalid repository URL") from None
if is_github_url(value):
return None
hosts = {"gitlab.com": TargetKind.GITLAB, "bitbucket.org": TargetKind.BITBUCKET}
configured = os.environ.get("SECFOO_GITLAB_HOST", "").strip().lower()
if configured:
try:
parsed_host = urlsplit("https://" + configured)
except ValueError:
raise TargetResolutionError("Invalid SECFOO_GITLAB_HOST") from None
if (parsed_host.netloc != configured or parsed_host.username or parsed_host.password
or parsed_host.path or parsed_host.query or parsed_host.fragment
or not parsed_host.hostname):
raise TargetResolutionError("SECFOO_GITLAB_HOST must be a hostname with an optional port")
try:
_ = parsed_host.port
except ValueError:
raise TargetResolutionError("Invalid SECFOO_GITLAB_HOST port") from None
if parsed_host.hostname in {"github.com", "bitbucket.org"}:
raise TargetResolutionError("SECFOO_GITLAB_HOST conflicts with another forge")
hosts[configured] = TargetKind.GITLAB
if value.startswith("git@"):
match = re.fullmatch(r"git@([^/:]+):(.+)", value)
if not match:
return None
authority, path = match.groups()
kind = hosts.get(authority.lower())
else:
try:
parsed = urlsplit(value)
if parsed.scheme.lower() not in {"https", "http"}:
return None
if parsed.username is not None or parsed.password is not None:
raise TargetResolutionError("Repository URLs must not contain credentials; use environment tokens")
authority, path = parsed.netloc.lower(), parsed.path.lstrip("/")
kind = hosts.get(authority)
if not kind:
return None
if parsed.scheme.lower() != "https" or parsed.query or parsed.fragment:
raise TargetResolutionError("GitLab and Bitbucket targets require HTTPS without query or fragment")
except ValueError:
raise TargetResolutionError("Invalid repository URL") from None
if kind:
parts = path.removesuffix(".git").split("/")
if (len(parts) < 2 or (kind is TargetKind.BITBUCKET and len(parts) != 2)
or any(not re.fullmatch(r"[A-Za-z0-9_.-]+", part) or part in {".", ".."}
for part in parts)):
raise TargetResolutionError("Repository URL must identify a namespace and repository")
return kind


def resolve_target(target: str | None, *, cwd: Path | None = None) -> ResolvedTarget:
cwd = cwd or Path.cwd()

Expand All @@ -37,9 +101,15 @@ def resolve_target(target: str | None, *, cwd: Path | None = None) -> ResolvedTa
return ResolvedTarget(TargetKind.LOCAL, resolved, str(resolved), False)

target = target.strip()
kind = forge_kind(target)
if kind:
return ResolvedTarget(kind, None, target, True)
if is_github_url(target):
return ResolvedTarget(TargetKind.GITHUB, None, target, True)

if looks_like_url(target) or target.startswith(("git@", "ssh://")):
raise TargetResolutionError("Unsupported repository URL")

path = Path(target).expanduser().resolve()
if not path.is_dir():
raise TargetResolutionError(f"Not a directory: {path}")
Expand Down
145 changes: 145 additions & 0 deletions tests/test_targets/test_forge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from __future__ import annotations

import shlex
import subprocess
from pathlib import Path

import pytest

from secfoo.runner import _project_display_name, _target_workdir
from secfoo.targets.forge import clone_forge
from secfoo.targets.resolver import TargetKind, TargetResolutionError, resolve_target


@pytest.mark.parametrize('url,kind', [
('https://gitlab.com/group/nested/repo.git', TargetKind.GITLAB),
('https://GITLAB.com/group/repo', TargetKind.GITLAB),
('git@gitlab.com:group/nested/repo.git', TargetKind.GITLAB),
('https://bitbucket.org/team/repo.git', TargetKind.BITBUCKET),
('git@bitbucket.org:team/repo', TargetKind.BITBUCKET),
])
def test_forge_targets(url, kind):
target = resolve_target(url)
assert target.kind is kind
assert target.path is None
assert target.is_temporary
assert target.display_name == url
assert _project_display_name(target) in {'group/nested/repo', 'group/repo', 'team/repo'}


def test_self_hosted(monkeypatch):
monkeypatch.setenv('SECFOO_GITLAB_HOST', 'git.example.org:8443')
target = resolve_target('https://git.example.org:8443/group/sub/repo')
assert target.kind is TargetKind.GITLAB
with pytest.raises(TargetResolutionError):
resolve_target('https://git.example.org/group/repo')


@pytest.mark.parametrize('host', ['https://git.example.org', 'user:secret@git.example.org',
'git.example.org/path', 'git.example.org:invalid', 'bitbucket.org'])
def test_bad_host_config(monkeypatch, host):
monkeypatch.setenv('SECFOO_GITLAB_HOST', host)
with pytest.raises(TargetResolutionError):
resolve_target('https://gitlab.com/group/repo')


@pytest.mark.parametrize('url', [
'https://gitlab.com/group', 'https://bitbucket.org/a/b/c',
'https://gitlab.com/a/../b', 'https://gitlab.com/a//b',
'https://gitlab.com/a/b?token=synthetic-secret',
'https://user:synthetic-secret@gitlab.com/a/b',
'https://user:synthetic-secret@github.com/a/b',
'https://gitlab.com.evil.org/a/b', 'http://gitlab.com/a/b',
'ssh://git@gitlab.com/a/b', 'https://gitlab.com/a/b#synthetic-secret',
])
def test_reject_invalid_url_without_leaking_input(url):
with pytest.raises(TargetResolutionError) as exc:
resolve_target(url)
assert 'synthetic-secret' not in str(exc.value)


@pytest.mark.parametrize('kind,url,var,username', [
(TargetKind.GITLAB, 'https://gitlab.com/a/b', 'SECFOO_GITLAB_TOKEN', 'oauth2'),
(TargetKind.BITBUCKET, 'https://bitbucket.org/a/b', 'SECFOO_BITBUCKET_TOKEN', 'x-token-auth'),
])
def test_authenticated_clone_and_runner_dispatch(monkeypatch, kind, url, var, username):
monkeypatch.setenv(var, 'synthetic-secret')
captured = {}
real_run = subprocess.run

def fake_run(cmd, **kwargs):
captured['cmd'] = cmd
captured['root'] = Path(cmd[-1]).parent
env = kwargs['env']
assert 'synthetic-secret' not in repr(cmd)
assert kwargs['timeout'] == 600
assert env['SECFOO_CLONE_TOKEN'] == 'synthetic-secret'
assert env['SECFOO_CLONE_USER'] == username
assert var not in env
assert env['GIT_TERMINAL_PROMPT'] == '0'
helper = Path(shlex.split(env['GIT_ASKPASS'])[1])
assert 'synthetic-secret' not in helper.read_text()
assert 'synthetic-secret' not in (helper.parent / 'askpass.py').read_text()
for prompt, expected in [('Username for https://example.org', username),
('Password for https://example.org', 'synthetic-secret')]:
result = real_run(shlex.split(env['GIT_ASKPASS']) + [prompt], env=env,
check=True, capture_output=True, text=True)
assert result.stdout.strip() == expected
Path(cmd[-1]).mkdir()
return subprocess.CompletedProcess(cmd, 0)

monkeypatch.setattr('secfoo.targets.forge.subprocess.run', fake_run)
with _target_workdir(resolve_target(url)) as checkout:
assert checkout.exists()
assert not (checkout.parent / 'askpass.py').exists()
assert captured['cmd'] == ['git', '-c', 'credential.helper=', '-c', 'http.followRedirects=false', 'clone', '--depth', '1', '--', url, str(checkout)]
assert not captured['root'].exists()


@pytest.mark.parametrize('error', ['failure', 'timeout', 'missing', 'body'])
def test_cleanup_and_safe_errors(monkeypatch, error):
root = []

def fake_run(cmd, **kwargs):
root.append(Path(cmd[-1]).parent)
if error == 'failure':
raise subprocess.CalledProcessError(128, cmd, stderr='synthetic-secret')
if error == 'timeout':
raise subprocess.TimeoutExpired(cmd, 5, stderr='synthetic-secret')
if error == 'missing':
raise FileNotFoundError('synthetic-secret')
Path(cmd[-1]).mkdir()
return subprocess.CompletedProcess(cmd, 0)

monkeypatch.setenv('SECFOO_GITLAB_TOKEN', 'synthetic-secret')
monkeypatch.setattr('secfoo.targets.forge.subprocess.run', fake_run)
with (
pytest.raises((TargetResolutionError, RuntimeError)) as exc,
clone_forge('https://gitlab.com/a/b', kind=TargetKind.GITLAB),
):
raise RuntimeError('body failure')
assert 'synthetic-secret' not in str(exc.value)
assert not root[0].exists()


@pytest.mark.parametrize('url', ['https://gitlab.com/a/b', 'git@gitlab.com:a/b'])
def test_no_token_uses_ordinary_git_auth(monkeypatch, url):
monkeypatch.delenv('SECFOO_GITLAB_TOKEN', raising=False)
commands = []

def fake_run(cmd, **kwargs):
commands.append(cmd)
Path(cmd[-1]).mkdir()
return subprocess.CompletedProcess(cmd, 0)

monkeypatch.setattr('secfoo.targets.forge.subprocess.run', fake_run)
with clone_forge(url, kind=TargetKind.GITLAB) as checkout:
assert commands == [['git', 'clone', '--depth', '1', '--', url, str(checkout)]]


def test_invalid_gitlab_configuration_keeps_local_and_github_working(monkeypatch, tmp_path):
monkeypatch.setenv('SECFOO_GITLAB_HOST', '[invalid')
assert resolve_target(str(tmp_path)).kind is TargetKind.LOCAL
assert resolve_target('https://github.com/org/repo').kind is TargetKind.GITHUB
with pytest.raises(TargetResolutionError):
resolve_target('https://gitlab.com/group/repo')
Loading