Skip to content
Open
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
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ hmac = "0.12"
sha2 = "0.10"
glob = "0.3"
thiserror = "2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
regex = "1.0"

[dev-dependencies]
tempfile = "3.10"
26 changes: 24 additions & 2 deletions src/app_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, error, info, instrument};

#[derive(Debug, Serialize)]
struct Claims {
Expand Down Expand Up @@ -39,7 +40,10 @@ impl GitHubTokenProvider {
}
}

#[instrument(skip(self))]
fn create_jwt(&self) -> Result<String, GitHubError> {
debug!("Creating JWT for GitHub App authentication");

let now = Utc::now();
let iat = now.timestamp();
let exp = (now + Duration::minutes(10)).timestamp();
Expand All @@ -52,17 +56,24 @@ impl GitHubTokenProvider {

let key = EncodingKey::from_rsa_pem(self.config.private_key_pem.as_bytes())?;
let token = encode(&Header::new(Algorithm::RS256), &claims, &key)?;

debug!("JWT created successfully");
Ok(token)
}

#[instrument(skip(self), fields(installation_id = %self.config.installation_id))]
async fn fetch_installation_token(&self) -> Result<CachedToken, GitHubError> {
info!("Fetching new installation access token");

let jwt = self.create_jwt()?;

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

debug!(url = %url, "Requesting installation token");

let response = self
.client
.post(&url)
Expand All @@ -76,9 +87,17 @@ impl GitHubTokenProvider {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await?;

// Tracing will automatically sanitize any tokens in the body
error!(
status = %status,
response_body = %body,
"Failed to get installation token"
);

return Err(GitHubError::Other(format!(
"Failed to get installation token: {} - {}",
status, body
"Failed to get installation token: {}",
status
)));
}

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

info!(expires_at = %expires_at, "Installation token fetched successfully");

Ok(CachedToken {
token: token_response.token,
expires_at,
Expand All @@ -99,6 +120,7 @@ impl GitHubTokenProvider {
/// 1. Quick read-only check if token is valid (fast path)
/// 2. If refresh needed, acquire lock and check again
/// 3. Only one task fetches new token, others wait and reuse it
#[instrument(skip(self))]
pub async fn get_token(&self) -> Result<String, GitHubError> {
// Fast path: Check if we have a valid token without holding lock during HTTP
{
Expand Down
47 changes: 43 additions & 4 deletions src/gitops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::{GitHubAppConfig, GitHubError, GitHubTokenProvider};
use serde::de::DeserializeOwned;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tracing::{debug, error, info, instrument, warn};

pub struct GitHubGitOps {
config: GitHubAppConfig,
Expand All @@ -16,85 +17,120 @@ impl GitHubGitOps {
}
}

#[instrument(skip(self, args), fields(git_cmd = ?args))]
fn run_git_command(&self, args: &[&str], cwd: Option<&Path>) -> Result<String, GitHubError> {
debug!("Executing git command");

let mut cmd = Command::new("git");
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());

if let Some(dir) = cwd {
cmd.current_dir(dir);
debug!(directory = ?dir, "Set working directory");
}

let output = cmd.output()?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);

// Log the error with tracing - the sanitizer will redact tokens automatically
error!(
exit_code = ?output.status.code(),
stderr = %stderr,
"Git command failed"
);

// Return a generic error to users (details are in logs)
return Err(GitHubError::Git(format!(
"Git command failed: git {}. Error: {}",
args.join(" "),
stderr
"Git command failed. Check logs for details. Exit code: {:?}",
output.status.code()
)));
}

Ok(String::from_utf8_lossy(&output.stdout).to_string())
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
debug!(output_length = stdout.len(), "Git command succeeded");
Ok(stdout)
}

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

info!("Initializing repository clone");

std::fs::create_dir_all(&self.config.git_clone_path)?;
debug!("Created clone directory");

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

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

info!("Repository clone completed successfully");
Ok(())
}

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

info!("Syncing repository with remote");

let token = self.token_provider.get_token().await?;
let remote_url = format!(
"https://x-access-token:{}@github.com/{}.git",
token, self.config.repo
);

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

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

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

info!("Repository sync completed successfully");
Ok(())
}

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

debug!("Loading manifests");

let pattern = self
.config
.git_clone_path
Expand All @@ -106,11 +142,14 @@ impl GitHubGitOps {

for entry in glob::glob(&pattern)? {
let path = entry?;
debug!(file = ?path, "Loading manifest file");

let content = std::fs::read_to_string(&path)?;
let manifest: T = serde_yaml::from_str(&content).map_err(GitHubError::Yaml)?;
manifests.push(manifest);
}

info!(count = manifests.len(), "Loaded manifests");
Ok(manifests)
}

Expand Down
80 changes: 80 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,90 @@ pub mod app_auth;
pub mod config;
pub mod error;
pub mod gitops;
pub mod tracing_sanitizer;
pub mod webhook;

pub use app_auth::GitHubTokenProvider;
pub use config::GitHubAppConfig;
pub use error::GitHubError;
pub use gitops::GitHubGitOps;
pub use tracing_sanitizer::sanitize_sensitive_data;
pub use webhook::{PushEvent, WebhookEvent, WebhookVerifier};

use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};

/// Initialize tracing with automatic sanitization of sensitive data
///
/// This sets up structured logging with automatic redaction of:
/// - GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
/// - Credentials in URLs
/// - Bearer tokens
/// - x-access-token URLs
///
/// # Environment Variables
///
/// - `RUST_LOG`: Control log level (e.g., "debug", "info", "warn", "error")
/// - Default: "info"
/// - Example: `RUST_LOG=debug cargo run`
///
/// # Examples
///
/// ```no_run
/// use github_app::init_tracing;
///
/// // Initialize once at application startup
/// init_tracing();
///
/// // Now all logs will have sensitive data automatically redacted
/// tracing::info!("Starting application");
/// ```
///
/// # Panics
///
/// Panics if called more than once (tracing can only be initialized once per process)
pub fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));

// Create a formatter that writes to a sanitizing writer
let fmt_layer = fmt::layer()
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_writer(tracing_sanitizer::SanitizingMakeWriter::new());

tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.init();
}

/// Initialize tracing with JSON output for structured logging
///
/// Useful for production environments where logs are shipped to aggregation systems
/// like DataDog, Splunk, or ELK. All output is still sanitized.
///
/// # Examples
///
/// ```no_run
/// use github_app::init_tracing_json;
///
/// init_tracing_json();
/// tracing::info!(user = "alice", "User logged in");
/// ```
pub fn init_tracing_json() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));

let fmt_layer = fmt::layer()
.json()
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_writer(tracing_sanitizer::SanitizingMakeWriter::new());

tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.init();
}
Loading