Skip to content

Commit 55d3349

Browse files
authored
Merge pull request #11419 from Turbo87/trustpub-exposure-notifications
Implement Trusted Publishing token exposure notifications
2 parents cf72cc3 + 46b2231 commit 55d3349

5 files changed

+290
-13
lines changed

src/controllers/github/secret_scanning.rs

Lines changed: 147 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
use crate::app::AppState;
22
use crate::email::Email;
33
use crate::models::{ApiToken, User};
4-
use crate::schema::api_tokens;
4+
use crate::schema::{api_tokens, crate_owners, crates, emails};
55
use crate::util::errors::{AppResult, BoxedAppError, bad_request};
66
use crate::util::token::HashedToken;
77
use anyhow::{Context, anyhow};
88
use axum::Json;
99
use axum::body::Bytes;
1010
use base64::{Engine, engine::general_purpose};
11+
use crates_io_database::models::OwnerKind;
1112
use crates_io_database::schema::trustpub_tokens;
1213
use crates_io_github::GitHubPublicKey;
1314
use crates_io_trustpub::access_token::AccessToken;
1415
use diesel::prelude::*;
1516
use diesel_async::{AsyncPgConnection, RunQueryDsl};
17+
use futures_util::TryStreamExt;
1618
use http::HeaderMap;
1719
use p256::PublicKey;
1820
use p256::ecdsa::VerifyingKey;
1921
use p256::ecdsa::signature::Verifier;
2022
use serde_json as json;
23+
use std::collections::{BTreeMap, BTreeSet, HashMap};
2124
use std::str::FromStr;
2225
use std::sync::LazyLock;
2326
use std::time::Duration;
@@ -138,19 +141,31 @@ async fn alert_revoke_token(
138141
if let Ok(token) = alert.token.parse::<AccessToken>() {
139142
let hashed_token = token.sha256();
140143

141-
// Check if the token exists in the database
142-
let deleted_count = diesel::delete(trustpub_tokens::table)
144+
// Delete the token and return crate_ids for notifications
145+
let crate_ids = diesel::delete(trustpub_tokens::table)
143146
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
144-
.execute(conn)
145-
.await?;
147+
.returning(trustpub_tokens::crate_ids)
148+
.get_result::<Vec<Option<i32>>>(conn)
149+
.await
150+
.optional()?;
146151

147-
if deleted_count > 0 {
148-
warn!("Active Trusted Publishing token received and revoked (true positive)");
149-
return Ok(GitHubSecretAlertFeedbackLabel::TruePositive);
150-
} else {
152+
let Some(crate_ids) = crate_ids else {
151153
debug!("Unknown Trusted Publishing token received (false positive)");
152154
return Ok(GitHubSecretAlertFeedbackLabel::FalsePositive);
155+
};
156+
157+
warn!("Active Trusted Publishing token received and revoked (true positive)");
158+
159+
// Send notification emails to all affected crate owners
160+
let actual_crate_ids: Vec<i32> = crate_ids.into_iter().flatten().collect();
161+
let result = send_trustpub_notification_emails(&actual_crate_ids, alert, state, conn).await;
162+
if let Err(error) = result {
163+
warn!(
164+
"Failed to send trusted publishing token exposure notifications for crates {actual_crate_ids:?}: {error}",
165+
);
153166
}
167+
168+
return Ok(GitHubSecretAlertFeedbackLabel::TruePositive);
154169
}
155170

156171
// If not a Trusted Publishing token or not found, try as a regular API token
@@ -224,6 +239,71 @@ async fn send_notification_email(
224239
Ok(())
225240
}
226241

242+
async fn send_trustpub_notification_emails(
243+
crate_ids: &[i32],
244+
alert: &GitHubSecretAlert,
245+
state: &AppState,
246+
conn: &mut AsyncPgConnection,
247+
) -> anyhow::Result<()> {
248+
// Build a mapping from crate_id to crate_name directly from the query
249+
let crate_id_to_name: HashMap<i32, String> = crates::table
250+
.select((crates::id, crates::name))
251+
.filter(crates::id.eq_any(crate_ids))
252+
.load_stream::<(i32, String)>(conn)
253+
.await?
254+
.try_fold(HashMap::new(), |mut map, (id, name)| {
255+
map.insert(id, name);
256+
std::future::ready(Ok(map))
257+
})
258+
.await
259+
.context("Failed to query crate names")?;
260+
261+
// Then, get all verified owner emails for these crates
262+
let owner_emails = crate_owners::table
263+
.filter(crate_owners::crate_id.eq_any(crate_ids))
264+
.filter(crate_owners::owner_kind.eq(OwnerKind::User)) // OwnerKind::User
265+
.filter(crate_owners::deleted.eq(false))
266+
.inner_join(emails::table.on(crate_owners::owner_id.eq(emails::user_id)))
267+
.filter(emails::verified.eq(true))
268+
.select((crate_owners::crate_id, emails::email))
269+
.order((emails::email, crate_owners::crate_id))
270+
.load::<(i32, String)>(conn)
271+
.await
272+
.context("Failed to query crate owners")?;
273+
274+
// Group by email address to send one notification per user
275+
let mut notifications: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
276+
277+
for (crate_id, email) in owner_emails {
278+
if let Some(crate_name) = crate_id_to_name.get(&crate_id) {
279+
notifications
280+
.entry(email)
281+
.or_default()
282+
.insert(crate_name.clone());
283+
}
284+
}
285+
286+
// Send notifications in sorted order by email for consistent testing
287+
for (email, crate_names) in notifications {
288+
let email_template = TrustedPublishingTokenExposedEmail {
289+
domain: &state.config.domain_name,
290+
reporter: "GitHub",
291+
source: &alert.source,
292+
crate_names: &crate_names.iter().cloned().collect::<Vec<_>>(),
293+
url: &alert.url,
294+
};
295+
296+
if let Err(error) = state.emails.send(&email, email_template).await {
297+
warn!(
298+
%email, ?crate_names, ?error,
299+
"Failed to send trusted publishing token exposure notification"
300+
);
301+
}
302+
}
303+
304+
Ok(())
305+
}
306+
227307
struct TokenExposedEmail<'a> {
228308
domain: &'a str,
229309
reporter: &'a str,
@@ -264,6 +344,64 @@ Source type: {source}",
264344
}
265345
}
266346

347+
struct TrustedPublishingTokenExposedEmail<'a> {
348+
domain: &'a str,
349+
reporter: &'a str,
350+
source: &'a str,
351+
crate_names: &'a [String],
352+
url: &'a str,
353+
}
354+
355+
impl Email for TrustedPublishingTokenExposedEmail<'_> {
356+
fn subject(&self) -> String {
357+
"crates.io: Your Trusted Publishing token has been revoked".to_string()
358+
}
359+
360+
fn body(&self) -> String {
361+
let authorization = if self.crate_names.len() == 1 {
362+
format!(
363+
"This token was only authorized to publish the \"{}\" crate.",
364+
self.crate_names[0]
365+
)
366+
} else {
367+
format!(
368+
"This token was authorized to publish the following crates: \"{}\".",
369+
self.crate_names.join("\", \"")
370+
)
371+
};
372+
373+
let mut body = format!(
374+
"{reporter} has notified us that one of your crates.io Trusted Publishing tokens \
375+
has been exposed publicly. We have revoked this token as a precaution.
376+
377+
{authorization}
378+
379+
Please review your account at https://{domain} and your GitHub repository \
380+
settings to confirm that no unexpected changes have been made to your crates \
381+
or trusted publishing configurations.
382+
383+
Source type: {source}",
384+
domain = self.domain,
385+
reporter = self.reporter,
386+
source = self.source,
387+
);
388+
389+
if self.url.is_empty() {
390+
body.push_str("\n\nWe were not informed of the URL where the token was found.");
391+
} else {
392+
body.push_str(&format!("\n\nURL where the token was found: {}", self.url));
393+
}
394+
395+
body.push_str(
396+
"\n\nTrusted Publishing tokens are temporary and used for automated \
397+
publishing from GitHub Actions. If this exposure was unexpected, please review \
398+
your repository's workflow files and secrets.",
399+
);
400+
401+
body
402+
}
403+
}
404+
267405
#[derive(Deserialize, Serialize)]
268406
pub struct GitHubSecretAlertFeedback {
269407
pub token_raw: String,

src/tests/github_secret_scanning.rs

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
use crate::tests::builders::CrateBuilder;
12
use crate::tests::util::MockRequestExt;
23
use crate::tests::util::insta::api_token_redaction;
34
use crate::tests::{RequestHelper, TestApp};
45
use crate::util::token::HashedToken;
56
use crate::{models::ApiToken, schema::api_tokens};
67
use base64::{Engine as _, engine::general_purpose};
78
use chrono::{TimeDelta, Utc};
9+
use crates_io_database::models::CrateOwner;
810
use crates_io_database::models::trustpub::NewToken;
911
use crates_io_database::schema::trustpub_tokens;
1012
use crates_io_github::{GitHubPublicKey, MockGitHubClient};
@@ -71,13 +73,16 @@ fn generate_trustpub_token() -> (String, Vec<u8>) {
7173
}
7274

7375
/// Create a new Trusted Publishing token in the database
74-
async fn insert_trustpub_token(conn: &mut diesel_async::AsyncPgConnection) -> QueryResult<String> {
76+
async fn insert_trustpub_token(
77+
conn: &mut diesel_async::AsyncPgConnection,
78+
crate_ids: &[i32],
79+
) -> QueryResult<String> {
7580
let (token, hashed_token) = generate_trustpub_token();
7681

7782
let new_token = NewToken {
7883
expires_at: Utc::now() + TimeDelta::minutes(30),
7984
hashed_token: &hashed_token,
80-
crate_ids: &[1], // Arbitrary crate ID for testing
85+
crate_ids,
8186
};
8287

8388
new_token.insert(conn).await?;
@@ -319,11 +324,16 @@ async fn github_secret_alert_invalid_signature_fails() {
319324

320325
#[tokio::test(flavor = "multi_thread")]
321326
async fn github_secret_alert_revokes_trustpub_token() {
322-
let (app, anon) = TestApp::init().with_github(github_mock()).empty().await;
327+
let (app, anon, cookie) = TestApp::init().with_github(github_mock()).with_user().await;
323328
let mut conn = app.db_conn().await;
324329

330+
let krate = CrateBuilder::new("foo", cookie.as_model().id)
331+
.build(&mut conn)
332+
.await
333+
.unwrap();
334+
325335
// Generate a valid Trusted Publishing token
326-
let token = insert_trustpub_token(&mut conn).await.unwrap();
336+
let token = insert_trustpub_token(&mut conn, &[krate.id]).await.unwrap();
327337

328338
// Verify the token exists in the database
329339
let count = trustpub_tokens::table
@@ -352,6 +362,9 @@ async fn github_secret_alert_revokes_trustpub_token() {
352362
.await
353363
.unwrap();
354364
assert_eq!(count, 0);
365+
366+
// Ensure an email was sent notifying about the token revocation
367+
assert_snapshot!(app.emails_snapshot().await);
355368
}
356369

357370
#[tokio::test(flavor = "multi_thread")]
@@ -389,4 +402,58 @@ async fn github_secret_alert_for_unknown_trustpub_token() {
389402
.await
390403
.unwrap();
391404
assert_eq!(count, 0);
405+
406+
// Ensure no emails were sent
407+
assert_eq!(app.emails().await.len(), 0);
408+
}
409+
410+
#[tokio::test(flavor = "multi_thread")]
411+
async fn github_secret_alert_revokes_trustpub_token_multiple_users() {
412+
let (app, anon) = TestApp::init().with_github(github_mock()).empty().await;
413+
let mut conn = app.db_conn().await;
414+
415+
// Create two users
416+
let user1 = app.db_new_user("user1").await;
417+
let user2 = app.db_new_user("user2").await;
418+
419+
// Create two crates
420+
// User 1 owns both crates 1 and 2
421+
let crate1 = CrateBuilder::new("crate1", user1.as_model().id)
422+
.build(&mut conn)
423+
.await
424+
.unwrap();
425+
let crate2 = CrateBuilder::new("crate2", user1.as_model().id)
426+
.build(&mut conn)
427+
.await
428+
.unwrap();
429+
430+
// Add user 2 as owner of crate2
431+
CrateOwner::builder()
432+
.crate_id(crate2.id)
433+
.user_id(user2.as_model().id)
434+
.created_by(user1.as_model().id)
435+
.build()
436+
.insert(&mut conn)
437+
.await
438+
.unwrap();
439+
440+
// Generate a trusted publishing token that has access to both crates
441+
let token = insert_trustpub_token(&mut conn, &[crate1.id, crate2.id])
442+
.await
443+
.unwrap();
444+
445+
// Send the GitHub alert to the API endpoint
446+
let mut request = anon.post_request(URL);
447+
let vec = github_alert_with_token(&token);
448+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
449+
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(&vec));
450+
*request.body_mut() = vec.into();
451+
let response = anon.run::<()>(request).await;
452+
assert_snapshot!(response.status(), @"200 OK");
453+
assert_json_snapshot!(response.json(), {
454+
"[].token_raw" => api_token_redaction()
455+
});
456+
457+
// Take a snapshot of all emails for detailed verification
458+
assert_snapshot!(app.emails_snapshot().await);
392459
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
source: src/tests/github_secret_scanning.rs
3+
expression: app.emails_snapshot().await
4+
---
5+
To: foo@example.com
6+
From: crates.io <noreply@crates.io>
7+
Subject: crates.io: Your Trusted Publishing token has been revoked
8+
Content-Type: text/plain; charset=utf-8
9+
Content-Transfer-Encoding: quoted-printable
10+
11+
GitHub has notified us that one of your crates.io Trusted Publishing tokens has been exposed publicly. We have revoked this token as a precaution.
12+
13+
This token was only authorized to publish the "foo" crate.
14+
15+
Please review your account at https://crates.io and your GitHub repository settings to confirm that no unexpected changes have been made to your crates or trusted publishing configurations.
16+
17+
Source type: some_source
18+
19+
URL where the token was found: some_url
20+
21+
Trusted Publishing tokens are temporary and used for automated publishing from GitHub Actions. If this exposure was unexpected, please review your repository's workflow files and secrets.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
source: src/tests/github_secret_scanning.rs
3+
expression: response.json()
4+
---
5+
[
6+
{
7+
"label": "true_positive",
8+
"token_raw": "[token]",
9+
"token_type": "some_type"
10+
}
11+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
source: src/tests/github_secret_scanning.rs
3+
expression: app.emails_snapshot().await
4+
---
5+
To: user1@example.com
6+
From: crates.io <noreply@crates.io>
7+
Subject: crates.io: Your Trusted Publishing token has been revoked
8+
Content-Type: text/plain; charset=utf-8
9+
Content-Transfer-Encoding: quoted-printable
10+
11+
GitHub has notified us that one of your crates.io Trusted Publishing tokens has been exposed publicly. We have revoked this token as a precaution.
12+
13+
This token was authorized to publish the following crates: "crate1", "crate2".
14+
15+
Please review your account at https://crates.io and your GitHub repository settings to confirm that no unexpected changes have been made to your crates or trusted publishing configurations.
16+
17+
Source type: some_source
18+
19+
URL where the token was found: some_url
20+
21+
Trusted Publishing tokens are temporary and used for automated publishing from GitHub Actions. If this exposure was unexpected, please review your repository's workflow files and secrets.
22+
----------------------------------------
23+
24+
To: user2@example.com
25+
From: crates.io <noreply@crates.io>
26+
Subject: crates.io: Your Trusted Publishing token has been revoked
27+
Content-Type: text/plain; charset=utf-8
28+
Content-Transfer-Encoding: quoted-printable
29+
30+
GitHub has notified us that one of your crates.io Trusted Publishing tokens has been exposed publicly. We have revoked this token as a precaution.
31+
32+
This token was only authorized to publish the "crate2" crate.
33+
34+
Please review your account at https://crates.io and your GitHub repository settings to confirm that no unexpected changes have been made to your crates or trusted publishing configurations.
35+
36+
Source type: some_source
37+
38+
URL where the token was found: some_url
39+
40+
Trusted Publishing tokens are temporary and used for automated publishing from GitHub Actions. If this exposure was unexpected, please review your repository's workflow files and secrets.

0 commit comments

Comments
 (0)