This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 845389afae Fixes #8730 : Keep the values of password fields out of
lint findings (#8737)
845389afae is described below
commit 845389afae13202c746572d82f8e8f43241df8da
Author: Bart Maertens <[email protected]>
AuthorDate: Fri Oct 9 15:20:53 2026 +0200
Fixes #8730 : Keep the values of password fields out of lint findings
(#8737)
* Fixes #8730 : Keep the values of password fields out of lint findings
* Issue #8730 : Hide the values of fields Hop stores as passwords in lint
findings, not only those named like one
---
.../org/apache/hop/lint/CustomRuleExecutor.java | 177 +++++++++----
.../apache/hop/lint/SecretValueInFindingTest.java | 290 +++++++++++++++++++++
2 files changed, 421 insertions(+), 46 deletions(-)
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
index 65b133bb7e..a2bf58c678 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/CustomRuleExecutor.java
@@ -171,7 +171,12 @@ public class CustomRuleExecutor {
}
if (evaluateCondition(
clause.getCondition(), clauseValue, clause.getConditionValue(),
rule)) {
- violated.add(clause.describe() + " (actual: " +
describeValue(clauseValue) + ")");
+ violated.add(
+ clause.describe()
+ + " (actual: "
+ + describeValue(
+ clauseValue, holdsASecret(rule, hopObject,
clause.getTargetField()))
+ + ")");
} else if (rule.getCombinator() == RuleCombinator.ALL_OF &&
rule.isComposed()) {
// allOf needs every clause broken, so one satisfied clause ends it.
return results;
@@ -220,7 +225,9 @@ public class CustomRuleExecutor {
boolean violatesRule = rule.isComposed() ? !violated.isEmpty() :
violated.size() == 1;
if (violatesRule) {
- String message = generateErrorMessage(rule, fieldValue);
+ String message =
+ generateErrorMessage(
+ rule, holdsASecret(rule, hopObject, rule.getTargetField()) ?
null : fieldValue);
if (rule.isComposed()) {
message =
message
@@ -775,34 +782,12 @@ public class CustomRuleExecutor {
try {
Class<?> clazz = obj.getClass();
- List<Field> fields = getAllFields(clazz);
-
- // The name Hop serialises the property under comes first. That is the
name a rule author
- // actually sees, in the .hpl or .hwf file and in the metadata JSON, and
it is the one that
- // survives a rename of the Java field behind it.
- for (Field field : fields) {
- if (fieldName.equals(serialisedNameOf(field))) {
- field.setAccessible(true);
- return field.get(obj);
- }
- }
-
- // Then a declared field anywhere in the hierarchy, and its value is
returned as-is.
- // Skipping null or empty values here used to make them
indistinguishable from a missing
- // field, so a rule like "url NOT_EMPTY" could never fire.
- for (Field field : fields) {
- if (field.getName().equals(fieldName)) {
- field.setAccessible(true);
- return field.get(obj);
- }
- }
- // Then the same match ignoring case, because rules are hand-written
YAML and Hop's own
- // field names are inconsistent about it ("fileName" here, "filename"
there).
- for (Field field : fields) {
- if (field.getName().equalsIgnoreCase(fieldName)) {
- field.setAccessible(true);
- return field.get(obj);
- }
+ // The value is returned as-is. Skipping null or empty values here used
to make them
+ // indistinguishable from a missing field, so a rule like "url
NOT_EMPTY" could never fire.
+ Field named = fieldNamed(clazz, fieldName);
+ if (named != null) {
+ named.setAccessible(true);
+ return named.get(obj);
}
// Then a getter, which covers metas that expose a value they do not
store directly.
@@ -875,10 +860,13 @@ public class CustomRuleExecutor {
}
/** A field value as it should read inside a composed rule's message. */
- private static String describeValue(Object value) {
+ private static String describeValue(Object value, boolean secret) {
if (value == null) {
return "null";
}
+ if (secret) {
+ return "hidden";
+ }
String text = value.toString();
return text.length() > 60 ? text.substring(0, 57) + "..." : text;
}
@@ -918,6 +906,40 @@ public class CustomRuleExecutor {
return Utils.isEmpty(property.key()) ? field.getName() : property.key();
}
+ /**
+ * The declared field a rule's name refers to, or null when no field carries
that name.
+ *
+ * @param clazz the class to look in, superclasses included
+ * @param fieldName the name the rule uses
+ * @return the field, or null
+ */
+ private static Field fieldNamed(Class<?> clazz, String fieldName) {
+ List<Field> fields = getAllFields(clazz);
+
+ // The name Hop serialises the property under comes first. That is the
name a rule author
+ // actually sees, in the .hpl or .hwf file and in the metadata JSON, and
it is the one that
+ // survives a rename of the Java field behind it.
+ for (Field field : fields) {
+ if (fieldName.equals(serialisedNameOf(field))) {
+ return field;
+ }
+ }
+ // Then a declared field anywhere in the hierarchy.
+ for (Field field : fields) {
+ if (field.getName().equals(fieldName)) {
+ return field;
+ }
+ }
+ // Then the same match ignoring case, because rules are hand-written YAML
and Hop's own
+ // field names are inconsistent about it ("fileName" here, "filename"
there).
+ for (Field field : fields) {
+ if (field.getName().equalsIgnoreCase(fieldName)) {
+ return field;
+ }
+ }
+ return null;
+ }
+
/** Get all fields from a class hierarchy */
static List<Field> getAllFields(Class<?> clazz) {
List<Field> fields = new ArrayList<>();
@@ -1020,23 +1042,86 @@ public class CustomRuleExecutor {
return
field.getName().toLowerCase().endsWith(pattern.trim().toLowerCase());
}
+ /**
+ * Whether the value a clause reads is a secret, and so must stay out of the
finding.
+ *
+ * <p>A finding's message ends up in CI build logs, JSON and SARIF reports
and the GUI. DB-001
+ * used to report a hardcoded database password as "(current value:
secret123)", decrypting an
+ * {@code Encrypted} one on the way, so the rule meant to catch exposed
passwords exposed them.
+ *
+ * <p>The field decides, not the condition: Hop stores it as a password, or
its name ends like a
+ * secret's, with the default name patterns as well as the rule's own.
Underscores are ignored so
+ * the serialised {@code secret_access_key} matches {@code secretAccessKey}.
+ */
+ private static boolean holdsASecret(CustomLintRule rule, Object hopObject,
String fieldName) {
+ if (Utils.isEmpty(fieldName)) {
+ return false;
+ }
+ if (storedAsPassword(hopObject, fieldName)) {
+ return true;
+ }
+ String name = withoutUnderscores(fieldName);
+ for (List<String> patterns :
+ List.of(DEFAULT_SECRET_FIELD_PATTERNS,
getPasswordFieldPatterns(rule))) {
+ for (String pattern : patterns) {
+ if (!Utils.isEmpty(pattern) &&
name.endsWith(withoutUnderscores(pattern))) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static String withoutUnderscores(String name) {
+ return name.trim().replace("_", "").toLowerCase();
+ }
+
+ /**
+ * Whether the field a rule reads is one Hop stores as a password ({@code
@HopMetadataProperty
+ * password = true}), which covers secrets such as {@code accessKey} or
{@code
+ * oauth_jwt_private_key} that no name pattern catches.
+ */
+ private static boolean storedAsPassword(Object hopObject, String fieldName) {
+ Object holder = hopObject;
+ if (hopObject instanceof TransformMeta transformMeta) {
+ holder = transformMeta.getTransform();
+ } else if (hopObject instanceof ActionMeta actionMeta) {
+ holder = actionMeta.getAction();
+ }
+ String name = fieldName;
+ int dot = fieldName.lastIndexOf('.');
+ if (dot > 0) {
+ holder = extractFieldFromObject(holder, fieldName.substring(0, dot));
+ name = fieldName.substring(dot + 1);
+ }
+ if (holder == null || holder == FIELD_NOT_FOUND) {
+ return false;
+ }
+ Field field = fieldNamed(holder.getClass(), name);
+ HopMetadataProperty property =
+ field != null ? field.getAnnotation(HopMetadataProperty.class) : null;
+ return property != null && property.password();
+ }
+
+ private static final List<String> DEFAULT_SECRET_FIELD_PATTERNS =
+ Arrays.asList(
+ "password",
+ "pwd",
+ "passwd",
+ "secret",
+ "secretKey",
+ "credential",
+ "credentials",
+ "apiKey",
+ "apikey",
+ "secretAccessKey",
+ "token",
+ "accessToken",
+ "authToken");
+
/** Get password field patterns from rule parameters or return defaults */
private static List<String> getPasswordFieldPatterns(CustomLintRule rule) {
- List<String> defaultPatterns =
- Arrays.asList(
- "password",
- "pwd",
- "passwd",
- "secret",
- "secretKey",
- "credential",
- "credentials",
- "apiKey",
- "apikey",
- "secretAccessKey",
- "token",
- "accessToken",
- "authToken");
+ List<String> defaultPatterns = DEFAULT_SECRET_FIELD_PATTERNS;
if (rule.getAdditionalParameters() != null) {
Object patternsObj = rule.getAdditionalParameters().get("fieldPatterns");
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/SecretValueInFindingTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/SecretValueInFindingTest.java
new file mode 100644
index 0000000000..d626e9885f
--- /dev/null
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/SecretValueInFindingTest.java
@@ -0,0 +1,290 @@
+/*
+ * 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.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.metadata.api.HopMetadata;
+import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.ITransform;
+import org.apache.hop.pipeline.transform.ITransformData;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.workflow.action.ActionBase;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A finding never carries the value of a secret.
+ *
+ * <p>DB-001 reported a hardcoded database password as "(current value:
secret123)", in CI logs,
+ * JSON and SARIF reports and the GUI, so the rule meant to catch exposed
passwords exposed them.
+ *
+ * @see <a href="https://github.com/apache/hop/issues/8730">#8730</a>
+ */
+public class SecretValueInFindingTest {
+
+ private static DatabaseMeta connection(String hostname, String username,
String password) {
+ DatabaseMeta databaseMeta = new DatabaseMeta();
+ databaseMeta.setName("CRM");
+ databaseMeta.setHostname(hostname);
+ databaseMeta.setUsername(username);
+ databaseMeta.setPassword(password);
+ return databaseMeta;
+ }
+
+ /** The core pack's DB-001, as it ships. */
+ private static CustomLintRule hardcodedDatabasePassword() {
+ CustomLintRule rule = new CustomLintRule();
+ rule.setId("DB-001");
+ rule.setEnabled(true);
+ rule.setSeverity("ERROR");
+ rule.setTarget(RuleTarget.DATABASE_CONNECTION);
+ rule.setTargetField("password");
+ rule.setCondition(RuleCondition.NO_HARDCODED);
+ rule.setName("Hardcoded Database Password");
+ rule.setDescription("Database connections must take the password from a
variable");
+ return rule;
+ }
+
+ private static String onlyMessage(CustomLintRule rule, DatabaseMeta
databaseMeta) {
+ List<LintResult> results =
+ CustomRuleExecutor.executeRule(rule, databaseMeta,
"/tmp/metadata/rdbms/CRM.json");
+ assertEquals(1, results.size(), "expected exactly one finding");
+ return results.get(0).getMessage();
+ }
+
+ @Test
+ public void aHardcodedPasswordIsReportedWithoutItsValue() {
+ String message =
+ onlyMessage(hardcodedDatabasePassword(), connection("localhost",
"crm", "secret123"));
+
+ assertFalse(message.contains("secret123"), message);
+ }
+
+ @Test
+ public void anEncryptedPasswordIsReportedWithoutEitherForm() {
+ String encrypted = Encr.encryptPasswordIfNotUsingVariables("secret123");
+
+ String message =
+ onlyMessage(hardcodedDatabasePassword(), connection("localhost",
"crm", encrypted));
+
+ assertFalse(message.contains("secret123"), message);
+ assertFalse(message.contains(encrypted), message);
+ }
+
+ @Test
+ public void aComposedRuleHidesThePasswordClause() {
+ CustomLintRule rule = new CustomLintRule();
+ rule.setId("CUSTOM-001");
+ rule.setEnabled(true);
+ rule.setSeverity("WARNING");
+ rule.setTarget(RuleTarget.DATABASE_CONNECTION);
+ rule.setTargetField("password");
+ rule.setCondition(RuleCondition.MATCHES_PATTERN);
+ rule.setConditionValue("^\\$\\{.*\\}$");
+ rule.setAdditionalClauses(List.of(new RuleClause("username",
RuleCondition.NOT_EMPTY, null)));
+ rule.setDescription("Use a variable for the password, and name a user");
+
+ String message = onlyMessage(rule, connection("localhost", "",
"secret123"));
+
+ assertFalse(message.contains("secret123"), message);
+ assertTrue(message.contains("actual: hidden"), message);
+ }
+
+ @Test
+ public void aValueThatIsNoSecretIsStillShown() {
+ CustomLintRule rule = new CustomLintRule();
+ rule.setId("CUSTOM-002");
+ rule.setEnabled(true);
+ rule.setSeverity("WARNING");
+ rule.setTarget(RuleTarget.DATABASE_CONNECTION);
+ rule.setTargetField("hostname");
+ rule.setCondition(RuleCondition.MATCHES_PATTERN);
+ rule.setConditionValue("^db\\..*");
+ rule.setDescription("Connections point at a db.* host");
+
+ String message = onlyMessage(rule, connection("localhost", "crm",
"secret123"));
+
+ assertTrue(message.contains("current value: localhost"), message);
+ }
+
+ /**
+ * A connection whose secrets Hop stores as passwords under names the
default patterns miss, as
+ * the S3, MinIO and Salesforce connections do.
+ */
+ @HopMetadata(key = "lint-test-storage-connection", name = "Storage
connection")
+ public static class StorageConnection extends HopMetadataBase {
+ @HopMetadataProperty private String endpoint =
"https://storage.example.com";
+
+ @HopMetadataProperty(password = true)
+ private String accessKey = "AKIAEXAMPLEKEY";
+
+ @HopMetadataProperty(key = "oauth_jwt_private_key", password = true)
+ private String oauthJwtPrivateKey = "-----BEGIN PRIVATE KEY-----";
+
+ /** A third-party plugin that names its secret but never flags it as a
password. */
+ @HopMetadataProperty(key = "secret_key")
+ private String signingKey = "s3cr3t-signing-key";
+
+ public StorageConnection() {
+ super("storage");
+ }
+ }
+
+ private static CustomLintRule metadataRule(String field, RuleCondition
condition, String value) {
+ CustomLintRule rule = new CustomLintRule();
+ rule.setId("CUSTOM-003");
+ rule.setEnabled(true);
+ rule.setSeverity("WARNING");
+ rule.setTarget(RuleTarget.METADATA);
+ rule.setTargetField(field);
+ rule.setCondition(condition);
+ rule.setConditionValue(value);
+ rule.setDescription("Use a variable");
+ return rule;
+ }
+
+ private static String onlyMessage(CustomLintRule rule, StorageConnection
connection) {
+ List<LintResult> results =
+ CustomRuleExecutor.executeRule(rule, connection,
"/tmp/metadata/storage/storage.json");
+ assertEquals(1, results.size(), "expected exactly one finding");
+ return results.get(0).getMessage();
+ }
+
+ @Test
+ public void aPasswordPropertyIsHiddenWhateverItIsNamed() {
+ String message =
+ onlyMessage(
+ metadataRule("accessKey", RuleCondition.MATCHES_PATTERN,
"^\\$\\{.*\\}$"),
+ new StorageConnection());
+
+ assertFalse(message.contains("AKIAEXAMPLEKEY"), message);
+ }
+
+ @Test
+ public void aPasswordPropertyNamedByItsKeyIsHidden() {
+ String message =
+ onlyMessage(
+ metadataRule("oauth_jwt_private_key",
RuleCondition.MATCHES_PATTERN, "^\\$\\{.*\\}$"),
+ new StorageConnection());
+
+ assertFalse(message.contains("BEGIN PRIVATE KEY"), message);
+ }
+
+ @Test
+ public void aSnakeCaseSecretKeyIsHidden() {
+ String message =
+ onlyMessage(
+ metadataRule("secret_key", RuleCondition.MATCHES_PATTERN,
"^\\$\\{.*\\}$"),
+ new StorageConnection());
+
+ assertFalse(message.contains("s3cr3t-signing-key"), message);
+ }
+
+ @Test
+ public void aComposedRuleHidesAPasswordPropertyClause() {
+ CustomLintRule rule = metadataRule("endpoint", RuleCondition.NOT_EMPTY,
null);
+ rule.setCondition(RuleCondition.MATCHES_PATTERN);
+ rule.setConditionValue("^\\$\\{.*\\}$");
+ rule.setAdditionalClauses(
+ List.of(new RuleClause("accessKey", RuleCondition.MATCHES_PATTERN,
"^\\$\\{.*\\}$")));
+
+ String message = onlyMessage(rule, new StorageConnection());
+
+ assertFalse(message.contains("AKIAEXAMPLEKEY"), message);
+ assertTrue(message.contains("actual: hidden"), message);
+ }
+
+ @Test
+ public void aHardcodedValueThatIsNoSecretIsStillShown() {
+ String message =
+ onlyMessage(
+ metadataRule("endpoint", RuleCondition.NO_HARDCODED, null), new
StorageConnection());
+
+ assertTrue(message.contains("current value: https://storage.example.com"),
message);
+ }
+
+ /** A transform that stores a secret as a password under a name no pattern
catches. */
+ public static class StreamTransformMeta extends
BaseTransformMeta<ITransform, ITransformData> {
+ @HopMetadataProperty(key = "access_key", password = true)
+ private String accessKey = "AKIATRANSFORMKEY";
+ }
+
+ /** The same for an action. */
+ public static class UploadAction extends ActionBase {
+ @HopMetadataProperty(password = true)
+ private String accessKey = "AKIAACTIONKEY";
+
+ @Override
+ public Result execute(Result previousResult, int nr) {
+ return previousResult;
+ }
+ }
+
+ private static CustomLintRule rule(RuleTarget target, String field) {
+ CustomLintRule rule = metadataRule(field, RuleCondition.MATCHES_PATTERN,
"^\\$\\{.*\\}$");
+ rule.setTarget(target);
+ return rule;
+ }
+
+ private static String onlyMessage(CustomLintRule rule, Object hopObject) {
+ List<LintResult> results = CustomRuleExecutor.executeRule(rule, hopObject,
"/tmp/test.hpl");
+ assertEquals(1, results.size(), "expected exactly one finding");
+ return results.get(0).getMessage();
+ }
+
+ private static TransformMeta streamTransform() {
+ TransformMeta transformMeta = new TransformMeta();
+ transformMeta.setName("Stream");
+ transformMeta.setTransformPluginId("StreamConsume");
+ transformMeta.setTransform(new StreamTransformMeta());
+ return transformMeta;
+ }
+
+ @Test
+ public void aTransformPasswordPropertyIsHiddenByItsJavaName() {
+ String message = onlyMessage(rule(RuleTarget.TRANSFORM, "accessKey"),
streamTransform());
+
+ assertFalse(message.contains("AKIATRANSFORMKEY"), message);
+ }
+
+ @Test
+ public void aTransformPasswordPropertyIsHiddenByItsKey() {
+ String message = onlyMessage(rule(RuleTarget.TRANSFORM, "access_key"),
streamTransform());
+
+ assertFalse(message.contains("AKIATRANSFORMKEY"), message);
+ }
+
+ @Test
+ public void anActionPasswordPropertyIsHidden() {
+ UploadAction action = new UploadAction();
+ action.setPluginId("Upload");
+ ActionMeta actionMeta = new ActionMeta(action);
+
+ String message = onlyMessage(rule(RuleTarget.ACTION, "accessKey"),
actionMeta);
+
+ assertFalse(message.contains("AKIAACTIONKEY"), message);
+ }
+}