Harden SSH parsing and add notice tooling
- Enforce exact service and channel names with bounded failure parsing - Add hash-pinned offline notice assembly and regression coverage - Record advisory dispositions, provenance, integration evidence, and remaining gates
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
"""Offline, bounded notice assembly for the reviewed installed dependency snapshot."""
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
|
||||
CATALOG = "third_party/release-notices/inputs.json"
|
||||
MAX_FILE = 4 * 1024 * 1024
|
||||
MAX_TOTAL = 32 * 1024 * 1024
|
||||
MAX_ENTRIES = 128
|
||||
ROOTS = {"project", "sdk", "toolchain"}
|
||||
DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||
FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC
|
||||
INTRO = """Release notice bundle — scoped engineering aid, NOT legal clearance
|
||||
|
||||
Start with inputs/project/third_party/release-notices/README.md.
|
||||
manifest.json records logical input paths, exact source hashes, byte ranges,
|
||||
and output hashes. No host paths or timestamps are recorded. Texts are retained
|
||||
verbatim, including mixed grants and the wolfSSH package/header discrepancy.
|
||||
|
||||
This is NOT corresponding source, a complete SBOM, an archive of the SDK/tools,
|
||||
or proof of notice delivery to firmware/device/browser recipients. Radio-blob
|
||||
corresponding-source/System Library questions remain unresolved. Icon provenance,
|
||||
wolfSSH packaging clarification, bootloader/runtime attribution, source delivery
|
||||
and Installation Information still require release review. No legal clearance.
|
||||
""".encode("utf-8")
|
||||
|
||||
|
||||
class NoticeError(Exception):
|
||||
"""A failed precondition; never silently omit a required input."""
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def json_bytes(value):
|
||||
return (json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def relative_parts(value):
|
||||
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
|
||||
raise NoticeError("invalid relative input path")
|
||||
parts = value.split("/")
|
||||
if PurePosixPath(value).is_absolute() or any(p in ("", ".", "..") for p in parts):
|
||||
raise NoticeError("unsafe relative input path")
|
||||
return parts
|
||||
|
||||
|
||||
def absolute_path(value):
|
||||
path = Path(value)
|
||||
if ".." in path.parts:
|
||||
raise NoticeError("parent traversal is not allowed")
|
||||
# Do not resolve(): it would hide symlinks from the descriptor walk.
|
||||
return Path(os.path.abspath(path))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def directory(path):
|
||||
"""Pin every directory component, rejecting symlinks (including ancestors)."""
|
||||
path = absolute_path(path)
|
||||
fd = os.open(path.anchor, DIR_FLAGS)
|
||||
try:
|
||||
for part in path.parts[1:]:
|
||||
child = os.open(part, DIR_FLAGS, dir_fd=fd)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
yield fd
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def read_bounded(root_fd, path, limit=MAX_FILE):
|
||||
parts = relative_parts(path)
|
||||
fd = os.dup(root_fd)
|
||||
try:
|
||||
for part in parts[:-1]:
|
||||
child = os.open(part, DIR_FLAGS, dir_fd=fd)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
source = os.open(parts[-1], FILE_FLAGS, dir_fd=fd)
|
||||
try:
|
||||
info = os.fstat(source)
|
||||
if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= limit:
|
||||
raise NoticeError(f"not a nonempty bounded regular file: {path}")
|
||||
with os.fdopen(source, "rb", closefd=False) as stream:
|
||||
data = stream.read(limit + 1)
|
||||
if len(data) != info.st_size or len(data) > limit:
|
||||
raise NoticeError(f"input changed size or exceeded limit: {path}")
|
||||
return data
|
||||
finally:
|
||||
os.close(source)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def validate_catalog(catalog):
|
||||
if not isinstance(catalog, dict) or set(catalog) != {"schema", "snapshot", "inputs"}:
|
||||
raise NoticeError("invalid catalog structure")
|
||||
if catalog["schema"] != 1 or not isinstance(catalog["snapshot"], dict):
|
||||
raise NoticeError("unsupported catalog schema")
|
||||
entries = catalog["inputs"]
|
||||
if not isinstance(entries, list) or not 0 < len(entries) <= MAX_ENTRIES:
|
||||
raise NoticeError("invalid catalog input count")
|
||||
seen = set()
|
||||
total = 0
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict) or set(entry) != {
|
||||
"root", "path", "size", "sha256", "range", "output_sha256", "purpose"
|
||||
}:
|
||||
raise NoticeError("invalid catalog entry")
|
||||
if entry["root"] not in ROOTS:
|
||||
raise NoticeError("unknown input root")
|
||||
relative_parts(entry["path"])
|
||||
key = (entry["root"], entry["path"])
|
||||
if key in seen:
|
||||
raise NoticeError("duplicate input")
|
||||
seen.add(key)
|
||||
size = entry["size"]
|
||||
if type(size) is not int or not 0 < size <= MAX_FILE:
|
||||
raise NoticeError("invalid input size")
|
||||
total += size
|
||||
for field in ("sha256", "output_sha256"):
|
||||
if not isinstance(entry[field], str) or not re.fullmatch(r"[0-9a-f]{64}", entry[field]):
|
||||
raise NoticeError("invalid digest")
|
||||
span = entry["range"]
|
||||
if span is not None and (
|
||||
not isinstance(span, list) or len(span) != 2
|
||||
or any(type(n) is not int for n in span)
|
||||
or not 0 <= span[0] < span[1] <= size
|
||||
):
|
||||
raise NoticeError("invalid byte range")
|
||||
if not isinstance(entry["purpose"], str) or not entry["purpose"]:
|
||||
raise NoticeError("missing input purpose")
|
||||
if total > MAX_TOTAL:
|
||||
raise NoticeError("catalog exceeds total read budget")
|
||||
|
||||
|
||||
def assemble(roots, output, catalog_data):
|
||||
"""Preflight all inputs before creating output. Catalog is trusted reviewed policy."""
|
||||
if set(roots) != ROOTS:
|
||||
raise NoticeError("all three input roots are required")
|
||||
roots = {name: absolute_path(path) for name, path in roots.items()}
|
||||
output = absolute_path(output)
|
||||
for root in roots.values():
|
||||
if output == root or root in output.parents:
|
||||
raise NoticeError("output must be outside every input root")
|
||||
if len(catalog_data) > MAX_FILE:
|
||||
raise NoticeError("catalog exceeds size bound")
|
||||
catalog = json.loads(catalog_data)
|
||||
validate_catalog(catalog)
|
||||
payloads = {}
|
||||
records = []
|
||||
for entry in sorted(catalog["inputs"], key=lambda e: (e["root"], e["path"])):
|
||||
logical = f"{entry['root']}/{entry['path']}"
|
||||
try:
|
||||
with directory(roots[entry["root"]]) as root_fd:
|
||||
source = read_bounded(root_fd, entry["path"], entry["size"])
|
||||
except OSError as error:
|
||||
raise NoticeError(f"cannot read required input: {logical} ({error.strerror})") from error
|
||||
if len(source) != entry["size"] or digest(source) != entry["sha256"]:
|
||||
raise NoticeError(f"source hash/size mismatch: {logical}; review drift, do not auto-repin")
|
||||
span = entry["range"]
|
||||
data = source if span is None else source[span[0]:span[1]]
|
||||
if digest(data) != entry["output_sha256"]:
|
||||
raise NoticeError(f"excerpt hash mismatch: {logical}")
|
||||
try:
|
||||
data.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise NoticeError(f"non-UTF-8 notice: {logical}") from error
|
||||
if b"\x00" in data:
|
||||
raise NoticeError(f"binary notice: {logical}")
|
||||
target = "inputs/" + logical + (".notice.txt" if span is not None else "")
|
||||
if target in payloads:
|
||||
raise NoticeError("output collision")
|
||||
payloads[target] = data
|
||||
records.append({**entry, "output": target, "output_size": len(data)})
|
||||
|
||||
payloads["README.txt"] = INTRO
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"snapshot": catalog["snapshot"],
|
||||
"catalog_sha256": digest(catalog_data),
|
||||
"inputs": records,
|
||||
"generated": [{"path": "README.txt", "sha256": digest(INTRO), "size": len(INTRO)}],
|
||||
}
|
||||
# Written last as the completion marker. The manifest does not hash itself.
|
||||
payloads["manifest.json"] = json_bytes(manifest)
|
||||
with directory(output.parent) as parent_fd:
|
||||
# Even an empty existing directory or dangling symlink is an error.
|
||||
os.mkdir(output.name, mode=0o700, dir_fd=parent_fd)
|
||||
out_fd = os.open(output.name, DIR_FLAGS, dir_fd=parent_fd)
|
||||
try:
|
||||
for name, data in payloads.items():
|
||||
write_new(out_fd, name, data)
|
||||
finally:
|
||||
os.close(out_fd)
|
||||
return manifest
|
||||
|
||||
|
||||
def write_new(root_fd, path, data):
|
||||
fd = os.dup(root_fd)
|
||||
try:
|
||||
parts = relative_parts(path)
|
||||
for part in parts[:-1]:
|
||||
try:
|
||||
os.mkdir(part, mode=0o700, dir_fd=fd)
|
||||
except FileExistsError:
|
||||
pass
|
||||
child = os.open(part, DIR_FLAGS, dir_fd=fd)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
target = os.open(parts[-1], os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
| os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=fd)
|
||||
with os.fdopen(target, "wb") as stream:
|
||||
stream.write(data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project-root", type=Path, default=Path(__file__).absolute().parent.parent)
|
||||
parser.add_argument("--sdk-root", type=Path, required=True)
|
||||
parser.add_argument("--toolchain-root", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True,
|
||||
help="new directory outside input roots; parent must already exist")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
# No user-supplied catalog, discovery, network, install, build, or repin mode.
|
||||
with directory(Path(__file__).absolute().parent.parent) as policy_fd:
|
||||
catalog_data = read_bounded(policy_fd, CATALOG)
|
||||
manifest = assemble({"project": args.project_root, "sdk": args.sdk_root,
|
||||
"toolchain": args.toolchain_root}, args.output, catalog_data)
|
||||
except (NoticeError, OSError, ValueError, TypeError) as error:
|
||||
print(f"release notices: FAILED: {error}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"PASS: {len(manifest['inputs'])} pinned notice/provenance inputs; no release clearance")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user