blob: a64645358b97a4930d23a4a74b4d8ddfedebc3e9 [file]
#!/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())