This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new a16b68f93a16 CAMEL-24220: Add camel_security_scan tool for route
security analysis (#25026)
a16b68f93a16 is described below
commit a16b68f93a16576502ef1062ce4006be7d41cbc7
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Jul 28 09:15:56 2026 +0200
CAMEL-24220: Add camel_security_scan tool for route security analysis
(#25026)
* CAMEL-24220: Add camel_security_scan tool for route security analysis
Adds a new MCP tool that performs static analysis of Camel route definitions
to detect security anti-patterns. Distinct from camel_route_harden_context
(which provides general security context and CVE advisories), this tool
performs line-by-line analysis and returns actionable findings with
severity,
line numbers, and remediation guidance.
Detection categories:
- Insecure options from SecurityUtils (trustAllCertificates,
allowJavaSerializedObject,
transferException, etc. — 24 options across ssl/serialization/dev
categories)
- Plain-text secrets in URIs (password, token, apiKey, etc.)
- Connection strings with embedded credentials (mongodb://, amqp://, etc.)
- Unencrypted protocols (HTTP vs HTTPS, FTP vs SFTP, LDAP vs LDAPS, SMTP vs
SMTPS)
- Missing Camel* header filters on HTTP consumers (CVE-2025-27636 family)
- Command injection via exec component
- SQL injection risk (missing parameterized queries)
- File path traversal with dynamic expressions
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24220: Address review feedback
- Fix RAW() false negative: remove RAW() negative lookahead from
SECRET_IN_URI regex — RAW() is a URI encoding wrapper, not a
security mechanism
- Fix empty insecureValue false positives: skip options with empty
insecureValue to avoid matching any assignment
- Fix SQL scheme false positive on composite schemes: use boundary
check in containsScheme() to avoid matching google-bigquery-sql:
as sql:
- Fix route-level header filter check: scope per-consumer line with
hasHeaderFilterNearby() instead of checking entire route text
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24220: Add regression tests and cleanup
- Add 4 regression tests for fixed bugs: RAW() detection, empty
insecureValue, composite scheme SQL, per-consumer header filter
- Add explicit parentheses in containsScheme() for readability
- Remove dead findLineContaining() method
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24220: delegate insecure-option matching to
SecurityUtils.isInsecureValue
Addresses the remaining gnodet review point on scanInsecureOptions: instead
of
reimplementing the value comparison with lower.contains(optionKey + "=" +
insecureValue) — which either false-positived on empty insecureValue
(sslEndpointAlgorithm= matched any assignment) or, after guarding, skipped
those options entirely — the scanner now extracts the assigned value and
calls
SecurityUtils.isInsecureValue(). That is the canonical check:
case-insensitive,
and it models the sslEndpointAlgorithm case where the empty/none/false
value is
the insecure one. So sslEndpointAlgorithm=HTTPS is not flagged while
sslEndpointAlgorithm=none now is (previously silently dropped).
Adds flagsSslEndpointAlgorithmDisabled to pin the restored coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---------
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
---
.../jbang/core/commands/mcp/SecurityScanTools.java | 379 ++++++++++++++++++
.../core/commands/mcp/SecurityScanToolsTest.java | 424 +++++++++++++++++++++
2 files changed, 803 insertions(+)
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanTools.java
new file mode 100644
index 000000000000..55a56b6cb54f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanTools.java
@@ -0,0 +1,379 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.mcp;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import io.quarkiverse.mcp.server.Tool;
+import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkiverse.mcp.server.ToolCallException;
+import org.apache.camel.util.SecurityUtils;
+
+/**
+ * MCP Tool for static security analysis of Camel route definitions.
+ * <p>
+ * Scans route content (YAML, XML, or Java DSL) for security anti-patterns
defined in the Camel security model
+ * ({@code design/security.adoc}). Produces actionable findings with severity,
line location, and remediation guidance.
+ * <p>
+ * Distinct from {@link HardenTools} which provides general security context
and CVE advisories — this tool performs
+ * line-by-line static analysis to find specific violations.
+ */
+@ApplicationScoped
+public class SecurityScanTools {
+
+ private static final Pattern SECRET_IN_URI = Pattern.compile(
+
"(?i)(password|passwd|pwd|secret|token|apikey|api[_-]?key|access[_-]?key)\\s*[=:]\\s*(?!\\{\\{)(?!\\$\\{)([^\\s,;}'\"\\]&]+)");
+
+ private static final Pattern CONNECTION_STRING_CREDS = Pattern.compile(
+ "(?i)(mongodb(\\+srv)?://|amqp://|redis://|jdbc:)\\S+:\\S+@");
+
+ private static final String[] CONSUMER_COMPONENTS = {
+ "platform-http", "netty-http", "jetty", "undertow", "servlet",
+ "cxf", "cxfrs", "rest", "coap", "grpc"
+ };
+
+ @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
+ description = "Scan a Camel route for security anti-patterns. "
+ + "Performs static analysis to detect: exposed secrets
in URIs, "
+ + "insecure configuration options
(trustAllCertificates, allowJavaSerializedObject, etc.), "
+ + "missing Camel* header filters on consumers,
unencrypted protocols, "
+ + "and other violations from the Camel security model.
"
+ + "Returns findings with severity, line number, and
remediation guidance. "
+ + "Distinct from camel_route_harden_context which
provides general security context "
+ + "and CVE advisories.")
+ public SecurityScanResult camel_security_scan(
+ @ToolArg(description = "The Camel route content (YAML, XML, or
Java DSL)") String route,
+ @ToolArg(description = "Route format: yaml, xml, or java (default:
yaml)") String format) {
+
+ if (route == null || route.isBlank()) {
+ throw new ToolCallException("Route content is required", null);
+ }
+
+ try {
+ String resolvedFormat = format != null && !format.isBlank() ?
format.toLowerCase(Locale.ROOT) : "yaml";
+ String[] lines = route.split("\n", -1);
+
+ List<Finding> findings = new ArrayList<>();
+
+ scanInsecureOptions(lines, findings);
+ scanSecretsInUris(lines, findings);
+ scanConnectionStringCredentials(lines, findings);
+ scanUnencryptedProtocols(lines, findings);
+ scanMissingHeaderFilters(route, lines, findings);
+ scanExecComponent(lines, findings);
+ scanSqlInjection(lines, findings);
+ scanFilePathTraversal(lines, findings);
+
+ findings.sort((a, b) -> severityRank(a.severity()) -
severityRank(b.severity()));
+
+ int critical = (int) findings.stream().filter(f ->
"critical".equals(f.severity())).count();
+ int high = (int) findings.stream().filter(f ->
"high".equals(f.severity())).count();
+ int medium = (int) findings.stream().filter(f ->
"medium".equals(f.severity())).count();
+ int low = (int) findings.stream().filter(f ->
"low".equals(f.severity())).count();
+
+ return new SecurityScanResult(
+ resolvedFormat, findings, findings.size(),
+ new SeverityCounts(critical, high, medium, low));
+ } catch (ToolCallException e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new ToolCallException(
+ "Failed to scan route (" + e.getClass().getName() + "): "
+ e.getMessage(), null);
+ }
+ }
+
+ private void scanInsecureOptions(String[] lines, List<Finding> findings) {
+ Map<String, SecurityUtils.SecurityOption> securityOptions =
SecurityUtils.getSecurityOptions();
+
+ for (int i = 0; i < lines.length; i++) {
+ String normalized =
lines[i].toLowerCase(Locale.ROOT).replaceAll("[\\s-]", "");
+ for (Map.Entry<String, SecurityUtils.SecurityOption> entry :
securityOptions.entrySet()) {
+ String optionKey = entry.getKey();
+ String category = entry.getValue().category();
+
+ String value = extractOptionValue(normalized, optionKey);
+ if (value == null) {
+ continue;
+ }
+ // Delegate the insecure-value decision to SecurityUtils
rather than reimplementing string
+ // comparison here. That keeps the scanner in sync with the
canonical logic — case-insensitive
+ // matching, and the sslEndpointAlgorithm case where the
*empty*/none/false value is the insecure
+ // one — so an empty insecureValue no longer either
false-positives on any assignment nor gets
+ // skipped entirely.
+ if (SecurityUtils.isInsecureValue(optionKey, value)) {
+ findings.add(new Finding(
+ severityForCategory(category),
+ category,
+ "Insecure option: " + optionKey + "=" + value,
+ i + 1,
+ remediationForCategory(category, optionKey)));
+ }
+ }
+ }
+ }
+
+ /**
+ * Extract the value assigned to {@code optionKey} on a normalized
(lowercased, whitespace/dash-stripped) line,
+ * covering the URI-query ({@code key=value}) and YAML/JSON ({@code
key:value}, {@code "key":value}) forms. Returns
+ * {@code null} when the option does not appear with an assignment on the
line, or an empty string when it is
+ * assigned an empty value — which is itself the insecure case for some
options (e.g. sslEndpointAlgorithm).
+ */
+ private static String extractOptionValue(String normalized, String
optionKey) {
+ int from = 0;
+ while (true) {
+ int idx = normalized.indexOf(optionKey, from);
+ if (idx < 0) {
+ return null;
+ }
+ int after = idx + optionKey.length();
+ // a quoted key ("key":value) leaves a closing quote before the
separator
+ if (after < normalized.length() && normalized.charAt(after) ==
'"') {
+ after++;
+ }
+ if (after < normalized.length()
+ && (normalized.charAt(after) == '=' ||
normalized.charAt(after) == ':')) {
+ int valStart = after + 1;
+ int valEnd = valStart;
+ while (valEnd < normalized.length() &&
"=:,;}'\"]&".indexOf(normalized.charAt(valEnd)) < 0) {
+ valEnd++;
+ }
+ return normalized.substring(valStart, valEnd);
+ }
+ from = idx + 1;
+ }
+ }
+
+ private void scanSecretsInUris(String[] lines, List<Finding> findings) {
+ for (int i = 0; i < lines.length; i++) {
+ Matcher matcher = SECRET_IN_URI.matcher(lines[i]);
+ while (matcher.find()) {
+ String key = matcher.group(1);
+ findings.add(new Finding(
+ "critical",
+ "secret",
+ "Potential plain-text secret in URI: " + key,
+ i + 1,
+ "Use property placeholders {{" + key + "}} or a
secrets management vault "
+ + "(HashiCorp Vault, AWS Secrets Manager, Azure
Key Vault)"));
+ }
+ }
+ }
+
+ private void scanConnectionStringCredentials(String[] lines, List<Finding>
findings) {
+ for (int i = 0; i < lines.length; i++) {
+ if (CONNECTION_STRING_CREDS.matcher(lines[i]).find()) {
+ findings.add(new Finding(
+ "critical",
+ "secret",
+ "Connection string with embedded credentials",
+ i + 1,
+ "Extract credentials from the connection string and
use property placeholders or vault services"));
+ }
+ }
+ }
+
+ private void scanUnencryptedProtocols(String[] lines, List<Finding>
findings) {
+ for (int i = 0; i < lines.length; i++) {
+ String lower = lines[i].toLowerCase(Locale.ROOT);
+
+ if (containsScheme(lower, "http") && !containsScheme(lower,
"https")
+ && !lower.contains("platform-http")) {
+ findings.add(new Finding(
+ "high",
+ "insecure:ssl",
+ "Using HTTP instead of HTTPS",
+ i + 1,
+ "Use HTTPS with TLS 1.2+ for secure communication"));
+ }
+ if (containsScheme(lower, "ftp") && !containsScheme(lower, "sftp")
&& !containsScheme(lower, "ftps")) {
+ findings.add(new Finding(
+ "high",
+ "insecure:ssl",
+ "Using plain FTP instead of SFTP/FTPS",
+ i + 1,
+ "Use SFTP or FTPS for encrypted file transfers"));
+ }
+ if (containsScheme(lower, "ldap") && !containsScheme(lower,
"ldaps")) {
+ findings.add(new Finding(
+ "medium",
+ "insecure:ssl",
+ "Using LDAP instead of LDAPS",
+ i + 1,
+ "Use LDAPS for encrypted LDAP communication"));
+ }
+ if (containsScheme(lower, "smtp") && !containsScheme(lower,
"smtps")) {
+ findings.add(new Finding(
+ "medium",
+ "insecure:ssl",
+ "Using SMTP instead of SMTPS",
+ i + 1,
+ "Use SMTPS for encrypted email communication"));
+ }
+ }
+ }
+
+ private void scanMissingHeaderFilters(String route, String[] lines,
List<Finding> findings) {
+ for (int i = 0; i < lines.length; i++) {
+ String lower = lines[i].toLowerCase(Locale.ROOT);
+ for (String consumer : CONSUMER_COMPONENTS) {
+ if (containsScheme(lower, consumer)) {
+ if (!hasHeaderFilterNearby(lines, i)) {
+ findings.add(new Finding(
+ "high",
+ "header-injection",
+ "Consumer '" + consumer + "' without Camel*
header filter",
+ i + 1,
+ "Add removeHeaders(\"Camel*\") or a
HeaderFilterStrategy to prevent "
+ + "external clients from injecting
Camel-internal headers "
+ + "(CVE-2025-27636 and related)"));
+ }
+ }
+ }
+ }
+ }
+
+ private boolean hasHeaderFilterNearby(String[] lines, int consumerLine) {
+ int searchEnd = Math.min(lines.length, consumerLine + 20);
+ for (int i = consumerLine; i < searchEnd; i++) {
+ String lower = lines[i].toLowerCase(Locale.ROOT);
+ if (lower.contains("removeheaders") ||
lower.contains("headerfilter")
+ || lower.contains("\"camel*\"") ||
lower.contains("'camel*'")) {
+ return true;
+ }
+ if (i > consumerLine && containsAnyConsumerScheme(lower)) {
+ break;
+ }
+ }
+ return false;
+ }
+
+ private boolean containsAnyConsumerScheme(String lower) {
+ for (String consumer : CONSUMER_COMPONENTS) {
+ if (containsScheme(lower, consumer)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void scanExecComponent(String[] lines, List<Finding> findings) {
+ for (int i = 0; i < lines.length; i++) {
+ if (containsScheme(lines[i].toLowerCase(Locale.ROOT), "exec")) {
+ findings.add(new Finding(
+ "critical",
+ "command-injection",
+ "Using exec component — high risk for command
injection",
+ i + 1,
+ "Validate all inputs strictly. Consider safer
alternatives. "
+ + "Never pass untrusted input directly to
exec"));
+ }
+ }
+ }
+
+ private void scanSqlInjection(String[] lines, List<Finding> findings) {
+ for (int i = 0; i < lines.length; i++) {
+ String lower = lines[i].toLowerCase(Locale.ROOT);
+ if (containsScheme(lower, "sql") && !lower.contains(":#") &&
!lower.contains(":?")) {
+ findings.add(new Finding(
+ "high",
+ "sql-injection",
+ "SQL query may not use parameterized queries",
+ i + 1,
+ "Use parameterized queries with named parameters
(:#param) or positional (:?)"));
+ }
+ }
+ }
+
+ private void scanFilePathTraversal(String[] lines, List<Finding> findings)
{
+ for (int i = 0; i < lines.length; i++) {
+ String lower = lines[i].toLowerCase(Locale.ROOT);
+ if (containsScheme(lower, "file") && (lower.contains("${") ||
lower.contains("$simple{"))) {
+ findings.add(new Finding(
+ "medium",
+ "path-traversal",
+ "File path contains dynamic expression — potential
path traversal risk",
+ i + 1,
+ "Validate file paths and restrict to allowed
directories using fileName option"));
+ }
+ }
+ }
+
+ private static boolean containsScheme(String text, String scheme) {
+ int idx = text.indexOf(scheme + ":");
+ if (idx >= 0) {
+ if (idx == 0 || (!Character.isLetterOrDigit(text.charAt(idx - 1))
&& text.charAt(idx - 1) != '-')) {
+ return true;
+ }
+ }
+ return text.contains("\"" + scheme + "\"") || text.contains("'" +
scheme + "'");
+ }
+
+ private static String severityForCategory(String category) {
+ return switch (category) {
+ case "insecure:serialization" -> "critical";
+ case "insecure:ssl" -> "high";
+ case "insecure:dev" -> "medium";
+ case "secret" -> "critical";
+ default -> "medium";
+ };
+ }
+
+ private static String remediationForCategory(String category, String
optionKey) {
+ return switch (category) {
+ case "insecure:ssl" -> "Remove " + optionKey + " or set it to a
secure value. "
+ + "Configure TLS via SSLContextParameters
with proper certificate validation";
+ case "insecure:serialization" -> "Remove " + optionKey + " or set
it to a secure value. "
+ + "Java serialization of
untrusted input enables remote code execution";
+ case "insecure:dev" -> "Disable " + optionKey + " in production. "
+ + "Development features can expose internal
state or enable remote control";
+ case "secret" -> "Use property placeholders or vault services
instead of plain-text secrets";
+ default -> "Review and correct the insecure configuration";
+ };
+ }
+
+ private static int severityRank(String severity) {
+ return switch (severity) {
+ case "critical" -> 0;
+ case "high" -> 1;
+ case "medium" -> 2;
+ case "low" -> 3;
+ default -> 4;
+ };
+ }
+
+ // Result records
+
+ public record SecurityScanResult(
+ String format, List<Finding> findings, int totalFindings,
+ SeverityCounts severityCounts) {
+ }
+
+ public record Finding(
+ String severity, String category, String issue,
+ int line, String remediation) {
+ }
+
+ public record SeverityCounts(int critical, int high, int medium, int low) {
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanToolsTest.java
new file mode 100644
index 000000000000..2eb0b5f4d247
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/SecurityScanToolsTest.java
@@ -0,0 +1,424 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.mcp;
+
+import io.quarkiverse.mcp.server.ToolCallException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SecurityScanToolsTest {
+
+ private final SecurityScanTools tools = new SecurityScanTools();
+
+ @Test
+ void nullRouteThrowsException() {
+ assertThatThrownBy(() -> tools.camel_security_scan(null, null))
+ .isInstanceOf(ToolCallException.class)
+ .hasMessageContaining("required");
+ }
+
+ @Test
+ void blankRouteThrowsException() {
+ assertThatThrownBy(() -> tools.camel_security_scan(" ", null))
+ .isInstanceOf(ToolCallException.class);
+ }
+
+ @Test
+ void cleanRouteProducesNoFindings() {
+ String route = """
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - log: "Hello"
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.totalFindings()).isZero();
+ assertThat(result.format()).isEqualTo("yaml");
+ }
+
+ @Test
+ void detectsTrustAllCertificates() {
+ String route = """
+ - route:
+ from:
+ uri: https://api.example.com?trustAllCertificates=true
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.issue().contains("trustallcertificates")
+ && f.category().equals("insecure:ssl"));
+ }
+
+ @Test
+ void detectsAllowJavaSerializedObject() {
+ String route = """
+ - route:
+ from:
+ uri: jms:queue:orders?allowJavaSerializedObject=true
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.issue().contains("allowjavaserializedobject")
+ && f.category().equals("insecure:serialization")
+ && f.severity().equals("critical"));
+ }
+
+ @Test
+ void detectsTransferExceptionEnabled() {
+ String route = """
+ - route:
+ from:
+ uri: netty-http:0.0.0.0:8080?transferException=true
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.issue().contains("transferexception"));
+ }
+
+ @Test
+ void detectsPlainTextPasswordInUri() {
+ String route = """
+ - route:
+ from:
+ uri: kafka:topic?password=mysecret123&brokers=localhost
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.category().equals("secret")
+ && f.severity().equals("critical")
+ && f.issue().contains("password"));
+ }
+
+ @Test
+ void doesNotFlagPlaceholderPassword() {
+ String route = """
+ - route:
+ from:
+ uri:
kafka:topic?password={{kafka.password}}&brokers=localhost
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).noneMatch(f ->
f.category().equals("secret")
+ && f.issue().contains("password"));
+ }
+
+ @Test
+ void detectsConnectionStringWithCredentials() {
+ String route = """
+ - route:
+ from:
+ uri: mongodb:myDb?connectionBean=#mongoClient
+ steps:
+ - setBody:
+ constant: "mongodb://admin:secret@host:27017/db"
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.issue().contains("Connection string with embedded credentials"));
+ }
+
+ @Test
+ void detectsHttpInsteadOfHttps() {
+ String route = """
+ - route:
+ from:
+ uri: http://api.example.com/data
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f -> f.issue().contains("HTTP
instead of HTTPS")
+ && f.severity().equals("high"));
+ }
+
+ @Test
+ void doesNotFlagHttpsAsInsecure() {
+ String route = """
+ - route:
+ from:
+ uri: https://api.example.com/data
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).noneMatch(f -> f.issue().contains("HTTP
instead of HTTPS"));
+ }
+
+ @Test
+ void detectsPlainFtp() {
+ String route = """
+ - route:
+ from:
+ uri: ftp://server/files
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f -> f.issue().contains("plain
FTP"));
+ }
+
+ @Test
+ void doesNotFlagSftp() {
+ String route = """
+ - route:
+ from:
+ uri: sftp://server/files
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).noneMatch(f -> f.issue().contains("plain
FTP"));
+ }
+
+ @Test
+ void detectsMissingHeaderFilterOnConsumer() {
+ String route = """
+ - route:
+ from:
+ uri: platform-http:/api/data
+ steps:
+ - log: "received"
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.category().equals("header-injection")
+ && f.issue().contains("platform-http"));
+ }
+
+ @Test
+ void headerFilterPresentSuppressesFinding() {
+ String route = """
+ - route:
+ from:
+ uri: platform-http:/api/data
+ steps:
+ - removeHeaders: "Camel*"
+ - log: "received"
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).noneMatch(f ->
f.category().equals("header-injection"));
+ }
+
+ @Test
+ void detectsExecComponent() {
+ String route = """
+ - route:
+ from:
+ uri: direct:run
+ steps:
+ - to: exec:ls
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.category().equals("command-injection")
+ && f.severity().equals("critical"));
+ }
+
+ @Test
+ void detectsSqlWithoutParameterizedQueries() {
+ String route = """
+ - route:
+ from:
+ uri: "sql:SELECT * FROM users WHERE name = ${body}"
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.category().equals("sql-injection"));
+ }
+
+ @Test
+ void detectsFilePathTraversal() {
+ String route = """
+ - route:
+ from:
+ uri: "file:/data/${header.dir}"
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.category().equals("path-traversal"));
+ }
+
+ @Test
+ void findingsAreSortedBySeverity() {
+ String route = """
+ - route:
+ from:
+ uri:
http://api.example.com?password=secret&allowJavaSerializedObject=true
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).isNotEmpty();
+ for (int i = 1; i < result.findings().size(); i++) {
+ String prev = result.findings().get(i - 1).severity();
+ String curr = result.findings().get(i).severity();
+ assertThat(severityRank(prev))
+ .as("findings should be sorted by severity: %s before %s",
prev, curr)
+ .isLessThanOrEqualTo(severityRank(curr));
+ }
+ }
+
+ @Test
+ void severityCountsAreAccurate() {
+ String route = """
+ - route:
+ from:
+ uri:
http://api.example.com?password=secret&allowJavaSerializedObject=true
+ steps:
+ - to: exec:cmd
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.severityCounts().critical()).isGreaterThan(0);
+ assertThat(result.totalFindings())
+ .isEqualTo(result.severityCounts().critical() +
result.severityCounts().high()
+ + result.severityCounts().medium() +
result.severityCounts().low());
+ }
+
+ @Test
+ void defaultFormatIsYaml() {
+ String route = "from: timer:tick";
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, null);
+ assertThat(result.format()).isEqualTo("yaml");
+ }
+
+ @Test
+ void detectsUnencryptedSmtp() {
+ String route = """
+ - route:
+ from:
+ uri: smtp://mail.example.com
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f -> f.issue().contains("SMTP
instead of SMTPS"));
+ }
+
+ @Test
+ void detectsDevConsoleEnabled() {
+ String route = """
+ - route:
+ from:
+ uri: timer:tick?devConsoleEnabled=true
+ """;
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings()).anyMatch(f ->
f.category().equals("insecure:dev"));
+ }
+
+ @Test
+ void lineNumbersAreCorrect() {
+ String route = "line1\nline2\npassword=mysecret\nline4";
+
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings())
+ .filteredOn(f -> f.category().equals("secret"))
+ .allMatch(f -> f.line() == 3);
+ }
+
+ @Test
+ void detectsRawWrappedPasswordAsPlainTextSecret() {
+ String route = "- route:\n from:\n uri:
\"kafka:topic?password=RAW(mysecret123)\"";
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings())
+ .anyMatch(f -> "secret".equals(f.category()) &&
f.issue().contains("password"));
+ }
+
+ @Test
+ void doesNotFlagSecureOptionValuesAsInsecure() {
+ String route = "- route:\n from:\n uri:
\"https:endpoint?sslEndpointAlgorithm=HTTPS\"";
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings())
+ .noneMatch(f -> f.issue().contains("sslendpointalgorithm"));
+ }
+
+ @Test
+ void flagsSslEndpointAlgorithmDisabled() {
+ // sslEndpointAlgorithm is the option with an empty insecureValue: the
insecure case is the empty/none/false
+ // value (no hostname verification), which
SecurityUtils.isInsecureValue models. The scanner must flag it.
+ String route = "- route:\n from:\n uri:
\"https:endpoint?sslEndpointAlgorithm=none\"";
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings())
+ .anyMatch(f -> f.issue().contains("sslendpointalgorithm") &&
"insecure:ssl".equals(f.category()));
+ }
+
+ @Test
+ void doesNotFlagCompositeSchemeAsSql() {
+ String route = "- route:\n from:\n uri:
\"google-bigquery-sql:project:dataset.table\"";
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings())
+ .noneMatch(f -> "sql-injection".equals(f.category()));
+ }
+
+ @Test
+ void perConsumerHeaderFilterCheckDoesNotSuppressGlobally() {
+ String route = """
+ - route:
+ from:
+ uri: "platform-http:/api/a"
+ steps:
+ - removeHeaders:
+ pattern: "Camel*"
+ - to: "direct:a"
+ - route:
+ from:
+ uri: "netty-http:0.0.0.0:8080/api/b"
+ steps:
+ - to: "direct:b"
+ """;
+ SecurityScanTools.SecurityScanResult result =
tools.camel_security_scan(route, "yaml");
+
+ assertThat(result.findings())
+ .anyMatch(f -> "header-injection".equals(f.category())
+ && f.issue().contains("netty-http"));
+ }
+
+ private static int severityRank(String severity) {
+ return switch (severity) {
+ case "critical" -> 0;
+ case "high" -> 1;
+ case "medium" -> 2;
+ case "low" -> 3;
+ default -> 4;
+ };
+ }
+}