Autopsy  4.12.0
Graphical digital forensics platform for The Sleuth Kit and other tools.
DocumentEmbeddedContentExtractor.java
Go to the documentation of this file.
1 /*
2  * Autopsy Forensic Browser
3  *
4  * Copyright 2015 Basis Technology Corp.
5  * Contact: carrier <at> sleuthkit <dot> org
6  *
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  * http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  */
19 package org.sleuthkit.autopsy.modules.embeddedfileextractor;
20 
21 import java.io.File;
22 import java.io.FileOutputStream;
23 import java.io.IOException;
24 import java.io.InputStream;
25 import java.nio.file.Path;
26 import java.nio.file.Paths;
27 import java.util.ArrayList;
28 import java.util.Collections;
29 import java.util.HashMap;
30 import java.util.List;
31 import java.util.Map;
32 import java.util.logging.Level;
33 import org.apache.commons.io.FilenameUtils;
34 import org.apache.commons.io.IOUtils;
35 import org.apache.poi.hwpf.usermodel.Picture;
36 import org.apache.poi.hslf.usermodel.HSLFPictureData;
37 import org.apache.poi.hslf.usermodel.HSLFSlideShow;
38 import org.apache.poi.hssf.usermodel.HSSFWorkbook;
39 import org.apache.poi.hwpf.HWPFDocument;
40 import org.apache.poi.hwpf.model.PicturesTable;
41 import org.apache.poi.sl.usermodel.PictureData.PictureType;
42 import org.apache.poi.ss.usermodel.Workbook;
43 import org.apache.tika.config.TikaConfig;
44 import org.apache.tika.detect.Detector;
45 import org.apache.tika.exception.TikaException;
46 import org.apache.tika.extractor.EmbeddedDocumentExtractor;
47 import org.apache.tika.extractor.ParsingEmbeddedDocumentExtractor;
48 import org.apache.tika.metadata.Metadata;
49 import org.apache.tika.mime.MediaType;
50 import org.apache.tika.mime.MimeTypeException;
51 import org.apache.tika.parser.AutoDetectParser;
52 import org.apache.tika.parser.ParseContext;
53 import org.apache.tika.parser.Parser;
54 import org.apache.tika.parser.microsoft.OfficeParserConfig;
55 import org.apache.tika.sax.BodyContentHandler;
56 import org.openide.util.NbBundle;
65 import org.sleuthkit.datamodel.AbstractFile;
66 import org.sleuthkit.datamodel.EncodedFileOutputStream;
67 import org.sleuthkit.datamodel.ReadContentInputStream;
68 import org.sleuthkit.datamodel.TskCoreException;
69 import org.sleuthkit.datamodel.TskData;
70 import org.xml.sax.ContentHandler;
71 import org.xml.sax.SAXException;
72 
77 class DocumentEmbeddedContentExtractor {
78 
79  private final FileManager fileManager;
80  private final IngestServices services;
81  private static final Logger LOGGER = Logger.getLogger(DocumentEmbeddedContentExtractor.class.getName());
82  private final IngestJobContext context;
83  private String parentFileName;
84  private final String UNKNOWN_IMAGE_NAME_PREFIX = "image_"; //NON-NLS
85  private final FileTypeDetector fileTypeDetector;
86 
87  private String moduleDirRelative;
88  private String moduleDirAbsolute;
89 
90  private AutoDetectParser parser = new AutoDetectParser();
91  private Detector detector = parser.getDetector();
92  private TikaConfig config = TikaConfig.getDefaultConfig();
93 
97  enum SupportedExtractionFormats {
98 
99  DOC("application/msword"), //NON-NLS
100  DOCX("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), //NON-NLS
101  PPT("application/vnd.ms-powerpoint"), //NON-NLS
102  PPTX("application/vnd.openxmlformats-officedocument.presentationml.presentation"), //NON-NLS
103  XLS("application/vnd.ms-excel"), //NON-NLS
104  XLSX("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), //NON-NLS
105  PDF("application/pdf"); //NON-NLS
106 
107  private final String mimeType;
108 
109  SupportedExtractionFormats(final String mimeType) {
110  this.mimeType = mimeType;
111  }
112 
113  @Override
114  public String toString() {
115  return this.mimeType;
116  }
117  }
118  private SupportedExtractionFormats abstractFileExtractionFormat;
119 
120  DocumentEmbeddedContentExtractor(IngestJobContext context, FileTypeDetector fileTypeDetector, String moduleDirRelative, String moduleDirAbsolute) throws NoCurrentCaseException {
121 
122  this.fileManager = Case.getCurrentCaseThrows().getServices().getFileManager();
123  this.services = IngestServices.getInstance();
124  this.context = context;
125  this.fileTypeDetector = fileTypeDetector;
126  this.moduleDirRelative = moduleDirRelative;
127  this.moduleDirAbsolute = moduleDirAbsolute;
128  }
129 
139  boolean isContentExtractionSupported(AbstractFile abstractFile) {
140  String abstractFileMimeType = fileTypeDetector.getMIMEType(abstractFile);
141  for (SupportedExtractionFormats s : SupportedExtractionFormats.values()) {
142  if (s.toString().equals(abstractFileMimeType)) {
143  abstractFileExtractionFormat = s;
144  return true;
145  }
146  }
147  return false;
148  }
149 
159  void extractEmbeddedContent(AbstractFile abstractFile) {
160  List<ExtractedFile> listOfExtractedImages = null;
161  List<AbstractFile> listOfExtractedImageAbstractFiles = null;
162  this.parentFileName = EmbeddedFileExtractorIngestModule.getUniqueName(abstractFile);
163 
164  // Skip files that already have been unpacked.
165  try {
166  if (abstractFile.hasChildren()) {
167  //check if local unpacked dir exists
168  if (new File(getOutputFolderPath(parentFileName)).exists()) {
169  LOGGER.log(Level.INFO, "File already has been processed as it has children and local unpacked file, skipping: {0}", abstractFile.getName()); //NON-NLS
170  return;
171  }
172  }
173  } catch (TskCoreException e) {
174  LOGGER.log(Level.SEVERE, String.format("Error checking if file already has been processed, skipping: %s", parentFileName), e); //NON-NLS
175  return;
176  }
177 
178  // Call the appropriate extraction method based on mime type
179  switch (abstractFileExtractionFormat) {
180  case DOCX:
181  case PPTX:
182  case XLSX:
183  listOfExtractedImages = extractEmbeddedContentFromOOXML(abstractFile);
184  break;
185  case DOC:
186  listOfExtractedImages = extractEmbeddedImagesFromDoc(abstractFile);
187  break;
188  case PPT:
189  listOfExtractedImages = extractEmbeddedImagesFromPpt(abstractFile);
190  break;
191  case XLS:
192  listOfExtractedImages = extractImagesFromXls(abstractFile);
193  break;
194  case PDF:
195  listOfExtractedImages = extractEmbeddedContentFromPDF(abstractFile);
196  break;
197  default:
198  break;
199  }
200 
201  if (listOfExtractedImages == null) {
202  return;
203  }
204  // the common task of adding abstractFile to derivedfiles is performed.
205  listOfExtractedImageAbstractFiles = new ArrayList<>();
206  for (ExtractedFile extractedImage : listOfExtractedImages) {
207  try {
208  listOfExtractedImageAbstractFiles.add(fileManager.addDerivedFile(extractedImage.getFileName(), extractedImage.getLocalPath(), extractedImage.getSize(),
209  extractedImage.getCtime(), extractedImage.getCrtime(), extractedImage.getAtime(), extractedImage.getAtime(),
210  true, abstractFile, null, EmbeddedFileExtractorModuleFactory.getModuleName(), null, null, TskData.EncodingType.XOR1));
211  } catch (TskCoreException ex) {
212  LOGGER.log(Level.SEVERE, NbBundle.getMessage(this.getClass(), "EmbeddedFileExtractorIngestModule.ImageExtractor.extractImage.addToDB.exception.msg"), ex); //NON-NLS
213  }
214  }
215  if (!listOfExtractedImages.isEmpty()) {
216  services.fireModuleContentEvent(new ModuleContentEvent(abstractFile));
217  context.addFilesToJob(listOfExtractedImageAbstractFiles);
218  }
219  }
220 
230  private List<ExtractedFile> extractEmbeddedContentFromOOXML(AbstractFile abstractFile) {
231  Metadata metadata = new Metadata();
232 
233  ParseContext parseContext = new ParseContext();
234  parseContext.set(Parser.class, parser);
235 
236  // Passing -1 to the BodyContentHandler constructor disables the Tika
237  // write limit (which defaults to 100,000 characters.
238  ContentHandler contentHandler = new BodyContentHandler(-1);
239 
240  // Use the more memory efficient Tika SAX parsers for DOCX and
241  // PPTX files (it already uses SAX for XLSX).
242  OfficeParserConfig officeParserConfig = new OfficeParserConfig();
243  officeParserConfig.setUseSAXPptxExtractor(true);
244  officeParserConfig.setUseSAXDocxExtractor(true);
245  parseContext.set(OfficeParserConfig.class, officeParserConfig);
246 
247  EmbeddedDocumentExtractor extractor = new EmbeddedContentExtractor(parseContext);
248  parseContext.set(EmbeddedDocumentExtractor.class, extractor);
249  ReadContentInputStream stream = new ReadContentInputStream(abstractFile);
250 
251  try {
252  parser.parse(stream, contentHandler, metadata, parseContext);
253  } catch (IOException | SAXException | TikaException ex) {
254  LOGGER.log(Level.WARNING, "Error while parsing file, skipping: " + abstractFile.getName(), ex); //NON-NLS
255  return null;
256  }
257 
258  return ((EmbeddedContentExtractor) extractor).getExtractedImages();
259  }
260 
269  private List<ExtractedFile> extractEmbeddedImagesFromDoc(AbstractFile af) {
270  List<Picture> listOfAllPictures;
271 
272  try {
273  HWPFDocument doc = new HWPFDocument(new ReadContentInputStream(af));
274  PicturesTable pictureTable = doc.getPicturesTable();
275  listOfAllPictures = pictureTable.getAllPictures();
276  } catch (Exception ex) {
277  // IOException:
278  // Thrown when the document has issues being read.
279 
280  // IllegalArgumentException:
281  // This will catch OldFileFormatException, which is thrown when the
282  // document's format is Word 95 or older. Alternatively, this is
283  // thrown when attempting to load an RTF file as a DOC file.
284  // However, our code verifies the file format before ever running it
285  // through the EmbeddedContentExtractor. This exception gets thrown in the
286  // "IN10-0137.E01" image regardless. The reason is unknown.
287  // IndexOutOfBoundsException:
288  // NullPointerException:
289  // These get thrown in certain images. The reason is unknown. It is
290  // likely due to problems with the file formats that POI is poorly
291  // handling.
292 
293  //Any runtime exception escaping
294  LOGGER.log(Level.WARNING, "Word document container could not be initialized. Reason: {0}", ex.getMessage()); //NON-NLS
295  return null;
296  }
297 
298  String outputFolderPath;
299  if (listOfAllPictures.isEmpty()) {
300  return null;
301  } else {
302  outputFolderPath = getOutputFolderPath(this.parentFileName);
303  }
304  if (outputFolderPath == null) {
305  return null;
306  }
307  List<ExtractedFile> listOfExtractedImages = new ArrayList<>();
308  byte[] data = null;
309  int pictureNumber = 0; //added to ensure uniqueness in cases where suggestFullFileName returns duplicates
310  for (Picture picture : listOfAllPictures) {
311  String fileName = UNKNOWN_IMAGE_NAME_PREFIX +pictureNumber +"."+ picture.suggestFileExtension();
312  try {
313  data = picture.getContent();
314  } catch (Exception ex) {
315  return null;
316  }
317  writeExtractedImage(Paths.get(outputFolderPath, fileName).toString(), data);
318  // TODO Extract more info from the Picture viz ctime, crtime, atime, mtime
319  listOfExtractedImages.add(new ExtractedFile(fileName, getFileRelativePath(fileName), picture.getSize()));
320  pictureNumber++;
321  }
322 
323  return listOfExtractedImages;
324  }
325 
334  private List<ExtractedFile> extractEmbeddedImagesFromPpt(AbstractFile af) {
335  List<HSLFPictureData> listOfAllPictures = null;
336 
337  try {
338  HSLFSlideShow ppt = new HSLFSlideShow(new ReadContentInputStream(af));
339  listOfAllPictures = ppt.getPictureData();
340  } catch (Exception ex) {
341  // IllegalArgumentException:
342  // This will catch OldFileFormatException, which is thrown when the
343  // document version is unsupported. The IllegalArgumentException may
344  // also get thrown for unknown reasons.
345 
346  // IOException:
347  // Thrown when the document has issues being read.
348  // IndexOutOfBoundsException:
349  // This gets thrown in certain images. The reason is unknown. It is
350  // likely due to problems with the file formats that POI is poorly
351  // handling.
352  LOGGER.log(Level.WARNING, "PPT container could not be initialized. Reason: {0}", ex.getMessage()); //NON-NLS
353  return null;
354  }
355 
356  // if no images are extracted from the PPT, return null, else initialize
357  // the output folder for image extraction.
358  String outputFolderPath;
359  if (listOfAllPictures.isEmpty()) {
360  return null;
361  } else {
362  outputFolderPath = getOutputFolderPath(this.parentFileName);
363  }
364  if (outputFolderPath == null) {
365  return null;
366  }
367 
368  // extract the content to the above initialized outputFolder.
369  // extraction path - outputFolder/image_number.ext
370  int i = 0;
371  List<ExtractedFile> listOfExtractedImages = new ArrayList<>();
372  byte[] data = null;
373  for (HSLFPictureData pictureData : listOfAllPictures) {
374 
375  // Get image extension, generate image name, write image to the module
376  // output folder, add it to the listOfExtractedImageAbstractFiles
377  PictureType type = pictureData.getType();
378  String ext;
379  switch (type) {
380  case JPEG:
381  ext = ".jpg"; //NON-NLS
382  break;
383  case PNG:
384  ext = ".png"; //NON-NLS
385  break;
386  case WMF:
387  ext = ".wmf"; //NON-NLS
388  break;
389  case EMF:
390  ext = ".emf"; //NON-NLS
391  break;
392  case PICT:
393  ext = ".pict"; //NON-NLS
394  break;
395  default:
396  continue;
397  }
398  String imageName = UNKNOWN_IMAGE_NAME_PREFIX + i + ext; //NON-NLS
399  try {
400  data = pictureData.getData();
401  } catch (Exception ex) {
402  return null;
403  }
404  writeExtractedImage(Paths.get(outputFolderPath, imageName).toString(), data);
405  listOfExtractedImages.add(new ExtractedFile(imageName, getFileRelativePath(imageName), pictureData.getData().length));
406  i++;
407  }
408  return listOfExtractedImages;
409  }
410 
419  private List<ExtractedFile> extractImagesFromXls(AbstractFile af) {
420  List<? extends org.apache.poi.ss.usermodel.PictureData> listOfAllPictures = null;
421 
422  try {
423  Workbook xls = new HSSFWorkbook(new ReadContentInputStream(af));
424  listOfAllPictures = xls.getAllPictures();
425  } catch (Exception ex) {
426  // IllegalArgumentException:
427  // This will catch OldFileFormatException, which is thrown when the
428  // document version is unsupported. The IllegalArgumentException may
429  // also get thrown for unknown reasons.
430 
431  // IOException:
432  // Thrown when the document has issues being read.
433  // LeftoverDataException:
434  // This is thrown for poorly formatted files that have more data
435  // than expected.
436  // RecordFormatException:
437  // This is thrown for poorly formatted files that have less data
438  // that expected.
439  // IllegalArgumentException:
440  // IndexOutOfBoundsException:
441  // These get thrown in certain images. The reason is unknown. It is
442  // likely due to problems with the file formats that POI is poorly
443  // handling.
444  LOGGER.log(Level.WARNING, "Excel (.xls) document container could not be initialized. Reason: {0}", ex.getMessage()); //NON-NLS
445  return null;
446  }
447 
448  // if no images are extracted from the PPT, return null, else initialize
449  // the output folder for image extraction.
450  String outputFolderPath;
451  if (listOfAllPictures.isEmpty()) {
452  return null;
453  } else {
454  outputFolderPath = getOutputFolderPath(this.parentFileName);
455  }
456  if (outputFolderPath == null) {
457  return null;
458  }
459 
460  int i = 0;
461  List<ExtractedFile> listOfExtractedImages = new ArrayList<>();
462  byte[] data = null;
463  for (org.apache.poi.ss.usermodel.PictureData pictureData : listOfAllPictures) {
464  String imageName = UNKNOWN_IMAGE_NAME_PREFIX + i + "." + pictureData.suggestFileExtension(); //NON-NLS
465  try {
466  data = pictureData.getData();
467  } catch (Exception ex) {
468  return null;
469  }
470  writeExtractedImage(Paths.get(outputFolderPath, imageName).toString(), data);
471  listOfExtractedImages.add(new ExtractedFile(imageName, getFileRelativePath(imageName), pictureData.getData().length));
472  i++;
473  }
474  return listOfExtractedImages;
475 
476  }
477 
484  private List<ExtractedFile> extractEmbeddedContentFromPDF(AbstractFile abstractFile) {
485  PDFAttachmentExtractor pdfExtractor = new PDFAttachmentExtractor(parser);
486  try {
487  Path outputDirectory = Paths.get(getOutputFolderPath(parentFileName));
488  //Get map of attachment name -> location disk.
489  Map<String, Path> extractedAttachments = pdfExtractor.extract(
490  new ReadContentInputStream(abstractFile), abstractFile.getId(),
491  outputDirectory);
492 
493  //Convert output to hook into the existing logic for creating derived files
494  List<ExtractedFile> extractedFiles = new ArrayList<>();
495  extractedAttachments.entrySet().forEach((pathEntry) -> {
496  String fileName = pathEntry.getKey();
497  Path writeLocation = pathEntry.getValue();
498  extractedFiles.add(new ExtractedFile(fileName,
499  getFileRelativePath(writeLocation.getFileName().toString()),
500  writeLocation.toFile().length()));
501  });
502 
503  return extractedFiles;
504  } catch (IOException | SAXException | TikaException ex) {
505  LOGGER.log(Level.WARNING, "Error attempting to extract attachments from PDFs", ex); //NON-NLS
506  }
507  return Collections.emptyList();
508  }
509 
517  private void writeExtractedImage(String outputPath, byte[] data) {
518  try (EncodedFileOutputStream fos = new EncodedFileOutputStream(new FileOutputStream(outputPath), TskData.EncodingType.XOR1)) {
519  fos.write(data);
520  } catch (IOException ex) {
521  LOGGER.log(Level.WARNING, "Could not write to the provided location: " + outputPath, ex); //NON-NLS
522  }
523  }
524 
533  private String getOutputFolderPath(String parentFileName) {
534  String outputFolderPath = moduleDirAbsolute + File.separator + parentFileName;
535  File outputFilePath = new File(outputFolderPath);
536  if (!outputFilePath.exists()) {
537  try {
538  outputFilePath.mkdirs();
539  } catch (SecurityException ex) {
540  LOGGER.log(Level.WARNING, NbBundle.getMessage(this.getClass(), "EmbeddedFileExtractorIngestModule.ImageExtractor.getOutputFolderPath.exception.msg", parentFileName), ex);
541  return null;
542  }
543  }
544  return outputFolderPath;
545  }
546 
556  private String getFileRelativePath(String fileName) {
557  return Paths.get(moduleDirRelative, this.parentFileName, fileName).toString();
558  }
559 
565  private static class ExtractedFile {
566  //String fileName, String localPath, long size, long ctime, long crtime,
567  //long atime, long mtime, boolean isFile, AbstractFile parentFile, String rederiveDetails, String toolName, String toolVersion, String otherDetails
568 
569  private final String fileName;
570  private final String localPath;
571  private final long size;
572  private final long ctime;
573  private final long crtime;
574  private final long atime;
575  private final long mtime;
576 
577  ExtractedFile(String fileName, String localPath, long size) {
578  this(fileName, localPath, size, 0, 0, 0, 0);
579  }
580 
581  ExtractedFile(String fileName, String localPath, long size, long ctime, long crtime, long atime, long mtime) {
582  this.fileName = fileName;
583  this.localPath = localPath;
584  this.size = size;
585  this.ctime = ctime;
586  this.crtime = crtime;
587  this.atime = atime;
588  this.mtime = mtime;
589  }
590 
591  public String getFileName() {
592  return fileName;
593  }
594 
595  public String getLocalPath() {
596  return localPath;
597  }
598 
599  public long getSize() {
600  return size;
601  }
602 
603  public long getCtime() {
604  return ctime;
605  }
606 
607  public long getCrtime() {
608  return crtime;
609  }
610 
611  public long getAtime() {
612  return atime;
613  }
614 
615  public long getMtime() {
616  return mtime;
617  }
618  }
619 
625  private class EmbeddedContentExtractor extends ParsingEmbeddedDocumentExtractor {
626 
627  private int fileCount = 0;
628  // Map of file name to ExtractedFile instance. This can revert to a
629  // plain old list after we upgrade to Tika 1.16 or above.
630  private final Map<String, ExtractedFile> nameToExtractedFileMap = new HashMap<>();
631 
632  public EmbeddedContentExtractor(ParseContext context) {
633  super(context);
634  }
635 
636  @Override
637  public boolean shouldParseEmbedded(Metadata metadata) {
638  return true;
639  }
640 
641  @Override
642  public void parseEmbedded(InputStream stream, ContentHandler handler,
643  Metadata metadata, boolean outputHtml) throws SAXException, IOException {
644 
645  // Get the mime type for the embedded document
646  MediaType contentType = detector.detect(stream, metadata);
647 
648  if (!contentType.getType().equalsIgnoreCase("image") //NON-NLS
649  && !contentType.getType().equalsIgnoreCase("video") //NON-NLS
650  && !contentType.getType().equalsIgnoreCase("application") //NON-NLS
651  && !contentType.getType().equalsIgnoreCase("audio")) { //NON-NLS
652  return;
653  }
654 
655  // try to get the name of the embedded file from the metadata
656  String name = metadata.get(Metadata.RESOURCE_NAME_KEY);
657 
658  // TODO: This can be removed after we upgrade to Tika 1.16 or
659  // above. The 1.16 version of Tika keeps track of files that
660  // have been seen before.
661  if (nameToExtractedFileMap.containsKey(name)) {
662  return;
663  }
664 
665  if (name == null) {
666  name = UNKNOWN_IMAGE_NAME_PREFIX + fileCount++;
667  } else {
668  //make sure to select only the file name (not any directory paths
669  //that might be included in the name) and make sure
670  //to normalize the name
671  name = FilenameUtils.normalize(FilenameUtils.getName(name));
672  }
673 
674  // Get the suggested extension based on mime type.
675  if (name.indexOf('.') == -1) {
676  try {
677  name += config.getMimeRepository().forName(contentType.toString()).getExtension();
678  } catch (MimeTypeException ex) {
679  LOGGER.log(Level.WARNING, "Failed to get suggested extension for the following type: " + contentType.toString(), ex); //NON-NLS
680  }
681  }
682 
683  File extractedFile = new File(Paths.get(getOutputFolderPath(parentFileName), name).toString());
684  byte[] fileData = IOUtils.toByteArray(stream);
685  writeExtractedImage(extractedFile.getAbsolutePath(), fileData);
686  nameToExtractedFileMap.put(name, new ExtractedFile(name, getFileRelativePath(name), fileData.length));
687  }
688 
694  public List<ExtractedFile> getExtractedImages() {
695  return new ArrayList<>(nameToExtractedFileMap.values());
696  }
697  }
698 }
void parseEmbedded(InputStream stream, ContentHandler handler, Metadata metadata, boolean outputHtml)
synchronized DerivedFile addDerivedFile(String fileName, String localPath, long size, long ctime, long crtime, long atime, long mtime, boolean isFile, Content parentObj, String rederiveDetails, String toolName, String toolVersion, String otherDetails, TskData.EncodingType encodingType)
void addFilesToJob(List< AbstractFile > files)
void fireModuleContentEvent(ModuleContentEvent moduleContentEvent)
synchronized static Logger getLogger(String name)
Definition: Logger.java:124
static synchronized IngestServices getInstance()

Copyright © 2012-2018 Basis Technology. Generated on: Wed Sep 18 2019
This work is licensed under a Creative Commons Attribution-Share Alike 3.0 United States License.