Skip to content

Commit d4d9ec0

Browse files
Fix/history file permissions (aws#10013)
* Update CLI history files with restrictive permissions on multi-user systems
1 parent 580c85a commit d4d9ec0

10 files changed

Lines changed: 280 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"type": "enhancement",
3+
"category": "cli-history",
4+
"description": "Create local history files with specific permissions"
5+
}

awscli/autoprompt/history.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,24 @@ def load_history_strings(self):
4444
def store_string(self, string):
4545
history = {'version': self.HISTORY_VERSION, 'commands': []}
4646
try:
47+
dir_path = os.path.dirname(self.filename)
4748
if os.path.exists(self.filename):
4849
with open(self.filename) as f:
4950
history = json.load(f)
50-
elif not os.path.exists(os.path.dirname(self.filename)):
51-
os.makedirs(os.path.dirname(self.filename))
51+
if not os.path.exists(dir_path):
52+
os.makedirs(dir_path)
53+
try:
54+
os.chmod(dir_path, 0o700)
55+
except OSError as e:
56+
LOG.debug('Unable to set directory permissions: %s', e)
5257
history['commands'].append(string)
5358
history['commands'] = history['commands'][-self._max_commands :]
5459
with open(self.filename, 'w') as f:
5560
json.dump(history, f)
61+
try:
62+
os.chmod(self.filename, 0o600)
63+
except OSError as e:
64+
LOG.debug('Unable to set file permissions: %s', e)
5665
except Exception:
5766
LOG.debug('Exception on loading prompt history:', exc_info=True)
5867

awscli/botocore/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3943,6 +3943,10 @@ def __setitem__(self, cache_key, value):
39433943
)
39443944
if not os.path.isdir(self._working_dir):
39453945
os.makedirs(self._working_dir)
3946+
try:
3947+
os.chmod(self._working_dir, 0o700)
3948+
except OSError as e:
3949+
logger.debug('Unable to set directory permissions: %s', e)
39463950
with os.fdopen(
39473951
os.open(full_key, os.O_WRONLY | os.O_CREAT, 0o600), 'w'
39483952
) as f:

awscli/customizations/history/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,14 @@ def attach_history_handler(session, parsed_args, **kwargs):
5353
history_filename = os.environ.get(
5454
HISTORY_FILENAME_ENV_VAR, DEFAULT_HISTORY_FILENAME
5555
)
56-
if not os.path.isdir(os.path.dirname(history_filename)):
57-
os.makedirs(os.path.dirname(history_filename))
56+
57+
history_dir = os.path.dirname(history_filename)
58+
if not os.path.isdir(history_dir):
59+
os.makedirs(history_dir)
60+
try:
61+
os.chmod(history_dir, 0o700)
62+
except OSError as e:
63+
LOG.debug('Unable to set directory permissions: %s', e)
5864

5965
connection = DatabaseConnection(history_filename)
6066
writer = DatabaseRecordWriter(connection)

awscli/customizations/history/db.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import datetime
1414
import json
1515
import logging
16+
import os
1617
import threading
1718
import time
1819
import uuid
@@ -37,6 +38,15 @@ class DatabaseConnection:
3738
_ENABLE_WAL = 'PRAGMA journal_mode=WAL'
3839

3940
def __init__(self, db_filename):
41+
# Skip file operations for in-memory databases
42+
if db_filename != ':memory:':
43+
if not os.path.exists(db_filename):
44+
# Create file so we can set permissions before sqlite opens it
45+
open(db_filename, 'a').close()
46+
try:
47+
os.chmod(db_filename, 0o600)
48+
except OSError as e:
49+
LOG.debug('Unable to set file permissions: %s', e)
4050
self._connection = sqlite3.connect(
4151
db_filename, check_same_thread=False, isolation_level=None
4252
)

awscli/telemetry.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# ANY KIND, either express or implied. See the License for the specific
1212
# language governing permissions and limitations under the License.
1313
import io
14+
import logging
1415
import os
1516
import sqlite3
1617
import sys
@@ -28,6 +29,8 @@
2829
from awscli.compat import is_windows
2930
from awscli.utils import add_component_to_user_agent_extra
3031

32+
LOG = logging.getLogger(__name__)
33+
3134
_CACHE_DIR = Path.home() / '.aws' / 'cli' / 'cache'
3235
_DATABASE_FILENAME = 'session.db'
3336
_SESSION_LENGTH_SECONDS = 60 * 30
@@ -77,12 +80,15 @@ class CLISessionDatabaseConnection:
7780
_ENABLE_WAL = 'PRAGMA journal_mode=WAL'
7881

7982
def __init__(self, connection=None):
80-
self._connection = connection or sqlite3.connect(
81-
_CACHE_DIR / _DATABASE_FILENAME,
82-
check_same_thread=False,
83-
isolation_level=None,
84-
)
85-
self._ensure_cache_dir()
83+
self._connection = connection
84+
if self._connection is None:
85+
self._ensure_cache_dir()
86+
self._ensure_database_file()
87+
self._connection = sqlite3.connect(
88+
_CACHE_DIR / _DATABASE_FILENAME,
89+
check_same_thread=False,
90+
isolation_level=None,
91+
)
8692
self._ensure_database_setup()
8793

8894
def execute(self, query, *parameters):
@@ -96,6 +102,19 @@ def execute(self, query, *parameters):
96102

97103
def _ensure_cache_dir(self):
98104
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
105+
try:
106+
os.chmod(_CACHE_DIR, 0o700)
107+
except OSError as e:
108+
LOG.debug('Unable to set directory permissions: %s', e)
109+
110+
def _ensure_database_file(self):
111+
db_path = _CACHE_DIR / _DATABASE_FILENAME
112+
if not db_path.exists():
113+
open(db_path, 'a').close()
114+
try:
115+
os.chmod(db_path, 0o600)
116+
except OSError as e:
117+
LOG.debug('Unable to set file permissions: %s', e)
99118

100119
def _ensure_database_setup(self):
101120
self._create_session_table()

tests/functional/test_telemetry.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1111
# ANY KIND, either express or implied. See the License for the specific
1212
# language governing permissions and limitations under the License.
13+
import os
1314
import sqlite3
15+
import stat
16+
from pathlib import Path
1417
from unittest.mock import MagicMock, PropertyMock, patch
1518

1619
import pytest
@@ -27,6 +30,7 @@
2730
CLISessionOrchestrator,
2831
add_session_id_component_to_user_agent_extra,
2932
)
33+
from awscli.testutils import FileCreator
3034
from tests.markers import skip_if_windows
3135

3236

@@ -130,6 +134,71 @@ def execute(self, query, *parameters):
130134
assert cursor.fetchall() == []
131135

132136

137+
@skip_if_windows
138+
class TestCLISessionDatabaseConnectionPermissions:
139+
def setup_method(self):
140+
self.files = FileCreator()
141+
142+
def teardown_method(self):
143+
self.files.remove_all()
144+
145+
def test_create_directory_with_secure_permissions(self):
146+
cache_dir = self.files.full_path('cache')
147+
cache_path = Path(cache_dir)
148+
149+
with patch('awscli.telemetry._CACHE_DIR', cache_path):
150+
with patch('awscli.telemetry._DATABASE_FILENAME', 'session.db'):
151+
conn = CLISessionDatabaseConnection()
152+
conn._connection.close()
153+
154+
assert os.path.isdir(cache_dir)
155+
dir_mode = stat.S_IMODE(os.stat(cache_dir).st_mode)
156+
assert dir_mode == 0o700
157+
158+
def test_tighten_existing_directory_permissions(self):
159+
cache_dir = self.files.full_path('cache')
160+
os.makedirs(cache_dir, mode=0o755)
161+
cache_path = Path(cache_dir)
162+
163+
with patch('awscli.telemetry._CACHE_DIR', cache_path):
164+
with patch('awscli.telemetry._DATABASE_FILENAME', 'session.db'):
165+
conn = CLISessionDatabaseConnection()
166+
conn._connection.close()
167+
168+
dir_mode = stat.S_IMODE(os.stat(cache_dir).st_mode)
169+
assert dir_mode == 0o700
170+
171+
def test_create_database_file_with_secure_permissions(self):
172+
cache_dir = self.files.full_path('cache')
173+
db_file = os.path.join(cache_dir, 'session.db')
174+
cache_path = Path(cache_dir)
175+
176+
with patch('awscli.telemetry._CACHE_DIR', cache_path):
177+
with patch('awscli.telemetry._DATABASE_FILENAME', 'session.db'):
178+
conn = CLISessionDatabaseConnection()
179+
conn._connection.close()
180+
181+
assert os.path.isfile(db_file)
182+
file_mode = stat.S_IMODE(os.stat(db_file).st_mode)
183+
assert file_mode == 0o600
184+
185+
def test_tighten_existing_database_file_permissions(self):
186+
cache_dir = self.files.full_path('cache')
187+
os.makedirs(cache_dir, mode=0o700)
188+
db_file = os.path.join(cache_dir, 'session.db')
189+
open(db_file, 'a').close()
190+
os.chmod(db_file, 0o644)
191+
cache_path = Path(cache_dir)
192+
193+
with patch('awscli.telemetry._CACHE_DIR', cache_path):
194+
with patch('awscli.telemetry._DATABASE_FILENAME', 'session.db'):
195+
conn = CLISessionDatabaseConnection()
196+
conn._connection.close()
197+
198+
file_mode = stat.S_IMODE(os.stat(db_file).st_mode)
199+
assert file_mode == 0o600
200+
201+
133202
class TestCLISessionDatabaseWriter:
134203
def test_write(self, session_writer, session_reader, session_sweeper):
135204
session_writer.write(

tests/unit/autoprompt/test_history.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
import json
1414
import os
1515
import shutil
16+
import stat
1617
import tempfile
1718

19+
import pytest
1820
from prompt_toolkit.buffer import Buffer
1921
from prompt_toolkit.completion import Completion
2022
from prompt_toolkit.document import Document
@@ -23,6 +25,7 @@
2325

2426
from awscli.autoprompt.history import HistoryCompleter, HistoryDriver
2527
from awscli.testutils import mock, unittest
28+
from tests.markers import skip_if_windows
2629

2730

2831
class TestHistoryCompleter(unittest.TestCase):
@@ -190,3 +193,60 @@ def test_handle_io_errors(self, file_history_mock):
190193
history_driver = HistoryDriver(self.filename)
191194
file_history_mock.store_string.side_effect = IOError
192195
history_driver.store_string('aws dynamodb create-table')
196+
197+
198+
@pytest.fixture
199+
def temp_dir():
200+
dirname = tempfile.mkdtemp()
201+
yield dirname
202+
shutil.rmtree(dirname)
203+
204+
205+
@skip_if_windows
206+
def test_create_directory_with_secure_permissions(temp_dir):
207+
subdir = os.path.join(temp_dir, 'newdir')
208+
filename = os.path.join(subdir, 'prompt_history.json')
209+
history_driver = HistoryDriver(filename)
210+
history_driver.store_string('aws ec2 describe-instances')
211+
212+
assert os.path.isdir(subdir)
213+
dir_mode = stat.S_IMODE(os.stat(subdir).st_mode)
214+
assert dir_mode == 0o700
215+
216+
217+
@skip_if_windows
218+
def test_tighten_existing_directory_permissions(temp_dir):
219+
subdir = os.path.join(temp_dir, 'existingdir')
220+
os.makedirs(subdir, mode=0o755)
221+
filename = os.path.join(subdir, 'prompt_history.json')
222+
223+
history_driver = HistoryDriver(filename)
224+
history_driver.store_string('aws ec2 describe-instances')
225+
226+
dir_mode = stat.S_IMODE(os.stat(subdir).st_mode)
227+
assert dir_mode == 0o700
228+
229+
230+
@skip_if_windows
231+
def test_create_file_with_secure_permissions(temp_dir):
232+
filename = os.path.join(temp_dir, 'prompt_history.json')
233+
history_driver = HistoryDriver(filename)
234+
history_driver.store_string('aws ec2 describe-instances')
235+
236+
assert os.path.isfile(filename)
237+
file_mode = stat.S_IMODE(os.stat(filename).st_mode)
238+
assert file_mode == 0o600
239+
240+
241+
@skip_if_windows
242+
def test_tighten_existing_file_permissions(temp_dir):
243+
filename = os.path.join(temp_dir, 'prompt_history.json')
244+
with open(filename, 'w') as f:
245+
json.dump({'version': 1, 'commands': []}, f)
246+
os.chmod(filename, 0o644)
247+
248+
history_driver = HistoryDriver(filename)
249+
history_driver.store_string('aws ec2 describe-instances')
250+
251+
file_mode = stat.S_IMODE(os.stat(filename).st_mode)
252+
assert file_mode == 0o600

tests/unit/customizations/history/test_db.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import numbers
1616
import os
1717
import re
18+
import stat
1819
import threading
1920

2021
from awscli.compat import queue
@@ -28,6 +29,7 @@
2829
)
2930
from awscli.testutils import FileCreator, mock, unittest
3031
from tests import CaseInsensitiveDict
32+
from tests.markers import skip_if_windows
3133

3234

3335
class FakeDatabaseConnection:
@@ -48,8 +50,20 @@ def tearDown(self):
4850

4951

5052
class TestDatabaseConnection(unittest.TestCase):
53+
def setUp(self):
54+
self.files = FileCreator()
55+
56+
def tearDown(self):
57+
self.files.remove_all()
58+
59+
@skip_if_windows
60+
@mock.patch('awscli.customizations.history.db.os.chmod')
61+
@mock.patch('awscli.customizations.history.db.os.path.exists')
5162
@mock.patch('awscli.compat.sqlite3.connect')
52-
def test_can_connect_to_argument_file(self, mock_connect):
63+
def test_can_connect_to_argument_file(
64+
self, mock_connect, mock_exists, mock_chmod
65+
):
66+
mock_exists.return_value = True
5367
expected_location = os.path.expanduser(
5468
os.path.join('~', 'foo', 'bar', 'baz.db')
5569
)
@@ -64,7 +78,7 @@ def test_does_try_to_enable_wal(self, mock_connect):
6478
conn._connection.execute.assert_any_call('PRAGMA journal_mode=WAL')
6579

6680
def test_does_ensure_table_created_first(self):
67-
db = DatabaseConnection(":memory:")
81+
db = DatabaseConnection(':memory:')
6882
cursor = db.execute('PRAGMA table_info(records)')
6983
schema = [col[:3] for col in cursor.fetchall()]
7084
expected_schema = [
@@ -85,6 +99,23 @@ def test_can_close(self, mock_connect):
8599
conn.close()
86100
self.assertTrue(connection.close.called)
87101

102+
@skip_if_windows
103+
def test_create_new_file_with_secure_permissions(self):
104+
db_filename = os.path.join(self.files.rootdir, 'new_secure.db')
105+
DatabaseConnection(db_filename)
106+
file_mode = stat.S_IMODE(os.stat(db_filename).st_mode)
107+
self.assertEqual(file_mode, 0o600)
108+
109+
@skip_if_windows
110+
def test_tighten_existing_file_permissions(self):
111+
db_filename = os.path.join(self.files.rootdir, 'existing.db')
112+
open(db_filename, 'a').close()
113+
os.chmod(db_filename, 0o644)
114+
115+
DatabaseConnection(db_filename)
116+
file_mode = stat.S_IMODE(os.stat(db_filename).st_mode)
117+
self.assertEqual(file_mode, 0o600)
118+
88119

89120
class TestDatabaseHistoryHandler(unittest.TestCase):
90121
UUID_PATTERN = re.compile(

0 commit comments

Comments
 (0)