Skip to content

controllers/github/secret_scanning: Add support for Trusted Publishing tokens #11405

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 23, 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
26 changes: 24 additions & 2 deletions src/controllers/github/secret_scanning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<GitHubSecretAlertFeedbackLabel> {
// First, try to handle as a Trusted Publishing 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)
.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`
Expand All @@ -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);
};

Expand Down
175 changes: 156 additions & 19 deletions src/tests/github_secret_scanning.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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<SigningKey> =
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<u8>) {
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<String> {
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,
};

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -225,31 +288,105 @@ 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");

// 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::<i64>(&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::<i64>(&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::<i64>(&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::<i64>(&mut conn)
.await
.unwrap();
assert_eq!(count, 0);
}
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": "false_positive",
"token_raw": "[token]",
"token_type": "some_type"
}
]
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"
}
]