// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.crash; import android.content.Context; import android.content.Intent; import android.util.Patterns; import org.chromium.base.Log; import org.chromium.base.VisibleForTesting; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.PrintWriter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.concurrent.Callable; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Extracts logcat out of Android devices and elide PII sensitive info from it. * * Elided information includes: Emails, IP address, MAC address, URL/domains as well as Javascript * console messages. * * Caller provides a list of minidump files as well as an intent. This Callable will then extract * the most recent logcat and save a copy for each minidump. * * Upon completion, each minidump + logcat pairs will be passed to the MinidumpPreparationService * along with the intent provided here. */ public class LogcatExtractionCallable implements Callable<Boolean> { private static final String TAG = "LogcatExtraction"; private static final long HALF_SECOND = 500; protected static final int LOGCAT_SIZE = 256; // Number of lines. protected static final String EMAIL_ELISION = "XXX@EMAIL.ELIDED"; @VisibleForTesting protected static final String URL_ELISION = "HTTP://WEBADDRESS.ELIDED"; private static final String GOOD_IRI_CHAR = "a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF"; private static final Pattern IP_ADDRESS = Pattern.compile( "((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]" + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]" + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}" + "|[1-9][0-9]|[0-9]))"); private static final String IRI = "[" + GOOD_IRI_CHAR + "]([" + GOOD_IRI_CHAR + "\\-]{0,61}[" + GOOD_IRI_CHAR + "]){0,1}"; private static final String GOOD_GTLD_CHAR = "a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF"; private static final String GTLD = "[" + GOOD_GTLD_CHAR + "]{2,63}"; private static final String HOST_NAME = "(" + IRI + "\\.)+" + GTLD; private static final Pattern DOMAIN_NAME = Pattern.compile("(" + HOST_NAME + "|" + IP_ADDRESS + ")"); private static final Pattern WEB_URL = Pattern.compile( "(?:\\b|^)((?:(http|https|Http|Https|rtsp|Rtsp):" + "\\/\\/(?:(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)" + "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_" + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@)?)?" + "(?:" + DOMAIN_NAME + ")" + "(?:\\:\\d{1,5})?)" + "(\\/(?:(?:[" + GOOD_IRI_CHAR + "\\;\\/\\?\\:\\@\\&\\=\\#\\~" + "\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])|(?:\\%[a-fA-F0-9]{2}))*)?" + "(?:\\b|$)"); @VisibleForTesting protected static final String BEGIN_MICRODUMP = "-----BEGIN BREAKPAD MICRODUMP-----"; @VisibleForTesting protected static final String END_MICRODUMP = "-----END BREAKPAD MICRODUMP-----"; @VisibleForTesting protected static final String SNIPPED_MICRODUMP = "-----SNIPPED OUT BREAKPAD MICRODUMP FOR THIS CRASH-----"; @VisibleForTesting protected static final String IP_ELISION = "1.2.3.4"; @VisibleForTesting protected static final String MAC_ELISION = "01:23:45:67:89:AB"; private static final String LOGCAT_EXTENSION = ".logcat"; @VisibleForTesting protected static final String CONSOLE_ELISION = "[ELIDED:CONSOLE(0)] ELIDED CONSOLE MESSAGE"; private static final Pattern MAC_ADDRESS = Pattern.compile("([0-9a-fA-F]{2}[-:]+){5}[0-9a-fA-F]{2}"); private static final Pattern CONSOLE_MSG = Pattern.compile("\\[\\w*:CONSOLE.*\\].*"); private static final Pattern MINIDUMP_EXTENSION = Pattern.compile("\\.dmp"); private static final String[] CHROME_NAMESPACE = new String[] { "org.chromium.", "com.google." }; private static final String[] SYSTEM_NAMESPACE = new String[] { "android.accessibilityservice", "android.accounts", "android.animation", "android.annotation", "android.app", "android.appwidget", "android.bluetooth", "android.content", "android.database", "android.databinding", "android.drm", "android.gesture", "android.graphics", "android.hardware", "android.inputmethodservice", "android.location", "android.media", "android.mtp", "android.net", "android.nfc", "android.opengl", "android.os", "android.preference", "android.print", "android.printservice", "android.provider", "android.renderscript", "android.sax", "android.security", "android.service", "android.speech", "android.support", "android.system", "android.telecom", "android.telephony", "android.test", "android.text", "android.transition", "android.util", "android.view", "android.webkit", "android.widget", "com.android.", "dalvik.", "java.", "javax.", "org.apache.", "org.json.", "org.w3c.dom.", "org.xml.", "org.xmlpull." }; private final Context mContext; private final String[] mMinidumpFilenames; private final Intent mRedirectIntent; /** * @param filenames List of minidump filenames that need logcat to be attached. * @param redirectIntent The intent to be triggered once upon completion. */ public LogcatExtractionCallable(Context context, String[] filenames, Intent redirectIntent) { if (context == null) { throw new NullPointerException("Context cannot be null."); } mContext = context; mMinidumpFilenames = Arrays.copyOf(filenames, filenames.length); mRedirectIntent = redirectIntent; } @Override public Boolean call() { Log.i(TAG, "Trying to extract logcat for minidump"); try { // Step 1: Extract a single logcat file. File logcatFile = getElidedLogcat(); // Step 2: Make copies of logcat file for each minidump then invoke // MinidumpPreparationService on each file pair. int len = mMinidumpFilenames.length; CrashFileManager fileManager = new CrashFileManager(mContext.getCacheDir()); for (int i = 0; i < len; i++) { // Output crash dump file path to logcat so non-browser crashes appear too. Log.i(TAG, "Output crash dump:"); Log.i(TAG, fileManager.getCrashFile(mMinidumpFilenames[i]).getAbsolutePath()); processMinidump(logcatFile, mMinidumpFilenames[i], fileManager, i == len - 1); } return true; } catch (IOException | InterruptedException e) { Log.w(TAG, e.toString()); return false; } } private void processMinidump(File logcatFile, String name, CrashFileManager manager, boolean isLast) throws IOException { String toPath = MINIDUMP_EXTENSION.matcher(name).replaceAll(LOGCAT_EXTENSION); File toFile = manager.createNewTempFile(toPath); // For the last file, we don't need to make extra copies. We'll use the original // logcat file but we would pass down the redirect intent so it can be triggered // upon completion. Intent intent = null; if (isLast) { move(logcatFile, toFile); intent = MinidumpPreparationService.createMinidumpPreparationIntent(mContext, manager.getCrashFile(name), toFile, mRedirectIntent); } else { copy(logcatFile, toFile); intent = MinidumpPreparationService.createMinidumpPreparationIntent(mContext, manager.getCrashFile(name), toFile, null); } mContext.startService(intent); } private File getElidedLogcat() throws IOException, InterruptedException { List<String> rawLogcat = getLogcat(); List<String> elidedLogcat = Collections.unmodifiableList(processLogcat(rawLogcat)); return writeLogcat(elidedLogcat); } private static void copy(File src, File dst) throws IOException { InputStream in = null; OutputStream out = null; try { in = new FileInputStream(src); out = new FileOutputStream(dst); // Transfer bytes from in to out byte[] buf = new byte[1024]; int len; while ((len = in.read(buf)) > 0) { out.write(buf, 0, len); } } catch (IOException e) { Log.w(TAG, e.toString()); } finally { try { if (in != null) in.close(); } finally { if (out != null) out.close(); } } } private static void move(File from, File to) { if (!from.renameTo(to)) { Log.w(TAG, "Fail to rename logcat file"); } } @VisibleForTesting protected List<String> getLogcat() throws IOException, InterruptedException { // Grab the last lines of the logcat output, with a generous buffer to compensate for any // microdumps that might be in the logcat output, since microdumps are stripped in the // extraction code. Note that the repeated check of the process exit value is to account for // the fact that the process might not finish immediately. And, it's not appropriate to // call p.waitFor(), because this call will block *forever* if the process's output buffer // fills up. Process p = Runtime.getRuntime().exec("logcat -d"); BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); LinkedList<String> rawLogcat = new LinkedList<>(); Integer exitValue = null; try { while (exitValue == null) { String logLn; while ((logLn = reader.readLine()) != null) { rawLogcat.add(logLn); if (rawLogcat.size() > LOGCAT_SIZE * 4) { rawLogcat.removeFirst(); } } try { exitValue = p.exitValue(); } catch (IllegalThreadStateException itse) { Thread.sleep(HALF_SECOND); } } } finally { reader.close(); } if (exitValue != 0) { String msg = "Logcat failed: " + exitValue; Log.w(TAG, msg); throw new IOException(msg); } return trimLogcat(rawLogcat, LOGCAT_SIZE); } /** * Extracts microdump-free logcat for more informative crash reports. Returns the most recent * lines that are likely to be relevant to the crash, which are either the lines leading up to a * microdump if a microdump is present, or just the final lines of the logcat if no microdump is * present. * * @param rawLogcat The last lines of the raw logcat file, with sufficient history to allow a * sufficient history even after trimming. * @param maxLines The maximum number of lines logcat extracts from minidump. * * @return Logcat up to specified length as a list of strings. */ @VisibleForTesting protected static List<String> trimLogcat(List<String> rawLogcat, int maxLines) { // Trim off the last microdump, and anything after it. for (int i = rawLogcat.size() - 1; i >= 0; i--) { if (rawLogcat.get(i).contains(BEGIN_MICRODUMP)) { rawLogcat = rawLogcat.subList(0, i); rawLogcat.add(SNIPPED_MICRODUMP); break; } } // Trim down the remainder to only contain the most recent lines. Thus, if the original // input contained a microdump, the result contains the most recent lines before the // microdump, which are most likely to be relevant to the crash. If there is no microdump // in the raw logcat, then just hope that the last lines in the dump are relevant. if (rawLogcat.size() > maxLines) { rawLogcat = rawLogcat.subList(rawLogcat.size() - maxLines, rawLogcat.size()); } return rawLogcat; } private File writeLogcat(List<String> elidedLogcat) throws IOException { CrashFileManager fileManager = new CrashFileManager(mContext.getCacheDir()); File logcatFile = fileManager.createNewTempFile("logcat.txt"); PrintWriter pWriter = null; try { pWriter = new PrintWriter(new FileWriter(logcatFile)); for (String ln : elidedLogcat) { pWriter.println(ln); } return logcatFile; } finally { if (pWriter != null) { pWriter.close(); } } } @VisibleForTesting protected static List<String> processLogcat(List<String> rawLogcat) { List<String> out = new ArrayList<String>(rawLogcat.size()); for (String ln : rawLogcat) { ln = elideEmail(ln); ln = elideUrl(ln); ln = elideIp(ln); ln = elideMac(ln); ln = elideConsole(ln); out.add(ln); } return out; } /** * Elides any emails in the specified {@link String} with * {@link #EMAIL_ELISION}. * * @param original String potentially containing emails. * @return String with elided emails. */ @VisibleForTesting protected static String elideEmail(String original) { return Patterns.EMAIL_ADDRESS.matcher(original).replaceAll(EMAIL_ELISION); } /** * Elides any URLs in the specified {@link String} with * {@link #URL_ELISION}. * * @param original String potentially containing URLs. * @return String with elided URLs. */ @VisibleForTesting protected static String elideUrl(String original) { StringBuilder buffer = new StringBuilder(original); Matcher matcher = WEB_URL.matcher(buffer); int start = 0; while (matcher.find(start)) { start = matcher.start(); int end = matcher.end(); String url = buffer.substring(start, end); if (!likelyToBeChromeNamespace(url) && !likelyToBeSystemNamespace(url)) { buffer.replace(start, end, URL_ELISION); end = start + URL_ELISION.length(); matcher = WEB_URL.matcher(buffer); } start = end; } return buffer.toString(); } public static boolean likelyToBeChromeNamespace(String url) { for (String ns : CHROME_NAMESPACE) { if (url.startsWith(ns)) { return true; } } return false; } public static boolean likelyToBeSystemNamespace(String url) { for (String ns : SYSTEM_NAMESPACE) { if (url.startsWith(ns)) { return true; } } return false; } /** * Elides any IP addresses in the specified {@link String} with * {@link #IP_ELISION}. * * @param original String potentially containing IPs. * @return String with elided IPs. */ @VisibleForTesting protected static String elideIp(String original) { return Patterns.IP_ADDRESS.matcher(original).replaceAll(IP_ELISION); } /** * Elides any MAC addresses in the specified {@link String} with * {@link #MAC_ELISION}. * * @param original String potentially containing MACs. * @return String with elided MACs. */ @VisibleForTesting protected static String elideMac(String original) { return MAC_ADDRESS.matcher(original).replaceAll(MAC_ELISION); } /** * Elides any console messages in the specified {@link String} with * {@link #CONSOLE_ELISION}. * * @param original String potentially containing console messages. * @return String with elided console messages. */ @VisibleForTesting protected static String elideConsole(String original) { return CONSOLE_MSG.matcher(original).replaceAll(CONSOLE_ELISION); } }