| #!/usr/bin/env python3 |
| # |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| """Validate the portable and security-sensitive layout of a source archive.""" |
| |
| import argparse |
| import stat |
| import sys |
| import tarfile |
| import unicodedata |
| from pathlib import PurePosixPath |
| from typing import Optional, Set |
| |
| |
| COMPILED_MAGICS = { |
| b"\x00asm": "WebAssembly module", |
| b"\x7fELF": "ELF binary", |
| b"BC\xc0\xde": "LLVM bitcode", |
| b"\xca\xfe\xba\xbe": "Java class or Mach-O universal binary", |
| b"\xce\xfa\xed\xfe": "Mach-O binary", |
| b"\xcf\xfa\xed\xfe": "Mach-O binary", |
| b"\xfe\xed\xfa\xce": "Mach-O binary", |
| b"\xfe\xed\xfa\xcf": "Mach-O binary", |
| } |
| |
| |
| class ValidationError(RuntimeError): |
| """The archive does not satisfy release safety requirements.""" |
| |
| |
| def require(condition: bool, message: str) -> None: |
| if not condition: |
| raise ValidationError(message) |
| |
| |
| def normalized_name(member: tarfile.TarInfo) -> str: |
| value = member.name |
| require(bool(value), "archive contains an empty path") |
| require("\\" not in value, f"archive path contains a backslash: {value!r}") |
| candidate = value[:-1] if member.isdir() and value.endswith("/") else value |
| path = PurePosixPath(candidate) |
| require(not path.is_absolute(), f"archive contains an absolute path: {value!r}") |
| require(".." not in path.parts, f"archive contains path traversal: {value!r}") |
| require(path.as_posix() == candidate, f"archive path is not canonical: {value!r}") |
| return path.as_posix() |
| |
| |
| def portable_key(name: str) -> str: |
| return unicodedata.normalize("NFC", name).casefold() |
| |
| |
| def compiled_description( |
| archive: tarfile.TarFile, member: tarfile.TarInfo |
| ) -> Optional[str]: |
| extracted = archive.extractfile(member) |
| if extracted is None: |
| raise ValidationError(f"cannot read archive member: {member.name}") |
| header = extracted.read(4096) |
| for magic, description in COMPILED_MAGICS.items(): |
| if header.startswith(magic): |
| return description |
| if header.startswith(b"!<arch>\n"): |
| return "Unix archive" |
| if header.startswith(b"MZ") and len(header) >= 64: |
| pe_offset = int.from_bytes(header[60:64], "little") |
| if pe_offset + 4 <= len(header) and header[pe_offset : pe_offset + 4] == b"PE\0\0": |
| return "Windows PE binary" |
| return None |
| |
| |
| def validate(artifact: str, expected_root: str) -> None: |
| expected_root = expected_root.rstrip("/") |
| require(bool(expected_root), "expected root must not be empty") |
| require("/" not in expected_root, "expected root must be one path component") |
| |
| try: |
| archive = tarfile.open(artifact, mode="r:gz") |
| except (OSError, tarfile.TarError) as error: |
| raise ValidationError(f"cannot open source archive: {error}") from error |
| |
| with archive: |
| members = archive.getmembers() |
| require(bool(members), "source archive is empty") |
| names: Set[str] = set() |
| portable_names: Set[str] = set() |
| found_root = False |
| |
| for member in members: |
| require( |
| member.isfile() or member.isdir(), |
| f"archive contains a link or special member: {member.name!r}", |
| ) |
| name = normalized_name(member) |
| parts = PurePosixPath(name).parts |
| require( |
| bool(parts) and parts[0] == expected_root, |
| f"archive entry is outside {expected_root}/: {member.name!r}", |
| ) |
| if name == expected_root and member.isdir(): |
| found_root = True |
| |
| require(name not in names, f"archive contains duplicate path: {name!r}") |
| names.add(name) |
| key = portable_key(name) |
| require( |
| key not in portable_names, |
| f"archive contains a portable path collision: {name!r}", |
| ) |
| portable_names.add(key) |
| |
| require( |
| member.mode & (stat.S_IWGRP | stat.S_IWOTH) == 0, |
| f"archive contains a group- or world-writable member: {name!r}", |
| ) |
| require( |
| member.mode & (stat.S_ISUID | stat.S_ISGID | stat.S_ISVTX) == 0, |
| f"archive contains a member with special permission bits: {name!r}", |
| ) |
| |
| if member.isfile(): |
| description = compiled_description(archive, member) |
| require( |
| description is None, |
| f"source archive contains a compiled file ({description}): {name}", |
| ) |
| |
| require(found_root, f"archive root directory is missing: {expected_root}/") |
| print(f"Validated {len(members)} archive members under {expected_root}/") |
| |
| |
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("artifact", help="source .tar.gz or .tgz archive") |
| parser.add_argument("--expected-root", required=True) |
| args = parser.parse_args() |
| try: |
| validate(args.artifact, args.expected_root) |
| except ValidationError as error: |
| print(f"Archive validation failed: {error}", file=sys.stderr) |
| return 1 |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |