/*
 * Copyright (C) 2011 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 static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_EXPORTED;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_PATH;
import static com.android.SdkConstants.ATTR_PATH_PATTERN;
import static com.android.SdkConstants.ATTR_PATH_PREFIX;
import static com.android.SdkConstants.ATTR_PERMISSION;
import static com.android.SdkConstants.ATTR_READ_PERMISSION;
import static com.android.SdkConstants.ATTR_WRITE_PERMISSION;
import static com.android.SdkConstants.TAG_ACTIVITY;
import static com.android.SdkConstants.TAG_APPLICATION;
import static com.android.SdkConstants.TAG_GRANT_PERMISSION;
import static com.android.SdkConstants.TAG_INTENT_FILTER;
import static com.android.SdkConstants.TAG_PATH_PERMISSION;
import static com.android.SdkConstants.TAG_PROVIDER;
import static com.android.SdkConstants.TAG_RECEIVER;
import static com.android.SdkConstants.TAG_SERVICE;
import static com.android.xml.AndroidManifest.NODE_ACTION;

import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.ConstantEvaluator;
import com.android.tools.lint.detector.api.Detector;
import com.android.tools.lint.detector.api.Detector.JavaPsiScanner;
import com.android.tools.lint.detector.api.Detector.XmlScanner;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.JavaContext;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Location;
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.intellij.psi.JavaElementVisitor;
import com.intellij.psi.PsiExpression;
import com.intellij.psi.PsiMethod;
import com.intellij.psi.PsiMethodCallExpression;
import com.intellij.psi.PsiReferenceExpression;

import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;

/**
 * Checks that exported services request a permission.
 */
public class SecurityDetector extends Detector implements XmlScanner, JavaPsiScanner {

    private static final Implementation IMPLEMENTATION_MANIFEST = new Implementation(
            SecurityDetector.class,
            Scope.MANIFEST_SCOPE);

    private static final Implementation IMPLEMENTATION_JAVA = new Implementation(
            SecurityDetector.class,
            Scope.JAVA_FILE_SCOPE);

    /** Exported services */
    public static final Issue EXPORTED_SERVICE = Issue.create(
            "ExportedService", //$NON-NLS-1$
            "Exported service does not require permission",
            "Exported services (services which either set `exported=true` or contain " +
            "an intent-filter and do not specify `exported=false`) should define a " +
            "permission that an entity must have in order to launch the service " +
            "or bind to it. Without this, any application can use this service.",
            Category.SECURITY,
            5,
            Severity.WARNING,
            IMPLEMENTATION_MANIFEST);

    /** Exported content providers */
    public static final Issue EXPORTED_PROVIDER = Issue.create(
            "ExportedContentProvider", //$NON-NLS-1$
            "Content provider does not require permission",
            "Content providers are exported by default and any application on the " +
            "system can potentially use them to read and write data. If the content " +
            "provider provides access to sensitive data, it should be protected by " +
            "specifying `export=false` in the manifest or by protecting it with a " +
            "permission that can be granted to other applications.",
            Category.SECURITY,
            5,
            Severity.WARNING,
            IMPLEMENTATION_MANIFEST);

    /** Exported receivers */
    public static final Issue EXPORTED_RECEIVER = Issue.create(
            "ExportedReceiver", //$NON-NLS-1$
            "Receiver does not require permission",
            "Exported receivers (receivers which either set `exported=true` or contain " +
            "an intent-filter and do not specify `exported=false`) should define a " +
            "permission that an entity must have in order to launch the receiver " +
            "or bind to it. Without this, any application can use this receiver.",
            Category.SECURITY,
            5,
            Severity.WARNING,
            IMPLEMENTATION_MANIFEST);

    /** Content provides which grant all URIs access */
    public static final Issue OPEN_PROVIDER = Issue.create(
            "GrantAllUris", //$NON-NLS-1$
            "Content provider shares everything",
            "The `<grant-uri-permission>` element allows specific paths to be shared. " +
            "This detector checks for a path URL of just '/' (everything), which is " +
            "probably not what you want; you should limit access to a subset.",
            Category.SECURITY,
            7,
            Severity.WARNING,
            IMPLEMENTATION_MANIFEST);

    /** Using java.io.File.setReadable(true, false) to set file world-readable */
    public static final Issue SET_READABLE = Issue.create(
            "SetWorldReadable",
            "`File.setReadable()` used to make file world-readable",
            "Setting files world-readable is very dangerous, and likely to cause security " +
            "holes in applications. It is strongly discouraged; instead, applications should " +
            "use more formal mechanisms for interactions such as `ContentProvider`, " +
            "`BroadcastReceiver`, and `Service`.",
            Category.SECURITY,
            6,
            Severity.WARNING,
            IMPLEMENTATION_JAVA);

    /** Using java.io.File.setWritable(true, false) to set file world-writable */
    public static final Issue SET_WRITABLE = Issue.create(
            "SetWorldWritable",
            "`File.setWritable()` used to make file world-writable",
            "Setting files world-writable is very dangerous, and likely to cause security " +
            "holes in applications. It is strongly discouraged; instead, applications should " +
            "use more formal mechanisms for interactions such as `ContentProvider`, " +
            "`BroadcastReceiver`, and `Service`.",
            Category.SECURITY,
            6,
            Severity.WARNING,
            IMPLEMENTATION_JAVA);

    /** Using the world-writable flag */
    public static final Issue WORLD_WRITEABLE = Issue.create(
            "WorldWriteableFiles", //$NON-NLS-1$
            "`openFileOutput()` or similar call passing `MODE_WORLD_WRITEABLE`",
            "There are cases where it is appropriate for an application to write " +
            "world writeable files, but these should be reviewed carefully to " +
            "ensure that they contain no private data, and that if the file is " +
            "modified by a malicious application it does not trick or compromise " +
            "your application.",
            Category.SECURITY,
            4,
            Severity.WARNING,
            IMPLEMENTATION_JAVA);


    /** Using the world-readable flag */
    public static final Issue WORLD_READABLE = Issue.create(
            "WorldReadableFiles", //$NON-NLS-1$
            "`openFileOutput()` or similar call passing `MODE_WORLD_READABLE`",
            "There are cases where it is appropriate for an application to write " +
            "world readable files, but these should be reviewed carefully to " +
            "ensure that they contain no private data that is leaked to other " +
            "applications.",
            Category.SECURITY,
            4,
            Severity.WARNING,
            IMPLEMENTATION_JAVA);

    private static final String FILE_CLASS = "java.io.File"; //$NON-NLS-1$

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

    // ---- Implements Detector.XmlScanner ----

    @Override
    public Collection<String> getApplicableElements() {
        return Arrays.asList(
            TAG_SERVICE,
            TAG_GRANT_PERMISSION,
            TAG_PROVIDER,
            TAG_ACTIVITY,
            TAG_RECEIVER
        );
    }

    @Override
    public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
        String tag = element.getTagName();
        if (tag.equals(TAG_SERVICE)) {
            checkService(context, element);
        } else if (tag.equals(TAG_GRANT_PERMISSION)) {
            checkGrantPermission(context, element);
        } else if (tag.equals(TAG_PROVIDER)) {
            checkProvider(context, element);
        } else if (tag.equals(TAG_RECEIVER)) {
            checkReceiver(context, element);
        }
    }

    public static boolean getExported(Element element) {
        // Used to check whether an activity, service or broadcast receiver is exported.
        String exportValue = element.getAttributeNS(ANDROID_URI, ATTR_EXPORTED);
        if (exportValue != null && !exportValue.isEmpty()) {
            return Boolean.valueOf(exportValue);
        } else {
            for (Element child : LintUtils.getChildren(element)) {
                if (child.getTagName().equals(TAG_INTENT_FILTER)) {
                    return true;
                }
            }
        }

      return false;
    }

    private static boolean isUnprotectedByPermission(Element element) {
        // Used to check whether an activity, service or broadcast receiver are
        // protected by a permission.
        String permission = element.getAttributeNS(ANDROID_URI, ATTR_PERMISSION);
        if (permission == null || permission.isEmpty()) {
            Node parent = element.getParentNode();
            if (parent.getNodeType() == Node.ELEMENT_NODE
                    && parent.getNodeName().equals(TAG_APPLICATION)) {
                Element application = (Element) parent;
                permission = application.getAttributeNS(ANDROID_URI, ATTR_PERMISSION);
                return permission == null || permission.isEmpty();
            }
        }

        return false;
    }

    private static boolean isWearableBindListener(@NonNull Element element) {
        // Checks whether a service has an Android Wear bind listener
        for (Element child : LintUtils.getChildren(element)) {
            if (child.getTagName().equals(TAG_INTENT_FILTER)) {
                for (Element innerChild : LintUtils.getChildren(child)) {
                    if (innerChild.getTagName().equals(NODE_ACTION)) {
                        String name = innerChild.getAttributeNS(ANDROID_URI, ATTR_NAME);
                        if ("com.google.android.gms.wearable.BIND_LISTENER".equals(name)) {
                            return true;
                        }
                    }
                }
            }
        }

        return false;
    }

    private static boolean isStandardReceiver(Element element) {
        // Play Services also the following receiver which we'll consider standard
        // in the sense that it doesn't require a separate permission
        String name = element.getAttributeNS(ANDROID_URI, ATTR_NAME);
        if ("com.google.android.gms.tagmanager.InstallReferrerReceiver".equals(name)) {
            return true;
        }

        // Checks whether a broadcast receiver receives a standard Android action
        for (Element child : LintUtils.getChildren(element)) {
            if (child.getTagName().equals(TAG_INTENT_FILTER)) {
                for (Element innerChild : LintUtils.getChildren(child)) {
                    if (innerChild.getTagName().equals(NODE_ACTION)) {
                        String categoryString = innerChild.getAttributeNS(ANDROID_URI, ATTR_NAME);
                        return categoryString.startsWith("android."); //$NON-NLS-1$
                    }
                }
            }
        }

        return false;
    }

    private static void checkReceiver(XmlContext context, Element element) {
        if (getExported(element) && isUnprotectedByPermission(element) &&
                !isStandardReceiver(element)) {
            // No declared permission for this exported receiver: complain
            context.report(EXPORTED_RECEIVER, element, context.getLocation(element),
                           "Exported receiver does not require permission");
        }
    }

    private static void checkService(XmlContext context, Element element) {
        if (getExported(element) && isUnprotectedByPermission(element)
                && !isWearableBindListener(element)) {
            // No declared permission for this exported service: complain
            context.report(EXPORTED_SERVICE, element, context.getLocation(element),
                           "Exported service does not require permission");
        }
    }

    private static void checkGrantPermission(XmlContext context, Element element) {
        Attr path = element.getAttributeNodeNS(ANDROID_URI, ATTR_PATH);
        Attr prefix = element.getAttributeNodeNS(ANDROID_URI, ATTR_PATH_PREFIX);
        Attr pattern = element.getAttributeNodeNS(ANDROID_URI, ATTR_PATH_PATTERN);

        String msg = "Content provider shares everything; this is potentially dangerous.";
        if (path != null && path.getValue().equals("/")) { //$NON-NLS-1$
            context.report(OPEN_PROVIDER, path, context.getLocation(path), msg);
        }
        if (prefix != null && prefix.getValue().equals("/")) { //$NON-NLS-1$
            context.report(OPEN_PROVIDER, prefix, context.getLocation(prefix), msg);
        }
        if (pattern != null && (pattern.getValue().equals("/")  //$NON-NLS-1$
               /* || pattern.getValue().equals(".*")*/)) {
            context.report(OPEN_PROVIDER, pattern, context.getLocation(pattern), msg);
        }
    }

    private static void checkProvider(XmlContext context, Element element) {
        String exportValue = element.getAttributeNS(ANDROID_URI, ATTR_EXPORTED);
        // Content providers are exported by default
        boolean exported = true;
        if (exportValue != null && !exportValue.isEmpty()) {
            exported = Boolean.valueOf(exportValue);
        }

        if (exported) {
            // Just check for some use of permissions. Other Lint checks can check the saneness
            // of the permissions. We'll accept the permission, readPermission, or writePermission
            // attributes on the provider element, or a path-permission element.
            String permission = element.getAttributeNS(ANDROID_URI, ATTR_READ_PERMISSION);
            if (permission == null || permission.isEmpty()) {
                permission = element.getAttributeNS(ANDROID_URI, ATTR_WRITE_PERMISSION);
                if (permission == null || permission.isEmpty()) {
                    permission = element.getAttributeNS(ANDROID_URI, ATTR_PERMISSION);
                    if (permission == null || permission.isEmpty()) {
                        // No permission attributes? Check for path-permission.

                        // TODO: Add a Lint check to ensure the path-permission is good, similar to
                        // the grant-uri-permission check.
                        boolean hasPermission = false;
                        for (Element child : LintUtils.getChildren(element)) {
                            String tag = child.getTagName();
                            if (tag.equals(TAG_PATH_PERMISSION)) {
                                hasPermission = true;
                                break;
                            }
                        }

                        if (!hasPermission) {
                            context.report(EXPORTED_PROVIDER, element,
                                    context.getLocation(element),
                                    "Exported content providers can provide access to " +
                                            "potentially sensitive data");
                        }
                    }
                }
            }
        }
    }

    // ---- Implements Detector.JavaScanner ----

    @Override
    public List<String> getApplicableMethodNames() {
        // These are the API calls that can accept a MODE_WORLD_READABLE/MODE_WORLD_WRITEABLE
        // argument.
        List<String> values = new ArrayList<String>(3);
        values.add("openFileOutput"); //$NON-NLS-1$
        values.add("getSharedPreferences"); //$NON-NLS-1$
        values.add("getDir"); //$NON-NLS-1$
        // These API calls can be used to set files world-readable or world-writable
        values.add("setReadable"); //$NON-NLS-1$
        values.add("setWritable"); //$NON-NLS-1$
        return values;
    }

    @Override
    public void visitMethod(@NonNull JavaContext context, @Nullable JavaElementVisitor visitor,
            @NonNull PsiMethodCallExpression node, @NonNull PsiMethod method) {
        PsiExpression[] args = node.getArgumentList().getExpressions();
        String methodName = node.getMethodExpression().getReferenceName();
        if (context.getEvaluator().isMemberInSubClassOf(method, FILE_CLASS, false)) {
            // Report calls to java.io.File.setReadable(true, false) or
            // java.io.File.setWritable(true, false)
            if ("setReadable".equals(methodName)) {
                if (args.length == 2 &&
                        Boolean.TRUE.equals(ConstantEvaluator.evaluate(context, args[0])) &&
                        Boolean.FALSE.equals(ConstantEvaluator.evaluate(context, args[1]))) {
                    context.report(SET_READABLE, node, context.getLocation(node),
                            "Setting file permissions to world-readable can be " +
                                    "risky, review carefully");
                }
                return;
            } else if ("setWritable".equals(methodName)) {
                if (args.length == 2 &&
                        Boolean.TRUE.equals(ConstantEvaluator.evaluate(context, args[0])) &&
                        Boolean.FALSE.equals(ConstantEvaluator.evaluate(context, args[1]))) {
                    context.report(SET_WRITABLE, node, context.getLocation(node),
                            "Setting file permissions to world-writable can be " +
                                    "risky, review carefully");
                }
                return;
            }
        }

        assert visitor != null;
        for (PsiExpression arg : args) {
            arg.accept(visitor);
        }
    }

    @Nullable
    @Override
    public JavaElementVisitor createPsiVisitor(@NonNull JavaContext context) {
        return new IdentifierVisitor(context);
    }

    private static class IdentifierVisitor extends JavaElementVisitor {
        private final JavaContext mContext;

        public IdentifierVisitor(JavaContext context) {
            super();
            mContext = context;
        }

        @Override
        public void visitReferenceExpression(PsiReferenceExpression node) {
            super.visitReferenceExpression(node);

            String name = node.getReferenceName();
            if ("MODE_WORLD_WRITEABLE".equals(name)) { //$NON-NLS-1$
                Location location = mContext.getLocation(node);
                mContext.report(WORLD_WRITEABLE, node, location,
                        "Using `MODE_WORLD_WRITEABLE` when creating files can be " +
                                "risky, review carefully");
            } else if ("MODE_WORLD_READABLE".equals(name)) { //$NON-NLS-1$
                Location location = mContext.getLocation(node);
                mContext.report(WORLD_READABLE, node, location,
                        "Using `MODE_WORLD_READABLE` when creating files can be " +
                                "risky, review carefully");
            }
        }
    }
}
