Skip to content

Commit 76e36cb

Browse files
Tighten output file permissions (#10196)
* Set owner-only (0600) permissions on streaming and S3 Select output files * Add tests to verify output file permissions are set to 0600 * Add changelog entry for file permissions * Use os.chmod instead of os.fchmod * Add comment explaining permission bitmask in tests
1 parent c3d9f10 commit 76e36cb

5 files changed

Lines changed: 75 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": "bugfix",
3+
"category": "s3, streaming output",
4+
"description": "Output files created by S3 Select and streaming output commands are now created with owner-only permissions (0600). Existing files are also tightened to 0600 when overwritten."
5+
}

awscli/customizations/s3events.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# language governing permissions and limitations under the License.
1313
"""Add S3 specific event streaming output arg."""
1414

15+
import os
16+
1517
from awscli.arguments import CustomArgument
1618

1719
STREAM_HELP_TEXT = 'Filename where the records will be saved'
@@ -59,8 +61,7 @@ def replace_event_stream_docs(help_command, **kwargs):
5961
# This should never happen, but in the rare case that it does
6062
# we should be raising something with a helpful error message.
6163
raise DocSectionNotFoundError(
62-
'Could not find the "output" section for the command: %s'
63-
% help_command
64+
f'Could not find the "output" section for the command: {help_command}'
6465
)
6566
doc.write('======\nOutput\n======\n')
6667
doc.write(
@@ -98,7 +99,7 @@ class S3SelectStreamOutputArgument(CustomArgument):
9899
_DOCUMENT_AS_REQUIRED = True
99100

100101
def __init__(self, stream_key, session, **kwargs):
101-
super(S3SelectStreamOutputArgument, self).__init__(**kwargs)
102+
super().__init__(**kwargs)
102103
# This is the key in the response body where we can find the
103104
# streamed contents.
104105
self._stream_key = stream_key
@@ -120,7 +121,11 @@ def save_file(self, parsed, **kwargs):
120121
if self._stream_key not in parsed:
121122
return
122123
event_stream = parsed[self._stream_key]
123-
with open(self._output_file, 'wb') as fp:
124+
fd = os.open(
125+
self._output_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
126+
)
127+
os.chmod(self._output_file, 0o600)
128+
with os.fdopen(fd, 'wb') as fp:
124129
for event in event_stream:
125130
if 'Records' in event:
126131
fp.write(event['Records']['Payload'])

awscli/customizations/streamingoutputarg.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
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
14+
1315
from botocore.model import Shape
1416

1517
from awscli.arguments import BaseCLIArgument
@@ -92,7 +94,7 @@ def add_to_params(self, parameters, value):
9294
service_id = self._operation_model.service_model.service_id.hyphenize()
9395
operation_name = self._operation_model.name
9496
self._session.register(
95-
'after-call.%s.%s' % (service_id, operation_name), self.save_file
97+
f'after-call.{service_id}.{operation_name}', self.save_file
9698
)
9799

98100
def save_file(self, parsed, **kwargs):
@@ -104,7 +106,11 @@ def save_file(self, parsed, **kwargs):
104106
return
105107
body = parsed[self._response_key]
106108
buffer_size = self._buffer_size
107-
with open(self._output_file, 'wb') as fp:
109+
fd = os.open(
110+
self._output_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
111+
)
112+
os.chmod(self._output_file, 0o600)
113+
with os.fdopen(fd, 'wb') as fp:
108114
data = body.read(buffer_size)
109115
while data:
110116
fp.write(data)

tests/functional/s3api/test_select_object_content.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,23 @@
1515
import shutil
1616
import tempfile
1717

18-
from awscli.testutils import BaseAWSCommandParamsTest, BaseAWSHelpOutputTest
18+
from awscli.testutils import (
19+
BaseAWSCommandParamsTest,
20+
BaseAWSHelpOutputTest,
21+
skip_if_windows,
22+
)
1923

2024

2125
class TestGetObject(BaseAWSCommandParamsTest):
2226
prefix = ['s3api', 'select-object-content']
2327

2428
def setUp(self):
25-
super(TestGetObject, self).setUp()
29+
super().setUp()
2630
self.parsed_response = {'Payload': self.create_fake_payload()}
2731
self._tempdir = tempfile.mkdtemp()
2832

2933
def tearDown(self):
30-
super(TestGetObject, self).tearDown()
34+
super().tearDown()
3135
shutil.rmtree(self._tempdir)
3236

3337
def create_fake_payload(self):
@@ -82,6 +86,28 @@ def test_can_stream_to_file(self):
8286
contents = f.read()
8387
self.assertEqual(contents, ('a,b,c,d\n' 'e,f,g,h\n'))
8488

89+
@skip_if_windows('chmod is not supported on Windows')
90+
def test_output_file_permissions(self):
91+
filename = os.path.join(self._tempdir, 'outfile_perms')
92+
cmdline = self.prefix + [
93+
'--bucket',
94+
'mybucket',
95+
'--key',
96+
'mykey',
97+
'--expression',
98+
'SELECT * FROM S3Object',
99+
'--expression-type',
100+
'SQL',
101+
'--input-serialization',
102+
'{"CSV": {}}',
103+
'--output-serialization',
104+
'{"CSV": {}}',
105+
filename,
106+
]
107+
self.assert_params_for_cmd(cmdline, ignore_params=True)
108+
# Mask file type bits to isolate permission bits (rwxrwxrwx)
109+
self.assertEqual(os.stat(filename).st_mode & 0o777, 0o600)
110+
85111
def test_errors_are_propagated(self):
86112
self.http_response.status_code = 400
87113
self.parsed_response = {

tests/functional/test_streaming_output.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,23 @@
1111
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1212
# ANY KIND, either express or implied. See the License for the specific
1313
# language governing permissions and limitations under the License.
14+
import os
15+
1416
from awscli.compat import BytesIO
15-
from awscli.testutils import BaseAWSCommandParamsTest, FileCreator
17+
from awscli.testutils import (
18+
BaseAWSCommandParamsTest,
19+
FileCreator,
20+
skip_if_windows,
21+
)
1622

1723

1824
class TestStreamingOutput(BaseAWSCommandParamsTest):
1925
def setUp(self):
20-
super(TestStreamingOutput, self).setUp()
26+
super().setUp()
2127
self.files = FileCreator()
2228

2329
def tearDown(self):
24-
super(TestStreamingOutput, self).tearDown()
30+
super().tearDown()
2531
self.files.remove_all()
2632

2733
def test_get_media_streaming_output(self):
@@ -41,3 +47,18 @@ def test_get_media_streaming_output(self):
4147
self.assert_params_for_cmd(cmdline % outpath, params)
4248
with open(outpath, 'rb') as outfile:
4349
self.assertEqual(outfile.read(), b'testbody')
50+
51+
@skip_if_windows('chmod is not supported on Windows')
52+
def test_streaming_output_file_permissions(self):
53+
cmdline = (
54+
'kinesis-video-media get-media --stream-name test-stream '
55+
'--start-selector StartSelectorType=EARLIEST %s'
56+
)
57+
self.parsed_response = {
58+
'ContentType': 'video/webm',
59+
'Payload': BytesIO(b'testbody'),
60+
}
61+
outpath = self.files.full_path('outfile')
62+
self.assert_params_for_cmd(cmdline % outpath, ignore_params=True)
63+
# Mask file type bits to isolate permission bits (rwxrwxrwx)
64+
self.assertEqual(os.stat(outpath).st_mode & 0o777, 0o600)

0 commit comments

Comments
 (0)