Skip to content
Merged
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
5 changes: 5 additions & 0 deletions crates/tirith-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@ thiserror = { workspace = true }
lopdf = { workspace = true }
uuid = { workspace = true }
tempfile = "3"
toml = "0.8"
ed25519-dalek = { workspace = true }

[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }

[build-dependencies]
toml = "0.8"
serde = { version = "1", features = ["derive"] }

[dev-dependencies]
toml = "0.8"
criterion = { version = "0.5", features = ["html_reports"] }
Expand Down
132 changes: 132 additions & 0 deletions crates/tirith-core/assets/data/credential_patterns.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Credential detection patterns — single source of truth.
#
# This file drives three consumers (no drift possible):
# 1. build.rs — generates tier-1 gate fragments
# 2. credential.rs — runtime pattern matching
# 3. redact.rs — evidence redaction
#
# Provider patterns sourced from:
# - gitleaks (MIT, Copyright 2019 Zachary Rice)
# - ripsecrets (MIT, Copyright 2021 ripsecrets contributors)

# ── Cloud Providers ──────────────────────────────────────────────────────

[[pattern]]
id = "aws_access_key"
name = "AWS Access Key"
regex = '(?:\bA3T[A-Z0-9]|\bAKIA|\bASIA|\bABIA|\bACCA)[A-Z2-7]{16}\b'
tier1_fragment = '(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]'
redact_prefix_len = 4
severity = "high"

[[pattern]]
id = "gcp_api_key"
name = "GCP API Key"
regex = '\bAIzaSy[0-9A-Za-z_-]{33}\b'
tier1_fragment = 'AIzaSy'
redact_prefix_len = 6
severity = "high"

# ── Source Control ────────────────────────────────────────────────────────

[[pattern]]
id = "github_pat"
name = "GitHub PAT"
regex = '\bgh[oprsu]_[0-9a-zA-Z]{36}\b'
tier1_fragment = 'gh[oprsu]_[0-9a-zA-Z]'
redact_prefix_len = 4
severity = "high"

[[pattern]]
id = "github_fine_grained_pat"
name = "GitHub Fine-Grained PAT"
regex = '\bgithub_pat_\w{82}\b'
tier1_fragment = 'github_pat_'
redact_prefix_len = 11
severity = "high"

[[pattern]]
id = "gitlab_pat"
name = "GitLab PAT"
regex = '\bglpat-[0-9A-Za-z_=-]{20,22}\b'
tier1_fragment = 'glpat-'
redact_prefix_len = 6
severity = "high"

# ── AI Providers ──────────────────────────────────────────────────────────

[[pattern]]
id = "anthropic_api_key"
name = "Anthropic API Key"
regex = '\bsk-ant-api03-[a-zA-Z0-9_-]{93}AA\b'
tier1_fragment = 'sk-ant-'
redact_prefix_len = 7
severity = "high"

# ── Messaging / Communication ────────────────────────────────────────────

[[pattern]]
id = "slack_token"
name = "Slack Token"
regex = '\bxox[aboprs]-(?:\d+-){2,}[A-Za-z0-9]*[A-Za-z][A-Za-z0-9]*\b'
tier1_fragment = 'xox[aboprs]-'
redact_prefix_len = 5
severity = "high"

[[pattern]]
id = "sendgrid_api_key"
name = "SendGrid API Key"
regex = '\bSG\.[a-zA-Z0-9=_-]{66}\b'
tier1_fragment = 'SG\.'
redact_prefix_len = 3
severity = "high"

[[pattern]]
id = "twilio_api_key"
name = "Twilio API Key"
regex = '\b(?:AC|SK)[0-9a-f]{32}\b'
tier1_fragment = '(?:AC|SK)[0-9a-f]{32}'
redact_prefix_len = 2
severity = "high"

# ── Payment Processing ───────────────────────────────────────────────────

[[pattern]]
id = "stripe_key"
name = "Stripe Key"
regex = '\b(?:sk|rk)_(?:test|live|prod)_[A-Za-z0-9]{16,}\b'
tier1_fragment = '(?:sk|rk)_(?:test|live|prod)_'
redact_prefix_len = 8
severity = "high"

# ── Package Registries ───────────────────────────────────────────────────

[[pattern]]
id = "npm_token"
name = "npm Token"
regex = '\bnpm_[0-9A-Za-z]{36}\b'
tier1_fragment = 'npm_[0-9A-Za-z]'
redact_prefix_len = 4
severity = "high"

# ── Encryption ───────────────────────────────────────────────────────────

[[pattern]]
id = "age_secret_key"
name = "age Secret Key"
regex = '\bAGE-SECRET-KEY-1[0-9A-Z]{58}\b'
tier1_fragment = 'AGE-SECRET-KEY-'
redact_prefix_len = 15
severity = "high"

# ── Private Keys (separate RuleId: PrivateKeyExposed) ────────────────────

[[private_key_pattern]]
id = "private_key"
name = "Private Key Block"
regex = '-----BEGIN\s[A-Z0-9 ]*PRIVATE KEY-----'
tier1_fragment = '-----BEGIN\s'
# Full PEM block regex for redaction (header + base64 body + footer).
# Detection only needs the header; redaction must scrub the entire block.
redact_regex = '-----BEGIN\s[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END\s[A-Z0-9 ]*PRIVATE KEY-----'
severity = "critical"
94 changes: 94 additions & 0 deletions crates/tirith-core/build.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,42 @@
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::Path;

#[derive(Deserialize)]
struct CredentialPatternsFile {
pattern: Option<Vec<CredPattern>>,
private_key_pattern: Option<Vec<PrivKeyPattern>>,
}

#[derive(Deserialize)]
struct CredPattern {
tier1_fragment: String,
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
regex: String,
#[allow(dead_code)]
redact_prefix_len: Option<usize>,
#[allow(dead_code)]
severity: String,
}

#[derive(Deserialize)]
struct PrivKeyPattern {
tier1_fragment: String,
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
regex: String,
#[allow(dead_code)]
severity: String,
}

fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
Expand All @@ -21,6 +56,7 @@ fn main() {
println!("cargo:rerun-if-changed=assets/data/popular_repos.csv");
println!("cargo:rerun-if-changed=assets/data/public_suffix_list.dat");
println!("cargo:rerun-if-changed=assets/data/ocr_confusions.tsv");
println!("cargo:rerun-if-changed=assets/data/credential_patterns.toml");
println!("cargo:rerun-if-changed=build.rs");
}

Expand Down Expand Up @@ -449,6 +485,64 @@ fn generate_tier1_regex(out_dir: &str) {
}
}

// Load credential patterns from TOML and inject tier-1 entries
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let cred_path = Path::new(&manifest_dir)
.join("assets")
.join("data")
.join("credential_patterns.toml");
let cred_content = fs::read_to_string(&cred_path)
.unwrap_or_else(|e| panic!("Failed to read credential_patterns.toml: {e}"));
let cred_file: CredentialPatternsFile = toml::from_str(&cred_content)
.unwrap_or_else(|e| panic!("Failed to parse credential_patterns.toml: {e}"));

// credential_known — exec fragments from all [[pattern]] entries
{
let mut known_frags: Vec<String> = Vec::new();
if let Some(ref patterns) = cred_file.pattern {
for p in patterns {
known_frags.push(p.tier1_fragment.clone());
}
}
assert!(
!known_frags.is_empty(),
"credential_patterns.toml has no [[pattern]] entries"
);
ids.push("credential_known".to_string());
for frag in &known_frags {
exec_fragments.push(frag.clone());
paste_fragments.push(frag.clone());
}
}

// credential_private_key — exec fragment from [[private_key_pattern]]
{
let pk_patterns = cred_file
.private_key_pattern
.as_ref()
.expect("credential_patterns.toml has no [[private_key_pattern]]");
assert!(
!pk_patterns.is_empty(),
"credential_patterns.toml [[private_key_pattern]] is empty"
);
ids.push("credential_private_key".to_string());
for pk in pk_patterns {
exec_fragments.push(pk.tier1_fragment.clone());
paste_fragments.push(pk.tier1_fragment.clone());
}
}

// credential_generic — paste-only fragment for generic key=value patterns
{
// Tier-1 must be a superset of GENERIC_SECRET_RE. The runtime regex
// allows optional quote/bracket before the operator (["']?\]?), which
// cannot contain literal " in the r"..." generated output. We use
// .{0,2} as a permissive stand-in for the optional quote+bracket.
let generic_frag = r"(?i:key|token|secret|password)\w*.{0,2}\s*(?:[:=]|:=|=>|<-|>)";
ids.push("credential_generic".to_string());
paste_fragments.push(generic_frag.to_string());
}

let exec_regex = format!("(?:{})", exec_fragments.join("|"));
let paste_regex = format!("(?:{})", paste_fragments.join("|"));

Expand Down
7 changes: 7 additions & 0 deletions crates/tirith-core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,11 @@ pub fn analyze(ctx: &AnalysisContext) -> Verdict {
);
findings.extend(command_findings);

// Run credential leak detection rules
let cred_findings =
crate::rules::credential::check(&ctx.input, ctx.shell, ctx.scan_context);
findings.extend(cred_findings);

// Run environment rules
let env_findings = crate::rules::environment::check(&crate::rules::environment::RealEnv);
findings.extend(env_findings);
Expand Down Expand Up @@ -917,6 +922,8 @@ fn mitre_id_for_rule(rule_id: crate::verdict::RuleId) -> Option<&'static str> {
RuleId::ShellInjectionEnv => Some("T1546.004"), // Shell Config Modification

// Credential Access
RuleId::CredentialInText | RuleId::HighEntropySecret => Some("T1552"), // Unsecured Credentials
RuleId::PrivateKeyExposed => Some("T1552.004"), // Private Keys
RuleId::MetadataEndpoint => Some("T1552.005"), // Unsecured Credentials: Cloud Instance Metadata
RuleId::SensitiveEnvExport => Some("T1552.001"), // Credentials In Files

Expand Down
Loading
Loading