From 10d42ece6b5b87516fc70b8651f9190c17e2f6e6 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Mon, 4 Mar 2024 15:43:40 -0600 Subject: [PATCH 1/5] Add PyPI-related utilities Add a utility method for extracting the file path out of an Inspector URL --- src/reporter/utils/pypi.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/reporter/utils/pypi.py diff --git a/src/reporter/utils/pypi.py b/src/reporter/utils/pypi.py new file mode 100644 index 0000000..e01970f --- /dev/null +++ b/src/reporter/utils/pypi.py @@ -0,0 +1,13 @@ +"""Utilities related to PyPI""" + +from urllib.parse import urlparse + +def file_path_from_inspector_url(inspector_url: str) -> str: + """Parse the file path out of a PyPI inspector URL""" + + parsed_url = urlparse(inspector_url) + path = parsed_url.path.strip("/") + segments = path.split("/") + + # The 8th element of path is when the file path starts + return "/".join(segments[8:]) From 176beb08537e4a0b5377c03adf39b56e96a2c8e4 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Mon, 4 Mar 2024 15:46:07 -0600 Subject: [PATCH 2/5] Add schemas Add schemas.py that will contain all Pydantic models for the API. Currently contains the `ReportPayload` model, which is the payload sent by users when they wish to report a package via email (as opposed to the PyPI Observations API) --- src/reporter/schemas.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/reporter/schemas.py diff --git a/src/reporter/schemas.py b/src/reporter/schemas.py new file mode 100644 index 0000000..4896c71 --- /dev/null +++ b/src/reporter/schemas.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel +from typing import Optional + +class ReportPayload(BaseModel): + name: str + version: str + rules_matched: list[str] + inspector_url: str + additional_information: Optional[str] = None + recipient: Optional[str] = None From 11f2f8270299e34b7ba57caab3b1224369fd9934 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Mon, 4 Mar 2024 15:49:08 -0600 Subject: [PATCH 3/5] Add method for building email body Add a method in `mailer.py` that will build a report email body --- src/reporter/mailer.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/reporter/mailer.py b/src/reporter/mailer.py index b716eca..843a1dc 100644 --- a/src/reporter/mailer.py +++ b/src/reporter/mailer.py @@ -1,6 +1,7 @@ """Sending emails.""" from logging import getLogger +from typing import Optional from msgraph import GraphServiceClient from msgraph.generated.models.attachment import Attachment @@ -12,11 +13,33 @@ from msgraph.generated.users.item.send_mail.send_mail_post_request_body import ( SendMailPostRequestBody, ) - +from textwrap import dedent from reporter.constants import Mail +from reporter.utils.pypi import file_path_from_inspector_url logger = getLogger(__name__) +def build_report_email_content( + *, + name: str, + version: str, + inspector_url: str, + rules_matched: list[str], + additional_information: Optional[str], +) -> str: + content = f""" + PyPI Malicious Package Report + - + Package Name: {name} + Version: {version} + File path: {file_path_from_inspector_url(inspector_url)} + Inspector URL: {inspector_url} + Additional Information: {additional_information or "No user description provided"} + Yara rules matched: {", ".join(rules_matched) or "No rules matched"} + """ + + return dedent(content) + async def send_mail( graph_client: GraphServiceClient, From 8e0368c24bae97cd1abdd9e95a0b45c5aefcab5f Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Mon, 4 Mar 2024 15:49:35 -0600 Subject: [PATCH 4/5] Add route for reporting via email --- src/reporter/app.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/reporter/app.py b/src/reporter/app.py index 89e00bf..fb10eb1 100644 --- a/src/reporter/app.py +++ b/src/reporter/app.py @@ -1,9 +1,15 @@ -from fastapi import FastAPI - +from typing import Annotated +from fastapi import Depends, FastAPI +from msgraph import GraphServiceClient +from reporter.schemas import ReportPayload +from reporter.constants import Mail from reporter.http_client import HTTPClientDependency from reporter.models import Observation from reporter.observations import send_observation +from reporter.dependencies import build_graph_client +from reporter.mailer import build_report_email_content, send_mail + app = FastAPI() @@ -21,3 +27,16 @@ async def report_endpoint( await send_observation( project_name=project_name, observation=observation, http_client=http_client ) + + + +@app.post("/report/email") +async def report_email_endpoint(payload: ReportPayload, graph_client: Annotated[GraphServiceClient, Depends(build_graph_client)]): + content=build_report_email_content(name=payload.name, version=payload.version, inspector_url=payload.inspector_url, rules_matched=payload.rules_matched, additional_information=payload.additional_information) + await send_mail( + graph_client, + to_addresses=[payload.recipient or Mail.recipient], + bcc_addresses=[], + subject=f"Automated PyPI Malware Report: {payload.name}@{payload.version}", + content=content, + ) From ee4f1af640e9755f78a1a2fabcf0cbb4555d0913 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Mon, 4 Mar 2024 16:21:33 -0600 Subject: [PATCH 5/5] Run through ruff formatter --- src/reporter/app.py | 21 ++++++++++++--------- src/reporter/http_client.py | 4 +--- src/reporter/mailer.py | 11 +++-------- src/reporter/models.py | 4 +--- src/reporter/observations.py | 4 +--- src/reporter/schemas.py | 1 + src/reporter/utils/pypi.py | 1 + 7 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src/reporter/app.py b/src/reporter/app.py index fb10eb1..d1d31a3 100644 --- a/src/reporter/app.py +++ b/src/reporter/app.py @@ -21,18 +21,21 @@ async def echo(http_client: HTTPClientDependency) -> str: @app.post("/report/{project_name}") -async def report_endpoint( - project_name: str, observation: Observation, http_client: HTTPClientDependency -): - await send_observation( - project_name=project_name, observation=observation, http_client=http_client - ) - +async def report_endpoint(project_name: str, observation: Observation, http_client: HTTPClientDependency): + await send_observation(project_name=project_name, observation=observation, http_client=http_client) @app.post("/report/email") -async def report_email_endpoint(payload: ReportPayload, graph_client: Annotated[GraphServiceClient, Depends(build_graph_client)]): - content=build_report_email_content(name=payload.name, version=payload.version, inspector_url=payload.inspector_url, rules_matched=payload.rules_matched, additional_information=payload.additional_information) +async def report_email_endpoint( + payload: ReportPayload, graph_client: Annotated[GraphServiceClient, Depends(build_graph_client)] +): + content = build_report_email_content( + name=payload.name, + version=payload.version, + inspector_url=payload.inspector_url, + rules_matched=payload.rules_matched, + additional_information=payload.additional_information, + ) await send_mail( graph_client, to_addresses=[payload.recipient or Mail.recipient], diff --git a/src/reporter/http_client.py b/src/reporter/http_client.py index 591dd45..5e2d118 100644 --- a/src/reporter/http_client.py +++ b/src/reporter/http_client.py @@ -12,9 +12,7 @@ class BearerAuthentication(httpx.Auth): def __init__(self, *, token: str) -> None: self.token = token - def auth_flow( - self, request: httpx.Request - ) -> Generator[httpx.Request, httpx.Response, None]: + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: request.headers["Authorization"] = f"Bearer {self.token}" yield request diff --git a/src/reporter/mailer.py b/src/reporter/mailer.py index 843a1dc..7b23278 100644 --- a/src/reporter/mailer.py +++ b/src/reporter/mailer.py @@ -19,6 +19,7 @@ logger = getLogger(__name__) + def build_report_email_content( *, name: str, @@ -53,15 +54,9 @@ async def send_mail( reply_to_recipient = Recipient(email_address=EmailAddress(address=Mail.reply_to)) from_recipient = Recipient(email_address=EmailAddress(address=Mail.sender)) - to_recipients = [ - Recipient(email_address=EmailAddress(address=to_address)) - for to_address in to_addresses - ] + to_recipients = [Recipient(email_address=EmailAddress(address=to_address)) for to_address in to_addresses] - bcc_recipients = [ - Recipient(email_address=EmailAddress(address=bcc_address)) - for bcc_address in bcc_addresses - ] + bcc_recipients = [Recipient(email_address=EmailAddress(address=bcc_address)) for bcc_address in bcc_addresses] email_body = ItemBody(content=content, content_type=BodyType.Html) diff --git a/src/reporter/models.py b/src/reporter/models.py index 74604f1..52fff0b 100644 --- a/src/reporter/models.py +++ b/src/reporter/models.py @@ -24,8 +24,6 @@ class Observation(BaseModel): @model_validator(mode="after") def model_validator(self: Observation) -> Observation: if self.kind == ObservationKind.Malware: - assert ( - self.inspector_url is not None - ), "inspector_url is required when kind is malware" + assert self.inspector_url is not None, "inspector_url is required when kind is malware" return self diff --git a/src/reporter/observations.py b/src/reporter/observations.py index 42a18a8..517129b 100644 --- a/src/reporter/observations.py +++ b/src/reporter/observations.py @@ -6,9 +6,7 @@ from reporter.models import Observation -async def send_observation( - project_name: str, observation: Observation, *, http_client: httpx.AsyncClient -): +async def send_observation(project_name: str, observation: Observation, *, http_client: httpx.AsyncClient): path = f"/danger-api/projects/{project_name}/observations" json = jsonable_encoder(observation) diff --git a/src/reporter/schemas.py b/src/reporter/schemas.py index 4896c71..8bbc1f8 100644 --- a/src/reporter/schemas.py +++ b/src/reporter/schemas.py @@ -1,6 +1,7 @@ from pydantic import BaseModel from typing import Optional + class ReportPayload(BaseModel): name: str version: str diff --git a/src/reporter/utils/pypi.py b/src/reporter/utils/pypi.py index e01970f..68a25c5 100644 --- a/src/reporter/utils/pypi.py +++ b/src/reporter/utils/pypi.py @@ -2,6 +2,7 @@ from urllib.parse import urlparse + def file_path_from_inspector_url(inspector_url: str) -> str: """Parse the file path out of a PyPI inspector URL"""