Skip to content

Commit 43f7796

Browse files
author
Dan Gardner
committed
Improvements based on PR feedback
1 parent e74268c commit 43f7796

File tree

11 files changed

+451
-658
lines changed

11 files changed

+451
-658
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ toml = "=0.5.9"
8585
tracing = "=0.1.37"
8686
tracing-subscriber = { version = "=0.3.16", features = ["env-filter"] }
8787
url = "=2.3.1"
88+
once_cell = "=1.16.0"
8889

8990
[dev-dependencies]
9091
cargo-registry-index = { path = "cargo-registry-index", features = ["testing"] }

src/controllers.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ pub mod util;
7474

7575
pub mod category;
7676
pub mod crate_owner_invitation;
77+
pub mod github;
7778
pub mod keyword;
7879
pub mod krate;
7980
pub mod metrics;

src/controllers/github.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod secret_scanning;
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
use once_cell::sync::Lazy;
2+
use std::sync::Mutex;
3+
4+
use crate::controllers::frontend_prelude::*;
5+
6+
use crate::models::{ApiToken, User};
7+
use crate::schema::api_tokens;
8+
use crate::util::read_fill;
9+
10+
use serde_json as json;
11+
12+
use base64;
13+
use ring::signature;
14+
15+
static PEM_HEADER: &str = "-----BEGIN PUBLIC KEY-----\n";
16+
static PEM_FOOTER: &str = "\n-----END PUBLIC KEY-----";
17+
18+
// Minimum number of seconds to wait before refreshing cache of GitHub's public keys
19+
static PUBLIC_KEY_CACHE_LIFETIME_SECONDS: i64 = 60 * 60 * 24; // 24 hours
20+
21+
// Cache of public keys that have been fetched from GitHub API
22+
static PUBLIC_KEY_CACHE: Lazy<Mutex<GitHubPublicKeyCache>> = Lazy::new(|| {
23+
let keys: Vec<GitHubPublicKey> = Vec::new();
24+
let cache = GitHubPublicKeyCache {
25+
keys,
26+
timestamp: None,
27+
};
28+
Mutex::new(cache)
29+
});
30+
31+
#[derive(Debug, Deserialize, Clone, Eq, Hash, PartialEq)]
32+
pub struct GitHubPublicKey {
33+
pub key_identifier: String,
34+
pub key: String,
35+
pub is_current: bool,
36+
}
37+
38+
#[derive(Debug, Deserialize)]
39+
pub struct GitHubPublicKeyList {
40+
pub public_keys: Vec<GitHubPublicKey>,
41+
}
42+
43+
#[derive(Debug, Clone)]
44+
struct GitHubPublicKeyCache {
45+
keys: Vec<GitHubPublicKey>,
46+
timestamp: Option<chrono::DateTime<chrono::Utc>>,
47+
}
48+
49+
/// Converts a PEM format ECDSA P-256 SHA-256 public key in SubjectPublicKeyInfo format into
50+
/// the Octet-String-to-Elliptic-Curve-Point format expected by ring::signature::verify
51+
fn key_from_spki(key: &GitHubPublicKey) -> Result<Vec<u8>, std::io::Error> {
52+
let start_idx = key
53+
.key
54+
.find(PEM_HEADER)
55+
.ok_or(std::io::ErrorKind::InvalidData)?;
56+
let gh_key = &key.key[(start_idx + PEM_HEADER.len())..];
57+
let end_idx = gh_key
58+
.find(PEM_FOOTER)
59+
.ok_or(std::io::ErrorKind::InvalidData)?;
60+
let gh_key = gh_key[..end_idx].replace('\n', "");
61+
let gh_key = base64::decode(gh_key)
62+
.map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidData))?;
63+
if gh_key.len() != 91 {
64+
return Err(std::io::Error::from(std::io::ErrorKind::InvalidData));
65+
}
66+
// extract the key bytes from the fixed position in the ASN.1 structure
67+
Ok(gh_key[26..91].to_vec())
68+
}
69+
70+
/// Check if cache of public keys is populated and not expired
71+
fn is_cache_valid(timestamp: Option<chrono::DateTime<chrono::Utc>>) -> bool {
72+
timestamp.is_some()
73+
&& chrono::Utc::now() - timestamp.unwrap()
74+
< chrono::Duration::seconds(PUBLIC_KEY_CACHE_LIFETIME_SECONDS)
75+
}
76+
77+
// Fetches list of public keys from GitHub API
78+
fn get_public_keys(req: &dyn RequestExt) -> Result<Vec<GitHubPublicKey>, Box<dyn AppError>> {
79+
// Return list from cache if populated and still valid
80+
if let Ok(cache) = PUBLIC_KEY_CACHE.lock() {
81+
if is_cache_valid(cache.timestamp) {
82+
return Ok(cache.keys.clone());
83+
}
84+
}
85+
// Fetch from GitHub API
86+
let app = req.app();
87+
let keys = app
88+
.github
89+
.public_keys(&app.config.gh_client_id, &app.config.gh_client_secret)
90+
.unwrap();
91+
92+
// Populate cache
93+
if let Ok(mut cache) = PUBLIC_KEY_CACHE.lock() {
94+
cache.keys = keys.clone();
95+
cache.timestamp = Some(chrono::Utc::now());
96+
}
97+
Ok(keys)
98+
}
99+
100+
/// Verifies that the GitHub signature in request headers is valid
101+
fn verify_github_signature(req: &dyn RequestExt, json: &[u8]) -> Result<(), Box<dyn AppError>> {
102+
// Read and decode request headers
103+
let headers = req.headers();
104+
let req_key_id = headers
105+
.get("GITHUB-PUBLIC-KEY-IDENTIFIER")
106+
.ok_or_else(|| bad_request("missing HTTP header: GITHUB-PUBLIC-KEY-IDENTIFIER"))?
107+
.to_str()
108+
.map_err(|e| bad_request(&format!("failed to decode HTTP header: {e:?}")))?;
109+
let sig = headers
110+
.get("GITHUB-PUBLIC-KEY-SIGNATURE")
111+
.ok_or_else(|| bad_request("missing HTTP header: GITHUB-PUBLIC-KEY-SIGNATURE"))?;
112+
let sig = base64::decode(sig)
113+
.map_err(|e| bad_request(&format!("failed to decode signature as base64: {e:?}")))?;
114+
let public_keys = get_public_keys(req)
115+
.map_err(|e| bad_request(&format!("failed to fetch GitHub public keys: {e:?}")))?;
116+
117+
for key in public_keys {
118+
if key.key_identifier == req_key_id {
119+
if !key.is_current {
120+
return Err(bad_request(&format!(
121+
"key id {req_key_id} is not a current key"
122+
)));
123+
}
124+
let key_bytes =
125+
key_from_spki(&key).map_err(|_| bad_request("cannot parse public key"))?;
126+
let gh_key =
127+
signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, &key_bytes);
128+
129+
return match gh_key.verify(json, &sig) {
130+
Ok(v) => {
131+
info!(
132+
"GitHub secret alert request validated with key id {}",
133+
key.key_identifier
134+
);
135+
Ok(v)
136+
}
137+
Err(e) => Err(bad_request(&format!("invalid signature: {e:?}"))),
138+
};
139+
}
140+
}
141+
142+
return Err(bad_request(&format!("unknown key id {req_key_id}")));
143+
}
144+
145+
#[derive(Deserialize, Serialize)]
146+
struct GitHubSecretAlert {
147+
token: String,
148+
r#type: String,
149+
url: String,
150+
source: String,
151+
}
152+
153+
/// Revokes an API token and notifies the token owner
154+
fn alert_revoke_token(
155+
req: &dyn RequestExt,
156+
alert: &GitHubSecretAlert,
157+
) -> Result<(), Box<dyn AppError>> {
158+
let conn = req.db_write()?;
159+
// not using ApiToken::find_by_api_token in order to preserve last_used_at
160+
let token = api_tokens::table
161+
.filter(api_tokens::token.eq(alert.token.as_bytes()))
162+
.first::<ApiToken>(&*conn)?;
163+
164+
diesel::update(&token)
165+
.set(api_tokens::revoked.eq(true))
166+
.execute(&*conn)?;
167+
168+
// send email notification to the token owner
169+
let user = User::find(&conn, token.user_id)?;
170+
info!(
171+
"Revoked API token '{}' for user {} ({})",
172+
alert.token, user.gh_login, user.id
173+
);
174+
match user.email(&conn)? {
175+
None => {
176+
info!(
177+
"No email address for user {} ({}), cannot send email notification",
178+
user.gh_login, user.id
179+
);
180+
Ok(())
181+
}
182+
Some(email) => req.app().emails.send_token_exposed_notification(
183+
&email,
184+
&alert.url,
185+
"GitHub",
186+
&alert.source,
187+
&token.name,
188+
),
189+
}
190+
}
191+
192+
#[derive(Deserialize, Serialize)]
193+
pub struct GitHubSecretAlertFeedback {
194+
pub token_raw: String,
195+
pub token_type: String,
196+
pub label: String,
197+
}
198+
199+
/// Handles the `POST /api/github/secret-scanning/verify` route.
200+
pub fn verify(req: &mut dyn RequestExt) -> EndpointResult {
201+
let max_size = 8192;
202+
let length = req
203+
.content_length()
204+
.ok_or_else(|| bad_request("missing header: Content-Length"))?;
205+
206+
if length > max_size {
207+
return Err(bad_request(&format!("max content length is: {max_size}")));
208+
}
209+
210+
let mut json = vec![0; length as usize];
211+
read_fill(req.body(), &mut json)?;
212+
verify_github_signature(req, &json)
213+
.map_err(|e| bad_request(&format!("failed to verify request signature: {e:?}")))?;
214+
215+
let json = String::from_utf8(json)
216+
.map_err(|e| bad_request(&format!("failed to decode request body: {e:?}")))?;
217+
let alerts: Vec<GitHubSecretAlert> = json::from_str(&json)
218+
.map_err(|e| bad_request(&format!("invalid secret alert request: {e:?}")))?;
219+
220+
let feedback: Vec<GitHubSecretAlertFeedback> = alerts
221+
.into_iter()
222+
.map(|alert| GitHubSecretAlertFeedback {
223+
token_raw: alert.token.clone(),
224+
token_type: alert.r#type.clone(),
225+
label: match alert_revoke_token(req, &alert) {
226+
Ok(()) => "true_positive".to_string(),
227+
Err(e) => {
228+
warn!(
229+
"Error revoking API token in GitHub secret alert: {} ({e:?})",
230+
alert.token
231+
);
232+
"false_positive".to_string()
233+
}
234+
},
235+
})
236+
.collect();
237+
238+
Ok(req.json(&feedback))
239+
}
240+
241+
#[cfg(test)]
242+
mod tests {
243+
use super::*;
244+
245+
#[test]
246+
fn test_is_cache_valid() {
247+
assert!(!is_cache_valid(None));
248+
assert!(!is_cache_valid(Some(
249+
chrono::Utc::now() - chrono::Duration::seconds(PUBLIC_KEY_CACHE_LIFETIME_SECONDS)
250+
)));
251+
assert!(is_cache_valid(Some(
252+
chrono::Utc::now() - chrono::Duration::seconds(PUBLIC_KEY_CACHE_LIFETIME_SECONDS - 1)
253+
)));
254+
assert!(is_cache_valid(Some(chrono::Utc::now())));
255+
// shouldn't happen, but just in case of time travel
256+
assert!(is_cache_valid(Some(
257+
chrono::Utc::now() + chrono::Duration::seconds(PUBLIC_KEY_CACHE_LIFETIME_SECONDS)
258+
)));
259+
}
260+
}

0 commit comments

Comments
 (0)