Repository: kestra-io/kestra (java)

Issue:
Add SECRET-type input
### Feature description

Currently, when we want to have a flow input that holds sensitive information (password, secrets, ...) it will be displayed in the flow execution form when entering the input and in the execution overview tab in the inputs section.

It would be great for security reason to hide the sensitive information from the UI.

## Implementation idea:
Provide a `SECRET` input type, this type of input will not be shown in the UI and will have a password input in the execution form when entering the input value.
We can also mandate base64 of the input but it may makes thinks complicated.

An alternative would be to add a `sensitive=true` property to all inputs making it available for all type of inputs but there is little advantage as a date for ex is not sensitive and a string can still holds any type of value.

Pull request description:
feat: new SECRET input type
Fixes #1442

With this PR, a new SECRET input type is introduced.

A secret input is encoded at execution created time and decoded only when creating the run context, i.e. only in memory inside the executor or the worker before executing a task when computing the list of input variables.

Patch:
diff --git a/core/src/main/java/io/kestra/core/models/flows/Input.java b/core/src/main/java/io/kestra/core/models/flows/Input.java
index 8110a3d05..6028a17a5 100644
--- a/core/src/main/java/io/kestra/core/models/flows/Input.java
+++ b/core/src/main/java/io/kestra/core/models/flows/Input.java
@@ -32,6 +32,7 @@ import jakarta.validation.constraints.Pattern;
     @JsonSubTypes.Type(value = FloatInput.class, name = "FLOAT"),
     @JsonSubTypes.Type(value = IntInput.class, name = "INT"),
     @JsonSubTypes.Type(value = JsonInput.class, name = "JSON"),
+    @JsonSubTypes.Type(value = SecretInput.class, name = "SECRET"),
     @JsonSubTypes.Type(value = StringInput.class, name = "STRING"),
     @JsonSubTypes.Type(value = TimeInput.class, name = "TIME"),
     @JsonSubTypes.Type(value = URIInput.class, name = "URI")
@@ -76,7 +77,8 @@ public abstract class Input<T> {
         DURATION(DurationInput.class.getName()),
         FILE(FileInput.class.getName()),
         JSON(JsonInput.class.getName()),
-        URI(URIInput.class.getName());
+        URI(URIInput.class.getName()),
+        SECRET(SecretInput.class.getName());
 
         private final String clsName;
 
diff --git a/core/src/main/java/io/kestra/core/models/flows/input/SecretInput.java b/core/src/main/java/io/kestra/core/models/flows/input/SecretInput.java
new file mode 100644
index 000000000..4ee85aee1
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/models/flows/input/SecretInput.java
@@ -0,0 +1,38 @@
+package io.kestra.core.models.flows.input;
+
+import io.kestra.core.models.flows.Input;
+import io.kestra.core.models.validations.ManualConstraintViolation;
+import io.kestra.core.validations.Regex;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.ConstraintViolationException;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.experimental.SuperBuilder;
+
+import java.util.Set;
+import java.util.regex.Pattern;
+
+@SuperBuilder
+@Getter
+@NoArgsConstructor
+public class SecretInput extends Input<String> {
+    @Schema(
+        title = "Regular expression validating the value."
+    )
+    @Regex
+    String validator;
+
+    @Override
+    public void validate(String input) throws ConstraintViolationException {
+        if (validator != null && ! Pattern.matches(validator, input)) {
+            throw new ConstraintViolationException("Invalid input '" + input + "', it must match the pattern '" + validator + "'",
+                Set.of(ManualConstraintViolation.of(
+                    "Invalid input",
+                    this,
+                    SecretInput.class,
+                    getId(),
+                    input
+                )));
+        }
+    }
+}
diff --git a/core/src/main/java/io/kestra/core/runners/RunContext.java b/core/src/main/java/io/kestra/core/runners/RunContext.java
index 71a664afc..676d3e8e5 100644
--- a/core/src/main/java/io/kestra/core/runners/RunContext.java
+++ b/core/src/main/java/io/kestra/core/runners/RunContext.java
@@ -1,11 +1,11 @@
 package io.kestra.core.runners;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CaseFormat;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
+import io.kestra.core.crypto.CryptoService;
 import io.kestra.core.exceptions.IllegalVariableEvaluationException;
 import io.kestra.core.metrics.MetricRegistry;
 import io.kestra.core.models.executions.AbstractMetricEntry;
@@ -13,6 +13,8 @@ import io.kestra.core.models.executions.Execution;
 import io.kestra.core.models.executions.LogEntry;
 import io.kestra.core.models.executions.TaskRun;
 import io.kestra.core.models.flows.Flow;
+import io.kestra.core.models.flows.Input;
+import io.kestra.core.models.flows.input.SecretInput;
 import io.kestra.core.models.tasks.Task;
 import io.kestra.core.models.triggers.AbstractTrigger;
 import io.kestra.core.models.triggers.TriggerContext;
@@ -36,13 +38,8 @@ import java.io.InputStream;
 import java.net.URI;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.AbstractMap;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
+import java.security.GeneralSecurityException;
+import java.util.*;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -64,6 +61,7 @@ public class RunContext {
     protected transient Path temporaryDirectory;
     private String triggerExecutionId;
     private Storage storage;
+    private CryptoService cryptoService;
 
     /**
      * Only used by {@link io.kestra.core.models.triggers.types.Flow}
@@ -137,6 +135,7 @@ public class RunContext {
         this.storageInterface = applicationContext.findBean(StorageInterface.class).orElse(null);
         this.meterRegistry = applicationContext.findBean(MetricRegistry.class).orElseThrow();
         this.runContextCache = applicationContext.findBean(RunContextCache.class).orElseThrow();
+        this.cryptoService = applicationContext.findBean(CryptoService.class).orElseThrow();
         this.tempBasedPath = Path.of(applicationContext
             .getProperty("kestra.tasks.tmp-dir.path", String.class)
             .orElse(System.getProperty("java.io.tmpdir"))
@@ -286,7 +285,21 @@ public class RunContext {
             }
 
             if (execution.getInputs() != null) {
-                builder.put("inputs", execution.getInputs());
+                Map<String, Object> inputs = new HashMap<>(execution.getInputs());
+                if (flow != null && flow.getInputs() != null) {
+                    // if some inputs are of type secret, we decode them
+                    for (Input<?> input : flow.getInputs()) {
+                        if (input instanceof SecretInput && inputs.containsKey(input.getId())) {
+                            try {
+                                String decoded = cryptoService.decrypt(((String) inputs.get(input.getId())));
+                                inputs.put(input.getId(), decoded);
+                            } catch (GeneralSecurityException e) {
+                                throw new RuntimeException(e);
+                            }
+                        }
+                    }
+                }
+                builder.put("inputs", inputs);
             }
 
             if (execution.getTrigger() != null && execution.getTrigger().getVariables() != null) {
diff --git a/core/src/main/java/io/kestra/core/runners/RunnerUtils.java b/core/src/main/java/io/kestra/core/runners/RunnerUtils.java
index 358a72d6e..45e041ed2 100644
--- a/core/src/main/java/io/kestra/core/runners/RunnerUtils.java
+++ b/core/src/main/java/io/kestra/core/runners/RunnerUtils.java
@@ -2,6 +2,7 @@ package io.kestra.core.runners;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.google.common.collect.ImmutableMap;
+import io.kestra.core.crypto.CryptoService;
 import io.kestra.core.exceptions.MissingRequiredInput;
 import io.kestra.core.models.Label;
 import io.kestra.core.models.executions.Execution;
@@ -28,6 +29,7 @@ import reactor.core.scheduler.Schedulers;
 import java.io.File;
 import java.io.IOException;
 import java.net.URI;
+import java.security.GeneralSecurityException;
 import java.time.Duration;
 import java.time.Instant;
 import java.time.LocalDate;
@@ -61,6 +63,9 @@ public class RunnerUtils {
     @Inject
     private ConditionService conditionService;
 
+    @Inject
+    private CryptoService cryptoService;
+
     public Map<String, Object> typedInputs(Flow flow, Execution execution, Map<String, Object> in, Publisher<StreamingFileUpload> files) throws IOException {
         if (files == null) {
             return this.typedInputs(flow, execution, in);
@@ -138,31 +143,41 @@ public class RunnerUtils {
 
     private Optional<AbstractMap.SimpleEntry<String, Object>> parseInput(Flow flow, Execution execution, Input<?> input, Object current) {
         switch (input.getType()) {
-            case STRING:
+            case STRING -> {
                 return Optional.of(new AbstractMap.SimpleEntry<>(
                     input.getId(),
                     current
                 ));
-
-            case INT:
+            }
+            case SECRET -> {
+                try {
+                    return Optional.of(new AbstractMap.SimpleEntry<>(
+                        input.getId(),
+                        cryptoService.encrypt((String) current)
+                    ));
+                } catch (GeneralSecurityException e) {
+                    throw new MissingRequiredInput("Invalid SECRET format for '" + input.getId() + "' for '" + current + "' with error " + e.getMessage(), e);
+                }
+            }
+            case INT -> {
                 return Optional.of(new AbstractMap.SimpleEntry<>(
                     input.getId(),
                     current instanceof Integer ? current : Integer.valueOf((String) current)
                 ));
-
-            case FLOAT:
+            }
+            case FLOAT -> {
                 return Optional.of(new AbstractMap.SimpleEntry<>(
                     input.getId(),
                     current instanceof Float ? current : Float.valueOf((String) current)
                 ));
-
-            case BOOLEAN:
+            }
+            case BOOLEAN -> {
                 return Optional.of(new AbstractMap.SimpleEntry<>(
                     input.getId(),
                     current instanceof Boolean ? current : Boolean.valueOf((String) current)
                 ));
-
-            case DATETIME:
+            }
+            case DATETIME -> {
                 try {
                     return Optional.of(new AbstractMap.SimpleEntry<>(
                         input.getId(),
@@ -171,8 +186,8 @@ public class RunnerUtils {
                 } catch (DateTimeParseException e) {
                     throw new MissingRequiredInput("Invalid DATETIME format for '" + input.getId() + "' for '" + current + "' with error " + e.getMessage(), e);
                 }
-
-            case DATE:
+            }
+            case DATE -> {
                 try {
                     return Optional.of(new AbstractMap.SimpleEntry<>(
                         input.getId(),
@@ -181,8 +196,8 @@ public class RunnerUtils {
                 } catch (DateTimeParseException e) {
                     throw new MissingRequiredInput("Invalid DATE format for '" + input.getId() + "' for '" + current + "' with error " + e.getMessage(), e);
                 }
-
-            case TIME:
+            }
+            case TIME -> {
                 try {
                     return Optional.of(new AbstractMap.SimpleEntry<>(
                         input.getId(),
@@ -191,8 +206,8 @@ public class RunnerUtils {
                 } catch (DateTimeParseException e) {
                     throw new MissingRequiredInput("Invalid TIME format for '" + input.getId() + "' for '" + current + "' with error " + e.getMessage(), e);
                 }
-
-            case DURATION:
+            }
+            case DURATION -> {
                 try {
                     return Optional.of(new AbstractMap.SimpleEntry<>(
                         input.getId(),
@@ -201,8 +216,8 @@ public class RunnerUtils {
                 } catch (DateTimeParseException e) {
                     throw new MissingRequiredInput("Invalid DURATION format for '" + input.getId() + "' for '" + current + "' with error " + e.getMessage(), e);
                 }
-
-            case FILE:
+            }
+            case FILE -> {
                 try {
                     URI uri = URI.create(((String) current).replace(File.separator, "/"));
 
@@ -220,8 +235,8 @@ public class RunnerUtils {
                 } catch (Exception e) {
                     throw new MissingRequiredInput("Invalid input arguments for file on input '" + input.getId() + "'", e);
                 }
-
-            case JSON:
+            }
+            case JSON -> {
                 try {
                     return Optional.of(new AbstractMap.SimpleEntry<>(
                         input.getId(),
@@ -230,9 +245,9 @@ public class RunnerUtils {
                 } catch (JsonProcessingException e) {
                     throw new MissingRequiredInput("Invalid JSON format for '" + input.getId() + "' for '" + current + "' with error " + e.getMessage(), e);
                 }
-
-            case URI:
-                Matcher matcher = URI_PATTERN.matcher(((String) current));
+            }
+            case URI -> {
+                Matcher matcher = URI_PATTERN.matcher((String) current);
                 if (matcher.matches()) {
                     return Optional.of(new AbstractMap.SimpleEntry<>(
                         input.getId(),
@@ -241,8 +256,8 @@ public class RunnerUtils {
                 } else {
                     throw new MissingRequiredInput("Invalid URI format for '" + input.getId() + "' for '" + current + "'");
                 }
-
-            default:
+            }
+            default ->
                 throw new MissingRequiredInput("Invalid input type '" + input.getType() + "' for '" + input.getId() + "'");
         }
     }
diff --git a/ui/src/components/executions/Overview.vue b/ui/src/components/executions/Overview.vue
index 1ac557ca3..4fd20024a 100644
--- a/ui/src/components/executions/Overview.vue
+++ b/ui/src/components/executions/Overview.vue
@@ -118,6 +118,7 @@
             }
         },
         computed: {
+            ...mapState("flow", ["flow"]),
             ...mapState("execution", ["execution"]),
             items() {
                 if (!this.execution) {
@@ -168,7 +169,19 @@
                 return ret;
             },
             inputs() {
-                return toRaw(this.execution.inputs);
+              if (!this.flow) {
+                return []
+              }
+
+              let inputs = toRaw(this.execution.inputs);
+              Object.keys(inputs).forEach(key => {
+                this.flow.inputs.forEach(input => {
+                  if(key === input.name && input.type === 'SECRET') {
+                    inputs[key] = '******';
+                  }
+                })
+              })
+              return inputs;
             }
         },
     };
diff --git a/ui/src/components/flows/FlowRun.vue b/ui/src/components/flows/FlowRun.vue
index cf62548d3..b093e839f 100644
--- a/ui/src/components/flows/FlowRun.vue
+++ b/ui/src/components/flows/FlowRun.vue
@@ -20,6 +20,12 @@
                     v-if="input.type === 'STRING' || input.type === 'URI'"
                     v-model="inputs[input.id]"
                 />
+                <el-input
+                    type="password"
+                    v-if="input.type === 'SECRET'"
+                    v-model="inputs[input.id]"
+                    show-password
+                />
                 <el-input-number
                     v-if="input.type === 'INT'"
                     v-model="inputs[input.id]"


Test patch:
diff --git a/core/src/test/java/io/kestra/core/runners/InputsTest.java b/core/src/test/java/io/kestra/core/runners/InputsTest.java
index 58c5cb8d7..003fc93df 100644
--- a/core/src/test/java/io/kestra/core/runners/InputsTest.java
+++ b/core/src/test/java/io/kestra/core/runners/InputsTest.java
@@ -8,6 +8,7 @@ import io.kestra.core.models.flows.Flow;
 import io.kestra.core.models.flows.State;
 import io.kestra.core.repositories.FlowRepositoryInterface;
 import io.kestra.core.storages.StorageInterface;
+import io.micronaut.context.annotation.Property;
 import jakarta.inject.Inject;
 import org.junit.jupiter.api.Test;
 
@@ -31,6 +32,7 @@ import static org.hamcrest.Matchers.*;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 @SuppressWarnings("OptionalGetWithoutIsPresent")
+@Property(name = "kestra.crypto.secret-key", value = "I6EGNzRESu3X3pKZidrqCGOHQFUFC0yK")
 public class InputsTest extends AbstractMemoryRunnerTest {
     public static Map<String, Object> inputs = ImmutableMap.<String, Object>builder()
         .put("string", "myString")
@@ -54,6 +56,7 @@ public class InputsTest extends AbstractMemoryRunnerTest {
         .put("validatedDuration", "PT15S")
         .put("validatedFloat", "0.42")
         .put("validatedTime", "11:27:49")
+        .put("secret", "secret")
         .build();
 
     @Inject
@@ -126,6 +129,7 @@ public class InputsTest extends AbstractMemoryRunnerTest {
         assertThat(typeds.get("validatedDuration"), is(Duration.parse("PT15S")));
         assertThat(typeds.get("validatedFloat"), is(0.42F));
         assertThat(typeds.get("validatedTime"), is(LocalTime.parse("11:27:49")));
+        assertThat(typeds.get("secret"), not("secret")); // secret inputs are encrypted
     }
 
     @Test
@@ -151,12 +155,17 @@ public class InputsTest extends AbstractMemoryRunnerTest {
             (flow, execution1) -> runnerUtils.typedInputs(flow, execution1, inputs)
         );
 
-        assertThat(execution.getTaskRunList(), hasSize(5));
+        assertThat(execution.getTaskRunList(), hasSize(6));
         assertThat(execution.getState().getCurrent(), is(State.Type.SUCCESS));
         assertThat(
             (String) execution.findTaskRunsByTaskId("file").get(0).getOutputs().get("value"),
             matchesRegex("kestra:///io/kestra/tests/inputs/executions/.*/inputs/file/application-test.yml")
         );
+        // secret inputs are decrypted to be used as task properties
+        assertThat(
+            (String) execution.findTaskRunsByTaskId("secret").get(0).getOutputs().get("value"),
+            is("secret")
+        );
     }
 
     @Test
diff --git a/core/src/test/java/io/kestra/core/serializers/YamlFlowParserTest.java b/core/src/test/java/io/kestra/core/serializers/YamlFlowParserTest.java
index 1230b1f98..fb141f321 100644
--- a/core/src/test/java/io/kestra/core/serializers/YamlFlowParserTest.java
+++ b/core/src/test/java/io/kestra/core/serializers/YamlFlowParserTest.java
@@ -125,8 +125,8 @@ class YamlFlowParserTest {
     void inputs() {
         Flow flow = this.parse("flows/valids/inputs.yaml");
 
-        assertThat(flow.getInputs().size(), is(24));
-        assertThat(flow.getInputs().stream().filter(Input::getRequired).count(), is(6L));
+        assertThat(flow.getInputs().size(), is(25));
+        assertThat(flow.getInputs().stream().filter(Input::getRequired).count(), is(7L));
         assertThat(flow.getInputs().stream().filter(r -> !r.getRequired()).count(), is(18L));
         assertThat(flow.getInputs().stream().filter(r -> r.getDefaults() != null).count(), is(1L));
         assertThat(flow.getInputs().stream().filter(r -> r instanceof StringInput && ((StringInput)r).getValidator() != null).count(), is(1L));
diff --git a/core/src/test/resources/flows/valids/inputs.yaml b/core/src/test/resources/flows/valids/inputs.yaml
index e69afad8b..4afa20447 100644
--- a/core/src/test/resources/flows/valids/inputs.yaml
+++ b/core/src/test/resources/flows/valids/inputs.yaml
@@ -85,6 +85,8 @@ inputs:
   after: "01:00:00"
   before: "11:59:59"
   required: false
+- name: secret
+  type: SECRET
 
 tasks:
 - id: string
@@ -102,3 +104,6 @@ tasks:
 - id: file
   type: io.kestra.core.tasks.debugs.Return
   format: "{{inputs.file}}"
+- id: secret
+  type: io.kestra.core.tasks.debugs.Return
+  format: "{{inputs.secret}}"
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
index d08295d04..284c9a039 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
@@ -18,6 +18,7 @@ import io.kestra.core.utils.Await;
 import io.kestra.core.utils.IdUtils;
 import io.kestra.webserver.controllers.h2.JdbcH2ControllerTest;
 import io.kestra.webserver.responses.PagedResults;
+import io.micronaut.context.annotation.Property;
 import io.micronaut.core.type.Argument;
 import io.micronaut.data.model.Pageable;
 import io.micronaut.http.HttpRequest;
@@ -52,6 +53,7 @@ import static org.hamcrest.Matchers.*;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+@Property(name = "kestra.crypto.secret-key", value = "I6EGNzRESu3X3pKZidrqCGOHQFUFC0yK")
 class ExecutionControllerTest extends JdbcH2ControllerTest {
     public static final String URL_LABEL_VALUE = "https://some-url.com";
     public static final String ENCODED_URL_LABEL_VALUE = URL_LABEL_VALUE.replace("/", URLEncoder.encode("/", StandardCharsets.UTF_8));
@@ -89,6 +91,7 @@ class ExecutionControllerTest extends JdbcH2ControllerTest {
         .put("float", "42.42")
         .put("instant", "2019-10-06T18:27:49Z")
         .put("file", Objects.requireNonNull(InputsTest.class.getClassLoader().getResource("data/hello.txt")).getPath())
+        .put("secret", "secret")
         .build();
 
     @Test
@@ -126,6 +129,7 @@ class ExecutionControllerTest extends JdbcH2ControllerTest {
             .addPart("instant", "2019-10-06T18:27:49Z")
             .addPart("files", "file", MediaType.TEXT_PLAIN_TYPE, applicationFile)
             .addPart("files", "optionalFile", MediaType.TEXT_XML_TYPE, logbackFile)
+            .addPart("secret", "secret")
             .build();
     }
 
@@ -159,7 +163,7 @@ class ExecutionControllerTest extends JdbcH2ControllerTest {
         Execution result = triggerInputsFlowExecution(true);
 
         assertThat(result.getState().getCurrent(), is(State.Type.SUCCESS));
-        assertThat(result.getTaskRunList().size(), is(5));
+        assertThat(result.getTaskRunList().size(), is(6));
     }
 
     @Test
@@ -432,7 +436,7 @@ class ExecutionControllerTest extends JdbcH2ControllerTest {
     @Test
     void downloadFile() throws TimeoutException {
         Execution execution = runnerUtils.runOne(null, TESTS_FLOW_NS, "inputs", null, (flow, execution1) -> runnerUtils.typedInputs(flow, execution1, inputs));
-        assertThat(execution.getTaskRunList(), hasSize(5));
+        assertThat(execution.getTaskRunList(), hasSize(6));
 
         String path = (String) execution.getInputs().get("file");
 
@@ -467,7 +471,7 @@ class ExecutionControllerTest extends JdbcH2ControllerTest {
     @Test
     void filePreview() throws TimeoutException {
         Execution defaultExecution = runnerUtils.runOne(null, TESTS_FLOW_NS, "inputs", null, (flow, execution1) -> runnerUtils.typedInputs(flow, execution1, inputs));
-        assertThat(defaultExecution.getTaskRunList(), hasSize(5));
+        assertThat(defaultExecution.getTaskRunList(), hasSize(6));
 
         String defaultPath = (String) defaultExecution.getInputs().get("file");
 
@@ -485,10 +489,11 @@ class ExecutionControllerTest extends JdbcH2ControllerTest {
             .put("float", "42.42")
             .put("instant", "2019-10-06T18:27:49Z")
             .put("file", Objects.requireNonNull(ExecutionControllerTest.class.getClassLoader().getResource("data/iso88591.txt")).getPath())
+            .put("secret", "secret")
             .build();
 
         Execution latin1Execution = runnerUtils.runOne(null, TESTS_FLOW_NS, "inputs", null, (flow, execution1) -> runnerUtils.typedInputs(flow, execution1, latin1FileInputs));
-        assertThat(latin1Execution.getTaskRunList(), hasSize(5));
+        assertThat(latin1Execution.getTaskRunList(), hasSize(6));
 
         String latin1Path = (String) latin1Execution.getInputs().get("file");
 
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/PluginControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/PluginControllerTest.java
index 92ca32342..96cb31a68 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/PluginControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/PluginControllerTest.java
@@ -205,7 +205,7 @@ class PluginControllerTest {
                 Argument.listOf(InputType.class)
             );
 
-            assertThat(doc.size(), is(11));
+            assertThat(doc.size(), is(12));
         });
     }