hardlinked source files are refused, remote schema URLs require an explicit flag, generated harvest artifacts use the hardened bundle writer, and task/handler/playbook YAML writes go through one safety gate.
This commit is contained in:
parent
f56f076765
commit
44d1a22db4
11 changed files with 202 additions and 37 deletions
|
|
@ -186,7 +186,11 @@ def _read_first_existing_text(
|
|||
try:
|
||||
fd = open_no_follow_path(path)
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_size > max_bytes:
|
||||
if (
|
||||
not stat.S_ISREG(st.st_mode)
|
||||
or st.st_nlink > 1
|
||||
or st.st_size > max_bytes
|
||||
):
|
||||
continue
|
||||
data = os.read(fd, max_bytes + 1)
|
||||
if len(data) > max_bytes:
|
||||
|
|
|
|||
|
|
@ -526,6 +526,16 @@ def _role_tag(role: str) -> str:
|
|||
return f"role_{safe}"
|
||||
|
||||
|
||||
def _write_generated_task_yaml(path: str, text: str, *, label: str) -> None:
|
||||
"""Write generated task/handler/playbook YAML through one safety gate."""
|
||||
|
||||
body = text.rstrip() + "\n"
|
||||
assert_generated_yaml_safe(body, label=label)
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(body)
|
||||
|
||||
|
||||
def _write_playbook_all(path: str, roles: List[str]) -> None:
|
||||
pb_lines = [
|
||||
"---",
|
||||
|
|
@ -540,9 +550,7 @@ def _write_playbook_all(path: str, roles: List[str]) -> None:
|
|||
pb_lines.append(f" - role: {safe}")
|
||||
pb_lines.append(f" tags: [{_role_tag(safe)}]")
|
||||
text = "\n".join(pb_lines) + "\n"
|
||||
assert_generated_yaml_safe(text, label="playbook.yml")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
_write_generated_task_yaml(path, text, label="playbook.yml")
|
||||
|
||||
|
||||
def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
|
||||
|
|
@ -560,9 +568,7 @@ def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
|
|||
pb_lines.append(f" - role: {safe}")
|
||||
pb_lines.append(f" tags: [{_role_tag(safe)}]")
|
||||
text = "\n".join(pb_lines) + "\n"
|
||||
assert_generated_yaml_safe(text, label="host playbook")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
_write_generated_task_yaml(path, text, label="host playbook")
|
||||
|
||||
|
||||
def _ensure_ansible_cfg(cfg_path: str) -> None:
|
||||
|
|
@ -772,16 +778,16 @@ def _write_ansible_role(
|
|||
# and keeps all harvested data in variable files; if any harvested value ever
|
||||
# leaked into this text and changed its shape, fail closed here rather than
|
||||
# emit a poisoned playbook.
|
||||
assert_generated_yaml_safe(tasks, label=f"role '{role}' tasks/main.yml")
|
||||
assert_generated_yaml_safe(handlers, label=f"role '{role}' handlers/main.yml")
|
||||
|
||||
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write(tasks.rstrip() + "\n")
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(handlers.rstrip() + "\n")
|
||||
_write_generated_task_yaml(
|
||||
os.path.join(role_dir, "tasks", "main.yml"),
|
||||
tasks,
|
||||
label=f"role '{role}' tasks/main.yml",
|
||||
)
|
||||
_write_generated_task_yaml(
|
||||
os.path.join(role_dir, "handlers", "main.yml"),
|
||||
handlers,
|
||||
label=f"role '{role}' handlers/main.yml",
|
||||
)
|
||||
|
||||
_write_role_meta(role_dir, collections)
|
||||
|
||||
|
|
@ -1798,13 +1804,16 @@ def _write_managed_files_role(
|
|||
_write_role_defaults(role_dir, vars_map)
|
||||
|
||||
tasks = _render_role_tasks(AnsibleRole(role), managed_content=True)
|
||||
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write(tasks.rstrip() + "\n")
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(handlers.rstrip() + "\n")
|
||||
_write_generated_task_yaml(
|
||||
os.path.join(role_dir, "tasks", "main.yml"),
|
||||
tasks,
|
||||
label=f"role '{role}' tasks/main.yml",
|
||||
)
|
||||
_write_generated_task_yaml(
|
||||
os.path.join(role_dir, "handlers", "main.yml"),
|
||||
handlers,
|
||||
label=f"role '{role}' handlers/main.yml",
|
||||
)
|
||||
|
||||
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write("---\ndependencies: []\n")
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ def copy_into_bundle(
|
|||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise OSError("refusing to copy non-regular source")
|
||||
if st.st_nlink > 1:
|
||||
raise OSError("refusing to copy hardlinked source")
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = os.read(fd, 1024 * 1024)
|
||||
|
|
|
|||
|
|
@ -895,8 +895,17 @@ def main() -> None:
|
|||
v.add_argument(
|
||||
"--schema",
|
||||
help=(
|
||||
"Optional JSON schema source (file path or https:// URL). "
|
||||
"If omitted, uses the schema vendored in the enroll codebase."
|
||||
"Optional JSON schema source. File paths are loaded by default; "
|
||||
"http(s) URLs require --allow-remote-schema. If omitted, uses "
|
||||
"the schema vendored in the enroll codebase."
|
||||
),
|
||||
)
|
||||
v.add_argument(
|
||||
"--allow-remote-schema",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Allow --schema to fetch an http(s) URL. Disabled by default so "
|
||||
"validation never makes network requests unless explicitly requested."
|
||||
),
|
||||
)
|
||||
v.add_argument(
|
||||
|
|
@ -1066,6 +1075,7 @@ def main() -> None:
|
|||
sops_mode=bool(getattr(args, "sops", False)),
|
||||
schema=getattr(args, "schema", None),
|
||||
no_schema=bool(getattr(args, "no_schema", False)),
|
||||
allow_remote_schema=bool(getattr(args, "allow_remote_schema", False)),
|
||||
)
|
||||
|
||||
fmt = str(getattr(args, "format", "text"))
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from .harvest_types import (
|
|||
SysctlSnapshot,
|
||||
)
|
||||
|
||||
from .capture import capture_file
|
||||
from .capture import capture_file, write_bytes_into_bundle
|
||||
from . import system_paths
|
||||
from .package_hints import package_section_from_installations, safe_name
|
||||
|
||||
|
|
@ -187,10 +187,12 @@ def _write_generated_artifact(
|
|||
bundle_dir: str, role_name: str, src_rel: str, content: str
|
||||
) -> None:
|
||||
"""Write a generated harvest artifact that did not exist as a file on disk."""
|
||||
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
with open(dst, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
# Keep generated artifacts on the same no-follow/exclusive-create path as
|
||||
# copied source artifacts. This preserves the invariant that nothing under
|
||||
# artifacts/ is written via a plain open() that could follow a symlink if a
|
||||
# directory were unexpectedly reused or raced.
|
||||
write_bytes_into_bundle(bundle_dir, role_name, src_rel, content.encode("utf-8"))
|
||||
|
||||
|
||||
_SYSCTL_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
|
|
|
|||
|
|
@ -355,6 +355,12 @@ class IgnorePolicy:
|
|||
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
return "not_regular_file", None
|
||||
if st.st_nlink > 1:
|
||||
# A hardlink gives a file another safe-looking pathname. When
|
||||
# Enroll is run as root, path-based deny rules such as
|
||||
# /etc/shadow must not be bypassed by copying the same inode via
|
||||
# an attacker-controlled alias under an otherwise allowed tree.
|
||||
return "hardlink_source", None
|
||||
if st.st_size > self.max_file_bytes:
|
||||
return "too_large", None
|
||||
|
||||
|
|
|
|||
|
|
@ -58,12 +58,15 @@ def _default_schema_path() -> Path:
|
|||
return Path(__file__).resolve().parent / "schema" / "state.schema.json"
|
||||
|
||||
|
||||
def _load_schema(schema: Optional[str]) -> Dict[str, Any]:
|
||||
def _load_schema(
|
||||
schema: Optional[str], *, allow_remote_schema: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Load a JSON schema.
|
||||
|
||||
If schema is None, load the vendored schema.
|
||||
If schema begins with http(s)://, fetch it.
|
||||
Otherwise, treat it as a local file path.
|
||||
If schema begins with http(s)://, fetch it only when remote schema
|
||||
loading was explicitly enabled by the caller. Otherwise, treat it as a
|
||||
local file path.
|
||||
"""
|
||||
|
||||
if not schema:
|
||||
|
|
@ -72,6 +75,11 @@ def _load_schema(schema: Optional[str]) -> Dict[str, Any]:
|
|||
return json.load(f)
|
||||
|
||||
if schema.startswith("http://") or schema.startswith("https://"):
|
||||
if not allow_remote_schema:
|
||||
raise ValueError(
|
||||
"remote schema URLs are disabled by default; use "
|
||||
"--allow-remote-schema only when the schema source is trusted"
|
||||
)
|
||||
with urllib.request.urlopen(schema, timeout=10) as resp: # nosec
|
||||
data = resp.read()
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
|
@ -136,6 +144,7 @@ def validate_harvest(
|
|||
sops_mode: bool = False,
|
||||
schema: Optional[str] = None,
|
||||
no_schema: bool = False,
|
||||
allow_remote_schema: bool = False,
|
||||
) -> ValidationResult:
|
||||
"""Validate an enroll harvest bundle.
|
||||
|
||||
|
|
@ -165,7 +174,7 @@ def validate_harvest(
|
|||
|
||||
if not no_schema:
|
||||
try:
|
||||
sch = _load_schema(schema)
|
||||
sch = _load_schema(schema, allow_remote_schema=allow_remote_schema)
|
||||
validator = jsonschema.Draft202012Validator(sch)
|
||||
for err in sorted(validator.iter_errors(state), key=str):
|
||||
ptr = _json_pointer(err)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue