//////////////////////////////////////////////////////////////////////// // // Copyright (c) 2009-2013 Denim Group, Ltd. // // The contents of this file are subject to the Mozilla Public 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.mozilla.org/MPL/ // // Software distributed under the License is distributed on an "AS IS" // basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the // License for the specific language governing rights and limitations // under the License. // // The Original Code is ThreadFix. // // The Initial Developer of the Original Code is Denim Group, Ltd. // Portions created by Denim Group, Ltd. are Copyright (C) // Denim Group, Ltd. All Rights Reserved. // // Contributor(s): Denim Group, Ltd. // //////////////////////////////////////////////////////////////////////// package com.denimgroup.threadfix.service.defects; import java.io.IOException; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.net.ssl.SSLHandshakeException; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import com.denimgroup.threadfix.data.entities.Defect; import com.denimgroup.threadfix.data.entities.Vulnerability; /** * This class has been rewritten to use the JIRA REST interface and may not work on older * JIRA installations. However, it should actually be functional now. * * <a href="http://www.atlassian.com/software/jira/">JIRA Homepage</a> * * @author mcollins */ public class JiraDefectTracker extends AbstractDefectTracker { // The double slash is the Jira newline wiki syntax. private static final String newLineRegex = "\\\\n", doubleSlashNewLine = " \\\\\\\\\\\\\\\\ "; // HELPER METHODS // I want to parse this into a java.net.URL object and then work with it, but I'm // not sure how that would work out with a non-atlassian hosted install. private String getUrlWithRest() { if (getUrl() == null || getUrl().trim().equals("")) { return null; } try { new URL(getUrl()); } catch (MalformedURLException e) { setLastError("The URL format was bad."); return null; } if (getUrl().endsWith("rest/api/2/")) { return getUrl(); } String tempUrl = getUrl().trim(); if (tempUrl.endsWith("/")) { tempUrl = tempUrl.concat("rest/api/2/"); } else { tempUrl = tempUrl.concat("/rest/api/2/"); } return tempUrl; } /** * * @param urlString JIRA URL to connect to * @return true if we get an HTTP 401, false if we get another HTTP response code (such as 200:OK) * or if an exception occurs */ private boolean requestHas401Error(String urlString) { log.info("Checking to see if we get an HTTP 401 error for the JIRA URL '" + urlString + "'"); boolean retVal; try { URL url = new URL(urlString); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setDoOutput(true); if (connection.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED) { retVal = true; } else { log.info("Got a non-401 HTTP repsonse code of: " + connection.getResponseCode()); retVal = false; } } catch (MalformedURLException e) { log.warn("JIRA URL string of '" + urlString + "' is not a valid URL.", e); setLastError(BAD_URL); retVal = false; } catch (SSLHandshakeException e) { log.warn("Certificate Error encountered while trying to find the response code.", e); setLastError(INVALID_CERTIFICATE); retVal = false; } catch (IOException e) { log.warn("IOException encountered while trying to find the response code: " + e.getMessage(), e); setLastError(IO_ERROR); retVal = false; } log.info("Return value will be " + retVal); return retVal; } private boolean hasXSeraphLoginReason() { URL url = null; try { url = new URL(getUrlWithRest() + "user?username=" + getUsername()); } catch (MalformedURLException e) { e.printStackTrace(); return false; } try { HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection(); RestUtils.setupAuthorization(httpConnection, username, password); httpConnection.addRequestProperty("Content-Type", "application/json"); httpConnection.addRequestProperty("Accept", "application/json"); String headerResult = httpConnection.getHeaderField("X-Seraph-LoginReason"); return headerResult != null && headerResult.equals("AUTHENTICATION_DENIED"); } catch (IOException e) { log.warn("IOException encountered while trying to find the response code.", e); } return false; } private List<String> getNamesFromList(String path) { String result = RestUtils.getUrlAsString(getUrlWithRest() + path, username, password); List<String> names = new ArrayList<String>(); if (result != null) { JSONArray returnArray = RestUtils.getJSONArray(result); for (int i = 0; i < returnArray.length(); i++) { try { names.add(returnArray.getJSONObject(i).getString("name")); } catch (JSONException e) { e.printStackTrace(); } } return names; } return null; } private Map<String,String> getNameFieldMap(String path, String field) { String result = RestUtils.getUrlAsString(getUrlWithRest() + path, username, password); if (result == null) { return null; } JSONArray returnArray = RestUtils.getJSONArray(result); Map<String,String> nameFieldMap = new HashMap<String,String>(); for (int i = 0; i < returnArray.length(); i++) { try { nameFieldMap.put(returnArray.getJSONObject(i).getString("name"), returnArray.getJSONObject(i).getString(field)); } catch (JSONException e) { e.printStackTrace(); } } return nameFieldMap; } // CHECKS FOR VALID CONFIGURATION @Override public boolean hasValidCredentials() { log.info("Checking JIRA credentials."); lastError = null; String response = RestUtils.getUrlAsString(getUrlWithRest() + "user?username=" + getUsername(),getUsername(),getPassword()); try { boolean valid = response != null && RestUtils.getJSONObject(response) != null && RestUtils.getJSONObject(response).getString("name").equals(getUsername()); if (valid) { log.info("JIRA Credentials are valid."); } else { log.info("JIRA Credentials are invalid."); } if (hasXSeraphLoginReason()) { lastError = "JIRA CAPTCHA protection has been tripped. Please log in at " + url + " to continue."; } return valid; } catch (JSONException e) { log.warn("JIRA credentials check did not return JSON, something is wrong.", e); return false; } } @Override public boolean hasValidProjectName() { if (projectName == null) return false; return getNamesFromList("project").contains(projectName); } @Override public boolean hasValidUrl() { log.info("Checking JIRA RPC Endpoint URL."); if (getUrlWithRest() == null) { log.info("URL was invalid."); return false; } boolean valid = requestHas401Error(getUrlWithRest() + "user"); if (valid) { log.info("JIRA URL was valid, returned 401 response as expected because we do not yet have credentials."); } else { log.warn("JIRA URL was invalid or some other problem occurred, 401 response was expected but not returned."); } return valid; } // PRE-SUBMISSION METHODS @Override public String getProductNames() { lastError = null; Map<String, String> nameIdMap = getNameFieldMap("project/","key"); if (nameIdMap != null && nameIdMap.size() > 0) { StringBuilder builder = new StringBuilder(); for (String name : nameIdMap.keySet()) { builder.append(name); builder.append(','); } return builder.substring(0,builder.length()-1); } else { if (!hasValidUrl()) { lastError = "Supplied endpoint was invalid."; } else if (hasXSeraphLoginReason()) { lastError = "JIRA CAPTCHA protection has been tripped. Please log in at " + url + " to continue."; } else if (!hasValidCredentials()) { lastError = "Invalid username / password combination"; } else if (nameIdMap != null) { lastError = "No projects were found. Check your JIRA instance."; } else { lastError = "Not sure what the error is."; } return null; } } @Override public String getLastError() { return lastError; } @Override public ProjectMetadata getProjectMetadata() { if (getProjectId() == null) setProjectId(getProjectIdByName()); List<String> components = getNamesFromList("project/" + projectId + "/components"); List<String> blankList = Arrays.asList(new String[] {"-"}); List<String> statusList = Arrays.asList(new String[] {"Open"}); List<String> priorities = getNamesFromList("priority"); if (components == null || components.isEmpty()) { components = Arrays.asList("-"); } ProjectMetadata data = new ProjectMetadata(components, blankList, blankList, statusList, priorities); return data; } @Override public String getProjectIdByName() { Map<String,String> projectNameIdMap = getNameFieldMap("project/","key"); if (projectNameIdMap == null) { return null; } else { return projectNameIdMap.get(projectName); } } // CREATION AND STATUS UPDATE METHODS @Override public String createDefect(List<Vulnerability> vulnerabilities, DefectMetadata metadata) { if (getProjectId() == null) { setProjectId(getProjectIdByName()); } Map<String,String> priorityHash = getNameFieldMap("priority", "id"), componentsHash = getNameFieldMap("project/" + projectId + "/components", "id"), projectsHash = getNameFieldMap("project","id"); String description = makeDescription(vulnerabilities, metadata); // TODO - Use a better JSON API to construct the JSON message. JSONObject.quote() is nice // and all, but... String payload = "{ \"fields\": {" + " \"project\": { \"id\": " + JSONObject.quote(projectsHash.get(getProjectName())) + " }," + " \"summary\": " + JSONObject.quote(metadata.getDescription()) + "," + " \"issuetype\": { \"id\": \"1\" }," + " \"assignee\": { \"name\":" + JSONObject.quote(username) + " }," + " \"reporter\": { \"name\": " + JSONObject.quote(username) + " }," + " \"priority\": { \"id\": " + JSONObject.quote(priorityHash.get(metadata.getPriority())) + " }," + " \"description\": " + JSONObject.quote(description); if (metadata.getComponent() != null && !metadata.getComponent().equals("-")) { payload += "," + " \"components\": [ { \"id\": " + JSONObject.quote(componentsHash.get(metadata.getComponent())) + " } ]"; } payload += " } }"; payload = payload.replaceAll(newLineRegex, doubleSlashNewLine); String result = RestUtils.postUrlAsString(getUrlWithRest() + "issue",payload,getUsername(),getPassword()); String id = null; try { if (result != null && RestUtils.getJSONObject(result) != null && RestUtils.getJSONObject(result).getString("key") != null) { id = RestUtils.getJSONObject(result).getString("key"); } } catch (JSONException e) { e.printStackTrace(); } return id; } @Override public Map<Defect, Boolean> getMultipleDefectStatus(List<Defect> defectList) { Map<Defect,Boolean> returnMap = new HashMap<Defect,Boolean>(); if (defectList != null && defectList.size() != 0) { log.info("Updating JIRA defect status for " + defectList.size() + " defects."); for (Defect defect : defectList) { if (defect != null) { String result = getStatus(defect); boolean isOpen = result != null && (!result.equals("Resolved") || !result.equals("Closed")); returnMap.put(defect, isOpen); } } } else { log.info("Tried to update defects but no defects were found."); } return returnMap; } private String getStatus(Defect defect) { if (defect == null || defect.getNativeId() == null) { log.warn("Bad defect passed to getStatus()"); return null; } log.info("Updating status for defect " + defect.getNativeId()); String result = RestUtils.getUrlAsString(getUrlWithRest() + "issue/" + defect.getNativeId(), getUsername(), getPassword()); if (result != null) { try { JSONObject resultObject = new JSONObject(result); if (resultObject != null && resultObject.getJSONObject("fields") != null && resultObject.getJSONObject("fields").getJSONObject("status") != null && resultObject.getJSONObject("fields").getJSONObject("status").getString("name") != null) { String status = resultObject.getJSONObject("fields").getJSONObject("status").getString("name"); log.info("Current status for defect " + defect.getNativeId() + " is " + status); defect.setStatus(status); return status; } } catch (JSONException e) { log.warn("JSON parsing failed when trying to get defect status."); } } return null; } @Override public String getTrackerError() { log.info("Attempting to find the reason that JIRA integration failed."); String reason = null; if (!hasValidUrl()) { reason = "The JIRA url was incorrect."; } else if (!hasValidCredentials()) { reason = "The supplied credentials were incorrect."; } else if (!hasValidProjectName()) { reason = "The project name was invalid."; } else { reason = "The JIRA integration failed but the " + "cause is not the URL, credentials, or the Project Name."; } log.info(reason); return reason; } @Override public String getBugURL(String endpointURL, String bugID) { String returnString = endpointURL; if (endpointURL.endsWith("rest/api/2/")) { returnString = endpointURL.replace("rest/api/2/", "browse/" + bugID); } else if (endpointURL.endsWith("/")) { returnString = endpointURL + "browse/" + bugID; } else { returnString = endpointURL + "/browse/" + bugID; } return returnString; } }