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