Repository navigation
Rootfs account safety: locked accounts by default, validated fields (PR-EB-1) #188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 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: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Home directories escape rootfs When rootfs assembly runs on Windows and a supplied home path contains backslash-separated parent components, Reject backslashes in home paths or validate their components in a way that prevents traversal on Windows before creating directories. Also at Prompt for AI agentsIn `ebuild/system/rootfs.py` at line 83:
**Home directories escape rootfs**
When rootfs assembly runs on Windows and a supplied home path contains backslash-separated parent components, `_validate_absolute_path` checks only `/`-separated components, so the path passes validation; `os.path.normpath` then interprets the backslashes and `home_dir.mkdir()` creates directories outside `rootfs_dir`.
Reject backslashes in home paths or validate their components in a way that prevents traversal on Windows before creating directories.
Also at `ebuild/system/rootfs.py:219-220`. |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+95
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Supplied passwords can fail at login _validate_password_hash() accepts any value beginning with $ and containing two dollar signs, so it accepts $6$salt even though that value has no hash component. When a caller supplies such a value for root or a user, configure_users() writes it verbatim to /etc/shadow instead of rejecting the unusable credential. Agent Prompt
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise RootfsError( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+96
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Malformed hashes disable login When a caller supplies an incomplete hash such as Require a nonempty hash component in addition to the identifier and salt. Prompt for AI agentsIn `ebuild/system/rootfs.py` at lines 96-97:
**Malformed hashes disable login**
When a caller supplies an incomplete hash such as `$6$salt` or `$6$`, `_validate_password_hash` accepts values with only two dollar signs, so it writes a hash missing a required component to shadow and the account cannot authenticate with the supplied password.
Require a nonempty hash component in addition to the identifier and salt.From Validate every input at the boundary it enters: type, range, length, format and
allowed values. Reject what does not match rather than coercing it. Validation
performed only in the client is not validation. |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| "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 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||
| 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] | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Aswin-V marked this conversation as resolved.
Dismissed
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High · Accounts become passwordless When a caller supplies a truthy non-boolean value such as the string Validate both passwordless options as booleans before using them. Also at Prompt for AI agentsIn `ebuild/system/rootfs.py` at line 175:
**Accounts become passwordless**
When a caller supplies a truthy non-boolean value such as the string `"false"` for either passwordless option, `configure_users` branches on the options' truthiness and writes an empty shadow password field, enabling passwordless login instead of rejecting the invalid setting.
Validate both passwordless options as booleans before using them.
Also at `ebuild/system/rootfs.py:200-209`.From Validate every input at the boundary it enters: type, range, length, format and
allowed values. Reject what does not match rather than coercing it. Validation
performed only in the client is not validation. |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 = "" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+223
to
+224
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. A false-valued option can unlock accounts configure_users() tests allow_passwordless and allow_passwordless_root by truthiness without requiring boolean values. If a caller passes a value such as the string "false", the affected account receives an empty shadow password field despite that value not being the explicit True opt-in. Agent Prompt
Comment on lines
+215
to
+224
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject non-boolean User dicts are typed 🔒 Proposed fix
Committable suggestion
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Failed home creation leaves account files When a configured home path names an existing file in the rootfs or home-directory creation otherwise fails, Create the home directories successfully before writing the account files, or roll back the account files if directory creation fails. Prompt for AI agentsIn `ebuild/system/rootfs.py` at line 229:
**Failed home creation leaves account files**
When a configured home path names an existing file in the rootfs or home-directory creation otherwise fails, `configure_users` writes the account files before `home_dir.mkdir()` runs; the directory creation raises and leaves the account files updated despite the failed call.
Create the home directories successfully before writing the account files, or roll back the account files if directory creation fails. |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| def configure_network(self, hostname: str = "eos") -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Create basic network configuration.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -127,11 +269,17 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
|
Comment on lines
+132
to
+134
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Late rejection leaves partial files When a valid user precedes a malformed user in the list, Include a valid user before the malformed one and assert that no account files remain after rejection. Prompt for AI agentsIn `tests/unit/test_rootfs_accounts.py` at lines 132-134:
**Late rejection leaves partial files**
When a valid user precedes a malformed user in the list, `test_rejected_user_writes_nothing` (lines 132–134) supplies only the malformed user, so an implementation that writes files after each valid user and raises on a later malformed one still passes these assertions while leaving partial account files for multi-user input.
Include a valid user before the malformed one and assert that no account files remain after rejection. |
||
|
|
||
|
|
||
| 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") == "!" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⚪ Low · Unicode whitespace bypasses validation
When a supplied account field contains Unicode whitespace or a C1 control character, the forbidden-character set covers ASCII controls only, and
_validate_username()checks only ASCII space and tab. Names containing Unicode whitespace such as U+2003 pass validation and are written into the account files despite the stated whitespace restriction; Unicode C1 control characters also pass the field check.Reject Unicode whitespace in usernames and include the full relevant Unicode control-character range or categories in field validation.
Also at
ebuild/system/rootfs.py:70-73.Prompt for AI agents