diff --git a/src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs b/src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs index 7b832a7..f2c8742 100644 --- a/src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs +++ b/src/Ivy.StackAnalyzer.Test/ComponentDetectorTests.cs @@ -76,4 +76,39 @@ public void Root_component_always_exists() repo.Write("readme.md", "# hi\n"); // no manifests at all Assert.Contains(Detect(repo), c => c.RelativePath == "."); } + + [Fact] + public void Compose_environment_keys_become_env_var_names() + { + // Services like Supabase/Slack are often wired via docker-compose env keys + // with no SDK dependency. Those keys must surface in EnvVarNames so the + // existing dotenv-prefixed detectors can fire. + using var repo = new TempRepo(); + repo.Write("package.json", """{ "name": "root" }""") + .Write("docker-compose.yaml", """ + services: + api: + image: node:20 + environment: + - SUPABASE_URL=http://localhost:54321 + - SLACK_WEBHOOK_URL + ports: + - "3000:3000" + worker: + image: node:20 + environment: + POSTHOG_API_KEY: phc_xxx + POSTHOG_HOST: https://eu.posthog.com + """); + + var root = Detect(repo).Single(c => c.RelativePath == "."); + + Assert.Contains("SUPABASE_URL", root.EnvVarNames); + Assert.Contains("SLACK_WEBHOOK_URL", root.EnvVarNames); + Assert.Contains("POSTHOG_API_KEY", root.EnvVarNames); + Assert.Contains("POSTHOG_HOST", root.EnvVarNames); + // keys outside an `environment:` block (e.g. ports) must not leak in + Assert.DoesNotContain("3000", root.EnvVarNames); + Assert.DoesNotContain("ports", root.EnvVarNames); + } } diff --git a/src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs b/src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs index 5630d4c..f3f5458 100644 --- a/src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs +++ b/src/Ivy.StackAnalyzer.Test/RuleEngineTests.cs @@ -105,4 +105,19 @@ public void Strong_match_keeps_declared_confidence() var react = engine.Detect(Ctx(deps: [Npm("react")])).Single(t => t.Name == "React"); Assert.Equal(Confidence.High, react.Confidence); } + + [Theory] + [InlineData("SUPABASE_URL", "Supabase")] + [InlineData("SLACK_WEBHOOK_URL", "Slack")] + [InlineData("POSTHOG_API_KEY", "PostHog")] + public void Detects_env_wired_saas_from_env_var_names(string envVar, string expectedName) + { + // These hosted services are commonly wired via env vars / compose with no SDK + // dependency; the dotenv-prefixed detectors should still surface them, but as a + // weak (Low) signal so hash/digest consumers can drop the noise. + var engine = new RuleEngine(Data); + var hit = engine.Detect(Ctx(envVars: [envVar])).FirstOrDefault(t => t.Name == expectedName); + Assert.NotNull(hit); + Assert.Equal(Confidence.Low, hit!.Confidence); + } } diff --git a/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs b/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs index e30a385..6cca97d 100644 --- a/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs +++ b/src/Ivy.StackAnalyzer/Components/ComponentDetector.cs @@ -137,23 +137,93 @@ private static IReadOnlySet ReadEnvVarNames(List files) var names = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var f in files) { - if (!f.File.FileName.StartsWith(".env", StringComparison.OrdinalIgnoreCase)) continue; - try + if (f.File.FileName.StartsWith(".env", StringComparison.OrdinalIgnoreCase)) + ReadDotenvKeys(f.File.FullPath, names); + else if (IsComposeFile(f.File.FileName)) + ReadComposeEnvKeys(f.File.FullPath, names); + } + return names; + } + + private static void ReadDotenvKeys(string fullPath, HashSet names) + { + try + { + foreach (var raw in File.ReadLines(fullPath)) { - foreach (var raw in File.ReadLines(f.File.FullPath)) - { - var line = raw.Trim(); - if (line.Length == 0 || line.StartsWith('#')) continue; - var eq = line.IndexOf('='); - if (eq <= 0) continue; - var key = line[..eq].Trim(); - if (key.StartsWith("export ")) key = key["export ".Length..].Trim(); - if (key.Length > 0) names.Add(key); - } + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith('#')) continue; + var eq = line.IndexOf('='); + if (eq <= 0) continue; + var key = line[..eq].Trim(); + if (key.StartsWith("export ")) key = key["export ".Length..].Trim(); + if (key.Length > 0) names.Add(key); } - catch (IOException) { } } - return names; + catch (IOException) { } + } + + // Many hosted services (e.g. Supabase, Slack webhooks) are wired purely through + // docker-compose `environment:` keys with no SDK dependency. Harvesting those key + // names feeds the same `dotenv` match source as .env files, so the existing + // dotenv-prefixed detectors fire for compose-wired services too. + private static bool IsComposeFile(string fileName) + => (fileName.StartsWith("docker-compose", StringComparison.OrdinalIgnoreCase) + || fileName.StartsWith("compose", StringComparison.OrdinalIgnoreCase)) + && (fileName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase)); + + private static void ReadComposeEnvKeys(string fullPath, HashSet names) + { + string[] lines; + try { lines = File.ReadAllLines(fullPath); } + catch (IOException) { return; } + + bool inEnv = false; + int envIndent = -1; // indentation of the `environment:` key + foreach (var raw in lines) + { + if (raw.TrimStart().StartsWith('#')) continue; + var trimmed = raw.Trim(); + if (trimmed.Length == 0) continue; + var indent = CountIndent(raw); + + if (!inEnv) + { + if (IsEnvironmentHeader(trimmed)) { inEnv = true; envIndent = indent; } + continue; + } + + // A line indented no further than `environment:` ends the block. + if (indent <= envIndent) + { + inEnv = IsEnvironmentHeader(trimmed); + if (inEnv) envIndent = indent; + continue; + } + + // List form: - KEY=value | - KEY + // Map form: KEY: value + var entry = trimmed; + if (entry == "-") continue; + if (entry.StartsWith("- ")) entry = entry[2..].Trim(); + + var sep = entry.IndexOfAny(['=', ':']); + var key = (sep < 0 ? entry : entry[..sep]).Trim().Trim('"', '\''); + if (key.Length > 0) names.Add(key); + } + } + + private static bool IsEnvironmentHeader(string trimmed) + => trimmed == "environment:" + || (trimmed.StartsWith("environment:", StringComparison.Ordinal) + && trimmed[12..].TrimStart() is "" or "{}" or "[]"); + + private static int CountIndent(string line) + { + int i = 0; + while (i < line.Length && line[i] == ' ') i++; + return i; } private static string NearestRoot(string relativePath, List sortedRootsDesc)