| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| from app import config | |
| import dataclasses | |
| import datetime | |
| from email.header import decode_header | |
| from email.utils import getaddresses | |
| from enum import Enum, auto | |
| import hashlib | |
| import json | |
| import pathlib | |
| from quart import current_app | |
| import re | |
| GLASSWING_LABEL = "aaa-glasswing" | |
| """Label holding the CVEs allocated as part of the Glasswing audit, rather | |
| than reported separately from outside.""" | |
| NOT_FORWARDED_LABEL = "aaa-non-fwd" | |
| """Label collecting the threads that have not been forwarded to the PMC yet.""" | |
| GLASSWING_STATE = "glasswing" | |
| NOT_FORWARDED_STATE = "non-fwd" | |
| _CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}") | |
| _CVE_PUBLISHED_SUFFIX = "was pushed to cve.org" | |
| """Subject suffix of the mail announcing that a CVE went public.""" | |
| @dataclasses.dataclass(frozen=True) | |
| class Reporter: | |
| name: str | |
| email: str | |
| @property | |
| def display_name(self) -> str: | |
| return self.name or self.email or "unknown" | |
| @property | |
| def tooltip(self) -> str: | |
| if self.name and self.email: | |
| return f"{self.name} <{self.email}>" | |
| return self.email or self.name or "unknown" | |
| @property | |
| def initials(self) -> str: | |
| source = self.name or self.email.split('@', 1)[0] | |
| letters = [w[0] for w in re.split(r'\s+', source) if w] | |
| if not letters: | |
| return '?' | |
| return ''.join(letters[:3]).upper() | |
| @property | |
| def color(self) -> str: | |
| digest = hashlib.md5(self.email.lower().encode()).hexdigest() | |
| hue = int(digest[:4], 16) % 360 | |
| return f"hsl({hue}, 55%, 45%)" | |
| @dataclasses.dataclass(frozen=True) | |
| class ThreadLink: | |
| """A mailing list thread that was merged into a report, other than | |
| the thread the report is primarily about.""" | |
| title: str | |
| link: str | |
| @dataclasses.dataclass(frozen=True) | |
| class Report: | |
| security_team_name: str | |
| """internal label the security team assigned to the thread, | |
| this is not a secret but not meant for external communication""" | |
| cves: list[str] | |
| github: (str, str) | |
| """If this project tracks security issues in private GitHub issues, the GitHub issue link (title and url)""" | |
| jira: str | |
| """If this project tracks security issues in private jira issues, the Jira ID""" | |
| title: str | |
| message_id: str | |
| """message_id of the first email in the thread.""" | |
| listid: str | |
| """list id of the first email in the thread.""" | |
| link: str | |
| """link to the email archive for project members who may not | |
| necessarily be ASF members.""" | |
| reporter: Reporter | None | |
| state: str | |
| subproject: str | None | |
| """For PMCs split across subprojects, the subproject this report belongs to.""" | |
| timestamp: datetime.datetime | |
| duplicates: tuple[ThreadLink, ...] = () | |
| """Other threads collapsed into this report, earliest first.""" | |
| @property | |
| def date(self) -> datetime.date: | |
| return self.timestamp.date() | |
| @property | |
| def sanitized_title(self) -> str: | |
| cleaned = re.sub(r"[^A-Za-z0-9 .\-()]", " ", self.title) | |
| cleaned = re.sub(r"\s+", " ", cleaned).strip() | |
| return cleaned[:200] | |
| @property | |
| def asf_member_link(self) -> str: | |
| return _ponymail_link(self.message_id, self.listid) | |
| def _known_bad_address(time: str | None, address: str): | |
| if address.endswith("gsuite.cloud.apache.org"): | |
| return True | |
| if address == "pmc@beam.apache.org": | |
| return True | |
| if time: | |
| mailtime = datetime.datetime.fromtimestamp(time, tz=datetime.timezone.utc).date() | |
| spark_retirement = datetime.date.fromisoformat("2026-02-16") | |
| if address == 'security@spark.apache.org' and spark_retirement < mailtime: | |
| return True | |
| return False | |
| _SEC_LIST_RE = re.compile(r"security@([-\w]+).apache.org") | |
| def _apache_list_address(pmc: str, email): | |
| addresses = list(getaddresses([email['to']])) | |
| if 'cc' in email: | |
| addresses.extend(getaddresses([email['cc']])) | |
| for _, address in addresses: | |
| if _known_bad_address(email.get('mailtime'), address): | |
| continue | |
| if address == "officesecurity@lists.freedesktop.org": | |
| return "security@openoffice.apache.org" | |
| m = _SEC_LIST_RE.match(address) | |
| if m: | |
| address_pmc = m.group(1) | |
| if address_pmc == pmc and pmc in config.get().pmcs_with_security_emails: | |
| return address | |
| else: | |
| continue | |
| if address.endswith('.apache.org'): | |
| return address | |
| return None | |
| def _ponymail_link(message_id, listid): | |
| partly_encoded_message_id = message_id.replace(' ', '+').replace('+', '%2B').replace('=', '%3D').replace('@', '%40') | |
| return f"https://lists.apache.org/thread/{partly_encoded_message_id}?<{listid}>" | |
| def _project_link(pmc, emails): | |
| for email in emails[:5]: | |
| list_addr = _apache_list_address(pmc, email) | |
| if list_addr: | |
| return _ponymail_link(email['message_id'], list_addr.replace('@', '.')) | |
| return _ponymail_link(emails[0]['message_id'], "security.apache.org") | |
| def _decoded(raw: str) -> str: | |
| """A raw header value with its RFC 2047 encoded words decoded.""" | |
| try: | |
| return "".join( | |
| s.decode(c or "ascii", errors="replace") if isinstance(s, bytes) else s | |
| for s, c in decode_header(raw) | |
| ) | |
| except Exception: | |
| return raw | |
| def _subject(email) -> str: | |
| return _decoded(email.get('subj', '')).strip() | |
| def _title(email) -> str: | |
| title = _subject(email) or "(untitled)" | |
| if title.startswith("[SECURITY] "): | |
| title = title.removeprefix("[SECURITY] ") | |
| elif title.startswith("[Security] "): | |
| title = title.removeprefix("[Security] ") | |
| return title | |
| def _threads(pmc: str, emails) -> list[ThreadLink]: | |
| """One entry per distinct thread in the report, earliest first.""" | |
| groups: dict[str, list] = {} | |
| for email in emails: | |
| groups.setdefault(_thread_key(_title(email)), []).append(email) | |
| return [ | |
| ThreadLink(_title(group[0]), _project_link(pmc, group)) | |
| for group in groups.values() | |
| ] | |
| def _reporter(email) -> Reporter | None: | |
| addresses = [a for a in getaddresses([email.get('from', '')]) if a[1]] | |
| if not addresses: | |
| return None | |
| name, address = addresses[0] | |
| if ' via ' in name and address.endswith('apache.org'): | |
| reply_to_addresses = [a for a in getaddresses([email.get('reply_to', '')]) if a[1]] | |
| if not reply_to_addresses: | |
| return None | |
| name, address = reply_to_addresses[0] | |
| return Reporter(name=_decoded(name), email=address) | |
| def load_pmc_report(pmc: str, path: pathlib.Path) -> Report | None: | |
| with open(path) as f: | |
| emails = json.loads(f.read()) | |
| m = re.match(r"(?:CVE-\S+\s+)*CVE-\S+", path.name) | |
| cves = m.group(0).split() if m else [] | |
| return _load_pmc_report(pmc, path.name[:-5], cves, emails) | |
| def _load_pmc_report(pmc: str, name: str, cves: list[str], emails: list[object]) -> Report | None: | |
| jira = None | |
| if pmc in config.get().pmcs_using_jira: | |
| m = re.match(r"\S+ (\d+) .*", name) | |
| if m: | |
| jira = config.get().pmcs_using_jira[pmc] + "-" + m.group(1) | |
| github = None | |
| if pmc in config.get().pmcs_using_github: | |
| for email in emails: | |
| m = re.match(r"^\[.*#(\d+)\)$", email['subj']) | |
| if m: | |
| issue_nr = m.group(1) | |
| github = (f"#{issue_nr}", f"https://github.com/{config.get().pmcs_using_github[pmc]}/issues/{issue_nr}") | |
| break | |
| if cves: | |
| state = "confirmed" | |
| else: | |
| m = re.match(r".*wf (.*)", name) | |
| if not m: | |
| state = "untriaged" | |
| elif m.groups()[0] == "cve-allocation": | |
| state = "confirmed" | |
| else: | |
| state = m.groups()[0] | |
| # the subproject is the word after the leading date or CVE(s) | |
| m = re.match(r"(?:(?:CVE-\S+\s+)*CVE-\S+|\d{4}-\d{2}-\d{2})\s+(\w+)", name) | |
| subproject = m.group(1) if m else None | |
| if not emails: | |
| print(f"Empty label: {name}") | |
| return None | |
| first_email = emails[0] | |
| title = _title(first_email) | |
| apache_list_address = _apache_list_address(pmc, first_email) | |
| if apache_list_address: | |
| listid = apache_list_address.replace('@', '.') | |
| else: | |
| listid = 'security.apache.org' | |
| link = _project_link(pmc, emails) | |
| duplicates = tuple(t for t in _threads(pmc, emails)[1:] if t.link != link) | |
| return Report( | |
| name, | |
| cves, | |
| github, | |
| jira, | |
| title, | |
| first_email['message_id'], | |
| listid, | |
| link, | |
| _reporter(first_email), | |
| state, | |
| subproject, | |
| datetime.datetime.fromtimestamp(first_email['mailtime'], tz=datetime.timezone.utc), | |
| duplicates=duplicates, | |
| ) | |
| def _read_emails(path: pathlib.Path) -> list[object]: | |
| try: | |
| with open(path) as f: | |
| emails = json.loads(f.read()) | |
| except (OSError, ValueError): | |
| print(f"Unreadable label: {path.name}") | |
| return [] | |
| # these labels collect many threads, so tolerate the odd unusable mail | |
| return [ | |
| e for e in emails | |
| if isinstance(e, dict) and e.get('message_id') and e.get('mailtime') | |
| ] | |
| def _label_report(pmc: str, name: str, cves: list[str], email, state: str) -> Report: | |
| """A report that stands for one entry inside a multi-thread label.""" | |
| apache_list_address = _apache_list_address(pmc, email) | |
| if apache_list_address: | |
| listid = apache_list_address.replace('@', '.') | |
| else: | |
| listid = 'security.apache.org' | |
| return Report( | |
| name, | |
| cves, | |
| None, | |
| None, | |
| _title(email), | |
| email['message_id'], | |
| listid, | |
| _project_link(pmc, [email]), | |
| _reporter(email), | |
| state, | |
| None, | |
| datetime.datetime.fromtimestamp(email['mailtime'], tz=datetime.timezone.utc), | |
| ) | |
| def _load_glasswing_reports(pmc: str, path: pathlib.Path) -> list[Report]: | |
| """One entry per CVE allocated under the audit label but not published yet. | |
| A CVE counts as published once the label holds the mail announcing it was | |
| pushed to cve.org; until then it is listed with the mail that allocated it. | |
| """ | |
| allocated: dict[str, object] = {} | |
| published: set[str] = set() | |
| for email in _read_emails(path): | |
| subject = _subject(email) | |
| cves = _CVE_RE.findall(subject) | |
| if not cves: | |
| continue | |
| if subject.lower().endswith(_CVE_PUBLISHED_SUFFIX): | |
| published.update(cves) | |
| continue | |
| for cve in cves: | |
| allocated.setdefault(cve, email) | |
| return [ | |
| _label_report(pmc, path.name, [cve], email, GLASSWING_STATE) | |
| for cve, email in allocated.items() | |
| if cve not in published | |
| ] | |
| # a reply prefix, in the languages reporters actually use, or a [SECURITY] tag | |
| _SUBJECT_PREFIX_RE = re.compile( | |
| r"^\s*(?:(?:re|aw|antw|fw|fwd|sv|vs)\s*(?:\[\d+\])?\s*:|\[security\])\s*", | |
| re.IGNORECASE, | |
| ) | |
| def _thread_key(subject: str) -> str: | |
| """Group a thread's mails together by their subject, minus any prefixes.""" | |
| while True: | |
| stripped = _SUBJECT_PREFIX_RE.sub("", subject) | |
| if stripped == subject: | |
| break | |
| subject = stripped | |
| return re.sub(r"\s+", " ", subject).strip().lower() | |
| def _load_not_forwarded_reports(pmc: str, path: pathlib.Path) -> list[Report]: | |
| """One entry per thread head under the not-forwarded label.""" | |
| heads: dict[str, object] = {} | |
| for email in _read_emails(path): | |
| key = _thread_key(_subject(email)) | |
| head = heads.get(key) | |
| if head is None or email['mailtime'] < head['mailtime']: | |
| heads[key] = email | |
| return [ | |
| _label_report(pmc, path.name, [], email, NOT_FORWARDED_STATE) | |
| for email in heads.values() | |
| ] | |
| _LABEL_LOADERS = { | |
| GLASSWING_LABEL: _load_glasswing_reports, | |
| NOT_FORWARDED_LABEL: _load_not_forwarded_reports, | |
| } | |
| MULTI_THREAD_LABELS = frozenset(_LABEL_LOADERS) | |
| """Labels that hold many threads instead of a single report.""" | |
| def _load_reports_dir(pmc: str) -> list[Report]: | |
| d = config.get().data_dir_path / pmc | |
| threads = list(d.glob('**/*.json')) | |
| result: list[Report] = [] | |
| for t in threads: | |
| # a handful of labels hold many threads instead of a single report | |
| loader = _LABEL_LOADERS.get(t.stem) | |
| if loader: | |
| result.extend(loader(pmc, t)) | |
| continue | |
| r = load_pmc_report(pmc, t) | |
| if r is not None: | |
| result.append(r) | |
| return result | |
| async def load_pmc_reports(pmc: str) -> list[Report]: | |
| if not re.fullmatch(r"[a-z0-9]+", pmc): | |
| raise ValueError(f"invalid PMC name: {pmc!r}") | |
| result = _load_reports_dir(pmc) | |
| # attic projects no longer have a PMC, so the security team | |
| # handles their reports directly | |
| if pmc == "security": | |
| for attic_pmc in config.get().pmcs_in_attic: | |
| if not re.fullmatch(r"[a-z0-9]+", attic_pmc): | |
| continue | |
| result.extend( | |
| dataclasses.replace(r, subproject=attic_pmc) | |
| for r in _load_reports_dir(attic_pmc) | |
| ) | |
| return result |