Skip to content

Commit 166dbd2

Browse files
feat(#289): add haveibeenpwned check (#1253)
1 parent 15e2a8b commit 166dbd2

File tree

8 files changed

+206
-10
lines changed

8 files changed

+206
-10
lines changed

Cargo.lock

Lines changed: 105 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/tests/check_email.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ use reacher_backend::routes::create_routes;
2323
use warp::http::StatusCode;
2424
use warp::test::request;
2525

26-
const FOO_BAR_RESPONSE: &str = r#"{"input":"foo@bar","is_reachable":"invalid","misc":{"is_disposable":false,"is_role_account":false,"gravatar_url":null},"mx":{"accepts_mail":false,"records":[]},"smtp":{"can_connect_smtp":false,"has_full_inbox":false,"is_catch_all":false,"is_deliverable":false,"is_disabled":false},"syntax":{"address":null,"domain":"","is_valid_syntax":false,"username":"","normalized_email":null,"suggestion":null}}"#;
27-
const FOO_BAR_BAZ_RESPONSE: &str = r#"{"input":"[email protected]","is_reachable":"invalid","misc":{"is_disposable":false,"is_role_account":false,"gravatar_url":null},"mx":{"accepts_mail":false,"records":[]},"smtp":{"can_connect_smtp":false,"has_full_inbox":false,"is_catch_all":false,"is_deliverable":false,"is_disabled":false},"syntax":{"address":"[email protected]","domain":"bar.baz","is_valid_syntax":true,"username":"foo","normalized_email":"[email protected]","suggestion":null}}"#;
26+
const FOO_BAR_RESPONSE: &str = r#"{"input":"foo@bar","is_reachable":"invalid","misc":{"is_disposable":false,"is_role_account":false,"gravatar_url":null,"haveibeenpwned":null},"mx":{"accepts_mail":false,"records":[]},"smtp":{"can_connect_smtp":false,"has_full_inbox":false,"is_catch_all":false,"is_deliverable":false,"is_disabled":false},"syntax":{"address":null,"domain":"","is_valid_syntax":false,"username":"","normalized_email":null,"suggestion":null}}"#;
27+
const FOO_BAR_BAZ_RESPONSE: &str = r#"{"input":"[email protected]","is_reachable":"invalid","misc":{"is_disposable":false,"is_role_account":false,"gravatar_url":null,"haveibeenpwned":null},"mx":{"accepts_mail":false,"records":[]},"smtp":{"can_connect_smtp":false,"has_full_inbox":false,"is_catch_all":false,"is_deliverable":false,"is_disabled":false},"syntax":{"address":"[email protected]","domain":"bar.baz","is_valid_syntax":true,"username":"foo","normalized_email":"[email protected]","suggestion":null}}"#;
2828

2929
#[tokio::test]
3030
async fn test_input_foo_bar() {

cli/src/main.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ pub struct Cli {
7777
#[clap(long, env, default_value = "false", parse(try_from_str))]
7878
pub check_gravatar: bool,
7979

80+
/// HaveIBeenPnwed API key, ignore if not provided.
81+
#[clap(long, env, parse(try_from_str))]
82+
pub haveibeenpwned_api_key: Option<String>,
83+
8084
/// The email to check.
8185
pub to_email: String,
8286
}
@@ -99,7 +103,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
99103
.set_gmail_use_api(CONF.gmail_use_api)
100104
.set_microsoft365_use_api(CONF.microsoft365_use_api)
101105
.set_check_gravatar(CONF.check_gravatar)
102-
.set_hotmail_use_headless(CONF.hotmail_use_headless.clone());
106+
.set_hotmail_use_headless(CONF.hotmail_use_headless.clone())
107+
.set_haveibeenpwned_api_key(CONF.haveibeenpwned_api_key.clone());
103108

104109
if let Some(proxy_host) = &CONF.proxy_host {
105110
input.set_proxy(CheckEmailInputProxy {

core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ serde_json = "1.0.93"
3131
trust-dns-proto = "0.21.2"
3232
md5 = "0.7.0"
3333
levenshtein = "1.0.5"
34+
pwned = "0.5.0"
3435

3536
[dev-dependencies]
3637
tokio = { version = "1.25.0" }

core/src/haveibeenpwned.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// check-if-email-exists
2+
// Copyright (C) 2018-2022 Reacher
3+
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published
6+
// by the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
use crate::util::constants::LOG_TARGET;
18+
use pwned::api::PwnedBuilder;
19+
20+
/// Check if the email has been found in any breach or paste using the
21+
/// HaveIBeenPwned API.
22+
/// This function will return the number of times the email has been found in
23+
/// any breach.
24+
pub async fn check_haveibeenpwned(to_email: &str, api_key: Option<String>) -> Option<bool> {
25+
let pwned = PwnedBuilder::default()
26+
.user_agent("reacher")
27+
.api_key(api_key)
28+
.build()
29+
.unwrap();
30+
31+
match pwned.check_email(to_email).await {
32+
Ok(answer) => {
33+
log::debug!(
34+
target: LOG_TARGET,
35+
"Email found in {} breaches",
36+
answer.len()
37+
);
38+
Some(!answer.is_empty())
39+
}
40+
Err(e) => {
41+
log::error!(
42+
target: LOG_TARGET,
43+
"Error while checking if email has been pwned: {}",
44+
e
45+
);
46+
match e {
47+
pwned::errors::Error::IoError(e) => match e.kind() {
48+
std::io::ErrorKind::NotFound => Some(false),
49+
_ => None,
50+
},
51+
_ => None,
52+
}
53+
}
54+
}
55+
}

core/src/lib.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
//! }
6363
//! ```
6464
65+
mod haveibeenpwned;
6566
pub mod misc;
6667
pub mod mx;
6768
pub mod smtp;
@@ -177,7 +178,12 @@ pub async fn check_email(input: &CheckEmailInput) -> CheckEmailOutput {
177178
.collect::<Vec<String>>()
178179
);
179180

180-
let my_misc = check_misc(&my_syntax, input.check_gravatar).await;
181+
let my_misc = check_misc(
182+
&my_syntax,
183+
input.check_gravatar,
184+
input.haveibeenpwned_api_key.clone(),
185+
)
186+
.await;
181187
log::debug!(
182188
target: LOG_TARGET,
183189
"[email={}] Found the following misc details: {:?}",

core/src/misc/mod.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
// along with this program. If not, see <https://www.gnu.org/licenses/>.
1616

1717
mod gravatar;
18+
use crate::haveibeenpwned::check_haveibeenpwned;
1819

1920
use serde::{Deserialize, Serialize};
2021
use std::default::Default;
@@ -32,6 +33,9 @@ pub struct MiscDetails {
3233
/// Is this email a role-based account?
3334
pub is_role_account: bool,
3435
pub gravatar_url: Option<String>,
36+
/// Is this email address listed in the haveibeenpwned database for
37+
/// previous breaches?
38+
pub haveibeenpwned: Option<bool>,
3539
}
3640

3741
/// Error occured connecting to this email server via SMTP. Right now this
@@ -42,7 +46,11 @@ pub struct MiscDetails {
4246
pub enum MiscError {}
4347

4448
/// Fetch misc details about the email address, such as whether it's disposable.
45-
pub async fn check_misc(syntax: &SyntaxDetails, cfg_check_gravatar: bool) -> MiscDetails {
49+
pub async fn check_misc(
50+
syntax: &SyntaxDetails,
51+
cfg_check_gravatar: bool,
52+
haveibeenpwned_api_key: Option<String>,
53+
) -> MiscDetails {
4654
let role_accounts: Vec<&str> =
4755
serde_json::from_str(ROLE_ACCOUNTS).expect("roles.json is a valid json. qed.");
4856

@@ -58,12 +66,19 @@ pub async fn check_misc(syntax: &SyntaxDetails, cfg_check_gravatar: bool) -> Mis
5866
gravatar_url = check_gravatar(address.as_ref()).await;
5967
}
6068

69+
let mut haveibeenpwned: Option<bool> = None;
70+
71+
if haveibeenpwned_api_key.is_some() {
72+
haveibeenpwned = check_haveibeenpwned(address.as_ref(), haveibeenpwned_api_key).await;
73+
}
74+
6175
MiscDetails {
6276
// mailchecker::is_valid checks also if the syntax is valid. But if
6377
// we're here, it means we're sure the syntax is valid, so is_valid
6478
// actually will only check if it's disposable.
6579
is_disposable: !mailchecker::is_valid(address.as_ref()),
6680
is_role_account: role_accounts.contains(&syntax.username.to_lowercase().as_ref()),
6781
gravatar_url,
82+
haveibeenpwned,
6883
}
6984
}

0 commit comments

Comments
 (0)