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
61 changes: 61 additions & 0 deletions docs/repo-sentinel-baseline-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,67 @@ execution, PR-ref acquisition, and workflow activation remain unwired. Rollback
restores the prior reader/materializer contract; there is no persisted output
format or repository-setting migration.

### Exact Pull-Head Acquisition

`acquire_pull_snapshot(remote, pull_number, expected_head_oid, scratch_root)`
creates a fresh bare object database below a caller-owned scratch directory. It
fetches only `refs/pull/<number>/head` into one private ref, requires that ref to
equal the expected full lowercase SHA-1 or SHA-256 OID, and calls the existing
bounded reader before yielding `AcquiredSnapshot`. No checkout is created.

Network remotes are restricted to credential-free HTTPS URLs without query or
fragment data. A `Path` remote exists only for caller-controlled local fixtures.
Git runs with inherited `GIT_*` variables and global/system configuration
removed. Terminal prompting is disabled, inherited Git/SSH askpass helpers are
neutralized, and `SSH_ASKPASS_REQUIRE` cannot force a parent helper. Replacement
lookup and redirects are disabled, protocol selection is restricted, and
automatic maintenance is disabled.
The fetch is depth one, writes no `FETCH_HEAD`, imports no tags or submodules,
and uses an explicit force refspec into the fresh database.

The only resulting ref must be `refs/repo-sentinel/acquired-head` at the exact
expected OID. A moved or missing PR ref, an unexpected ref set, an object-format
mismatch, fetch failure, or reader refusal cannot yield a snapshot. The reader
then independently validates raw commit, tree and blob identities and admission
limits; acquisition does not replace those checks.

The default acquisition timeout is 60 seconds for initialization, fetch and ref
verification. The reader retains its separate timeout. A 64 MiB repository-size
check runs after fetch; it makes an over-budget result fail closed but is not a
hard transport or peak-disk quota because Git may exceed it before the fetch
returns. A trusted Git executable and enough scratch capacity for that interval
remain preconditions.

Normal exit, setup refusal and consumer exceptions remove the fresh database.
Cleanup failure is explicit and may leave residual files for the scratch owner.
Tests cover SHA-1/SHA-256 acquisition, exact raw bytes, unrequested refs, ref
movement, missing refs, input validation, reader refusal, timeout, repository
budget, symlinked scratch input and lifecycle cleanup.

Before the path-contract merge, a read-only HTTPS probe acquired the exact PR
#15 head `2f7b7a9bef43715141086b0d79bacbe67a178288` and tree
`960dc6c6496f1260f6fab74b64f26408024cd5fb`, then the reader refused
`unsupported_path`. Eight of that public tree's 242 regular-file paths were
outside the former portable ASCII reader subset. That historical result remains
evidence that acquisition propagated downstream reader refusal without
rewriting names, omitting files or yielding a partial snapshot.

After the path contract merged in PR #17 at
`d8e30ba019247a21b9d42e1c1d52900a1f1de623`, the same exact-head probe traversed
acquisition, the logical-path reader and the portable materializer. All 242
paths, modes and blob OIDs were preserved with exact file bytes, totalling
2,260,062 bytes. The SHA-256 manifest over each ordered
`path NUL mode NUL oid NUL sha256(data)` record was
`ad39acf89d0826ce2651ed14d12e143d7f0a8b5cb6b2eb219b46895688f45d0a`.
Both the bare acquisition database and materialized target were removed after
their contexts exited. Repository content was handled only as data and was not
executed; acquisition remained checkout-free.

This helper is not connected to a workflow or scanner invocation. It does not
add `pull_request_target`, secrets, caches, Check API writes, permissions or
repository enforcement. Rollback removes the acquisition helper/tests/docs;
the merged reader and materializer remain independently usable.

## Relationship To Issue #5

This record closed [issue #5](https://github.com/stacknil/sec-writeups-public/issues/5)
Expand Down
249 changes: 249 additions & 0 deletions scripts/repo_sentinel_acquire.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
"""Acquire one pull-request head as data in a fresh verifier-owned Git database."""

from __future__ import annotations

import math
import os
import re
import stat
import subprocess
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.parse import urlsplit

from repo_sentinel_reader import ReaderLimits, Snapshot, read_snapshot


class AcquisitionRefused(ValueError):
"""Acquisition failed; the message is a fixed code without remote or path data."""


@dataclass(frozen=True)
class AcquisitionLimits:
timeout_seconds: float = 60.0
max_repository_bytes: int = 64 * 1024 * 1024

def __post_init__(self) -> None:
if (
type(self.timeout_seconds) not in (int, float)
or not math.isfinite(self.timeout_seconds)
or self.timeout_seconds <= 0
or type(self.max_repository_bytes) is not int
or self.max_repository_bytes <= 0
):
raise AcquisitionRefused("invalid_limits")


@dataclass(frozen=True)
class AcquiredSnapshot:
snapshot: Snapshot
source_ref: str
repository: Path = field(repr=False)


def _regular_directory(path: Path) -> None:
info = path.lstat()
reparse = getattr(info, "st_file_attributes", 0) & getattr(
stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0
)
if reparse or not stat.S_ISDIR(info.st_mode):
raise AcquisitionRefused("unsafe_scratch_root")


def _remote_argument(remote: str | Path) -> str:
if isinstance(remote, Path):
try:
resolved = remote.resolve(strict=True)
except OSError:
raise AcquisitionRefused("invalid_remote") from None
if not resolved.is_dir():
raise AcquisitionRefused("invalid_remote")
return resolved.as_uri()
if type(remote) is not str or re.search(r"[\x00-\x20\x7f]", remote):
raise AcquisitionRefused("invalid_remote")
try:
parsed = urlsplit(remote)
except ValueError:
raise AcquisitionRefused("invalid_remote") from None
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise AcquisitionRefused("invalid_remote")
return remote


def _environment() -> dict[str, str]:
environment = {
key: value
for key, value in os.environ.items()
if not key.upper().startswith("GIT_")
and key.upper() != "SSH_ASKPASS_REQUIRE"
}
environment.update(
GIT_CONFIG_NOSYSTEM="1",
GIT_CONFIG_GLOBAL=os.devnull,
GIT_TERMINAL_PROMPT="0",
GIT_ASKPASS="",
SSH_ASKPASS="",
GIT_NO_LAZY_FETCH="1",
)
return environment


def _git_arguments(*arguments: str) -> list[str]:
return [
"git",
"--no-replace-objects",
"-c", "protocol.allow=never",
"-c", "protocol.https.allow=always",
"-c", "protocol.file.allow=always",
"-c", "http.followRedirects=false",
"-c", "gc.auto=0",
"-c", "maintenance.auto=false",
*arguments,
]


def _run(
cwd: Path,
arguments: list[str],
deadline: float,
refusal: str,
*,
capture: bool = False,
) -> bytes:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise AcquisitionRefused("time_limit")
try:
result = subprocess.run(
arguments,
cwd=cwd,
env=_environment(),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=remaining,
check=False,
)
except subprocess.TimeoutExpired:
raise AcquisitionRefused("time_limit") from None
except OSError:
raise AcquisitionRefused("git_unavailable") from None
if result.returncode:
raise AcquisitionRefused(refusal)
output = result.stdout or b""
if len(output) > 256:
raise AcquisitionRefused("invalid_git_output")
return output


def _repository_size(root: Path, limit: int) -> int:
total = 0
try:
for directory, names, files in os.walk(root, followlinks=False):
base = Path(directory)
entries = [*((name, True) for name in names), *((name, False) for name in files)]
for name, directory_entry in entries:
info = (base / name).lstat()
reparse = getattr(info, "st_file_attributes", 0) & getattr(
stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0
)
expected = stat.S_ISDIR if directory_entry else stat.S_ISREG
if reparse or not expected(info.st_mode):
raise AcquisitionRefused("unsafe_git_database")
if not directory_entry:
total += info.st_size
if total > limit:
return total
except OSError:
raise AcquisitionRefused("filesystem_io_failed") from None
return total


@contextmanager
def acquire_pull_snapshot(
remote: str | Path,
pull_number: int,
expected_head_oid: str,
scratch_root: Path,
*,
acquisition_limits: AcquisitionLimits = AcquisitionLimits(),
reader_limits: ReaderLimits = ReaderLimits(),
) -> Iterator[AcquiredSnapshot]:
"""Yield a verified pull head and fresh database, then remove the database."""
if type(pull_number) is not int or not 0 < pull_number <= 2_147_483_647:
raise AcquisitionRefused("invalid_pull_number")
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", expected_head_oid):
raise AcquisitionRefused("invalid_head_oid")
algorithm = "sha1" if len(expected_head_oid) == 40 else "sha256"
remote_argument = _remote_argument(remote)
deadline = time.monotonic() + acquisition_limits.timeout_seconds
source_ref = f"refs/pull/{pull_number}/head"
target_ref = "refs/repo-sentinel/acquired-head"
temporary: TemporaryDirectory[str] | None = None
try:
try:
_regular_directory(scratch_root)
temporary = TemporaryDirectory(
prefix="repo-sentinel-acquire-", dir=scratch_root.resolve()
)
root = Path(temporary.name)
template = root / "empty-template"
template.mkdir(mode=0o700)
repository = root / "objects.git"
_run(
root,
_git_arguments(
"init", "--bare", "--quiet", f"--object-format={algorithm}",
f"--template={template}", str(repository),
),
deadline,
"init_failed",
)
_run(
repository,
_git_arguments(
"fetch", "--quiet", "--depth=1", "--no-tags",
"--no-recurse-submodules", "--no-write-fetch-head", "--",
remote_argument, f"+{source_ref}:{target_ref}",
),
deadline,
"fetch_failed",
)
if (
_repository_size(repository, acquisition_limits.max_repository_bytes)
> acquisition_limits.max_repository_bytes
):
raise AcquisitionRefused("repository_byte_limit")
refs = _run(
repository,
_git_arguments(
"for-each-ref", "--format=%(refname)%00%(objectname)", "refs/"
),
deadline,
"head_unavailable",
capture=True,
)
expected = f"{target_ref}\0{expected_head_oid}\n".encode("ascii")
if refs != expected:
raise AcquisitionRefused("head_mismatch")
snapshot = read_snapshot(repository, expected_head_oid, limits=reader_limits)
except OSError:
raise AcquisitionRefused("filesystem_io_failed") from None
yield AcquiredSnapshot(snapshot, source_ref, repository)
finally:
if temporary is not None:
try:
temporary.cleanup()
except OSError:
raise AcquisitionRefused("cleanup_failed") from None
Loading
Loading