blob: 916b6c4b3509cc171bdc8ccc870e03e6174084bf [file] [log] [blame]
/*
* Copyright (C) 2015 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.SdkConstants;
import com.android.annotations.NonNull;
import com.android.ide.common.repository.GradleVersion;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.ClassContext;
import com.android.tools.lint.detector.api.ClassScanner;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Detector;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.Lint;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.google.common.base.Charsets;
import com.google.common.io.Files;
import com.google.common.io.LineProcessor;
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.EnumSet;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FieldInsnNode;
import org.objectweb.asm.tree.FieldNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodNode;
/** Checks to detect vulnerable versions of Apache Cordova. */
public class CordovaVersionDetector extends Detector implements ClassScanner {
private static final Implementation IMPL =
new Implementation(
CordovaVersionDetector.class,
EnumSet.of(Scope.CLASS_FILE, Scope.JAVA_LIBRARIES));
/** Vulnerable Cordova Version */
public static final Issue ISSUE =
Issue.create(
"VulnerableCordovaVersion",
"Vulnerable Cordova Version",
"The version of Cordova used in the app is vulnerable to security "
+ "issues. Please update to the latest Apache Cordova version.",
Category.SECURITY,
9,
Severity.WARNING,
IMPL)
.addMoreInfo(
"https://cordova.apache.org/announcements/2015/11/20/security.html")
.setAndroidSpecific(true);
/** Version string format in a class file. Note that any qualifiers such as -dev are ignored. */
private static final Pattern VERSION_STR = Pattern.compile("(\\d+\\.\\d+\\.\\d+).*");
/** The class name that declares the cordovaVersion for versions < 3.x.x */
private static final String FQN_CORDOVA_DEVICE = "org/apache/cordova/Device";
/** The name of the static field if present in the {@code FQN_CORDOVA_DEVICE} */
private static final String FIELD_NAME_CORDOVA_VERSION = "cordovaVersion";
private static final String FQN_CORDOVA_WEBVIEW = "org/apache/cordova/CordovaWebView";
/** The name of the static final field present in {@code FQN_CORDOVA_WEBVIEW} */
private static final String FIELD_NAME_CORDOVA_VERSION_WEBVIEW = "CORDOVA_VERSION";
private static final String CORDOVA_DOT_JS = "cordova.js";
private static final FilenameFilter CORDOVA_JS_FILTER =
(dir, filename) ->
filename.startsWith(CORDOVA_DOT_JS) || new File(dir, filename).isDirectory();
/** Constructs a new {@link CordovaVersionDetector} check */
public CordovaVersionDetector() {}
/**
* The cordova version is similar to a dewey decimal like system used by {@link GradleVersion}
* except for the named qualifiers.
*/
private GradleVersion mCordovaVersion;
@Override
public void afterCheckRootProject(@NonNull Context context) {
checkAssetsFolder(context);
}
private void checkAssetsFolder(@NonNull Context context) {
if (!context.getProject().getReportIssues()) {
// If this is a library project not being analyzed, ignore it
return;
}
Project project = context.getProject();
List<File> assetFolders = project.getAssetFolders();
Deque<File> files = new ArrayDeque<>();
for (File assetFolder : assetFolders) {
// The js file could be at an arbitrary level in the directory tree.
// examples:
// assets/www/cordova.js
// assets/www/plugins/phonegap/cordova.js.2.2.android
files.push(assetFolder);
while (!files.isEmpty() && mCordovaVersion == null) {
File current = files.pop();
if (current.isDirectory()) {
File[] filtered = current.listFiles(CORDOVA_JS_FILTER);
if (filtered != null) {
for (File file : filtered) {
files.push(file);
}
}
} else {
checkFile(context, current);
}
}
files.clear();
}
}
private void checkFile(@NonNull Context context, @NonNull File file) {
if (mCordovaVersion == null
&& file.getPath().contains(CORDOVA_DOT_JS)
&& file.getName().startsWith(CORDOVA_DOT_JS)) {
try {
mCordovaVersion =
Files.readLines(file, Charsets.UTF_8, new JsVersionLineProcessor());
if (mCordovaVersion != null) {
validateCordovaVersion(context, mCordovaVersion, Location.create(file));
}
} catch (IOException ignore) {
// Ignore this exception.
}
}
}
private static void validateCordovaVersion(
@NonNull Context context, @NonNull GradleVersion cordovaVersion, Location location) {
// All cordova versions below 4.1.1 are considered vulnerable.
if (!cordovaVersion.isAtLeast(4, 1, 1)) {
String message =
String.format(
"You are using a vulnerable version of Cordova: %1$s",
cordovaVersion.toString());
context.report(ISSUE, location, message);
}
}
// ---- Implements ClassScanner ----
@Override
public void checkClass(@NonNull ClassContext context, @NonNull ClassNode classNode) {
//noinspection VariableNotUsedInsideIf
if (mCordovaVersion != null) {
// Exit early if we have already found the cordova version in the JS file.
// This will be the case for all versions > 3.x.x
return;
}
// For cordova versions such as 2.7.1, the version is a static *non-final* field in
// a class named Device. Since it is non-final, it is initialized in the <clinit> method.
// Example:
//
// ldc #5 // String 2.7.1
// putstatic #6 // Field cordovaVersion:Ljava/lang/String;
// ...
if (classNode.name.equals(FQN_CORDOVA_DEVICE)) {
//noinspection unchecked ASM api.
List<MethodNode> methods = classNode.methods;
for (MethodNode method : methods) {
if (SdkConstants.CLASS_CONSTRUCTOR.equals(method.name)) {
InsnList nodes = method.instructions;
for (int i = 0, n = nodes.size(); i < n; i++) {
AbstractInsnNode instruction = nodes.get(i);
int type = instruction.getType();
if (type == AbstractInsnNode.FIELD_INSN) {
checkInstructionInternal(context, classNode, instruction);
break;
}
}
}
}
} else if (classNode.name.equals(FQN_CORDOVA_WEBVIEW)) {
// For versions > 3.x.x, the version string is stored as a static final String in
// CordovaWebView.
// Note that this is also stored in the cordova.js.* but from a lint api perspective,
// it's much faster to look it up here than load and check in the JS file.
// e.g.
// public static final java.lang.String CORDOVA_VERSION;
// descriptor: Ljava/lang/String;
// flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
// ConstantValue: String 4.1.1
//
//noinspection unchecked ASM api.
List<FieldNode> fields = classNode.fields;
for (FieldNode node : fields) {
if (FIELD_NAME_CORDOVA_VERSION_WEBVIEW.equals(node.name)
&& (node.access & Opcodes.ACC_FINAL) == Opcodes.ACC_FINAL // is final
&& (node.access & Opcodes.ACC_STATIC) == Opcodes.ACC_STATIC // is static
&& node.value instanceof String) {
mCordovaVersion = createVersion((String) node.value);
if (mCordovaVersion != null) {
validateCordovaVersion(
context, mCordovaVersion, context.getLocation(classNode));
}
}
}
}
}
private void checkInstructionInternal(
ClassContext context, ClassNode classNode, AbstractInsnNode instruction) {
FieldInsnNode node = (FieldInsnNode) instruction;
if (node.getOpcode() == Opcodes.PUTSTATIC
&& node.owner.equals(FQN_CORDOVA_DEVICE)
&& node.name.equals(FIELD_NAME_CORDOVA_VERSION)) {
AbstractInsnNode prevInstruction = Lint.getPrevInstruction(node);
if (prevInstruction == null || prevInstruction.getOpcode() != Opcodes.LDC) {
return;
}
LdcInsnNode ldcInsnNode = (LdcInsnNode) prevInstruction;
if (ldcInsnNode.cst instanceof String) {
mCordovaVersion = createVersion((String) ldcInsnNode.cst);
if (mCordovaVersion != null) {
validateCordovaVersion(
context, mCordovaVersion, context.getLocation(classNode));
}
}
}
}
private static GradleVersion createVersion(String version) {
Matcher matcher = VERSION_STR.matcher(version);
if (matcher.matches()) {
return GradleVersion.tryParse(matcher.group(1));
}
return null;
}
/**
* A {@link LineProcessor} that matches a specific cordova pattern and if successful terminates
* early. The constant is typically declared at the start of the file so it makes it efficient
* to terminate early.
*/
public static class JsVersionLineProcessor implements LineProcessor<GradleVersion> {
// var CORDOVA_JS_BUILD_LABEL = '3.5.1-dev';
private static final Pattern PATTERN =
Pattern.compile(
"var\\s*(PLATFORM_VERSION_BUILD_LABEL|CORDOVA_JS_BUILD_LABEL)\\s*"
+ "=\\s*'(\\d+\\.\\d+\\.\\d+)[^']*';.*");
private GradleVersion mVersion;
@Override
public boolean processLine(@NonNull String line) throws IOException {
Matcher matcher = PATTERN.matcher(line);
if (matcher.matches()) {
mVersion = GradleVersion.tryParse(matcher.group(2));
return false; // stop processing other lines.
}
return true;
}
@Override
public GradleVersion getResult() {
return mVersion;
}
}
}