Skip to content

Commit 53f6695

Browse files
committed
controllers/github/secret_scanning: Add support for Trusted Publishing tokens
1 parent ecc6055 commit 53f6695

4 files changed

+157
-2
lines changed

src/controllers/github/secret_scanning.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ 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::schema::trustpub_tokens;
1112
use crates_io_github::GitHubPublicKey;
13+
use crates_io_trustpub::access_token::AccessToken;
1214
use diesel::prelude::*;
1315
use diesel_async::{AsyncPgConnection, RunQueryDsl};
1416
use http::HeaderMap;
@@ -126,12 +128,32 @@ struct GitHubSecretAlert {
126128
source: String,
127129
}
128130

129-
/// Revokes an API token and notifies the token owner
131+
/// Revokes an API token or Trusted Publishing token and notifies the token owner
130132
async fn alert_revoke_token(
131133
state: &AppState,
132134
alert: &GitHubSecretAlert,
133135
conn: &mut AsyncPgConnection,
134136
) -> QueryResult<GitHubSecretAlertFeedbackLabel> {
137+
// First, try to handle as a Trusted Publishing token
138+
if let Ok(token) = alert.token.parse::<AccessToken>() {
139+
let hashed_token = token.sha256();
140+
141+
// Check if the token exists in the database
142+
let deleted_count = diesel::delete(trustpub_tokens::table)
143+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
144+
.execute(conn)
145+
.await?;
146+
147+
if deleted_count > 0 {
148+
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);
153+
}
154+
}
155+
156+
// If not a Trusted Publishing token or not found, try as a regular API token
135157
let hashed_token = HashedToken::hash(&alert.token);
136158

137159
// Not using `ApiToken::find_by_api_token()` in order to preserve `last_used_at`
@@ -143,7 +165,7 @@ async fn alert_revoke_token(
143165
.optional()?;
144166

145167
let Some(token) = token else {
146-
debug!("Unknown API token received (false positive)");
168+
debug!("Unknown token received (false positive)");
147169
return Ok(GitHubSecretAlertFeedbackLabel::FalsePositive);
148170
};
149171

src/tests/github_secret_scanning.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
use crate::tests::util::MockRequestExt;
2+
use crate::tests::util::insta::api_token_redaction;
23
use crate::tests::{RequestHelper, TestApp};
34
use crate::util::token::HashedToken;
45
use crate::{models::ApiToken, schema::api_tokens};
56
use base64::{Engine as _, engine::general_purpose};
7+
use chrono::{TimeDelta, Utc};
8+
use crates_io_database::models::trustpub::NewToken;
9+
use crates_io_database::schema::trustpub_tokens;
610
use crates_io_github::{GitHubPublicKey, MockGitHubClient};
11+
use crates_io_trustpub::access_token::AccessToken;
712
use diesel::prelude::*;
813
use diesel_async::RunQueryDsl;
914
use googletest::prelude::*;
1015
use insta::{assert_json_snapshot, assert_snapshot};
1116
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
1217
use p256::pkcs8::DecodePrivateKey;
18+
use secrecy::ExposeSecret;
1319
use std::sync::LazyLock;
1420

1521
static URL: &str = "/api/github/secret-scanning/verify";
@@ -18,6 +24,14 @@ static URL: &str = "/api/github/secret-scanning/verify";
1824
static GITHUB_ALERT: &[u8] =
1925
br#"[{"token":"some_token","type":"some_type","url":"some_url","source":"some_source"}]"#;
2026

27+
/// Generate a GitHub alert with a given token
28+
fn github_alert_with_token(token: &str) -> Vec<u8> {
29+
format!(
30+
r#"[{{"token":"{token}","type":"some_type","url":"some_url","source":"some_source"}}]"#,
31+
)
32+
.into_bytes()
33+
}
34+
2135
/// Private key for signing payloads (ECDSA P-256)
2236
///
2337
/// Generated specifically for testing - do not use in production.
@@ -48,6 +62,29 @@ fn sign_payload(payload: &[u8]) -> String {
4862
general_purpose::STANDARD.encode(signature.to_der())
4963
}
5064

65+
/// Generate a new Trusted Publishing token and its SHA256 hash
66+
fn generate_trustpub_token() -> (String, Vec<u8>) {
67+
let token = AccessToken::generate();
68+
let finalized_token = token.finalize().expose_secret().to_string();
69+
let hashed_token = token.sha256().to_vec();
70+
(finalized_token, hashed_token)
71+
}
72+
73+
/// Create a new Trusted Publishing token in the database
74+
async fn insert_trustpub_token(conn: &mut diesel_async::AsyncPgConnection) -> QueryResult<String> {
75+
let (token, hashed_token) = generate_trustpub_token();
76+
77+
let new_token = NewToken {
78+
expires_at: Utc::now() + TimeDelta::minutes(30),
79+
hashed_token: &hashed_token,
80+
crate_ids: &[1], // Arbitrary crate ID for testing
81+
};
82+
83+
new_token.insert(conn).await?;
84+
85+
Ok(token)
86+
}
87+
5188
fn github_mock() -> MockGitHubClient {
5289
let mut mock = MockGitHubClient::new();
5390

@@ -279,3 +316,77 @@ async fn github_secret_alert_invalid_signature_fails() {
279316
let response = anon.run::<()>(request).await;
280317
assert_snapshot!(response.status(), @"400 Bad Request");
281318
}
319+
320+
#[tokio::test(flavor = "multi_thread")]
321+
async fn github_secret_alert_revokes_trustpub_token() {
322+
let (app, anon) = TestApp::init().with_github(github_mock()).empty().await;
323+
let mut conn = app.db_conn().await;
324+
325+
// Generate a valid Trusted Publishing token
326+
let token = insert_trustpub_token(&mut conn).await.unwrap();
327+
328+
// Verify the token exists in the database
329+
let count = trustpub_tokens::table
330+
.count()
331+
.get_result::<i64>(&mut conn)
332+
.await
333+
.unwrap();
334+
assert_eq!(count, 1);
335+
336+
// Send the GitHub alert to the API endpoint
337+
let mut request = anon.post_request(URL);
338+
let vec = github_alert_with_token(&token);
339+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
340+
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(&vec));
341+
*request.body_mut() = vec.into();
342+
let response = anon.run::<()>(request).await;
343+
assert_snapshot!(response.status(), @"200 OK");
344+
assert_json_snapshot!(response.json(), {
345+
"[].token_raw" => api_token_redaction()
346+
});
347+
348+
// Verify the token was deleted from the database
349+
let count = trustpub_tokens::table
350+
.count()
351+
.get_result::<i64>(&mut conn)
352+
.await
353+
.unwrap();
354+
assert_eq!(count, 0);
355+
}
356+
357+
#[tokio::test(flavor = "multi_thread")]
358+
async fn github_secret_alert_for_unknown_trustpub_token() {
359+
let (app, anon) = TestApp::init().with_github(github_mock()).empty().await;
360+
let mut conn = app.db_conn().await;
361+
362+
// Generate a valid Trusted Publishing token but don't insert it into the database
363+
let (token, _) = generate_trustpub_token();
364+
365+
// Verify no tokens exist in the database
366+
let count = trustpub_tokens::table
367+
.count()
368+
.get_result::<i64>(&mut conn)
369+
.await
370+
.unwrap();
371+
assert_eq!(count, 0);
372+
373+
// Send the GitHub alert to the API endpoint
374+
let mut request = anon.post_request(URL);
375+
let vec = github_alert_with_token(&token);
376+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
377+
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(&vec));
378+
*request.body_mut() = vec.into();
379+
let response = anon.run::<()>(request).await;
380+
assert_snapshot!(response.status(), @"200 OK");
381+
assert_json_snapshot!(response.json(), {
382+
"[].token_raw" => api_token_redaction()
383+
});
384+
385+
// Verify still no tokens exist in the database
386+
let count = trustpub_tokens::table
387+
.count()
388+
.get_result::<i64>(&mut conn)
389+
.await
390+
.unwrap();
391+
assert_eq!(count, 0);
392+
}
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": "false_positive",
8+
"token_raw": "[token]",
9+
"token_type": "some_type"
10+
}
11+
]
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+
]

0 commit comments

Comments
 (0)