Skip to content

Commit 10429cd

Browse files
committed
fix(env): 템플릿 secret 판정 정밀화
env template secret warning을 key-aware classifier로 전환하고 Rust/JS fallback 동작을 맞춘다. Closes #107
1 parent e5ea2c8 commit 10429cd

6 files changed

Lines changed: 279 additions & 12 deletions

File tree

‎crates/maximus-checks/src/env.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ pub fn run_env_check_with_options(
273273

274274
for contract_record in &contract_records {
275275
for entry in &contract_record.parsed.entries {
276-
if !looks_like_secret(&entry.value) {
276+
if !looks_like_secret(&entry.key, &entry.value) {
277277
continue;
278278
}
279279

‎crates/maximus-checks/tests/env_checks.rs‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,75 @@ fn env_check_matches_js_findings_for_duplicates_invalid_sync_secret_override_and
126126
);
127127
}
128128

129+
#[test]
130+
fn env_example_secret_warning_uses_key_aware_classifier() {
131+
let fixture = TempDir::new().expect("temp dir should exist");
132+
133+
write(
134+
fixture.path().join(".env.example"),
135+
"\
136+
AUTH_TOKEN=github-token
137+
SUPABASE_SERVICE_KEY=service-role-key
138+
SUPABASE_SERVICE_ROLE_KEY=service-role-key
139+
GOOGLE_SERVICE_ACCOUNT_KEY=service-account-key
140+
PRIVATE_KEY=private-key-value
141+
SHARED=sk_live_1234567890abcdef
142+
NEXT_PUBLIC_OKTA_CLIENT_ID=0oa1234567890abcdefghijkl
143+
VALIDATION_ISSUE_REPO=JeremyDev87/maximus-audit-signal
144+
VALIDATION_ISSUE_DASHBOARD_URL=https://github.com/JeremyDev87/maximus/issues/107
145+
VALIDATION_LABELS=enhancement,test,key-aware-env
146+
WINDOW_START_DATE=2026-05-05
147+
ERROR_PERCENT=100
148+
SYNC_HOURS=24
149+
SERVICE_WORKER_CACHE_KEY=v1
150+
PLACEHOLDER_TOKEN=change-me
151+
",
152+
);
153+
154+
let project = discover_project(fixture.path()).expect("project should discover");
155+
let outcome = run_env_check(&project).expect("check should run");
156+
let env_file = fixture.path().join(".env.example");
157+
158+
for key in [
159+
"AUTH_TOKEN",
160+
"SUPABASE_SERVICE_KEY",
161+
"SUPABASE_SERVICE_ROLE_KEY",
162+
"GOOGLE_SERVICE_ACCOUNT_KEY",
163+
"PRIVATE_KEY",
164+
"SHARED",
165+
] {
166+
assert_has_finding(
167+
&outcome.findings,
168+
&format!("env-example-secret:{}:{key}", env_file.to_string_lossy()),
169+
Severity::Warn,
170+
&format!(".env.example appears to contain a real value for \"{key}\""),
171+
"Contract files should describe the interface, not ship concrete secrets.",
172+
"Replace the value with a blank or placeholder string before sharing the repo.",
173+
Some(env_file.clone()),
174+
false,
175+
&[],
176+
);
177+
}
178+
179+
for key in [
180+
"NEXT_PUBLIC_OKTA_CLIENT_ID",
181+
"VALIDATION_ISSUE_REPO",
182+
"VALIDATION_ISSUE_DASHBOARD_URL",
183+
"VALIDATION_LABELS",
184+
"WINDOW_START_DATE",
185+
"ERROR_PERCENT",
186+
"SYNC_HOURS",
187+
"SERVICE_WORKER_CACHE_KEY",
188+
"PLACEHOLDER_TOKEN",
189+
] {
190+
let id = format!("env-example-secret:{}:{key}", env_file.to_string_lossy());
191+
assert!(
192+
!outcome.findings.iter().any(|finding| finding.id == id),
193+
"{key} should not produce env-example-secret"
194+
);
195+
}
196+
}
197+
129198
#[test]
130199
fn env_check_plans_example_creation_when_runtime_env_files_exist_without_contract() {
131200
let fixture = TempDir::new().expect("temp dir should exist");

‎crates/maximus-core/src/env_parser.rs‎

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ pub fn is_concrete_env_file_name(name: &str) -> bool {
189189
is_env_file_name(name) && !is_template_env_file_name(name)
190190
}
191191

192-
pub fn looks_like_secret(value: &str) -> bool {
192+
pub fn looks_like_secret(key: &str, value: &str) -> bool {
193193
if value.is_empty() {
194194
return false;
195195
}
@@ -198,10 +198,11 @@ pub fn looks_like_secret(value: &str) -> bool {
198198
return false;
199199
}
200200

201-
value.len() >= 16
202-
&& value
203-
.chars()
204-
.all(|character| character.is_ascii_alphanumeric() || "/_+=-".contains(character))
201+
if has_high_confidence_secret_value(value) {
202+
return true;
203+
}
204+
205+
is_secret_like_env_key(key)
205206
}
206207

207208
fn split_env_assignment(line: &str) -> Option<(&str, &str)> {
@@ -280,6 +281,89 @@ fn is_placeholder_value(value: &str) -> bool {
280281
.unwrap_or(false)
281282
}
282283

284+
fn has_high_confidence_secret_value(value: &str) -> bool {
285+
let lower = value.to_ascii_lowercase();
286+
287+
value.contains("-----BEGIN PRIVATE KEY-----")
288+
|| lower.starts_with("sk_live_")
289+
|| lower.starts_with("sk_test_")
290+
|| lower.starts_with("ghp_")
291+
|| lower.starts_with("github_pat_")
292+
|| lower.starts_with("xoxb-")
293+
|| lower.starts_with("xoxp-")
294+
|| lower.starts_with("xoxa-")
295+
|| (value.len() == 20
296+
&& value.starts_with("AKIA")
297+
&& value
298+
.chars()
299+
.all(|character| character.is_ascii_uppercase() || character.is_ascii_digit()))
300+
|| (value.len() >= 35
301+
&& value.starts_with("AIza")
302+
&& value
303+
.chars()
304+
.all(|character| character.is_ascii_alphanumeric() || "-_".contains(character)))
305+
}
306+
307+
fn is_secret_like_env_key(key: &str) -> bool {
308+
let segments = env_key_segments(key);
309+
if segments.is_empty() {
310+
return false;
311+
}
312+
313+
if contains_adjacent_segments(&segments, "PRIVATE", "KEY")
314+
|| contains_service_key_segments(&segments)
315+
{
316+
return true;
317+
}
318+
319+
if segments.iter().any(|segment| {
320+
matches!(
321+
segment.as_str(),
322+
"TOKEN" | "SECRET" | "PASSWORD" | "PASSWD" | "PWD"
323+
)
324+
}) {
325+
return true;
326+
}
327+
328+
if contains_adjacent_segments(&segments, "API", "KEY")
329+
|| contains_adjacent_segments(&segments, "ACCESS", "KEY")
330+
{
331+
return !is_public_key_identifier(&segments);
332+
}
333+
334+
false
335+
}
336+
337+
fn env_key_segments(key: &str) -> Vec<String> {
338+
key.split(|character: char| !character.is_ascii_alphanumeric())
339+
.filter(|segment| !segment.is_empty())
340+
.map(|segment| segment.to_ascii_uppercase())
341+
.collect()
342+
}
343+
344+
fn contains_adjacent_segments(segments: &[String], left: &str, right: &str) -> bool {
345+
segments
346+
.windows(2)
347+
.any(|window| window[0] == left && window[1] == right)
348+
}
349+
350+
fn contains_service_key_segments(segments: &[String]) -> bool {
351+
segments
352+
.windows(2)
353+
.any(|window| window[0] == "SERVICE" && window[1] == "KEY")
354+
|| segments.windows(3).any(|window| {
355+
window[0] == "SERVICE"
356+
&& matches!(window[1].as_str(), "ROLE" | "ACCOUNT")
357+
&& window[2] == "KEY"
358+
})
359+
}
360+
361+
fn is_public_key_identifier(segments: &[String]) -> bool {
362+
contains_adjacent_segments(segments, "PUBLIC", "KEY")
363+
|| contains_adjacent_segments(segments, "ANON", "KEY")
364+
|| contains_adjacent_segments(segments, "CLIENT", "ID")
365+
}
366+
283367
fn normalize_env_template_source_group(group: EnvTemplateSourceGroup) -> EnvTemplateSourceGroup {
284368
let mut unique_keys = group
285369
.keys

‎crates/maximus-core/tests/core_models.rs‎

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,65 @@ fn env_helpers_match_current_js_behavior() {
144144
assert!(is_concrete_env_file_name(".env.local"));
145145
assert!(!is_concrete_env_file_name(".env."));
146146
assert!(!is_concrete_env_file_name(".env.template"));
147-
assert!(looks_like_secret("supersecretvalue12345"));
148-
assert!(!looks_like_secret("localhost"));
149-
assert!(!looks_like_secret("your-api-key"));
147+
assert!(looks_like_secret("AUTH_TOKEN", "supersecretvalue12345"));
148+
assert!(!looks_like_secret("AUTH_TOKEN", "localhost"));
149+
assert!(!looks_like_secret("AUTH_TOKEN", "your-api-key"));
150+
}
151+
152+
#[test]
153+
fn env_secret_helper_is_key_aware() {
154+
for (key, value) in [
155+
("AUTH_TOKEN", "github-token"),
156+
("DATABASE_SECRET", "secret-value"),
157+
("SUPABASE_SERVICE_KEY", "service-role-key"),
158+
("SUPABASE_SERVICE_ROLE_KEY", "service-role-key"),
159+
("GOOGLE_SERVICE_ACCOUNT_KEY", "service-account-key"),
160+
("PRIVATE_KEY", "private-key-value"),
161+
("API_KEY", "api-key-value"),
162+
("SHARED", "sk_live_1234567890abcdef"),
163+
] {
164+
assert!(
165+
looks_like_secret(key, value),
166+
"{key}={value} should be treated as a secret-like template value"
167+
);
168+
}
169+
170+
for (key, value) in [
171+
("NEXT_PUBLIC_OKTA_CLIENT_ID", "0oa1234567890abcdefghijkl"),
172+
("VALIDATION_ISSUE_REPO", "JeremyDev87/maximus-audit-signal"),
173+
(
174+
"VALIDATION_ISSUE_DASHBOARD_URL",
175+
"https://github.com/JeremyDev87/maximus/issues/107",
176+
),
177+
("VALIDATION_LABELS", "enhancement,test,key-aware-env"),
178+
("WINDOW_START_DATE", "2026-05-05"),
179+
("ERROR_PERCENT", "100"),
180+
("SYNC_HOURS", "24"),
181+
("SUPABASE_ANON_KEY", "supabase-anon-public-key"),
182+
("PUBLIC_KEY", "public-key-identifier"),
183+
("SERVICE_WORKER_CACHE_KEY", "v1"),
184+
] {
185+
assert!(
186+
!looks_like_secret(key, value),
187+
"{key}={value} should be treated as public/config template data"
188+
);
189+
}
190+
191+
for placeholder in [
192+
"change-me",
193+
"placeholder",
194+
"your-api-key",
195+
"example",
196+
"true",
197+
"false",
198+
"0",
199+
"1",
200+
] {
201+
assert!(
202+
!looks_like_secret("AUTH_TOKEN", placeholder),
203+
"{placeholder} should remain a non-warning placeholder"
204+
);
205+
}
150206
}
151207

152208
#[test]

‎src/checks/env.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export async function runEnvCheck(project) {
181181

182182
for (const contractRecord of contractRecords) {
183183
for (const entry of contractRecord.parsed.entries) {
184-
if (!looksLikeSecret(entry.value)) {
184+
if (!looksLikeSecret(entry.key, entry.value)) {
185185
continue;
186186
}
187187

‎src/lib/env.js‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export function isConcreteEnvFileName(name) {
8585
return /^\.env(?:\..+)?$/u.test(name) && !isTemplateEnvFileName(name);
8686
}
8787

88-
export function looksLikeSecret(value) {
88+
export function looksLikeSecret(key, value) {
8989
if (!value) {
9090
return false;
9191
}
@@ -94,13 +94,71 @@ export function looksLikeSecret(value) {
9494
return false;
9595
}
9696

97-
if (/^[A-Za-z0-9/_+=-]{16,}$/u.test(value)) {
97+
if (hasHighConfidenceSecretValue(value)) {
9898
return true;
9999
}
100100

101+
return isSecretLikeEnvKey(key);
102+
}
103+
104+
function hasHighConfidenceSecretValue(value) {
105+
return (
106+
value.includes("-----BEGIN PRIVATE KEY-----") ||
107+
/^(?:sk_live_|sk_test_|ghp_|github_pat_|xox[abp]-)/iu.test(value) ||
108+
/^AKIA[A-Z0-9]{16}$/u.test(value) ||
109+
/^AIza[A-Za-z0-9_-]{31,}$/u.test(value)
110+
);
111+
}
112+
113+
function isSecretLikeEnvKey(key) {
114+
const segments = key
115+
.split(/[^A-Za-z0-9]+/u)
116+
.filter(Boolean)
117+
.map((segment) => segment.toUpperCase());
118+
119+
if (segments.length === 0) {
120+
return false;
121+
}
122+
123+
if (containsAdjacentSegments(segments, "PRIVATE", "KEY") || containsServiceKeySegments(segments)) {
124+
return true;
125+
}
126+
127+
if (segments.some((segment) => ["TOKEN", "SECRET", "PASSWORD", "PASSWD", "PWD"].includes(segment))) {
128+
return true;
129+
}
130+
131+
if (containsAdjacentSegments(segments, "API", "KEY") || containsAdjacentSegments(segments, "ACCESS", "KEY")) {
132+
return !isPublicKeyIdentifier(segments);
133+
}
134+
101135
return false;
102136
}
103137

138+
function containsAdjacentSegments(segments, left, right) {
139+
return segments.some((segment, index) => segment === left && segments[index + 1] === right);
140+
}
141+
142+
function containsServiceKeySegments(segments) {
143+
return segments.some((segment, index) => {
144+
if (segment !== "SERVICE") {
145+
return false;
146+
}
147+
148+
const next = segments[index + 1];
149+
const following = segments[index + 2];
150+
return next === "KEY" || ((next === "ROLE" || next === "ACCOUNT") && following === "KEY");
151+
});
152+
}
153+
154+
function isPublicKeyIdentifier(segments) {
155+
return (
156+
containsAdjacentSegments(segments, "PUBLIC", "KEY") ||
157+
containsAdjacentSegments(segments, "ANON", "KEY") ||
158+
containsAdjacentSegments(segments, "CLIENT", "ID")
159+
);
160+
}
161+
104162
export function parseExactGitignorePatterns(text) {
105163
return text
106164
.split(/\r?\n/u)

0 commit comments

Comments
 (0)