Skip to content

Commit f68d4b1

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 baba41d commit f68d4b1

5 files changed

Lines changed: 373 additions & 6 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ hmac = "0.12"
2525
sha2 = "0.10"
2626
glob = "0.3"
2727
thiserror = "2.0"
28+
tracing = "0.1"
29+
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
30+
regex = "1.0"
2831

2932
[dev-dependencies]
3033
tempfile = "3.10"

src/app_auth.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
44
use serde::{Deserialize, Serialize};
55
use std::sync::Arc;
66
use tokio::sync::Mutex;
7+
use tracing::{debug, error, info, instrument};
78

89
#[derive(Debug, Serialize)]
910
struct Claims {
@@ -39,7 +40,10 @@ impl GitHubTokenProvider {
3940
}
4041
}
4142

43+
#[instrument(skip(self))]
4244
fn create_jwt(&self) -> Result<String, GitHubError> {
45+
debug!("Creating JWT for GitHub App authentication");
46+
4347
let now = Utc::now();
4448
let iat = now.timestamp();
4549
let exp = (now + Duration::minutes(10)).timestamp();
@@ -52,17 +56,24 @@ impl GitHubTokenProvider {
5256

5357
let key = EncodingKey::from_rsa_pem(self.config.private_key_pem.as_bytes())?;
5458
let token = encode(&Header::new(Algorithm::RS256), &claims, &key)?;
59+
60+
debug!("JWT created successfully");
5561
Ok(token)
5662
}
5763

64+
#[instrument(skip(self), fields(installation_id = %self.config.installation_id))]
5865
async fn fetch_installation_token(&self) -> Result<CachedToken, GitHubError> {
66+
info!("Fetching new installation access token");
67+
5968
let jwt = self.create_jwt()?;
6069

6170
let url = format!(
6271
"https://api.github.com/app/installations/{}/access_tokens",
6372
self.config.installation_id
6473
);
6574

75+
debug!(url = %url, "Requesting installation token");
76+
6677
let response = self
6778
.client
6879
.post(&url)
@@ -76,9 +87,17 @@ impl GitHubTokenProvider {
7687
if !response.status().is_success() {
7788
let status = response.status();
7889
let body = response.text().await?;
90+
91+
// Tracing will automatically sanitize any tokens in the body
92+
error!(
93+
status = %status,
94+
response_body = %body,
95+
"Failed to get installation token"
96+
);
97+
7998
return Err(GitHubError::Other(format!(
80-
"Failed to get installation token: {} - {}",
81-
status, body
99+
"Failed to get installation token: {}",
100+
status
82101
)));
83102
}
84103

@@ -87,6 +106,8 @@ impl GitHubTokenProvider {
87106
.map_err(|e| GitHubError::Other(format!("Failed to parse expiry time: {}", e)))?
88107
.with_timezone(&Utc);
89108

109+
info!(expires_at = %expires_at, "Installation token fetched successfully");
110+
90111
Ok(CachedToken {
91112
token: token_response.token,
92113
expires_at,
@@ -99,6 +120,7 @@ impl GitHubTokenProvider {
99120
/// 1. Quick read-only check if token is valid (fast path)
100121
/// 2. If refresh needed, acquire lock and check again
101122
/// 3. Only one task fetches new token, others wait and reuse it
123+
#[instrument(skip(self))]
102124
pub async fn get_token(&self) -> Result<String, GitHubError> {
103125
// Fast path: Check if we have a valid token without holding lock during HTTP
104126
{

src/gitops.rs

Lines changed: 43 additions & 4 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,85 +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");
2125
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
2226

2327
if let Some(dir) = cwd {
2428
cmd.current_dir(dir);
29+
debug!(directory = ?dir, "Set working directory");
2530
}
2631

2732
let output = cmd.output()?;
2833

2934
if !output.status.success() {
3035
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)
3145
return Err(GitHubError::Git(format!(
32-
"Git command failed: git {}. Error: {}",
33-
args.join(" "),
34-
stderr
46+
"Git command failed. Check logs for details. Exit code: {:?}",
47+
output.status.code()
3548
)));
3649
}
3750

38-
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)
3954
}
4055

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

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

4868
let token = self.token_provider.get_token().await?;
4969
let clone_url = format!(
5070
"https://x-access-token:{}@github.com/{}.git",
5171
token, self.config.repo
5272
);
5373

74+
// Tracing will automatically sanitize the clone_url in logs
75+
debug!("Starting git clone operation");
5476
self.run_git_command(
5577
&["clone", "--branch", &self.config.branch, &clone_url, "."],
5678
Some(&self.config.git_clone_path),
5779
)?;
5880

81+
info!("Repository clone completed successfully");
5982
Ok(())
6083
}
6184

85+
#[instrument(skip(self), fields(repo = %self.config.repo, branch = %self.config.branch))]
6286
pub async fn sync(&self) -> Result<(), GitHubError> {
6387
if !self.config.git_clone_path.exists() {
88+
warn!("Repository not initialized");
6489
return Err(GitHubError::Git(
6590
"Repository not initialized. Call initialize() first.".to_string(),
6691
));
6792
}
6893

94+
info!("Syncing repository with remote");
95+
6996
let token = self.token_provider.get_token().await?;
7097
let remote_url = format!(
7198
"https://x-access-token:{}@github.com/{}.git",
7299
token, self.config.repo
73100
);
74101

102+
// Tracing will automatically sanitize the remote_url in logs
103+
debug!("Updating remote URL");
75104
self.run_git_command(
76105
&["remote", "set-url", "origin", &remote_url],
77106
Some(&self.config.git_clone_path),
78107
)?;
79108

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

82112
let remote_branch = format!("origin/{}", self.config.branch);
113+
debug!(remote_branch = %remote_branch, "Resetting to remote branch");
83114
self.run_git_command(
84115
&["reset", "--hard", &remote_branch],
85116
Some(&self.config.git_clone_path),
86117
)?;
87118

119+
info!("Repository sync completed successfully");
88120
Ok(())
89121
}
90122

123+
#[instrument(skip(self), fields(repo = %self.config.repo, glob = %self.config.manifest_glob))]
91124
pub fn load_all_manifests<T: DeserializeOwned>(&self) -> Result<Vec<T>, GitHubError> {
92125
if !self.config.git_clone_path.exists() {
126+
warn!("Repository not initialized");
93127
return Err(GitHubError::Git(
94128
"Repository not initialized. Call initialize() first.".to_string(),
95129
));
96130
}
97131

132+
debug!("Loading manifests");
133+
98134
let pattern = self
99135
.config
100136
.git_clone_path
@@ -106,11 +142,14 @@ impl GitHubGitOps {
106142

107143
for entry in glob::glob(&pattern)? {
108144
let path = entry?;
145+
debug!(file = ?path, "Loading manifest file");
146+
109147
let content = std::fs::read_to_string(&path)?;
110148
let manifest: T = serde_yaml::from_str(&content).map_err(GitHubError::Yaml)?;
111149
manifests.push(manifest);
112150
}
113151

152+
info!(count = manifests.len(), "Loaded manifests");
114153
Ok(manifests)
115154
}
116155

src/lib.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,90 @@ pub mod app_auth;
22
pub mod config;
33
pub mod error;
44
pub mod gitops;
5+
pub mod tracing_sanitizer;
56
pub mod webhook;
67

78
pub use app_auth::GitHubTokenProvider;
89
pub use config::GitHubAppConfig;
910
pub use error::GitHubError;
1011
pub use gitops::GitHubGitOps;
12+
pub use tracing_sanitizer::sanitize_sensitive_data;
1113
pub use webhook::{PushEvent, WebhookEvent, WebhookVerifier};
14+
15+
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
16+
17+
/// Initialize tracing with automatic sanitization of sensitive data
18+
///
19+
/// This sets up structured logging with automatic redaction of:
20+
/// - GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
21+
/// - Credentials in URLs
22+
/// - Bearer tokens
23+
/// - x-access-token URLs
24+
///
25+
/// # Environment Variables
26+
///
27+
/// - `RUST_LOG`: Control log level (e.g., "debug", "info", "warn", "error")
28+
/// - Default: "info"
29+
/// - Example: `RUST_LOG=debug cargo run`
30+
///
31+
/// # Examples
32+
///
33+
/// ```no_run
34+
/// use github_app::init_tracing;
35+
///
36+
/// // Initialize once at application startup
37+
/// init_tracing();
38+
///
39+
/// // Now all logs will have sensitive data automatically redacted
40+
/// tracing::info!("Starting application");
41+
/// ```
42+
///
43+
/// # Panics
44+
///
45+
/// Panics if called more than once (tracing can only be initialized once per process)
46+
pub fn init_tracing() {
47+
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
48+
49+
// Create a formatter that writes to a sanitizing writer
50+
let fmt_layer = fmt::layer()
51+
.with_target(true)
52+
.with_thread_ids(false)
53+
.with_thread_names(false)
54+
.with_file(true)
55+
.with_line_number(true)
56+
.with_writer(tracing_sanitizer::SanitizingMakeWriter::new());
57+
58+
tracing_subscriber::registry()
59+
.with(filter)
60+
.with(fmt_layer)
61+
.init();
62+
}
63+
64+
/// Initialize tracing with JSON output for structured logging
65+
///
66+
/// Useful for production environments where logs are shipped to aggregation systems
67+
/// like DataDog, Splunk, or ELK. All output is still sanitized.
68+
///
69+
/// # Examples
70+
///
71+
/// ```no_run
72+
/// use github_app::init_tracing_json;
73+
///
74+
/// init_tracing_json();
75+
/// tracing::info!(user = "alice", "User logged in");
76+
/// ```
77+
pub fn init_tracing_json() {
78+
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
79+
80+
let fmt_layer = fmt::layer()
81+
.json()
82+
.with_target(true)
83+
.with_file(true)
84+
.with_line_number(true)
85+
.with_writer(tracing_sanitizer::SanitizingMakeWriter::new());
86+
87+
tracing_subscriber::registry()
88+
.with(filter)
89+
.with(fmt_layer)
90+
.init();
91+
}

0 commit comments

Comments
 (0)