Skip to content

Commit

Permalink
Merge 61995d5 into 8fd0ae2
Browse files Browse the repository at this point in the history
  • Loading branch information
ninoseki committed Feb 4, 2024
2 parents 8fd0ae2 + 61995d5 commit 82f4c80
Show file tree
Hide file tree
Showing 86 changed files with 1,746 additions and 4,155 deletions.
7 changes: 7 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ jobs:
run: poetry run pyupgrade --py311-plus **/*.py
test:
runs-on: ubuntu-latest
services:
spamassassin:
image: instantlinux/spamassassin:4.0.0-6
ports:
- 783:783
strategy:
matrix:
python-version: [3.11]
Expand All @@ -63,6 +68,8 @@ jobs:
run: poetry install
- name: Make dummy frontend directory
run: mkdir -p frontend/dist/
- name: Wait until SpamAssasin ready
run: poetry run python scripts/ping.py
- name: Run tests
run: poetry run pytest -v --cov=app --cov-report=term-missing
- name: Coveralls
Expand Down
3 changes: 3 additions & 0 deletions backend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .clients.emailrep import EmailRep # noqa: F401
from .clients.inquest import InQuest # noqa: F401
from .clients.spamassasin import SpamAssassin # noqa: F401
50 changes: 44 additions & 6 deletions backend/api/endpoints/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@
from pydantic import ValidationError
from redis import Redis

from backend import deps, schemas
from backend.core import settings
from backend import clients, deps, schemas, settings
from backend.factories.response import ResponseFactory

router = APIRouter()


async def _analyze(file: bytes) -> schemas.Response:
async def _analyze(
file: bytes,
*,
spam_assassin: clients.SpamAssassin,
email_rep: clients.EmailRep,
optional_inquest: clients.InQuest | None = None,
optional_vt: clients.VirusTotal | None = None,
optional_urlscan: clients.UrlScan | None = None,
) -> schemas.Response:
try:
payload = schemas.FilePayload(file=file)
except ValidationError as exc:
Expand All @@ -19,7 +26,14 @@ async def _analyze(file: bytes) -> schemas.Response:
detail=jsonable_encoder(exc.errors()),
) from exc

return await ResponseFactory.from_bytes(payload.file)
return await ResponseFactory.call(
payload.file,
email_rep=email_rep,
spam_assassin=spam_assassin,
optional_inquest=optional_inquest,
optional_urlscan=optional_urlscan,
optional_vt=optional_vt,
)


def cache_response(
Expand All @@ -45,8 +59,20 @@ async def analyze(
*,
background_tasks: BackgroundTasks,
optional_redis: deps.OptionalRedis,
spam_assassin: deps.SpamAssassin,
email_rep: deps.EmailRep,
optional_inquest: deps.OptionalInQuest,
optional_vt: deps.OptionalVirusTotal,
optional_urlscan: deps.OptionalUrlScan,
) -> schemas.Response:
response = await _analyze(payload.file.encode())
response = await _analyze(
payload.file.encode(),
email_rep=email_rep,
spam_assassin=spam_assassin,
optional_inquest=optional_inquest,
optional_urlscan=optional_urlscan,
optional_vt=optional_vt,
)

if optional_redis is not None:
background_tasks.add_task(
Expand All @@ -67,8 +93,20 @@ async def analyze_file(
*,
background_tasks: BackgroundTasks,
optional_redis: deps.OptionalRedis,
spam_assassin: deps.SpamAssassin,
email_rep: deps.EmailRep,
optional_inquest: deps.OptionalInQuest,
optional_vt: deps.OptionalVirusTotal,
optional_urlscan: deps.OptionalUrlScan,
) -> schemas.Response:
response = await _analyze(file)
response = await _analyze(
file,
email_rep=email_rep,
spam_assassin=spam_assassin,
optional_inquest=optional_inquest,
optional_urlscan=optional_urlscan,
optional_vt=optional_vt,
)

if optional_redis is not None:
background_tasks.add_task(
Expand Down
3 changes: 1 addition & 2 deletions backend/api/endpoints/lookup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from fastapi import APIRouter, HTTPException, status

from backend import deps, schemas
from backend.core import settings
from backend import deps, schemas, settings

router = APIRouter()

Expand Down
52 changes: 28 additions & 24 deletions backend/api/endpoints/submit.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import httpx
from fastapi import APIRouter, HTTPException, status
from httpx._exceptions import HTTPError

from backend.core.utils import has_inquest_api_key, has_virustotal_api_key
from backend import deps, schemas
from backend.schemas.eml import Attachment
from backend.schemas.submission import SubmissionResult
from backend.submitters.inquest import InQuestSubmitter
from backend.submitters.virustotal import VirusTotalSubmitter
from backend.utils import attachment_to_file

router = APIRouter()

Expand All @@ -17,12 +15,9 @@
description="Submit an attachment to InQuest",
status_code=200,
)
async def submit_to_inquest(attachment: Attachment) -> SubmissionResult:
if not has_inquest_api_key():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have the InQuest API key",
)
async def submit_to_inquest(
attachment: Attachment, *, optional_inquest: deps.OptionalInQuest
) -> schemas.SubmissionResult:
# check ext type
valid_types = ["doc", "docx", "ppt", "pptx", "xls", "xlsx"]
if attachment.extension not in valid_types:
Expand All @@ -31,36 +26,45 @@ async def submit_to_inquest(attachment: Attachment) -> SubmissionResult:
detail=f"{attachment.extension} is not supported.",
)

submitter = InQuestSubmitter(attachment)
if optional_inquest is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have the InQuest API key",
)

try:
return await submitter.submit()
except HTTPError as e:
return await optional_inquest.submit(attachment_to_file(attachment))
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Something went wrong with InQuest submission: {e!s}",
status_code=e.response.status_code,
detail=f"Something went wrong with InQuest submission: {e}",
) from e


@router.post(
"/virustotal",
response_model=SubmissionResult,
response_description="Return a submission result",
summary="Submit an attachment to VirusTotal",
description="Submit an attachment to VirusTotal",
status_code=200,
)
async def submit_to_virustotal(attachment: Attachment) -> SubmissionResult:
if not has_virustotal_api_key():
async def submit_to_virustotal(
attachment: Attachment, *, optional_vt: deps.OptionalVirusTotal
) -> schemas.SubmissionResult:
if optional_vt is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have the VirusTotal API key",
)

submitter = VirusTotalSubmitter(attachment)
try:
return await submitter.submit()
except HTTPError as e:
await optional_vt.scan_file_async(attachment_to_file(attachment))
sha256 = attachment.hash.sha256
return schemas.SubmissionResult(
reference_url=f"https://www.virustotal.com/gui/file/{sha256}/detection"
)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Something went wrong with VirusTotal submission: {e!s}",
status_code=e.response.status_code,
detail=f"Something went wrong with VirusTotal submission: {e}",
) from e
8 changes: 8 additions & 0 deletions backend/clients/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import vt

from .emailrep import EmailRep # noqa: F401
from .inquest import InQuest # noqa: F401
from .spamassasin import SpamAssassin # noqa: F401
from .urlscan import UrlScan # noqa: F401

VirusTotal = vt.Client
13 changes: 13 additions & 0 deletions backend/clients/emailrep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import httpx

from backend import schemas


class EmailRep(httpx.AsyncClient):
def __init__(self) -> None:
super().__init__(base_url="https://emailrep.io")

async def lookup(self, email: str) -> schemas.EmailRepLookup:
r = await self.get(f"/{email}")
r.raise_for_status()
return schemas.EmailRepLookup.model_validate(r.json())
27 changes: 27 additions & 0 deletions backend/clients/inquest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import io

import httpx
from starlette.datastructures import Secret

from backend import schemas


class InQuest(httpx.AsyncClient):
def __init__(self, api_key: Secret) -> None:
super().__init__(
base_url="https://labs.inquest.net",
headers={"Authorization": f"Basic: {api_key}"},
)

async def lookup(self, sha256: str) -> schemas.InQuestLookup:
r = await self.get("/api/dfi/details", params={"sha256": sha256})
r.raise_for_status()
return schemas.InQuestLookup.model_validate(r.json())

async def submit(self, f: io.BytesIO) -> schemas.SubmissionResult:
r = await self.post("/api/dfi/upload", files={"file": f})
r.raise_for_status()
data = r.json()["data"]
return schemas.SubmissionResult(
reference_url=f"https://labs.inquest.net/dfi/sha256/{data}"
)
62 changes: 25 additions & 37 deletions backend/services/spamassassin.py → backend/clients/spamassasin.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,32 @@
from dataclasses import dataclass
from typing import Any

import aiospamc
from aiospamc.header_values import Headers
from async_timeout import timeout


@dataclass
class Detail:
name: str
score: float
description: str


@dataclass
class Report:
score: float
details: list[Detail]
level = 5.0

def is_spam(self, level: float = 5.0) -> bool:
return self.score is None or self.score > level
from backend import schemas, settings


class Parser:
def __init__(self, headers: dict[str, Any], body: str):
def __init__(self, headers: Headers, body: str):
self.headers = headers
self.body = body
self.score = 0.0
self.details: list[Detail] = []
self.details: list[schemas.SpamAssassinDetail] = []

def _parse_headers(self):
spam_value = self.headers["Spam"]
self.score = spam_value.score
spam_value = self.headers.get("Spam")
if spam_value is not None:
self.score = spam_value.score

def _parse_detail(self, line: str) -> Detail:
def _parse_detail(self, line: str) -> schemas.SpamAssassinDetail:
parts = line.split()
score = float(parts[0])
name = parts[1]
description = " ".join(parts[2:])
return Detail(name=name, score=score, description=description)
return schemas.SpamAssassinDetail(
name=name, score=score, description=description
)

def _parse_details(self, details: str) -> list[Detail]:
def _parse_details(self, details: str) -> list[schemas.SpamAssassinDetail]:
lines = details.splitlines()
normalized_line: list[str] = []

Expand All @@ -56,33 +42,35 @@ def _parse_details(self, details: str) -> list[Detail]:

def _parse_body(self):
lines = [line for line in self.body.splitlines() if line != ""]
demiliter_index = 0
delimiter_index = 0
for index, line in enumerate(lines):
if "---" in line:
demiliter_index = index + 1
delimiter_index = index + 1
break

details = "\n".join(lines[demiliter_index:])
details = "\n".join(lines[delimiter_index:])
self.details = self._parse_details(details)

def parse(self):
def parse(self) -> schemas.SpamAssassinReport:
self._parse_headers()
self._parse_body()

def to_report(self) -> Report:
return Report(score=self.score, details=self.details)
return schemas.SpamAssassinReport(score=self.score, details=self.details)


class SpamAssassin:
def __init__(self, host: str = "127.0.0.1", port: int = 783, timeout: int = 10):
def __init__(
self,
host: str = settings.SPAMASSASSIN_HOST,
port: int = settings.SPAMASSASSIN_PORT,
timeout: int = settings.SPAMASSASSIN_TIMEOUT,
):
self.host = host
self.port = port
self.timeout = timeout

async def report(self, message: bytes) -> Report:
async def report(self, message: bytes) -> schemas.SpamAssassinReport:
async with timeout(self.timeout):
response = await aiospamc.report(message, host=self.host, port=self.port)

parser = Parser(headers=response.headers, body=response.body.decode())
parser.parse()
return parser.to_report()
return parser.parse()
26 changes: 26 additions & 0 deletions backend/clients/urlscan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from urllib.parse import urlparse

import httpx
from starlette.datastructures import Secret

from backend import schemas


class UrlScan(httpx.AsyncClient):
def __init__(self, api_key: Secret) -> None:
super().__init__(
base_url="https://urlscan.io", headers={"api-key": str(api_key)}
)

async def lookup(
self,
url: str,
) -> schemas.UrlScanLookup:
parsed = urlparse(url)
params = {
"q": f'task.url:"{url}" AND task.domain:"{parsed.hostname}" AND verdicts.malicious:true',
"size": 1,
}
r = await self.get("/api/v1/search/", params=params)
r.raise_for_status()
return schemas.UrlScanLookup.model_validate(r.json())
Empty file removed backend/core/__init__.py
Empty file.
3 changes: 0 additions & 3 deletions backend/core/resources.py

This file was deleted.

0 comments on commit 82f4c80

Please sign in to comment.