Skip to content

Commit 047319a

Browse files
committed
Implement trusted publishing token revocation notifications
1 parent 2d9353e commit 047319a

5 files changed

+245
-12
lines changed

src/controllers/github/secret_scanning.rs

Lines changed: 102 additions & 8 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::HashMap;
2124
use std::str::FromStr;
2225
use std::sync::LazyLock;
2326
use std::time::Duration;
@@ -138,19 +141,40 @@ 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+
// Query for token data before deleting to get crate_ids for notifications
145+
let token_data = trustpub_tokens::table
146+
.select(trustpub_tokens::crate_ids)
147+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
148+
.get_result::<Vec<Option<i32>>>(conn)
149+
.await
150+
.optional()?;
151+
152+
let Some(crate_ids) = token_data else {
153+
debug!("Unknown Trusted Publishing token received (false positive)");
154+
return Ok(GitHubSecretAlertFeedbackLabel::FalsePositive);
155+
};
156+
157+
// Delete the token
158+
diesel::delete(trustpub_tokens::table)
143159
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
144160
.execute(conn)
145161
.await?;
146162

147-
if deleted_count > 0 {
148163
warn!("Active Trusted Publishing token received and revoked (true positive)");
149-
return Ok(GitHubSecretAlertFeedbackLabel::TruePositive);
150-
} else {
151-
debug!("Unknown Trusted Publishing token received (false positive)");
152-
return Ok(GitHubSecretAlertFeedbackLabel::FalsePositive);
164+
165+
// Send notification emails to all affected crate owners
166+
let actual_crate_ids: Vec<i32> = crate_ids.into_iter().flatten().collect();
167+
if let Err(error) =
168+
send_trustpub_notification_emails(&actual_crate_ids, alert, state, conn).await
169+
{
170+
warn!(
171+
?actual_crate_ids,
172+
?error,
173+
"Failed to send trusted publishing token exposure notifications"
174+
);
153175
}
176+
177+
return Ok(GitHubSecretAlertFeedbackLabel::TruePositive);
154178
}
155179

156180
// If not a Trusted Publishing token or not found, try as a regular API token
@@ -224,6 +248,76 @@ async fn send_notification_email(
224248
Ok(())
225249
}
226250

251+
async fn send_trustpub_notification_emails(
252+
crate_ids: &[i32],
253+
alert: &GitHubSecretAlert,
254+
state: &AppState,
255+
conn: &mut AsyncPgConnection,
256+
) -> anyhow::Result<()> {
257+
// Build a mapping from crate_id to crate_name directly from the query
258+
let crate_id_to_name: HashMap<i32, String> = crates::table
259+
.select((crates::id, crates::name))
260+
.filter(crates::id.eq_any(crate_ids))
261+
.load_stream::<(i32, String)>(conn)
262+
.await?
263+
.try_fold(HashMap::new(), |mut map, (id, name)| {
264+
map.insert(id, name);
265+
std::future::ready(Ok(map))
266+
})
267+
.await
268+
.context("Failed to query crate names")?;
269+
270+
// Then, get all verified owner emails for these crates
271+
let owner_emails = crate_owners::table
272+
.filter(crate_owners::crate_id.eq_any(crate_ids))
273+
.filter(crate_owners::owner_kind.eq(OwnerKind::User)) // OwnerKind::User
274+
.filter(crate_owners::deleted.eq(false))
275+
.inner_join(emails::table.on(crate_owners::owner_id.eq(emails::user_id)))
276+
.filter(emails::verified.eq(true))
277+
.select((crate_owners::crate_id, emails::email))
278+
.order((emails::email, crate_owners::crate_id))
279+
.load::<(i32, String)>(conn)
280+
.await
281+
.context("Failed to query crate owners")?;
282+
283+
// Group by email address to send one notification per user
284+
let mut notifications: HashMap<String, Vec<String>> = HashMap::new();
285+
286+
for (crate_id, email) in owner_emails {
287+
if let Some(crate_name) = crate_id_to_name.get(&crate_id) {
288+
notifications
289+
.entry(email)
290+
.or_default()
291+
.push(crate_name.clone());
292+
}
293+
}
294+
295+
// Send notifications in sorted order by email for consistent testing
296+
let mut sorted_notifications: Vec<(String, Vec<String>)> = notifications.into_iter().collect();
297+
sorted_notifications.sort_by(|a, b| a.0.cmp(&b.0));
298+
299+
for (email, mut crate_names) in sorted_notifications {
300+
crate_names.sort();
301+
302+
let email_template = TrustedPublishingTokenExposedEmail {
303+
domain: &state.config.domain_name,
304+
reporter: "GitHub",
305+
source: &alert.source,
306+
crate_names: &crate_names,
307+
url: &alert.url,
308+
};
309+
310+
if let Err(error) = state.emails.send(&email, email_template).await {
311+
warn!(
312+
%email, ?crate_names, ?error,
313+
"Failed to send trusted publishing token exposure notification"
314+
);
315+
}
316+
}
317+
318+
Ok(())
319+
}
320+
227321
struct TokenExposedEmail<'a> {
228322
domain: &'a str,
229323
reporter: &'a str,

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)