Skip to content

Implement Trusted Publishing token exposure notifications #11419

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 147 additions & 9 deletions src/controllers/github/secret_scanning.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -138,19 +141,31 @@ async fn alert_revoke_token(
if let Ok(token) = alert.token.parse::<AccessToken>() {
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::<Vec<Option<i32>>>(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<i32> = 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
Expand Down Expand Up @@ -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<i32, String> = 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<String, BTreeSet<String>> = 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::<Vec<_>>(),
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,
Expand Down Expand Up @@ -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,
Expand Down
75 changes: 71 additions & 4 deletions src/tests/github_secret_scanning.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use crate::tests::builders::CrateBuilder;
use crate::tests::util::MockRequestExt;
use crate::tests::util::insta::api_token_redaction;
use crate::tests::{RequestHelper, TestApp};
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};
Expand Down Expand Up @@ -71,13 +73,16 @@ fn generate_trustpub_token() -> (String, Vec<u8>) {
}

/// Create a new Trusted Publishing token in the database
async fn insert_trustpub_token(conn: &mut diesel_async::AsyncPgConnection) -> QueryResult<String> {
async fn insert_trustpub_token(
conn: &mut diesel_async::AsyncPgConnection,
crate_ids: &[i32],
) -> QueryResult<String> {
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?;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
source: src/tests/github_secret_scanning.rs
expression: app.emails_snapshot().await
---
To: [email protected]
From: crates.io <[email protected]>
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
source: src/tests/github_secret_scanning.rs
expression: response.json()
---
[
{
"label": "true_positive",
"token_raw": "[token]",
"token_type": "some_type"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
source: src/tests/github_secret_scanning.rs
expression: app.emails_snapshot().await
---
To: [email protected]
From: crates.io <[email protected]>
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: [email protected]
From: crates.io <[email protected]>
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.