Skip to content

Commit 45ccc62

Browse files
authored
Merge pull request #11405 from Turbo87/trustpub-scanning
controllers/github/secret_scanning: Add support for Trusted Publishing tokens
2 parents b2d3876 + 53f6695 commit 45ccc62

4 files changed

+202
-21
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: 156 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,97 @@
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};
6+
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;
510
use crates_io_github::{GitHubPublicKey, MockGitHubClient};
11+
use crates_io_trustpub::access_token::AccessToken;
612
use diesel::prelude::*;
713
use diesel_async::RunQueryDsl;
814
use googletest::prelude::*;
915
use insta::{assert_json_snapshot, assert_snapshot};
16+
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
17+
use p256::pkcs8::DecodePrivateKey;
18+
use secrecy::ExposeSecret;
19+
use std::sync::LazyLock;
1020

1121
static URL: &str = "/api/github/secret-scanning/verify";
1222

13-
// Test request and signature from https://docs.github.com/en/developers/overview/secret-scanning-partner-program#create-a-secret-alert-service
23+
// Test request payload for GitHub secret scanning
1424
static GITHUB_ALERT: &[u8] =
1525
br#"[{"token":"some_token","type":"some_type","url":"some_url","source":"some_source"}]"#;
1626

17-
static GITHUB_PUBLIC_KEY_IDENTIFIER: &str =
18-
"f9525bf080f75b3506ca1ead061add62b8633a346606dc5fe544e29231c6ee0d";
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+
35+
/// Private key for signing payloads (ECDSA P-256)
36+
///
37+
/// Generated specifically for testing - do not use in production.
38+
///
39+
/// This corresponds to the public key below and is used to generate valid signatures
40+
static PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
41+
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgV64BdEFXg9aT/m4p
42+
wOQ/o9WUHxZ6qfBaP3D7Km1TOWuhRANCAARYKkbkTbIr//8klg1CMYGQIwtlfNd4
43+
JQYV5+q0s3+JnBSLb1/sx/lEDzmMVZQIZQrACUHFW4UVdmox2NvmNWyy
44+
-----END PRIVATE KEY-----"#;
45+
46+
/// Public key (corresponds to the private key above)
47+
static PUBLIC_KEY: &str = r#"-----BEGIN PUBLIC KEY-----
48+
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWCpG5E2yK///JJYNQjGBkCMLZXzX
49+
eCUGFefqtLN/iZwUi29f7Mf5RA85jFWUCGUKwAlBxVuFFXZqMdjb5jVssg==
50+
-----END PUBLIC KEY-----"#;
51+
52+
/// Public key identifier (SHA256 hash of the DER-encoded public key)
53+
static KEY_IDENTIFIER: &str = "2aafbbe2d329af78d875cd2dd0291048799176466844315b6a846d6e12aa26ca";
54+
55+
/// Signing key derived from the private key
56+
static SIGNING_KEY: LazyLock<SigningKey> =
57+
LazyLock::new(|| SigningKey::from_pkcs8_pem(PRIVATE_KEY).unwrap());
58+
59+
/// Generate a signature for the payload using our private key
60+
fn sign_payload(payload: &[u8]) -> String {
61+
let signature: Signature = SIGNING_KEY.sign(payload);
62+
general_purpose::STANDARD.encode(signature.to_der())
63+
}
64+
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+
}
1972

20-
/// Test key from https://docs.github.com/en/developers/overview/secret-scanning-partner-program#create-a-secret-alert-service
21-
static GITHUB_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsz9ugWDj5jK5ELBK42ynytbo38gP\nHzZFI03Exwz8Lh/tCfL3YxwMdLjB+bMznsanlhK0RwcGP3IDb34kQDIo3Q==\n-----END PUBLIC KEY-----";
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();
2276

23-
static GITHUB_PUBLIC_KEY_SIGNATURE: &str = "MEUCIFLZzeK++IhS+y276SRk2Pe5LfDrfvTXu6iwKKcFGCrvAiEAhHN2kDOhy2I6eGkOFmxNkOJ+L2y8oQ9A2T9GGJo6WJY=";
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+
}
2487

2588
fn github_mock() -> MockGitHubClient {
2689
let mut mock = MockGitHubClient::new();
2790

2891
mock.expect_public_keys().returning(|_, _| {
2992
let key = GitHubPublicKey {
30-
key_identifier: GITHUB_PUBLIC_KEY_IDENTIFIER.to_string(),
31-
key: GITHUB_PUBLIC_KEY.to_string(),
93+
key_identifier: KEY_IDENTIFIER.to_string(),
94+
key: PUBLIC_KEY.to_string(),
3295
is_current: true,
3396
};
3497

@@ -70,8 +133,8 @@ async fn github_secret_alert_revokes_token() {
70133

71134
let mut request = anon.post_request(URL);
72135
*request.body_mut() = GITHUB_ALERT.into();
73-
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER);
74-
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE);
136+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
137+
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT));
75138
let response = anon.run::<()>(request).await;
76139
assert_snapshot!(response.status(), @"200 OK");
77140
assert_json_snapshot!(response.json());
@@ -134,8 +197,8 @@ async fn github_secret_alert_for_revoked_token() {
134197

135198
let mut request = anon.post_request(URL);
136199
*request.body_mut() = GITHUB_ALERT.into();
137-
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER);
138-
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE);
200+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
201+
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT));
139202
let response = anon.run::<()>(request).await;
140203
assert_snapshot!(response.status(), @"200 OK");
141204
assert_json_snapshot!(response.json());
@@ -187,8 +250,8 @@ async fn github_secret_alert_for_unknown_token() {
187250

188251
let mut request = anon.post_request(URL);
189252
*request.body_mut() = GITHUB_ALERT.into();
190-
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER);
191-
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE);
253+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
254+
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT));
192255
let response = anon.run::<()>(request).await;
193256
assert_snapshot!(response.status(), @"200 OK");
194257
assert_json_snapshot!(response.json());
@@ -225,31 +288,105 @@ async fn github_secret_alert_invalid_signature_fails() {
225288

226289
// Headers but no request body
227290
let mut request = anon.post_request(URL);
228-
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER);
229-
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE);
291+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
292+
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT));
230293
let response = anon.run::<()>(request).await;
231294
assert_snapshot!(response.status(), @"400 Bad Request");
232295

233296
// Request body but only key identifier header
234297
let mut request = anon.post_request(URL);
235298
*request.body_mut() = GITHUB_ALERT.into();
236-
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER);
299+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
237300
let response = anon.run::<()>(request).await;
238301
assert_snapshot!(response.status(), @"400 Bad Request");
239302

240303
// Invalid signature
241304
let mut request = anon.post_request(URL);
242305
*request.body_mut() = GITHUB_ALERT.into();
243-
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER);
306+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
244307
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", "bad signature");
245308
let response = anon.run::<()>(request).await;
246309
assert_snapshot!(response.status(), @"400 Bad Request");
247310

248311
// Invalid signature that is valid base64
249312
let mut request = anon.post_request(URL);
250313
*request.body_mut() = GITHUB_ALERT.into();
251-
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER);
314+
request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER);
252315
request.header("GITHUB-PUBLIC-KEY-SIGNATURE", "YmFkIHNpZ25hdHVyZQ==");
253316
let response = anon.run::<()>(request).await;
254317
assert_snapshot!(response.status(), @"400 Bad Request");
255318
}
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)