diff --git a/README.md b/README.md index 63d8fb0..0130924 100644 --- a/README.md +++ b/README.md @@ -272,11 +272,24 @@ directory after `//`), the action tries, in order: 1. `.github/python-audit//` (org-specific) 2. `.github/python-audit/` (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//` 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 diff --git a/src/resolve_config_source.py b/src/resolve_config_source.py index 13fbc5d..ec308f2 100644 --- a/src/resolve_config_source.py +++ b/src/resolve_config_source.py @@ -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. @@ -217,6 +219,8 @@ 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 @@ -224,6 +228,8 @@ def resolve( 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 @@ -251,6 +257,8 @@ def resolve( "ref": ref, "candidates": candidates, "path_explicit": path_explicit, + "fallback_dir": fallback_dir, + "fallback_filename": fallback_filename, "comment": comment, } @@ -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 ` 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 + `//` 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: @@ -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, } @@ -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" ) diff --git a/tests/test_resolve_config_source.py b/tests/test_resolve_config_source.py index f84c3eb..3e264b0 100644 --- a/tests/test_resolve_config_source.py +++ b/tests/test_resolve_config_source.py @@ -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) @@ -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 `//` 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"] == ""