Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
326 changes: 302 additions & 24 deletions library/sr_fingerprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,148 @@
DOCUMENTATION = """
---
module: sr_fingerprint
short_description: Write a message string to syslog using Ansible C(module.log) function.
short_description: Write role fingerprint data to syslog and optionally to a JSONL log file
description:
- Writes the given string to the system log using Ansible C(module.log) function.
- Collects role fingerprint data into a canonical record and writes it to
syslog using Ansible C(module.log) as C(key=value) pairs.
- Optionally appends the same record as a JSON line to a log file
(one JSON object per line, JSONL format), by default
C(/var/log/sysroles.jsonl).
- Playbook variables are not available inside modules automatically. Roles
pass C(role_name), C(role_path), C(ansible_play_hosts_all),
C(distribution), and C(distribution_version) from the task.
- C(ansible_check_mode) is collected from the module execution context.
- Intended for role-internal or diagnostic use.
author: Rich Megginson (@richm)
options:
sr_message:
description: Text to record in syslog.
status:
description: Role execution status.
type: str
required: true
choices:
- begin
- success
write_log_file:
description: >-
If C(true), append fingerprint data to the JSONL log file.
Defaults to C(false).
type: bool
default: false
log_file:
description: >-
Path to the JSONL log file. A lock sidecar (C(<log_file>.lock))
is created next to the log file for cross-process safety.
type: path
default: /var/log/sysroles.jsonl
max_log_size:
description: >-
Maximum log file size in bytes. When appending a new record
would exceed this limit, the oldest records are removed first.
Set to C(0) to disable trimming.
type: int
default: 2000000
role_name:
description: Name of the role, typically C({{ role_name }}).
type: str
required: true
role_path:
description: Path to the role, typically C({{ role_path }}).
type: path
required: true
ansible_play_hosts_all:
description: >-
All hosts in the play, typically C({{ ansible_play_hosts_all }}).
Used to derive C(play_hosts_number).
type: list
elements: str
required: true
distribution:
description: >-
OS distribution name, typically
C({{ ansible_facts["distribution"] }}).
type: str
default: ""
distribution_version:
description: >-
OS distribution version, typically
C({{ ansible_facts["distribution_version"] }}).
type: str
default: ""
"""

EXAMPLES = """
- name: Record a fingerprint message in syslog
- name: Record role begin fingerprint to syslog only (not log file)
sr_fingerprint:
status: begin
role_name: bootloader
role_path: "{{ role_path }}"
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
distribution: "{{ ansible_facts['distribution'] }}"
distribution_version: "{{ ansible_facts['distribution_version'] }}"
write_log_file: false

- name: Record role success fingerprint
sr_fingerprint:
sr_message: "system_role:ROLENAME"
status: success
role_name: bootloader
role_path: "{{ role_path }}"
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
distribution: "{{ ansible_facts['distribution'] }}"
distribution_version: "{{ ansible_facts['distribution_version'] }}"
write_log_file: true
"""

RETURN = r""" # """
RETURN = r"""
fingerprint:
description: The fingerprint record written to syslog and optionally to the log file.
returned: always
type: dict
sample:
date: "2026-08-03T10:15:00+02:00"
role_name: network
role_path: /usr/share/ansible/roles/linux-system-roles.network
status: success
ansible_version: "2.16.3"
managed_node_distro: RedHat-9.4
play_hosts_number: 3
ansible_check_mode: false
message:
description: Informational message shown in check mode.
returned: check mode
type: str
sample: "Check mode: message not logged - [date=... role_name=...]"
jsonl_row:
description: The JSON line that would be appended to the log file.
returned: check mode and O(write_log_file=true)
type: str
log_file:
description: Path to the log file that would be written.
returned: check mode and O(write_log_file=true)
type: str
"""

from ansible.module_utils.basic import AnsibleModule

import datetime
import errno
import fcntl
import json
import os
import stat
import tempfile

FINGERPRINT_FIELDS = (
"date",
"role_name",
"role_path",
"status",
"ansible_version",
"managed_node_distro",
"play_hosts_number",
"ansible_check_mode",
)

FINGERPRINT_SYSLOG_SEPARATOR = " "


def _local_iso8601_no_microseconds():
Expand All @@ -51,33 +170,192 @@ def _local_iso8601_no_microseconds():
return datetime.datetime.now(utc).astimezone().replace(microsecond=0).isoformat()


def run_module():
module_args = dict(
sr_message=dict(type="str", required=True),
)
def _ensure_parent_dir(path):
parent = os.path.dirname(path)
if not parent:
return
if os.path.isdir(parent):
return
try:
os.makedirs(parent)
except OSError as exc:
# another process may have created the directory
if exc.errno != errno.EEXIST or not os.path.isdir(parent):
raise

module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True,
)

log_message = "%s %s" % (
module.params["sr_message"],
_local_iso8601_no_microseconds(),
)
def _format_fingerprint_jsonl(record):
"""Format the canonical fingerprint record as a single JSON line."""
return json.dumps(record, separators=(",", ":"), sort_keys=False)


def _trim_log_file(log_file, size_needed):
"""Remove oldest records until the file can accommodate size_needed bytes."""
with open(log_file, "r") as log_fd:
lines = log_fd.readlines()
size_removed = 0
while lines and size_removed < size_needed:
size_removed += len(lines.pop(0))
orig_stat = os.stat(log_file)
dir_name = os.path.dirname(log_file) or "."
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
try:
os.fchmod(fd, stat.S_IMODE(orig_stat.st_mode))
try:
os.fchown(fd, orig_stat.st_uid, orig_stat.st_gid)
except OSError:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
# not running as root; keep default ownership
pass
with os.fdopen(fd, "w") as tmp_fd:
tmp_fd.writelines(lines)
tmp_fd.flush()
os.fsync(tmp_fd.fileno())
os.rename(tmp_path, log_file)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
# already removed or never created
pass
raise


def _write_jsonl_log(log_file, record, max_size=0):
_ensure_parent_dir(log_file)
new_line = _format_fingerprint_jsonl(record) + "\n"
lock_path = log_file + ".lock"
lock_fd = open(lock_path, "w")
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
cur_size = os.path.getsize(log_file)
except OSError:
# file does not exist yet
cur_size = 0
if max_size > 0 and cur_size + len(new_line) > max_size and cur_size > 0:
_trim_log_file(log_file, len(new_line))
Comment on lines +235 to +236

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Enforce max_log_size after a limit change.

If an existing file is already larger than max_log_size, this call removes only len(new_line) bytes. The file can remain above the configured limit after every later write.

Calculate cur_size + len(new_line) - max_log_size and trim that overflow. Define and test the behavior when one JSONL record is larger than max_log_size.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 232-232: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(log_file, "a")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@library/sr_fingerprint.py` around lines 231 - 232, Update the size-handling
logic around _trim_log_file to trim the full overflow, calculated as cur_size
plus len(new_line) minus max_size, whenever the write would exceed max_size,
including files already over the limit. Define and test the expected behavior
for a single JSONL record larger than max_log_size, preserving valid record
handling.

with open(log_file, "a") as log_fd:
log_fd.write(new_line)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()


def _get_managed_node_distro(distribution, distribution_version):
if distribution and distribution_version:
return "%s-%s" % (distribution, distribution_version)
return "unknown"


def _get_play_hosts_number(play_hosts_all):
return len(play_hosts_all)


def _get_ansible_version(module):
version = getattr(module, "ansible_version", None)
if version:
return version
return "unknown"


def _get_check_mode(module):
return bool(getattr(module, "check_mode", False))


def _collect_fingerprint_record(module, status):
"""Build the canonical fingerprint record used by all output formatters."""
return {
"date": _local_iso8601_no_microseconds(),
"role_name": module.params["role_name"],
"role_path": module.params["role_path"],
"status": status,
"ansible_version": _get_ansible_version(module),
"managed_node_distro": _get_managed_node_distro(
module.params["distribution"], module.params["distribution_version"]
),
"play_hosts_number": _get_play_hosts_number(
module.params["ansible_play_hosts_all"]
),
"ansible_check_mode": _get_check_mode(module),
}


def _fingerprint_record_items(record):
return [(field, record[field]) for field in FINGERPRINT_FIELDS]


def _format_fingerprint_key_value(field, value):
text = "" if value is None else str(value)
if any(char in text for char in ' "='):
return '%s="%s"' % (field, text.replace('"', '""'))
return "%s=%s" % (field, text)
Comment on lines +287 to +291

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate file outline =="
ast-grep outline library/sr_fingerprint.py --view expanded | sed -n '1,220p' || true

echo "== sr_fingerprint relevant sections =="
cat -n library/sr_fingerprint.py | sed -n '1,340p'

echo "== imports/usages of module.log in sr_fingerprint =="
rg -n "module\.log|log_file|write_log|fingerprint|_format_fingerprint_syslog|_format_fingerprint_jsonl|managed_node_distro|distribution" library/sr_fingerprint.py

echo "== tests relevant sections =="
cat -n tests/unit/test_sr_fingerprint.py | sed -n '1,170p'
cat -n tests/unit/test_sr_fingerprint.py | sed -n '200,370p'

echo "== deterministic behavior probe for sanitizer =="
python3 - <<'PY'
def _format_fingerprint_key_value(field, value):
    text = "" if value is None else str(value)
    if any(char in text for char in ' "='):
        return '%s="%s"' % (field, text.replace('"', '""'))
    return "%s=%s" % (field, text)

for v in ["RedHat\nLinux", "RedHat\rLinux", '"a\nb"']:
    print(repr(v), "=>", repr(_format_fingerprint_key_value("distribution", v)))
PY

Repository: linux-system-roles/bootloader

Length of output: 32975


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all module.log occurrences in repository =="
rg -n "module\.log\(" .

echo "== module.log definitions/usages in collection roles (if present) =="
rg -n "def log\b|module\.log|syslog|rsyslog|journalctl|/var/log|ansible.builtin.logging|ansible.builtin.syslog" .

Repository: linux-system-roles/bootloader

Length of output: 2178


🌐 Web query:

Ansible module_utils basic module.log syslog message newline escape control characters

💡 Result:

The Ansible module.log() method, defined within ansible.module_utils.basic.AnsibleModule, is designed to send log messages to system logging facilities (syslog or systemd-journal) rather than standard output or error [1][2][3]. Regarding the handling of newline characters and escape sequences: 1. Escape Character Handling: The log() method does not perform explicit escaping of control characters such as newlines (\n) before sending the string to the logging system [1][2]. Because it ultimately relies on the underlying system's syslog or journald implementation, messages containing literal newline characters can break the log format, causing the log entry to span multiple lines in the target file [1][4]. 2. Potential Errors: The inclusion of certain control characters, particularly null characters (\0), can cause the log() function to fail. Historical issues have identified that passing strings containing embedded null characters results in a ValueError during the logging process, causing module execution to crash [4]. 3. Recommendations: If you need to log messages that contain newlines or control characters, it is best practice to sanitize or replace these characters manually within your module code before passing the string to module.log() [1][2]. You can use standard Python string methods like .replace('\n', ' ') or encoding/escaping functions to ensure the log output remains stable and readable within the system logs [4]. Because module.log() is intended for internal tracking and not for direct user feedback, output is not visible via standard Ansible verbosity (e.g., -v to -vvvv), which is why standard print() statements or specific error reporting methods are generally preferred for troubleshooting or user-facing output [3].

Citations:


Other (CWE-117)

Sanitize syslog values before module.log.

_format_fingerprint_key_value preserves \n and \r in values like distribution, and module.log() passes that message to syslog/journald without newline escaping. This can split a single fingerprint into multiple logical log entries. Escape or reject CR/LF before module.log() and add a regression test with a newline in distribution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@library/sr_fingerprint.py` around lines 283 - 287, Update
_format_fingerprint_key_value to sanitize carriage-return and newline characters
in field values before formatting them for module.log, preventing multiline
syslog entries while preserving existing quoting behavior. Add a regression test
covering a distribution value containing a newline and verify the emitted
fingerprint remains a single log entry.



def _format_fingerprint_syslog(record):
"""Format the canonical fingerprint record as key=value syslog text."""
pairs = [
_format_fingerprint_key_value(field, value)
for field, value in _fingerprint_record_items(record)
]
return FINGERPRINT_SYSLOG_SEPARATOR.join(pairs)


def _handle_fingerprint(module):
max_log_size = module.params["max_log_size"]
if max_log_size < 0:
module.fail_json(
msg="max_log_size must be 0 or a positive integer, got %d" % max_log_size
)

fingerprint_record = _collect_fingerprint_record(module, module.params["status"])
log_message = _format_fingerprint_syslog(fingerprint_record)

if module.check_mode:
module.exit_json(
result = dict(
changed=False,
message="Check mode: message not logged - [%s]" % log_message,
fingerprint=fingerprint_record,
)
if module.params["write_log_file"]:
result["jsonl_row"] = _format_fingerprint_jsonl(fingerprint_record)
result["log_file"] = module.params["log_file"]
module.exit_json(**result)

module.log(log_message)

# we don't actually change anything, so we're not changed - writing a log message
# is not considered a change
# also, we don't want to report changed every time the role runs
module.exit_json(changed=False)
if module.params["write_log_file"]:
log_file = module.params["log_file"]
try:
_write_jsonl_log(
log_file, fingerprint_record, module.params["max_log_size"]
)
except (IOError, OSError) as exc:
module.fail_json(
msg="Failed to write fingerprint log file %s: %s" % (log_file, exc)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

module.exit_json(changed=False, fingerprint=fingerprint_record)


def run_module():
module_args = dict(
status=dict(type="str", required=True, choices=["begin", "success"]),
write_log_file=dict(type="bool", default=False),
log_file=dict(type="path", default="/var/log/sysroles.jsonl"),
max_log_size=dict(type="int", default=2000000),
role_name=dict(type="str", required=True),
role_path=dict(type="path", required=True),
ansible_play_hosts_all=dict(type="list", elements="str", required=True),
distribution=dict(type="str", default=""),
distribution_version=dict(type="str", default=""),
)

module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True,
)

_handle_fingerprint(module)


def main():
Expand Down
7 changes: 2 additions & 5 deletions pytest_extra_requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
# SPDX-License-Identifier: MIT

# Write extra requirements for running pytest here:
# If you need ansible then uncomment the following line:
-ransible_pytest_extra_requirements.txt
# If you need mock then uncomment the following line:
mock ; python_version < "3.0"
# ansible and dependencies for all supported platforms
ansible-core ; python_version > "2.6"
10 changes: 7 additions & 3 deletions tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@

- name: Record role success fingerprint
sr_fingerprint:
sr_message: >-
success system_role:bootloader ansible_version={{ ansible_version.full }}
{{ ansible_facts['distribution'] }}-{{ ansible_facts['distribution_version'] }}
status: success
role_name: bootloader
role_path: "{{ role_path }}"
ansible_play_hosts_all: "{{ ansible_play_hosts_all }}"
distribution: "{{ ansible_facts['distribution'] }}"
distribution_version: "{{ ansible_facts['distribution_version'] }}"
write_log_file: "{{ __bootloader_write_log_file }}"
Loading