diff --git a/ebuild/system/rootfs.py b/ebuild/system/rootfs.py index a467cac..39ea3db 100644 --- a/ebuild/system/rootfs.py +++ b/ebuild/system/rootfs.py @@ -11,7 +11,7 @@ import os from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional class RootfsError(Exception): @@ -29,6 +29,78 @@ class RootfsError(Exception): ] +# Shadow password field for a locked account: no password can log in. +LOCKED_PASSWORD = "!" + +# Characters that must never appear in a passwd/shadow field: the field +# separator, line breaks, and control characters (including NUL and DEL). +_FORBIDDEN_FIELD_CHARS = frozenset(":\n\r") | frozenset( + chr(c) for c in range(0x20) +) | frozenset("\x7f") + + +def _validate_passwd_field(value: str, label: str) -> str: + """Reject a value that would break /etc/passwd or /etc/shadow lines. + + A ``:`` or newline smuggled into a name, shell, or home would inject + extra fields or whole lines into the account files, so anything + containing one (or any control character) is refused outright. + """ + if not isinstance(value, str): + raise RootfsError( + f"{label} must be a string, got {type(value).__name__}" + ) + if value == "": + raise RootfsError(f"{label} must not be empty") + bad = sorted(set(value) & _FORBIDDEN_FIELD_CHARS) + if bad: + raise RootfsError( + f"{label} contains forbidden character(s): {bad!r}" + ) + return value + + +def _validate_username(name: str) -> str: + """Validate a login name for use in passwd/shadow/group lines.""" + name = _validate_passwd_field(name, "user name") + if name == "root": + raise RootfsError("user name 'root' is reserved for UID 0") + if name in (".", "..") or name.startswith("-"): + raise RootfsError(f"invalid user name: {name!r}") + if any(c in name for c in "/ \t"): + raise RootfsError( + f"user name {name!r} must not contain '/' or whitespace" + ) + return name + + +def _validate_absolute_path(value: str, label: str) -> str: + """Validate a shell or home path for use in a passwd line.""" + value = _validate_passwd_field(value, label) + if not value.startswith("/"): + raise RootfsError(f"{label} must be an absolute path: {value!r}") + parts = [part for part in value.split("/") if part not in ("", ".")] + if ".." in parts: + raise RootfsError(f"{label} must not contain '..': {value!r}") + return value + + +def _validate_password_hash(password_hash: str) -> str: + """Validate a crypt(3) password hash for /etc/shadow. + + Accepts only the ``$id$salt$hash`` form. The lock markers ``!`` and + ``*`` are not hashes and are refused here, so a caller cannot + accidentally pass a lock value where a usable password was meant. + """ + value = _validate_passwd_field(password_hash, "password hash") + if not value.startswith("$") or value.count("$") < 2: + raise RootfsError( + "password hash must be a crypt(3) hash such as " + "'$6$salt$hash'" + ) + return value + + class RootfsBuilder: """Assembles a root filesystem from packages and configuration.""" @@ -73,33 +145,103 @@ def install_init(self, init_system: str = "busybox") -> None: if os.name != "nt": rcs.chmod(0o755) + def _write_account_file(self, name: str, content: str) -> None: + """Write one /etc account file (passwd, group, or shadow). + + The only credential material ever written here is a validated + crypt(3) hash or a "!" lock marker (see _validate_password_hash); + /etc/shadow is the designated store for password hashes, so this + is not clear-text storage of a password. + """ + path = self.rootfs_dir / "etc" / name + # Writes only validated crypt(3) hashes or "!" lock markers to + # /etc/shadow (the designated hash store), never a clear-text + # password - the CodeQL clear-text-storage alert is a false + # positive here, suppressed with the justification below. + path.write_text(content) # codeql[py/clear-text-storage-sensitive-data] + def configure_users( self, - users: Optional[List[Dict[str, str]]] = None, + users: Optional[List[Dict[str, Any]]] = None, + root_password_hash: Optional[str] = None, + allow_passwordless_root: bool = False, ) -> None: - """Create /etc/passwd, /etc/group, /etc/shadow.""" + """Create /etc/passwd, /etc/group, /etc/shadow. + + Accounts are locked by default: root and every user get ``!`` in + the shadow password field, so no password can log in. A usable + password comes only from an explicit ``password_hash`` (a crypt(3) + hash) on that account, or from the explicit opt-in + ``allow_passwordless=True`` on a user dict / + ``allow_passwordless_root=True`` for root. Passwordless accounts + are never the default. + + Every name, shell, home, and hash is validated before anything is + written, so a ``:``, newline, or control character can never + inject extra fields or lines into the account files. + """ + if root_password_hash is not None and allow_passwordless_root: + raise RootfsError( + "root_password_hash and allow_passwordless_root are " + "mutually exclusive" + ) + if root_password_hash is not None: + root_shadow_pw = _validate_password_hash(root_password_hash) + elif allow_passwordless_root: + root_shadow_pw = "" + else: + root_shadow_pw = LOCKED_PASSWORD + passwd_lines = ["root:x:0:0:root:/root:/bin/sh\n"] group_lines = ["root:x:0:\n"] - shadow_lines = ["root::0:0:99999:7:::\n"] + shadow_lines = [f"root:{root_shadow_pw}:0:0:99999:7:::\n"] + home_dirs: List[Path] = [] + seen_names = {"root"} if users: uid = 1000 for user in users: - name = user.get("name", "user") - shell = user.get("shell", "/bin/sh") - home = user.get("home", f"/home/{name}") - passwd_lines.append(f"{name}:x:{uid}:{uid}:{name}:{home}:{shell}\n") + name = _validate_username(user.get("name", "user")) + if name in seen_names: + raise RootfsError(f"duplicate user name: {name!r}") + seen_names.add(name) + shell = _validate_absolute_path( + user.get("shell", "/bin/sh"), "shell" + ) + home = _validate_absolute_path( + user.get("home", f"/home/{name}"), "home" + ) + password_hash = user.get("password_hash") + allow_passwordless = user.get("allow_passwordless", False) + if password_hash is not None and allow_passwordless: + raise RootfsError( + f"user {name!r}: password_hash and " + "allow_passwordless are mutually exclusive" + ) + if password_hash is not None: + shadow_pw = _validate_password_hash(password_hash) + elif allow_passwordless: + shadow_pw = "" + else: + shadow_pw = LOCKED_PASSWORD + passwd_lines.append( + f"{name}:x:{uid}:{uid}:{name}:{home}:{shell}\n" + ) group_lines.append(f"{name}:x:{uid}:\n") - shadow_lines.append(f"{name}::0:0:99999:7:::\n") + shadow_lines.append( + f"{name}:{shadow_pw}:0:0:99999:7:::\n" + ) safe_home = os.path.normpath(home.lstrip("/")) - if ".." in safe_home.split(os.sep): - raise RootfsError(f"Invalid home path for user {name!r}: {home!r}") - (self.rootfs_dir / safe_home).mkdir(parents=True, exist_ok=True) + home_dirs.append(self.rootfs_dir / safe_home) uid += 1 - (self.rootfs_dir / "etc" / "passwd").write_text("".join(passwd_lines)) - (self.rootfs_dir / "etc" / "group").write_text("".join(group_lines)) - (self.rootfs_dir / "etc" / "shadow").write_text("".join(shadow_lines)) + # All validation passed; only now touch the filesystem, so a bad + # account can never leave a half-written passwd/shadow behind. + self._write_account_file("passwd", "".join(passwd_lines)) + self._write_account_file("group", "".join(group_lines)) + self._write_account_file("shadow", "".join(shadow_lines)) + for home_dir in home_dirs: + home_dir.mkdir(parents=True, exist_ok=True) def configure_network(self, hostname: str = "eos") -> None: """Create basic network configuration.""" @@ -127,11 +269,17 @@ def assemble( self, init_system: str = "busybox", hostname: str = "eos", - users: Optional[List[Dict[str, str]]] = None, + users: Optional[List[Dict[str, Any]]] = None, + root_password_hash: Optional[str] = None, + allow_passwordless_root: bool = False, ) -> Path: """Full rootfs assembly pipeline.""" self.create_skeleton() self.install_init(init_system) - self.configure_users(users) + self.configure_users( + users, + root_password_hash=root_password_hash, + allow_passwordless_root=allow_passwordless_root, + ) self.configure_network(hostname) return self.rootfs_dir diff --git a/tests/unit/test_rootfs_accounts.py b/tests/unit/test_rootfs_accounts.py new file mode 100644 index 0000000..c3bc148 --- /dev/null +++ b/tests/unit/test_rootfs_accounts.py @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Rootfs account safety (foundation PR-EB-1). + +Generated images must never ship passwordless accounts by default: root +and every user are locked unless a crypt(3) password hash is supplied or +passwordless login is explicitly opted into. Names, shells, homes, and +hashes are validated before any passwd/shadow line is written, so a +``:``, newline, or control character can never inject extra fields or +lines into the account files. +""" + +from pathlib import Path + +import pytest + +from ebuild.system.rootfs import RootfsBuilder, RootfsError + + +def _builder(tmp_path: Path) -> RootfsBuilder: + builder = RootfsBuilder(tmp_path) + builder.create_skeleton() + return builder + + +def _read(builder: RootfsBuilder, name: str) -> str: + return (builder.rootfs_dir / "etc" / name).read_text() + + +def _shadow_field(shadow: str, user: str) -> str: + for line in shadow.splitlines(): + fields = line.split(":") + if fields[0] == user: + return fields[1] + raise AssertionError(f"no shadow entry for {user!r}") + + +def test_root_locked_by_default(tmp_path: Path) -> None: + builder = _builder(tmp_path) + builder.configure_users() + assert _shadow_field(_read(builder, "shadow"), "root") == "!" + assert _read(builder, "passwd") == "root:x:0:0:root:/root:/bin/sh\n" + + +def test_user_locked_by_default(tmp_path: Path) -> None: + builder = _builder(tmp_path) + builder.configure_users(users=[{"name": "eos", "shell": "/bin/sh"}]) + shadow = _read(builder, "shadow") + assert _shadow_field(shadow, "eos") == "!" + assert "eos:x:1000:1000:eos:/home/eos:/bin/sh\n" in _read(builder, "passwd") + assert (builder.rootfs_dir / "home" / "eos").is_dir() + + +def test_user_password_hash_is_used(tmp_path: Path) -> None: + builder = _builder(tmp_path) + digest = "$6$salt$hashvalue" + builder.configure_users( + users=[{"name": "eos", "password_hash": digest}] + ) + assert _shadow_field(_read(builder, "shadow"), "eos") == digest + + +def test_user_passwordless_opt_in(tmp_path: Path) -> None: + builder = _builder(tmp_path) + builder.configure_users( + users=[{"name": "eos", "allow_passwordless": True}] + ) + assert _shadow_field(_read(builder, "shadow"), "eos") == "" + + +def test_root_password_hash_and_opt_in(tmp_path: Path) -> None: + digest = "$y$j9T$saltsaltsaltsalt$hashvalue" + builder = _builder(tmp_path) + builder.configure_users(root_password_hash=digest) + assert _shadow_field(_read(builder, "shadow"), "root") == digest + + builder = _builder(tmp_path) + builder.configure_users(allow_passwordless_root=True) + assert _shadow_field(_read(builder, "shadow"), "root") == "" + + +def test_mutually_exclusive_options_rejected(tmp_path: Path) -> None: + builder = _builder(tmp_path) + with pytest.raises(RootfsError): + builder.configure_users( + root_password_hash="$6$s$h", allow_passwordless_root=True + ) + with pytest.raises(RootfsError): + builder.configure_users( + users=[{ + "name": "eos", + "password_hash": "$6$s$h", + "allow_passwordless": True, + }] + ) + + +@pytest.mark.parametrize( + "user", + [ + {"name": "bad:name"}, + {"name": "bad\nname"}, + {"name": "bad\x00name"}, + {"name": "bad/name"}, + {"name": "bad name"}, + {"name": "root"}, + {"name": "-eos"}, + {"name": "eos", "shell": "/bin/sh:evil"}, + {"name": "eos", "shell": "bin/sh"}, + {"name": "eos", "shell": "/bin/sh\n/root:x:0:0::/root:/bin/sh"}, + {"name": "eos", "home": "/home/eos:evil"}, + {"name": "eos", "home": "/home/eos\nroot:x:0:0::/root:/bin/sh"}, + {"name": "eos", "home": "home/eos"}, + {"name": "eos", "home": "/home/../../etc"}, + {"name": "eos", "password_hash": "*"}, + {"name": "eos", "password_hash": "!"}, + {"name": "eos", "password_hash": "plaintext"}, + {"name": "eos", "password_hash": "$6$salt:hash"}, + {"name": "eos", "password_hash": "$6$salt\nhash"}, + ], +) +def test_injection_cases_rejected(tmp_path: Path, user: dict) -> None: + builder = _builder(tmp_path) + with pytest.raises(RootfsError): + builder.configure_users(users=[user]) + + +def test_rejected_user_writes_nothing(tmp_path: Path) -> None: + builder = _builder(tmp_path) + with pytest.raises(RootfsError): + builder.configure_users(users=[{"name": "bad:name"}]) + for name in ("passwd", "group", "shadow"): + assert not (builder.rootfs_dir / "etc" / name).exists() + + +def test_duplicate_user_names_rejected(tmp_path: Path) -> None: + builder = _builder(tmp_path) + with pytest.raises(RootfsError): + builder.configure_users( + users=[{"name": "eos"}, {"name": "eos"}] + ) + + +def test_assemble_threads_account_options(tmp_path: Path) -> None: + builder = RootfsBuilder(tmp_path) + builder.assemble(root_password_hash="$6$s$h") + assert _shadow_field(_read(builder, "shadow"), "root") == "$6$s$h" + + builder = RootfsBuilder(tmp_path / "second") + builder.assemble() + assert _shadow_field(_read(builder, "shadow"), "root") == "!"