From 10429cd0c4a4c2a82c10b6ff757074c0d0dc660d Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Tue, 5 May 2026 23:26:22 +0900 Subject: [PATCH] =?UTF-8?q?fix(env):=20=ED=85=9C=ED=94=8C=EB=A6=BF=20secre?= =?UTF-8?q?t=20=ED=8C=90=EC=A0=95=20=EC=A0=95=EB=B0=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env template secret warning을 key-aware classifier로 전환하고 Rust/JS fallback 동작을 맞춘다. Closes #107 --- crates/maximus-checks/src/env.rs | 2 +- crates/maximus-checks/tests/env_checks.rs | 69 +++++++++++++++++ crates/maximus-core/src/env_parser.rs | 94 +++++++++++++++++++++-- crates/maximus-core/tests/core_models.rs | 62 ++++++++++++++- src/checks/env.js | 2 +- src/lib/env.js | 62 ++++++++++++++- 6 files changed, 279 insertions(+), 12 deletions(-) diff --git a/crates/maximus-checks/src/env.rs b/crates/maximus-checks/src/env.rs index 717477f..e6f5453 100644 --- a/crates/maximus-checks/src/env.rs +++ b/crates/maximus-checks/src/env.rs @@ -273,7 +273,7 @@ pub fn run_env_check_with_options( for contract_record in &contract_records { for entry in &contract_record.parsed.entries { - if !looks_like_secret(&entry.value) { + if !looks_like_secret(&entry.key, &entry.value) { continue; } diff --git a/crates/maximus-checks/tests/env_checks.rs b/crates/maximus-checks/tests/env_checks.rs index 32593a7..99e35bb 100644 --- a/crates/maximus-checks/tests/env_checks.rs +++ b/crates/maximus-checks/tests/env_checks.rs @@ -126,6 +126,75 @@ fn env_check_matches_js_findings_for_duplicates_invalid_sync_secret_override_and ); } +#[test] +fn env_example_secret_warning_uses_key_aware_classifier() { + let fixture = TempDir::new().expect("temp dir should exist"); + + write( + fixture.path().join(".env.example"), + "\ +AUTH_TOKEN=github-token +SUPABASE_SERVICE_KEY=service-role-key +SUPABASE_SERVICE_ROLE_KEY=service-role-key +GOOGLE_SERVICE_ACCOUNT_KEY=service-account-key +PRIVATE_KEY=private-key-value +SHARED=sk_live_1234567890abcdef +NEXT_PUBLIC_OKTA_CLIENT_ID=0oa1234567890abcdefghijkl +VALIDATION_ISSUE_REPO=JeremyDev87/maximus-audit-signal +VALIDATION_ISSUE_DASHBOARD_URL=https://github.com/JeremyDev87/maximus/issues/107 +VALIDATION_LABELS=enhancement,test,key-aware-env +WINDOW_START_DATE=2026-05-05 +ERROR_PERCENT=100 +SYNC_HOURS=24 +SERVICE_WORKER_CACHE_KEY=v1 +PLACEHOLDER_TOKEN=change-me +", + ); + + let project = discover_project(fixture.path()).expect("project should discover"); + let outcome = run_env_check(&project).expect("check should run"); + let env_file = fixture.path().join(".env.example"); + + for key in [ + "AUTH_TOKEN", + "SUPABASE_SERVICE_KEY", + "SUPABASE_SERVICE_ROLE_KEY", + "GOOGLE_SERVICE_ACCOUNT_KEY", + "PRIVATE_KEY", + "SHARED", + ] { + assert_has_finding( + &outcome.findings, + &format!("env-example-secret:{}:{key}", env_file.to_string_lossy()), + Severity::Warn, + &format!(".env.example appears to contain a real value for \"{key}\""), + "Contract files should describe the interface, not ship concrete secrets.", + "Replace the value with a blank or placeholder string before sharing the repo.", + Some(env_file.clone()), + false, + &[], + ); + } + + for key in [ + "NEXT_PUBLIC_OKTA_CLIENT_ID", + "VALIDATION_ISSUE_REPO", + "VALIDATION_ISSUE_DASHBOARD_URL", + "VALIDATION_LABELS", + "WINDOW_START_DATE", + "ERROR_PERCENT", + "SYNC_HOURS", + "SERVICE_WORKER_CACHE_KEY", + "PLACEHOLDER_TOKEN", + ] { + let id = format!("env-example-secret:{}:{key}", env_file.to_string_lossy()); + assert!( + !outcome.findings.iter().any(|finding| finding.id == id), + "{key} should not produce env-example-secret" + ); + } +} + #[test] fn env_check_plans_example_creation_when_runtime_env_files_exist_without_contract() { let fixture = TempDir::new().expect("temp dir should exist"); diff --git a/crates/maximus-core/src/env_parser.rs b/crates/maximus-core/src/env_parser.rs index 48a6929..2aca87d 100644 --- a/crates/maximus-core/src/env_parser.rs +++ b/crates/maximus-core/src/env_parser.rs @@ -189,7 +189,7 @@ pub fn is_concrete_env_file_name(name: &str) -> bool { is_env_file_name(name) && !is_template_env_file_name(name) } -pub fn looks_like_secret(value: &str) -> bool { +pub fn looks_like_secret(key: &str, value: &str) -> bool { if value.is_empty() { return false; } @@ -198,10 +198,11 @@ pub fn looks_like_secret(value: &str) -> bool { return false; } - value.len() >= 16 - && value - .chars() - .all(|character| character.is_ascii_alphanumeric() || "/_+=-".contains(character)) + if has_high_confidence_secret_value(value) { + return true; + } + + is_secret_like_env_key(key) } fn split_env_assignment(line: &str) -> Option<(&str, &str)> { @@ -280,6 +281,89 @@ fn is_placeholder_value(value: &str) -> bool { .unwrap_or(false) } +fn has_high_confidence_secret_value(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + + value.contains("-----BEGIN PRIVATE KEY-----") + || lower.starts_with("sk_live_") + || lower.starts_with("sk_test_") + || lower.starts_with("ghp_") + || lower.starts_with("github_pat_") + || lower.starts_with("xoxb-") + || lower.starts_with("xoxp-") + || lower.starts_with("xoxa-") + || (value.len() == 20 + && value.starts_with("AKIA") + && value + .chars() + .all(|character| character.is_ascii_uppercase() || character.is_ascii_digit())) + || (value.len() >= 35 + && value.starts_with("AIza") + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_".contains(character))) +} + +fn is_secret_like_env_key(key: &str) -> bool { + let segments = env_key_segments(key); + if segments.is_empty() { + return false; + } + + if contains_adjacent_segments(&segments, "PRIVATE", "KEY") + || contains_service_key_segments(&segments) + { + return true; + } + + if segments.iter().any(|segment| { + matches!( + segment.as_str(), + "TOKEN" | "SECRET" | "PASSWORD" | "PASSWD" | "PWD" + ) + }) { + return true; + } + + if contains_adjacent_segments(&segments, "API", "KEY") + || contains_adjacent_segments(&segments, "ACCESS", "KEY") + { + return !is_public_key_identifier(&segments); + } + + false +} + +fn env_key_segments(key: &str) -> Vec { + key.split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|segment| !segment.is_empty()) + .map(|segment| segment.to_ascii_uppercase()) + .collect() +} + +fn contains_adjacent_segments(segments: &[String], left: &str, right: &str) -> bool { + segments + .windows(2) + .any(|window| window[0] == left && window[1] == right) +} + +fn contains_service_key_segments(segments: &[String]) -> bool { + segments + .windows(2) + .any(|window| window[0] == "SERVICE" && window[1] == "KEY") + || segments.windows(3).any(|window| { + window[0] == "SERVICE" + && matches!(window[1].as_str(), "ROLE" | "ACCOUNT") + && window[2] == "KEY" + }) +} + +fn is_public_key_identifier(segments: &[String]) -> bool { + contains_adjacent_segments(segments, "PUBLIC", "KEY") + || contains_adjacent_segments(segments, "ANON", "KEY") + || contains_adjacent_segments(segments, "CLIENT", "ID") +} + fn normalize_env_template_source_group(group: EnvTemplateSourceGroup) -> EnvTemplateSourceGroup { let mut unique_keys = group .keys diff --git a/crates/maximus-core/tests/core_models.rs b/crates/maximus-core/tests/core_models.rs index 1ce53db..205aa0c 100644 --- a/crates/maximus-core/tests/core_models.rs +++ b/crates/maximus-core/tests/core_models.rs @@ -144,9 +144,65 @@ fn env_helpers_match_current_js_behavior() { assert!(is_concrete_env_file_name(".env.local")); assert!(!is_concrete_env_file_name(".env.")); assert!(!is_concrete_env_file_name(".env.template")); - assert!(looks_like_secret("supersecretvalue12345")); - assert!(!looks_like_secret("localhost")); - assert!(!looks_like_secret("your-api-key")); + assert!(looks_like_secret("AUTH_TOKEN", "supersecretvalue12345")); + assert!(!looks_like_secret("AUTH_TOKEN", "localhost")); + assert!(!looks_like_secret("AUTH_TOKEN", "your-api-key")); +} + +#[test] +fn env_secret_helper_is_key_aware() { + for (key, value) in [ + ("AUTH_TOKEN", "github-token"), + ("DATABASE_SECRET", "secret-value"), + ("SUPABASE_SERVICE_KEY", "service-role-key"), + ("SUPABASE_SERVICE_ROLE_KEY", "service-role-key"), + ("GOOGLE_SERVICE_ACCOUNT_KEY", "service-account-key"), + ("PRIVATE_KEY", "private-key-value"), + ("API_KEY", "api-key-value"), + ("SHARED", "sk_live_1234567890abcdef"), + ] { + assert!( + looks_like_secret(key, value), + "{key}={value} should be treated as a secret-like template value" + ); + } + + for (key, value) in [ + ("NEXT_PUBLIC_OKTA_CLIENT_ID", "0oa1234567890abcdefghijkl"), + ("VALIDATION_ISSUE_REPO", "JeremyDev87/maximus-audit-signal"), + ( + "VALIDATION_ISSUE_DASHBOARD_URL", + "https://github.com/JeremyDev87/maximus/issues/107", + ), + ("VALIDATION_LABELS", "enhancement,test,key-aware-env"), + ("WINDOW_START_DATE", "2026-05-05"), + ("ERROR_PERCENT", "100"), + ("SYNC_HOURS", "24"), + ("SUPABASE_ANON_KEY", "supabase-anon-public-key"), + ("PUBLIC_KEY", "public-key-identifier"), + ("SERVICE_WORKER_CACHE_KEY", "v1"), + ] { + assert!( + !looks_like_secret(key, value), + "{key}={value} should be treated as public/config template data" + ); + } + + for placeholder in [ + "change-me", + "placeholder", + "your-api-key", + "example", + "true", + "false", + "0", + "1", + ] { + assert!( + !looks_like_secret("AUTH_TOKEN", placeholder), + "{placeholder} should remain a non-warning placeholder" + ); + } } #[test] diff --git a/src/checks/env.js b/src/checks/env.js index 3fd7b04..4e14e1b 100644 --- a/src/checks/env.js +++ b/src/checks/env.js @@ -181,7 +181,7 @@ export async function runEnvCheck(project) { for (const contractRecord of contractRecords) { for (const entry of contractRecord.parsed.entries) { - if (!looksLikeSecret(entry.value)) { + if (!looksLikeSecret(entry.key, entry.value)) { continue; } diff --git a/src/lib/env.js b/src/lib/env.js index 1bdc12b..e87fd19 100644 --- a/src/lib/env.js +++ b/src/lib/env.js @@ -85,7 +85,7 @@ export function isConcreteEnvFileName(name) { return /^\.env(?:\..+)?$/u.test(name) && !isTemplateEnvFileName(name); } -export function looksLikeSecret(value) { +export function looksLikeSecret(key, value) { if (!value) { return false; } @@ -94,13 +94,71 @@ export function looksLikeSecret(value) { return false; } - if (/^[A-Za-z0-9/_+=-]{16,}$/u.test(value)) { + if (hasHighConfidenceSecretValue(value)) { return true; } + return isSecretLikeEnvKey(key); +} + +function hasHighConfidenceSecretValue(value) { + return ( + value.includes("-----BEGIN PRIVATE KEY-----") || + /^(?:sk_live_|sk_test_|ghp_|github_pat_|xox[abp]-)/iu.test(value) || + /^AKIA[A-Z0-9]{16}$/u.test(value) || + /^AIza[A-Za-z0-9_-]{31,}$/u.test(value) + ); +} + +function isSecretLikeEnvKey(key) { + const segments = key + .split(/[^A-Za-z0-9]+/u) + .filter(Boolean) + .map((segment) => segment.toUpperCase()); + + if (segments.length === 0) { + return false; + } + + if (containsAdjacentSegments(segments, "PRIVATE", "KEY") || containsServiceKeySegments(segments)) { + return true; + } + + if (segments.some((segment) => ["TOKEN", "SECRET", "PASSWORD", "PASSWD", "PWD"].includes(segment))) { + return true; + } + + if (containsAdjacentSegments(segments, "API", "KEY") || containsAdjacentSegments(segments, "ACCESS", "KEY")) { + return !isPublicKeyIdentifier(segments); + } + return false; } +function containsAdjacentSegments(segments, left, right) { + return segments.some((segment, index) => segment === left && segments[index + 1] === right); +} + +function containsServiceKeySegments(segments) { + return segments.some((segment, index) => { + if (segment !== "SERVICE") { + return false; + } + + const next = segments[index + 1]; + const following = segments[index + 2]; + return next === "KEY" || ((next === "ROLE" || next === "ACCOUNT") && following === "KEY"); + }); +} + +function isPublicKeyIdentifier(segments) { + return ( + containsAdjacentSegments(segments, "PUBLIC", "KEY") || + containsAdjacentSegments(segments, "ANON", "KEY") || + containsAdjacentSegments(segments, "CLIENT", "ID") + ); +} + export function parseExactGitignorePatterns(text) { return text .split(/\r?\n/u)