1#
2# Licensed to the Apache Software Foundation (ASF) under one
3# or more contributor license agreements.  See the NOTICE file
4# distributed with this work for additional information
5# regarding copyright ownership.  The ASF licenses this file
6# to you under the Apache License, Version 2.0 (the
7# "License"); you may not use this file except in compliance
8# with the License.  You may obtain a copy of the License at
9#
10#   http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing,
13# software distributed under the License is distributed on an
14# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15# KIND, either express or implied.  See the License for the
16# specific language governing permissions and limitations
17# under the License.
18#
19
20"""
21Generator of signed advisory mails
22"""
23
24from __future__ import absolute_import
25
26import re
27import uuid
28import hashlib
29import smtplib
30import textwrap
31import email.utils
32
33from email.mime.multipart import MIMEMultipart
34from email.mime.text import MIMEText
35
36try:
37    import gnupg
38except ImportError:
39    import security._gnupg as gnupg
40
41import security.parser
42
43
44class Mailer(object):
45    """
46    Constructs signed PGP/MIME advisory mails.
47    """
48
49    def __init__(self, notification, sender, message_template,
50                 release_date, dist_revision, *release_versions):
51        assert len(notification) > 0
52        self.__sender = sender
53        self.__notification = notification
54        self.__message_content = self.__message_content(
55            message_template, release_date, dist_revision, release_versions)
56
57    def __subject(self):
58        """
59        Construct a subject line for the notification mail.
60        """
61
62        template = ('Confidential pre-notification of'
63                    ' {multiple}Subversion {culprit}{vulnerability}')
64
65        # Construct the {culprit} replacement value. If all advisories
66        # are either about the server or the client, use the
67        # appropriate value; for mixed server/client advisories, use
68        # an empty string.
69        culprit = set()
70        for metadata in self.__notification:
71            culprit |= metadata.culprit
72        assert len(culprit) > 0
73        if len(culprit) > 1:
74            culprit = ''
75        elif self.__notification.Metadata.CULPRIT_CLIENT in culprit:
76            culprit = 'client '
77        elif self.__notification.Metadata.CULPRIT_SERVER in culprit:
78            culprit = 'server '
79        else:
80            raise ValueError('Unknown culprit ' + repr(culprit))
81
82        # Construct the format parameters
83        if len(self.__notification) > 1:
84            kwargs = dict(multiple='multiple ', culprit=culprit,
85                          vulnerability='vulnerabilities')
86        else:
87            kwargs = dict(multiple='a ', culprit=culprit,
88                          vulnerability='vulnerability')
89
90        return template.format(**kwargs)
91
92    def __message_content(self, message_template,
93                          release_date, dist_revision, release_versions):
94        """
95        Construct the message from the notification mail template.
96        """
97
98        # Construct the replacement arguments for the notification template
99        culprits = set()
100        advisories = []
101        base_version_keys = self.__notification.base_version_keys()
102        for metadata in self.__notification:
103            culprits |= metadata.culprit
104            advisories.append(
105                '  * {}\n    {}'.format(metadata.tracking_id, metadata.title))
106        release_version_keys = set(security.parser.Patch.split_version(n)
107                                   for n in release_versions)
108
109        multi = (len(self.__notification) > 1)
110        kwargs = dict(multiple=(multi and 'multiple ' or 'a '),
111                      alert=(multi and 'alerts' or 'alert'),
112                      culprits=self.__culprits(culprits),
113                      advisories='\n'.join(advisories),
114                      release_date=release_date.strftime('%d %B %Y'),
115                      release_day=release_date.strftime('%d %B'),
116                      base_versions = self.__versions(base_version_keys),
117                      release_versions = self.__versions(release_version_keys),
118                      dist_revision=str(dist_revision))
119
120        # Parse, interpolate and rewrap the notification template
121        wrapped = []
122        content = security.parser.Text(message_template)
123        for line in content.text.format(**kwargs).split('\n'):
124            if len(line) > 0 and not line[0].isspace():
125                for part in textwrap.wrap(line,
126                                          break_long_words=False,
127                                          break_on_hyphens=False):
128                    wrapped.append(part)
129            else:
130                wrapped.append(line)
131        return security.parser.Text(None, '\n'.join(wrapped).encode('utf-8'))
132
133    def __versions(self, versions):
134        """
135        Return a textual representation of the set of VERSIONS
136        suitable for inclusion in a notification mail.
137        """
138
139        text = tuple(security.parser.Patch.join_version(n)
140                     for n in sorted(versions))
141        assert len(text) > 0
142        if len(text) == 1:
143            return text[0]
144        elif len(text) == 2:
145            return ' and '.join(text)
146        else:
147            return ', '.join(text[:-1]) + ' and ' + text[-1]
148
149    def __culprits(self, culprits):
150        """
151        Return a textual representation of the set of CULPRITS
152        suitable for inclusion in a notification mail.
153        """
154
155        if self.__notification.Metadata.CULPRIT_CLIENT in culprits:
156            if self.__notification.Metadata.CULPRIT_SERVER in culprits:
157                return 'clients and servers'
158            else:
159                return 'clients'
160        elif self.__notification.Metadata.CULPRIT_SERVER in culprits:
161            return 'servers'
162        else:
163            raise ValueError('Unknown culprit ' + repr(culprits))
164
165    def __attachments(self):
166        filenames = set()
167
168        def attachment(filename, description, encoding, content):
169            if filename in filenames:
170                raise ValueError('Named attachment already exists: '
171                                 + filename)
172            filenames.add(filename)
173
174            att = MIMEText('', 'plain', 'utf-8')
175            att.set_param('name', filename)
176            att.replace_header('Content-Transfer-Encoding', encoding)
177            att.add_header('Content-Description', description)
178            att.add_header('Content-Disposition',
179                            'attachment', filename=filename)
180            att.set_payload(content)
181            return att
182
183        for metadata in self.__notification:
184            filename = metadata.tracking_id + '-advisory.txt'
185            description = metadata.tracking_id + ' Advisory'
186            yield attachment(filename, description, 'quoted-printable',
187                             metadata.advisory.quoted_printable)
188
189            for patch in metadata.patches:
190                filename = (metadata.tracking_id +
191                            '-' + patch.base_version + '.patch')
192                description = (metadata.tracking_id
193                               + ' Patch for Subversion ' + patch.base_version)
194                yield attachment(filename, description, 'base64', patch.base64)
195
196    def generate_message(self):
197        message = SignedMessage(
198            self.__message_content,
199            self.__attachments())
200        message['From'] = self.__sender
201        message['Reply-To'] = self.__sender
202        message['To'] = self.__sender     # Will be replaced later
203        message['Subject'] = self.__subject()
204        message['Date'] = email.utils.formatdate()
205
206        # Try to make the message-id refer to the sender's domain
207        address = email.utils.parseaddr(self.__sender)[1]
208        if not address:
209            domain = None
210        else:
211            domain = address.split('@')[1]
212            if not domain:
213                domain = None
214
215        idstring = uuid.uuid1().hex
216        try:
217            msgid = email.utils.make_msgid(idstring, domain=domain)
218        except TypeError:
219            # The domain keyword was added in Python 3.2
220            msgid = email.utils.make_msgid(idstring)
221        message["Message-ID"] = msgid
222        return message
223
224    def send_mail(self, message, username, password, recipients=None,
225                  host='mail-relay.apache.org', starttls=True, port=None):
226        if not port and starttls:
227            port = 587
228        server = smtplib.SMTP(host, port)
229        if starttls:
230            server.starttls()
231        if username and password:
232            server.login(username, password)
233
234        def send(message):
235            # XXX: The from,to arguments should be bare addresses with no "foo:"
236            #      prefix.  It works this way in practice, but that appears to
237            #      be an accident of implementation of smtplib.
238            server.sendmail("From: " + message['From'],
239                            "To: " + message['To'],
240                            message.as_string())
241
242        if recipients is None:
243            # Test mode, send message back to originator to checck
244            # that contents and signature are OK.
245            message.replace_header('To', message['From'])
246            send(message)
247        else:
248            for recipient in recipients:
249                message.replace_header('To', recipient)
250                send(message)
251        server.quit()
252
253
254class SignedMessage(MIMEMultipart):
255    """
256    The signed PGP/MIME message.
257    """
258
259    def __init__(self, message, attachments,
260                 gpgbinary='gpg', gnupghome=None, use_agent=True,
261                 keyring=None, keyid=None):
262
263        # Hack around the fact that the Pyton 2.x MIMEMultipart is not
264        # a new-style class.
265        try:
266            unicode                       # Doesn't exist in Python 3
267            MIMEMultipart.__init__(self, 'signed')
268        except NameError:
269            super(SignedMessage, self).__init__('signed')
270
271        payload = self.__payload(message, attachments)
272        signature = self.__signature(
273            payload, gpgbinary, gnupghome, use_agent, keyring, keyid)
274
275        self.set_param('protocol', 'application/pgp-signature')
276        self.set_param('micalg', 'pgp-sha512')   ####!!! GET THIS FROM KEY!
277        self.preamble = 'This is an OpenPGP/MIME signed message.'
278        self.attach(payload)
279        self.attach(signature)
280
281    def __payload(self, message, attachments):
282        """
283        Create the payload from the given MESSAGE and a
284        set of pre-cooked ATTACHMENTS.
285        """
286
287        payload = MIMEMultipart()
288        payload.preamble = 'This is a multi-part message in MIME format.'
289
290        msg = MIMEText('', 'plain', 'utf-8')
291        msg.replace_header('Content-Transfer-Encoding', 'quoted-printable')
292        msg.set_payload(message.quoted_printable)
293        payload.attach(msg)
294
295        for att in attachments:
296            payload.attach(att)
297        return payload
298
299    def __signature(self, payload,
300                    gpgbinary, gnupghome, use_agent, keyring, keyid):
301        """
302        Sign the PAYLOAD and return the detached signature as
303        a MIME attachment.
304        """
305
306        # RFC3156 section 5 says line endings in the signed message
307        # must be canonical <CR><LF>.
308        cleartext = re.sub(r'\r?\n', '\r\n', payload.as_string())
309
310        gpg = gnupg.GPG(gpgbinary=gpgbinary, gnupghome=gnupghome,
311                        use_agent=use_agent, keyring=keyring)
312        signature = gpg.sign(cleartext,
313                             keyid=keyid, detach=True, clearsign=False)
314        sig = MIMEText('')
315        sig.set_type('application/pgp-signature')
316        sig.set_charset(None)
317        sig.set_param('name', 'signature.asc')
318        sig.add_header('Content-Description', 'OpenPGP digital signature')
319        sig.add_header('Content-Disposition',
320                       'attachment', filename='signature.asc')
321        sig.set_payload(str(signature))
322        return sig
323