From a9660bc48ee65ce93cc185e98fc71f61f70c9396 Mon Sep 17 00:00:00 2001 From: Sagar Patil Date: Tue, 9 Jun 2026 19:51:14 -0700 Subject: [PATCH 1/2] Harden scripts against argument injection and malformed inputs Addresses the findings from the vuln-hunt sweep against 820b478. The dominant class was argument injection: untrusted CLI values (--rev, --image, --registry, --tag) flowing into git/docker as positional args where a leading dash is parsed as an option (e.g. --rev=--upload-pack= -> RCE; --image=--privileged -> full container caps). Security fixes: - common.reject_option_like(): central guard refusing values that begin with '-' before they reach a subprocess argv. A valid image ref, git ref, or tag never starts with '-'. - repro_test.clone(): reject option-like repo/rev and pin --end-of-options before the git fetch positionals (the high-severity RCE vector). - docker_inspect: guard image refs, manifest tag, and sources. - git_remote.ls_remote: guard URL/refspecs and pin --end-of-options. - Boundary guards on --image/--tag/--registry in smoke_test_image, repro_test, write_metadata, build_image, publish_manifests, publish_aliases. - release_body: validate every metadata field (digest, arch, tag, rust_base_key) and --registry/--repo against safe charsets before interpolating them into the copy-paste verification shell block; refuse symlinked meta-*.json. - validate_json.iter_json_files: skip *.json that resolve outside the repo tree (symlink-followed arbitrary-file read). Robustness fixes: - builds.load(): require a top-level JSON object (was AttributeError downstream). - builds.split_entry(): require a string pin (was AttributeError on null/int). - refresh: catch CalledProcessError from the docker digest lookup; only re-sort rust_versions when a pin was actually appended (no spurious reorder on no-op). Adds focused tests for each guard; full suite passes (223), ruff clean. Co-Authored-By: Claude Fable 5 --- scripts/build_image.py | 2 + scripts/lib/builds.py | 14 ++++++- scripts/lib/common.py | 22 +++++++++++ scripts/lib/docker_inspect.py | 7 +++- scripts/lib/git_remote.py | 7 +++- scripts/publish_aliases.py | 1 + scripts/publish_manifests.py | 5 +++ scripts/refresh.py | 17 +++++++-- scripts/release_body.py | 37 +++++++++++++++++++ scripts/repro_test.py | 20 +++++++++- scripts/smoke_test_image.py | 3 ++ scripts/validate_json.py | 7 ++++ scripts/write_metadata.py | 5 +++ tests/integration/test_refresh.py | 31 ++++++++++++++++ tests/unit/test_build_image.py | 21 +++++++++++ tests/unit/test_builds.py | 13 +++++++ tests/unit/test_common.py | 17 +++++++++ tests/unit/test_docker_inspect.py | 27 ++++++++++++++ tests/unit/test_git_remote.py | 23 ++++++++++++ tests/unit/test_release_body.py | 57 +++++++++++++++++++++++++++++ tests/unit/test_repro_test.py | 23 ++++++++++++ tests/unit/test_smoke_test_image.py | 26 +++++++++++++ tests/unit/test_validate_json.py | 17 +++++++++ tests/unit/test_write_metadata.py | 23 ++++++++++++ 24 files changed, 416 insertions(+), 9 deletions(-) diff --git a/scripts/build_image.py b/scripts/build_image.py index 5057a46..93aa1f8 100755 --- a/scripts/build_image.py +++ b/scripts/build_image.py @@ -31,6 +31,8 @@ def main(argv: list[str] | None = None) -> int: data = builds.load() rust_digest = args.rust_image_digest try: + if args.tag: + common.reject_option_like(args.tag, "--tag") builds.assert_pair_declared( data, args.stellar_cli_version, f"{args.rust_version}@{rust_digest}" ) diff --git a/scripts/lib/builds.py b/scripts/lib/builds.py index 3d169a6..05d4697 100644 --- a/scripts/lib/builds.py +++ b/scripts/lib/builds.py @@ -20,7 +20,15 @@ def load(path: Path | None = None) -> dict[str, Any]: target = path or DEFAULT_PATH - return json.loads(target.read_text()) + data = json.loads(target.read_text()) + if not isinstance(data, dict): + # Every caller treats the result as a mapping (data.get(...)); a JSON + # scalar/array would otherwise surface as an opaque AttributeError deep + # in an unrelated call. Fail here with a message naming the file. + raise ValueError( + f"{target} must contain a JSON object at the top level, got {type(data).__name__}" + ) + return data def dump(data: dict[str, Any], path: Path | None = None) -> None: @@ -59,6 +67,10 @@ def split_entry(pin: str) -> tuple[str, str]: `rust: