Skip to content
Merged
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ 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.
- A failed `changed_files` diff resolution is no longer indistinguishable from
an empty diff. Git errors are captured and logged (instead of discarded), and
when the scope cannot be resolved — unreadable repository, unresolvable base
ref (e.g. a shallow fetch without the base), or `pr` mode with no base ref —
Socket Basics now **falls back to a full-repo scan with a prominent warning**
rather than skipping every scanner and reporting a green run that scanned
nothing. A genuinely empty diff (e.g. a delete-only PR) still keeps the empty
scope and skips as before.

### Added
- The resolved `changed_files` scope is now logged on every scoped run: file
count at INFO, the full file list at DEBUG — so an empty diff and a failed
lookup are visible and distinguishable in run logs.

## [3.0.0] - 2026-08-06

Major release: Trivy-backed scanning returns, now built and published through
Expand Down
234 changes: 171 additions & 63 deletions socket_basics/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1568,39 +1568,55 @@ def create_config_from_args(args) -> Config:
if changed_files_arg:
val = str(changed_files_arg).strip()
config_dict['changed_files_scope_requested'] = True
# 'auto' resolves to the PR base-ref diff in CI, else staged changes.
if val.lower() == 'auto':
_scope_log = logging.getLogger(__name__)

def _apply_scoped_changed_files(mode_label: str, **detect_kwargs) -> None:
"""Resolve the diff scope, distinguishing failure from an empty diff.

A failed resolution (None) falls back to a full-repo scan with a
prominent warning: a scoped scan that silently resolves to nothing
reports a green run while scanning zero files, which is the
fail-open failure mode this guards against. A genuinely empty diff
(e.g. a delete-only PR) keeps the empty scope and skips, as before.
"""
try:
git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='auto')
config_dict['changed_files'] = git_changed
resolved = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), **detect_kwargs)
except Exception as e:
logging.getLogger(__name__).warning("Warning: failed to detect git changed files (auto): %s", e)
_scope_log.warning("Warning: failed to detect git changed files (%s): %s", mode_label, e)
resolved = None
if resolved is None:
_scope_log.warning(
"changed_files scope could not be resolved (%s); falling back to a "
"full-repo scan so nothing is silently skipped. See the warnings "
"above for the underlying git error.",
mode_label,
)
config_dict['changed_files'] = []
config_dict['changed_files_scope_requested'] = False
return
_scope_log.info("changed_files scope resolved to %d file(s) (%s)", len(resolved), mode_label)
if resolved:
_scope_log.debug("changed_files scope: %s", ", ".join(resolved))
else:
_scope_log.info(
"changed_files diff is genuinely empty (e.g. delete-only change); "
"scoped scanners will be skipped"
)
config_dict['changed_files'] = resolved

# 'auto' resolves to the PR base-ref diff in CI, else staged changes.
if val.lower() == 'auto':
_apply_scoped_changed_files('auto', mode='auto')
elif val.lower() == 'pr':
# Explicit PR diff against the base branch (GITHUB_BASE_REF).
try:
git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='pr')
config_dict['changed_files'] = git_changed
except Exception as e:
logging.getLogger(__name__).warning("Warning: failed to detect git changed files (pr): %s", e)
config_dict['changed_files'] = []
_apply_scoped_changed_files('pr', mode='pr')
elif val.lower() in ('current-commit', 'current_commit'):
try:
git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='current-commit')
config_dict['changed_files'] = git_changed
except Exception as e:
logging.getLogger(__name__).warning("Warning: failed to detect git changed files (current-commit): %s", e)
config_dict['changed_files'] = []
_apply_scoped_changed_files('current-commit', mode='current-commit')
else:
# If value looks like a commit hash, list files in that commit
import re
if re.match(r'^[0-9a-fA-F]{7,40}$', val):
try:
git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='commit', commit=val)
config_dict['changed_files'] = git_changed
except Exception as e:
logging.getLogger(__name__).warning("Warning: failed to detect git changed files (commit %s): %s", val, e)
config_dict['changed_files'] = []
_apply_scoped_changed_files(f'commit {val}', mode='commit', commit=val)
else:
# parse comma-separated list of files provided manually
config_dict['changed_files'] = [f.strip() for f in val.split(',') if f.strip()]
Expand Down Expand Up @@ -1639,7 +1655,51 @@ def create_config_from_args(args) -> Config:
return Config(config_dict)


def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit: str | None = None, base_ref: str | None = None) -> List[str]:
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. The path is resolved to an absolute one
first because git ignores relative ``safe.directory`` values.
"""
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(Path(ws).resolve())
env['GIT_CONFIG_COUNT'] = str(count + 1)
return env


class _GitScopeError(Exception):
"""A git invocation needed for changed-files scoping failed outright.

Carries git's first stderr line as the message. ``ref_miss`` is True when
the failure only means "this ref does not exist" (safe to try another
candidate) rather than "git could not read the repository at all."
"""

def __init__(self, message: str, ref_miss: bool = False):
super().__init__(message)
self.ref_miss = ref_miss


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

mode:
Expand All @@ -1653,11 +1713,16 @@ def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit:
(``GITHUB_BASE_REF`` is set), otherwise staged changes.
This is what ``--changed-files auto`` resolves to.

Returns a list of file paths relative to the workspace root. If not a git
repo or detection fails, returns [].
Returns a list of file paths relative to the workspace root. An empty list
means git resolved the diff and it is genuinely empty (e.g. a delete-only
change), or the workspace is not a git repo (nothing to diff). Returns
``None`` when resolution *failed* — git could not read the repository, or a
requested base ref could not be resolved — so callers can distinguish "no
changed files" from "the lookup broke" instead of silently scanning
nothing. The specific git error is logged here at WARNING level.
"""
log = logging.getLogger(__name__)
try:
from subprocess import check_output, CalledProcessError
import subprocess

# Prefer GITHUB_WORKSPACE if set (GitHub Actions environment)
Expand All @@ -1675,35 +1740,61 @@ 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)

# stderr markers that mean "this ref does not exist" — a soft miss the
# base-ref candidate loop may retry — as opposed to git being unable
# to read the repository at all (ownership, corruption, ...).
ref_miss_markers = ('unknown revision', 'bad revision', 'ambiguous argument')

def _split(out: str) -> List[str]:
return [line.strip() for line in out.splitlines() if line.strip()]

def _run_git(args: List[str]) -> List[str]:
"""Run git, returning stdout lines; raise _GitScopeError on failure.

stderr is captured rather than discarded so the failure reason —
e.g. git's self-diagnosing ``dubious ownership`` message — survives
into the logs instead of being indistinguishable from an empty diff.
"""
res = subprocess.run(args, text=True, capture_output=True, env=git_env)
if res.returncode != 0:
stderr = (res.stderr or '').strip()
first = stderr.splitlines()[0] if stderr else f'exit code {res.returncode}'
miss = any(m in stderr.lower() for m in ref_miss_markers)
raise _GitScopeError(first, ref_miss=miss)
return _split(res.stdout)

# Change to workspace directory before running git commands
# This ensures git runs in the correct repository context
original_cwd = os.getcwd()
try:
os.chdir(str(ws))

def _split(out: str) -> List[str]:
return [line.strip() for line in out.splitlines() if line.strip()]

def _diff_against_base(ref: str) -> Optional[List[str]]:
"""Diff changed files (excluding deletions) against a base ref.

Tries the remote-tracking ref (``origin/<ref>``) first, then the
bare ref. Returns None when neither ref can be resolved so the
caller can fall back to another detection strategy. The
``--diff-filter=ACMR`` excludes deleted paths so they never
become scan targets.
bare ref. Returns None when neither candidate resolves (the ref
does not exist locally); raises _GitScopeError when git itself
cannot read the repository. The ``--diff-filter=ACMR`` excludes
deleted paths so they never become scan targets.
"""
if not ref:
return None
last_miss = ''
for candidate in (f'origin/{ref}', ref):
try:
out = check_output(
['git', 'diff', '--name-only', '--diff-filter=ACMR', f'{candidate}...HEAD'],
text=True, stderr=subprocess.DEVNULL,
)
return _split(out)
except CalledProcessError:
continue
return _run_git(['git', 'diff', '--name-only', '--diff-filter=ACMR', f'{candidate}...HEAD'])
except _GitScopeError as e:
if e.ref_miss:
last_miss = str(e)
continue
raise
log.warning("changed_files scope: base ref %r could not be resolved (%s)", ref, last_miss or 'no candidates tried')
return None

if mode == 'auto':
Expand All @@ -1713,32 +1804,44 @@ 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)
return _split(out)
if base:
# A base ref was provided (we are in a PR context) but could
# not be resolved (e.g. shallow fetch without the base). The
# staged-diff fallback would almost always be empty in CI —
# silently scanning nothing — so report failure instead.
return None
return _run_git(['git', 'diff', '--name-only', '--cached'])
elif mode == 'pr':
base = base_ref or os.environ.get('GITHUB_BASE_REF', '')
return _diff_against_base(base) or []
if not base:
log.warning("changed_files scope: mode 'pr' but no base ref available (GITHUB_BASE_REF unset)")
return None
return _diff_against_base(base)
elif mode == 'staged':
# staged but not yet committed
out = check_output(['git', 'diff', '--name-only', '--cached'], text=True, stderr=subprocess.DEVNULL)
return _split(out)
return _run_git(['git', 'diff', '--name-only', '--cached'])
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)
return _split(out)
return _run_git(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'])
elif mode == 'commit' and commit:
out = check_output(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit], text=True, stderr=subprocess.DEVNULL)
return _split(out)
return _run_git(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit])
else:
return []
finally:
# Always restore original working directory
os.chdir(original_cwd)

except CalledProcessError:
return []
except Exception:
return []
except _GitScopeError as e:
msg = str(e)
hint = ''
if 'dubious ownership' in msg.lower():
hint = (" — the checkout is owned by a different user; the workspace should be"
" marked safe.directory automatically as of this release, so please report this")
log.warning("changed_files scope: git failed: %s%s", msg, hint)
return None
except Exception as e:
log.warning("changed_files scope: unexpected error during git detection: %s", e)
return None


def discover_all_files(workspace_path: str, respect_gitignore: bool = True) -> List[str]:
Expand Down Expand Up @@ -1934,9 +2037,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 +2107,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 +2145,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 +2190,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 +2212,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
Loading