overhaul node package and nsp analyzer

This commit is contained in:
Jeremy Long
2017-11-25 11:13:02 -05:00
parent 0b32d3b991
commit 332bbe72aa
10 changed files with 432 additions and 73 deletions

View File

@@ -164,6 +164,10 @@ Copyright (c) 2012 Jeremy Long. All Rights Reserved.
</plugins>
</reporting>
<dependencies>
<dependency>
<groupId>com.vdurmont</groupId>
<artifactId>semver4j</artifactId>
</dependency>
<!-- Note, to stay compatible with Jenkins installations only JARs compiled to 1.6 can be used -->
<dependency>
<groupId>joda-time</groupId>

View File

@@ -85,6 +85,14 @@ public abstract class AbstractAnalyzer implements Analyzer {
@Override
public void initialize(Settings settings) {
this.settings = settings;
final String key = getAnalyzerEnabledSettingKey();
try {
this.setEnabled(settings.getBoolean(key, true));
} catch (InvalidSettingException ex) {
final String msg = String.format("Invalid setting for property '%s'", key);
LOGGER.warn(msg);
LOGGER.debug(msg, ex);
}
}
/**
@@ -95,15 +103,6 @@ public abstract class AbstractAnalyzer implements Analyzer {
*/
@Override
public final void prepare(Engine engine) throws InitializationException {
final String key = getAnalyzerEnabledSettingKey();
try {
this.setEnabled(settings.getBoolean(key, true));
} catch (InvalidSettingException ex) {
final String msg = String.format("Invalid setting for property '%s'", key);
LOGGER.warn(msg);
LOGGER.debug(msg, ex);
}
if (isEnabled()) {
prepareAnalyzer(engine);
} else {

View File

@@ -0,0 +1,289 @@
/*
* This file is part of dependency-check-core.
*
* 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.
*
* Copyright (c) 2017 Steve Springett. All Rights Reserved.
*/
package org.owasp.dependencycheck.analyzer;
import org.owasp.dependencycheck.Engine;
import org.owasp.dependencycheck.dependency.Confidence;
import org.owasp.dependencycheck.dependency.Dependency;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import javax.annotation.concurrent.ThreadSafe;
import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.json.JsonObjectBuilder;
import javax.json.JsonString;
import javax.json.JsonValue;
import org.owasp.dependencycheck.dependency.EvidenceType;
import org.owasp.dependencycheck.utils.Checksum;
/**
* An abstract NPM analyzer that contains common methods for concrete
* implementations.
*
* @author Steve Springett
*/
@ThreadSafe
public abstract class AbstractNpmAnalyzer extends AbstractFileTypeAnalyzer {
/**
* The logger.
*/
private static final Logger LOGGER = LoggerFactory.getLogger(AbstractNpmAnalyzer.class);
/**
* A descriptor for the type of dependencies processed or added by this
* analyzer.
*/
public static final String NPM_DEPENDENCY_ECOSYSTEM = "npm";
/**
* The file name to scan.
*/
private static final String PACKAGE_JSON = "package.json";
/**
* Determines if the file can be analyzed by the analyzer.
*
* @param pathname the path to the file
* @return true if the file can be analyzed by the given analyzer; otherwise
* false
*/
@Override
public boolean accept(File pathname) {
boolean accept = super.accept(pathname);
if (accept) {
try {
// Do not scan the node_modules directory
if (pathname.getCanonicalPath().contains(File.separator + "node_modules" + File.separator)) {
LOGGER.debug("Skipping analysis of node module: " + pathname.getCanonicalPath());
accept = false;
}
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
return accept;
}
/**
* Construct a dependency object.
*
* @param dependency the parent dependency
* @param name the name of the dependency to create
* @param version the version of the dependency to create
* @param scope the scope of the dependency being created
* @return the generated dependency
*/
protected Dependency createDependency(Dependency dependency, String name, String version, String scope) {
final Dependency nodeModule = new Dependency(new File(dependency.getActualFile() + "?" + name), true);
nodeModule.setEcosystem(NPM_DEPENDENCY_ECOSYSTEM);
//this is virtual - the sha1 is purely for the hyperlink in the final html report
nodeModule.setSha1sum(Checksum.getSHA1Checksum(String.format("%s:%s", name, version)));
nodeModule.setMd5sum(Checksum.getMD5Checksum(String.format("%s:%s", name, version)));
nodeModule.addEvidence(EvidenceType.PRODUCT, "package.json", "name", name, Confidence.HIGHEST);
nodeModule.addEvidence(EvidenceType.VENDOR, "package.json", "name", name, Confidence.HIGH);
nodeModule.addEvidence(EvidenceType.VERSION, "package.json", "version", version, Confidence.HIGHEST);
nodeModule.addProjectReference(dependency.getName() + ": " + scope);
nodeModule.setName(name);
nodeModule.setVersion(version);
nodeModule.addIdentifier("npm", String.format("%s:%s", name, version), null, Confidence.HIGHEST);
return nodeModule;
}
/**
* Processes a part of package.json (as defined by JsonArray) and update the
* specified dependency with relevant info.
*
* @param engine the dependency-check engine
* @param dependency the Dependency to update
* @param jsonArray the jsonArray to parse
* @param depType the dependency type
*/
protected void processPackage(Engine engine, Dependency dependency, JsonArray jsonArray, String depType) {
final JsonObjectBuilder builder = Json.createObjectBuilder();
for (JsonString str : jsonArray.getValuesAs(JsonString.class)) {
builder.add(str.toString(), "");
}
final JsonObject jsonObject = builder.build();
processPackage(engine, dependency, jsonObject, depType);
}
/**
* Processes a part of package.json (as defined by JsonObject) and update
* the specified dependency with relevant info.
*
* @param engine the dependency-check engine
* @param dependency the Dependency to update
* @param jsonObject the jsonObject to parse
* @param depType the dependency type
*/
protected void processPackage(Engine engine, Dependency dependency, JsonObject jsonObject, String depType) {
for (int i = 0; i < jsonObject.size(); i++) {
for (Map.Entry<String, JsonValue> entry : jsonObject.entrySet()) {
final String name = entry.getKey();
String version = "";
if (entry.getValue() != null && entry.getValue().getValueType() == JsonValue.ValueType.STRING) {
version = ((JsonString) entry.getValue()).getString();
}
final Dependency existing = findDependency(engine, name, version);
if (existing == null) {
final Dependency nodeModule = createDependency(dependency, name, version, depType);
engine.addDependency(nodeModule);
} else {
existing.addProjectReference(dependency.getName() + ": " + depType);
}
}
}
}
/**
* Adds information to an evidence collection from the node json
* configuration.
*
* @param dep the dependency to add the evidence
* @param t the type of evidence to add
* @param json information from node.js
* @return the actual string set into evidence
* @param key the key to obtain the data from the json information
*/
private static String addToEvidence(Dependency dep, EvidenceType t, JsonObject json, String key) {
String evidenceStr = null;
if (json.containsKey(key)) {
final JsonValue value = json.get(key);
if (value instanceof JsonString) {
evidenceStr = ((JsonString) value).getString();
dep.addEvidence(t, PACKAGE_JSON, key, evidenceStr, Confidence.HIGHEST);
} else if (value instanceof JsonObject) {
final JsonObject jsonObject = (JsonObject) value;
for (final Map.Entry<String, JsonValue> entry : jsonObject.entrySet()) {
final String property = entry.getKey();
final JsonValue subValue = entry.getValue();
if (subValue instanceof JsonString) {
evidenceStr = ((JsonString) subValue).getString();
dep.addEvidence(t, PACKAGE_JSON,
String.format("%s.%s", key, property),
evidenceStr,
Confidence.HIGHEST);
} else {
LOGGER.warn("JSON sub-value not string as expected: {}", subValue);
}
}
} else {
LOGGER.warn("JSON value not string or JSON object as expected: {}", value);
}
}
return evidenceStr;
}
/**
* Locates the dependency from the list of dependencies that have been
* scanned by the engine.
*
* @param engine the dependency-check engine
* @param name the name of the dependency to find
* @param version the version of the dependency to find
* @return the identified dependency; otherwise null
*/
protected Dependency findDependency(Engine engine, String name, String version) {
for (Dependency d : engine.getDependencies()) {
if (NPM_DEPENDENCY_ECOSYSTEM.equals(d.getEcosystem()) && name.equals(d.getName()) && version != null && d.getVersion() != null) {
String dependencyVersion = d.getVersion();
if (DependencyBundlingAnalyzer.npmVersionsMatch(version, dependencyVersion)) {
return d;
}
// if (dependencyVersion.startsWith("^") || dependencyVersion.startsWith("~")) {
// dependencyVersion = dependencyVersion.substring(1);
// }
//
// if (version.equals(dependencyVersion)) {
// return d;
// }
// if (version.startsWith("^") || version.startsWith("~") || version.contains("*")) {
// String type;
// String tmp;
// if (version.startsWith("^") || version.startsWith("~")) {
// type = version.substring(0, 1);
// tmp = version.substring(1);
// } else {
// type = "*";
// tmp = version;
// }
// final String[] v = tmp.split(" ")[0].split("\\.");
// final String[] depVersion = dependencyVersion.split("\\.");
//
// if ("^".equals(type) && v[0].equals(depVersion[0])) {
// return d;
// } else if ("~".equals(type) && v.length >= 2 && depVersion.length >= 2
// && v[0].equals(depVersion[0]) && v[1].equals(depVersion[1])) {
// return d;
// } else if (v[0].equals("*")
// || (v.length >= 2 && v[0].equals(depVersion[0]) && v[1].equals("*"))
// || (v.length >= 3 && depVersion.length >= 2 && v[0].equals(depVersion[0])
// && v[1].equals(depVersion[1]) && v[2].equals("*"))) {
// return d;
// }
// }
}
}
return null;
}
/**
* Collects evidence from the given JSON for the associated dependency.
*
* @param json the JSON that contains the evidence to collect
* @param dependency the dependency to add the evidence too
*/
public void gatherEvidence(final JsonObject json, Dependency dependency) {
if (json.containsKey("name")) {
final Object value = json.get("name");
if (value instanceof JsonString) {
final String valueString = ((JsonString) value).getString();
dependency.setName(valueString);
dependency.setPackagePath(valueString);
dependency.addEvidence(EvidenceType.PRODUCT, PACKAGE_JSON, "name", valueString, Confidence.HIGHEST);
dependency.addEvidence(EvidenceType.VENDOR, PACKAGE_JSON, "name", valueString, Confidence.HIGH);
} else {
LOGGER.warn("JSON value not string as expected: {}", value);
}
}
final String desc = addToEvidence(dependency, EvidenceType.PRODUCT, json, "description");
dependency.setDescription(desc);
addToEvidence(dependency, EvidenceType.VENDOR, json, "author");
final String version = addToEvidence(dependency, EvidenceType.VERSION, json, "version");
if (version != null) {
dependency.setVersion(version);
dependency.addIdentifier("npm", String.format("%s:%s", dependency.getName(), version), null, Confidence.HIGHEST);
}
// Adds the license if defined in package.json
if (json.containsKey("license")) {
final Object value = json.get("license");
if (value instanceof JsonString) {
dependency.setLicense(json.getString("license"));
} else {
dependency.setLicense(json.getJsonObject("license").getString("type"));
}
}
}
}

View File

@@ -17,6 +17,9 @@
*/
package org.owasp.dependencycheck.analyzer;
import com.vdurmont.semver4j.Semver;
import com.vdurmont.semver4j.Semver.SemverType;
import com.vdurmont.semver4j.SemverException;
import java.io.File;
import java.util.Set;
import java.util.regex.Matcher;
@@ -135,10 +138,11 @@ public class DependencyBundlingAnalyzer extends AbstractDependencyComparingAnaly
mergeDependencies(nextDependency, dependency, dependenciesToRemove);
return true; //since we merged into the next dependency - skip forward to the next in mainIterator
}
} else if (ecoSystemIs(NspAnalyzer.DEPENDENCY_ECOSYSTEM, dependency, nextDependency)
} else if (ecoSystemIs(AbstractNpmAnalyzer.NPM_DEPENDENCY_ECOSYSTEM, dependency, nextDependency)
&& namesAreEqual(dependency, nextDependency)
&& versionsAreEqual(dependency, nextDependency)) {
if (dependency.isVirtual()) {
&& npmVersionsMatch(dependency.getVersion(), nextDependency.getVersion())) {
if (!dependency.isVirtual()) {
DependencyMergingAnalyzer.mergeDependencies(dependency, nextDependency, dependenciesToRemove);
} else {
DependencyMergingAnalyzer.mergeDependencies(nextDependency, dependency, dependenciesToRemove);
@@ -158,7 +162,7 @@ public class DependencyBundlingAnalyzer extends AbstractDependencyComparingAnaly
* removed from the main analysis loop, this function adds to this
* collection
*/
private void mergeDependencies(final Dependency dependency, final Dependency relatedDependency, final Set<Dependency> dependenciesToRemove) {
public static void mergeDependencies(final Dependency dependency, final Dependency relatedDependency, final Set<Dependency> dependenciesToRemove) {
dependency.addRelatedDependency(relatedDependency);
for (Dependency d : relatedDependency.getRelatedDependencies()) {
dependency.addRelatedDependency(d);
@@ -167,7 +171,9 @@ public class DependencyBundlingAnalyzer extends AbstractDependencyComparingAnaly
if (dependency.getSha1sum().equals(relatedDependency.getSha1sum())) {
dependency.addAllProjectReferences(relatedDependency.getProjectReferences());
}
dependenciesToRemove.add(relatedDependency);
if (dependenciesToRemove != null) {
dependenciesToRemove.add(relatedDependency);
}
}
/**
@@ -487,14 +493,72 @@ public class DependencyBundlingAnalyzer extends AbstractDependencyComparingAnaly
/**
* Determine if the dependency version is equal in the given dependencies.
* This method attempts to evaluate version range checks.
*
* @param dependency a dependency to compare
* @param nextDependency a dependency to compare
* @param current a dependency version to compare
* @param nextDependency a dependency version to compare
* @return true if the version is equal in both dependencies; otherwise
* false
*/
private boolean versionsAreEqual(Dependency dependency, Dependency nextDependency) {
return dependency.getVersion() != null && dependency.getVersion().equals(nextDependency.getVersion());
public static boolean npmVersionsMatch(String current, String next) {
String left = current;
String right = next;
if (left == null || right == null) {
return false;
}
if (left.equals(right) || "*".equals(left) || "*".equals(right)) {
return true;
}
if (left.contains(" ")) { // we have a version string from package.json
if (right.contains(" ")) { // we can't evaluate this ">=1.5.4 <2.0.0" vs "2 || 3"
return false;
}
if (!right.matches("^\\d.*$")) {
right = stripLeadingNonNumeric(right);
if (right == null) {
return false;
}
}
try {
Semver v = new Semver(right, SemverType.NPM);
return v.satisfies(left);
} catch (SemverException ex) {
LOGGER.trace("ignore", ex);
}
} else {
if (!left.matches("^\\d.*$")) {
left = stripLeadingNonNumeric(left);
if (left == null) {
return false;
}
}
try {
Semver v = new Semver(left, SemverType.NPM);
if (v.satisfies(right)) {
return true;
}
if (!right.contains((" "))) {
left = current;
right = stripLeadingNonNumeric(right);
if (right != null) {
v = new Semver(right, SemverType.NPM);
return v.satisfies(left);
}
}
} catch (SemverException ex) {
LOGGER.trace("ignore", ex);
}
}
return false;
}
private static String stripLeadingNonNumeric(String str) {
for (int x = 0; x < str.length(); x++) {
if (Character.isDigit(str.codePointAt(x))) {
return str.substring(x);
}
}
return null;
}
}

View File

@@ -128,4 +128,4 @@ updater.nvdcve.enabled=true
updater.versioncheck.enabled=true
analyzer.versionfilter.enabled=true
ecosystem.skip.nvdcve=npm
ecosystem.skip.cpeanalyzer=npm

View File

@@ -43,6 +43,9 @@ public class NodePackageAnalyzerTest extends BaseTest {
* The analyzer to test.
*/
private NodePackageAnalyzer analyzer;
/**
* A reference to the engine.
*/
private Engine engine;
/**
@@ -87,7 +90,8 @@ public class NodePackageAnalyzerTest extends BaseTest {
*/
@Test
public void testSupportsFiles() {
assertThat(analyzer.accept(new File("package.json")), is(true));
assertThat(analyzer.accept(new File("package-lock.json")), is(true));
assertThat(analyzer.accept(new File("shrinkwrap.json")), is(true));
}
/**
@@ -96,10 +100,12 @@ public class NodePackageAnalyzerTest extends BaseTest {
* @throws AnalysisException is thrown when an exception occurs.
*/
@Test
public void testAnalyzePackageJson() throws AnalysisException {
final Dependency result = new Dependency(BaseTest.getResourceAsFile(this,
"nodejs/node_modules/dns-sync/package.json"));
analyzer.analyze(result, null);
public void testAnalyzeShrinkwrapJson() throws AnalysisException {
final Dependency toScan = new Dependency(BaseTest.getResourceAsFile(this,
"nodejs/shrinkwrap.json"));
analyzer.analyze(toScan, engine);
assertEquals("Expected 1 dependency", engine.getDependencies().length, 1);
final Dependency result = engine.getDependencies()[0];
final String vendorString = result.getEvidence(EvidenceType.VENDOR).toString();
assertThat(vendorString, containsString("Sanjeev Koranga"));
assertThat(vendorString, containsString("dns-sync"));
@@ -109,4 +115,24 @@ public class NodePackageAnalyzerTest extends BaseTest {
assertEquals("dns-sync", result.getName());
assertEquals("0.1.0", result.getVersion());
}
/**
* Test of inspect method, of class PythonDistributionAnalyzer.
*
* @throws AnalysisException is thrown when an exception occurs.
*/
@Test
public void testAnalyzePackageJsonWithShrinkwrap() throws AnalysisException {
final Dependency packageLock = new Dependency(BaseTest.getResourceAsFile(this,
"nodejs/package-lock.json"));
final Dependency shrinkwrap = new Dependency(BaseTest.getResourceAsFile(this,
"nodejs/shrinkwrap.json"));
engine.addDependency(packageLock);
engine.addDependency(shrinkwrap);
assertEquals(2, engine.getDependencies().length);
analyzer.analyze(packageLock, engine);
assertEquals(1, engine.getDependencies().length); //package-lock was removed without analysis
analyzer.analyze(shrinkwrap, engine);
assertEquals(1, engine.getDependencies().length); //shrinkwrap was removed with analysis adding 1 dependency
}
}

View File

@@ -1,7 +1,5 @@
package org.owasp.dependencycheck.analyzer;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.owasp.dependencycheck.BaseTest;
import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
@@ -36,12 +34,20 @@ public class NspAnalyzerTest extends BaseTest {
analyzer.setFilesMatched(true);
analyzer.initialize(getSettings());
analyzer.prepare(engine);
final Dependency result = new Dependency(BaseTest.getResourceAsFile(this, "nsp/package.json"));
analyzer.analyze(result, engine);
assertTrue(result.getEvidence(EvidenceType.VENDOR).toString().contains("owasp-nodejs-goat"));
assertTrue(result.getEvidence(EvidenceType.PRODUCT).toString().contains("A tool to learn OWASP Top 10 for node.js developers"));
assertTrue(result.getEvidence(EvidenceType.VERSION).toString().contains("1.3.0"));
final Dependency toScan = new Dependency(BaseTest.getResourceAsFile(this, "nsp/package.json"));
analyzer.analyze(toScan, engine);
boolean found = false;
assertEquals("4 dependencies should be identified", 4, engine.getDependencies().length);
for (Dependency result : engine.getDependencies()) {
if ("package.json?uglify-js".equals(result.getFileName())) {
found = true;
assertTrue(result.getEvidence(EvidenceType.VENDOR).toString().contains("uglify-js"));
assertTrue(result.getEvidence(EvidenceType.PRODUCT).toString().contains("uglify-js"));
assertTrue(result.getEvidence(EvidenceType.VERSION).toString().contains("2.4.24"));
assertTrue(result.isVirtual());
}
}
assertTrue("Uglify was not found", found);
}
}
@@ -61,38 +67,6 @@ public class NspAnalyzerTest extends BaseTest {
}
}
@Test
public void testAnalyzePackageJsonWithBundledDeps() throws AnalysisException, InitializationException {
try (Engine engine = new Engine(getSettings())) {
NspAnalyzer analyzer = new NspAnalyzer();
analyzer.setFilesMatched(true);
analyzer.initialize(getSettings());
analyzer.prepare(engine);
final Dependency result = new Dependency(BaseTest.getResourceAsFile(this, "nsp/bundled.deps.package.json"));
analyzer.analyze(result, engine);
assertTrue(result.getEvidence(EvidenceType.VENDOR).toString().contains("Philipp Dunkel <pip@pipobscure.com>"));
assertTrue(result.getEvidence(EvidenceType.PRODUCT).toString().contains("Native Access to Mac OS-X FSEvents"));
assertTrue(result.getEvidence(EvidenceType.VERSION).toString().contains("1.1.1"));
}
}
@Test
public void testAnalyzePackageJsonWithLicenseObject() throws AnalysisException, InitializationException {
try (Engine engine = new Engine(getSettings())) {
NspAnalyzer analyzer = new NspAnalyzer();
analyzer.setFilesMatched(true);
analyzer.initialize(getSettings());
analyzer.prepare(engine);
final Dependency result = new Dependency(BaseTest.getResourceAsFile(this, "nsp/license.obj.package.json"));
analyzer.analyze(result, engine);
assertTrue(result.getEvidence(EvidenceType.VENDOR).toString().contains("Twitter, Inc."));
assertTrue(result.getEvidence(EvidenceType.PRODUCT).toString().contains("The most popular front-end framework for developing responsive, mobile first projects on the web"));
assertTrue(result.getEvidence(EvidenceType.VERSION).toString().contains("3.2.0"));
}
}
@Test
public void testAnalyzePackageJsonInNodeModulesDirectory() throws AnalysisException, InitializationException {
try (Engine engine = new Engine(getSettings())) {
@@ -100,12 +74,10 @@ public class NspAnalyzerTest extends BaseTest {
analyzer.setFilesMatched(true);
analyzer.initialize(getSettings());
analyzer.prepare(engine);
final Dependency result = new Dependency(BaseTest.getResourceAsFile(this, "nodejs/node_modules/dns-sync/package.json"));
analyzer.analyze(result, engine);
// package.json adds 5 bits of evidence
assertTrue(result.size() == 5);
// but no vulnerabilities were cited
assertTrue(result.getVulnerabilities().isEmpty());
final Dependency toScan = new Dependency(BaseTest.getResourceAsFile(this, "nodejs/node_modules/dns-sync/package.json"));
engine.addDependency(toScan);
analyzer.analyze(toScan, engine);
assertEquals("No dependencies should exist", 0, engine.getDependencies().length);
}
}

View File

@@ -124,4 +124,4 @@ analyzer.vulnerabilitysuppression.enabled=true
updater.nvdcve.enabled=true
updater.versioncheck.enabled=true
ecosystem.skip.nvdcve=npm
ecosystem.skip.cpeanalyzer=npm

View File

@@ -443,9 +443,9 @@ public final class Settings {
*/
public static final String UPDATE_VERSION_CHECK_ENABLED = "updater.versioncheck.enabled";
/**
* The key to determine which ecosystems should skip the NVD CVE analysis.
* The key to determine which ecosystems should skip the CPE analysis.
*/
public static final String ECOSYSTEM_SKIP_NVDCVE = "ecosystem.skip.nvdcve";
public static final String ECOSYSTEM_SKIP_CPEANALYZER = "ecosystem.skip.cpeanalyzer";
/**
* private constructor because this is a "utility" class containing

View File

@@ -625,6 +625,11 @@ Copyright (c) 2012 - Jeremy Long
</reporting>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vdurmont</groupId>
<artifactId>semver4j</artifactId>
<version>2.1.0</version>
</dependency>
<!-- analysis core (used by Jenkins) uses 1.6-->
<dependency>
<groupId>joda-time</groupId>