Repository: kestra-io/kestra (java)

Issue:
Mask the value of SECRET-type input if accidentally exposed in the logs
### Describe the issue

When a SECRET is asked by inputs it is leaked at logs when it is used as `{{inputs.password}}` (see the example below). 

In the example below, in Kestra task logs `export SUPER_PASSWORD=<the_plain_password_here>` the input (secret) is shown in plain. This should be masked.

```yaml
id: test_input_secret
namespace: test

inputs:
  - id: username
    type: STRING

  - id: password
    type: SECRET

tasks:
  - id: commands_task
    type: io.kestra.plugin.scripts.shell.Commands
    taskRunner:
      type: io.kestra.plugin.scripts.runner.docker.Docker
      pullPolicy: ALWAYS
      entryPoint: []
    containerImage: "ubuntu:latest"
    namespaceFiles:
      enabled: true
    commands:
      - export SUPER_PASSWORD={{ inputs.password }}
```


### Environment

- Kestra Version: v0.18.9
- Operating System (OS/Docker/Kubernetes): Kubernetes RKE2



Pull request:
fix(core): hide secret inputs in logs
Fixes #5259

As it's security related, @tchiotludo can you do a review?

Patch:
diff --git a/.github/workflows/generate_translations.yml b/.github/workflows/generate_translations.yml
index d9735a80b..ca91c52a5 100644
--- a/.github/workflows/generate_translations.yml
+++ b/.github/workflows/generate_translations.yml
@@ -5,19 +5,22 @@ on:
     types: [opened, synchronize]
     paths:
       - 'ui/src/translations/en.json'
+  push:
+    branches:
+      - develop
 
 env:
   OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
 
 jobs:
-  generate-translations:
-    name: Generate Translations and Add Commit to PR
+  generate:
+    name: Generate Translations
     runs-on: ubuntu-latest
     steps:
     - name: Checkout code
       uses: actions/checkout@v4
       with:
-        fetch-depth: 10  # Ensures that at least 10 commits are fetched for comparison
+        fetch-depth: 10
         ref: ${{ github.head_ref }}
 
     - name: Set up Python
@@ -31,12 +34,53 @@ jobs:
     - name: Generate translations
       run: python ui/src/translations/generate_translations.py
 
-    - name: Commit and push changes to existing PR
+  commit:
+    name: Commit directly to PR
+    runs-on: ubuntu-latest
+    needs: generate
+    if: ${{ github.event.pull_request.head.repo.fork == false }}
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v4
+      with:
+        fetch-depth: 10
+
+    - name: Set up Git
+      run: |
+        git config --global user.name "GitHub Action"
+        git config --global user.email "actions@github.com"
+
+    - name: Commit and push changes
       env:
         GH_TOKEN: ${{ github.token }}
+      run: |
+        git add ui/src/translations/*.json
+        git commit -m "chore(translations): auto generate values for languages other than english"
+        git push origin ${{ github.head_ref }}
+
+  pull_request:
+    name: Open PR for a forked repository
+    runs-on: ubuntu-latest
+    needs: generate
+    if: ${{ github.event.pull_request.head.repo.fork == true }}
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v4
+      with:
+        fetch-depth: 10
+
+    - name: Set up Git
       run: |
         git config --global user.name "GitHub Action"
         git config --global user.email "actions@github.com"
+
+    - name: Create and push a new branch
+      env:
+        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      run: |
+        BRANCH_NAME="generated-translations-${{ github.event.pull_request.head.repo.name }}"
+        
+        git checkout -b $BRANCH_NAME
         git add ui/src/translations/*.json
         git commit -m "chore(translations): auto generate values for languages other than english"
-        git push origin ${{ github.head_ref }}
+        git push origin $BRANCH_NAME
\ No newline at end of file
diff --git a/core/src/main/java/io/kestra/core/runners/DefaultRunContext.java b/core/src/main/java/io/kestra/core/runners/DefaultRunContext.java
index f3a965f37..d371a2947 100644
--- a/core/src/main/java/io/kestra/core/runners/DefaultRunContext.java
+++ b/core/src/main/java/io/kestra/core/runners/DefaultRunContext.java
@@ -11,6 +11,7 @@ import io.kestra.core.services.KVStoreService;
 import io.kestra.core.storages.Storage;
 import io.kestra.core.storages.StorageInterface;
 import io.kestra.core.storages.kv.KVStore;
+import io.kestra.core.utils.ListUtils;
 import io.kestra.core.utils.VersionProvider;
 import io.micronaut.context.ApplicationContext;
 import io.micronaut.context.annotation.Value;
@@ -30,7 +31,6 @@ import java.nio.file.Path;
 import java.security.GeneralSecurityException;
 import java.util.*;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
 import static io.kestra.core.utils.MapUtils.mergeWithNullableValues;
@@ -67,6 +67,7 @@ public class DefaultRunContext extends RunContext {
     private String triggerExecutionId;
     private Storage storage;
     private Map<String, Object> pluginConfiguration;
+    private List<String> secretInputs;
 
     private final AtomicBoolean isInitialized = new AtomicBoolean(false);
 
@@ -98,6 +99,15 @@ public class DefaultRunContext extends RunContext {
         return variables;
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    @JsonInclude
+    public List<String> getSecretInputs() {
+        return secretInputs;
+    }
+
     @JsonIgnore
     public ApplicationContext getApplicationContext() {
         return applicationContext;
@@ -123,6 +133,17 @@ public class DefaultRunContext extends RunContext {
 
     void setLogger(final RunContextLogger logger) {
         this.logger = logger;
+
+        // this is used when a run context is re-hydrated so we need to add again the secrets from the inputs
+        if (!ListUtils.isEmpty(secretInputs) && getVariables().containsKey("inputs")) {
+            Map<String, Object> inputs = (Map<String, Object>) getVariables().get("inputs");
+            for (String secretInput : secretInputs) {
+                String secret = (String) inputs.get(secretInput);
+                if (secret != null) {
+                    logger.usedSecret(secret);
+                }
+            }
+        }
     }
 
     void setPluginConfiguration(final Map<String, Object> pluginConfiguration) {
@@ -488,6 +509,7 @@ public class DefaultRunContext extends RunContext {
         private String triggerExecutionId;
         private RunContextLogger logger;
         private KVStoreService kvStoreService;
+        private List<String> secretInputs;
 
         /**
          * Builds the new {@link DefaultRunContext} object.
@@ -507,6 +529,7 @@ public class DefaultRunContext extends RunContext {
             context.storage = storage;
             context.triggerExecutionId = triggerExecutionId;
             context.kvStoreService = kvStoreService;
+            context.secretInputs = secretInputs;
             return context;
         }
     }
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 8b1b93040..bf1aaf8a0 100644
--- a/core/src/main/java/io/kestra/core/runners/RunContext.java
+++ b/core/src/main/java/io/kestra/core/runners/RunContext.java
@@ -47,6 +47,12 @@ public abstract class RunContext {
     @JsonInclude
     public abstract Map<String, Object> getVariables();
 
+    /**
+     * Returns the list of inputs of type SECRET.
+     */
+    @JsonInclude
+    public abstract List<String> getSecretInputs();
+
     public abstract String render(String inline) throws IllegalVariableEvaluationException;
 
     public abstract Object renderTyped(String inline) throws IllegalVariableEvaluationException;
diff --git a/core/src/main/java/io/kestra/core/runners/RunContextFactory.java b/core/src/main/java/io/kestra/core/runners/RunContextFactory.java
index ad2351786..7088271a6 100644
--- a/core/src/main/java/io/kestra/core/runners/RunContextFactory.java
+++ b/core/src/main/java/io/kestra/core/runners/RunContextFactory.java
@@ -5,6 +5,7 @@ import io.kestra.core.metrics.MetricRegistry;
 import io.kestra.core.models.executions.Execution;
 import io.kestra.core.models.executions.TaskRun;
 import io.kestra.core.models.flows.Flow;
+import io.kestra.core.models.flows.Type;
 import io.kestra.core.models.tasks.Task;
 import io.kestra.core.models.triggers.AbstractTrigger;
 import io.kestra.core.plugins.PluginConfigurations;
@@ -15,12 +16,12 @@ import io.kestra.core.storages.StorageContext;
 import io.kestra.core.storages.StorageInterface;
 import io.micronaut.context.ApplicationContext;
 import io.micronaut.context.annotation.Value;
-import jakarta.annotation.Nullable;
 import jakarta.inject.Inject;
 import jakarta.inject.Singleton;
-import jakarta.validation.constraints.NotNull;
 
 import java.net.URI;
+import java.util.Collections;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.function.Function;
@@ -83,8 +84,10 @@ public class RunContextFactory {
                     .withFlow(flow)
                     .withExecution(execution)
                     .withDecryptVariables(true)
+                    .withSecretInputs(secretInputsFromFlow(flow))
                 )
                 .build(runContextLogger))
+            .withSecretInputs(secretInputsFromFlow(flow))
             .build();
     }
 
@@ -107,8 +110,10 @@ public class RunContextFactory {
                 .withExecution(execution)
                 .withTaskRun(taskRun)
                 .withDecryptVariables(decryptVariables)
+                .withSecretInputs(secretInputsFromFlow(flow))
                 .build(runContextLogger))
             .withKvStoreService(kvStoreService)
+            .withSecretInputs(secretInputsFromFlow(flow))
             .build();
     }
 
@@ -122,8 +127,10 @@ public class RunContextFactory {
             .withVariables(newRunVariablesBuilder()
                 .withFlow(flow)
                 .withTrigger(trigger)
+                .withSecretInputs(secretInputsFromFlow(flow))
                 .build(runContextLogger)
             )
+            .withSecretInputs(secretInputsFromFlow(flow))
             .build();
     }
 
@@ -135,6 +142,7 @@ public class RunContextFactory {
             .withLogger(runContextLogger)
             .withStorage(new InternalStorage(runContextLogger.logger(), StorageContext.forFlow(flow), storageInterface, flowService))
             .withVariables(variables)
+            .withSecretInputs(secretInputsFromFlow(flow))
             .build();
     }
 
@@ -177,6 +185,16 @@ public class RunContextFactory {
         return of(Map.of());
     }
 
+    private List<String> secretInputsFromFlow(Flow flow) {
+        if (flow == null || flow.getInputs() == null) {
+            return Collections.emptyList();
+        }
+
+        return flow.getInputs().stream()
+            .filter(input -> input.getType() == Type.SECRET)
+            .map(input -> input.getId()).toList();
+    }
+
     private DefaultRunContext.Builder newBuilder() {
         return new DefaultRunContext.Builder()
             // inject mandatory services and config
diff --git a/core/src/main/java/io/kestra/core/runners/RunVariables.java b/core/src/main/java/io/kestra/core/runners/RunVariables.java
index 66e9adc31..fead48d04 100644
--- a/core/src/main/java/io/kestra/core/runners/RunVariables.java
+++ b/core/src/main/java/io/kestra/core/runners/RunVariables.java
@@ -9,6 +9,7 @@ import io.kestra.core.models.flows.State;
 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.utils.ListUtils;
 import lombok.AllArgsConstructor;
 import lombok.With;
 
@@ -125,6 +126,8 @@ public final class RunVariables {
 
         Builder withGlobals(Map<?, ?> globals);
 
+        Builder withSecretInputs(List<String> secretInputs);
+
         /**
          * Builds the immutable map of run variables.
          *
@@ -152,6 +155,7 @@ public final class RunVariables {
         protected Map<String, ?> envs;
         protected Map<?, ?> globals;
         private final Optional<String> secretKey;
+        private List<String> secretInputs;
 
         public DefaultBuilder() {
             this(Optional.empty());
@@ -252,6 +256,16 @@ public final class RunVariables {
 
                 if (!inputs.isEmpty()) {
                     builder.put("inputs", inputs);
+
+                    // if a secret input is used, add it to the list of secrets to mask on the logger
+                    if (logger != null && !ListUtils.isEmpty(secretInputs)) {
+                        for (String secretInput : secretInputs) {
+                            String secret = (String) inputs.get(secretInput);
+                            if (secret != null) {
+                                logger.usedSecret(secret);
+                            }
+                        }
+                    }
                 }
 
                 if (execution.getTrigger() != null && execution.getTrigger().getVariables() != null) {
diff --git a/core/src/main/java/io/kestra/core/runners/Worker.java b/core/src/main/java/io/kestra/core/runners/Worker.java
index 605269608..3dc9ab90d 100644
--- a/core/src/main/java/io/kestra/core/runners/Worker.java
+++ b/core/src/main/java/io/kestra/core/runners/Worker.java
@@ -704,8 +704,7 @@ public class Worker implements Service, Runnable, AutoCloseable {
     }
 
     private WorkerTask runAttempt(WorkerTask workerTask) throws QueueException {
-        DefaultRunContext runContext = (DefaultRunContext) workerTask.getRunContext();
-        runContextInitializer.forWorker(runContext, workerTask);
+        DefaultRunContext runContext = runContextInitializer.forWorker((DefaultRunContext) workerTask.getRunContext(), workerTask);;
 
         Logger logger = runContext.logger();
 
diff --git a/ui/src/components/executions/Logs.vue b/ui/src/components/executions/Logs.vue
index a7a0db201..28d5804a9 100644
--- a/ui/src/components/executions/Logs.vue
+++ b/ui/src/components/executions/Logs.vue
@@ -26,6 +26,7 @@
                     :total-count="countByLogLevel[logLevel]"
                     @previous="previousLogForLevel(logLevel)"
                     @next="nextLogForLevel(logLevel)"
+                    @close="clearLogLevel(logLevel)"
                 />
             </el-form-item>
             <el-form-item>
@@ -217,7 +218,17 @@
 
                 const sortedIndices = [...logIndicesForLevel, this.logCursor].filter(Utils.distinctFilter).sort(this.sortLogsByViewOrder);
                 this.logCursor = sortedIndices?.[sortedIndices.indexOf(this.logCursor) + 1] ?? sortedIndices[0];
-            }
+            },
+            clearLogLevel(level) {
+                if (this.logCursor !== undefined && this.cursorLogLevel === level) {
+                    this.logCursor = undefined;
+                }
+                if (this.level === level) {
+                    this.level = undefined;
+                    this.onChange();
+                }
+            }   
+
         }
     };
 </script>
diff --git a/ui/src/components/flows/FlowRun.vue b/ui/src/components/flows/FlowRun.vue
index 2dfe7f802..34fcf79a9 100644
--- a/ui/src/components/flows/FlowRun.vue
+++ b/ui/src/components/flows/FlowRun.vue
@@ -6,7 +6,7 @@
         </el-alert>
 
         <el-form label-position="top" :model="inputs" ref="form" @submit.prevent="false">
-            <inputs-form :initial-inputs="flow.inputs" :flow="flow" v-model="inputs" :execute-clicked="executeClicked" />
+            <inputs-form :initial-inputs="flow.inputs" :flow="flow" v-model="inputs" :execute-clicked="executeClicked" @confirm="onSubmit($refs.form)" />
 
             <el-collapse class="mt-4" v-model="collapseName">
                 <el-collapse-item :title="$t('advanced configuration')" name="advanced">
@@ -50,7 +50,7 @@
                             @click="onSubmit($refs.form); executeClicked = true;"
                             type="primary"
                             native-type="submit"
-                            :disabled="flow.disabled || haveBadLabels"
+                            :disabled="!flowCanBeExecuted"
                         >
                             {{ $t('launch execution') }}
                         </el-button>
@@ -111,6 +111,9 @@
             haveBadLabels() {
                 return this.executionLabels.some(label => (label.key && !label.value) || (!label.key && label.value));
             },
+            flowCanBeExecuted() {
+                return this.flow && !this.flow.disabled && !this.haveBadLabels;
+            }
         },
         methods: {
             getExecutionLabels() {
@@ -152,7 +155,7 @@
                 return inputs;
             },
             onSubmit(formRef) {
-                if (formRef) {
+                if (formRef && this.flowCanBeExecuted) {
                     formRef.validate((valid) => {
                         if (!valid) {
                             return false;
@@ -175,7 +178,6 @@
                     });
                 }
             },
-
             state(input) {
                 const required = input.required === undefined ? true : input.required;
 
diff --git a/ui/src/components/inputs/InputsForm.vue b/ui/src/components/inputs/InputsForm.vue
index de40cbe29..8c9078c39 100644
--- a/ui/src/components/inputs/InputsForm.vue
+++ b/ui/src/components/inputs/InputsForm.vue
@@ -14,6 +14,7 @@
                 v-if="input.type === 'STRING' || input.type === 'URI'"
                 v-model="inputs[input.id]"
                 @update:model-value="onChange"
+                @confirm="onSubmit"
             />
             <el-select
                 :full-height="false"
@@ -209,7 +210,7 @@
                 multiSelectInputs: {},
             };
         },
-        emits: ["update:modelValue"],
+        emits: ["update:modelValue", "confirm"],
         created() {
             this.inputsList.push(...(this.initialInputs ?? []));
             this.validateInputs();
@@ -223,9 +224,10 @@
             }, 500)
 
             this._keyListener = function(e) {
-                if (e.keyCode === 13 && (e.ctrlKey || e.metaKey))  {
+                // Ctrl/Control + Enter
+                if (e.key === "Enter" && (e.ctrlKey || e.metaKey))  {
                     e.preventDefault();
-                    this.onSubmit(this.$refs.form);
+                    this.onSubmit();
                 }
             };
 
@@ -248,6 +250,9 @@
             onChange() {
                 this.$emit("update:modelValue", this.inputs);
             },
+            onSubmit() {
+                this.$emit("confirm");
+            },
             onMultiSelectChange(input, e) {
                 this.inputs[input] = JSON.stringify(e).toString();
                 this.onChange();
diff --git a/ui/src/components/inputs/MonacoEditor.vue b/ui/src/components/inputs/MonacoEditor.vue
index e18c63714..983ef5945 100644
--- a/ui/src/components/inputs/MonacoEditor.vue
+++ b/ui/src/components/inputs/MonacoEditor.vue
@@ -302,6 +302,16 @@
                     }
                 })
             }
+
+            // Expose paste function globally for testing
+            window.pasteToEditor = (textToPaste) => {               
+                this.editor.executeEdits("", [
+                    {
+                        range: this.editor.getSelection(),
+                        text: textToPaste,
+                    },
+                ]);
+            };
         },
         beforeUnmount: function () {
             this.destroy();
diff --git a/ui/src/components/logs/LogLevelNavigator.vue b/ui/src/components/logs/LogLevelNavigator.vue
index 3fec10c77..3c4f57a83 100644
--- a/ui/src/components/logs/LogLevelNavigator.vue
+++ b/ui/src/components/logs/LogLevelNavigator.vue
@@ -7,12 +7,14 @@
         <div class="d-flex align-items-center gap-2">
             <chevron-up class="medium-icon nav-button" @click="forwardEvent('previous')" />
             <chevron-down class="medium-icon nav-button" @click="forwardEvent('next')" />
+            <close class="medium-icon nav-button close-button" @click="forwardEvent('close')" v-if="isSelected" />
         </div>
     </div>
 </template>
 <script setup>
     import ChevronUp from "vue-material-design-icons/ChevronUp.vue";
     import ChevronDown from "vue-material-design-icons/ChevronDown.vue";
+    import Close from "vue-material-design-icons/Close.vue";
 </script>
 <script>
     export default {
@@ -66,5 +68,12 @@
         .medium-icon {
             font-size: 1.1rem;
         }
+
+        .close-button {
+            color: var(--el-text-color-secondary);
+            &:hover {
+                color: var(--el-text-color-primary);
+            }
+        }
     }
 </style>


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 a7613d0ff..d926eca99 100644
--- a/core/src/test/java/io/kestra/core/runners/InputsTest.java
+++ b/core/src/test/java/io/kestra/core/runners/InputsTest.java
@@ -3,16 +3,23 @@ package io.kestra.core.runners;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.io.CharStreams;
 import io.kestra.core.models.executions.Execution;
+import io.kestra.core.models.executions.LogEntry;
 import io.kestra.core.models.flows.Flow;
 import io.kestra.core.models.flows.State;
 import io.kestra.core.queues.QueueException;
+import io.kestra.core.queues.QueueFactoryInterface;
+import io.kestra.core.queues.QueueInterface;
 import io.kestra.core.repositories.FlowRepositoryInterface;
 import io.kestra.core.storages.StorageInterface;
+import io.kestra.core.utils.TestsUtils;
 import jakarta.inject.Inject;
+import jakarta.inject.Named;
 import org.jcodings.util.Hash;
 import org.junit.jupiter.api.Test;
 
 import jakarta.validation.ConstraintViolationException;
+import reactor.core.publisher.Flux;
+
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
@@ -22,17 +29,19 @@ import java.time.Duration;
 import java.time.Instant;
 import java.time.LocalDate;
 import java.time.LocalTime;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
+import java.util.*;
 import java.util.concurrent.TimeoutException;
+import java.util.function.Consumer;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.*;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 public class InputsTest extends AbstractMemoryRunnerTest {
+    @Inject
+    @Named(QueueFactoryInterface.WORKERTASKLOG_NAMED)
+    private QueueInterface<LogEntry> logQueue;
+
     public static Map<String, Object> inputs = ImmutableMap.<String, Object>builder()
         .put("string", "myString")
         .put("enum", "ENUM_VALUE")
@@ -351,4 +360,22 @@ public class InputsTest extends AbstractMemoryRunnerTest {
         assertThat(((Map<?, ?>) execution.getInputs().get("json")).size(), is(0));
         assertThat((String) execution.findTaskRunsByTaskId("jsonOutput").getFirst().getOutputs().get("value"), is("{}"));
     }
+
+    @Test
+    void shouldNotLogSecretInput() throws TimeoutException, QueueException {
+        Flux<LogEntry> receive = TestsUtils.receive(logQueue, l -> {});
+
+        Execution execution = runnerUtils.runOne(
+            null,
+            "io.kestra.tests",
+            "input-log-secret"
+        );
+
+        assertThat(execution.getTaskRunList(), hasSize(1));
+        assertThat(execution.getState().getCurrent(), is(State.Type.SUCCESS));
+
+        var logEntry = receive.blockLast();
+        assertThat(logEntry, notNullValue());
+        assertThat(logEntry.getMessage(), is("This is my secret: ********"));
+    }
 }
diff --git a/core/src/test/resources/flows/valids/input-log-secret.yaml b/core/src/test/resources/flows/valids/input-log-secret.yaml
new file mode 100644
index 000000000..2b62cd3d9
--- /dev/null
+++ b/core/src/test/resources/flows/valids/input-log-secret.yaml
@@ -0,0 +1,12 @@
+id: input-log-secret
+namespace: io.kestra.tests
+
+inputs:
+  - id: secret
+    type: SECRET
+    defaults: password
+
+tasks:
+  - id: log-secret
+    type: io.kestra.plugin.core.log.Log
+    message: "This is my secret: {{inputs.secret}}"
\ No newline at end of file