Skip to content
Open
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
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,24 @@ directory after `//`), the action tries, in order:

1. `.github/python-audit/<workflow-org>/<filename>` (org-specific)
2. `.github/python-audit/<filename>` (host-wide family default)

The first file that exists wins. If neither exists, the action
proceeds with `ignore_vulns` alone (soft, no warning) — consistent
with the default-URL behaviour above. An **explicit** path that is
missing is always a hard error.
3. `.github/python-audit/<org>/<filename>` when a **single** org
directory in the fetched tree carries the file (sole-org fallback)

The first file that exists wins. The sole-org fallback covers forks:
a fork's `.github` repository carries the upstream org's file at the
pinned ref, but the workflow org resolves to the fork owner, which
prevents the org-specific candidate from matching there. With a
single org directory present the choice is unambiguous and the
ref-pinned content is byte-identical to upstream; two or more org
directories keep the miss (the choice would be ambiguous). Explicit
paths never fall back.

If no candidate exists, the action
proceeds with `ignore_vulns` alone (soft) — consistent with the
default-URL behaviour above. When the sole-org fallback finds the
file under two or more org directories it notes the ambiguity on
stderr, but the miss stays soft in the same way. An **explicit**
path that is missing is always a hard error.

#### `config` examples

Expand Down
90 changes: 88 additions & 2 deletions src/resolve_config_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ def resolve(

subpath = parts["subpath"]
path_explicit = False
fallback_dir = ""
fallback_filename = ""

if not parts["has_subpath"] or subpath == "":
# No subpath, or a bare '//': default dir search chain + default file.
Expand All @@ -217,13 +219,17 @@ def resolve(
f"{default_dir_specific}/{filename}",
f"{default_dir_family}/{filename}",
]
fallback_dir = default_dir_family
fallback_filename = filename
elif "/" not in subpath:
# Filename only: override the filename, keep the default dir search.
filename = subpath
candidates = [
f"{default_dir_specific}/{filename}",
f"{default_dir_family}/{filename}",
]
fallback_dir = default_dir_family
fallback_filename = filename
else:
# Explicit directory present: use exactly this path, no search.
path_explicit = True
Expand Down Expand Up @@ -251,6 +257,8 @@ def resolve(
"ref": ref,
"candidates": candidates,
"path_explicit": path_explicit,
"fallback_dir": fallback_dir,
"fallback_filename": fallback_filename,
"comment": comment,
}

Expand All @@ -277,12 +285,25 @@ def fetch_file(
candidates: list[str],
token: str = "",
timeout: int = 30,
fallback_dir: str = "",
fallback_filename: str = "",
) -> dict:
"""Shallow-fetch `ref` and return the first existing candidate file.

Works uniformly for branches, tags and commit SHAs: GitHub honours
`git fetch --depth 1 <sha>` for reachable commits. Returns a dict with
found (bool), resolved_sha, matched_path, content (str | None).
found (bool), resolved_sha, matched_path, content (str | None) and
fallback_used (bool).

When every candidate misses and `fallback_dir`/`fallback_filename`
name an auto-derived search root, the fetched tree is scanned for
`<fallback_dir>/<org>/<fallback_filename>` entries. A single match
resolves as a "sole-org fallback": forks carry the upstream org's
file at the pinned ref but derive a different workflow org, so the
org-specific candidate can never match there even though exactly
one (byte-identical, ref-pinned) list exists in the tree. Zero or
several matches keep the miss (several would make the choice
ambiguous).
"""
url = f"https://github.com/{host_org}/{repo}.git"
with tempfile.TemporaryDirectory() as tmp:
Expand Down Expand Up @@ -366,13 +387,74 @@ def git(args: list[str]) -> subprocess.CompletedProcess:
"resolved_sha": resolved_sha,
"matched_path": cand,
"content": shown.stdout,
"fallback_used": False,
}

if fallback_dir and fallback_filename:
listed = git(["ls-tree", "-r", "--name-only", resolved_sha, fallback_dir])
if listed.returncode == 0:
matches = []
for line in listed.stdout.splitlines():
line = line.strip()
if not line.startswith(f"{fallback_dir}/"):
continue
rel = line[len(fallback_dir) + 1 :]
segs = rel.split("/")
# Exactly one org directory between the family dir
# and the filename, with a safe path segment (never
# '.' or '..', matching resolve()'s validation), and
# not a candidate we already tried above.
if (
len(segs) == 2
and segs[1] == fallback_filename
and segs[0] not in (".", "..")
and SEGMENT_RE.match(segs[0])
and line not in candidates
):
matches.append(line)
if len(matches) == 1:
cand = matches[0]
# Same blob guard as the candidate loop above: only
# accept regular files, not trees or gitlinks.
typ = git(["cat-file", "-t", f"{resolved_sha}:{cand}"])
if typ.returncode == 0 and typ.stdout.strip() == "blob":
size = git(["cat-file", "-s", f"{resolved_sha}:{cand}"])
try:
if (
size.returncode == 0
and int(size.stdout.strip()) > MAX_FILE_BYTES
):
raise ResolveError(
f"config file '{cand}' exceeds "
f"{MAX_FILE_BYTES}-byte limit"
)
except ValueError:
pass
shown = git(["show", f"{resolved_sha}:{cand}"])
if shown.returncode == 0:
return {
"found": True,
"resolved_sha": resolved_sha,
"matched_path": cand,
"content": shown.stdout,
"fallback_used": True,
}
elif len(matches) > 1:
print(
"sole-org fallback skipped: files named "
f"'{fallback_filename}' exist under more than one "
f"org directory in '{fallback_dir}/' "
f"({len(matches)} matches); the choice would be "
"ambiguous",
file=sys.stderr,
)

return {
"found": False,
"resolved_sha": resolved_sha,
"matched_path": "",
"content": None,
"fallback_used": False,
}


Expand Down Expand Up @@ -561,13 +643,17 @@ def main(argv: list[str] | None = None) -> int:
candidates=resolved["candidates"],
token=token,
timeout=args.timeout,
fallback_dir=resolved["fallback_dir"],
fallback_filename=resolved["fallback_filename"],
)

tokens: list[str] = []
matched_candidate = ""
if fetched["found"]:
tokens = sanitise(fetched["content"], args.mode)
if fetched["matched_path"] == resolved["candidates"][0]:
if fetched["fallback_used"]:
matched_candidate = "sole-org-fallback"
elif fetched["matched_path"] == resolved["candidates"][0]:
matched_candidate = (
"explicit" if resolved["path_explicit"] else "org-specific"
)
Expand Down
143 changes: 143 additions & 0 deletions tests/test_resolve_config_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ def cp(rc, out=""):
if sub[:1] == ["show"]:
path = sub[1].split(":", 1)[1]
return cp(0, files[path])
if sub[:1] == ["ls-tree"]:
prefix = sub[-1]
listed = [
p for p in sorted(files) if p == prefix or p.startswith(prefix + "/")
]
return cp(0, "".join(f"{p}\n" for p in listed))
return cp(0)

monkeypatch.setattr(rcs, "_run_git", fake_run_git)
Expand Down Expand Up @@ -408,3 +414,140 @@ def test_fetch_file_directory_only_not_found(monkeypatch):
candidates=cands,
)
assert result["found"] is False


# ---------------------------------------------------------------------
# fetch_file: sole-org fallback (fork scenario)
# ---------------------------------------------------------------------


def test_fetch_file_sole_org_fallback(monkeypatch):
# Fork scenario: the workflow org derives fork-specific candidates,
# but the fetched tree carries exactly one (upstream) org's file.
cands = [
".github/harden-runner/fork-org/allow_list.txt",
".github/harden-runner/allow_list.txt",
]
upstream = ".github/harden-runner/lfreleng-actions/allow_list.txt"
_install_fake_git(monkeypatch, files={upstream: "github.com:443\n"})
result = rcs.fetch_file(
host_org="fork-org",
repo=".github",
ref="main",
candidates=cands,
fallback_dir=".github/harden-runner",
fallback_filename="allow_list.txt",
)
assert result["found"] is True
assert result["fallback_used"] is True
assert result["matched_path"] == upstream
assert result["content"] == "github.com:443\n"


def test_fetch_file_fallback_ambiguous_stays_missing(monkeypatch):
# Two org directories carry the file: the choice would be
# ambiguous, so the miss stands.
cands = [
".github/harden-runner/fork-org/allow_list.txt",
".github/harden-runner/allow_list.txt",
]
_install_fake_git(
monkeypatch,
files={
".github/harden-runner/org-a/allow_list.txt": "a.example:443\n",
".github/harden-runner/org-b/allow_list.txt": "b.example:443\n",
},
)
result = rcs.fetch_file(
host_org="fork-org",
repo=".github",
ref="main",
candidates=cands,
fallback_dir=".github/harden-runner",
fallback_filename="allow_list.txt",
)
assert result["found"] is False
assert result["fallback_used"] is False


def test_fetch_file_fallback_ignores_other_filenames_and_depths(monkeypatch):
# Only `<dir>/<org>/<filename>` entries count: other filenames and
# deeper nesting are ignored, so a single valid entry still wins.
cands = [
".github/harden-runner/fork-org/allow_list.txt",
".github/harden-runner/allow_list.txt",
]
upstream = ".github/harden-runner/lfreleng-actions/allow_list.txt"
_install_fake_git(
monkeypatch,
files={
upstream: "github.com:443\n",
".github/harden-runner/org-a/other_list.txt": "x.example\n",
".github/harden-runner/org-b/nested/allow_list.txt": "y.example\n",
},
)
result = rcs.fetch_file(
host_org="fork-org",
repo=".github",
ref="main",
candidates=cands,
fallback_dir=".github/harden-runner",
fallback_filename="allow_list.txt",
)
assert result["found"] is True
assert result["fallback_used"] is True
assert result["matched_path"] == upstream


def test_fetch_file_fallback_rejects_non_blob(monkeypatch):
# A sole ls-tree match that is not a blob (e.g. a gitlink) must not
# resolve: the fallback applies the same blob guard as the
# candidate loop.
upstream = ".github/harden-runner/lfreleng-actions/allow_list.txt"
_install_fake_git(
monkeypatch,
files={upstream: "github.com:443\n"},
trees=[upstream],
)
result = rcs.fetch_file(
host_org="fork-org",
repo=".github",
ref="main",
candidates=[
".github/harden-runner/fork-org/allow_list.txt",
".github/harden-runner/allow_list.txt",
],
fallback_dir=".github/harden-runner",
fallback_filename="allow_list.txt",
)
assert result["found"] is False
assert result["fallback_used"] is False


def test_fetch_file_no_fallback_without_dir(monkeypatch):
# Explicit-path mode passes no fallback coordinates: a miss stays a
# miss even when an org file exists in the tree.
_install_fake_git(
monkeypatch,
files={".github/harden-runner/lfreleng-actions/allow_list.txt": "h:443\n"},
)
result = rcs.fetch_file(
host_org="fork-org",
repo=".github",
ref="main",
candidates=["custom/dir/allow_list.txt"],
)
assert result["found"] is False
assert result["fallback_used"] is False


def test_resolve_exposes_fallback_coordinates():
resolved = _resolve("@deadbeef" + "0" * 32, org="fork-org")
assert resolved["fallback_dir"] == ".github/python-audit"
assert resolved["fallback_filename"] == "allow_list.txt"


def test_resolve_explicit_path_has_no_fallback():
resolved = _resolve("lfit//configs/onap/list.txt@main")
assert resolved["fallback_dir"] == ""
assert resolved["fallback_filename"] == ""
Loading