Skip to content

Commit 9959249

Browse files
feat(security): add global tracing scrubber to prevent token exposure in logs
Implements automatic sanitization of GitHub tokens and credentials across all log output using tracing-subscriber. Prevents accidental exposure of: - GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) - Credentials in URLs (x-access-token, basic auth) - Bearer tokens Resolves critical security vulnerability where installation tokens embedded in git URLs would leak through error messages. - add tracing and tracing-subscriber to Cargo.toml
1 parent e6df050 commit 9959249

8 files changed

Lines changed: 455 additions & 65 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "github_app"
33
version = "0.1.0"
4-
edition = "2024"
4+
edition = "2021"
55

66
[dependencies]
77
serde = { version = "1.0", features = ["derive"] }
@@ -16,6 +16,9 @@ hmac = "0.12"
1616
sha2 = "0.10"
1717
glob = "0.3"
1818
thiserror = "2.0"
19+
tracing = "0.1"
20+
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
21+
regex = "1.0"
1922

2023
[dev-dependencies]
2124
tempfile = "3.10"

src/app_auth.rs

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use crate::{GitHubAppConfig, GitHubError};
22
use chrono::{DateTime, Duration, Utc};
3-
use jsonwebtoken::{encode, EncodingKey, Header, Algorithm};
3+
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
44
use serde::{Deserialize, Serialize};
5-
use std::sync::{Arc, Mutex};
5+
use std::sync::Arc;
6+
use tokio::sync::Mutex;
7+
use tracing::{debug, error, info, instrument, warn};
68

79
#[derive(Debug, Serialize)]
810
struct Claims {
@@ -37,7 +39,10 @@ impl GitHubTokenProvider {
3739
}
3840
}
3941

42+
#[instrument(skip(self))]
4043
fn create_jwt(&self) -> Result<String, GitHubError> {
44+
debug!("Creating JWT for GitHub App authentication");
45+
4146
let now = Utc::now();
4247
let iat = now.timestamp();
4348
let exp = (now + Duration::minutes(10)).timestamp();
@@ -50,18 +55,26 @@ impl GitHubTokenProvider {
5055

5156
let key = EncodingKey::from_rsa_pem(self.config.private_key_pem.as_bytes())?;
5257
let token = encode(&Header::new(Algorithm::RS256), &claims, &key)?;
58+
59+
debug!("JWT created successfully");
5360
Ok(token)
5461
}
5562

63+
#[instrument(skip(self), fields(installation_id = %self.config.installation_id))]
5664
async fn fetch_installation_token(&self) -> Result<CachedToken, GitHubError> {
65+
info!("Fetching new installation access token");
66+
5767
let jwt = self.create_jwt()?;
58-
68+
5969
let url = format!(
6070
"https://api.github.com/app/installations/{}/access_tokens",
6171
self.config.installation_id
6272
);
6373

64-
let response = self.client
74+
debug!(url = %url, "Requesting installation token");
75+
76+
let response = self
77+
.client
6578
.post(&url)
6679
.header("Authorization", format!("Bearer {}", jwt))
6780
.header("Accept", "application/vnd.github+json")
@@ -73,9 +86,17 @@ impl GitHubTokenProvider {
7386
if !response.status().is_success() {
7487
let status = response.status();
7588
let body = response.text().await?;
89+
90+
// Tracing will automatically sanitize any tokens in the body
91+
error!(
92+
status = %status,
93+
response_body = %body,
94+
"Failed to get installation token"
95+
);
96+
7697
return Err(GitHubError::Other(format!(
77-
"Failed to get installation token: {} - {}",
78-
status, body
98+
"Failed to get installation token: {}",
99+
status
79100
)));
80101
}
81102

@@ -84,31 +105,44 @@ impl GitHubTokenProvider {
84105
.map_err(|e| GitHubError::Other(format!("Failed to parse expiry time: {}", e)))?
85106
.with_timezone(&Utc);
86107

108+
info!(expires_at = %expires_at, "Installation token fetched successfully");
109+
87110
Ok(CachedToken {
88111
token: token_response.token,
89112
expires_at,
90113
})
91114
}
92115

116+
#[instrument(skip(self))]
93117
pub async fn get_token(&self) -> Result<String, GitHubError> {
94-
let mut cached = self.cached_token.lock().unwrap();
95-
118+
let mut cached = self.cached_token.lock().await;
119+
96120
let now = Utc::now();
97121
let should_refresh = match &*cached {
98-
None => true,
122+
None => {
123+
debug!("No cached token available");
124+
true
125+
}
99126
Some(token) => {
100127
let buffer = Duration::minutes(5);
101-
token.expires_at - buffer < now
128+
let needs_refresh = token.expires_at - buffer < now;
129+
if needs_refresh {
130+
debug!("Cached token expired or expiring soon");
131+
} else {
132+
debug!("Using cached token");
133+
}
134+
needs_refresh
102135
}
103136
};
104137

105138
if should_refresh {
106139
let new_token = self.fetch_installation_token().await?;
107140
let token_str = new_token.token.clone();
108141
*cached = Some(new_token);
142+
info!("Token refreshed and cached");
109143
Ok(token_str)
110144
} else {
111-
Ok(cached.as_ref().unwrap().token.clone())
145+
Ok(cached.as_ref().expect("Token must exist").token.clone())
112146
}
113147
}
114148
}

src/config.rs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub struct GitHubAppConfig {
1313
}
1414

1515
impl GitHubAppConfig {
16+
#[allow(clippy::too_many_arguments)]
1617
pub fn new(
1718
app_id: u64,
1819
installation_id: u64,
@@ -37,25 +38,39 @@ impl GitHubAppConfig {
3738

3839
pub fn validate(&self) -> Result<(), crate::error::GitHubError> {
3940
if self.app_id == 0 {
40-
return Err(crate::error::GitHubError::Config("app_id cannot be 0".to_string()));
41+
return Err(crate::error::GitHubError::Config(
42+
"app_id cannot be 0".to_string(),
43+
));
4144
}
4245
if self.installation_id == 0 {
43-
return Err(crate::error::GitHubError::Config("installation_id cannot be 0".to_string()));
46+
return Err(crate::error::GitHubError::Config(
47+
"installation_id cannot be 0".to_string(),
48+
));
4449
}
4550
if self.private_key_pem.is_empty() {
46-
return Err(crate::error::GitHubError::Config("private_key_pem cannot be empty".to_string()));
51+
return Err(crate::error::GitHubError::Config(
52+
"private_key_pem cannot be empty".to_string(),
53+
));
4754
}
4855
if self.webhook_secret.is_empty() {
49-
return Err(crate::error::GitHubError::Config("webhook_secret cannot be empty".to_string()));
56+
return Err(crate::error::GitHubError::Config(
57+
"webhook_secret cannot be empty".to_string(),
58+
));
5059
}
5160
if self.repo.is_empty() {
52-
return Err(crate::error::GitHubError::Config("repo cannot be empty".to_string()));
61+
return Err(crate::error::GitHubError::Config(
62+
"repo cannot be empty".to_string(),
63+
));
5364
}
5465
if self.branch.is_empty() {
55-
return Err(crate::error::GitHubError::Config("branch cannot be empty".to_string()));
66+
return Err(crate::error::GitHubError::Config(
67+
"branch cannot be empty".to_string(),
68+
));
5669
}
5770
if self.manifest_glob.is_empty() {
58-
return Err(crate::error::GitHubError::Config("manifest_glob cannot be empty".to_string()));
71+
return Err(crate::error::GitHubError::Config(
72+
"manifest_glob cannot be empty".to_string(),
73+
));
5974
}
6075
Ok(())
6176
}

src/gitops.rs

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::{GitHubAppConfig, GitHubError, GitHubTokenProvider};
22
use serde::de::DeserializeOwned;
33
use std::path::{Path, PathBuf};
44
use std::process::{Command, Stdio};
5+
use tracing::{debug, error, info, instrument, warn};
56

67
pub struct GitHubGitOps {
78
config: GitHubAppConfig,
@@ -16,87 +17,120 @@ impl GitHubGitOps {
1617
}
1718
}
1819

20+
#[instrument(skip(self, args), fields(git_cmd = ?args))]
1921
fn run_git_command(&self, args: &[&str], cwd: Option<&Path>) -> Result<String, GitHubError> {
22+
debug!("Executing git command");
23+
2024
let mut cmd = Command::new("git");
21-
cmd.args(args)
22-
.stdout(Stdio::piped())
23-
.stderr(Stdio::piped());
25+
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
2426

2527
if let Some(dir) = cwd {
2628
cmd.current_dir(dir);
29+
debug!(directory = ?dir, "Set working directory");
2730
}
2831

2932
let output = cmd.output()?;
3033

3134
if !output.status.success() {
3235
let stderr = String::from_utf8_lossy(&output.stderr);
36+
37+
// Log the error with tracing - the sanitizer will redact tokens automatically
38+
error!(
39+
exit_code = ?output.status.code(),
40+
stderr = %stderr,
41+
"Git command failed"
42+
);
43+
44+
// Return a generic error to users (details are in logs)
3345
return Err(GitHubError::Git(format!(
34-
"Git command failed: git {}. Error: {}",
35-
args.join(" "),
36-
stderr
46+
"Git command failed. Check logs for details. Exit code: {:?}",
47+
output.status.code()
3748
)));
3849
}
3950

40-
Ok(String::from_utf8_lossy(&output.stdout).to_string())
51+
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
52+
debug!(output_length = stdout.len(), "Git command succeeded");
53+
Ok(stdout)
4154
}
4255

56+
#[instrument(skip(self), fields(repo = %self.config.repo, branch = %self.config.branch))]
4357
pub async fn initialize(&self) -> Result<(), GitHubError> {
4458
if self.config.git_clone_path.exists() {
59+
info!("Repository already exists, skipping clone");
4560
return Ok(());
4661
}
4762

63+
info!("Initializing repository clone");
64+
4865
std::fs::create_dir_all(&self.config.git_clone_path)?;
66+
debug!("Created clone directory");
4967

5068
let token = self.token_provider.get_token().await?;
5169
let clone_url = format!(
5270
"https://x-access-token:{}@github.com/{}.git",
5371
token, self.config.repo
5472
);
5573

74+
// Tracing will automatically sanitize the clone_url in logs
75+
debug!("Starting git clone operation");
5676
self.run_git_command(
5777
&["clone", "--branch", &self.config.branch, &clone_url, "."],
5878
Some(&self.config.git_clone_path),
5979
)?;
6080

81+
info!("Repository clone completed successfully");
6182
Ok(())
6283
}
6384

85+
#[instrument(skip(self), fields(repo = %self.config.repo, branch = %self.config.branch))]
6486
pub async fn sync(&self) -> Result<(), GitHubError> {
6587
if !self.config.git_clone_path.exists() {
88+
warn!("Repository not initialized");
6689
return Err(GitHubError::Git(
6790
"Repository not initialized. Call initialize() first.".to_string(),
6891
));
6992
}
7093

94+
info!("Syncing repository with remote");
95+
7196
let token = self.token_provider.get_token().await?;
7297
let remote_url = format!(
7398
"https://x-access-token:{}@github.com/{}.git",
7499
token, self.config.repo
75100
);
76101

102+
// Tracing will automatically sanitize the remote_url in logs
103+
debug!("Updating remote URL");
77104
self.run_git_command(
78105
&["remote", "set-url", "origin", &remote_url],
79106
Some(&self.config.git_clone_path),
80107
)?;
81108

109+
debug!("Fetching from origin");
82110
self.run_git_command(&["fetch", "origin"], Some(&self.config.git_clone_path))?;
83111

84112
let remote_branch = format!("origin/{}", self.config.branch);
113+
debug!(remote_branch = %remote_branch, "Resetting to remote branch");
85114
self.run_git_command(
86115
&["reset", "--hard", &remote_branch],
87116
Some(&self.config.git_clone_path),
88117
)?;
89118

119+
info!("Repository sync completed successfully");
90120
Ok(())
91121
}
92122

123+
#[instrument(skip(self), fields(repo = %self.config.repo, glob = %self.config.manifest_glob))]
93124
pub fn load_all_manifests<T: DeserializeOwned>(&self) -> Result<Vec<T>, GitHubError> {
94125
if !self.config.git_clone_path.exists() {
126+
warn!("Repository not initialized");
95127
return Err(GitHubError::Git(
96128
"Repository not initialized. Call initialize() first.".to_string(),
97129
));
98130
}
99131

132+
debug!("Loading manifests");
133+
100134
let pattern = self
101135
.config
102136
.git_clone_path
@@ -108,13 +142,14 @@ impl GitHubGitOps {
108142

109143
for entry in glob::glob(&pattern)? {
110144
let path = entry?;
145+
debug!(file = ?path, "Loading manifest file");
146+
111147
let content = std::fs::read_to_string(&path)?;
112-
let manifest: T = serde_yaml::from_str(&content).map_err(|e| {
113-
GitHubError::Yaml(e)
114-
})?;
148+
let manifest: T = serde_yaml::from_str(&content).map_err(GitHubError::Yaml)?;
115149
manifests.push(manifest);
116150
}
117151

152+
info!(count = manifests.len(), "Loaded manifests");
118153
Ok(manifests)
119154
}
120155

0 commit comments

Comments
 (0)