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())
|
||||
@@ -131,8 +131,9 @@ TLS_POLICY = """ /* mbedTLS retains this pointer: it must outlive every serve
|
||||
# the input object. Keep original upstream notices verbatim, rather than changing
|
||||
# their copyright year; the central project modification notice is separate.
|
||||
# Bounded server parser subset of official wolfSSL/wolfssh PRs 892, 881,
|
||||
# and 880 (reviewed alongside PR 899). Keep the 1.4.20 state machine and
|
||||
# password/async edits below. GetSize already uses safe remaining lengths.
|
||||
# and 880, plus the current-server PR899/902 disposition documented in
|
||||
# docs/ssh_parser_remaining_review.md. Preserve ordering/password/async edits.
|
||||
# GetSize already uses safe remaining lengths.
|
||||
WOLFSSH_PARSER_EDITS = (
|
||||
Edit("""int GetString(char* s, word32* sSz, const byte* buf, word32 len, word32 *idx)
|
||||
{
|
||||
@@ -200,9 +201,36 @@ WOLFSSH_PARSER_EDITS = (
|
||||
ret = GetString(serviceName, &nameSz, buf, len, &begin);
|
||||
if (ret != WS_SUCCESS)
|
||||
return ret;
|
||||
/* PR902 current-server subset: reject before publishing the transition.
|
||||
* The owner closes on this error; no best-effort disconnect is queued. */
|
||||
if (nameSz != sizeof("ssh-userauth") - 1 ||
|
||||
WMEMCMP(serviceName, "ssh-userauth", sizeof("ssh-userauth") - 1) != 0)
|
||||
return WS_INVALID_STATE_E;
|
||||
*idx = begin;
|
||||
|
||||
WLOG(WS_LOG_DEBUG, "Requesting service: %s", serviceName);"""),
|
||||
# PR899 fixes the reversed length predicate. The pinned handler additionally
|
||||
# needs a bounded recipient parser, not merely the later-tree one-line fix.
|
||||
Edit(""" if (ssh == NULL || buf == NULL || len != 0 || idx == NULL)
|
||||
ret = WS_BAD_ARGUMENT;
|
||||
|
||||
if (ret == WS_SUCCESS)
|
||||
ret = WS_CHANOPEN_FAILED;""", """ word32 begin, channelId;
|
||||
|
||||
if (ssh == NULL || buf == NULL || idx == NULL)
|
||||
return WS_BAD_ARGUMENT;
|
||||
|
||||
begin = *idx;
|
||||
ret = GetUint32(&channelId, buf, len, &begin);
|
||||
if (ret != WS_SUCCESS)
|
||||
return ret;
|
||||
if (begin != len)
|
||||
return WS_BUFFER_E;
|
||||
if (ChannelFind(ssh, channelId, WS_CHANNEL_ID_SELF) == NULL)
|
||||
return WS_INVALID_CHANID;
|
||||
|
||||
*idx = begin;
|
||||
ret = WS_CHANOPEN_FAILED;"""),
|
||||
Edit(""" channel->peerWindowSz += bytesToAdd;
|
||||
|
||||
WLOG(WS_LOG_INFO, " update peerWindowSz = %u",
|
||||
@@ -351,6 +379,17 @@ WOLFSSH_PARSER_EDITS = (
|
||||
ret = wc_ed25519_verify_msg_init(pk->signature + i, sz,"""),
|
||||
)
|
||||
|
||||
# DoChannelRequest's bounded GetString may truncate at 31 bytes; all recognized
|
||||
# names are shorter. Exact length first prevents short-name reads and aliases;
|
||||
# memcmp (not strncmp) also rejects embedded NULs. Keep branch bodies unchanged.
|
||||
WOLFSSH_PARSER_EDITS += tuple(
|
||||
Edit(f'WSTRNCMP(type, "{name}", typeSz) == 0',
|
||||
f'typeSz == sizeof("{name}") - 1 &&\n'
|
||||
f' WMEMCMP(type, "{name}", sizeof("{name}") - 1) == 0')
|
||||
for name in ("env", "shell", "exec", "subsystem", "pty-req", "window-change",
|
||||
"exit-status", "exit-signal", "auth-agent-req@openssh.com")
|
||||
)
|
||||
|
||||
ENTRIES = (
|
||||
Entry("dhcpserver", "lwip", "idf",
|
||||
"components/lwip/apps/dhcpserver/dhcpserver.c",
|
||||
@@ -649,6 +688,12 @@ def render_entry(entry: Entry, roots: dict[str, Path]) -> tuple[Path, bytes]:
|
||||
" * plus project restricted no-EXT_INFO correction. Provenance and\n"
|
||||
" * limitations: tools/wolfssh_order/README.md and delta.json.\n"
|
||||
" */\n")
|
||||
if entry.name == "wolfssh_internal":
|
||||
notice += ("/* Server parser review modified 2026-09-16: bounded CHANNEL_FAILURE\n"
|
||||
" * and ssh-userauth service validation; PR899/902 subset, not full PRs.\n"
|
||||
" * Local follow-up: exact bounded channel-request names.\n"
|
||||
" * Provenance/limits: docs/ssh_parser_remaining_review.md.\n"
|
||||
" */\n")
|
||||
if entry.header:
|
||||
notice += ("#if defined(_WOLFSSH_INTERNAL_H_) && \\\n"
|
||||
" (!defined(SAK_WOLFSSH_ORDER_ABI) || SAK_WOLFSSH_ORDER_ABI != 20260916)\n"
|
||||
|
||||
Reference in New Issue
Block a user