Files
glances/tests/test_outdated.py
nicolargo cf14166fbe test(outdated): json round-trip and graceful migration from legacy pickle cache
Cover the non-RCE behaviour of the new JSON cache:
- round-trip: written file is valid JSON, re-read produces equivalent dict
- legacy pickle: a pre-fix pickle cache is treated as a cache miss, not
  a crash (upgrade path)
- expiry: caches older than 7 days are invalidated
- version skew: caches written by a different installed version are
  invalidated
- first run: a missing file is not an error
2026-05-23 11:52:53 +02:00

165 lines
5.9 KiB
Python
Executable File

#!/usr/bin/env python
#
# This file is part of Glances.
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Tests for the Glances version cache safety:
- Insecure pickle deserialization (GHSA-9837-48hr-q32j / CVE-2026-46607)."""
import json
import os
import pickle
import shutil
import tempfile
import unittest
from datetime import datetime, timedelta
from types import SimpleNamespace
from glances.outdated import Outdated
# Module-level marker. If a poisoned pickle is deserialized via
# pickle.load(), its __reduce__ callable will flip this flag.
PICKLE_PAYLOAD_EXECUTED = False
def _mark_pickle_executed():
"""Module-level callable referenced by the poisoned pickle payload.
pickle requires the __reduce__ callable to be importable, so it must
live at module level (not inside a test method).
"""
global PICKLE_PAYLOAD_EXECUTED
PICKLE_PAYLOAD_EXECUTED = True
return 0
class _Poison:
"""Pickle payload whose deserialization invokes _mark_pickle_executed."""
def __reduce__(self):
return (_mark_pickle_executed, ())
class TestOutdatedCache(unittest.TestCase):
"""Verify the version cache file is not deserialized via pickle."""
def setUp(self):
global PICKLE_PAYLOAD_EXECUTED
PICKLE_PAYLOAD_EXECUTED = False
self.tmpdir = tempfile.mkdtemp(prefix='glances-test-outdated-')
self.cache_file = os.path.join(self.tmpdir, 'glances-version.db')
# Build an Outdated instance bypassing __init__ so we don't trigger
# the background PyPI HTTP request during unit tests.
self.outdated = object.__new__(Outdated)
self.outdated.args = SimpleNamespace(disable_check_update=False, time=2)
self.outdated.data = {
'installed_version': '4.5.5',
'latest_version': '0.0',
'refresh_date': datetime.now(),
}
self.outdated.cache_dir = self.tmpdir
self.outdated.cache_file = self.cache_file
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_001_malicious_pickle_is_not_executed(self):
"""A pickle planted at the cache path must NOT be deserialized.
Regression test for CVE-2026-46607 / GHSA-9837-48hr-q32j: the cache
file must be parsed with a non-executable format. Before the fix
this assertion fails because pickle.load() invokes the payload's
__reduce__ callable.
"""
with open(self.cache_file, 'wb') as f:
pickle.dump(_Poison(), f)
# _load_cache must not raise to the caller and must not execute
# the embedded callable. We tolerate any exception here because
# the pre-fix code path also raises a TypeError after the payload
# has already fired — but the side effect (PICKLE_PAYLOAD_EXECUTED)
# is what proves the vulnerability.
try:
self.outdated._load_cache()
except Exception:
pass
self.assertFalse(
PICKLE_PAYLOAD_EXECUTED,
"Pickle deserialization fired — RCE vector still present (CVE-2026-46607).",
)
def test_002_json_round_trip(self):
"""A fresh cache written by _save_cache must be re-read identically."""
self.outdated.data = {
'installed_version': '4.5.5',
'latest_version': '4.5.6',
'refresh_date': datetime.now(),
}
self.outdated._save_cache()
# File on disk must be valid JSON, not pickle.
with open(self.cache_file, encoding='utf-8') as f:
raw = json.load(f)
self.assertEqual(raw['installed_version'], '4.5.5')
self.assertEqual(raw['latest_version'], '4.5.6')
self.assertIsInstance(raw['refresh_date'], str)
loaded = self.outdated._load_cache()
self.assertEqual(loaded['installed_version'], '4.5.5')
self.assertEqual(loaded['latest_version'], '4.5.6')
self.assertIsInstance(loaded['refresh_date'], datetime)
def test_003_legacy_pickle_cache_is_ignored_gracefully(self):
"""A pre-fix pickle cache must be discarded silently, not crash.
Upgrade path: an existing user has a legitimate pickle cache from
a previous Glances release. _load_cache() must treat it as a cache
miss and return an empty dict so the caller refreshes from PyPI.
"""
with open(self.cache_file, 'wb') as f:
pickle.dump(
{
'installed_version': '4.5.4',
'latest_version': '4.5.5',
'refresh_date': datetime.now(),
},
f,
)
self.assertEqual(self.outdated._load_cache(), {})
def test_004_stale_cache_returns_empty(self):
"""A cache older than 7 days is treated as missing."""
self.outdated.data = {
'installed_version': '4.5.5',
'latest_version': '4.5.6',
'refresh_date': datetime.now() - timedelta(days=8),
}
self.outdated._save_cache()
self.assertEqual(self.outdated._load_cache(), {})
def test_005_version_mismatch_invalidates_cache(self):
"""A cache written by a different installed version is discarded."""
self.outdated.data = {
'installed_version': '4.5.4',
'latest_version': '4.5.6',
'refresh_date': datetime.now(),
}
self.outdated._save_cache()
# Simulate that the user has since upgraded to 4.5.5.
self.outdated.data['installed_version'] = '4.5.5'
self.assertEqual(self.outdated._load_cache(), {})
def test_006_missing_cache_file_returns_empty(self):
"""No cache file on disk is a normal first-run state, not an error."""
self.assertFalse(os.path.exists(self.cache_file))
self.assertEqual(self.outdated._load_cache(), {})
if __name__ == '__main__':
unittest.main()