Skip to content
Merged
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed
- `changed_files` diff-only mode always resolved to zero files in the pre-built
Docker GitHub Action: the container runs as root while the checkout is owned
by the runner user, so git's ownership check refused every diff lookup, the
scope silently resolved to nothing, and the scanners skipped with a green
run. Git subprocesses now mark the scan workspace as `safe.directory` via
command-scope `GIT_CONFIG_*` environment entries. No config files are
touched, and caller-provided `GIT_CONFIG_*` entries (including the previously
documented workaround) are preserved. The same mismatch broke git-based
repository/branch/commit and default-branch discovery in local Docker runs;
those lookups are covered by the same change.

## [3.0.0] - 2026-08-06

Major release: Trivy-backed scanning returns, now built and published through
Expand Down
68 changes: 54 additions & 14 deletions socket_basics/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1639,6 +1639,36 @@ def create_config_from_args(args) -> Config:
return Config(config_dict)


def _git_env(workspace_path: str | Path | None = None) -> Dict[str, str]:
"""Environment for git subprocesses that marks the scan workspace safe to read.

The pre-built GitHub Action runs as root inside a Docker container while the
checkout at ``GITHUB_WORKSPACE`` is owned by the runner user, so git's
ownership check (git 2.35.2+) refuses the repository and every git lookup
here fails. ``changed_files`` diff-only mode then resolves to zero files and
the scanners silently skip. ``actions/checkout`` cannot help: its
``safe.directory`` entry is written to the runner's global config, which is
not mounted into container actions.

The workspace is an explicit scan target, not an incidentally discovered
repository, so mark it safe for these subprocesses only. Injecting via
``GIT_CONFIG_*`` (command-scope config, honored for ``safe.directory`` since
git 2.38; the bundled image ships newer) touches no config files, and
appending after any caller-provided ``GIT_CONFIG_*`` entries preserves
workarounds users already deployed.
"""
env = dict(os.environ)
try:
count = max(0, int(env.get('GIT_CONFIG_COUNT', '0') or '0'))
except ValueError:
count = 0
ws = workspace_path or os.environ.get('GITHUB_WORKSPACE') or os.getcwd()
env[f'GIT_CONFIG_KEY_{count}'] = 'safe.directory'
env[f'GIT_CONFIG_VALUE_{count}'] = str(ws)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
env['GIT_CONFIG_COUNT'] = str(count + 1)
return env


def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit: str | None = None, base_ref: str | None = None) -> List[str]:
"""Detect changed files in a git repository.

Expand Down Expand Up @@ -1675,6 +1705,11 @@ def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit:
if not git_dir.exists():
return []

# Mark the workspace safe for the git subprocesses below; without this
# every command fails under the container-action ownership mismatch and
# the diff silently resolves to nothing.
git_env = _git_env(ws)

# Change to workspace directory before running git commands
# This ensures git runs in the correct repository context
original_cwd = os.getcwd()
Expand All @@ -1699,7 +1734,7 @@ def _diff_against_base(ref: str) -> Optional[List[str]]:
try:
out = check_output(
['git', 'diff', '--name-only', '--diff-filter=ACMR', f'{candidate}...HEAD'],
text=True, stderr=subprocess.DEVNULL,
text=True, stderr=subprocess.DEVNULL, env=git_env,
)
return _split(out)
except CalledProcessError:
Expand All @@ -1713,21 +1748,21 @@ def _diff_against_base(ref: str) -> Optional[List[str]]:
pr_files = _diff_against_base(base)
if pr_files is not None:
return pr_files
out = check_output(['git', 'diff', '--name-only', '--cached'], text=True, stderr=subprocess.DEVNULL)
out = check_output(['git', 'diff', '--name-only', '--cached'], text=True, stderr=subprocess.DEVNULL, env=git_env)
return _split(out)
elif mode == 'pr':
base = base_ref or os.environ.get('GITHUB_BASE_REF', '')
return _diff_against_base(base) or []
elif mode == 'staged':
# staged but not yet committed
out = check_output(['git', 'diff', '--name-only', '--cached'], text=True, stderr=subprocess.DEVNULL)
out = check_output(['git', 'diff', '--name-only', '--cached'], text=True, stderr=subprocess.DEVNULL, env=git_env)
return _split(out)
elif mode == 'current-commit':
# files that are part of HEAD commit
out = check_output(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], text=True, stderr=subprocess.DEVNULL)
out = check_output(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], text=True, stderr=subprocess.DEVNULL, env=git_env)
return _split(out)
elif mode == 'commit' and commit:
out = check_output(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit], text=True, stderr=subprocess.DEVNULL)
out = check_output(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit], text=True, stderr=subprocess.DEVNULL, env=git_env)
return _split(out)
else:
return []
Expand Down Expand Up @@ -1934,9 +1969,10 @@ def _discover_repository(cli_repo: str | None, github_repository: str = '', gith
# 4. Git information
try:
url = subprocess.check_output(
['git', 'config', '--get', 'remote.origin.url'],
text=True,
stderr=subprocess.DEVNULL
['git', 'config', '--get', 'remote.origin.url'],
text=True,
stderr=subprocess.DEVNULL,
env=_git_env()
).strip()

if url.endswith('.git'):
Expand Down Expand Up @@ -2003,9 +2039,10 @@ def _discover_branch(cli_branch: str | None, github_head_ref: str = '', github_r
# 4. Git information
try:
branch = subprocess.check_output(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
text=True,
stderr=subprocess.DEVNULL
stderr=subprocess.DEVNULL,
env=_git_env()
).strip()

if branch and branch != 'HEAD':
Expand Down Expand Up @@ -2040,9 +2077,10 @@ def _discover_commit_hash() -> str:
# 2. Git information
try:
commit = subprocess.check_output(
['git', 'rev-parse', '--short', 'HEAD'],
['git', 'rev-parse', '--short', 'HEAD'],
text=True,
stderr=subprocess.DEVNULL
stderr=subprocess.DEVNULL,
env=_git_env()
).strip()

if commit:
Expand Down Expand Up @@ -2084,7 +2122,8 @@ def _discover_is_default_branch(current_branch: str, workspace_path: str = '') -
['git', 'symbolic-ref', 'refs/remotes/origin/HEAD'],
text=True,
stderr=subprocess.DEVNULL,
cwd=cwd
cwd=cwd,
env=_git_env(workspace_path)
).strip()

# Extract branch name from refs/remotes/origin/branch-name
Expand All @@ -2105,7 +2144,8 @@ def _discover_is_default_branch(current_branch: str, workspace_path: str = '') -
['git', 'ls-remote', '--symref', 'origin', 'HEAD'],
text=True,
stderr=subprocess.DEVNULL,
cwd=cwd
cwd=cwd,
env=_git_env(workspace_path)
).strip()

# Parse the output: "ref: refs/heads/main\tHEAD"
Expand Down
94 changes: 93 additions & 1 deletion tests/test_changed_files_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@

import pytest

from socket_basics.core.config import Config, _detect_git_changed_files, create_config_from_args
from socket_basics.core.config import (
Config,
_detect_git_changed_files,
_discover_repository,
_git_env,
create_config_from_args,
)


def _make_config(workspace, **overrides):
Expand Down Expand Up @@ -170,3 +176,89 @@ def test_delete_only_pr_config_creation_keeps_empty_scope(self, tmp_path, monkey

assert cfg.get("changed_files") == []
assert cfg.get_scan_targets() == []


def _git_refuses_repo(repo):
"""True when git, told to assume a different owner, refuses to read `repo`.

``GIT_TEST_ASSUME_DIFFERENT_OWNER`` is git's own test knob for the
ownership check behind ``safe.directory``; it makes every repository look
like it belongs to another user, which is exactly what a checkout looks
like from inside the container action. Used as a control so these tests
skip (instead of passing vacuously) on a git build without the knob.
"""
probe = subprocess.run(
["git", "-C", str(repo), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
env={**os.environ, "GIT_TEST_ASSUME_DIFFERENT_OWNER": "1"},
)
return probe.returncode != 0


class TestDubiousOwnership:
"""Git subprocesses must survive the container-action ownership mismatch.

The pre-built Docker action runs as root while the checkout is owned by
the runner user; without ``safe.directory`` git refuses the repo, the diff
resolves to zero files, and the scanners silently skip.
"""

def test_pr_diff_survives_dubious_ownership(self, pr_repo, monkeypatch):
for i in range(3):
monkeypatch.delenv(f"GIT_CONFIG_KEY_{i}", raising=False)
monkeypatch.delenv(f"GIT_CONFIG_VALUE_{i}", raising=False)
monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False)

if not _git_refuses_repo(pr_repo):
pytest.skip("this git build does not honor GIT_TEST_ASSUME_DIFFERENT_OWNER")

monkeypatch.setenv("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1")
files = _detect_git_changed_files(str(pr_repo), mode="pr", base_ref="main")
assert sorted(files) == ["base.py", "feat.py"]

def test_repo_discovery_survives_dubious_ownership(self, pr_repo, monkeypatch):
_git(pr_repo, "remote", "add", "origin", "https://github.com/acme/demo.git")

if not _git_refuses_repo(pr_repo):
pytest.skip("this git build does not honor GIT_TEST_ASSUME_DIFFERENT_OWNER")

monkeypatch.chdir(pr_repo)
monkeypatch.setenv("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1")
assert _discover_repository(None, "", "") == "acme/demo"


class TestGitEnv:
"""_git_env injects safe.directory without clobbering caller config."""

def test_injects_safe_directory_for_workspace(self, monkeypatch):
monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False)
env = _git_env("/scan/me")
assert env["GIT_CONFIG_COUNT"] == "1"
assert env["GIT_CONFIG_KEY_0"] == "safe.directory"
assert env["GIT_CONFIG_VALUE_0"] == "/scan/me"

def test_appends_after_caller_provided_entries(self, monkeypatch):
# A user already deploying the documented env-var workaround must not
# have their entry clobbered.
monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
monkeypatch.setenv("GIT_CONFIG_KEY_0", "user.name")
monkeypatch.setenv("GIT_CONFIG_VALUE_0", "runner")
env = _git_env("/scan/me")
assert env["GIT_CONFIG_COUNT"] == "2"
assert env["GIT_CONFIG_KEY_0"] == "user.name"
assert env["GIT_CONFIG_VALUE_0"] == "runner"
assert env["GIT_CONFIG_KEY_1"] == "safe.directory"
assert env["GIT_CONFIG_VALUE_1"] == "/scan/me"

def test_garbage_count_treated_as_zero(self, monkeypatch):
monkeypatch.setenv("GIT_CONFIG_COUNT", "not-a-number")
env = _git_env("/scan/me")
assert env["GIT_CONFIG_COUNT"] == "1"
assert env["GIT_CONFIG_KEY_0"] == "safe.directory"

def test_defaults_to_github_workspace(self, monkeypatch):
monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False)
monkeypatch.setenv("GITHUB_WORKSPACE", "/github/workspace")
env = _git_env()
assert env["GIT_CONFIG_VALUE_0"] == "/github/workspace"