All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.github.robtimus.obfuscation.yaml.YAMLObfuscator Maven / Gradle / Ivy

There is a newer version: 1.3
Show newest version
/*
 * YAMLObfuscator.java
 * Copyright 2020 Rob Spoor
 *
 * 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
 *
 *     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 com.github.robtimus.obfuscation.yaml;

import static com.github.robtimus.obfuscation.support.ObfuscatorUtils.checkStartAndEnd;
import static com.github.robtimus.obfuscation.support.ObfuscatorUtils.copyTo;
import static com.github.robtimus.obfuscation.support.ObfuscatorUtils.discardAll;
import static com.github.robtimus.obfuscation.support.ObfuscatorUtils.reader;
import static com.github.robtimus.obfuscation.support.ObfuscatorUtils.skipLeadingWhitespace;
import static com.github.robtimus.obfuscation.support.ObfuscatorUtils.skipTrailingWhitespace;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.snakeyaml.engine.v2.api.LoadSettings;
import org.snakeyaml.engine.v2.common.Anchor;
import org.snakeyaml.engine.v2.common.ScalarStyle;
import org.snakeyaml.engine.v2.events.Event;
import org.snakeyaml.engine.v2.events.ScalarEvent;
import org.snakeyaml.engine.v2.exceptions.YamlEngineException;
import org.snakeyaml.engine.v2.parser.Parser;
import org.snakeyaml.engine.v2.parser.ParserImpl;
import org.snakeyaml.engine.v2.scanner.StreamReader;
import com.github.robtimus.obfuscation.Obfuscator;
import com.github.robtimus.obfuscation.support.CachingObfuscatingWriter;
import com.github.robtimus.obfuscation.support.CaseSensitivity;
import com.github.robtimus.obfuscation.support.MapBuilder;

/**
 * An obfuscator that obfuscates YAML properties in {@link CharSequence CharSequences} or the contents of {@link Reader Readers}.
 *
 * @author Rob Spoor
 */
public final class YAMLObfuscator extends Obfuscator {

    private static final Logger LOGGER = LoggerFactory.getLogger(YAMLObfuscator.class);

    private final Map properties;

    private final LoadSettings settings;

    private final String malformedYAMLWarning;

    private YAMLObfuscator(ObfuscatorBuilder builder) {
        properties = builder.properties();

        settings = LoadSettings.builder()
                // be as lenient as possible
                .setAllowDuplicateKeys(true)
                .setAllowRecursiveKeys(true)
                // use marks, as they are needed for obfuscating text
                .setUseMarks(true)
                .build();

        malformedYAMLWarning = builder.malformedYAMLWarning;
    }

    @Override
    public CharSequence obfuscateText(CharSequence s, int start, int end) {
        checkStartAndEnd(s, start, end);
        StringBuilder sb = new StringBuilder(end - start);
        obfuscateText(s, start, end, sb);
        return sb.toString();
    }

    @Override
    public void obfuscateText(CharSequence s, int start, int end, Appendable destination) throws IOException {
        checkStartAndEnd(s, start, end);
        @SuppressWarnings("resource")
        Reader input = reader(s, start, end);
        obfuscateText(input, s, start, end, destination);
    }

    @Override
    public void obfuscateText(Reader input, Appendable destination) throws IOException {
        StringBuilder contents = new StringBuilder();
        @SuppressWarnings("resource")
        Reader reader = copyTo(input, contents);
        obfuscateText(reader, contents, 0, -1, destination);
    }

    private void obfuscateText(Reader input, CharSequence s, int start, int end, Appendable destination) throws IOException {
        Parser parser = new ParserImpl(new StreamReader(input, settings), settings);
        Context context = new Context(parser, s, start, end, destination);
        Event event = null;
        try {
            while (context.hasNextEvent()) {
                event = context.nextEvent();
                if (event.getEventId() == Event.ID.Scalar && context.hasCurrentFieldName()) {
                    String property = context.currentFieldName();
                    PropertyConfig propertyConfig = properties.get(property);
                    if (propertyConfig != null) {
                        obfuscateProperty(propertyConfig, context);
                    }
                }
            }
            // read the remainder so the final append will include all text
            discardAll(input);
            context.appendRemainder();
        } catch (YamlEngineException e) {
            LOGGER.warn(Messages.YAMLObfuscator.malformedYAML.warning.get(), e);
            if (malformedYAMLWarning != null) {
                destination.append(malformedYAMLWarning);
            }
        }
    }

    private void obfuscateProperty(PropertyConfig propertyConfig, Context context) throws IOException {
        Event event = context.nextEvent();
        switch (event.getEventId()) {
        case SequenceStart:
            if (!propertyConfig.obfuscateSequences) {
                // there is an obfuscator for the sequence property, but the obfuscation mode prohibits obfuscating sequences;
                // abort and continue with the next property
                return;
            }
            context.appendUntilEvent(event);
            obfuscateNested(propertyConfig.obfuscator, context, event, Event.ID.SequenceStart, Event.ID.SequenceEnd);
            break;
        case MappingStart:
            if (!propertyConfig.obfuscateMappings) {
                // there is an obfuscator for the mapping property, but the obfuscation mode prohibits obfuscating mappings;
                // abort and continue with the next property
                return;
            }
            context.appendUntilEvent(event);
            obfuscateNested(propertyConfig.obfuscator, context, event, Event.ID.MappingStart, Event.ID.MappingEnd);
            break;
        case Scalar:
            context.appendUntilEvent(event);
            obfuscateScalar(propertyConfig.obfuscator, context, event);
            break;
        default:
            // do nothing
            break;
        }
    }

    private void obfuscateNested(Obfuscator obfuscator, Context context, Event startEvent, Event.ID beginEventId, Event.ID endEventId)
            throws IOException {

        int depth = 1;
        Event endEvent = null;
        while (depth > 0 && context.hasNextEvent()) {
            endEvent = context.nextEvent();
            if (endEvent.getEventId() == beginEventId) {
                depth++;
            } else if (endEvent.getEventId() == endEventId) {
                depth--;
            }
        }
        context.obfuscateUntilEvent(startEvent, endEvent, obfuscator);
    }

    private void obfuscateScalar(Obfuscator obfuscator, Context context, Event event) throws IOException {
        context.obfuscateEvent(event, obfuscator);
    }

    private static final class Context {
        private final Parser parser;
        private final CharSequence text;
        private final Appendable destination;

        private final int textOffset;
        private final int textEnd;
        private int textIndex;

        // Snakeyaml reports field names as Scalar events. The only difference with actual values is the current state.
        // We need to keep track of this state, more specifically whether or not the current structure is a mapping or no, and the current field name.
        private final Deque structureStack = new ArrayDeque<>();
        private String currentFieldName;

        private Context(Parser parser, CharSequence source, int start, int end, Appendable destination) {
            this.parser = parser;
            this.text = source;
            this.textOffset = start;
            this.textEnd = end;
            this.textIndex = start;
            this.destination = destination;
        }

        private boolean hasNextEvent() {
            return parser.hasNext();
        }

        private Event nextEvent() {
            Event event = parser.next();
            Event.ID eventId = event.getEventId();
            switch (eventId) {
            case StreamStart:
            case DocumentStart:
            case SequenceStart:
            case MappingStart:
                structureStack.addLast(eventId);
                currentFieldName = null;
                break;
            case StreamEnd:
            case DocumentEnd:
            case SequenceEnd:
            case MappingEnd:
                structureStack.removeLast();
                currentFieldName = null;
                break;
            case Alias:
                currentFieldName = null;
                break;
            case Scalar:
                if (structureStack.peekLast() == Event.ID.MappingStart && currentFieldName == null) {
                    // directly inside a sequence, and there is no current field name, so this must be it
                    currentFieldName = ((ScalarEvent) event).getValue();
                } else {
                    currentFieldName = null;
                }
                break;
            default:
                break;
            }
            return event;
        }

        private boolean hasCurrentFieldName() {
            return currentFieldName != null;
        }

        private String currentFieldName() {
            return currentFieldName;
        }

        private void appendUntilEvent(Event event) throws IOException {
            int eventStartIndex = startIndex(event);
            destination.append(text, textIndex, eventStartIndex);
            textIndex = eventStartIndex;
        }

        private void obfuscateUntilEvent(Event startEvent, Event endEvent, Obfuscator obfuscator) throws IOException {
            int startEventStartIndex = startIndex(startEvent);
            int eventEndIndex = endIndex(endEvent);
            // don't include any trailing white space
            eventEndIndex = skipTrailingWhitespace(text, startEventStartIndex, eventEndIndex);

            obfuscator.obfuscateText(text, startEventStartIndex, eventEndIndex, destination);
            textIndex = eventEndIndex;
        }

        private void obfuscateEvent(Event event, Obfuscator obfuscator) throws IOException {
            int eventStartIndex = startIndex(event);
            int eventEndIndex = endIndex(event);

            ScalarEvent scalarEvent = (ScalarEvent) event;

            // don't obfuscate any anchor
            eventStartIndex = appendAnchor(scalarEvent, eventStartIndex, eventEndIndex);

            // don't obfuscate any " or ' around the actual value
            ScalarStyle scalarStyle = scalarEvent.getScalarStyle();
            if (scalarStyle == ScalarStyle.DOUBLE_QUOTED || scalarStyle == ScalarStyle.SINGLE_QUOTED) {
                destination.append(text.charAt(eventStartIndex));
                obfuscator.obfuscateText(text, eventStartIndex + 1, eventEndIndex - 1, destination);
                destination.append(text.charAt(eventEndIndex - 1));
            } else {
                obfuscator.obfuscateText(text, eventStartIndex, eventEndIndex, destination);
            }
            textIndex = eventEndIndex;
        }

        private int appendAnchor(ScalarEvent event, int eventStartIndex, int eventEndIndex) throws IOException {
            Optional anchor = event.getAnchor();
            if (anchor.isPresent()) {
                // skip past the & and anchor name
                int newStartIndex = eventStartIndex;
                // In snakeyaml-engine 2.0, class Anchor has method getAnchor()
                // In snakeyaml-engine 2.1 that was replaced by method getValue()
                // To support both, use toString() that returns the anchor/value for both versions
                newStartIndex += 1 + anchor.get().toString().length();
                newStartIndex = skipLeadingWhitespace(text, newStartIndex, eventEndIndex);
                destination.append(text, eventStartIndex, newStartIndex);
                return newStartIndex;
            }
            return eventStartIndex;
        }

        private void appendRemainder() throws IOException {
            int end = textEnd == -1 ? text.length() : textEnd;
            destination.append(text, textIndex, end);
            textIndex = end;
        }

        private int startIndex(Event event) {
            return textOffset + event.getStartMark().get().getIndex();
        }

        private int endIndex(Event event) {
            return textOffset + event.getEndMark().get().getIndex();
        }
    }

    @Override
    public Writer streamTo(Appendable destination) {
        return new CachingObfuscatingWriter(this, destination);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || o.getClass() != getClass()) {
            return false;
        }
        YAMLObfuscator other = (YAMLObfuscator) o;
        return properties.equals(other.properties)
                && Objects.equals(malformedYAMLWarning, other.malformedYAMLWarning);
    }

    @Override
    public int hashCode() {
        return properties.hashCode() ^ Objects.hashCode(malformedYAMLWarning);
    }

    @Override
    @SuppressWarnings("nls")
    public String toString() {
        return getClass().getName()
                + "[properties=" + properties
                + ",malformedYAMLWarning=" + malformedYAMLWarning
                + "]";
    }

    /**
     * Returns a builder that will create {@code YAMLObfuscators}.
     *
     * @return A builder that will create {@code YAMLObfuscators}.
     */
    public static Builder builder() {
        return new ObfuscatorBuilder();
    }

    /**
     * A builder for {@link YAMLObfuscator YAMLObfuscators}.
     *
     * @author Rob Spoor
     */
    public abstract static class Builder {

        private Builder() {
            super();
        }

        /**
         * Adds a property to obfuscate.
         * This method is an alias for {@link #withProperty(String, Obfuscator, CaseSensitivity)} with the last specified default case sensitivity
         * using {@link #caseSensitiveByDefault()} or {@link #caseInsensitiveByDefault()}. The default is {@link CaseSensitivity#CASE_SENSITIVE}.
         *
         * @param property The name of the property.
         * @param obfuscator The obfuscator to use for obfuscating the property.
         * @return An object that can be used to configure the property, or continue building {@link YAMLObfuscator YAMLObfuscators}.
         * @throws NullPointerException If the given property name or obfuscator is {@code null}.
         * @throws IllegalArgumentException If a property with the same name and the same case sensitivity was already added.
         */
        public abstract PropertyConfigurer withProperty(String property, Obfuscator obfuscator);

        /**
         * Adds a property to obfuscate.
         *
         * @param property The name of the property.
         * @param obfuscator The obfuscator to use for obfuscating the property.
         * @param caseSensitivity The case sensitivity for the property.
         * @return An object that can be used to configure the property, or continue building {@link YAMLObfuscator YAMLObfuscators}.
         * @throws NullPointerException If the given property name, obfuscator or case sensitivity is {@code null}.
         * @throws IllegalArgumentException If a property with the same name and the same case sensitivity was already added.
         */
        public abstract PropertyConfigurer withProperty(String property, Obfuscator obfuscator, CaseSensitivity caseSensitivity);

        /**
         * Sets the default case sensitivity for new properties to {@link CaseSensitivity#CASE_SENSITIVE}. This is the default setting.
         * 

* Note that this will not change the case sensitivity of any property that was already added. * * @return This object. */ public abstract Builder caseSensitiveByDefault(); /** * Sets the default case sensitivity for new properties to {@link CaseSensitivity#CASE_INSENSITIVE}. *

* Note that this will not change the case sensitivity of any property that was already added. * * @return This object. */ public abstract Builder caseInsensitiveByDefault(); /** * Indicates that by default properties will not be obfuscated if they are YAML mappings or sequences. * This method is shorthand for calling both {@link #excludeMappingsByDefault()} and {@link #excludeSequencesByDefault()}. *

* Note that this will not change what will be obfuscated for any property that was already added. * * @return This object. */ public Builder scalarsOnlyByDefault() { return excludeMappingsByDefault() .excludeSequencesByDefault(); } /** * Indicates that by default properties will not be obfuscated if they are YAML mappings. * This can be overridden per property using {@link PropertyConfigurer#excludeMappings()} *

* Note that this will not change what will be obfuscated for any property that was already added. * * @return This object. */ public abstract Builder excludeMappingsByDefault(); /** * Indicates that by default properties will not be obfuscated if they are YAML sequences. * This can be overridden per property using {@link PropertyConfigurer#excludeSequences()} *

* Note that this will not change what will be obfuscated for any property that was already added. * * @return This object. */ public abstract Builder excludeSequencesByDefault(); /** * Indicates that by default properties will be obfuscated if they are YAML mappings or sequences (default). * This method is shorthand for calling both {@link #includeMappingsByDefault()} and {@link #includeSequencesByDefault()}. *

* Note that this will not change what will be obfuscated for any property that was already added. * * @return This object. */ public Builder allByDefault() { return includeMappingsByDefault() .includeSequencesByDefault(); } /** * Indicates that by default properties will be obfuscated if they are YAML mappings (default). * This can be overridden per property using {@link PropertyConfigurer#excludeMappings()} *

* Note that this will not change what will be obfuscated for any property that was already added. * * @return This object. */ public abstract Builder includeMappingsByDefault(); /** * Indicates that by default properties will be obfuscated if they are YAML sequences (default). * This can be overridden per property using {@link PropertyConfigurer#excludeSequences()} *

* Note that this will not change what will be obfuscated for any property that was already added. * * @return This object. */ public abstract Builder includeSequencesByDefault(); /** * Sets the warning to include if a {@link YamlEngineException} is thrown. * This can be used to override the default message. Use {@code null} to omit the warning. * * @param warning The warning to include. * @return This object. */ public abstract Builder withMalformedYAMLWarning(String warning); /** * This method allows the application of a function to this builder. *

* Any exception thrown by the function will be propagated to the caller. * * @param The type of the result of the function. * @param f The function to apply. * @return The result of applying the function to this builder. */ public R transform(Function f) { return f.apply(this); } /** * Creates a new {@code YAMLObfuscator} with the properties and obfuscators added to this builder. * * @return The created {@code YAMLObfuscator}. */ public abstract YAMLObfuscator build(); } /** * An object that can be used to configure a property that should be obfuscated. * * @author Rob Spoor */ public abstract static class PropertyConfigurer extends Builder { private PropertyConfigurer() { super(); } /** * Indicates that properties with the current name will not be obfuscated if they are YAML mappings or sequences. * This method is shorthand for calling both {@link #excludeMappings()} and {@link #excludeSequences()}. * * @return An object that can be used to configure the property, or continue building {@link YAMLObfuscator YAMLObfuscators}. */ public PropertyConfigurer scalarsOnly() { return excludeMappings() .excludeSequences(); } /** * Indicates that properties with the current name will not be obfuscated if they are YAML mappings. * * @return An object that can be used to configure the property, or continue building {@link YAMLObfuscator YAMLObfuscators}. */ public abstract PropertyConfigurer excludeMappings(); /** * Indicates that properties with the current name will not be obfuscated if they are YAML sequences. * * @return An object that can be used to configure the property, or continue building {@link YAMLObfuscator YAMLObfuscators}. */ public abstract PropertyConfigurer excludeSequences(); /** * Indicates that properties with the current name will be obfuscated if they are YAML mappings or sequences. * This method is shorthand for calling both {@link #includeMappings()} and {@link #includeSequences()}. * * @return An object that can be used to configure the property, or continue building {@link YAMLObfuscator YAMLObfuscators}. */ public PropertyConfigurer all() { return includeMappings() .includeSequences(); } /** * Indicates that properties with the current name will be obfuscated if they are YAML mappings. * * @return An object that can be used to configure the property, or continue building {@link YAMLObfuscator YAMLObfuscators}. */ public abstract PropertyConfigurer includeMappings(); /** * Indicates that properties with the current name will be obfuscated if they are YAML sequences. * * @return An object that can be used to configure the property, or continue building {@link YAMLObfuscator YAMLObfuscators}. */ public abstract PropertyConfigurer includeSequences(); } private static final class ObfuscatorBuilder extends PropertyConfigurer { private final MapBuilder properties; private String malformedYAMLWarning; // default settings private boolean obfuscateMappingsByDefault; private boolean obfuscateSequencesByDefault; // per property settings private String property; private Obfuscator obfuscator; private CaseSensitivity caseSensitivity; private boolean obfuscateMappings; private boolean obfuscateSequences; private ObfuscatorBuilder() { properties = new MapBuilder<>(); malformedYAMLWarning = Messages.YAMLObfuscator.malformedYAML.text.get(); obfuscateMappingsByDefault = true; obfuscateSequencesByDefault = true; } @Override public PropertyConfigurer withProperty(String property, Obfuscator obfuscator) { addLastProperty(); properties.testEntry(property); this.property = property; this.obfuscator = obfuscator; this.caseSensitivity = null; this.obfuscateMappings = obfuscateMappingsByDefault; this.obfuscateSequences = obfuscateSequencesByDefault; return this; } @Override public PropertyConfigurer withProperty(String property, Obfuscator obfuscator, CaseSensitivity caseSensitivity) { addLastProperty(); properties.testEntry(property, caseSensitivity); this.property = property; this.obfuscator = obfuscator; this.caseSensitivity = caseSensitivity; this.obfuscateMappings = obfuscateMappingsByDefault; this.obfuscateSequences = obfuscateSequencesByDefault; return this; } @Override public PropertyConfigurer caseSensitiveByDefault() { properties.caseSensitiveByDefault(); return this; } @Override public PropertyConfigurer caseInsensitiveByDefault() { properties.caseInsensitiveByDefault(); return this; } @Override public Builder excludeMappingsByDefault() { obfuscateMappingsByDefault = false; return this; } @Override public Builder excludeSequencesByDefault() { obfuscateSequencesByDefault = false; return this; } @Override public Builder includeMappingsByDefault() { obfuscateMappingsByDefault = true; return this; } @Override public Builder includeSequencesByDefault() { obfuscateSequencesByDefault = true; return this; } @Override public PropertyConfigurer excludeMappings() { obfuscateMappings = false; return this; } @Override public PropertyConfigurer excludeSequences() { obfuscateSequences = false; return this; } @Override public PropertyConfigurer includeMappings() { obfuscateMappings = true; return this; } @Override public PropertyConfigurer includeSequences() { obfuscateSequences = true; return this; } @Override public Builder withMalformedYAMLWarning(String warning) { malformedYAMLWarning = warning; return this; } private Map properties() { return properties.build(); } private void addLastProperty() { if (property != null) { PropertyConfig propertyConfig = new PropertyConfig(obfuscator, obfuscateMappings, obfuscateSequences); if (caseSensitivity != null) { properties.withEntry(property, propertyConfig, caseSensitivity); } else { properties.withEntry(property, propertyConfig); } } property = null; obfuscator = null; caseSensitivity = null; obfuscateMappings = obfuscateMappingsByDefault; obfuscateSequences = obfuscateSequencesByDefault; } @Override public YAMLObfuscator build() { addLastProperty(); return new YAMLObfuscator(this); } } private static final class PropertyConfig { private final Obfuscator obfuscator; private final boolean obfuscateMappings; private final boolean obfuscateSequences; private PropertyConfig(Obfuscator obfuscator, boolean obfuscateMappings, boolean obfuscateSequences) { this.obfuscator = Objects.requireNonNull(obfuscator); this.obfuscateMappings = obfuscateMappings; this.obfuscateSequences = obfuscateSequences; } @Override public boolean equals(Object o) { // null and different types should not occur PropertyConfig other = (PropertyConfig) o; return obfuscator.equals(other.obfuscator) && obfuscateMappings == other.obfuscateMappings && obfuscateSequences == other.obfuscateSequences; } @Override public int hashCode() { return obfuscator.hashCode() ^ Boolean.hashCode(obfuscateMappings) ^ Boolean.hashCode(obfuscateSequences); } @Override @SuppressWarnings("nls") public String toString() { return "[obfuscator=" + obfuscator + ",obfuscateMappings=" + obfuscateMappings + ",obfuscateSequences=" + obfuscateSequences + "]"; } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy