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

com.android.tools.lint.checks.NetworkSecurityConfigDetector Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * 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.android.tools.lint.checks;

import com.android.annotations.NonNull;
import com.android.ide.common.res2.AbstractResourceRepository;
import com.android.ide.common.resources.ResourceUrl;
import com.android.resources.ResourceFolderType;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.ResourceXmlDetector;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.XmlContext;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;

/**
 * Check which makes sure that a network-security-config descriptor file is valid and logical
 */
public class NetworkSecurityConfigDetector extends ResourceXmlDetector {

    public static final Implementation IMPLEMENTATION = new Implementation(
            NetworkSecurityConfigDetector.class,
            Scope.RESOURCE_FILE_SCOPE);

    /**
     * Validate the entire network-security-config descriptor.
     */
    public static final Issue ISSUE = Issue.create(
            "NetworkSecurityConfig",
            "Valid Network Security Config File",

            "Ensures that a `` file, which is pointed to by an " +
            "`android:networkSecurityConfig` attribute in the manifest file, is valid",

            Category.CORRECTNESS,
            5,
            Severity.FATAL,
            IMPLEMENTATION)
            .addMoreInfo("https://developer.android.com/preview/features/security-config.html");

    /**
     * Validate the pin-set expiration attribute and warn if the expiry is in the
     * near future.
     */
    public static final Issue PIN_SET_EXPIRY = Issue.create(
            "PinSetExpiry",
            "Validate `` expiration attribute",

            "Ensures that the `expiration` attribute of the `` element is valid and has " +
            "not already expired or is expiring soon",

            Category.CORRECTNESS,
            6,
            Severity.WARNING,
            IMPLEMENTATION)
            .addMoreInfo("https://developer.android.com/preview/features/security-config.html");

    /**
     * No backup pin specified
     */
    public static final Issue MISSING_BACKUP_PIN = Issue.create(
            "MissingBackupPin",
            "Missing Backup Pin",

            "It is highly recommended to declare a backup `` element. " +
            "Not having a second pin defined can cause connection failures when the " +
            "particular site certificate is rotated and the app has not yet been updated.",

            Category.CORRECTNESS,
            6,
            Severity.WARNING,
            IMPLEMENTATION)
            .addMoreInfo("https://developer.android.com/preview/features/security-config.html");

    public static final String ATTR_DIGEST = "digest";

    private static final String TAG_NETWORK_SECURITY_CONFIG =
            "network-security-config";
    private static final String TAG_BASE_CONFIG = "base-config";
    private static final String TAG_DOMAIN_CONFIG = "domain-config";
    private static final String TAG_DEBUG_OVERRIDES = "debug-overrides";
    private static final String TAG_DOMAIN = "domain";
    private static final String TAG_PIN_SET = "pin-set";
    private static final String TAG_TRUST_ANCHORS = "trust-anchors";
    private static final String TAG_CERTIFICATES = "certificates";
    private static final String TAG_PIN = "pin";

    private static final String ATTR_SRC = "src";
    private static final String ATTR_INCLUDE_SUBDOMAINS = "includeSubdomains";
    private static final String ATTR_EXPIRATION = "expiration";
    private static final String ATTR_CLEARTEXT_TRAFFIC_PERMITTED =
            "cleartextTrafficPermitted";

    private static final String INVALID_DIGEST_ALGORITHM =
            "Invalid digest algorithm. Supported digests: `%1$s`";
    private static final String PIN_DIGEST_ALGORITHM = "SHA-256";
    // SHA 256 bit = 32 bytes
    private static final int PIN_DECODED_DIGEST_LEN_SHA_256 = 32;
    private static final Set VALID_CONFIG_TAGS =
            ImmutableSet.of(TAG_DOMAIN, TAG_TRUST_ANCHORS, TAG_PIN_SET, TAG_DOMAIN_CONFIG);
    public static final Set VALID_BASE_TAGS =
            ImmutableSet.of(TAG_DOMAIN_CONFIG, TAG_BASE_CONFIG, TAG_DEBUG_OVERRIDES);
    private static final String UNEXPECTED_ELEMENT_MESSAGE = "Unexpected element `<%1$s>`";
    private static final String ALREADY_DECLARED_MESSAGE = "Already declared here";

    /**
     * Constructs a new {@link NetworkSecurityConfigDetector}
     */
    public NetworkSecurityConfigDetector() {
    }

    /**
     * Keep track of whether the debug-overrides element was seen in one of the
     * network-security-config files.
     *
     * Context: When an app is debuggable, a file named $config_resource$_debug.xml is
     * also looked up by framework to check for debug overrides.
     */
    private Location.Handle mDebugOverridesHandle;

    @Override
    public boolean appliesTo(@NonNull ResourceFolderType folderType) {
        return folderType == ResourceFolderType.XML;
    }

    @Override
    public void beforeCheckProject(@NonNull Context context) {
        mDebugOverridesHandle = null;
    }

    @Override
    public void visitDocument(@NonNull XmlContext context, @NonNull Document document) {
        Element root = document.getDocumentElement();
        if (root == null) {
            return;
        }
        if (!TAG_NETWORK_SECURITY_CONFIG.equals(root.getTagName())) {
            return;
        }

        Location.Handle baseConfigHandle = null;
        Map seenDomains2Nodes = Maps.newHashMap();

        // 0 or 1 of 
        // Any number of 
        // 0 or 1 of 
        for (Element child : LintUtils.getChildren(root)) {
            String tagName = child.getTagName();
            if (TAG_BASE_CONFIG.equals(tagName)) {
                if (baseConfigHandle != null) {
                    reportExceeded(context, TAG_BASE_CONFIG, child, baseConfigHandle);
                } else {
                    baseConfigHandle = context.createLocationHandle(child);
                    handleConfigElement(context, child, seenDomains2Nodes);
                }
            } else if (TAG_DEBUG_OVERRIDES.equals(tagName)) {
                if (mDebugOverridesHandle != null) {
                    reportExceeded(context, TAG_DEBUG_OVERRIDES, child, mDebugOverridesHandle);
                } else {
                    mDebugOverridesHandle = context.createLocationHandle(child);
                    handleConfigElement(context, child, seenDomains2Nodes);
                }
            } else if (TAG_DOMAIN_CONFIG.equals(tagName)) {
                handleConfigElement(context, child, seenDomains2Nodes);
            } else {
                // It's possible to check for only the tags that can appear
                // by looking at `seenBaseConfig` and `seenDebugOverrides` but that may
                // be unnecessary. We can let the developer first fix the spelling
                // and then revalidate the values to check for duplicates (according to rules).
                if (!checkForTyposInTags(context, child, VALID_BASE_TAGS)) {
                    context.report(ISSUE, child, context.getNameLocation(child),
                            String.format(UNEXPECTED_ELEMENT_MESSAGE, tagName));
                }
            }
        }
    }

    private void handleConfigElement(XmlContext context, Element config,
            @NonNull Map seenDomainsToLocations) {
        String configName = config.getTagName();
        boolean isDomainConfig = TAG_DOMAIN_CONFIG.equals(configName);
        String message = "`%1$s` element not allowed in `%2$s`";
        // Assumption: Multiple trust-anchors and pinSetNode elements are not allowed within
        // a single domain-config. Nested domain-config elements can still have them.
        Node trustAnchorsNode = null;
        Node pinSetNode = null;

        checkForTyposInAttributes(context, config, ATTR_CLEARTEXT_TRAFFIC_PERMITTED, false);

        for (Element node : LintUtils.getChildren(config)) {
            String tagName = node.getTagName();
            if (TAG_DOMAIN.equals(tagName)) {
                if (!isDomainConfig) {
                    context.report(ISSUE, node, context.getNameLocation(node),
                            String.format(message, TAG_DOMAIN, configName));
                } else {
                    checkForTyposInAttributes(context, node, ATTR_INCLUDE_SUBDOMAINS, true);
                    String domainName = node.getTextContent().trim().toLowerCase(Locale.US);
                    if (seenDomainsToLocations.containsKey(domainName)) {
                        String duplicateMessage = "Duplicate domain names are not allowed";
                        Node previousNode = seenDomainsToLocations.get(domainName);
                        context.report(ISSUE, node.getFirstChild(),
                                context.getLocation(node.getFirstChild()).withSecondary(
                                        context.getLocation(previousNode),
                                        ALREADY_DECLARED_MESSAGE),
                                duplicateMessage);
                    } else {
                        seenDomainsToLocations.put(domainName, node.getFirstChild());
                    }
                }
            } else if (TAG_TRUST_ANCHORS.equals(tagName)) {
                if (trustAnchorsNode != null) {
                    String anchorMessage = "Multiple `` elements are not allowed";
                    context.report(ISSUE, node,
                            context.getNameLocation(node).withSecondary(
                                    context.getNameLocation(trustAnchorsNode),
                                    ALREADY_DECLARED_MESSAGE),
                            anchorMessage);
                } else {
                    trustAnchorsNode = node;
                    handleTrustAnchors(context, node);
                }
            } else if (TAG_DOMAIN_CONFIG.equals(tagName)) {
                if (!isDomainConfig) {
                    // If the parent is any config other than a domain-config report an error
                    context.report(ISSUE, node, context.getNameLocation(node),
                            String.format(
                                    "Nested `` elements are not allowed in `%1$s`",
                                    configName));
                } else {
                    handleConfigElement(context, node, seenDomainsToLocations);
                }
            } else if (TAG_PIN_SET.equals(tagName)) {
                if (!isDomainConfig) {
                    context.report(ISSUE, node, context.getNameLocation(node),
                            String.format(message, TAG_PIN_SET, configName));
                }
                if (pinSetNode != null) {
                    String pinSetMessage = "Multiple `` elements are not allowed";
                    context.report(ISSUE, node,
                            context.getNameLocation(node).withSecondary(
                                    context.getNameLocation(pinSetNode),
                                    ALREADY_DECLARED_MESSAGE),
                            pinSetMessage);
                } else {
                    pinSetNode = node;
                    handlePinSet(context, node);
                }
            } else {
                // Note: Only typos are marked as errors here to be forward compatible
                // where new elements are added here.
                checkForTyposInTags(context, node, VALID_CONFIG_TAGS);
            }
        }

        if (isDomainConfig && seenDomainsToLocations.isEmpty()) {
            context.report(ISSUE, config, context.getNameLocation(config),
                    "No `` elements in ``");
        }
    }

    private static void handlePinSet(XmlContext context, Element node) {

        if (node.hasAttribute(ATTR_EXPIRATION)) {
            Attr expirationAttr = node.getAttributeNode(ATTR_EXPIRATION);
            String message = null;
            try {
                LocalDate date = LocalDate.parse(expirationAttr.getValue(),
                        DateTimeFormatter.ISO_LOCAL_DATE);
                // If the pin-set has already expired report a warning.
                LocalDate now = LocalDate.now();
                if (date.isBefore(now)) {
                    message = "`pin-set` has already expired";
                } else if (date.isBefore(now.plusDays(10))) {
                    // OR if the pin-set will expire within 10 days from now
                    message = "`pin-set` is expiring soon";
                }
            } catch (DateTimeParseException e) {
                context.report(ISSUE, expirationAttr, context.getValueLocation(expirationAttr),
                        "Invalid expiration in `pin-set`");
            }

            if (message != null) {
                context.report(PIN_SET_EXPIRY, expirationAttr,
                        context.getValueLocation(expirationAttr), message);
            }
        } else {
            checkForTyposInAttributes(context, node, ATTR_EXPIRATION, false);
        }

        int pinElementCount = 0;
        boolean foundTyposInPin = false;
        for (Element child : LintUtils.getChildren(node)) {
            String tagName = child.getTagName();
            if (TAG_PIN.equals(tagName)) {
                pinElementCount += 1;
                if (child.hasAttribute(ATTR_DIGEST)) {
                    Attr digestAttr = child.getAttributeNode(ATTR_DIGEST);
                    if (!PIN_DIGEST_ALGORITHM.equalsIgnoreCase(digestAttr.getValue())) {
                        String values = LintUtils.formatList(getSupportedPinDigestAlgorithms(), 2);
                        context.report(ISSUE, digestAttr, context.getValueLocation(digestAttr),
                                String.format(INVALID_DIGEST_ALGORITHM, values));
                    }
                } else {
                    checkForTyposInAttributes(context, child, ATTR_DIGEST, true);
                }
                Node digestNode;
                if (!child.hasChildNodes()
                        || (digestNode = child.getFirstChild()) == null
                        || digestNode.getNodeType() != Node.TEXT_NODE) {
                    // missing text node
                    context.report(ISSUE, child, context.getLocation(child), "Missing pin digest");
                } else {
                    try {
                        // Validate the actual data
                        byte[] decodedDigest =
                                Base64.getDecoder().decode(digestNode.getNodeValue());
                        if (decodedDigest.length != PIN_DECODED_DIGEST_LEN_SHA_256) {
                            // incorrect digest length
                            String message = String.format(
                                    "Decoded digest length `%1$d` does not match expected "
                                            + "length for `%2$s` of `%3$d`",
                              decodedDigest.length,
                              PIN_DIGEST_ALGORITHM, PIN_DECODED_DIGEST_LEN_SHA_256);
                            context.report(ISSUE, digestNode, context.getLocation(digestNode),
                              message);
                        }
                    } catch (Exception ex) {
                        context.report(ISSUE, digestNode, context.getLocation(digestNode),
                                "Invalid pin digest");
                    }
                }
            } else {
                foundTyposInPin |=
                        checkForTyposInTags(context, child, Collections.singleton(TAG_PIN));
            }
        }
        // Let the developer fix the typos before we can ascertain that the pin is missing
        if (!foundTyposInPin) {
            if (pinElementCount == 0) {
                context.report(ISSUE, node, context.getNameLocation(node),
                        "Missing `` element(s)");
            } else if (pinElementCount == 1) {
                // We should probably check to see if both hashes are the same here.
                context.report(MISSING_BACKUP_PIN, node, context.getNameLocation(node),
                        "A backup `` declaration is highly recommended");
            }
        }
    }

    private static void handleTrustAnchors(XmlContext context, Element node) {
        for (Element child : LintUtils.getChildren(node)) {
            if (TAG_CERTIFICATES.equals(child.getTagName())) {
                if (!child.hasAttribute(ATTR_SRC)) {
                    checkForTyposInAttributes(context, child, ATTR_SRC, true);
                } else {
                    Attr sourceIdAttr = child.getAttributeNode(ATTR_SRC);
                    String sourceId = sourceIdAttr.getValue();
                    ResourceUrl resourceUrl = ResourceUrl.parse(sourceId);
                    if (context.getClient().supportsProjectResources()
                            && resourceUrl != null
                            && !resourceUrl.framework) {
                        // ensure that this is a valid resource
                        AbstractResourceRepository resources = context.getClient()
                          .getResourceRepository(context.getProject(), true, false);
                        if (resources != null
                                && !resources.hasResourceItem(resourceUrl.type, resourceUrl.name)) {
                            context.report(ISSUE, sourceIdAttr,
                                    context.getValueLocation(sourceIdAttr),
                                    "Missing `src` resource.");
                        }
                    }
                    // The value should be either "system", "user" or a resource Id
                    if (resourceUrl == null
                            && !"user".equals(sourceId)
                            && !"system".equals(sourceId)) {
                        context.report(ISSUE, sourceIdAttr, context.getValueLocation(sourceIdAttr),
                                "Unknown certificates `src` attribute. "
                                        + "Expecting `system`, `user` or an @resource value");
                    }
                }
            } else {
                checkForTyposInTags(context, child, Collections.singleton(TAG_CERTIFICATES));
            }
        }
    }

    private static boolean checkForTyposInTags(XmlContext context, Element node,
            Collection validPossibleTags) {
        String tagName = node.getTagName();
        List suggestions = generateTypoSuggestions(tagName, validPossibleTags);
        if (suggestions != null) {
            assert !suggestions.isEmpty();
            String suggestionString;
            if (suggestions.size() == 1) {
                suggestionString = suggestions.get(0);
            } else if (suggestions.size() == 2) {
                suggestionString = String.format("%1$s or %2$s",
                        suggestions.get(0), suggestions.get(1));
            } else {
                suggestionString = LintUtils.formatList(suggestions, -1);
            }
            String message = String.format("Misspelled tag `<%1$s>`: Did you mean `%2$s` ?",
                    tagName, suggestionString);

            context.report(ISSUE, node, context.getNameLocation(node), message);
            return true;
        }
        return false;
    }

    private static void checkForTyposInAttributes(XmlContext context, Element node,
            String attrName, boolean requiredAttribute) {
        if (node.hasAttribute(attrName)) {
            return;
        }
        List suggestions = null;
        NamedNodeMap attributes = node.getAttributes();
        boolean foundSpellingError = false;
        Set validAttributeNames = Collections.singleton(attrName);
        for (int i = 0; i < attributes.getLength(); i++) {
            Node attr = attributes.item(i);
            String nodeName = attr.getNodeName();
            if (nodeName != null) {
                suggestions = generateTypoSuggestions(nodeName, validAttributeNames);
            }
            if (suggestions != null && suggestions.size() == 1) {
                context.report(ISSUE, attr, context.getNameLocation(attr),
                        String.format("Misspelled attribute `%1$s`: Did you mean `%2$s` ?",
                                nodeName, attrName));
                foundSpellingError |= true;
            }
        }
        if (!foundSpellingError && requiredAttribute) {
            context.report(ISSUE, node, context.getNameLocation(node),
                    String.format("Missing `%1$s` attribute", attrName));
        }
    }

    private static List generateTypoSuggestions(@NonNull String name,
            @NonNull Collection validAttributeNames) {
        List suggestions = null;
        for (String suggestion : validAttributeNames) {
            if (LintUtils.isEditableTo(suggestion, name, 3)) {
                if (suggestions == null) {
                    suggestions = new ArrayList<>(validAttributeNames.size());
                }
                suggestions.add(suggestion);
            }
        }
        return suggestions;
    }

    private static void reportExceeded(XmlContext context, String elementName, Element element,
            @NonNull Location.Handle handle) {
        context.report(ISSUE, element, context.getNameLocation(element)
                        .withSecondary(handle.resolve(), ALREADY_DECLARED_MESSAGE),
                String.format("Expecting at most 1 `<%1$s>`", elementName));
    }

    /**
     * For a given error message created by this lint detector, returns whether the error
     * was due to a typo in an attribute name.
     * This is primarily for use by IDE quick fixes.
     *
     * @param errorMessage The error message associated with this detector.
     * @return true if this is a spelling error in an attribute.
     */
    @SuppressWarnings("unused")
    public static boolean isAttributeSpellingError(@NonNull String errorMessage) {
        return errorMessage.startsWith("Misspelled attribute");
    }

    /**
     * For a given misspelled attribute, return the allowed suggestions/corrections.
     *
     * @param errorAttribute the misspelled attribute
     * @param parentTag the parent tag used for determining the allowed attributes
     * @return list of strings containing the suggestions or null if no suggestions
     */
    @SuppressWarnings("unused")
    @NonNull
    public static List getAttributeSpellingSuggestions(@NonNull String errorAttribute,
      @NonNull String parentTag) {
        Collection validAttributes;
        switch (parentTag) {
            case TAG_BASE_CONFIG: // fallthrough
            case TAG_DOMAIN_CONFIG: // fallthrough
            case TAG_DEBUG_OVERRIDES:
                validAttributes = Collections.singleton(ATTR_CLEARTEXT_TRAFFIC_PERMITTED);
                break;
            case TAG_CERTIFICATES:
                validAttributes = Collections.singleton(ATTR_SRC);
                break;
            case TAG_DOMAIN:
                validAttributes = Collections.singleton(ATTR_INCLUDE_SUBDOMAINS);
                break;
            case TAG_PIN_SET:
                validAttributes = Collections.singleton(ATTR_EXPIRATION);
                break;
            case TAG_PIN:
                validAttributes = Collections.singleton(ATTR_DIGEST);
                break;
            default:
                return Collections.emptyList();
        }
        List result = generateTypoSuggestions(errorAttribute, validAttributes);
        return result == null ? Collections.emptyList() : result;
    }

    /**
     * @param errorMessage The error message associated with this detector.
     * @return true if this is a spelling error in the element name.
     */
    @SuppressWarnings("unused")
    public static boolean isTagSpellingError(@NonNull String errorMessage) {
        return errorMessage.startsWith("Misspelled tag");
    }

    /**
     * For a given misspelled attribute, return the allowed suggestions/corrections.
     *
     * @param errorTag the misspelled attribute
     * @param parentTag the parent tag used for determining the allowed attributes
     * @return list of strings containing the suggestions or null if no suggestions
     */
    @SuppressWarnings("unused")
    @NonNull
    public static List getTagSpellingSuggestions(@NonNull String errorTag,
      @NonNull String parentTag) {
        Collection validTags;
        switch (parentTag) {
            case TAG_NETWORK_SECURITY_CONFIG:
                validTags = VALID_BASE_TAGS;
                break;
            case TAG_BASE_CONFIG: // fallthrough
            case TAG_DOMAIN_CONFIG: // fallthrough
            case TAG_DEBUG_OVERRIDES:
                validTags = VALID_CONFIG_TAGS;
                break;
            case TAG_TRUST_ANCHORS:
                validTags = Collections.singleton(TAG_CERTIFICATES);
                break;
            case TAG_PIN_SET:
                validTags = Collections.singleton(TAG_PIN);
                break;
            default:
                return Collections.emptyList();
        }
        List result = generateTypoSuggestions(errorTag, validTags);
        return result == null ? Collections.emptyList() : result;
    }

    /**
     * Used by the IDE for quick fixes.
     *
     * @return supported pin digest algorithms
     */
    public static List getSupportedPinDigestAlgorithms() {
        return Collections.singletonList(PIN_DIGEST_ALGORITHM);
    }

    @SuppressWarnings("unused")
    public static boolean isInvalidDigestAlgorithmMessage(String message) {
        return message.startsWith("Invalid digest algorithm");
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy