View Javadoc
1   /*
2    * This file is part of dependency-check-core.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   *
16   * Copyright (c) 2017 Steve Springett. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.analyzer;
19  
20  import com.github.packageurl.MalformedPackageURLException;
21  import com.github.packageurl.PackageURL;
22  import com.github.packageurl.PackageURL.StandardTypes;
23  import com.github.packageurl.PackageURLBuilder;
24  import org.semver4j.Semver;
25  import org.semver4j.SemverException;
26  import org.owasp.dependencycheck.Engine;
27  import org.owasp.dependencycheck.data.nodeaudit.Advisory;
28  import org.owasp.dependencycheck.data.nodeaudit.NodeAuditSearch;
29  import org.owasp.dependencycheck.dependency.Confidence;
30  import org.owasp.dependencycheck.dependency.Dependency;
31  import org.owasp.dependencycheck.dependency.Reference;
32  import org.owasp.dependencycheck.dependency.Vulnerability;
33  import org.owasp.dependencycheck.dependency.VulnerableSoftware;
34  import org.owasp.dependencycheck.dependency.VulnerableSoftwareBuilder;
35  import org.owasp.dependencycheck.exception.InitializationException;
36  import org.owasp.dependencycheck.utils.InvalidSettingException;
37  import org.owasp.dependencycheck.utils.Settings;
38  import org.slf4j.Logger;
39  import org.slf4j.LoggerFactory;
40  import java.io.File;
41  import java.io.IOException;
42  import java.net.MalformedURLException;
43  import java.net.URL;
44  import java.util.Collection;
45  import java.util.List;
46  import java.util.Map;
47  import javax.annotation.concurrent.ThreadSafe;
48  import javax.json.Json;
49  import javax.json.JsonArray;
50  import javax.json.JsonObject;
51  import javax.json.JsonObjectBuilder;
52  import javax.json.JsonString;
53  import javax.json.JsonValue;
54  import javax.json.JsonValue.ValueType;
55  import org.apache.commons.collections4.MultiValuedMap;
56  import org.apache.commons.lang3.StringUtils;
57  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
58  import org.owasp.dependencycheck.analyzer.exception.UnexpectedAnalysisException;
59  import org.owasp.dependencycheck.data.nvd.ecosystem.Ecosystem;
60  import org.owasp.dependencycheck.dependency.EvidenceType;
61  import org.owasp.dependencycheck.dependency.naming.GenericIdentifier;
62  import org.owasp.dependencycheck.dependency.naming.Identifier;
63  import org.owasp.dependencycheck.dependency.naming.PurlIdentifier;
64  import org.owasp.dependencycheck.utils.Checksum;
65  import us.springett.parsers.cpe.exceptions.CpeValidationException;
66  import us.springett.parsers.cpe.values.Part;
67  
68  /**
69   * An abstract NPM analyzer that contains common methods for concrete
70   * implementations.
71   *
72   * @author Steve Springett
73   */
74  @ThreadSafe
75  public abstract class AbstractNpmAnalyzer extends AbstractFileTypeAnalyzer {
76  
77      /**
78       * The logger.
79       */
80      private static final Logger LOGGER = LoggerFactory.getLogger(AbstractNpmAnalyzer.class);
81  
82      /**
83       * A descriptor for the type of dependencies processed or added by this
84       * analyzer.
85       */
86      public static final String NPM_DEPENDENCY_ECOSYSTEM = Ecosystem.NODEJS;
87      /**
88       * The file name to scan.
89       */
90      private static final String PACKAGE_JSON = "package.json";
91  
92      /**
93       * The Node Audit Searcher.
94       */
95      private NodeAuditSearch searcher;
96  
97      /**
98       * Determines if the file can be analyzed by the analyzer.
99       *
100      * @param pathname the path to the file
101      * @return true if the file can be analyzed by the given analyzer; otherwise
102      * false
103      */
104     @Override
105     public boolean accept(File pathname) {
106         boolean accept = super.accept(pathname);
107         if (accept) {
108             try {
109                 accept = shouldProcess(pathname);
110             } catch (AnalysisException ex) {
111                 throw new UnexpectedAnalysisException(ex.getMessage(), ex.getCause());
112             }
113         }
114         return accept;
115     }
116 
117     /**
118      * Determines if the path contains "/node_modules/" or "/bower_components/"
119      * (i.e. it is a child module). This analyzer does not scan child modules.
120      *
121      * @param pathname the path to test
122      * @return <code>true</code> if the path does not contain "/node_modules/"
123      * or "/bower_components/"
124      * @throws AnalysisException thrown if the canonical path cannot be obtained
125      * from the given file
126      */
127     public static boolean shouldProcess(File pathname) throws AnalysisException {
128         try {
129             // Do not scan the node_modules (or bower_components) directory
130             final String canonicalPath = pathname.getCanonicalPath();
131             if (canonicalPath.contains(File.separator + "node_modules" + File.separator)
132                     || canonicalPath.contains(File.separator + "bower_components" + File.separator)) {
133                 LOGGER.debug("Skipping analysis of node/bower module: {}", canonicalPath);
134                 return false;
135             }
136         } catch (IOException ex) {
137             throw new AnalysisException("Unable to process dependency", ex);
138         }
139         return true;
140     }
141 
142     /**
143      * Construct a dependency object.
144      *
145      * @param dependency the parent dependency
146      * @param name the name of the dependency to create
147      * @param version the version of the dependency to create
148      * @param scope the scope of the dependency being created
149      * @return the generated dependency
150      */
151     protected Dependency createDependency(Dependency dependency, String name, String version, String scope) {
152         final Dependency nodeModule = new Dependency(new File(dependency.getActualFile() + "?" + name), true);
153         nodeModule.setEcosystem(NPM_DEPENDENCY_ECOSYSTEM);
154         //this is virtual - the sha1 is purely for the hyperlink in the final html report
155         nodeModule.setSha1sum(Checksum.getSHA1Checksum(String.format("%s:%s", name, version)));
156         nodeModule.setSha256sum(Checksum.getSHA256Checksum(String.format("%s:%s", name, version)));
157         nodeModule.setMd5sum(Checksum.getMD5Checksum(String.format("%s:%s", name, version)));
158         nodeModule.addEvidence(EvidenceType.PRODUCT, "package.json", "name", name, Confidence.HIGHEST);
159         nodeModule.addEvidence(EvidenceType.VENDOR, "package.json", "name", name, Confidence.HIGH);
160         if (!StringUtils.isBlank(version)) {
161             nodeModule.addEvidence(EvidenceType.VERSION, "package.json", "version", version, Confidence.HIGHEST);
162             nodeModule.setVersion(version);
163         }
164         if (dependency.getName() != null) {
165             nodeModule.addProjectReference(dependency.getName() + ": " + scope);
166         } else {
167             nodeModule.addProjectReference(dependency.getDisplayFileName() + ": " + scope);
168         }
169         nodeModule.setName(name);
170 
171         //TODO  - we can likely create a valid CPE as a low confidence guess using cpe:2.3:a:[name]_project:[name]:[version]
172         //(and add a targetSw of npm/node)
173         Identifier id;
174         try {
175             final PackageURL purl = PackageURLBuilder.aPackageURL().withType(StandardTypes.NPM)
176                     .withName(name).withVersion(version).build();
177             id = new PurlIdentifier(purl, Confidence.HIGHEST);
178         } catch (MalformedPackageURLException ex) {
179             LOGGER.debug("Unable to generate Purl - using a generic identifier instead " + ex.getMessage());
180             id = new GenericIdentifier(String.format("npm:%s@%s", dependency.getName(), version), Confidence.HIGHEST);
181         }
182         nodeModule.addSoftwareIdentifier(id);
183         return nodeModule;
184     }
185 
186     /**
187      * Processes a part of package.json (as defined by JsonArray) and update the
188      * specified dependency with relevant info.
189      *
190      * @param engine the dependency-check engine
191      * @param dependency the Dependency to update
192      * @param jsonArray the jsonArray to parse
193      * @param depType the dependency type
194      */
195     protected void processPackage(Engine engine, Dependency dependency, JsonArray jsonArray, String depType) {
196         final JsonObjectBuilder builder = Json.createObjectBuilder();
197         jsonArray.getValuesAs(JsonString.class).forEach((str) -> builder.add(str.toString(), ""));
198         final JsonObject jsonObject = builder.build();
199         processPackage(engine, dependency, jsonObject, depType);
200     }
201 
202     /**
203      * Processes a part of package.json (as defined by JsonObject) and update
204      * the specified dependency with relevant info.
205      *
206      * @param engine the dependency-check engine
207      * @param dependency the Dependency to update
208      * @param jsonObject the jsonObject to parse
209      * @param depType the dependency type
210      */
211     protected void processPackage(Engine engine, Dependency dependency, JsonObject jsonObject, String depType) {
212         for (int i = 0; i < jsonObject.size(); i++) {
213             jsonObject.forEach((name, value) -> {
214                 String version = "";
215                 if (value != null && value.getValueType() == ValueType.STRING) {
216                     version = ((JsonString) value).getString();
217                 }
218                 final Dependency existing = findDependency(engine, name, version);
219                 if (existing == null) {
220                     final Dependency nodeModule = createDependency(dependency, name, version, depType);
221                     engine.addDependency(nodeModule);
222                 } else {
223                     existing.addProjectReference(dependency.getName() + ": " + depType);
224                 }
225             });
226         }
227     }
228 
229     /**
230      * Adds information to an evidence collection from the node json
231      * configuration.
232      *
233      * @param dep the dependency to add the evidence
234      * @param t the type of evidence to add
235      * @param json information from node.js
236      * @return the actual string set into evidence
237      * @param key the key to obtain the data from the json information
238      */
239     private static String addToEvidence(Dependency dep, EvidenceType t, JsonObject json, String key) {
240         String evidenceStr = null;
241         if (json.containsKey(key)) {
242             final JsonValue value = json.get(key);
243             if (value instanceof JsonString) {
244                 evidenceStr = ((JsonString) value).getString();
245                 dep.addEvidence(t, PACKAGE_JSON, key, evidenceStr, Confidence.HIGHEST);
246             } else if (value instanceof JsonObject) {
247                 final JsonObject jsonObject = (JsonObject) value;
248                 for (final Map.Entry<String, JsonValue> entry : jsonObject.entrySet()) {
249                     final String property = entry.getKey();
250                     final JsonValue subValue = entry.getValue();
251                     if (subValue instanceof JsonString) {
252                         evidenceStr = ((JsonString) subValue).getString();
253                         dep.addEvidence(t, PACKAGE_JSON,
254                                 String.format("%s.%s", key, property),
255                                 evidenceStr,
256                                 Confidence.HIGHEST);
257                     } else {
258                         LOGGER.warn("JSON sub-value not string as expected: {}", subValue);
259                     }
260                 }
261             } else if (value instanceof JsonArray) {
262                 final JsonArray jsonArray = (JsonArray) value;
263                 jsonArray.forEach(entry -> {
264                     if (entry instanceof JsonObject) {
265                         ((JsonObject) entry).keySet().forEach(item -> {
266                             final JsonValue v = ((JsonObject) entry).get(item);
267                             if (v instanceof JsonString) {
268                                 final String eStr = ((JsonString) v).getString();
269                                 dep.addEvidence(t, PACKAGE_JSON,
270                                         String.format("%s.%s", key, item),
271                                         eStr,
272                                         Confidence.HIGHEST);
273                             }
274                         });
275                     }
276                 });
277             } else {
278                 LOGGER.warn("JSON value not string or JSON object as expected: {}", value);
279             }
280         }
281         return evidenceStr;
282     }
283 
284     /**
285      * Locates the dependency from the list of dependencies that have been
286      * scanned by the engine.
287      *
288      * @param engine the dependency-check engine
289      * @param name the name of the dependency to find
290      * @param version the version of the dependency to find
291      * @return the identified dependency; otherwise null
292      */
293     protected Dependency findDependency(Engine engine, String name, String version) {
294         for (Dependency d : engine.getDependencies()) {
295             if (NPM_DEPENDENCY_ECOSYSTEM.equals(d.getEcosystem()) && name.equals(d.getName()) && version != null && d.getVersion() != null) {
296                 final String dependencyVersion = d.getVersion();
297                 if (DependencyBundlingAnalyzer.npmVersionsMatch(version, dependencyVersion)) {
298                     return d;
299                 }
300             }
301         }
302         return null;
303     }
304 
305     /**
306      * Collects evidence from the given JSON for the associated dependency.
307      *
308      * @param json the JSON that contains the evidence to collect
309      * @param dependency the dependency to add the evidence too
310      */
311     public void gatherEvidence(final JsonObject json, Dependency dependency) {
312         String displayName = null;
313         if (json.containsKey("name")) {
314             final Object value = json.get("name");
315             if (value instanceof JsonString) {
316                 final String valueString = ((JsonString) value).getString();
317                 displayName = valueString;
318                 dependency.setName(valueString);
319                 dependency.setPackagePath(valueString);
320                 dependency.addEvidence(EvidenceType.PRODUCT, PACKAGE_JSON, "name", valueString, Confidence.HIGHEST);
321                 dependency.addEvidence(EvidenceType.VENDOR, PACKAGE_JSON, "name", valueString, Confidence.HIGHEST);
322                 dependency.addEvidence(EvidenceType.VENDOR, PACKAGE_JSON, "name", valueString + "_project", Confidence.HIGHEST);
323             } else {
324                 LOGGER.warn("JSON value not string as expected: {}", value);
325             }
326         }
327         //TODO - if we start doing CPE analysis on node - we need to exclude description as it creates too many FP
328         final String desc = addToEvidence(dependency, EvidenceType.VENDOR, json, "description");
329         dependency.setDescription(desc);
330         String vendor = addToEvidence(dependency, EvidenceType.VENDOR, json, "author");
331         if (vendor == null) {
332             vendor = addToEvidence(dependency, EvidenceType.VENDOR, json, "maintainers");
333         } else {
334             addToEvidence(dependency, EvidenceType.VENDOR, json, "maintainers");
335         }
336         addToEvidence(dependency, EvidenceType.VENDOR, json, "homepage");
337         addToEvidence(dependency, EvidenceType.VENDOR, json, "bugs");
338 
339         final String version = addToEvidence(dependency, EvidenceType.VERSION, json, "version");
340         if (version != null) {
341             displayName = String.format("%s:%s", displayName, version);
342             dependency.setVersion(version);
343             dependency.setPackagePath(displayName);
344             Identifier id;
345             try {
346                 final PackageURL purl = PackageURLBuilder.aPackageURL()
347                         .withType(StandardTypes.NPM).withName(dependency.getName()).withVersion(version).build();
348                 id = new PurlIdentifier(purl, Confidence.HIGHEST);
349             } catch (MalformedPackageURLException ex) {
350                 LOGGER.debug("Unable to generate Purl - using a generic identifier instead " + ex.getMessage());
351                 id = new GenericIdentifier(String.format("npm:%s:%s", dependency.getName(), version), Confidence.HIGHEST);
352             }
353             dependency.addSoftwareIdentifier(id);
354         }
355         if (displayName != null) {
356             dependency.setDisplayFileName(displayName);
357             dependency.setPackagePath(displayName);
358         } else {
359             LOGGER.warn("Unable to determine package name or version for {}", dependency.getActualFilePath());
360             if (vendor != null && !vendor.isEmpty()) {
361                 dependency.setDisplayFileName(String.format("%s package.json", vendor));
362             }
363         }
364         // Adds the license if defined in package.json
365         if (json.containsKey("license")) {
366             final Object value = json.get("license");
367             if (value instanceof JsonString) {
368                 dependency.setLicense(json.getString("license"));
369             } else if (value instanceof JsonArray) {
370                 final JsonArray array = (JsonArray) value;
371                 final StringBuilder sb = new StringBuilder();
372                 boolean addComma = false;
373                 for (int x = 0; x < array.size(); x++) {
374                     if (!array.isNull(x)) {
375                         if (addComma) {
376                             sb.append(", ");
377                         } else {
378                             addComma = true;
379                         }
380                         if (ValueType.STRING == array.get(x).getValueType()) {
381                             sb.append(array.getString(x));
382                         } else {
383                             final JsonObject lo = array.getJsonObject(x);
384                             if (lo.containsKey("type") && !lo.isNull("type")
385                                     && lo.containsKey("url") && !lo.isNull("url")) {
386                                 final String license = String.format("%s (%s)", lo.getString("type"), lo.getString("url"));
387                                 sb.append(license);
388                             } else if (lo.containsKey("type") && !lo.isNull("type")) {
389                                 sb.append(lo.getString("type"));
390                             } else if (lo.containsKey("url") && !lo.isNull("url")) {
391                                 sb.append(lo.getString("url"));
392                             }
393                         }
394                     }
395                 }
396                 dependency.setLicense(sb.toString());
397             } else {
398                 dependency.setLicense(json.getJsonObject("license").getString("type"));
399             }
400         }
401     }
402 
403     /**
404      * Initializes the analyzer once before any analysis is performed.
405      *
406      * @param engine a reference to the dependency-check engine
407      * @throws InitializationException if there's an error during initialization
408      */
409     @Override
410     protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
411         if (!isEnabled() || !getFilesMatched()) {
412             this.setEnabled(false);
413             return;
414         }
415         if (searcher == null) {
416             LOGGER.debug("Initializing {}", getName());
417             try {
418                 searcher = new NodeAuditSearch(getSettings());
419             } catch (MalformedURLException ex) {
420                 setEnabled(false);
421                 throw new InitializationException("The configured URL to NPM Audit API is malformed", ex);
422             }
423             try {
424                 final Settings settings = engine.getSettings();
425                 final boolean nodeEnabled = settings.getBoolean(Settings.KEYS.ANALYZER_NODE_PACKAGE_ENABLED);
426                 if (!nodeEnabled) {
427                     LOGGER.warn("The Node Package Analyzer has been disabled; the resulting report will only "
428                             + "contain the known vulnerable dependency - not a bill of materials for the node project.");
429                 }
430             } catch (InvalidSettingException ex) {
431                 throw new InitializationException("Unable to read configuration settings", ex);
432             }
433         }
434     }
435 
436     /**
437      * Processes the advisories creating the appropriate dependency objects and
438      * adding the resulting vulnerabilities.
439      *
440      * @param advisories a collection of advisories from npm
441      * @param engine a reference to the analysis engine
442      * @param dependency a reference to the package-lock.json dependency
443      * @param dependencyMap a collection of module/version pairs obtained from
444      * the package-lock file - used in case the advisories do not include a
445      * version number
446      * @throws CpeValidationException thrown when a CPE cannot be created
447      */
448     protected void processResults(final List<Advisory> advisories, Engine engine,
449             Dependency dependency, MultiValuedMap<String, String> dependencyMap)
450             throws CpeValidationException {
451         for (Advisory advisory : advisories) {
452             //Create a new vulnerability out of the advisory returned by nsp.
453             final Vulnerability vuln = new Vulnerability();
454             vuln.setDescription(advisory.getOverview());
455             vuln.setName(String.valueOf(advisory.getGhsaId()));
456             vuln.setUnscoredSeverity(advisory.getSeverity());
457             vuln.setCvssV3(advisory.getCvssV3());
458             vuln.setSource(Vulnerability.Source.NPM);
459             for (String cwe : advisory.getCwes()) {
460                 vuln.addCwe(cwe);
461             }
462             if (advisory.getReferences() != null) {
463                 final String[] references = advisory.getReferences().split("\\n");
464                 for (String reference : references) {
465                     if (reference.length() > 3) {
466                         String url = reference.substring(2);
467                         try {
468                             new URL(url);
469                         } catch (MalformedURLException ignored) {
470                             // reference is not a format-valid URL, so null it to make the reference be used as plaintext
471                             url = null;
472                         }
473                         vuln.addReference("NPM Advisory reference: ", url == null ? reference : url, url);
474                     }
475                 }
476             }
477 
478             //Create a single vulnerable software object - these do not use CPEs unlike the NVD.
479             final VulnerableSoftwareBuilder builder = new VulnerableSoftwareBuilder();
480             builder.part(Part.APPLICATION).product(advisory.getModuleName().replace(" ", "_"))
481                     .version(advisory.getVulnerableVersions().replace(" ", ""));
482             final VulnerableSoftware vs = builder.build();
483             vuln.addVulnerableSoftware(vs);
484 
485             String version = advisory.getVersion();
486             if (version == null && dependencyMap.containsKey(advisory.getModuleName())) {
487                 version = determineVersionFromMap(advisory.getVulnerableVersions(), dependencyMap.get(advisory.getModuleName()));
488             }
489             final Dependency existing = findDependency(engine, advisory.getModuleName(), version);
490             if (existing == null) {
491                 final Dependency nodeModule = createDependency(dependency, advisory.getModuleName(), version, "transitive");
492                 nodeModule.addVulnerability(vuln);
493                 engine.addDependency(nodeModule);
494             } else {
495                 replaceOrAddVulnerability(existing, vuln);
496             }
497         }
498     }
499 
500     /**
501      * Evaluates if the vulnerability is already present; if it is the
502      * vulnerability is not added.
503      *
504      * @param dependency a reference to the dependency being analyzed
505      * @param vuln the vulnerability to add
506      */
507     protected void replaceOrAddVulnerability(Dependency dependency, Vulnerability vuln) {
508         boolean found = vuln.getSource() == Vulnerability.Source.NPM && 
509                 dependency.getVulnerabilities().stream().anyMatch(existing -> {
510             return existing.getReferences().stream().anyMatch(ref ->{
511                     return ref.getName() != null
512                             && ref.getName().equals("https://nodesecurity.io/advisories/" + vuln.getName());
513             });
514         });
515         if (!found) {
516             dependency.addVulnerability(vuln);
517         }
518     }
519 
520     /**
521      * Returns the node audit search utility.
522      *
523      * @return the node audit search utility
524      */
525     protected NodeAuditSearch getSearcher() {
526         return searcher;
527     }
528 
529     /**
530      * Give an NPM version range and a collection of versions, this method
531      * attempts to select a specific version from the collection that is in the
532      * range.
533      *
534      * @param versionRange the version range to evaluate
535      * @param availableVersions the collection of possible versions to select
536      * @return the selected range from the versionRange
537      */
538     public static String determineVersionFromMap(String versionRange, Collection<String> availableVersions) {
539         if (availableVersions.size() == 1) {
540             return availableVersions.iterator().next();
541         }
542         for (String v : availableVersions) {
543             try {
544                 final Semver version = new Semver(v);
545                 if (version.satisfies(versionRange)) {
546                     return v;
547                 }
548             } catch (SemverException ex) {
549                 LOGGER.debug("invalid semver: " + v);
550             }
551         }
552         return availableVersions.iterator().next();
553     }
554 }