From 6715f679328f07cc243f974db1afb45102621b95 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Tue, 24 Jun 2025 08:13:34 +0200 Subject: [PATCH 1/2] Add email template for Trusted Publishing token exposure notifications This commit implements a `TrustedPublishingTokenExposedEmail` template that will be used to notify users when their trusted publishing tokens are revoked due to GitHub secret scanning alerts. The template handles both single and multiple crate scenarios and provides security guidance. --- src/controllers/github/secret_scanning.rs | 60 ++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/controllers/github/secret_scanning.rs b/src/controllers/github/secret_scanning.rs index 320aa72af1c..778e6c4666f 100644 --- a/src/controllers/github/secret_scanning.rs +++ b/src/controllers/github/secret_scanning.rs @@ -145,7 +145,7 @@ async fn alert_revoke_token( .await?; if deleted_count > 0 { - warn!("Active Trusted Publishing token received and revoked (true positive)"); + warn!("Active Trusted Publishing token received and revoked (true positive)"); return Ok(GitHubSecretAlertFeedbackLabel::TruePositive); } else { debug!("Unknown Trusted Publishing token received (false positive)"); @@ -264,6 +264,64 @@ Source type: {source}", } } +struct TrustedPublishingTokenExposedEmail<'a> { + domain: &'a str, + reporter: &'a str, + source: &'a str, + crate_names: &'a [String], + url: &'a str, +} + +impl Email for TrustedPublishingTokenExposedEmail<'_> { + fn subject(&self) -> String { + "crates.io: Your Trusted Publishing token has been revoked".to_string() + } + + fn body(&self) -> String { + let authorization = if self.crate_names.len() == 1 { + format!( + "This token was only authorized to publish the \"{}\" crate.", + self.crate_names[0] + ) + } else { + format!( + "This token was authorized to publish the following crates: \"{}\".", + self.crate_names.join("\", \"") + ) + }; + + let mut body = format!( + "{reporter} has notified us that one of your crates.io Trusted Publishing tokens \ +has been exposed publicly. We have revoked this token as a precaution. + +{authorization} + +Please review your account at https://{domain} and your GitHub repository \ +settings to confirm that no unexpected changes have been made to your crates \ +or trusted publishing configurations. + +Source type: {source}", + domain = self.domain, + reporter = self.reporter, + source = self.source, + ); + + if self.url.is_empty() { + body.push_str("\n\nWe were not informed of the URL where the token was found."); + } else { + body.push_str(&format!("\n\nURL where the token was found: {}", self.url)); + } + + body.push_str( + "\n\nTrusted 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.", + ); + + body + } +} + #[derive(Deserialize, Serialize)] pub struct GitHubSecretAlertFeedback { pub token_raw: String, From 46b2231e4310825f8cc931fa2a3f540ef9fee89f Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Tue, 24 Jun 2025 08:56:39 +0200 Subject: [PATCH 2/2] Implement trusted publishing token revocation notifications --- src/controllers/github/secret_scanning.rs | 98 +++++++++++++++++-- src/tests/github_secret_scanning.rs | 75 +++++++++++++- ...secret_alert_revokes_trustpub_token-3.snap | 21 ++++ ...vokes_trustpub_token_multiple_users-2.snap | 11 +++ ...vokes_trustpub_token_multiple_users-3.snap | 40 ++++++++ 5 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token-3.snap create mode 100644 src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token_multiple_users-2.snap create mode 100644 src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token_multiple_users-3.snap diff --git a/src/controllers/github/secret_scanning.rs b/src/controllers/github/secret_scanning.rs index 778e6c4666f..a2002b96c6c 100644 --- a/src/controllers/github/secret_scanning.rs +++ b/src/controllers/github/secret_scanning.rs @@ -1,23 +1,26 @@ use crate::app::AppState; use crate::email::Email; use crate::models::{ApiToken, User}; -use crate::schema::api_tokens; +use crate::schema::{api_tokens, crate_owners, crates, emails}; use crate::util::errors::{AppResult, BoxedAppError, bad_request}; use crate::util::token::HashedToken; use anyhow::{Context, anyhow}; use axum::Json; use axum::body::Bytes; use base64::{Engine, engine::general_purpose}; +use crates_io_database::models::OwnerKind; use crates_io_database::schema::trustpub_tokens; use crates_io_github::GitHubPublicKey; use crates_io_trustpub::access_token::AccessToken; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use futures_util::TryStreamExt; use http::HeaderMap; use p256::PublicKey; use p256::ecdsa::VerifyingKey; use p256::ecdsa::signature::Verifier; use serde_json as json; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::str::FromStr; use std::sync::LazyLock; use std::time::Duration; @@ -138,19 +141,31 @@ async fn alert_revoke_token( if let Ok(token) = alert.token.parse::() { let hashed_token = token.sha256(); - // Check if the token exists in the database - let deleted_count = diesel::delete(trustpub_tokens::table) + // Delete the token and return crate_ids for notifications + let crate_ids = diesel::delete(trustpub_tokens::table) .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) - .execute(conn) - .await?; + .returning(trustpub_tokens::crate_ids) + .get_result::>>(conn) + .await + .optional()?; - if deleted_count > 0 { - warn!("Active Trusted Publishing token received and revoked (true positive)"); - return Ok(GitHubSecretAlertFeedbackLabel::TruePositive); - } else { + let Some(crate_ids) = crate_ids else { debug!("Unknown Trusted Publishing token received (false positive)"); return Ok(GitHubSecretAlertFeedbackLabel::FalsePositive); + }; + + warn!("Active Trusted Publishing token received and revoked (true positive)"); + + // Send notification emails to all affected crate owners + let actual_crate_ids: Vec = crate_ids.into_iter().flatten().collect(); + let result = send_trustpub_notification_emails(&actual_crate_ids, alert, state, conn).await; + if let Err(error) = result { + warn!( + "Failed to send trusted publishing token exposure notifications for crates {actual_crate_ids:?}: {error}", + ); } + + return Ok(GitHubSecretAlertFeedbackLabel::TruePositive); } // If not a Trusted Publishing token or not found, try as a regular API token @@ -224,6 +239,71 @@ async fn send_notification_email( Ok(()) } +async fn send_trustpub_notification_emails( + crate_ids: &[i32], + alert: &GitHubSecretAlert, + state: &AppState, + conn: &mut AsyncPgConnection, +) -> anyhow::Result<()> { + // Build a mapping from crate_id to crate_name directly from the query + let crate_id_to_name: HashMap = crates::table + .select((crates::id, crates::name)) + .filter(crates::id.eq_any(crate_ids)) + .load_stream::<(i32, String)>(conn) + .await? + .try_fold(HashMap::new(), |mut map, (id, name)| { + map.insert(id, name); + std::future::ready(Ok(map)) + }) + .await + .context("Failed to query crate names")?; + + // Then, get all verified owner emails for these crates + let owner_emails = crate_owners::table + .filter(crate_owners::crate_id.eq_any(crate_ids)) + .filter(crate_owners::owner_kind.eq(OwnerKind::User)) // OwnerKind::User + .filter(crate_owners::deleted.eq(false)) + .inner_join(emails::table.on(crate_owners::owner_id.eq(emails::user_id))) + .filter(emails::verified.eq(true)) + .select((crate_owners::crate_id, emails::email)) + .order((emails::email, crate_owners::crate_id)) + .load::<(i32, String)>(conn) + .await + .context("Failed to query crate owners")?; + + // Group by email address to send one notification per user + let mut notifications: BTreeMap> = BTreeMap::new(); + + for (crate_id, email) in owner_emails { + if let Some(crate_name) = crate_id_to_name.get(&crate_id) { + notifications + .entry(email) + .or_default() + .insert(crate_name.clone()); + } + } + + // Send notifications in sorted order by email for consistent testing + for (email, crate_names) in notifications { + let email_template = TrustedPublishingTokenExposedEmail { + domain: &state.config.domain_name, + reporter: "GitHub", + source: &alert.source, + crate_names: &crate_names.iter().cloned().collect::>(), + url: &alert.url, + }; + + if let Err(error) = state.emails.send(&email, email_template).await { + warn!( + %email, ?crate_names, ?error, + "Failed to send trusted publishing token exposure notification" + ); + } + } + + Ok(()) +} + struct TokenExposedEmail<'a> { domain: &'a str, reporter: &'a str, diff --git a/src/tests/github_secret_scanning.rs b/src/tests/github_secret_scanning.rs index 0488f5d3e10..c3b8b83754a 100644 --- a/src/tests/github_secret_scanning.rs +++ b/src/tests/github_secret_scanning.rs @@ -1,3 +1,4 @@ +use crate::tests::builders::CrateBuilder; use crate::tests::util::MockRequestExt; use crate::tests::util::insta::api_token_redaction; use crate::tests::{RequestHelper, TestApp}; @@ -5,6 +6,7 @@ use crate::util::token::HashedToken; use crate::{models::ApiToken, schema::api_tokens}; use base64::{Engine as _, engine::general_purpose}; use chrono::{TimeDelta, Utc}; +use crates_io_database::models::CrateOwner; use crates_io_database::models::trustpub::NewToken; use crates_io_database::schema::trustpub_tokens; use crates_io_github::{GitHubPublicKey, MockGitHubClient}; @@ -71,13 +73,16 @@ fn generate_trustpub_token() -> (String, Vec) { } /// Create a new Trusted Publishing token in the database -async fn insert_trustpub_token(conn: &mut diesel_async::AsyncPgConnection) -> QueryResult { +async fn insert_trustpub_token( + conn: &mut diesel_async::AsyncPgConnection, + crate_ids: &[i32], +) -> QueryResult { let (token, hashed_token) = generate_trustpub_token(); let new_token = NewToken { expires_at: Utc::now() + TimeDelta::minutes(30), hashed_token: &hashed_token, - crate_ids: &[1], // Arbitrary crate ID for testing + crate_ids, }; new_token.insert(conn).await?; @@ -319,11 +324,16 @@ async fn github_secret_alert_invalid_signature_fails() { #[tokio::test(flavor = "multi_thread")] async fn github_secret_alert_revokes_trustpub_token() { - let (app, anon) = TestApp::init().with_github(github_mock()).empty().await; + let (app, anon, cookie) = TestApp::init().with_github(github_mock()).with_user().await; let mut conn = app.db_conn().await; + let krate = CrateBuilder::new("foo", cookie.as_model().id) + .build(&mut conn) + .await + .unwrap(); + // Generate a valid Trusted Publishing token - let token = insert_trustpub_token(&mut conn).await.unwrap(); + let token = insert_trustpub_token(&mut conn, &[krate.id]).await.unwrap(); // Verify the token exists in the database let count = trustpub_tokens::table @@ -352,6 +362,9 @@ async fn github_secret_alert_revokes_trustpub_token() { .await .unwrap(); assert_eq!(count, 0); + + // Ensure an email was sent notifying about the token revocation + assert_snapshot!(app.emails_snapshot().await); } #[tokio::test(flavor = "multi_thread")] @@ -389,4 +402,58 @@ async fn github_secret_alert_for_unknown_trustpub_token() { .await .unwrap(); assert_eq!(count, 0); + + // Ensure no emails were sent + assert_eq!(app.emails().await.len(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn github_secret_alert_revokes_trustpub_token_multiple_users() { + let (app, anon) = TestApp::init().with_github(github_mock()).empty().await; + let mut conn = app.db_conn().await; + + // Create two users + let user1 = app.db_new_user("user1").await; + let user2 = app.db_new_user("user2").await; + + // Create two crates + // User 1 owns both crates 1 and 2 + let crate1 = CrateBuilder::new("crate1", user1.as_model().id) + .build(&mut conn) + .await + .unwrap(); + let crate2 = CrateBuilder::new("crate2", user1.as_model().id) + .build(&mut conn) + .await + .unwrap(); + + // Add user 2 as owner of crate2 + CrateOwner::builder() + .crate_id(crate2.id) + .user_id(user2.as_model().id) + .created_by(user1.as_model().id) + .build() + .insert(&mut conn) + .await + .unwrap(); + + // Generate a trusted publishing token that has access to both crates + let token = insert_trustpub_token(&mut conn, &[crate1.id, crate2.id]) + .await + .unwrap(); + + // Send the GitHub alert to the API endpoint + let mut request = anon.post_request(URL); + let vec = github_alert_with_token(&token); + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(&vec)); + *request.body_mut() = vec.into(); + let response = anon.run::<()>(request).await; + assert_snapshot!(response.status(), @"200 OK"); + assert_json_snapshot!(response.json(), { + "[].token_raw" => api_token_redaction() + }); + + // Take a snapshot of all emails for detailed verification + assert_snapshot!(app.emails_snapshot().await); } diff --git a/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token-3.snap b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token-3.snap new file mode 100644 index 00000000000..df6bd5175fb --- /dev/null +++ b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token-3.snap @@ -0,0 +1,21 @@ +--- +source: src/tests/github_secret_scanning.rs +expression: app.emails_snapshot().await +--- +To: foo@example.com +From: crates.io +Subject: crates.io: Your Trusted Publishing token has been revoked +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +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. + +This token was only authorized to publish the "foo" crate. + +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. + +Source type: some_source + +URL where the token was found: some_url + +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. diff --git a/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token_multiple_users-2.snap b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token_multiple_users-2.snap new file mode 100644 index 00000000000..046417706e5 --- /dev/null +++ b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token_multiple_users-2.snap @@ -0,0 +1,11 @@ +--- +source: src/tests/github_secret_scanning.rs +expression: response.json() +--- +[ + { + "label": "true_positive", + "token_raw": "[token]", + "token_type": "some_type" + } +] diff --git a/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token_multiple_users-3.snap b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token_multiple_users-3.snap new file mode 100644 index 00000000000..4e12cfaabc5 --- /dev/null +++ b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token_multiple_users-3.snap @@ -0,0 +1,40 @@ +--- +source: src/tests/github_secret_scanning.rs +expression: app.emails_snapshot().await +--- +To: user1@example.com +From: crates.io +Subject: crates.io: Your Trusted Publishing token has been revoked +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +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. + +This token was authorized to publish the following crates: "crate1", "crate2". + +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. + +Source type: some_source + +URL where the token was found: some_url + +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. +---------------------------------------- + +To: user2@example.com +From: crates.io +Subject: crates.io: Your Trusted Publishing token has been revoked +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +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. + +This token was only authorized to publish the "crate2" crate. + +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. + +Source type: some_source + +URL where the token was found: some_url + +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.