Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8a42221
feat: add OSWAP security event logging for user creation and system r…
blackboxsw Jan 23, 2026
e4fd5a9
doc: document security_log_file base config setting
blackboxsw Jan 26, 2026
9b7790b
feat: define decorators for sec_log_* and apply to distro methods
blackboxsw Feb 23, 2026
5b4eb29
fix: route SECURITY logs exclusively to /var/log/cloud-init-output.log
blackboxsw Feb 25, 2026
65bf5b0
chore: revert unnecessary changes to cloudinit.util
blackboxsw Feb 25, 2026
e1efd72
feat: add _get_host_ip helper to report host_ip in security logs
blackboxsw Mar 1, 2026
1a56972
fix: security_event_log setup should only be added when absent from l…
blackboxsw Mar 2, 2026
46d59b5
feat: sec log groups, sudo and doas
blackboxsw Mar 2, 2026
9a1b589
feat: sec log groups, sudo and doas
blackboxsw Mar 2, 2026
97a816f
chore: mypy warnings and prevent subclassing for create_user and shut…
blackboxsw Mar 4, 2026
e91cc58
fix: ignore down nics, support chpasswd logging
blackboxsw Mar 5, 2026
6d59281
chore: generalize Distro.add_user refactor subclass overrides
blackboxsw Mar 9, 2026
cf12e89
refactor: move get_host_ip from securtiy_event_log to netinfo
blackboxsw Mar 10, 2026
5233396
fix: provide mode and delay values in shutdown sec events
blackboxsw Mar 10, 2026
2032bd3
fix: address mypy and ai review comments
blackboxsw Mar 10, 2026
967ebbd
fix: mypy errors for @final Distro methods
blackboxsw Mar 10, 2026
8eafe51
fix: pylint issues
blackboxsw Mar 10, 2026
9aebc9b
fix: mypy
blackboxsw Mar 10, 2026
45bc3c7
test: add assertion on expeted host_ip
blackboxsw Mar 10, 2026
476f0c5
chore: drop host_ip from owasp logs use hostname for identity instead
blackboxsw Mar 11, 2026
310cac1
comments: typing on add_user*, decorators to use mandatory positional…
blackboxsw Mar 13, 2026
db03c52
chore: refactor Distros.shutdown_command
blackboxsw Mar 13, 2026
573d590
chore: correct _build_shutdown command call signature
blackboxsw Mar 13, 2026
289f2af
fix: incorrect naming alpine._build_shutdown_cmd
blackboxsw Mar 13, 2026
46291ca
chore: add SecurityFormatter to add datetime to sec logs from record.…
blackboxsw Mar 13, 2026
56d70f9
refactor: add helper method to process groups in kwargs
blackboxsw Mar 15, 2026
b1192a1
chore: drop _build_event_string and fold into _log_security_event
blackboxsw Mar 20, 2026
e076eec
chore: type hints for plist_in
blackboxsw Mar 20, 2026
95ab64c
chore: default insert cloud-init a main event actor. Remove stray dhc…
blackboxsw Mar 20, 2026
064ba63
chore: typing of name and doc log file correction
blackboxsw Mar 20, 2026
a18a102
refactor: add _get_elevated_roles and wire to sec_log_user_created
blackboxsw Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cloudinit/config/cc_set_passwords.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import random
import re
import string
from typing import List
from typing import List, Tuple

from cloudinit import features, lifecycle, subp, util
from cloudinit.cloud import Cloud
Expand All @@ -32,7 +32,7 @@
LOG = logging.getLogger(__name__)


def get_users_by_type(users_list: list, pw_type: str) -> list:
def get_users_by_type(users_list: list, pw_type: str) -> List[Tuple[str, str]]:
"""either password or type: RANDOM is required, user is always required"""
return (
[]
Expand Down
179 changes: 105 additions & 74 deletions cloudinit/distros/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
Tuple,
Type,
Union,
final,
)

import cloudinit.net.netops.iproute2 as iproute2
Expand All @@ -52,6 +53,12 @@
from cloudinit.distros.parsers import hosts
from cloudinit.features import ALLOW_EC2_MIRRORS_ON_NON_AWS_INSTANCE_TYPES
from cloudinit.lifecycle import log_with_downgradable_level
from cloudinit.log.security_event_log import (
sec_log_password_changed,
sec_log_password_changed_batch,
sec_log_system_shutdown,
sec_log_user_created,
)
from cloudinit.net import activators, dhcp, renderers
from cloudinit.net.netops import NetOps
from cloudinit.net.network_state import parse_net_config_data
Expand Down Expand Up @@ -659,27 +666,80 @@ def preferred_ntp_clients(self):
def get_default_user(self):
return self.get_option("default_user")

def add_user(self, name, **kwargs) -> bool:
"""
Add a user to the system using standard GNU tools
def _user_groups_to_list(self, groups) -> List[str]:
"""Return a list of designation groups with whitespace removed."""
if not groups:
return []
if isinstance(groups, str):
groups = groups.split(",")
return [g.strip() for g in groups]

def _get_elevated_roles(self, **kwargs) -> List[str]:
elevated_roles = []
if kwargs.get("sudo"):
elevated_roles.append("sudo")
if kwargs.get("doas"):
elevated_roles.append("doas")
return elevated_roles

@final
@sec_log_user_created
def add_user(self, name, **kwargs) -> None:
"""Add a user to the system."""

self._add_user_preprocess_kwargs(name, kwargs)

create_groups = kwargs.pop("create_groups", True)

if isinstance(kwargs.get("groups", None), dict):
lifecycle.deprecate(
deprecated=f"The user {name} has a 'groups' config value "
"of type dict",
deprecated_version="22.3",
extra_message="Use a comma-delimited string or "
"array instead: group1,group2.",
)
groups = self._user_groups_to_list(kwargs.pop("groups", None))
if groups:
primary_group = kwargs.get("primary_group")
if primary_group:
groups.append(primary_group)

if create_groups and groups:
for group in groups:
if not util.is_group(group):
self.create_group(group)
LOG.debug("created group '%s' for user '%s'", group, name)

This should be overridden on distros where useradd is not desirable or
not available.
if "uid" in kwargs:
kwargs["uid"] = str(kwargs["uid"])

Returns False if user already exists, otherwise True.
LOG.debug("Adding user %s", name)
cmd, log_cmd = self._build_add_user_cmd(name, groups, **kwargs)
try:
subp.subp(cmd, logstring=log_cmd)
except Exception as e:
util.logexc(LOG, "Failed to create user %s", name)
raise e

self._post_add_user(name, groups, **kwargs)

def _add_user_preprocess_kwargs(self, name: str, kwargs: dict) -> None:
"""Preprocess kwargs in-place before building the add-user command.

Overridden to filter for distro-specific user creation tools.
"""
# XXX need to make add_user idempotent somehow as we
# still want to add groups or modify SSH keys on pre-existing
# users in the image.
if util.is_user(name):
LOG.info("User %s already exists, skipping.", name)
return False

if "create_groups" in kwargs:
create_groups = kwargs.pop("create_groups")
else:
create_groups = True
def _build_add_user_cmd(
self, name: str, groups: List[str], **kwargs
) -> Tuple[List[str], List[str]]:
"""Build the useradd command for GNU/Linux systems.

Overridden for distro-specific user-creation tools.

Returns a (cmd, log_cmd) tuple where log_cmd has sensitive values
redacted.
"""
useradd_cmd = ["useradd", name]
log_useradd_cmd = ["useradd", name]
if util.system_is_snappy():
Expand All @@ -694,7 +754,6 @@ def add_user(self, name, **kwargs) -> bool:
"homedir": "--home",
"primary_group": "--gid",
"uid": "--uid",
"groups": "--groups",
"passwd": "--password",
"shell": "--shell",
"expiredate": "--expiredate",
Expand All @@ -710,42 +769,10 @@ def add_user(self, name, **kwargs) -> bool:

redact_opts = ["passwd"]

# support kwargs having groups=[list] or groups="g1,g2"
groups = kwargs.get("groups")
if groups:
if isinstance(groups, str):
groups = groups.split(",")

if isinstance(groups, dict):
lifecycle.deprecate(
deprecated=f"The user {name} has a 'groups' config value "
"of type dict",
deprecated_version="22.3",
extra_message="Use a comma-delimited string or "
"array instead: group1,group2.",
)

# remove any white spaces in group names, most likely
# that came in as a string like: groups: group1, group2
groups = [g.strip() for g in groups]

# kwargs.items loop below wants a comma delimited string
# that can go right through to the command.
kwargs["groups"] = ",".join(groups)

primary_group = kwargs.get("primary_group")
if primary_group:
groups.append(primary_group)

if create_groups and groups:
for group in groups:
if not util.is_group(group):
self.create_group(group)
LOG.debug("created group '%s' for user '%s'", group, name)
if "uid" in kwargs.keys():
kwargs["uid"] = str(kwargs["uid"])

# Check the values and create the command
if groups:
useradd_cmd.extend(["--groups", ",".join(groups)])
log_useradd_cmd.extend(["--groups", ",".join(groups)])
for key, val in sorted(kwargs.items()):
if key in useradd_opts and val and isinstance(val, str):
useradd_cmd.extend([useradd_opts[key], val])
Expand All @@ -769,17 +796,16 @@ def add_user(self, name, **kwargs) -> bool:
useradd_cmd.append("-m")
log_useradd_cmd.append("-m")

# Run the command
LOG.debug("Adding user %s", name)
try:
subp.subp(useradd_cmd, logstring=log_useradd_cmd)
except Exception as e:
util.logexc(LOG, "Failed to create user %s", name)
raise e
return useradd_cmd, log_useradd_cmd

# Indicate that a new user was created
return True
def _post_add_user(self, name: str, groups: List[str], **kwargs) -> None:
"""Hook called after the user-creation command succeeds.

Overridden to perform distro-specific post-creation steps.
"""

@final
@sec_log_user_created
def add_snap_user(self, name, **kwargs):
"""
Add a snappy user to the system using snappy tools
Expand All @@ -802,14 +828,10 @@ def add_snap_user(self, name, **kwargs):
create_user_cmd, logstring=create_user_cmd, capture=True
)
LOG.debug("snap create-user returned: %s:%s", out, err)
jobj = util.load_json(out)
username = jobj.get("username", None)
except Exception as e:
util.logexc(LOG, "Failed to create snap user %s", name)
raise e

return username

def _shadow_file_has_empty_user_password(self, username) -> bool:
"""
Check whether username exists in shadow files with empty password.
Expand Down Expand Up @@ -844,6 +866,7 @@ def _shadow_file_has_empty_user_password(self, username) -> bool:
return True
return False

@final
def create_user(self, name, **kwargs):
"""
Creates or partially updates the ``name`` user in the system.
Expand All @@ -869,8 +892,11 @@ def create_user(self, name, **kwargs):
if "snapuser" in kwargs:
return self.add_snap_user(name, **kwargs)

# Add the user
pre_existing_user = not self.add_user(name, **kwargs)
pre_existing_user = util.is_user(name)
if pre_existing_user:
LOG.info("User %s already exists, skipping.", name)
else:
self.add_user(name, **kwargs)

has_existing_password = False
ud_blank_password_specified = False
Expand Down Expand Up @@ -1021,7 +1047,6 @@ def create_user(self, name, **kwargs):
ssh_util.setup_user_keys(
set(cloud_keys), name, options=disable_option
)
return True

def lock_passwd(self, name):
"""
Expand Down Expand Up @@ -1093,6 +1118,7 @@ def expire_passwd(self, user):
util.logexc(LOG, "Failed to set 'expire' for %s", user)
raise e

@sec_log_password_changed
def set_passwd(self, user, passwd, hashed=False):
pass_string = "%s:%s" % (user, passwd)
cmd = ["chpasswd"]
Expand All @@ -1113,7 +1139,8 @@ def set_passwd(self, user, passwd, hashed=False):

return True

def chpasswd(self, plist_in: list, hashed: bool):
@sec_log_password_changed_batch
def chpasswd(self, plist_in: List[Tuple[str, str]], hashed: bool):
payload = (
"\n".join(
Comment on lines 1121 to 1145
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add @final here too?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also migrated the sec_log_user_created decorator to add_user and add_snap_user as that is actually where a user gets created. Added @final decorators to both of those methods.
Refactored subclasses of add_user behavior into _add_user_preprocess_kwargs, _build_add_user_cmd and _post_add_user methods.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still overridden in a child class.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in distros/bsd.py, but bsd directly calls set_passwd for each name in plist_in. So BSD will still be logging separate OWASP events for each user, just as our @sec_log_password_changed_batch will.

As a result, I don't want to decorate chpasswd with @final and I also don't want to decorate cloudinit.distros.bsd.Disto.chpasswd with @sec_log passwd_changed_batch

(":".join([name, password]) for name, password in plist_in)
Expand Down Expand Up @@ -1322,9 +1349,9 @@ def create_group(self, name, members=None):
LOG.info("Added user '%s' to group '%s'", member, name)

@classmethod
@final
@sec_log_system_shutdown
def shutdown_command(cls, *, mode, delay, message):
# called from cc_power_state_change.load_power_state
command = ["shutdown", cls.shutdown_options_map[mode]]
try:
if delay != "now":
delay = "+%d" % int(delay)
Expand All @@ -1333,10 +1360,14 @@ def shutdown_command(cls, *, mode, delay, message):
"power_state[delay] must be 'now' or '+m' (minutes)."
" found '%s'." % (delay,)
) from e
args = command + [delay]
return cls._build_shutdown_command(mode, delay, message)

@classmethod
def _build_shutdown_command(cls, mode, delay, message):
command = ["shutdown", cls.shutdown_options_map[mode], delay]
if message:
args.append(message)
return args
command.append(message)
return command

@classmethod
def reload_init(cls, rcs=None):
Expand Down
Loading
Loading