diff --git a/src/controllers/github/secret_scanning.rs b/src/controllers/github/secret_scanning.rs index 320aa72af1..a2002b96c6 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, @@ -264,6 +344,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, diff --git a/src/tests/github_secret_scanning.rs b/src/tests/github_secret_scanning.rs index 0488f5d3e1..c3b8b83754 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 0000000000..df6bd5175f --- /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 0000000000..046417706e --- /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 0000000000..4e12cfaabc --- /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.