Skip to content

Commit

Permalink
Feat: phishing detection
Browse files Browse the repository at this point in the history
Signed-off-by: Hamza Mahjoubi <hamzamahjoubi221@gmail.com>
  • Loading branch information
hamza221 committed Jun 27, 2024
1 parent e63f1e0 commit 9b87b23
Show file tree
Hide file tree
Showing 26 changed files with 860 additions and 9 deletions.
18 changes: 18 additions & 0 deletions lib/Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,27 @@ public function getLabel(): ?string {
// Fallback
return $this->getEmail();
}
$personal = trim(explode('<', $personal)[0]); // Remove the email part if present
return $personal;
}

/**
* @return string|null
*/
public function getCustomEmail(): ?string {
$personal = $this->wrapped->personal;
if ($personal === null) {
// Fallback
return null;
}
$parts = explode('<', $personal);
if (count($parts) === 1) {
return null;
}
$customEmail = trim($parts[1], '>');
return $customEmail;
}

/**
* @return string|null
*/
Expand Down
25 changes: 24 additions & 1 deletion lib/IMAP/ImapMessageFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use OCA\Mail\IMAP\Charset\Converter;
use OCA\Mail\Model\IMAPMessage;
use OCA\Mail\Service\Html;
use OCA\Mail\Service\PhishingDetection\PhishingDetectionService;
use OCA\Mail\Service\SmimeService;
use OCP\AppFramework\Db\DoesNotExistException;
use function str_starts_with;
Expand All @@ -36,8 +37,10 @@ class ImapMessageFetcher {

private Html $htmlService;
private SmimeService $smimeService;
private PhishingDetectionService $phishingDetectionService;
private string $userId;

private bool $runPhishingCheck = false;
// Conditional fetching/parsing
private bool $loadBody = false;

Expand All @@ -54,6 +57,7 @@ class ImapMessageFetcher {
private string $rawReferences = '';
private string $dispositionNotificationTo = '';
private bool $hasDkimSignature = false;
private array $phishingDetails = [];
private ?string $unsubscribeUrl = null;
private bool $isOneClickUnsubscribe = false;
private ?string $unsubscribeMailto = null;
Expand All @@ -64,13 +68,16 @@ public function __construct(int $uid,
string $userId,
Html $htmlService,
SmimeService $smimeService,
private Converter $converter) {
private Converter $converter,
PhishingDetectionService $phishingDetectionService,
) {
$this->uid = $uid;
$this->mailbox = $mailbox;
$this->client = $client;
$this->userId = $userId;
$this->htmlService = $htmlService;
$this->smimeService = $smimeService;
$this->phishingDetectionService = $phishingDetectionService;
}


Expand All @@ -85,6 +92,17 @@ public function withBody(bool $value): ImapMessageFetcher {
return $this;
}

/**
* Configure the fetcher to check for phishing.
*
* @param bool $value
* @return $this
*/
public function withPhishingCheck(bool $value): ImapMessageFetcher {
$this->runPhishingCheck = $value;
return $this;
}

/**
* @param Horde_Imap_Client_Data_Fetch|null $fetch
* Will be reused if no body is requested.
Expand Down Expand Up @@ -238,6 +256,7 @@ public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPM
$this->rawReferences,
$this->dispositionNotificationTo,
$this->hasDkimSignature,
$this->phishingDetails,
$this->unsubscribeUrl,
$this->isOneClickUnsubscribe,
$this->unsubscribeMailto,
Expand Down Expand Up @@ -495,6 +514,10 @@ private function parseHeaders(Horde_Imap_Client_Data_Fetch $fetch): void {
$dkimSignatureHeader = $parsedHeaders->getHeader('dkim-signature');
$this->hasDkimSignature = $dkimSignatureHeader !== null;

if ($this->runPhishingCheck) {
$this->phishingDetails = $this->phishingDetectionService->checkHeadersForPhishing($parsedHeaders, $this->hasHtmlMessage, $this->htmlMessage);
}

$listUnsubscribeHeader = $parsedHeaders->getHeader('list-unsubscribe');
if ($listUnsubscribeHeader !== null) {
$listHeaders = new Horde_ListHeaders();
Expand Down
7 changes: 6 additions & 1 deletion lib/IMAP/ImapMessageFetcherFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,23 @@
use Horde_Imap_Client_Base;
use OCA\Mail\IMAP\Charset\Converter;
use OCA\Mail\Service\Html;
use OCA\Mail\Service\PhishingDetection\PhishingDetectionService;
use OCA\Mail\Service\SmimeService;

class ImapMessageFetcherFactory {
private Html $htmlService;
private SmimeService $smimeService;
private Converter $charsetConverter;
private PhishingDetectionService $phishingDetectionService;

public function __construct(Html $htmlService,
SmimeService $smimeService,
Converter $charsetConverter) {
Converter $charsetConverter,
PhishingDetectionService $phishingDetectionService) {
$this->htmlService = $htmlService;
$this->smimeService = $smimeService;
$this->charsetConverter = $charsetConverter;
$this->phishingDetectionService = $phishingDetectionService;
}

public function build(int $uid,
Expand All @@ -39,6 +43,7 @@ public function build(int $uid,
$this->htmlService,
$this->smimeService,
$this->charsetConverter,
$this->phishingDetectionService,
);
}
}
8 changes: 5 additions & 3 deletions lib/IMAP/MessageMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public function find(Horde_Imap_Client_Base $client,
int $id,
string $userId,
bool $loadBody = false): IMAPMessage {
$result = $this->findByIds($client, $mailbox, new Horde_Imap_Client_Ids([$id]), $userId, $loadBody);
$result = $this->findByIds($client, $mailbox, new Horde_Imap_Client_Ids([$id]), $userId, $loadBody, true);

if (count($result) === 0) {
throw new DoesNotExistException("Message does not exist");
Expand Down Expand Up @@ -249,7 +249,8 @@ public function findByIds(Horde_Imap_Client_Base $client,
string $mailbox,
$ids,
string $userId,
bool $loadBody = false): array {
bool $loadBody = false,
bool $runPhishingCheck = false): array {
$query = new Horde_Imap_Client_Fetch_Query();
$query->envelope();
$query->flags();
Expand Down Expand Up @@ -294,7 +295,7 @@ public function findByIds(Horde_Imap_Client_Base $client,
$this->logger->debug("findByIds in $mailbox got " . count($ids) . " UIDs ($range) and found " . count($fetchResults) . ". minFetched=$minFetched maxFetched=$maxFetched");
}

return array_map(function (Horde_Imap_Client_Data_Fetch $fetchResult) use ($client, $mailbox, $loadBody, $userId) {
return array_map(function (Horde_Imap_Client_Data_Fetch $fetchResult) use ($client, $mailbox, $loadBody, $userId, $runPhishingCheck) {
return $this->imapMessageFactory
->build(
$fetchResult->getUid(),
Expand All @@ -303,6 +304,7 @@ public function findByIds(Horde_Imap_Client_Base $client,
$userId,
)
->withBody($loadBody)
->withPhishingCheck($runPhishingCheck)
->fetchMessage($fetchResult);
}, $fetchResults);
}
Expand Down
4 changes: 4 additions & 0 deletions lib/Model/IMAPMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class IMAPMessage implements IMessage, JsonSerializable {
private string $rawReferences;
private string $dispositionNotificationTo;
private bool $hasDkimSignature;
private array $phishingDetails;
private ?string $unsubscribeUrl;
private bool $isOneClickUnsubscribe;
private ?string $unsubscribeMailto;
Expand Down Expand Up @@ -85,6 +86,7 @@ public function __construct(int $uid,
string $rawReferences,
string $dispositionNotificationTo,
bool $hasDkimSignature,
array $phishingDetails,
?string $unsubscribeUrl,
bool $isOneClickUnsubscribe,
?string $unsubscribeMailto,
Expand Down Expand Up @@ -113,6 +115,7 @@ public function __construct(int $uid,
$this->rawReferences = $rawReferences;
$this->dispositionNotificationTo = $dispositionNotificationTo;
$this->hasDkimSignature = $hasDkimSignature;
$this->phishingDetails = $phishingDetails;
$this->unsubscribeUrl = $unsubscribeUrl;
$this->isOneClickUnsubscribe = $isOneClickUnsubscribe;
$this->unsubscribeMailto = $unsubscribeMailto;
Expand Down Expand Up @@ -299,6 +302,7 @@ public function jsonSerialize() {
'hasHtmlBody' => $this->hasHtmlMessage,
'dispositionNotificationTo' => $this->getDispositionNotificationTo(),
'hasDkimSignature' => $this->hasDkimSignature,
'phishingDetails' => $this->phishingDetails,
'unsubscribeUrl' => $this->unsubscribeUrl,
'isOneClickUnsubscribe' => $this->isOneClickUnsubscribe,
'unsubscribeMailto' => $this->unsubscribeMailto,
Expand Down
53 changes: 53 additions & 0 deletions lib/PhishingDetectionList.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail;

use JsonSerializable;
use ReturnTypeWillChange;

class PhishingDetectionList implements JsonSerializable {

/** @var PhishingDetectionResult[] */
private array $checks;

private bool $warning = false;

/**
* @param PhishingDetectionResult[] $checks
*/
public function __construct(array $checks = []) {
$this->checks = $checks;
}

public function addCheck(PhishingDetectionResult $check) {
$this->checks[] = $check;
}

private function isWarning() {
foreach ($this->checks as $check) {
if (in_array($check->getType(), [PhishingDetectionResult::DATE_CHECK, PhishingDetectionResult::LINK_CHECK, PhishingDetectionResult::CUSTOM_EMAIL_CHECK, PhishingDetectionResult::CONTACTS_CHECK]) && $check->isPhishing()) {
return true;
}
}
return false;
}

#[ReturnTypeWillChange]
public function jsonSerialize() {
$result = array_map(static function (PhishingDetectionResult $check) {
return $check->jsonSerialize();
}, $this->checks);
return [
'checks' => $result,
'warning' => $this->isWarning(),
];
}

}
57 changes: 57 additions & 0 deletions lib/PhishingDetectionResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Mail;

use JsonSerializable;
use ReturnTypeWillChange;

/**
* @psalm-immutable
*/
class PhishingDetectionResult implements JsonSerializable {

public const DATE_CHECK = "Date";
public const LINK_CHECK = "Link";
public const REPLYTO_CHECK = "Reply-To";
public const CUSTOM_EMAIL_CHECK = "Custom Email";
public const CONTACTS_CHECK = "Contacts";
public const TRUSTED_CHECK = "Trusted";

private string $message = "";
private bool $isPhishing;
private array $additionalData = [];
private string $type;

public function __construct(string $type, bool $isPhishing, string $message = "", array $additionalData = []) {
$this->type = $type;
$this->message = $message;
$this->isPhishing = $isPhishing;
$this->additionalData = $additionalData;

}

public function getType(): string {
return $this->type;
}

public function isPhishing(): bool {
return $this->isPhishing;
}

#[ReturnTypeWillChange]
public function jsonSerialize() {
return [
'type' => $this->type,
'isPhishing' => $this->isPhishing,
'message' => $this->message,
'additionalData' => $this->additionalData,
];
}

}
10 changes: 6 additions & 4 deletions lib/Service/ContactsIntegration.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,22 +223,24 @@ public function newContact(string $name, string $mailAddr, string $type = 'HOME'
/**
* @param string[] $fields
*/
private function doSearch(string $term, array $fields, bool $strictSearch): array {
private function doSearch(string $term, array $fields, bool $strictSearch, bool $forceSAB = false) : array {
$allowSystemUsers = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'no') === 'yes';

$result = $this->contactsManager->search($term, $fields, [
'strict_search' => $strictSearch
]);
$matches = [];
foreach ($result as $r) {
if (!$allowSystemUsers && isset($r['isLocalSystemBook']) && $r['isLocalSystemBook']) {
if ((!$allowSystemUsers && !$forceSAB) && isset($r['isLocalSystemBook']) && $r['isLocalSystemBook']) {
continue;
}
$id = $r['UID'];
$fn = $r['FN'];
$email = $r['EMAIL'];
$matches[] = [
'id' => $id,
'label' => $fn,
'email' => $email,
];
}
return $matches;
Expand All @@ -257,7 +259,7 @@ public function getContactsWithMail(string $mailAddr) {
/**
* Extracts all Contacts with the specified name
*/
public function getContactsWithName(string $name): array {
return $this->doSearch($name, ['FN'], false);
public function getContactsWithName(string $name, bool $forceSAB = false): array {
return $this->doSearch($name, ['FN'], false, $forceSAB);
}
}
Loading

0 comments on commit 9b87b23

Please sign in to comment.