diff --git a/src/controllers/github/secret_scanning.rs b/src/controllers/github/secret_scanning.rs index 793b76dd81c..320aa72af1c 100644 --- a/src/controllers/github/secret_scanning.rs +++ b/src/controllers/github/secret_scanning.rs @@ -8,7 +8,9 @@ use anyhow::{Context, anyhow}; use axum::Json; use axum::body::Bytes; use base64::{Engine, engine::general_purpose}; +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 http::HeaderMap; @@ -126,12 +128,32 @@ struct GitHubSecretAlert { source: String, } -/// Revokes an API token and notifies the token owner +/// Revokes an API token or Trusted Publishing token and notifies the token owner async fn alert_revoke_token( state: &AppState, alert: &GitHubSecretAlert, conn: &mut AsyncPgConnection, ) -> QueryResult { + // First, try to handle as a Trusted Publishing 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) + .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) + .execute(conn) + .await?; + + if deleted_count > 0 { + warn!("Active Trusted Publishing token received and revoked (true positive)"); + return Ok(GitHubSecretAlertFeedbackLabel::TruePositive); + } else { + debug!("Unknown Trusted Publishing token received (false positive)"); + return Ok(GitHubSecretAlertFeedbackLabel::FalsePositive); + } + } + + // If not a Trusted Publishing token or not found, try as a regular API token let hashed_token = HashedToken::hash(&alert.token); // Not using `ApiToken::find_by_api_token()` in order to preserve `last_used_at` @@ -143,7 +165,7 @@ async fn alert_revoke_token( .optional()?; let Some(token) = token else { - debug!("Unknown API token received (false positive)"); + debug!("Unknown token received (false positive)"); return Ok(GitHubSecretAlertFeedbackLabel::FalsePositive); }; diff --git a/src/tests/github_secret_scanning.rs b/src/tests/github_secret_scanning.rs index 4a1c05365b6..0488f5d3e10 100644 --- a/src/tests/github_secret_scanning.rs +++ b/src/tests/github_secret_scanning.rs @@ -1,34 +1,97 @@ 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::trustpub::NewToken; +use crates_io_database::schema::trustpub_tokens; use crates_io_github::{GitHubPublicKey, MockGitHubClient}; +use crates_io_trustpub::access_token::AccessToken; use diesel::prelude::*; use diesel_async::RunQueryDsl; use googletest::prelude::*; use insta::{assert_json_snapshot, assert_snapshot}; +use p256::ecdsa::{Signature, SigningKey, signature::Signer}; +use p256::pkcs8::DecodePrivateKey; +use secrecy::ExposeSecret; +use std::sync::LazyLock; static URL: &str = "/api/github/secret-scanning/verify"; -// Test request and signature from https://docs.github.com/en/developers/overview/secret-scanning-partner-program#create-a-secret-alert-service +// Test request payload for GitHub secret scanning static GITHUB_ALERT: &[u8] = br#"[{"token":"some_token","type":"some_type","url":"some_url","source":"some_source"}]"#; -static GITHUB_PUBLIC_KEY_IDENTIFIER: &str = - "f9525bf080f75b3506ca1ead061add62b8633a346606dc5fe544e29231c6ee0d"; +/// Generate a GitHub alert with a given token +fn github_alert_with_token(token: &str) -> Vec { + format!( + r#"[{{"token":"{token}","type":"some_type","url":"some_url","source":"some_source"}}]"#, + ) + .into_bytes() +} + +/// Private key for signing payloads (ECDSA P-256) +/// +/// Generated specifically for testing - do not use in production. +/// +/// This corresponds to the public key below and is used to generate valid signatures +static PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgV64BdEFXg9aT/m4p +wOQ/o9WUHxZ6qfBaP3D7Km1TOWuhRANCAARYKkbkTbIr//8klg1CMYGQIwtlfNd4 +JQYV5+q0s3+JnBSLb1/sx/lEDzmMVZQIZQrACUHFW4UVdmox2NvmNWyy +-----END PRIVATE KEY-----"#; + +/// Public key (corresponds to the private key above) +static PUBLIC_KEY: &str = r#"-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWCpG5E2yK///JJYNQjGBkCMLZXzX +eCUGFefqtLN/iZwUi29f7Mf5RA85jFWUCGUKwAlBxVuFFXZqMdjb5jVssg== +-----END PUBLIC KEY-----"#; + +/// Public key identifier (SHA256 hash of the DER-encoded public key) +static KEY_IDENTIFIER: &str = "2aafbbe2d329af78d875cd2dd0291048799176466844315b6a846d6e12aa26ca"; + +/// Signing key derived from the private key +static SIGNING_KEY: LazyLock = + LazyLock::new(|| SigningKey::from_pkcs8_pem(PRIVATE_KEY).unwrap()); + +/// Generate a signature for the payload using our private key +fn sign_payload(payload: &[u8]) -> String { + let signature: Signature = SIGNING_KEY.sign(payload); + general_purpose::STANDARD.encode(signature.to_der()) +} + +/// Generate a new Trusted Publishing token and its SHA256 hash +fn generate_trustpub_token() -> (String, Vec) { + let token = AccessToken::generate(); + let finalized_token = token.finalize().expose_secret().to_string(); + let hashed_token = token.sha256().to_vec(); + (finalized_token, hashed_token) +} -/// Test key from https://docs.github.com/en/developers/overview/secret-scanning-partner-program#create-a-secret-alert-service -static GITHUB_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsz9ugWDj5jK5ELBK42ynytbo38gP\nHzZFI03Exwz8Lh/tCfL3YxwMdLjB+bMznsanlhK0RwcGP3IDb34kQDIo3Q==\n-----END PUBLIC KEY-----"; +/// Create a new Trusted Publishing token in the database +async fn insert_trustpub_token(conn: &mut diesel_async::AsyncPgConnection) -> QueryResult { + let (token, hashed_token) = generate_trustpub_token(); -static GITHUB_PUBLIC_KEY_SIGNATURE: &str = "MEUCIFLZzeK++IhS+y276SRk2Pe5LfDrfvTXu6iwKKcFGCrvAiEAhHN2kDOhy2I6eGkOFmxNkOJ+L2y8oQ9A2T9GGJo6WJY="; + let new_token = NewToken { + expires_at: Utc::now() + TimeDelta::minutes(30), + hashed_token: &hashed_token, + crate_ids: &[1], // Arbitrary crate ID for testing + }; + + new_token.insert(conn).await?; + + Ok(token) +} fn github_mock() -> MockGitHubClient { let mut mock = MockGitHubClient::new(); mock.expect_public_keys().returning(|_, _| { let key = GitHubPublicKey { - key_identifier: GITHUB_PUBLIC_KEY_IDENTIFIER.to_string(), - key: GITHUB_PUBLIC_KEY.to_string(), + key_identifier: KEY_IDENTIFIER.to_string(), + key: PUBLIC_KEY.to_string(), is_current: true, }; @@ -70,8 +133,8 @@ async fn github_secret_alert_revokes_token() { let mut request = anon.post_request(URL); *request.body_mut() = GITHUB_ALERT.into(); - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); - request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE); + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT)); let response = anon.run::<()>(request).await; assert_snapshot!(response.status(), @"200 OK"); assert_json_snapshot!(response.json()); @@ -134,8 +197,8 @@ async fn github_secret_alert_for_revoked_token() { let mut request = anon.post_request(URL); *request.body_mut() = GITHUB_ALERT.into(); - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); - request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE); + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT)); let response = anon.run::<()>(request).await; assert_snapshot!(response.status(), @"200 OK"); assert_json_snapshot!(response.json()); @@ -187,8 +250,8 @@ async fn github_secret_alert_for_unknown_token() { let mut request = anon.post_request(URL); *request.body_mut() = GITHUB_ALERT.into(); - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); - request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE); + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT)); let response = anon.run::<()>(request).await; assert_snapshot!(response.status(), @"200 OK"); assert_json_snapshot!(response.json()); @@ -225,22 +288,22 @@ async fn github_secret_alert_invalid_signature_fails() { // Headers but no request body let mut request = anon.post_request(URL); - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); - request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE); + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT)); let response = anon.run::<()>(request).await; assert_snapshot!(response.status(), @"400 Bad Request"); // Request body but only key identifier header let mut request = anon.post_request(URL); *request.body_mut() = GITHUB_ALERT.into(); - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); let response = anon.run::<()>(request).await; assert_snapshot!(response.status(), @"400 Bad Request"); // Invalid signature let mut request = anon.post_request(URL); *request.body_mut() = GITHUB_ALERT.into(); - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); request.header("GITHUB-PUBLIC-KEY-SIGNATURE", "bad signature"); let response = anon.run::<()>(request).await; assert_snapshot!(response.status(), @"400 Bad Request"); @@ -248,8 +311,82 @@ async fn github_secret_alert_invalid_signature_fails() { // Invalid signature that is valid base64 let mut request = anon.post_request(URL); *request.body_mut() = GITHUB_ALERT.into(); - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); request.header("GITHUB-PUBLIC-KEY-SIGNATURE", "YmFkIHNpZ25hdHVyZQ=="); let response = anon.run::<()>(request).await; assert_snapshot!(response.status(), @"400 Bad Request"); } + +#[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 mut conn = app.db_conn().await; + + // Generate a valid Trusted Publishing token + let token = insert_trustpub_token(&mut conn).await.unwrap(); + + // Verify the token exists in the database + let count = trustpub_tokens::table + .count() + .get_result::(&mut conn) + .await + .unwrap(); + assert_eq!(count, 1); + + // 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() + }); + + // Verify the token was deleted from the database + let count = trustpub_tokens::table + .count() + .get_result::(&mut conn) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn github_secret_alert_for_unknown_trustpub_token() { + let (app, anon) = TestApp::init().with_github(github_mock()).empty().await; + let mut conn = app.db_conn().await; + + // Generate a valid Trusted Publishing token but don't insert it into the database + let (token, _) = generate_trustpub_token(); + + // Verify no tokens exist in the database + let count = trustpub_tokens::table + .count() + .get_result::(&mut conn) + .await + .unwrap(); + assert_eq!(count, 0); + + // 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() + }); + + // Verify still no tokens exist in the database + let count = trustpub_tokens::table + .count() + .get_result::(&mut conn) + .await + .unwrap(); + assert_eq!(count, 0); +} diff --git a/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_for_unknown_trustpub_token-2.snap b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_for_unknown_trustpub_token-2.snap new file mode 100644 index 00000000000..64b244fd673 --- /dev/null +++ b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_for_unknown_trustpub_token-2.snap @@ -0,0 +1,11 @@ +--- +source: src/tests/github_secret_scanning.rs +expression: response.json() +--- +[ + { + "label": "false_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-2.snap b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token-2.snap new file mode 100644 index 00000000000..046417706e5 --- /dev/null +++ b/src/tests/snapshots/crates_io__tests__github_secret_scanning__github_secret_alert_revokes_trustpub_token-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" + } +]