Skip to content

Commit 01a21ee

Browse files
Tighten output file permissions (#10197)
* 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 output file permissions fix * Add comment explaining permission bitmask in tests
1 parent 5d519db commit 01a21ee

5 files changed

Lines changed: 156 additions & 68 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: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
# ANY KIND, either express or implied. See the License for the specific
1212
# language governing permissions and limitations under the License.
1313
"""Add S3 specific event streaming output arg."""
14-
from awscli.arguments import CustomArgument
1514

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

1719
STREAM_HELP_TEXT = 'Filename where the records will be saved'
1820

@@ -24,26 +26,28 @@ class DocSectionNotFoundError(Exception):
2426
def register_event_stream_arg(event_handlers):
2527
event_handlers.register(
2628
'building-argument-table.s3api.select-object-content',
27-
add_event_stream_output_arg)
29+
add_event_stream_output_arg,
30+
)
2831
event_handlers.register_last(
29-
'doc-output.s3api.select-object-content',
30-
replace_event_stream_docs
32+
'doc-output.s3api.select-object-content', replace_event_stream_docs
3133
)
3234

3335

3436
def register_document_expires_string(event_handlers):
35-
event_handlers.register_last(
36-
'doc-output.s3api',
37-
document_expires_string
38-
)
37+
event_handlers.register_last('doc-output.s3api', document_expires_string)
3938

40-
def add_event_stream_output_arg(argument_table, operation_model,
41-
session, **kwargs):
39+
40+
def add_event_stream_output_arg(
41+
argument_table, operation_model, session, **kwargs
42+
):
4243
argument_table['outfile'] = S3SelectStreamOutputArgument(
43-
name='outfile', help_text=STREAM_HELP_TEXT,
44-
cli_type_name='string', positional_arg=True,
44+
name='outfile',
45+
help_text=STREAM_HELP_TEXT,
46+
cli_type_name='string',
47+
positional_arg=True,
4548
stream_key=operation_model.output_shape.serialization['payload'],
46-
session=session)
49+
session=session,
50+
)
4751

4852

4953
def replace_event_stream_docs(help_command, **kwargs):
@@ -56,11 +60,14 @@ def replace_event_stream_docs(help_command, **kwargs):
5660
# This should never happen, but in the rare case that it does
5761
# we should be raising something with a helpful error message.
5862
raise DocSectionNotFoundError(
59-
'Could not find the "output" section for the command: %s'
60-
% help_command)
63+
f'Could not find the "output" section for the command: {help_command}'
64+
)
6165
doc.write('======\nOutput\n======\n')
62-
doc.write("This command generates no output. The selected "
63-
"object content is written to the specified outfile.\n")
66+
doc.write(
67+
"This command generates no output. The selected "
68+
"object content is written to the specified outfile.\n"
69+
)
70+
6471

6572
def document_expires_string(help_command, **kwargs):
6673
doc = help_command.doc
@@ -78,7 +85,7 @@ def document_expires_string(help_command, **kwargs):
7885
f'\n\n{" " * doc.style.indentation * doc.style.indent_width}',
7986
'ExpiresString -> (string)\n\n',
8087
'\tThe raw, unparsed value of the ``Expires`` field.',
81-
f'\n\n{" " * doc.style.indentation * doc.style.indent_width}'
88+
f'\n\n{" " * doc.style.indentation * doc.style.indent_width}',
8289
]
8390

8491
for idx, write in enumerate(deprecation_note_and_expires_string):
@@ -91,7 +98,7 @@ class S3SelectStreamOutputArgument(CustomArgument):
9198
_DOCUMENT_AS_REQUIRED = True
9299

93100
def __init__(self, stream_key, session, **kwargs):
94-
super(S3SelectStreamOutputArgument, self).__init__(**kwargs)
101+
super().__init__(**kwargs)
95102
# This is the key in the response body where we can find the
96103
# streamed contents.
97104
self._stream_key = stream_key
@@ -100,8 +107,9 @@ def __init__(self, stream_key, session, **kwargs):
100107

101108
def add_to_params(self, parameters, value):
102109
self._output_file = value
103-
self._session.register('after-call.s3.SelectObjectContent',
104-
self.save_file)
110+
self._session.register(
111+
'after-call.s3.SelectObjectContent', self.save_file
112+
)
105113

106114
def save_file(self, parsed, **kwargs):
107115
# This method is hooked into after-call which fires
@@ -112,7 +120,11 @@ def save_file(self, parsed, **kwargs):
112120
if self._stream_key not in parsed:
113121
return
114122
event_stream = parsed[self._stream_key]
115-
with open(self._output_file, 'wb') as fp:
123+
fd = os.open(
124+
self._output_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
125+
)
126+
os.chmod(self._output_file, 0o600)
127+
with os.fdopen(fd, 'wb') as fp:
116128
for event in event_stream:
117129
if 'Records' in event:
118130
fp.write(event['Records']['Payload'])

awscli/customizations/streamingoutputarg.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,26 @@
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
1618

1719

18-
def add_streaming_output_arg(argument_table, operation_model,
19-
session, **kwargs):
20+
def add_streaming_output_arg(
21+
argument_table, operation_model, session, **kwargs
22+
):
2023
# Implementation detail: hooked up to 'building-argument-table'
2124
# event.
2225
if _has_streaming_output(operation_model):
2326
streaming_argument_name = _get_streaming_argument_name(operation_model)
2427
argument_table['outfile'] = StreamingOutputArgument(
2528
response_key=streaming_argument_name,
2629
operation_model=operation_model,
27-
session=session, name='outfile')
30+
session=session,
31+
name='outfile',
32+
)
2833

2934

3035
def _has_streaming_output(model):
@@ -36,15 +41,16 @@ def _get_streaming_argument_name(model):
3641

3742

3843
class StreamingOutputArgument(BaseCLIArgument):
39-
4044
BUFFER_SIZE = 32768
4145
HELP = 'Filename where the content will be saved'
4246

43-
def __init__(self, response_key, operation_model, name,
44-
session, buffer_size=None):
47+
def __init__(
48+
self, response_key, operation_model, name, session, buffer_size=None
49+
):
4550
self._name = name
46-
self.argument_model = Shape('StreamingOutputArgument',
47-
{'type': 'string'})
51+
self.argument_model = Shape(
52+
'StreamingOutputArgument', {'type': 'string'}
53+
)
4854
if buffer_size is None:
4955
buffer_size = self.BUFFER_SIZE
5056
self._buffer_size = buffer_size
@@ -81,15 +87,15 @@ def documentation(self):
8187
return self.HELP
8288

8389
def add_to_parser(self, parser):
84-
parser.add_argument(self._name, metavar=self.py_name,
85-
help=self.HELP)
90+
parser.add_argument(self._name, metavar=self.py_name, help=self.HELP)
8691

8792
def add_to_params(self, parameters, value):
8893
self._output_file = value
8994
service_id = self._operation_model.service_model.service_id.hyphenize()
9095
operation_name = self._operation_model.name
91-
self._session.register('after-call.%s.%s' % (
92-
service_id, operation_name), self.save_file)
96+
self._session.register(
97+
f'after-call.{service_id}.{operation_name}', self.save_file
98+
)
9399

94100
def save_file(self, parsed, **kwargs):
95101
if self._response_key not in parsed:
@@ -100,7 +106,11 @@ def save_file(self, parsed, **kwargs):
100106
return
101107
body = parsed[self._response_key]
102108
buffer_size = self._buffer_size
103-
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:
104114
data = body.read(buffer_size)
105115
while data:
106116
fp.write(data)

tests/functional/s3api/test_select_object_content.py

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,17 @@
1212
# ANY KIND, either express or implied. See the License for the specific
1313
# language governing permissions and limitations under the License.
1414
import os
15-
import tempfile
1615
import shutil
16+
import tempfile
1717

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

2124

2225
class TestGetObject(BaseAWSCommandParamsTest):
23-
2426
prefix = ['s3api', 'select-object-content']
2527

2628
def setUp(self):
@@ -36,11 +38,23 @@ def create_fake_payload(self):
3638
yield {'Records': {'Payload': b'a,b,c,d\n'}}
3739
# These next two events are ignored because they aren't
3840
# "Records".
39-
yield {'Progress': {'Details': {'BytesScanned': 1048576,
40-
'BytesProcessed': 37748736}}}
41+
yield {
42+
'Progress': {
43+
'Details': {
44+
'BytesScanned': 1048576,
45+
'BytesProcessed': 37748736,
46+
}
47+
}
48+
}
4149
yield {'Records': {'Payload': b'e,f,g,h\n'}}
42-
yield {'Stats': {'Details': {'BytesProcessed': 62605400,
43-
'BytesScanned': 1662276}}}
50+
yield {
51+
'Stats': {
52+
'Details': {
53+
'BytesProcessed': 62605400,
54+
'BytesScanned': 1662276,
55+
}
56+
}
57+
}
4458
yield {'End': {}}
4559

4660
def test_can_stream_to_file(self):
@@ -51,14 +65,15 @@ def test_can_stream_to_file(self):
5165
cmdline.extend(['--expression', 'SELECT * FROM S3Object'])
5266
cmdline.extend(['--expression-type', 'SQL'])
5367
cmdline.extend(['--request-progress', 'Enabled=True'])
54-
cmdline.extend(['--input-serialization',
55-
'{"CSV": {}, "CompressionType": "GZIP"}'])
68+
cmdline.extend(
69+
['--input-serialization', '{"CSV": {}, "CompressionType": "GZIP"}']
70+
)
5671
cmdline.extend(['--output-serialization', '{"CSV": {}}'])
5772
cmdline.extend([filename])
5873

5974
expected_params = {
6075
'Bucket': 'mybucket',
61-
'Key': u'mykey',
76+
'Key': 'mykey',
6277
'Expression': 'SELECT * FROM S3Object',
6378
'ExpressionType': 'SQL',
6479
'InputSerialization': {'CSV': {}, 'CompressionType': 'GZIP'},
@@ -67,12 +82,31 @@ def test_can_stream_to_file(self):
6782
}
6883
stdout = self.assert_params_for_cmd(cmdline, expected_params)[0]
6984
self.assertEqual(stdout, '')
70-
with open(filename, 'r') as f:
85+
with open(filename) as f:
7186
contents = f.read()
72-
self.assertEqual(contents, (
73-
'a,b,c,d\n'
74-
'e,f,g,h\n'
75-
))
87+
self.assertEqual(contents, ('a,b,c,d\n' 'e,f,g,h\n'))
88+
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)
76110

77111
def test_errors_are_propagated(self):
78112
self.http_response.status_code = 400
@@ -83,30 +117,39 @@ def test_errors_are_propagated(self):
83117
}
84118
}
85119
cmdline = self.prefix + [
86-
'--bucket', 'mybucket',
87-
'--key', 'mykey',
88-
'--expression', 'SELECT * FROM S3Object',
89-
'--expression-type', 'SQL',
90-
'--request-progress', 'Enabled=True',
91-
'--input-serialization', '{"CSV": {}, "CompressionType": "GZIP"}',
92-
'--output-serialization', '{"CSV": {}}',
120+
'--bucket',
121+
'mybucket',
122+
'--key',
123+
'mykey',
124+
'--expression',
125+
'SELECT * FROM S3Object',
126+
'--expression-type',
127+
'SQL',
128+
'--request-progress',
129+
'Enabled=True',
130+
'--input-serialization',
131+
'{"CSV": {}, "CompressionType": "GZIP"}',
132+
'--output-serialization',
133+
'{"CSV": {}}',
93134
os.path.join(self._tempdir, 'outfile'),
94135
]
95136
expected_params = {
96137
'Bucket': 'mybucket',
97-
'Key': u'mykey',
138+
'Key': 'mykey',
98139
'Expression': 'SELECT * FROM S3Object',
99140
'ExpressionType': 'SQL',
100141
'InputSerialization': {'CSV': {}, 'CompressionType': 'GZIP'},
101142
'OutputSerialization': {'CSV': {}},
102143
'RequestProgress': {'Enabled': True},
103144
}
104145
self.assert_params_for_cmd(
105-
cmd=cmdline, params=expected_params,
146+
cmd=cmdline,
147+
params=expected_params,
106148
expected_rc=255,
107149
stderr_contains=(
108150
'An error occurred (CastFailed) when '
109-
'calling the SelectObjectContent operation'),
151+
'calling the SelectObjectContent operation'
152+
),
110153
)
111154

112155

@@ -116,8 +159,7 @@ def test_output(self):
116159
# We don't want to be super picky because the wording may change
117160
# We just want to verify the Output section was customized.
118161
self.assert_contains(
119-
'Output\n======\n'
120-
'This command generates no output'
162+
'Output\n======\n' 'This command generates no output'
121163
)
122164
self.assert_not_contains('[outfile')
123165
self.assert_contains('outfile')

0 commit comments

Comments
 (0)