blob: d089ecc5d253d4c914606ea482d9f17dd6472ba0 [file]
# 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.
import io
import pathlib
import tarfile
import zipfile
import atr.detection as detection
import atr.models.attestable as models
type TarArchiveEntry = tuple[str, str, bytes | str]
def test_check_archive_safety_accepts_dotenv_anywhere_in_tar_and_zip(tmp_path):
tar_path = tmp_path / "safe-dotenv.tar.gz"
_write_tar_gz(
tar_path,
[
_tar_regular_file(".env", b"ATR_STATUS=ALPHA\n"),
_tar_regular_file("config/.env", b"SECRET=value\n"),
],
)
zip_path = tmp_path / "safe-dotenv.zip"
_write_zip(
zip_path,
[
(".env", b"ATR_STATUS=ALPHA\n"),
("config/.env", b"SECRET=value\n"),
],
)
assert detection.check_archive_safety(str(tar_path)) == []
assert detection.check_archive_safety(str(zip_path)) == []
def test_check_archive_safety_accepts_safe_tar_gz(tmp_path):
archive_path = tmp_path / "safe.tar.gz"
_write_tar_gz(
archive_path,
[
_tar_regular_file("dist/apache-widget-1.0-src.tar.gz", b"payload"),
_tar_regular_file("docs/readme.txt", b"hello"),
],
)
assert detection.check_archive_safety(str(archive_path)) == []
def test_check_archive_safety_accepts_safe_zip(tmp_path):
archive_path = tmp_path / "safe.zip"
_write_zip(
archive_path,
[
("dist/apache-widget-1.0-src.zip", b"payload"),
("docs/readme.txt", b"hello"),
],
)
assert detection.check_archive_safety(str(archive_path)) == []
def test_check_archive_safety_rejects_absolute_paths_in_tar_and_zip(tmp_path):
tar_path = tmp_path / "unsafe-absolute.tar.gz"
_write_tar_gz(
tar_path,
[
_tar_regular_file("/absolute.txt", b"x"),
],
)
zip_path = tmp_path / "unsafe-absolute.zip"
_write_zip(
zip_path,
[
("/absolute.txt", b"x"),
],
)
tar_errors = detection.check_archive_safety(str(tar_path))
zip_errors = detection.check_archive_safety(str(zip_path))
assert any("/absolute.txt" in error for error in tar_errors)
assert any("path traversal" in error for error in tar_errors)
assert any("/absolute.txt" in error for error in zip_errors)
assert any("path traversal" in error for error in zip_errors)
def test_check_archive_safety_rejects_hardlink_target_outside_root(tmp_path):
archive_path = tmp_path / "unsafe-hardlink.tar.gz"
_write_tar_gz(
archive_path,
[
_tar_regular_file("dist/file.txt", b"ok"),
_tar_hardlink("dist/hard", "../../outside.txt"),
],
)
errors = detection.check_archive_safety(str(archive_path))
assert any("dist/hard" in error for error in errors)
assert any("escapes root" in error for error in errors)
def test_check_archive_safety_rejects_hardlink_target_resolved_from_root(tmp_path):
archive_path = tmp_path / "unsafe-hardlink-depth.tar.gz"
_write_tar_gz(
archive_path,
[
_tar_regular_file("a/b/file.txt", b"ok"),
_tar_hardlink("a/b/link", "../secret"),
],
)
errors = detection.check_archive_safety(str(archive_path))
assert any("a/b/link" in error for error in errors)
assert any("escapes root" in error for error in errors)
def test_check_archive_safety_rejects_parent_path_traversal_in_tar_and_zip(tmp_path):
tar_path = tmp_path / "unsafe-parent.tar.gz"
_write_tar_gz(
tar_path,
[
_tar_regular_file("../outside.txt", b"x"),
],
)
zip_path = tmp_path / "unsafe-parent.zip"
_write_zip(
zip_path,
[
("../outside.txt", b"x"),
],
)
tar_errors = detection.check_archive_safety(str(tar_path))
zip_errors = detection.check_archive_safety(str(zip_path))
assert any("../outside.txt" in error for error in tar_errors)
assert any("path traversal" in error for error in tar_errors)
assert any("../outside.txt" in error for error in zip_errors)
assert any("path traversal" in error for error in zip_errors)
def test_check_archive_safety_rejects_symlink_target_outside_root(tmp_path):
archive_path = tmp_path / "unsafe-symlink.tar.gz"
_write_tar_gz(
archive_path,
[
_tar_regular_file("dist/file.txt", b"ok"),
_tar_symlink("dist/link", "../../outside.txt"),
],
)
errors = detection.check_archive_safety(str(archive_path))
assert any("dist/link" in error for error in errors)
assert any("escapes root" in error for error in errors)
def test_deduplicate_quarantine_archives_different_extensions_kept_separately():
paths_list = ["a/src.tar.gz", "a/src.zip"]
path_to_hash = {"a/src.tar.gz": "h1", "a/src.zip": "h1"}
result = detection.deduplicate_quarantine_archives(paths_list, path_to_hash)
assert len(result) == 2
def test_deduplicate_quarantine_archives_empty_input():
result = detection.deduplicate_quarantine_archives([], {})
assert result == []
def test_deduplicate_quarantine_archives_keeps_smallest_rel_path_per_hash_suffix():
paths_list = ["b/archive.tar.gz", "a/archive.tar.gz", "c/other.zip"]
path_to_hash = {
"a/archive.tar.gz": "h1",
"b/archive.tar.gz": "h1",
"c/other.zip": "h2",
}
result = detection.deduplicate_quarantine_archives(paths_list, path_to_hash)
assert result == [("a/archive.tar.gz", "h1"), ("c/other.zip", "h2")]
def test_deduplicate_quarantine_archives_tgz_normalises_to_tar_gz():
paths_list = ["a/src.tgz", "b/src.tar.gz"]
path_to_hash = {"a/src.tgz": "h1", "b/src.tar.gz": "h1"}
result = detection.deduplicate_quarantine_archives(paths_list, path_to_hash)
assert len(result) == 1
assert result[0][1] == "h1"
def test_detect_archives_requiring_quarantine_known_hash_and_different_extension():
previous = models.AttestableV1(
paths={"dist/apache-widget-1.0-src.tgz": "h1"},
hashes={"h1": models.HashEntry(size=100, uploaders=[("alice", "00001")], basenames=["old-src.tgz"])},
policy={},
)
result = detection.detect_archives_requiring_quarantine(
path_to_hash={"dist/apache-widget-1.0.zip": "h1"},
previous_attestable=previous,
)
assert result == ["dist/apache-widget-1.0.zip"]
def test_detect_archives_requiring_quarantine_known_hash_and_same_extension():
previous = models.AttestableV1(
paths={"dist/apache-widget-1.0-src.tar.gz": "h1"},
hashes={
"h1": models.HashEntry(
size=100,
uploaders=[("alice", "00001")],
basenames=["apache-widget-0.9-src.tar.gz"],
)
},
policy={},
)
result = detection.detect_archives_requiring_quarantine(
path_to_hash={"dist/apache-widget-1.0-src.tar.gz": "h1"},
previous_attestable=previous,
)
assert result == []
def test_detect_archives_requiring_quarantine_missing_historical_basenames():
hash_entry = models.HashEntry(size=100, uploaders=[("alice", "00001")])
previous = models.AttestableV1(
paths={"dist/apache-widget-1.0-src.tar.gz": "h1"},
hashes={"h1": hash_entry},
policy={},
)
result = detection.detect_archives_requiring_quarantine(
path_to_hash={"dist/apache-widget-1.1-src.tar.gz": "h1"},
previous_attestable=previous,
)
assert "basenames" not in hash_entry.model_fields_set
assert result == ["dist/apache-widget-1.1-src.tar.gz"]
def test_detect_archives_requiring_quarantine_new_hash_new_extension():
previous = models.AttestableV1(
paths={"dist/apache-widget-1.0-src.tar.gz": "h_old"},
hashes={"h_old": models.HashEntry(size=100, uploaders=[("alice", "00001")], basenames=["old-src.tar.gz"])},
policy={},
)
result = detection.detect_archives_requiring_quarantine(
path_to_hash={"dist/apache-widget-1.1.zip": "h_new"},
previous_attestable=previous,
)
assert result == ["dist/apache-widget-1.1.zip"]
def test_detect_archives_requiring_quarantine_no_previous_attestable():
result = detection.detect_archives_requiring_quarantine(
path_to_hash={"dist/apache-widget-1.0-src.tar.gz": "h1"},
previous_attestable=None,
)
assert result == ["dist/apache-widget-1.0-src.tar.gz"]
def test_detect_archives_requiring_quarantine_non_archive_files_are_ignored():
previous = models.AttestableV1(paths={}, hashes={}, policy={})
result = detection.detect_archives_requiring_quarantine(
path_to_hash={"dist/README.md": "h1", "dist/KEYS": "h2"},
previous_attestable=previous,
)
assert result == []
def test_detect_archives_requiring_quarantine_tgz_and_tar_gz_are_equivalent():
previous = models.AttestableV1(
paths={"dist/apache-widget-1.0-src.tar.gz": "h1"},
hashes={
"h1": models.HashEntry(
size=100,
uploaders=[("alice", "00001")],
basenames=["apache-widget-1.0-src.tar.gz"],
)
},
policy={},
)
result = detection.detect_archives_requiring_quarantine(
path_to_hash={"dist/apache-widget-1.0-src.tgz": "h1"},
previous_attestable=previous,
)
assert result == []
def _tar_hardlink(name: str, link_target: str) -> TarArchiveEntry:
return ("hardlink", name, link_target)
def _tar_regular_file(name: str, data: bytes) -> TarArchiveEntry:
return ("file", name, data)
def _tar_symlink(name: str, link_target: str) -> TarArchiveEntry:
return ("symlink", name, link_target)
def _write_tar_gz(archive_path: pathlib.Path, members: list[TarArchiveEntry]) -> None:
with tarfile.open(archive_path, "w:gz") as archive:
for member_type, member_name, member_data in members:
if member_type == "file":
if not isinstance(member_data, bytes):
raise ValueError("Tar regular file data must be bytes")
info = tarfile.TarInfo(member_name)
info.size = len(member_data)
archive.addfile(info, io.BytesIO(member_data))
continue
if member_type == "symlink":
if not isinstance(member_data, str):
raise ValueError("Tar symlink data must be a path string")
info = tarfile.TarInfo(member_name)
info.type = tarfile.SYMTYPE
info.linkname = member_data
archive.addfile(info)
continue
if member_type == "hardlink":
if not isinstance(member_data, str):
raise ValueError("Tar hardlink data must be a path string")
info = tarfile.TarInfo(member_name)
info.type = tarfile.LNKTYPE
info.linkname = member_data
archive.addfile(info)
continue
raise ValueError(f"Unsupported tar member type: {member_type}")
def _write_zip(archive_path: pathlib.Path, members: list[tuple[str, bytes]]) -> None:
with zipfile.ZipFile(archive_path, "w") as archive:
for member_name, member_data in members:
archive.writestr(member_name, member_data)