Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions rewrite-core/src/main/java/org/openrewrite/Option.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,19 @@
String[] valid() default "";

boolean required() default true;

/**
* Indicates the value is sensitive (an API token, password, etc.).
* <p>
* 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.
* <p>
* 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;
}
5 changes: 4 additions & 1 deletion rewrite-core/src/main/java/org/openrewrite/Recipe.java
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public String getInstanceName() {
}

List<OptionDescriptor> options = new ArrayList<>(getOptionDescriptors());
options.removeIf(opt -> !opt.isRequired());
options.removeIf(opt -> !opt.isRequired() || opt.isSecret());
if (options.isEmpty()) {
return getDisplayName();
}
Expand Down Expand Up @@ -276,6 +276,7 @@ private List<OptionDescriptor> getOptionDescriptors() {
option.example().isEmpty() ? null : option.example(),
validValues(option, field.getType()),
option.required(),
option.secret(),
value));
}
}
Expand All @@ -291,6 +292,7 @@ private List<OptionDescriptor> getOptionDescriptors() {
option.example().isEmpty() ? null : option.example(),
validValues(option, method.getReturnType()),
option.required(),
option.secret(),
null));
}
}
Expand All @@ -307,6 +309,7 @@ private List<OptionDescriptor> getOptionDescriptors() {
option.example().isEmpty() ? null : option.example(),
validValues(option, parameter.getType()),
option.required(),
option.secret(),
null));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,19 @@ public class OptionDescriptor {

boolean required;

boolean secret;

@Nullable
@EqualsAndHashCode.Include
Object value;

/**
* If this option is marked {@link #secret}, returns a copy with {@link #value} nulled.
* Otherwise returns this instance unchanged. Use at persistence and external-transport
* boundaries to ensure secret values are not written to durable storage.
*/
public OptionDescriptor withRedactedSecretValue() {
return secret ? new OptionDescriptor(name, type, displayName, description,
example, valid, required, true, null) : this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ private String optionsToJson(List<OptionDescriptor> options) {
return "";
}
try {
return JSON_MAPPER.writeValueAsString(options);
List<OptionDescriptor> toSerialize = options.stream().anyMatch(OptionDescriptor::isSecret)
? options.stream().map(OptionDescriptor::withRedactedSecretValue).collect(toList())
: options;
return JSON_MAPPER.writeValueAsString(toSerialize);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize options to JSON", e);
}
Expand Down
195 changes: 195 additions & 0 deletions rewrite-core/src/test/java/org/openrewrite/SecretOptionTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* Copyright 2026 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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");
}
}
42 changes: 42 additions & 0 deletions rewrite-csharp/csharp/OpenRewrite.Tests/Recipe/RecipeTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenRewrite.Core.ExecutionContext> GetVisitor() =>
new CSharpVisitor<OpenRewrite.Core.ExecutionContext>();
}

class RenameClassRecipe : OpenRewrite.Core.Recipe
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,7 @@ public class OptionDescriptorDto
public string? Example { get; set; }
public List<string>? Valid { get; set; }
public bool Required { get; set; }
public bool Secret { get; set; }
public object? Value { get; set; }

public static OptionDescriptorDto FromDescriptor(OptionDescriptor d)
Expand All @@ -1582,6 +1583,7 @@ public static OptionDescriptorDto FromDescriptor(OptionDescriptor d)
Example = d.Example,
Valid = d.Valid?.ToList(),
Required = d.Required,
Secret = d.Secret,
Value = d.Value
};
}
Expand Down
11 changes: 11 additions & 0 deletions rewrite-csharp/csharp/OpenRewrite/Core/OptionAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,15 @@ public sealed class OptionAttribute : Attribute
public string[]? Valid { get; set; }

public bool Required { get; set; } = true;

/// <summary>
/// Indicates the value is sensitive (an API token, password, etc.).
/// <para>
/// Consumers persisting or displaying option values are expected to redact secret values.
/// The corresponding <see cref="OptionDescriptor.Value"/> is left raw at the source so the
/// value can still be passed into recipe execution (including across RPC); redaction happens
/// at each persistence boundary.
/// </para>
/// </summary>
public bool Secret { get; set; } = false;
}
12 changes: 11 additions & 1 deletion rewrite-csharp/csharp/OpenRewrite/Core/OptionDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,15 @@ public record OptionDescriptor(
string? Example,
IReadOnlyList<string>? Valid,
bool Required,
bool Secret,
object? Value
);
)
{
/// <summary>
/// If this option is marked <see cref="Secret"/>, returns a copy with <see cref="Value"/>
/// nulled. Otherwise returns this instance unchanged. Use at persistence and external-
/// transport boundaries to ensure secret values are not written to durable storage.
/// </summary>
public OptionDescriptor WithRedactedSecretValue() =>
Secret ? this with { Value = null } : this;
}
1 change: 1 addition & 0 deletions rewrite-csharp/csharp/OpenRewrite/Core/Recipe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ private List<OptionDescriptor> GetOptionDescriptors()
attr.Valid is { Length: > 0 } v && !(v.Length == 1 && v[0] == "")
? v.ToList() : null,
attr.Required,
attr.Secret,
value
));
}
Expand Down
Loading
Loading