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.esotericsoftware.minlog.Log;
21  import com.github.packageurl.MalformedPackageURLException;
22  import com.github.packageurl.PackageURLBuilder;
23  import com.h3xstream.retirejs.repo.JsLibraryResult;
24  import com.h3xstream.retirejs.repo.ScannerFacade;
25  import com.h3xstream.retirejs.repo.VulnerabilitiesRepository;
26  import com.h3xstream.retirejs.repo.VulnerabilitiesRepositoryLoader;
27  import org.apache.commons.lang3.StringUtils;
28  import org.apache.commons.validator.routines.UrlValidator;
29  import org.json.JSONException;
30  import org.jspecify.annotations.NonNull;
31  import org.jspecify.annotations.Nullable;
32  import org.owasp.dependencycheck.Engine;
33  import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
34  import org.owasp.dependencycheck.data.nvd.ecosystem.Ecosystem;
35  import org.owasp.dependencycheck.data.nvdcve.DatabaseException;
36  import org.owasp.dependencycheck.data.update.RetireJSDataSource;
37  import org.owasp.dependencycheck.data.update.exception.UpdateException;
38  import org.owasp.dependencycheck.dependency.Confidence;
39  import org.owasp.dependencycheck.dependency.Dependency;
40  import org.owasp.dependencycheck.dependency.EvidenceType;
41  import org.owasp.dependencycheck.dependency.Reference;
42  import org.owasp.dependencycheck.dependency.Vulnerability;
43  import org.owasp.dependencycheck.dependency.naming.GenericIdentifier;
44  import org.owasp.dependencycheck.dependency.naming.Identifier;
45  import org.owasp.dependencycheck.dependency.naming.PurlIdentifier;
46  import org.owasp.dependencycheck.exception.InitializationException;
47  import org.owasp.dependencycheck.exception.WriteLockException;
48  import org.owasp.dependencycheck.utils.FileFilterBuilder;
49  import org.owasp.dependencycheck.utils.Settings;
50  import org.owasp.dependencycheck.utils.WriteLock;
51  import org.owasp.dependencycheck.utils.search.FileContentSearch;
52  import org.slf4j.Logger;
53  import org.slf4j.LoggerFactory;
54  
55  import javax.annotation.concurrent.ThreadSafe;
56  import java.io.File;
57  import java.io.FileFilter;
58  import java.io.FileInputStream;
59  import java.io.IOException;
60  import java.io.InputStream;
61  import java.net.URL;
62  import java.nio.file.Files;
63  import java.util.HashSet;
64  import java.util.LinkedHashMap;
65  import java.util.List;
66  import java.util.Map;
67  import java.util.Objects;
68  import java.util.Optional;
69  import java.util.Set;
70  import java.util.stream.Collectors;
71  
72  import org.apache.commons.io.IOUtils;
73  
74  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.CVE;
75  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.GITHUB_SECURITY_ADVISORY;
76  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.SECONDARY_NAME_TYPES;
77  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.SUMMARY;
78  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.singleEntry;
79  import static org.owasp.dependencycheck.analyzer.RetireJsLibrary.KnownIdentifierTypes.singleItem;
80  
81  /**
82   * The RetireJS analyzer uses the manually curated list of vulnerabilities from
83   * the RetireJS community along with the necessary information to assist in
84   * identifying vulnerable components. Vulnerabilities documented by the RetireJS
85   * community usually originate from other sources such as the NVD, GHSA,
86   * and various issue trackers.
87   *
88   * @author Steve Springett
89   */
90  @ThreadSafe
91  public class RetireJsAnalyzer extends AbstractFileTypeAnalyzer {
92  
93      /**
94       * A descriptor for the type of dependencies processed or added by this
95       * analyzer.
96       */
97      public static final String DEPENDENCY_ECOSYSTEM = Ecosystem.JAVASCRIPT;
98      /**
99       * The logger.
100      */
101     private static final Logger LOGGER = LoggerFactory.getLogger(RetireJsAnalyzer.class);
102     /**
103      * The name of the analyzer.
104      */
105     private static final String ANALYZER_NAME = "RetireJS Analyzer";
106     /**
107      * The phase that this analyzer is intended to run in.
108      */
109     private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.FINDING_ANALYSIS;
110     /**
111      * The set of file extensions supported by this analyzer.
112      */
113     private static final String[] EXTENSIONS = {"js"};
114     /**
115      * The file filter used to determine which files this analyzer supports.
116      */
117     private static final FileFilter FILTER = FileFilterBuilder.newInstance().addExtensions(EXTENSIONS).build();
118     /**
119      * An instance of the local VulnerabilitiesRepository
120      */
121     private VulnerabilitiesRepository jsRepository;
122     /**
123      * The list of filters used to exclude files by file content; the intent is
124      * that this could be used to filter out a companies custom files by filter
125      * on their own copyright statements.
126      */
127     private String[] filters = null;
128 
129     /**
130      * Returns the FileFilter.
131      *
132      * @return the FileFilter
133      */
134     @Override
135     protected FileFilter getFileFilter() {
136         return FILTER;
137     }
138 
139     /**
140      * Determines if the file can be analyzed by the analyzer.
141      *
142      * @param pathname the path to the file
143      * @return true if the file can be analyzed by the given analyzer; otherwise
144      * false
145      */
146     @Override
147     public boolean accept(File pathname) {
148         try {
149             final boolean accepted = super.accept(pathname);
150             if (accepted && !pathname.exists()) {
151                 //file may not yet have been extracted from an archive
152                 super.setFilesMatched(true);
153                 return true;
154             }
155             if (accepted && filters != null && FileContentSearch.contains(pathname, filters)) {
156                 return false;
157             }
158             return accepted;
159         } catch (IOException ex) {
160             LOGGER.warn("Error testing file {}", pathname, ex);
161         }
162         return false;
163     }
164 
165     /**
166      * Initializes the analyzer with the configured settings.
167      *
168      * @param settings the configured settings to use
169      */
170     @Override
171     public void initialize(Settings settings) {
172         super.initialize(settings);
173         if (this.isEnabled()) {
174             this.filters = settings.getArray(Settings.KEYS.ANALYZER_RETIREJS_FILTERS);
175         }
176     }
177 
178     /**
179      * {@inheritDoc}
180      *
181      * @param engine a reference to the dependency-check engine
182      * @throws InitializationException thrown if there is an exception during
183      * initialization
184      */
185     @Override
186     protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
187         // RetireJS outputs a bunch of repeated output like the following for
188         // vulnerable dependencies, with little context:
189         //
190         // INFO: Vulnerability found: jquery below 1.6.3
191         //
192         // This logging is suppressed because it isn't particularly useful, and
193         // it aligns with other analyzers that don't log such information.
194         Log.set(Log.LEVEL_WARN);
195 
196         File repoFile = null;
197         boolean repoEmpty = false;
198         try {
199             final String configuredUrl = getSettings().getString(Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_URL, RetireJSDataSource.DEFAULT_JS_URL);
200             final URL url = new URL(configuredUrl);
201             final File filepath = new File(url.getPath());
202             repoFile = new File(getSettings().getDataDirectory(), filepath.getName());
203             if (!repoFile.isFile() || repoFile.length() <= 1L) {
204                 LOGGER.warn("Retire JS repository is empty or missing - attempting to force the update");
205                 repoEmpty = true;
206                 getSettings().setBoolean(Settings.KEYS.ANALYZER_RETIREJS_FORCEUPDATE, true);
207             }
208         } catch (IOException ex) {
209             this.setEnabled(false);
210             throw new InitializationException("Failed to initialize the RetireJS", ex);
211         }
212 
213         final boolean autoupdate = getSettings().getBoolean(Settings.KEYS.AUTO_UPDATE, true);
214         final boolean forceupdate = getSettings().getBoolean(Settings.KEYS.ANALYZER_RETIREJS_FORCEUPDATE, false);
215         if ((!autoupdate && forceupdate) || (autoupdate && repoEmpty)) {
216             final RetireJSDataSource ds = new RetireJSDataSource();
217             try {
218                 ds.update(engine);
219             } catch (UpdateException ex) {
220                 throw new InitializationException("Unable to initialize the Retire JS repository", ex);
221             }
222         }
223 
224         //several users are reporting that the retire js repository is getting corrupted.
225         try (WriteLock ignored = new WriteLock(getSettings(), true, repoFile.getName() + ".lock")) {
226             final File temp = getSettings().getTempDirectory();
227             final File tempRepo = new File(temp, repoFile.getName());
228             LOGGER.debug("copying retireJs repo {} to {}", repoFile.toPath(), tempRepo.toPath());
229             Files.copy(repoFile.toPath(), tempRepo.toPath());
230             repoFile = tempRepo;
231         } catch (WriteLockException | IOException ex) {
232             this.setEnabled(false);
233             throw new InitializationException("Failed to copy the RetireJS repo", ex);
234         }
235         try (FileInputStream in = new FileInputStream(repoFile)) {
236             this.jsRepository = new VulnerabilitiesRepositoryLoader().loadFromInputStream(in);
237         } catch (JSONException ex) {
238             this.setEnabled(false);
239             throw new InitializationException("Failed to initialize the RetireJS repo: `" + repoFile
240                     + "` appears to be malformed. Please delete the file or run the dependency-check purge "
241                     + "command and re-try running dependency-check.", ex);
242         } catch (IOException ex) {
243             this.setEnabled(false);
244             throw new InitializationException("Failed to initialize the RetireJS repo", ex);
245         }
246     }
247 
248     /**
249      * Returns the name of the analyzer.
250      *
251      * @return the name of the analyzer.
252      */
253     @Override
254     public String getName() {
255         return ANALYZER_NAME;
256     }
257 
258     /**
259      * Returns the phase that the analyzer is intended to run in.
260      *
261      * @return the phase that the analyzer is intended to run in.
262      */
263     @Override
264     public AnalysisPhase getAnalysisPhase() {
265         return ANALYSIS_PHASE;
266     }
267 
268     /**
269      * Returns the key used in the properties file to reference the analyzer's
270      * enabled property.
271      *
272      * @return the analyzer's enabled property setting key
273      */
274     @Override
275     protected String getAnalyzerEnabledSettingKey() {
276         return Settings.KEYS.ANALYZER_RETIREJS_ENABLED;
277     }
278 
279     /**
280      * Analyzes the specified JavaScript file.
281      *
282      * @param dependency the dependency to analyze.
283      * @param engine     the engine that is scanning the dependencies
284      * @throws AnalysisException is thrown if there is an error reading the file
285      */
286     @Override
287     public void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
288         if (dependency.isVirtual()) {
289             return;
290         }
291         try (InputStream fis = new FileInputStream(dependency.getActualFile())) {
292             final List<RetireJsLibrary> vulnerableLibraries = new ScannerFacade(jsRepository)
293                     .scanScript(dependency.getActualFile().getAbsolutePath(), IOUtils.toByteArray(fis), 0)
294                     .stream().map(RetireJsLibrary::adapt).collect(Collectors.toList());
295 
296             if (vulnerableLibraries.isEmpty() && getSettings().getBoolean(Settings.KEYS.ANALYZER_RETIREJS_FILTER_NON_VULNERABLE, false)) {
297                 engine.removeDependency(dependency);
298                 return;
299             }
300 
301             for (RetireJsLibrary lib : vulnerableLibraries) {
302                 dependency.setName(lib.libraryName());
303                 dependency.setVersion(lib.version());
304                 dependency.addSoftwareIdentifier(lib.identifier());
305                 dependency.addEvidence(EvidenceType.VERSION, "RetireJS", "version", lib.version(), Confidence.HIGH);
306                 dependency.addEvidence(EvidenceType.PRODUCT, "RetireJS", "name", lib.libraryName(), Confidence.HIGH);
307                 dependency.addEvidence(EvidenceType.VENDOR, "RetireJS", "name", lib.libraryName(), Confidence.HIGH);
308                 dependency.addVulnerabilities(lib.vulnerabilities(cve -> engine.getDatabase().getVulnerability(cve)));
309             }
310         } catch (StackOverflowError ex) {
311             final String msg = String.format("An error occurred trying to analyze %s. "
312                             + "To resolve this error please try increasing the Java stack size to "
313                             + "8mb and re-run dependency-check:%n%n"
314                             + "(win) : set JAVA_OPTS=\"-Xss8m\"%n"
315                             + "(*nix): export JAVA_OPTS=\"-Xss8m\"%n%n",
316                     dependency.getDisplayFileName());
317             throw new AnalysisException(msg, ex);
318         } catch (IOException | DatabaseException e) {
319             throw new AnalysisException(e);
320         }
321     }
322 
323     @Override
324     protected void closeAnalyzer() throws Exception {
325         Log.set(Log.LEVEL_INFO);
326     }
327 }
328 
329 class RetireJsLibrary {
330     private static final Logger LOGGER = LoggerFactory.getLogger(RetireJsLibrary.class);
331 
332     private final JsLibraryResult result;
333 
334     private RetireJsLibrary(JsLibraryResult result) {
335         this.result = result;
336     }
337 
338     static RetireJsLibrary adapt(JsLibraryResult result) {
339         return new RetireJsLibrary(result);
340     }
341 
342     String libraryName() {
343         return result.getLibrary().getName();
344     }
345 
346     String version() {
347         return result.getDetectedVersion();
348     }
349 
350     Identifier identifier() {
351         try {
352             return new PurlIdentifier(
353                     PackageURLBuilder.aPackageURL()
354                             .withType("javascript")
355                             .withName(libraryName())
356                             .withVersion(version())
357                             .build(),
358                     Confidence.HIGHEST);
359         } catch (MalformedPackageURLException ex) {
360             LOGGER.debug("Unable to build package url for retireJS; using generic identifier", ex);
361             return new GenericIdentifier(String.format("javascript:%s@%s", libraryName(), version()), Confidence.HIGHEST);
362         }
363     }
364 
365     List<Vulnerability> vulnerabilities(KnownCveProvider knownCveProvider) {
366         List<Vulnerability> vulns = new RetireJsVulnerabilityIdentifiers(result.getVuln().getIdentifiers())
367                 .toVulnerabilities(knownCveProvider, result.getVuln().getSeverity());
368 
369         for (Vulnerability vuln : vulns) {
370             vuln.addReferences(infoReferences());
371         }
372         return vulns;
373     }
374 
375     private @NonNull Set<Reference> infoReferences() {
376         return result.getVuln().getInfo().stream()
377                 .map(info -> new Reference(info, "info", UrlValidator.getInstance().isValid(info) ? info : null))
378                 .collect(Collectors.toSet());
379     }
380 
381     @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
382     private class RetireJsVulnerabilityIdentifiers {
383 
384         public static final int MAX_NAME_LENGTH = 100;
385 
386         // Preferred global identifiers
387         private final List<String> cveIds;
388         private final Optional<String> ghsaId;
389 
390         // Fallback identifiers that can be used as vuln names
391         private final Map<String, String> secondaryNameIds;
392         private final Optional<String> summary;
393 
394         RetireJsVulnerabilityIdentifiers(Map<String, List<String>> rawIdentifiers) {
395             // CVE identifiers can be a list
396             this.cveIds = Optional.ofNullable(rawIdentifiers.get(CVE)).orElse(List.of()).stream()
397                     .map(StringUtils::trimToNull)
398                     .filter(StringUtils::isNotEmpty)
399                     .collect(Collectors.toList());
400 
401             // Other identifiers are only supported by the underlying schema as single items, so we get the first
402             this.ghsaId = singleItem(rawIdentifiers.get(GITHUB_SECURITY_ADVISORY));
403             this.secondaryNameIds = SECONDARY_NAME_TYPES.stream()
404                     .flatMap(type -> singleEntry(type, rawIdentifiers.get(type)).stream())
405                     .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new));
406 
407             // Summary is sometimes present; and can be a fallback vulnerability name as well as description
408             this.summary = singleItem(rawIdentifiers.get(SUMMARY));
409         }
410 
411         List<Vulnerability> toVulnerabilities(KnownCveProvider cveProvider, String severity) {
412             // Prefer CVEs; and see if we already know about them from the NVD.
413             // RetireJS can map multiple CVEs, so create 'N' vulns
414             List<Vulnerability> discoveredVulnerabilities = cveIds.stream()
415                     .map(cveId -> cveProvider.optional(cveId).orElseGet(() -> retireJsVulnFor(cveId)))
416                     .collect(Collectors.toList());
417 
418             // We try and index off CVEs that we can find existing from NVD; else create a single new one
419             // with the best canonical name we can determine from identifiers
420             if (discoveredVulnerabilities.isEmpty()) {
421                 discoveredVulnerabilities.add(retireJsVulnFor(vulnerabilityName()));
422             }
423 
424             // For vulnerabilities not referenced externally; populate description and references from identifiers
425             discoveredVulnerabilities.stream()
426                     .filter(vuln -> Vulnerability.Source.RETIREJS.equals(vuln.getSource()))
427                     .forEach(vuln -> {
428                         vuln.setUnscoredSeverity(severity);
429                         summary.ifPresent(vuln::setDescription);
430                         vuln.addReferences(references());
431                     });
432             return discoveredVulnerabilities;
433         }
434 
435         private Vulnerability retireJsVulnFor(String name) {
436             final Vulnerability vuln = new Vulnerability(name);
437             vuln.setSource(Vulnerability.Source.RETIREJS);
438             return vuln;
439         }
440 
441 
442         private @NonNull String vulnerabilityName() {
443             if (!cveIds.isEmpty()) {
444                 throw new IllegalStateException("vulnerability names for RetireJS vulnerabilities should be taken from the CVE ID");
445             }
446 
447             // Use the GHSA as a universal identifier if present; otherwise create a vuln name that is library
448             // contextual, as we don't know we have a globally unique ID.
449             return ghsaId
450                     .or(() -> secondaryNameIds.entrySet().stream().findFirst().map(e -> libraryContextualName(e.getKey(), e.getValue())))
451                     .or(() -> summary.filter(this::isSmallSingleLine))
452                     .orElseGet(() -> "Vulnerability in " + libraryName());
453         }
454 
455         private String libraryContextualName(String type, String id) {
456             return String.format("%s %s: %s", libraryName(), type, id);
457         }
458 
459         private boolean isSmallSingleLine(String value) {
460             return value.length() <= MAX_NAME_LENGTH && value.lines().limit(2).count() == 1;
461         }
462 
463         private Set<Reference> references() {
464             Set<Reference> references = new HashSet<>();
465             // RetireJS identifiers are never URLs
466             ghsaId.ifPresent(id -> references.add(new Reference(id, "ghsaId", null)));
467             secondaryNameIds.forEach((type, id) -> references.add(new Reference(id, type, null)));
468             return references;
469         }
470     }
471 
472     @FunctionalInterface
473     interface KnownCveProvider {
474         @Nullable Vulnerability lookup(String cve);
475 
476         default @NonNull Optional<Vulnerability> optional(String cve) {
477             return Optional.ofNullable(lookup(cve));
478         }
479     }
480 
481     /**
482      * Types of identifiers within the RetireJS repo. Note that there are some legacy/deprecated types which we do not
483      * attempt to handle (e.g osvdb, retid, tenable, gist, PR. blog)
484      * <br/>
485      * Resources:
486      *  - <a href="https://raw.githubusercontent.com/Retirejs/retire.js/master/repository/jsrepository.json">Latest raw data </a>
487      *  - <a href="https://github.com/RetireJS/retire.js/blob/700590ffc92f993dfe15af1b89e364b443bd9bfa/node/src/types.ts#L23-L37">TypeScript types for the identifiers</a>
488      *  - <a href="https://github.com/RetireJS/retire.js/blob/700590ffc92f993dfe15af1b89e364b443bd9bfa/node/src/repo.ts#L29-L57">Repo validation</a>
489      */
490     interface KnownIdentifierTypes {
491         String CVE = "CVE";
492         String GITHUB_SECURITY_ADVISORY = "githubID";
493         List<String> SECONDARY_NAME_TYPES = List.of("issue", "bug", "PR");
494         String SUMMARY = "summary";
495 
496         static @NonNull Optional<String> singleItem(@Nullable List<String> identifiers) {
497             return Optional.ofNullable(identifiers)
498                     .flatMap(s -> s.stream().map(StringUtils::trimToNull).filter(Objects::nonNull).findFirst());
499         }
500 
501         static @NonNull Optional<Map.Entry<String, String>> singleEntry(@NonNull String type, @Nullable List<String> identifiers) {
502             return singleItem(identifiers).map(id -> Map.entry(type, id));
503         }
504     }
505 }