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