diff --git a/rewrite-core/src/main/java/org/openrewrite/Option.java b/rewrite-core/src/main/java/org/openrewrite/Option.java index 25598fce1c1..60977395b80 100644 --- a/rewrite-core/src/main/java/org/openrewrite/Option.java +++ b/rewrite-core/src/main/java/org/openrewrite/Option.java @@ -40,4 +40,19 @@ String[] valid() default ""; boolean required() default true; + + /** + * Indicates the value is sensitive (an API token, password, etc.). + *
+ * Consumers persisting or displaying option values are expected to redact secret values. + * The corresponding {@link org.openrewrite.config.OptionDescriptor#getValue()} is left raw + * at the source so the value can still be passed into recipe execution (including across + * RPC to non-JVM recipe peers); redaction happens at each persistence boundary. + *
+ * Recipe authors overriding {@link Recipe#getInstanceName()} or
+ * {@link Recipe#getInstanceNameSuffix()} must not include the value of a secret option
+ * in the returned string. The default {@code getInstanceName()} skips secret options
+ * automatically, but custom overrides bypass that filter.
+ */
+ boolean secret() default false;
}
diff --git a/rewrite-core/src/main/java/org/openrewrite/Recipe.java b/rewrite-core/src/main/java/org/openrewrite/Recipe.java
index 6c988939b20..cb671689cb4 100644
--- a/rewrite-core/src/main/java/org/openrewrite/Recipe.java
+++ b/rewrite-core/src/main/java/org/openrewrite/Recipe.java
@@ -145,7 +145,7 @@ public String getInstanceName() {
}
List
+ * Licensed 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
+ *
+ * https://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.openrewrite;
+
+import lombok.Getter;
+import org.junit.jupiter.api.Test;
+import org.openrewrite.config.OptionDescriptor;
+import org.openrewrite.config.RecipeDescriptor;
+import org.openrewrite.marketplace.RecipeMarketplace;
+import org.openrewrite.marketplace.RecipeMarketplaceReader;
+import org.openrewrite.marketplace.RecipeMarketplaceWriter;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SecretOptionTest {
+
+ @Getter
+ static class RecipeWithSecretField extends Recipe {
+ private final String displayName = "Secret field recipe";
+ private final String description = "Secret field recipe.";
+
+ @Option(displayName = "API token", description = "API token.", secret = true)
+ final String apiToken;
+
+ public RecipeWithSecretField(String apiToken) {
+ this.apiToken = apiToken;
+ }
+ }
+
+ @Test
+ void fieldOptionPropagatesSecret() {
+ RecipeDescriptor d = new RecipeWithSecretField("hunter2").createRecipeDescriptor();
+ OptionDescriptor opt = d.getOptions().getFirst();
+ assertThat(opt.isSecret()).isTrue();
+ }
+
+ @Test
+ void fieldOptionRawValuePreservedOnDescriptor() {
+ // Critical invariant: OptionDescriptor.value is NOT redacted at the source. RPC peers
+ // and the recipe clone/withOptions path rely on the raw value being available via
+ // OptionDescriptor#getValue(). Redaction happens only at persistence boundaries
+ // (e.g. RecipeMarketplaceWriter.optionsToJson) via withRedactedSecretValue().
+ RecipeDescriptor d = new RecipeWithSecretField("hunter2").createRecipeDescriptor();
+ OptionDescriptor opt = d.getOptions().getFirst();
+ assertThat(opt.getValue()).isEqualTo("hunter2");
+ }
+
+ static class RecipeBase extends Recipe {
+ String apiToken;
+
+ public RecipeBase(String apiToken) {
+ this.apiToken = apiToken;
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "Method secret recipe";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Method secret recipe.";
+ }
+ }
+
+ static class RecipeWithSecretMethod extends RecipeBase {
+ public RecipeWithSecretMethod(String apiToken) {
+ super(apiToken);
+ }
+
+ @SuppressWarnings("unused")
+ @Option(displayName = "API token", description = "API token.", secret = true)
+ String getApiToken() {
+ return apiToken;
+ }
+ }
+
+ @Test
+ void methodOptionPropagatesSecret() {
+ RecipeDescriptor d = new RecipeWithSecretMethod("hunter2").createRecipeDescriptor();
+ OptionDescriptor opt = d.getOptions().stream()
+ .filter(o -> "apiToken".equals(o.getName()))
+ .findFirst().orElseThrow();
+ assertThat(opt.isSecret()).isTrue();
+ }
+
+ @Getter
+ static class RecipeWithSecretConstructorParam extends Recipe {
+ private final String displayName = "Constructor secret recipe";
+ private final String description = "Constructor secret recipe.";
+ final String apiToken;
+
+ public RecipeWithSecretConstructorParam(
+ @Option(displayName = "API token", description = "API token.", secret = true) String apiToken) {
+ this.apiToken = apiToken;
+ }
+ }
+
+ @Test
+ void constructorParamOptionPropagatesSecret() {
+ RecipeDescriptor d = new RecipeWithSecretConstructorParam("hunter2").createRecipeDescriptor();
+ OptionDescriptor opt = d.getOptions().getFirst();
+ assertThat(opt.isSecret()).isTrue();
+ }
+
+ @Test
+ void withRedactedSecretValueNullsValueButPreservesOtherFields() {
+ OptionDescriptor opt = new OptionDescriptor("apiToken", "String",
+ "API token", "API token.", null, null, true, true, "hunter2");
+
+ OptionDescriptor redacted = opt.withRedactedSecretValue();
+
+ assertThat(redacted.getName()).isEqualTo("apiToken");
+ assertThat(redacted.getType()).isEqualTo("String");
+ assertThat(redacted.getDisplayName()).isEqualTo("API token");
+ assertThat(redacted.getDescription()).isEqualTo("API token.");
+ assertThat(redacted.isRequired()).isTrue();
+ assertThat(redacted.isSecret()).isTrue();
+ assertThat(redacted.getValue()).isNull();
+ }
+
+ @Test
+ void withRedactedSecretValueReturnsSameInstanceWhenNotSecret() {
+ OptionDescriptor opt = new OptionDescriptor("methodPattern", "String",
+ "Method pattern", "Method pattern.", null, null, true, false, "java.util.List add(..)");
+
+ OptionDescriptor result = opt.withRedactedSecretValue();
+
+ assertThat(result).isSameAs(opt);
+ assertThat(result.getValue()).isEqualTo("java.util.List add(..)");
+ }
+
+ /**
+ * Regression test: a recipe whose only required option is marked secret must NOT
+ * include the value of that option in {@link Recipe#getInstanceName()}. Without
+ * the filter in {@code Recipe#getInstanceName()}, the credential would be composed
+ * into a user-visible string that appears in dashboards, logs, and audit views.
+ */
+ @Test
+ void instanceNameDoesNotLeakSecretOptionValue() {
+ RecipeWithSecretField recipe = new RecipeWithSecretField("hunter2");
+ assertThat(recipe.getInstanceName()).isEqualTo(recipe.getDisplayName());
+ assertThat(recipe.getInstanceName()).doesNotContain("hunter2");
+ }
+
+ @Getter
+ static class RecipeWithNonSecretRequiredOption extends Recipe {
+ private final String displayName = "Non-secret recipe";
+ private final String description = "Non-secret recipe.";
+
+ @Option(displayName = "Method pattern", description = "Method pattern.", example = "java.util.List add(..)")
+ final String methodPattern;
+
+ public RecipeWithNonSecretRequiredOption(String methodPattern) {
+ this.methodPattern = methodPattern;
+ }
+ }
+
+ @Test
+ void instanceNameStillComposesNonSecretRequiredOptionValue() {
+ // Sanity: instance-name composition still works for non-secret required options.
+ RecipeWithNonSecretRequiredOption recipe = new RecipeWithNonSecretRequiredOption("java.util.List add(..)");
+ assertThat(recipe.getInstanceName()).isEqualTo("Non-secret recipe `java.util.List add(..)`");
+ }
+
+ @Test
+ void marketplaceWriterRedactsSecretOptionValueInJson() {
+ // Marketplace storage is durable and externally visible. Even when an option
+ // descriptor carries a runtime value (e.g. because the listing was loaded from
+ // a recipe instance), the secret value must not be serialized to CSV/JSON.
+ RecipeMarketplace marketplace = new RecipeMarketplaceReader().fromCsv("""
+ name,displayName,options,category,ecosystem,packageName
+ org.example.SecretRecipe,Secret Recipe,"[{""name"":""apiToken"",""type"":""String"",""displayName"":""API token"",""description"":""API token."",""required"":true,""secret"":true,""value"":""hunter2""}]",Example,maven,org.example:example
+ """);
+
+ String writtenCsv = new RecipeMarketplaceWriter().toCsv(marketplace);
+
+ assertThat(writtenCsv).doesNotContain("hunter2");
+ // The secret flag itself is preserved so downstream consumers know to treat the option as secret.
+ assertThat(writtenCsv).contains("\"\"secret\"\":true");
+ }
+}
diff --git a/rewrite-csharp/csharp/OpenRewrite.Tests/Recipe/RecipeTest.cs b/rewrite-csharp/csharp/OpenRewrite.Tests/Recipe/RecipeTest.cs
index e9c06fa3804..dec783210f3 100644
--- a/rewrite-csharp/csharp/OpenRewrite.Tests/Recipe/RecipeTest.cs
+++ b/rewrite-csharp/csharp/OpenRewrite.Tests/Recipe/RecipeTest.cs
@@ -90,11 +90,53 @@ public void RecipeDescriptorHasOptions()
Assert.Equal("The class name to rename from.", descriptor.Options[0].Description);
Assert.Equal("Foo", descriptor.Options[0].Example);
Assert.True(descriptor.Options[0].Required);
+ Assert.False(descriptor.Options[0].Secret);
Assert.Equal("Foo", descriptor.Options[0].Value);
Assert.Equal("To", descriptor.Options[1].Name);
Assert.Equal("Bar", descriptor.Options[1].Value);
}
+ [Fact]
+ public void SecretOptionPropagatesAndCanBeRedacted()
+ {
+ var recipe = new RecipeWithSecret { ApiToken = "hunter2" };
+ var descriptor = recipe.GetDescriptor();
+
+ Assert.Single(descriptor.Options);
+ var opt = descriptor.Options[0];
+ Assert.Equal("ApiToken", opt.Name);
+ Assert.True(opt.Secret);
+ // Raw value is preserved on the source-level descriptor so RPC + recipe execution
+ // still see it. Persistence boundaries must call WithRedactedSecretValue().
+ Assert.Equal("hunter2", opt.Value);
+
+ var redacted = opt.WithRedactedSecretValue();
+ Assert.True(redacted.Secret);
+ Assert.Null(redacted.Value);
+ Assert.Equal("ApiToken", redacted.Name);
+ Assert.Equal("API token", redacted.DisplayName);
+ }
+
+ [Fact]
+ public void WithRedactedSecretValueIsNoOpForNonSecret()
+ {
+ var recipe = new RenameClassRecipe { From = "Foo", To = "Bar" };
+ var opt = recipe.GetDescriptor().Options[0];
+ Assert.Same(opt, opt.WithRedactedSecretValue());
+ }
+
+}
+
+class RecipeWithSecret : OpenRewrite.Core.Recipe
+{
+ [Option(DisplayName = "API token", Description = "API token used by the recipe.", Secret = true)]
+ public required string ApiToken { get; init; }
+
+ public override string DisplayName => "Recipe with secret";
+ public override string Description => "Recipe with secret.";
+
+ public override JavaVisitor extends Recipe {
diff --git a/rewrite-javascript/rewrite/test/secret-option.test.ts b/rewrite-javascript/rewrite/test/secret-option.test.ts
new file mode 100644
index 00000000000..c74b8e06252
--- /dev/null
+++ b/rewrite-javascript/rewrite/test/secret-option.test.ts
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * 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.
+ */
+import {ExecutionContext, noopVisitor, Option, Recipe, TreeVisitor} from "../src";
+
+class RecipeWithSecret extends Recipe {
+ name = "org.example.RecipeWithSecret";
+ displayName = "Recipe with secret";
+ description = "Recipe with secret option.";
+
+ @Option({
+ displayName: "API token",
+ description: "API token used by the recipe.",
+ secret: true,
+ })
+ apiToken!: string;
+
+ constructor(options: { apiToken: string }) {
+ super(options);
+ }
+
+ async editor(): Promise
+# Licensed under the Moderne Source Available License (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://docs.moderne.io/licensing/moderne-source-available-license
+#
+# 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.
+
+"""Tests for the secret flag on recipe options."""
+
+from dataclasses import dataclass, field
+from typing import Any
+
+from rewrite import Recipe, OptionDescriptor, option
+from rewrite.rpc.server import _recipe_descriptor_to_dict
+
+
+@dataclass
+class RecipeWithSecret(Recipe):
+ """A recipe with a secret option."""
+
+ api_token: str = field(
+ default="",
+ metadata=option(
+ display_name="API token",
+ description="API token used by the recipe.",
+ secret=True,
+ ),
+ )
+
+ @property
+ def name(self) -> str:
+ return "org.example.RecipeWithSecret"
+
+ @property
+ def display_name(self) -> str:
+ return "Recipe with secret"
+
+ @property
+ def description(self) -> str:
+ return "Recipe with secret."
+
+
+class TestSecretOption:
+ """Tests for the secret flag on OptionDescriptor."""
+
+ def test_option_descriptor_defaults_to_non_secret(self):
+ desc = OptionDescriptor(display_name="x", description="x")
+ assert desc.secret is False
+
+ def test_option_factory_can_mark_secret(self):
+ meta = option(display_name="API token", description="API token.", secret=True)
+ desc: OptionDescriptor = meta["option"]
+ assert desc.secret is True
+
+ def test_recipe_field_with_secret_option_exposes_flag(self):
+ recipe = RecipeWithSecret(api_token="hunter2")
+ recipe_descriptor = recipe.descriptor()
+ # options is List[tuple[str, Any, OptionDescriptor]]
+ name, value, opt = recipe_descriptor.options[0]
+ assert name == "api_token"
+ assert value == "hunter2"
+ assert opt.secret is True
+ # Raw value is preserved on the Python-side descriptor; redaction is a
+ # persistence-boundary concern, not a source-level concern.
+
+ def test_rpc_serialization_includes_secret_flag(self):
+ """The Java peer must learn that an option is secret over the wire.
+
+ `_recipe_descriptor_to_dict` is the canonical Python -> JSON serializer
+ consumed by the Java-side RPC bridge."""
+ recipe = RecipeWithSecret(api_token="hunter2")
+ d: dict[str, Any] = _recipe_descriptor_to_dict(recipe.descriptor())
+
+ opt = d["options"][0]
+ assert opt["name"] == "api_token"
+ assert opt["secret"] is True
+ # The Python serializer ships the raw value; the Java side is responsible
+ # for redaction at its persistence boundaries (RecipeMarketplaceWriter,
+ # moderne-cli trace.json, moderne-saas event log).
+ assert opt["value"] == "hunter2"