Skip to content

Commit 63b7da2

Browse files
committed
Merge branch 'GHSA-49g7-2ww7-3vf5' into develop
2 parents 6f4ec53 + f3e9493 commit 63b7da2

File tree

6 files changed

+250
-24
lines changed

6 files changed

+250
-24
lines changed

conf/glances.conf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,14 @@ host=nats://localhost:4222
928928
# Prefix for the subjects (default is 'glances')
929929
prefix=glances
930930

931+
[duckdb]
932+
# database defines where data are stored, can be one of:
933+
# :memory: (see https://duckdb.org/docs/stable/clients/python/dbapi#in-memory-connection)
934+
# :memory:glances (see https://duckdb.org/docs/stable/clients/python/dbapi#in-memory-connection)
935+
# /path/to/glances.db (see https://duckdb.org/docs/stable/clients/python/dbapi#file-based-connection)
936+
# Or anyone else supported by the API (see https://duckdb.org/docs/stable/clients/python/dbapi)
937+
database=:memory:
938+
931939
##############################################################################
932940
# AMPS
933941
# * enable: Enable (true) or disable (false) the AMP

glances/exports/glances_duckdb/__init__.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@
1818
from glances.exports.export import GlancesExport
1919
from glances.logger import logger
2020

21+
22+
def _quote_identifier(name):
23+
"""Quote a SQL identifier to prevent injection.
24+
25+
DuckDB uses standard double-quote escaping for identifiers.
26+
Any embedded double-quote is doubled to escape it.
27+
"""
28+
return '"' + str(name).replace('"', '""') + '"'
29+
30+
2131
# Define the type conversions for DuckDB
2232
# https://duckdb.org/docs/stable/clients/python/conversion
2333
convert_types = {
@@ -112,10 +122,12 @@ def update(self, stats):
112122
values_list = [] # List of values to insert (list of lists, one list per row)
113123
if isinstance(plugin_stats, dict):
114124
# Create the list to create the table
115-
creation_list.append('time TIMETZ')
116-
creation_list.append('hostname_id VARCHAR')
125+
creation_list.append(f'{_quote_identifier("time")} TIMETZ')
126+
creation_list.append(f'{_quote_identifier("hostname_id")} VARCHAR')
117127
for key, value in plugin_stats.items():
118-
creation_list.append(f"{key} {convert_types[type(self.normalize(value)).__name__]}")
128+
creation_list.append(
129+
f"{_quote_identifier(key)} {convert_types[type(self.normalize(value)).__name__]}"
130+
)
119131
# Create the list of values to insert
120132
item_list = []
121133
item_list.append(self.normalize(datetime.now().replace(microsecond=0)))
@@ -124,11 +136,13 @@ def update(self, stats):
124136
values_list = [item_list]
125137
elif isinstance(plugin_stats, list) and len(plugin_stats) > 0 and 'key' in plugin_stats[0]:
126138
# Create the list to create the table
127-
creation_list.append('time TIMETZ')
128-
creation_list.append('hostname_id VARCHAR')
129-
creation_list.append('key_id VARCHAR')
139+
creation_list.append(f'{_quote_identifier("time")} TIMETZ')
140+
creation_list.append(f'{_quote_identifier("hostname_id")} VARCHAR')
141+
creation_list.append(f'{_quote_identifier("key_id")} VARCHAR')
130142
for key, value in plugin_stats[0].items():
131-
creation_list.append(f"{key} {convert_types[type(self.normalize(value)).__name__]}")
143+
creation_list.append(
144+
f"{_quote_identifier(key)} {convert_types[type(self.normalize(value)).__name__]}"
145+
)
132146
# Create the list of values to insert
133147
for plugin_item in plugin_stats:
134148
item_list = []
@@ -150,13 +164,11 @@ def export(self, plugin, creation_list, values_list):
150164
logger.debug(f"Export {plugin} stats to DuckDB")
151165

152166
# Create the table if it does not exist
167+
quoted_plugin = _quote_identifier(plugin)
153168
table_list = [t[0] for t in self.client.sql("SHOW TABLES").fetchall()]
154169
if plugin not in table_list:
155170
# Execute the create table query
156-
create_query = f"""
157-
CREATE TABLE {plugin} (
158-
{', '.join(creation_list)}
159-
);"""
171+
create_query = f"CREATE TABLE {quoted_plugin} ({', '.join(creation_list)});"
160172
logger.debug(f"Create table: {create_query}")
161173
try:
162174
self.client.execute(create_query)
@@ -169,10 +181,7 @@ def export(self, plugin, creation_list, values_list):
169181

170182
# Insert values into the table
171183
for values in values_list:
172-
insert_query = f"""
173-
INSERT INTO {plugin} VALUES (
174-
{', '.join(['?' for _ in values])}
175-
);"""
184+
insert_query = f"INSERT INTO {quoted_plugin} VALUES ({', '.join(['?' for _ in values])});"
176185
logger.debug(f"Insert values into table {plugin}: {values}")
177186
try:
178187
self.client.execute(insert_query, values)

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ containers = [
7575
"six",
7676
]
7777
export = [
78-
"bernhard", # "cassandra-driver, may cause issue on Docker image, TODO: test",
78+
"bernhard",
79+
"duckdb",
7980
"elasticsearch",
8081
"graphitesender",
8182
"ibmcloudant",

tests-data/tools/duckdbcheck.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ def check_duckdb(input_file, expected_lines, expected_columns=None):
2121

2222
# Check 1: Number of lines for CPU
2323
row_count = len(result)
24-
if row_count != expected_lines:
24+
if row_count < expected_lines:
2525
print(f"Error: Expected {expected_lines} CPU lines, but found {row_count}")
2626
return False
2727

2828
result = db.sql("SELECT * from network").fetchall()
2929

3030
# Check 2: Number of lines for Network
3131
row_count = len(result)
32-
if row_count != expected_lines:
32+
if row_count < expected_lines:
3333
print(f"Error: Expected {expected_lines} Network lines, but found {row_count}")
3434
return False
3535

tests/test_duckdb_sanitize.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#!/usr/bin/env python
2+
#
3+
# Glances - An eye on your system
4+
#
5+
# SPDX-FileCopyrightText: 2025 Nicolas Hennion <nicolas@nicolargo.com>
6+
#
7+
# SPDX-License-Identifier: LGPL-3.0-only
8+
#
9+
10+
"""Glances unit tests for DuckDB export SQL injection prevention.
11+
12+
Tests cover:
13+
- _quote_identifier properly escapes SQL identifiers
14+
- CREATE TABLE and INSERT INTO use quoted identifiers
15+
- SQL injection via crafted column names is prevented
16+
- SQL injection via crafted table names is prevented
17+
- Normal export workflow still works with quoting
18+
"""
19+
20+
import pytest
21+
22+
try:
23+
import duckdb
24+
except ImportError:
25+
pytest.skip("duckdb not installed", allow_module_level=True)
26+
27+
from glances.exports.glances_duckdb import _quote_identifier
28+
29+
# ---------------------------------------------------------------------------
30+
# Tests – _quote_identifier
31+
# ---------------------------------------------------------------------------
32+
33+
34+
class TestQuoteIdentifier:
35+
"""Unit tests for the _quote_identifier helper."""
36+
37+
def test_simple_name(self):
38+
assert _quote_identifier('cpu_percent') == '"cpu_percent"'
39+
40+
def test_name_with_spaces(self):
41+
assert _quote_identifier('my column') == '"my column"'
42+
43+
def test_name_with_double_quote(self):
44+
"""Embedded double quotes must be doubled."""
45+
assert _quote_identifier('col"name') == '"col""name"'
46+
47+
def test_name_with_multiple_double_quotes(self):
48+
assert _quote_identifier('a"b"c') == '"a""b""c"'
49+
50+
def test_sql_injection_attempt(self):
51+
"""SQL metacharacters must be safely quoted."""
52+
malicious = 'cpu); DROP TABLE secrets; --'
53+
quoted = _quote_identifier(malicious)
54+
assert quoted == '"cpu); DROP TABLE secrets; --"'
55+
56+
def test_empty_string(self):
57+
assert _quote_identifier('') == '""'
58+
59+
def test_non_string_input(self):
60+
"""Non-string input should be converted to string."""
61+
assert _quote_identifier(42) == '"42"'
62+
63+
def test_name_with_semicolon(self):
64+
assert _quote_identifier('col;name') == '"col;name"'
65+
66+
def test_name_with_parentheses(self):
67+
assert _quote_identifier('col(name)') == '"col(name)"'
68+
69+
70+
# ---------------------------------------------------------------------------
71+
# Tests – SQL injection prevention with real DuckDB
72+
# ---------------------------------------------------------------------------
73+
74+
75+
class TestDuckDBInjectionPrevention:
76+
"""Verify that quoted identifiers prevent SQL injection in real DuckDB."""
77+
78+
@pytest.fixture
79+
def db(self):
80+
"""Create an in-memory DuckDB connection."""
81+
conn = duckdb.connect(':memory:')
82+
yield conn
83+
conn.close()
84+
85+
def test_create_table_with_safe_names(self, db):
86+
"""Normal table and column creation works with quoting."""
87+
table = _quote_identifier('cpu')
88+
col1 = _quote_identifier('time')
89+
col2 = _quote_identifier('cpu_percent')
90+
db.execute(f'CREATE TABLE {table} ({col1} VARCHAR, {col2} DOUBLE);')
91+
db.execute(f'INSERT INTO {table} VALUES (?, ?);', ['2024-01-01', 95.5])
92+
result = db.execute(f'SELECT * FROM {table}').fetchall()
93+
assert len(result) == 1
94+
assert result[0] == ('2024-01-01', 95.5)
95+
96+
def test_create_table_with_special_column_names(self, db):
97+
"""Column names with special characters are properly handled."""
98+
table = _quote_identifier('test_plugin')
99+
col_special = _quote_identifier('my column with spaces')
100+
db.execute(f'CREATE TABLE {table} ({col_special} VARCHAR);')
101+
db.execute(f'INSERT INTO {table} VALUES (?);', ['value'])
102+
result = db.execute(f'SELECT * FROM {table}').fetchall()
103+
assert result[0] == ('value',)
104+
105+
def test_injection_in_column_name_is_neutralized(self, db):
106+
"""A malicious column name must not execute injected SQL."""
107+
# Create a target table that the injection would try to drop
108+
db.execute('CREATE TABLE secrets (data VARCHAR);')
109+
db.execute("INSERT INTO secrets VALUES ('sensitive');")
110+
111+
# Attempt injection via column name
112+
malicious_col = 'cpu BIGINT); DROP TABLE secrets; --'
113+
safe_col = _quote_identifier(malicious_col)
114+
table = _quote_identifier('test_inject')
115+
116+
# This should create a table with a weird column name, NOT drop secrets
117+
db.execute(f'CREATE TABLE {table} ({safe_col} VARCHAR);')
118+
119+
# Verify secrets table still exists and has data
120+
result = db.execute('SELECT * FROM secrets').fetchall()
121+
assert result == [('sensitive',)]
122+
123+
def test_injection_in_table_name_is_neutralized(self, db):
124+
"""A malicious table name must not execute injected SQL."""
125+
db.execute('CREATE TABLE important (data VARCHAR);')
126+
db.execute("INSERT INTO important VALUES ('keep');")
127+
128+
malicious_table = 'x (a INT); DROP TABLE important; --'
129+
safe_table = _quote_identifier(malicious_table)
130+
db.execute(f'CREATE TABLE {safe_table} (col1 VARCHAR);')
131+
132+
# important table must still exist
133+
result = db.execute('SELECT * FROM important').fetchall()
134+
assert result == [('keep',)]
135+
136+
def test_insert_with_quoted_table(self, db):
137+
"""INSERT INTO with quoted table name works correctly."""
138+
table = _quote_identifier('my-plugin')
139+
db.execute(f'CREATE TABLE {table} ({_quote_identifier("val")} BIGINT);')
140+
db.execute(f'INSERT INTO {table} VALUES (?);', [42])
141+
result = db.execute(f'SELECT * FROM {table}').fetchall()
142+
assert result == [(42,)]
143+
144+
def test_full_export_simulation(self, db):
145+
"""Simulate a full Glances DuckDB export cycle with quoting."""
146+
plugin = 'cpu'
147+
stats = {
148+
'total': 85.5,
149+
'user': 60.0,
150+
'system': 25.5,
151+
'idle': 14.5,
152+
}
153+
convert_types = {
154+
'float': 'DOUBLE',
155+
'int': 'BIGINT',
156+
'str': 'VARCHAR',
157+
}
158+
159+
# Build creation_list as the real code does
160+
creation_list = [
161+
f'{_quote_identifier("time")} VARCHAR',
162+
f'{_quote_identifier("hostname_id")} VARCHAR',
163+
]
164+
for key, value in stats.items():
165+
creation_list.append(f'{_quote_identifier(key)} {convert_types[type(value).__name__]}')
166+
167+
# CREATE TABLE
168+
quoted_plugin = _quote_identifier(plugin)
169+
create_query = f'CREATE TABLE {quoted_plugin} ({", ".join(creation_list)});'
170+
db.execute(create_query)
171+
172+
# INSERT
173+
values = ['2024-01-01T00:00:00', 'myhost'] + list(stats.values())
174+
placeholders = ', '.join(['?' for _ in values])
175+
insert_query = f'INSERT INTO {quoted_plugin} VALUES ({placeholders});'
176+
db.execute(insert_query, values)
177+
178+
# Verify
179+
result = db.execute(f'SELECT * FROM {quoted_plugin}').fetchall()
180+
assert len(result) == 1
181+
assert result[0][0] == '2024-01-01T00:00:00'
182+
assert result[0][1] == 'myhost'
183+
assert result[0][2] == 85.5
184+
185+
def test_column_with_double_quote_in_name(self, db):
186+
"""Column name containing double quotes is properly escaped."""
187+
table = _quote_identifier('test')
188+
col = _quote_identifier('col"with"quotes')
189+
db.execute(f'CREATE TABLE {table} ({col} VARCHAR);')
190+
db.execute(f'INSERT INTO {table} VALUES (?);', ['value'])
191+
result = db.execute(f'SELECT * FROM {table}').fetchall()
192+
assert result == [('value',)]

tests/test_export_duckdb.sh

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,32 @@
33
# Exit on error
44
set -e
55

6-
# Remove previous test database
7-
echo "Remove previous test database..."
8-
rm -f /tmp/glances.db
6+
# Paths
7+
DEFAULT_CONF="./conf/glances.conf"
8+
CUSTOM_CONF="/tmp/glances_duckdb_test.conf"
9+
DUCKDB_FILE="/tmp/glances.db"
10+
11+
# Remove previous test artifacts
12+
echo "Remove previous test database and config..."
13+
rm -f "$DUCKDB_FILE"
14+
rm -f "$CUSTOM_CONF"
15+
16+
# Generate a custom config from the default one,
17+
# replacing database=:memory: with a file-based database
18+
echo "Generate custom config from ${DEFAULT_CONF}..."
19+
sed 's|^database=:memory:$|database=/tmp/glances.db|' "$DEFAULT_CONF" > "$CUSTOM_CONF"
920

1021
# Run glances with export to DuckDB, stopping after 10 writes
1122
# This will run synchronously now since we're using --stop-after
1223
echo "Glances to export system stats to DuckDB (duration: ~ 20 seconds)"
13-
.venv/bin/python -m glances --config ./conf/glances.conf --export duckdb --stop-after 10 --quiet
24+
.venv/bin/python -m glances --config "$CUSTOM_CONF" --export duckdb --stop-after 10 --quiet
1425

1526
echo "Checking DuckDB database..."
16-
.venv/bin/python ./tests-data/tools/duckdbcheck.py -i /tmp/glances.db -l 9
27+
.venv/bin/python ./tests-data/tools/duckdbcheck.py -i "$DUCKDB_FILE" -l 9
28+
29+
# Cleanup
30+
echo "Cleanup test artifacts..."
31+
rm -f "$DUCKDB_FILE"
32+
rm -f "$CUSTOM_CONF"
1733

18-
echo "Script completed successfully!"
34+
echo "Script completed successfully!"

0 commit comments

Comments
 (0)