Skip to content

Commit fb56426

Browse files
authored
Merge pull request #5638 from Turbo87/gh-secret-alerts
GitHub Secret Scanning improvements
2 parents 2a620db + 27c3e79 commit fb56426

File tree

3 files changed

+83
-76
lines changed

3 files changed

+83
-76
lines changed

src/controllers/github/secret_scanning.rs

Lines changed: 71 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ fn key_from_spki(key: &GitHubPublicKey) -> Result<Vec<u8>, std::io::Error> {
6666
/// Check if cache of public keys is populated and not expired
6767
fn is_cache_valid(timestamp: Option<chrono::DateTime<chrono::Utc>>) -> bool {
6868
timestamp.is_some()
69-
&& chrono::Utc::now() - timestamp.unwrap()
70-
< chrono::Duration::seconds(PUBLIC_KEY_CACHE_LIFETIME_SECONDS)
69+
&& chrono::Utc::now()
70+
< timestamp.unwrap() + chrono::Duration::seconds(PUBLIC_KEY_CACHE_LIFETIME_SECONDS)
7171
}
7272

7373
// Fetches list of public keys from GitHub API
@@ -82,8 +82,7 @@ fn get_public_keys(req: &dyn RequestExt) -> Result<Vec<GitHubPublicKey>, Box<dyn
8282
let app = req.app();
8383
let keys = app
8484
.github
85-
.public_keys(&app.config.gh_client_id, &app.config.gh_client_secret)
86-
.unwrap();
85+
.public_keys(&app.config.gh_client_id, &app.config.gh_client_secret)?;
8786

8887
// Populate cache
8988
if let Ok(mut cache) = PUBLIC_KEY_CACHE.lock() {
@@ -110,32 +109,31 @@ fn verify_github_signature(req: &dyn RequestExt, json: &[u8]) -> Result<(), Box<
110109
let public_keys = get_public_keys(req)
111110
.map_err(|e| bad_request(&format!("failed to fetch GitHub public keys: {e:?}")))?;
112111

113-
for key in public_keys {
114-
if key.key_identifier == req_key_id {
115-
if !key.is_current {
116-
return Err(bad_request(&format!(
117-
"key id {req_key_id} is not a current key"
118-
)));
119-
}
120-
let key_bytes =
121-
key_from_spki(&key).map_err(|_| bad_request("cannot parse public key"))?;
122-
let gh_key =
123-
signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, &key_bytes);
124-
125-
return match gh_key.verify(json, &sig) {
126-
Ok(v) => {
127-
info!(
128-
"GitHub secret alert request validated with key id {}",
129-
key.key_identifier
130-
);
131-
Ok(v)
132-
}
133-
Err(e) => Err(bad_request(&format!("invalid signature: {e:?}"))),
134-
};
135-
}
112+
let key = public_keys
113+
.iter()
114+
.find(|key| key.key_identifier == req_key_id);
115+
116+
let Some(key) = key else {
117+
return Err(bad_request(&format!("unknown key id {req_key_id}")));
118+
};
119+
120+
if !key.is_current {
121+
let error = bad_request(&format!("key id {req_key_id} is not a current key"));
122+
return Err(error);
136123
}
137124

138-
return Err(bad_request(&format!("unknown key id {req_key_id}")));
125+
let key_bytes = key_from_spki(key).map_err(|_| bad_request("cannot parse public key"))?;
126+
let gh_key = signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, &key_bytes);
127+
128+
gh_key
129+
.verify(json, &sig)
130+
.map_err(|e| bad_request(&format!("invalid signature: {e:?}")))?;
131+
132+
debug!(
133+
key_id = %key.key_identifier,
134+
"GitHub secret alert request validated",
135+
);
136+
Ok(())
139137
}
140138

141139
#[derive(Deserialize, Serialize)]
@@ -150,45 +148,64 @@ struct GitHubSecretAlert {
150148
fn alert_revoke_token(
151149
req: &dyn RequestExt,
152150
alert: &GitHubSecretAlert,
153-
) -> Result<(), Box<dyn AppError>> {
151+
) -> Result<GitHubSecretAlertFeedbackLabel, Box<dyn AppError>> {
154152
let conn = req.db_write()?;
155153

156154
// not using ApiToken::find_by_api_token in order to preserve last_used_at
157155
// the token field has a uniqueness constraint so get_result() should be safe to use
158-
let token: ApiToken = diesel::update(api_tokens::table)
156+
let token = diesel::update(api_tokens::table)
159157
.filter(api_tokens::token.eq(alert.token.as_bytes()))
160158
.set(api_tokens::revoked.eq(true))
161-
.get_result::<ApiToken>(&*conn)?;
159+
.get_result::<ApiToken>(&*conn)
160+
.optional()?;
161+
162+
let Some(token) = token else {
163+
return Ok(GitHubSecretAlertFeedbackLabel::FalsePositive);
164+
};
162165

163166
// send email notification to the token owner
164167
let user = User::find(&conn, token.user_id)?;
165-
info!(
168+
warn!(
166169
"Revoked API token '{}' for user {} ({})",
167170
alert.token, user.gh_login, user.id
168171
);
169-
match user.email(&conn)? {
170-
None => {
171-
info!(
172-
"No email address for user {} ({}), cannot send email notification",
173-
user.gh_login, user.id
174-
);
175-
Ok(())
176-
}
177-
Some(email) => req.app().emails.send_token_exposed_notification(
172+
173+
if let Some(email) = user.email(&conn)? {
174+
let result = req.app().emails.send_token_exposed_notification(
178175
&email,
179176
&alert.url,
180177
"GitHub",
181178
&alert.source,
182179
&token.name,
183-
),
184-
}
180+
);
181+
if let Err(error) = result {
182+
warn!(
183+
gh_login = %user.gh_login, user_id = %user.id, ?error,
184+
"Failed to send email notification",
185+
);
186+
}
187+
} else {
188+
warn!(
189+
gh_login = %user.gh_login, user_id = %user.id,
190+
"Failed to send email notification: No address found",
191+
);
192+
};
193+
194+
Ok(GitHubSecretAlertFeedbackLabel::TruePositive)
185195
}
186196

187197
#[derive(Deserialize, Serialize)]
188198
pub struct GitHubSecretAlertFeedback {
189199
pub token_raw: String,
190200
pub token_type: String,
191-
pub label: String,
201+
pub label: GitHubSecretAlertFeedbackLabel,
202+
}
203+
204+
#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
205+
#[serde(rename_all = "snake_case")]
206+
pub enum GitHubSecretAlertFeedbackLabel {
207+
TruePositive,
208+
FalsePositive,
192209
}
193210

194211
/// Handles the `POST /api/github/secret-scanning/verify` route.
@@ -207,28 +224,20 @@ pub fn verify(req: &mut dyn RequestExt) -> EndpointResult {
207224
verify_github_signature(req, &json)
208225
.map_err(|e| bad_request(&format!("failed to verify request signature: {e:?}")))?;
209226

210-
let json = String::from_utf8(json)
211-
.map_err(|e| bad_request(&format!("failed to decode request body: {e:?}")))?;
212-
let alerts: Vec<GitHubSecretAlert> = json::from_str(&json)
227+
let alerts: Vec<GitHubSecretAlert> = json::from_slice(&json)
213228
.map_err(|e| bad_request(&format!("invalid secret alert request: {e:?}")))?;
214229

215-
let feedback: Vec<GitHubSecretAlertFeedback> = alerts
230+
let feedback = alerts
216231
.into_iter()
217-
.map(|alert| GitHubSecretAlertFeedback {
218-
token_raw: alert.token.clone(),
219-
token_type: alert.r#type.clone(),
220-
label: match alert_revoke_token(req, &alert) {
221-
Ok(()) => "true_positive".to_string(),
222-
Err(e) => {
223-
warn!(
224-
"Error revoking API token in GitHub secret alert: {} ({e:?})",
225-
alert.token
226-
);
227-
"false_positive".to_string()
228-
}
229-
},
232+
.map(|alert| {
233+
let label = alert_revoke_token(req, &alert)?;
234+
Ok(GitHubSecretAlertFeedback {
235+
token_raw: alert.token,
236+
token_type: alert.r#type,
237+
label,
238+
})
230239
})
231-
.collect();
240+
.collect::<Result<Vec<GitHubSecretAlertFeedback>, Box<dyn AppError>>>()?;
232241

233242
Ok(req.json(&feedback))
234243
}

src/tests/github_secret_scanning.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use crate::{RequestHelper, TestApp};
2-
use cargo_registry::controllers::github::secret_scanning::GitHubSecretAlertFeedback;
2+
use cargo_registry::controllers::github::secret_scanning::{
3+
GitHubSecretAlertFeedback, GitHubSecretAlertFeedbackLabel,
4+
};
35
use cargo_registry::{models::ApiToken, schema::api_tokens};
46
use conduit::StatusCode;
57
use diesel::prelude::*;
@@ -49,7 +51,10 @@ fn github_secret_alert_revokes_token() {
4951
assert_eq!(feedback.len(), 1);
5052
assert_eq!(feedback[0].token_raw, "some_token");
5153
assert_eq!(feedback[0].token_type, "some_type");
52-
assert_eq!(feedback[0].label, "true_positive");
54+
assert_eq!(
55+
feedback[0].label,
56+
GitHubSecretAlertFeedbackLabel::TruePositive
57+
);
5358

5459
// Ensure that the token was revoked
5560
app.db(|conn| {
@@ -95,7 +100,10 @@ fn github_secret_alert_for_unknown_token() {
95100
assert_eq!(feedback.len(), 1);
96101
assert_eq!(feedback[0].token_raw, "some_token");
97102
assert_eq!(feedback[0].token_type, "some_type");
98-
assert_eq!(feedback[0].label, "false_positive");
103+
assert_eq!(
104+
feedback[0].label,
105+
GitHubSecretAlertFeedbackLabel::FalsePositive
106+
);
99107

100108
// Ensure that the token was not revoked
101109
app.db(|conn| {

src/tests/util/github.rs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ impl GitHubClient for MockGitHubClient {
170170
}
171171

172172
fn public_keys(&self, _username: &str, _password: &str) -> AppResult<Vec<GitHubPublicKey>> {
173-
Ok(self.data.public_keys.iter().collect())
173+
Ok(self.data.public_keys.iter().map(Into::into).collect())
174174
}
175175
}
176176

@@ -215,13 +215,3 @@ impl From<&'static MockPublicKey> for GitHubPublicKey {
215215
}
216216
}
217217
}
218-
219-
impl FromIterator<&'static MockPublicKey> for Vec<GitHubPublicKey> {
220-
fn from_iter<I: IntoIterator<Item = &'static MockPublicKey>>(iter: I) -> Self {
221-
let mut c = Vec::new();
222-
for k in iter {
223-
c.push(k.into());
224-
}
225-
c
226-
}
227-
}

0 commit comments

Comments
 (0)