瀏覽代碼

Refactor Reports (#6)

- Add ability to use different data output formats using `-f <type>` or `--format <type>` and types: `json`, `raw-json`, `csv`, `raw-csv`, `table`
- Add ability to write reports to disk using `-o <path>` or `--output <path>` 
- Add ability to run multiple reports at a time, by specifying a list: `-r wifi,calls` or by using the `all` report type.
- Add ability to join JSON reports into a unified report using `--join-reports`
Rich Infante 7 年之前
父節點
當前提交
2294ec5c54

+ 28 - 4
Readme.md

@@ -47,7 +47,7 @@ UDID="0c1bc52c50016933679b0980ccff3680e5831162"
     - `conversations` - List all SMS and iMessage conversations
     - `list` - List of all backups. alias for -l
     - `manifest` - List all the files contained in the backup (iOS 10+)
-    - `messages` - List all SMS and iMessage messages in a conversation
+    - `messages` - List all SMS and iMessage messages in a conversation. This requires using the `--id` flag to specify a conversation to inspect.
     - `notes` - List all iOS notes
     - `oldnotes` - List all iOS notes (from older unused database)
     - `photolocations` - List all geolocation information for iOS photos (iOS 10+)
@@ -96,18 +96,42 @@ ibackuptool -b $UDID --report manifest --extract BACKUP --filter all
 ibackuptool -b $UDID --report manifest --extract BACKUP --filter CameraRollDomain
 ```
 
+### Reporting formats
+iBackupTool now supports multiple kinds of data export:
+- `table` - Selected data columns in an ascii table
+- `json` - Selected data columns for display (same data as `table`)
+- `csv` - CSV file containing selected columns (same data as `table`)
+
+Additionally, there are more comprehensive export functions that will export ALL the data collected, and keep original formatting and columns:
+- `raw-csv` - Full-data CSV export from each of the tables.
+- `raw`, `raw-json` - Full-data JSON export from each of the tables. This output can be quite large.
+
+### Multiple-Reporting 
+You can also provide a comma separated list of reports to generate. Additionally, there is a special `all` report type which will run all available reports. This is best paired with the `-o` option for saving to disk and the `-f` option for selecting a format such as CSV, or JSON.
+
+```bash
+ibackuptool -b $UDID --report wifi,calls,voicemail
+```
+
+the `-o` option specifies a folder to export reports to:
+```bash
+ibackuptool -b $UDID --report wifi,calls,voicemail -o exported
+```
+
+#### Joined Reports
+Additionally, for the `json` and `raw-json` types, there's a `--join-reports` flag which will merge all of the data into a single JSON file, where the top level object has a key for each report type that is selected.
+
+
 ### Messages Access
 
 ```bash
 # List of all conversations, indexed by ID.
 # Each row starts with an ID number, which is needed for the next step.
-ibackuptool -b $UDID --conversations
 ibackuptool -b $UDID --report conversations
 
 # Now, Fetch the messages with the following command
 # Replace $CONVERSATION_ID with a row ID from `ibackuptool -b $UDID --conversations`
-ibackuptool -b $UDID --messages $CONVERSATION_ID
-ibackuptool -b $UDID --report messages --messages $CONVERSATION_ID
+ibackuptool -b $UDID --report messages --id $CONVERSATION_ID
 ```
 
 ## Need More Data?

File diff suppressed because it is too large
+ 2360 - 38
package-lock.json


+ 5 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "ibackuptool",
-  "version": "2.0.7",
+  "version": "3.0.0",
   "description": "Read Messages and info from iOS Backups",
   "main": "tools/index.js",
   "scripts": {
@@ -19,9 +19,13 @@
     "chalk": "^1.1.3",
     "commander": "^2.12.2",
     "fs-extra": "^4.0.3",
+    "json2csv": "^3.11.5",
     "plist": "^2.0.1",
     "sqlite3": "^3.1.8",
     "strip-ansi": "^4.0.0",
     "zpad": "^0.5.0"
+  },
+  "devDependencies": {
+    "tap": "^11.1.0"
   }
 }

+ 13 - 0
tests/test_version.js

@@ -0,0 +1,13 @@
+const tap = require('tap')
+const version = require('../tools/util/version_compare')
+
+tap.test('version', function (childTest) {
+    childTest.equal(version.versionCheck('9.1', '=9.1'), true)
+    childTest.equal(version.versionCheck('9.1', '<=9.1'), true)
+    childTest.equal(version.versionCheck('9.1', '>=9.1'), true)
+    childTest.equal(version.versionCheck('10.0', '<=9.1'), false)
+    childTest.equal(version.versionCheck('10.0', '<9.1'), false)
+    childTest.equal(version.versionCheck('10.0', '>9.1'), true)
+    childTest.equal(version.versionCheck('10.0', '>=9.1'), true)
+    childTest.end()
+})

+ 18 - 0
tools/formatters/_default.js

@@ -0,0 +1,18 @@
+const fs = require('fs-extra')
+const path = require('path')
+
+module.exports.finalReport = async function(reports, program) {
+    if (program.reportOutput === undefined) {
+      return
+    }
+    
+    // Ensure the output directory exists.
+    fs.ensureDirSync(program.reportOutput)
+
+    // Write each report to the disk
+    for(var report of reports) {
+      var outPath = path.join(program.reportOutput, report.name + '.json')
+      console.log('saving', outPath)
+      fs.writeFileSync(outPath, JSON.stringify(report.contents), 'utf8')
+    }
+  }

+ 50 - 0
tools/formatters/csv.js

@@ -0,0 +1,50 @@
+const json2csv = require('json2csv')
+
+
+module.exports.format = function (data, options) {
+  var data = data.map(el => {
+    var row = {}
+    
+    // Iterate over the columns and add each item to the new row.
+    for (var key in options.columns) {
+      row[key] = options.columns[key](el)
+    }
+    
+    return row
+  })
+
+  const csv = json2csv({ data })
+  
+  
+  if(options.program) {
+    // If reporting output is defined, ignore console log here.
+    if (options.program.reportOutput === undefined) {
+      console.log(csv)
+    }
+  } else {
+    console.log(csv)
+  }
+  
+  
+  return csv
+}
+
+const fs = require('fs-extra')
+const path = require('path')
+
+
+module.exports.finalReport = async function(reports, program) {
+  if (program.reportOutput === undefined) {
+    return
+  }
+  
+  // Ensure the output directory exists.
+  fs.ensureDirSync(program.reportOutput)
+
+  // Write each report to the disk
+  for(var report of reports) {
+    var outPath = path.join(program.reportOutput, report.name + '.csv')
+    console.log('saving', outPath)
+    fs.writeFileSync(outPath, report.contents, 'utf8')
+  }
+}

+ 65 - 0
tools/formatters/json.js

@@ -0,0 +1,65 @@
+module.exports.format = function (data, options) {
+  var data = data.map(el => {
+    var row = {}
+    
+    // Iterate over the columns and add each item to the new row.
+    for (var key in options.columns) {
+      row[key] = options.columns[key](el)
+    }
+    
+    return row
+  })
+  
+  // Strigify the output, using 2 space indent.
+  var output = JSON.stringify(data, null, 2)
+  
+  if(options.program) {
+    // If reporting output is defined, ignore console log here.
+    if (options.program.reportOutput === undefined) {
+      console.log(output)
+    } else {
+      return data
+    }
+  } else {
+    console.log(output)
+  }
+  
+  return data
+}
+
+const fs = require('fs-extra')
+const path = require('path')
+
+module.exports.finalReport = async function(reports, program) {
+  if (program.reportOutput === undefined) {
+    return
+  }
+
+  if (program.joinReports) {
+    var out = {}
+
+    for(var report of reports) {
+      console.log('saving report', report.name)
+      out[report.name] = report.contents
+    }
+
+    if (program.reportOutput == '-') {
+      console.log(JSON.stringify(out, null, 2))
+    } else {
+      // fs.ensureDirSync(path.dirname(program.reportOutput))
+      //fs.copySync(sourceFile, outDir)
+      var outPath = program.reportOutput + '.json'
+      console.log('writing joined to', outPath)
+      fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8')
+    }
+  } else {
+    console.log(program.reportOutput)
+    fs.ensureDirSync(program.reportOutput)
+
+    for(var report of reports) {
+      var outPath = path.join(program.reportOutput, report.name + '.json')
+      console.log('saving', outPath)
+      fs.writeFileSync(outPath, JSON.stringify(report.contents, null, 2), 'utf8')
+    }
+  }
+}

+ 39 - 0
tools/formatters/raw-csv.js

@@ -0,0 +1,39 @@
+const json2csv = require('json2csv')
+
+
+module.exports.format = function (data, options) {
+  const csv = json2csv({ data })
+  
+  
+  if(options.program) {
+    // If reporting output is defined, ignore console log here.
+    if (options.program.reportOutput === undefined) {
+      console.log(csv)
+    }
+  } else {
+    console.log(csv)
+  }
+  
+  
+  return csv
+}
+
+const fs = require('fs-extra')
+const path = require('path')
+
+
+module.exports.finalReport = async function(reports, program) {
+  if (program.reportOutput === undefined) {
+    return
+  }
+  
+  // Ensure the output directory exists.
+  fs.ensureDirSync(program.reportOutput)
+
+  // Write each report to the disk
+  for(var report of reports) {
+    var outPath = path.join(program.reportOutput, report.name + '.csv')
+    console.log('saving', outPath)
+    fs.writeFileSync(outPath, report.contents, 'utf8')
+  }
+}

+ 55 - 0
tools/formatters/raw-json.js

@@ -0,0 +1,55 @@
+module.exports.format = function (data, options) {
+  var output = JSON.stringify(data)
+
+  if(options.program) {
+    // If reporting output is defined, ignore console log here.
+    if (options.program.reportOutput === undefined) {
+      console.log(output)
+    } else {
+        return data
+    }
+  } else {
+    console.log(output)
+  }
+
+  return data
+}
+
+const fs = require('fs-extra')
+const path = require('path')
+
+module.exports.finalReport = async function(reports, program) {
+  if (program.reportOutput === undefined) {
+    return
+  }
+
+  if (program.joinReports) {
+    var out = {}
+
+    for(var report of reports) {
+      console.log('saving report', report.name)
+      out[report.name] = report.contents
+    }
+
+    if (program.reportOutput == '-') {
+      console.log(JSON.stringify(out, null, 2))
+    } else {
+      // fs.ensureDirSync(path.dirname(program.reportOutput))
+      //fs.copySync(sourceFile, outDir)
+      var outPath = program.reportOutput + '.json'
+      console.log('writing joined to', outPath)
+      fs.writeFileSync(outPath, JSON.stringify(out), 'utf8')
+    }
+  } else {
+    // Ensure the output directory exists.
+    fs.ensureDirSync(program.reportOutput)
+
+    // Write each report to the disk
+    for(var report of reports) {
+      var outPath = path.join(program.reportOutput, report.name + '.json')
+      console.log('saving', outPath)
+      fs.writeFileSync(outPath, JSON.stringify(report.contents), 'utf8')
+    }
+  }
+}
+

+ 100 - 0
tools/formatters/table.js

@@ -0,0 +1,100 @@
+const stripAnsi = require('strip-ansi')
+
+function normalizeCols (rows, max) {
+  function padEnd (string, maxLength, fillString) {
+    while ((stripAnsi(string) + '').length < maxLength) {
+      string = string + fillString
+    }
+
+    return string
+  }
+
+  var widths = []
+  max = max || rows[0].length
+
+  for (var i = 0; i < rows.length; i++) {
+    for (var j = 0; j < rows[i].length && j < max; j++) {
+      if (!widths[j] || widths[j] < (stripAnsi(rows[i][j]) + '').length) {
+        widths[j] = (stripAnsi(rows[i][j] + '') + '').length
+      }
+    }
+  }
+
+  for (var i = 0; i < rows.length; i++) {
+    for (var j = 0; j < rows[i].length && j < max; j++) {
+      if (rows[i][j] == '-') {
+        rows[i][j] = padEnd(rows[i][j], widths[j], '-')
+      } else {
+        rows[i][j] = padEnd(rows[i][j], widths[j], ' ')
+      }
+    }
+  }
+
+  return rows
+}
+
+function keyValueArray(columns, keys, obj) {
+  return keys.map(el => columns[el](obj))
+}
+
+module.exports.format = function (data, options) {
+    // Keys for each column
+    var keys = []
+
+    // Separators for each column
+    var separators = []
+    
+    // Add to collection of keys
+    for(var key in options.columns) {
+      keys.push(key)
+      separators.push('-')
+    }
+
+    // Create the rows
+    var items = [
+        keys,
+        separators,
+        ...data.map(data => keyValueArray(options.columns, keys, data))
+    ]
+
+    // Normalize column widths.
+    items = normalizeCols(items).map(el => {
+      return el.join(' | ').replace(/\n/g, '')
+    }).join('\n')
+
+    
+
+    if(options.program) {
+
+      // Disable color output
+      if (!options.program.color) { items = stripAnsi(items) }
+
+      // If reporting output is defined, ignore console log here.
+      if (options.program.reportOutput === undefined) {
+        console.log(items)
+      }
+    } else {
+      console.log(items)
+    }
+    
+    return items
+}
+
+const fs = require('fs-extra')
+const path = require('path')
+
+module.exports.finalReport = async function(reports, program) {
+    if (program.reportOutput === undefined) {
+      return
+    }
+    
+    // Ensure the output directory exists.
+    fs.ensureDirSync(program.reportOutput)
+
+    // Write each report to the disk
+    for(var report of reports) {
+      var outPath = path.join(program.reportOutput, report.name + '.txt')
+      console.log('saving', outPath)
+      fs.writeFileSync(outPath, report.contents, 'utf8')
+    }
+  }

+ 223 - 40
tools/index.js

@@ -3,6 +3,8 @@
 const program = require('commander')
 const path = require('path')
 const chalk = require('chalk')
+const version = require('./util/version_compare')
+const iPhoneBackup = require('./util/iphone_backup.js').iPhoneBackup
 var base = path.join(process.env.HOME, '/Library/Application Support/MobileSync/Backup/')
 
 var reportTypes = {
@@ -21,73 +23,254 @@ var reportTypes = {
   'wifi': require('./reports/wifi')
 }
 
+var formatters = {
+  'json': require('./formatters/json'),
+  'table': require('./formatters/table'),
+  'raw':  require('./formatters/raw-json'),
+  'raw-json': require('./formatters/raw-json'),
+  'csv': require('./formatters/csv'),
+  'raw-csv': require('./formatters/raw-csv'),
+}
+
 program
-    .version('2.0.6')
-    .option('-l, --list', 'List Backups')
-    .option(`-b, --backup <backup>`, 'Backup ID')
-    .option('-r, --report <report_type>', 'Select a report type. see below for a full list.')
-    .option('-c, --conversations', 'List Conversations')
-    .option('-m, --messages <conversation_id>', 'List messages')
-    .option(`-e, --extract <dir>`, 'Extract data for commands. supported by: voicemail-files')
-    .option(`-f, --filter <filter>`, 'Filter output for individual reports. See the README for usage.')
-    .option(`-d, --dir <directory>`, `Backup Directory (default: ${base})`)
-    .option(`-v, --verbose`, 'Verbose debugging output')
-    .option(`-x, --no-color`, 'Disable colorized output')
-    .option('-z, --dump', 'Dump a ton of raw JSON formatted data instead of formatted output')
+.version('3.0.0')
+.option('-l, --list', 'List Backups')
+.option(`-b, --backup <backup>`, 'Backup ID')
+.option(`-d, --dir <directory>`, `Backup Directory (default: ${base})`)
+.option('-r, --report <report_type>', 'Select a report type. see below for a full list.')
+.option('-i, --id <id>', 'Specify an ID for filtering certain reports')
+.option('-f, --formatter <type>', 'Specify output format. default: table')
+.option(`-e, --extract <dir>`, 'Extract data for commands. supported by: voicemail-files, manifest')
+.option('-o, --report-output <path>', 'Specify an output directory for files to be written to.')
+.option(`-v, --verbose`, 'Verbose debugging output')
+.option(`    --filter <filter>`, 'Filter output for individual reports. See the README for usage.')
+.option('    --join-reports', 'Join JSON reports together. (available for -f json or -f raw only!)')
+.option(`    --no-color`, 'Disable colorized output')
+.option(`    --dump`, 'alias for "--formatter raw"')
 
 program.on('--help', function () {
   console.log('')
   console.log('Supported Report Types:')
+
+  // Generate a list of report types.
   for (var i in reportTypes) {
+    var r = reportTypes[i]
     if (program.isTTY) {
-      console.log('  ', reportTypes[i].name, '-', reportTypes[i].description)
+      console.log('  ', r.name, (r.supportedVersions ? '(iOS ' + r.supportedVersions + ')' : ' ') + '-', r.description)
     } else {
-      console.log('  ', chalk.green(reportTypes[i].name), '-', reportTypes[i].description)
+      console.log('  ', chalk.green(r.name), (r.supportedVersions ? chalk.gray('(iOS ' + r.supportedVersions + ') ') : '') + '-', r.description)
     }
   }
   console.log('')
   console.log("If you're interested to know how this works, check out my post:")
   console.log('https://www.richinfante.com/2017/3/16/reverse-engineering-the-ios-backup')
   console.log('')
+  console.log('Issue tracker:')
+  console.log('https://github.com/richinfante/iphonebackuptools/issues')
+  console.log('')
 })
 
+// Parse argv.
 program.parse(process.argv)
 
+// Global verbose output flag.
+// This is bad
+global.verbose = program.verbose
+
+// Save the formatter
+program.formatter = formatters[program.formatter] || formatters.table
+
+// Legacy support for `--dump` flag.
+if (program.dump) {
+  program.formatter = formatters.raw
+}
+
+// Disable color for non-ttys.
 if (!process.stdout.isTTY) { program.color = false }
 
+// Find the base
 base = program.dir || base
 
 if (program.verbose) console.log('Using source:', base)
 
-if (program.list) {
-    // Shortcut for list report
-  reportTypes.list.func(program, base)
-} else if (program.conversations) {
-    // Legacy shortcut for conversations report
-  reportTypes.conversations.func(program, base)
-} else if (program.messages) {
-    // Legacy shortcut for messages report
-  reportTypes.messages.func(program, base)
-} else if (program.report) {
-    // If the report is valid
-  if (reportTypes[program.report]) {
-    var report = reportTypes[program.report]
-
-        // Try to use it
-    if (report.func) {
-      try {
-        report.func(program, base)
-      } catch (e) {
-        console.log('[!] Encountered an error', e)
+// Run the main function
+main()
+
+async function main() {
+  if (program.list) {
+    // Run the list report standalone
+    let result = await new Promise((resolve, reject) => {
+      reportTypes.list.func(program, base, resolve, reject)
+    })
+  } else if (program.report) {
+    var reportContents = [] 
+
+    // Turn the report argument into an array of report type names
+    var selectedTypes = program.report
+      .split(',')
+      .map(el => el.trim())
+      .filter(el => el != '')
+
+    // Add all types if type is 'all'
+    if (program.report == 'all') {
+      selectedTypes = []
+
+      for (var key in reportTypes) {
+        if (reportTypes[key].requiresInteractivity === true) {
+          continue
+        }
+
+        selectedTypes.push(key)  
+      }
+    }
+    
+    for(var reportName of selectedTypes) {
+      // If the report is valid
+      if (reportTypes[reportName]) {
+        
+        var report = reportTypes[reportName]
+
+        if(selectedTypes.length > 1 && !report.usesPromises) {
+          console.log('Warning: report that does not utilize promises in multi-request.')
+          console.log('Warning: this may not work.')
+        }
+        
+        // Check if there's a backup specified and one is required.
+        if (report.requiresBackup) {
+          if (!program.backup) {
+            console.log('use -b or --backup <id> to specify backup.')
+            process.exit(1)
+          }
+        }
+        
+        if (report.func) {
+          var report = await runSingleReport(report, program)
+
+          reportContents.push({ 
+            name: reportName, 
+            contents: report
+          })
+        } else if (report.functions) {
+          var report = await runSwitchedReport(report, program)
+
+          reportContents.push({ 
+            name: reportName, 
+            contents: report
+          })
+        }
+      } else {
+        console.log('')
+        console.log('  [!] Unknown Option type:', reportName)
+        console.log('  [!] It\'s possible this tool is out-of date.')
+        console.log('  https://github.com/richinfante/iphonebackuptools/issues')
+        console.log('')
+        program.outputHelp()
       }
     }
+
+    program.formatter.finalReport(reportContents, program)
   } else {
-    console.log('')
-    console.log('  [!] Unknown Option type:', program.report)
-    console.log('  [!] It\'s possible this tool is out-of date.')
-    console.log('')
     program.outputHelp()
   }
-} else {
-  program.outputHelp()
 }
+
+async function runSwitchedReport(report, program) {
+  try {
+    // New type of reports
+    var backup = iPhoneBackup.fromID(program.backup, base)
+          
+    var flag = false
+    var value
+    // Check for a compatible reporting tool.
+    for (var key in report.functions) {
+      if (version.versionCheck(backup.iOSVersion, key)) {
+        if(!report.usesPromises) {
+          if(program.verbose) console.log('using synchronous call.')
+            
+          value = report.functions[key](program, backup)
+        } else {
+          // Create a promise to resolve this function
+          async function createPromise() {
+            if(program.verbose) console.log('resolving using promises.')
+            
+            return new Promise((resolve, reject) => {
+              report.functions[key](program, backup, resolve, reject)
+            })
+          }
+
+          // Use promises to resolve synchronously
+          value = await createPromise()
+        }
+        flag = true
+        break
+      }
+    }
+    
+    if (!flag) {
+      console.log('[!] The report generator "', program.report,'" does not support iOS', backup.iOSVersion)
+      console.log('')
+      console.log('    If you think it should, file an issue here:')
+      console.log('    https://github.com/richinfante/iphonebackuptools/issues')
+      console.log('')
+      process.exit(1)
+    }
+
+    return value
+  } catch (e) {
+    console.log('[!] Encountered an error', e)
+  }
+}
+
+async function runSingleReport(report, program) {
+  async function runReport(backup, base) {
+    if(!report.usesPromises) {
+      if(program.verbose) console.log('using synchronous call.')
+
+      // Old-style non-promise based report.
+      if (report.requiresBackup) {
+        return report.func(program, backup)
+      } else {
+        return report.func(program, base)
+      }
+    } else {
+      // Create a promise to resolve this function
+      async  function createPromise() {
+        if(program.verbose) console.log('resolving using promises.')
+        
+        return new Promise((resolve, reject) => {
+          if (report.requiresBackup) {
+            report.func(program, backup, resolve, reject)
+          } else {
+            report.func(program, base, resolve, reject)
+          }
+        })
+      }
+
+      // Use promises to resolve synchronously
+      return await createPromise()
+    }
+  }
+
+
+  try {
+    // New type of reports
+    var backup = iPhoneBackup.fromID(program.backup, base)
+    
+    if (report.supportedVersions !== undefined) {
+      if (version.versionCheck(backup.iOSVersion, report.supportedVersions)) {
+        return await runReport(backup, base)
+      } else {
+        console.log('[!] The report generator "' + program.report + '" does not support iOS', backup.iOSVersion)
+        console.log('')
+        console.log('    If you think it should, file an issue here:')
+        console.log('    https://github.com/richinfante/iphonebackuptools/issues')
+        console.log('')
+        process.exit(1)
+      }
+    } else {
+      return await runReport(backup, base)
+    }
+  } catch (e) {
+    console.log('[!] Encountered an error', e)
+  }
+}

+ 45 - 0
tools/reports/_example.js

@@ -0,0 +1,45 @@
+// Name of the module.
+module.exports.name = 'example module'
+
+// Description of the module
+module.exports.description = 'An example module to show how it works'
+
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
+
+// Should this report be skipped in automated reports?
+// This is used when the 'all' report type is specified, and all possible reports are generated.
+// with this set to true, the report WILL NOT run when report type = 'all'
+module.exports.requiresInteractivity = true
+
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
+
+// Specify this only works for iOS 10+
+module.exports.supportedVersions = '>=10.0'
+
+// Reporting function (for usesPromises = false)
+module.exports.func = function (program, backup) {
+  // This function will be called with the `commander` program, and the iPhoneBackup instance as arguments
+  // This is deprecated.
+}
+
+// Reporting function (for usesPromises = true)
+module.exports.func = function (program, backup, resolve, reject) {
+    // This function will be called with the `commander` program, and the iPhoneBackup instance as arguments
+    // It MUST resolve() the final result, or reject() if there's an error
+}
+
+// --- OR ---
+
+// You can also provide an array of functions instead of using `module.exports.func`.
+// These functions *should* be independent ranges to ensure reliable execution
+module.exports.functions = {
+    '>=10.0': function(program,backup) {
+        // This function would be called for iOS 10+
+    }, 
+    '>=9.0,<10.0': function(program,backup) {
+        // This function would be called for all iOS 9.
+    }
+}

+ 20 - 17
tools/reports/apps.js

@@ -3,29 +3,32 @@ const iPhoneBackup = require('../util/iphone_backup.js').iPhoneBackup
 module.exports.name = 'apps'
 module.exports.description = 'List all installed applications and container IDs.'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
 
-        // Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
 
-  if (!backup.manifest) return {}
+module.exports.func = function (program, backup, resolve, reject) {
 
-        // Possibly dump output
-  if (program.dump) {
-    console.log(JSON.stringify(backup.manifest, null, 4))
-    return
-  }
+  if (!backup.manifest) return reject(new Error('Manifest does not exist in this version'))
 
-        // Enumerate the apps in the backup
+  // Enumerate the apps in the backup
   var apps = []
   for (var key in backup.manifest.Applications) {
-    apps.push(key)
+    var app = backup.manifest.Applications[key]
+
+    apps.push({ bundleID: app.CFBundleIdentifier, path:  app.Path})
   }
 
-  console.log(`Apps installed inside backup: ${backup.id}`)
-  console.log(apps.map(el => '- ' + el).join('\n'))
+  var result = program.formatter.format(apps, {
+    program: program,
+    columns: {
+      'Bundle ID': el => el.bundleID,
+      'Bundle Path': el => el.path
+    }
+  })
+
+  resolve(result)
 }

+ 36 - 38
tools/reports/calls.js

@@ -1,46 +1,44 @@
-const stripAnsi = require('strip-ansi')
-const iPhoneBackup = require('../util/iphone_backup.js').iPhoneBackup
-const normalizeCols = require('../util/normalize.js')
-
 module.exports.name = 'calls'
 module.exports.description = 'List all call records contained in the backup.'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
+
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
+
+// Specify this only works for iOS 10+
+module.exports.supportedVersions = '>=10.0'
+
+module.exports.func = function (program, backup, resolve, reject) {
 
-// Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
   backup.getCallsList()
-    .then((items) => {
-      if (program.dump) {
-        console.log(JSON.stringify(items, null, 4))
-        return
-      }
+  .then((items) => {
 
-      items = items.map(el => [
-        el.Z_PK + '',
-        el.XFORMATTEDDATESTRING,
-        el.ZANSWERED + '',
-        el.ZORIGINATED + '',
-        el.ZCALLTYPE + '',
-        el.ZDURATION + '',
-        el.ZLOCATION + '',
-        el.ZISO_COUNTRY_CODE + '',
-        el.ZSERVICE_PROVIDER + '',
-        (el.ZADDRESS || '').toString()
-      ])
-
-      items = [['ID', 'Date', 'Answered', 'Originated', 'Type', 'Duration', 'Location', 'Country', 'Service', 'Address'], ['-', '-', '-', '-', '-', '-', '-', '-', '-', '-'], ...items]
-      items = normalizeCols(items).map(el => el.join(' | ').replace(/\n/g, '')).join('\n')
-
-      if (!program.color) { items = stripAnsi(items) }
-
-      console.log(items)
-    })
-    .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+    // Use the configured formatter to print the rows.
+    const result = program.formatter.format(items, {
+      // Color formatting?
+      program: program,
+
+      // Columns to be displayed in human-readable printouts.
+      // Some formatters, like raw or CSV, ignore these.
+      columns: {
+        'ID': el => el.Z_PK,
+        'Date': el => el.XFORMATTEDDATESTRING,
+        'Answered': el => el.ZANSWERED + '',
+        'Originated': el => el.ZORIGINATED + '',
+        'Call Type': el => el.ZCALLTYPE + '',
+        'Duration': el => el.ZDURATION + '',
+        'Location': el => el.ZLOCATION + '',
+        'Country': el => el.ZISO_COUNTRY_CODE + '',
+        'Service': el => el.ZSERVICE_PROVIDER + '',
+        'Address': el => (el.ZADDRESS || '').toString()
+      }
     })
+
+    // If using promises, we must call resolve()
+    resolve(result)
+  })
+  .catch(reject)
 }

+ 20 - 26
tools/reports/conversations.js

@@ -6,35 +6,29 @@ const normalizeCols = require('../util/normalize.js')
 module.exports.name = 'conversations'
 module.exports.description = 'List all SMS and iMessage conversations'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
 
-  // Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
 
-  backup.getConversations(program.dump)
+module.exports.func = function (program, backup, resolve, reject) {
+  backup.getConversations()
     .then((items) => {
-      if (program.dump) return
 
-      items = items.map(el => [
-        el.ROWID + '',
-        chalk.gray(el.XFORMATTEDDATESTRING || '??'),
-        el.service_name + '', 
-        el.chat_identifier + '',
-        el.display_name + ''
-      ])
-
-      items = [['ID', 'DATE', 'Service', 'Chat Name', 'Display Name'], ['-', '-', '-', '-', '-'], ...items]
-      items = normalizeCols(items).map(el => el.join(' | ')).join('\n')
-
-      if (!program.color) { items = stripAnsi(items) }
-
-      console.log(items)
-    })
-    .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+      var result = program.formatter.format(items, {
+        program: program,
+        columns: {
+          'ID': el => el.ROWID,
+          'Date': el => el.XFORMATTEDDATESTRING || '??',
+          'Service': el => el.service_name + '',
+          'Chat Name': el => el.chat_identifier + '',
+          'Display Name': el => el.display_name + '',
+        }
+      })
+
+      resolve(result)
     })
+    .catch(reject)
 }

+ 21 - 41
tools/reports/list.js

@@ -7,52 +7,32 @@ const fs = require('fs-extra')
 module.exports.name = 'list'
 module.exports.description = 'List of all backups. alias for -l'
 
-module.exports.func = function (program, base) {
+
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = false
+
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
+
+module.exports.func = function (program, base, resolve, reject) {
   var items = fs.readdirSync(base, { encoding: 'utf8' })
     .filter(el => (el !== '.DS_Store'))
     .map(file => iPhoneBackup.fromID(file, base))
+    .filter(el => el.manifest && el.status)
 
-    // Possibly dump output
-  if (program.dump) {
-    console.log(JSON.stringify(items, null, 4))
-    return
-  }
-
-  items = items.map(el => {
-    if (!el.manifest || !el.status) { return null }
-    return {
-      encrypted: el.manifest ? el.manifest.IsEncrypted
-                                    ? chalk.green('encrypted')
-                                    : chalk.red('not encrypted')
-                            : 'unknown encryption',
-      device_name: el.manifest ? el.manifest.Lockdown.DeviceName : 'Unknown Device',
-      device_id: el.id,
-      serial: el.manifest.Lockdown.SerialNumber,
-      iOSVersion: el.manifest.Lockdown.ProductVersion + '(' + el.manifest.Lockdown.BuildVersion + ')',
-      backupVersion: el.status ? el.status.Version : '?',
-      date: el.status ? new Date(el.status.Date).toLocaleString() : ''
+  var output = program.formatter.format(items, {
+    program: program,
+    columns: {
+      'UDID': el => el.id,
+      'Encryption': el => el.manifest ? (el.manifest.IsEncrypted ? 'encrypted' : 'not encrypted') : 'unknown',
+      'Date': el => el.status ? new Date(el.status.Date).toLocaleString() : '',
+      'Device Name': el => el.manifest ? el.manifest.Lockdown.DeviceName : 'Unknown Device',
+      'Serial #': el => el.manifest.Lockdown.SerialNumber,
+      'iOS Version': el => el.manifest ? el.manifest.Lockdown.ProductVersion : '?',
+      'Backup Version': el => el.status ? el.status.Version : '?'
     }
   })
-  .filter(el => el != null)
-  .map(el => [
-    chalk.gray(el.device_id),
-    el.encrypted,
-    el.date,
-    el.device_name,
-    el.serial,
-    el.iOSVersion,
-    el.backupVersion
-  ])
-
-  items = [
-    ['UDID', 'Encryption', 'Date', 'Device Name', 'Serial #', 'iOS Version', 'Backup Version'],
-    ['-', '-', '-', '-', '-', '-', '-'],
-    ...items
-  ]
-  items = normalizeCols(items)
-  items = items.map(el => el.join(' | ')).join('\n')
-
-  if (!program.color) { items = stripAnsi(items) }
 
-  console.log(items)
+  resolve(output)
 }

+ 23 - 19
tools/reports/manifest.js

@@ -7,15 +7,19 @@ const path = require('path')
 module.exports.name = 'manifest'
 module.exports.description = 'List all the files contained in the backup (iOS 10+)'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
 
-// Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
-  backup.getFileManifest()
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
+
+// Specify this only works for iOS 10+
+module.exports.supportedVersions = '>=10.0'
+
+module.exports.func = function (program, backup, resolve, reject) {
+
+    backup.getFileManifest()
     .then((items) => {
       if (program.dump) {
         console.log(JSON.stringify(items, null, 4))
@@ -61,22 +65,22 @@ module.exports.func = function (program, base) {
             console.log(chalk.red('fail'), item.relativePath, e.toString())
           }
         }
-      } else {
-        // Otherwise, output the table of items.
-        items = items.map(el => [
-          el.fileID + '',
-          el.domain + ': ' + el.relativePath
-        ])
 
-        items = [['ID', 'Domain/Path'], ['-'], ...items]
-        items = normalizeCols(items, 1).map(el => el.join(' | ').replace(/\n/g, '')).join('\n')
+        resolve(result)
+      } else {
 
-        if (!program.color) { items = stripAnsi(items) }
+        var result = program.formatter.format(items, {
+          program: program,
+          columns: {
+            'ID': el => el.fileID,
+            'Domain/Path': el => el.domain + ': ' + el.relativePath
+          }
+        })
 
-        console.log(items)
+        resolve(result)
       }
     })
     .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+        console.log('[!] Encountered an Error:', e)
     })
 }

+ 29 - 23
tools/reports/messages.js

@@ -6,32 +6,38 @@ const normalizeCols = require('../util/normalize.js')
 module.exports.name = 'messages'
 module.exports.description = 'List all SMS and iMessage messages in a conversation'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
-
-  // Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
-
-  backup.getMessages(program.messages, program.dump)
-    .then((items) => {
-      if (program.dump) return
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
 
-      items = items.map(el => [
-        chalk.gray(el.XFORMATTEDDATESTRING + ''),
-        chalk.blue(el.x_sender + ''),
-        el.text || ''
-      ])
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
 
-      items = normalizeCols(items, 2).map(el => el.join(' | ')).join('\n')
+// Should this report be skipped in automated reports?
+// This is used when the 'all' report type is specified, and all possible reports are generated.
+// with this set to true, the report WILL NOT run when report type = 'all'
+module.exports.requiresInteractivity = true
 
-      if (!program.color) { items = stripAnsi(items) }
+module.exports.func = function (program, backup, resolve, reject) {
+  if (!program.id) {
+    console.log('use -i or --id <id> to specify conversation ID.')
+    process.exit(1)
+  }
 
-      console.log(items)
-    })
-    .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+  backup.getMessages(program.id)
+    .then((items) => {
+  
+      var result = program.formatter.format(items, {
+        program: program,
+        columns: {
+          'ID' : el => el.ROWID,
+          'Date': el => el.XFORMATTEDDATESTRING,
+          'Sender': el => el.x_sender,
+          'Text': el => (el.text || '').trim()
+        }
+      })
+
+      resolve(result)
     })
+    .catch(reject)
 }

+ 23 - 29
tools/reports/notes.js

@@ -5,37 +5,31 @@ const normalizeCols = require('../util/normalize.js')
 module.exports.name = 'notes'
 module.exports.description = 'List all iOS notes'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
+// Specify this only works for iOS 10+
+module.exports.supportedVersions = '>=9.0'
 
-// Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
-  backup.getNotes(program.dump)
-    .then((items) => {
-        // Dump if needed
-      if (program.dump) {
-        console.log(JSON.stringify(items, null, 4))
-        return
-      }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
 
-        // Otherwise, format table
-      items = items.map(el => [
-        (el.XFORMATTEDDATESTRING || el.XFORMATTEDDATESTRING1) + '',
-            (el.Z_PK + ''),
-        (el.ZTITLE2 + '').trim().substring(0, 128),
-        (el.ZTITLE1 + '').trim() || ''
-      ])
-      items = [['Modified', 'ID', 'Title2', 'Title1'], ['-', '-', '-', '-'], ...items]
-      items = normalizeCols(items, 3).map(el => el.join(' | ')).join('\n')
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
 
-      if (!program.color) { items = stripAnsi(items) }
+module.exports.func = function (program, backup, resolve, reject) {
+  backup.getNotes(program.dump)
+    .then((items) => {
+      // Format the output
+      var result = program.formatter.format(items, {
+        program: program,
+        columns: {
+          'Modified': el => (el.XFORMATTEDDATESTRING || el.XFORMATTEDDATESTRING1) + '',
+          'ID': el => el.Z_PK,
+          'Title2': el => (el.ZTITLE2 + '').trim().substring(0, 128),
+          'Title1': el => (el.ZTITLE1 + '').trim() || ''
+        }
+      })
 
-      console.log(items)
-    })
-    .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+      resolve(result)
     })
-}
+    .catch(reject)
+}

+ 18 - 27
tools/reports/oldnotes.js

@@ -5,37 +5,28 @@ const normalizeCols = require('../util/normalize.js')
 module.exports.name = 'oldnotes'
 module.exports.description = 'List all iOS notes (from older unused database)'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
+
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
+
+module.exports.func = function (program, backup, resolve, reject) {
 
-  // Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
   backup.getOldNotes(program.dump)
     .then((items) => {
-      // Dump if needed
-      if (program.dump) {
-        console.log(JSON.stringify(items, null, 4))
-        return
-      }
 
-      // Otherwise, format table
-      items = items.map(el => [el.XFORMATTEDDATESTRING + '', (el.Z_PK + ''), (el.ZTITLE + '').substring(0, 128)])
-      items = [
-        ['Modified', 'ID', 'Title'],
-        ['-', '-', '-'], ...items
-      ]
-      items = normalizeCols(items).map(el => el.join(' | ')).join('\n')
+      var result = program.formatter.format(items, {
+        program: program,
+        columns: {
+          'Modified': el => el.XFORMATTEDDATESTRING,
+          'ID': el => el.Z_PK,
+          'Title': el => (el.ZTITLE + '').substring(0, 128)
+        }
+      })
 
-      if (!program.color) {
-        items = stripAnsi(items)
-      }
-
-      console.log(items)
-    })
-    .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+      resolve(result)
     })
+    .catch(reject)
 }

+ 24 - 32
tools/reports/photolocations.js

@@ -1,40 +1,32 @@
-const stripAnsi = require('strip-ansi')
-const iPhoneBackup = require('../util/iphone_backup.js').iPhoneBackup
-const normalizeCols = require('../util/normalize.js')
-
 module.exports.name = 'photolocations'
 module.exports.description = 'List all geolocation information for iOS photos (iOS 10+)'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
-
-// Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
-  backup.getPhotoLocationHistory(program.dump)
-    .then((history) => {
-      if (program.dump) {
-        console.log(JSON.stringify(history, null, 4))
-        return
-      }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
 
-      var items = history.map(el => [
-        el.XFORMATTEDDATESTRING + '' || '',
-        el.ZLATITUDE + '' || '',
-        el.ZLONGITUDE + '' || '',
-        el.ZFILENAME + '' || ''
-      ])
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
 
-      items = [['Time', 'Latitude', 'Longitude', 'Photo Name'], ['-', '-', '-'], ...items]
-      items = normalizeCols(items).map(el => el.join(' | ').replace(/\n/g, '')).join('\n')
+// Specify this only works for iOS 10+
+module.exports.supportedVersions = '>=10.0'
 
-      if (!program.color) { items = stripAnsi(items) }
+// Reporting function
+module.exports.func = function (program, backup, resolve, reject) {
+  backup.getPhotoLocationHistory()
+    .then((history) => {
+      // Format the output according to the configured formatter.
+      var output = program.formatter.format(history, {
+        program: program,
+        columns: {
+          'Time': el => el.XFORMATTEDDATESTRING,
+          'Latitude': el => el.ZLATITUDE,
+          'Longitude': el => el.ZLONGITUDE,
+          'File': el => el.ZFILENAME,
+        }
+      })
 
-      console.log(items)
-    })
-    .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+      resolve(output)
     })
-}
+    .catch(reject)
+}

+ 20 - 23
tools/reports/voicemail-files.js

@@ -7,21 +7,19 @@ const fs = require('fs-extra')
 module.exports.name = 'voicemail-files'
 module.exports.description = 'List all or extract voicemail files (iOS 10+)'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
-
-// Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
+
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
+
+module.exports.func = function (program, backup, resolve, reject) {
+
   backup.getVoicemailFileList()
     .then((list) => {
-      if (program.dump) {
-        console.log(JSON.stringify(list, null, 4))
-        return
-      }
 
+      // Extract to the specified location
       if (program.extract) {
         for (var item of list) {
           try {
@@ -35,18 +33,17 @@ module.exports.func = function (program, base) {
         }
       }
 
-      var items = list.map(el => [
-        el.fileID + '',
-        el.relativePath,
-        el.output_dir || '<not exported>'
-      ])
-
-      items = [['ID', 'Path', 'Exported Path'], ['-', '-', '-'], ...items]
-      items = normalizeCols(items).map(el => el.join(' | ').replace(/\n/g, '')).join('\n')
-
-      if (!program.color) { items = stripAnsi(items) }
+      // Generate report.
+      var result = program.formatter.format(list, {
+        program: program,
+        columns: {
+          'ID': el => el.fileID,
+          'Path': el => el.relativePath,
+          'Export Path': el => el.output_dir || '<not exported>'
+        }
+      })
 
-      console.log(items)
+      resolve(result)
     })
     .catch((e) => {
       console.log('[!] Encountered an Error:', e)

+ 27 - 34
tools/reports/voicemail.js

@@ -1,44 +1,37 @@
-const stripAnsi = require('strip-ansi')
-const iPhoneBackup = require('../util/iphone_backup.js').iPhoneBackup
-const normalizeCols = require('../util/normalize.js')
-
 module.exports.name = 'voicemail'
 module.exports.description = 'List all or extract voicemails on device'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
 
-// Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
-  backup.getVoicemailsList()
-    .then((items) => {
-      if (program.dump) {
-        console.log(JSON.stringify(items, null, 4))
-        return
-      }
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
 
-      items = items.map(el => [
-        el.ROWID + '',
-        el.XFORMATTEDDATESTRING,
-        el.sender + '',
-        el.token + '',
-        el.duration + '',
-        el.expiration + '',
-        el.trashed_date + '',
-        el.flags + ''
-      ])
+// Specify this only works for iOS 10+
+module.exports.supportedVersions = '>=9.0'
 
-      items = [['ID', 'Date', 'Sender', 'Token', 'Duration', 'Expiration', 'Trashed', 'Flags'], ['-', '-', '-', '-', '-', '-', '-', '-'], ...items]
-      items = normalizeCols(items).map(el => el.join(' | ').replace(/\n/g, '')).join('\n')
+module.exports.func = function (program, backup, resolve, reject) {
 
-      if (!program.color) { items = stripAnsi(items) }
+  backup.getVoicemailsList()
+    .then((items) => {
 
-      console.log(items)
-    })
-    .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+      var result = program.formatter.format(items, {
+        program: program,
+        columns: {
+          'ID': el => el.ROWID,
+          'Date': el => el.XFORMATTEDDATESTRING,
+          'Sender': el => el.sender,
+          'Token': el => el.token,
+          'Duration': el => el.duration,
+          'Expiration': el => el.expiration,
+          'Duration': el => el.duration,
+          'Trashed': el => el.trashed_date,
+          'Flags': el => el.flags
+        }
+      })
+
+      resolve(result)
     })
+    .catch(reject)
 }

+ 21 - 25
tools/reports/webhistory.js

@@ -6,35 +6,31 @@ const normalizeCols = require('../util/normalize.js')
 module.exports.name = 'webhistory'
 module.exports.description = 'List all web history'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
 
-// Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
-  backup.getWebHistory(program.dump)
-    .then((history) => {
-      if (program.dump) {
-        console.log(JSON.stringify(history, null, 4))
-        return
-      }
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
 
-      var items = history.map(el => [
-        el.XFORMATTEDDATESTRING + '' || '',
-        new URL(el.url || '').origin || '',
-        (el.title || '').substring(0, 64)
-      ])
+// Specify this only works for iOS 6+
+module.exports.supportedVersions = '>=9.0'
 
-      items = [['Time', 'URL', 'Title'], ['-', '-', '-'], ...items]
-      items = normalizeCols(items).map(el => el.join(' | ').replace(/\n/g, '')).join('\n')
+module.exports.func = function (program, backup, resolve, reject) {
 
-      if (!program.color) { items = stripAnsi(items) }
+  backup.getWebHistory(program.dump)
+    .then((history) => {
+      
+      var result = program.formatter.format(history, {
+        program: program,
+        columns: {
+          'Time': el => el.XFORMATTEDDATESTRING,
+          'URL': el => new URL(el.url || '').origin || '',
+          'Title': el => (el.title || '').substring(0, 64)
+        }
+      })
 
-      console.log(items)
-    })
-    .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+      resolve(result)
     })
+    .catch(reject)
 }

+ 22 - 29
tools/reports/wifi.js

@@ -5,39 +5,32 @@ const normalizeCols = require('../util/normalize.js')
 module.exports.name = 'wifi'
 module.exports.description = 'List associated wifi networks and their usage information'
 
-module.exports.func = function (program, base) {
-  if (!program.backup) {
-    console.log('use -b or --backup <id> to specify backup.')
-    process.exit(1)
-  }
+// Specify this reporter requires a backup. 
+// The second parameter to func() is now a backup instead of the path to one.
+module.exports.requiresBackup = true
 
-// Grab the backup
-  var backup = iPhoneBackup.fromID(program.backup, base)
-  backup.getWifiList()
-    .then((items) => {
-      if (program.dump) {
-        console.log(JSON.stringify(items, null, 4))
-        return
-      }
+// Specify this reporter supports the promises API for allowing chaining of reports.
+module.exports.usesPromises = true
 
-      items = items['List of known networks'].map(el => [
-        el.lastJoined + '' || '',
-        el.lastAutoJoined + '' || '',
-        el.SSID_STR + '',
-        el.BSSID + '',
-        el.SecurityMode || '',
-        el.HIDDEN_NETWORK + '',
-        el.enabled + ''
-      ]).sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
+module.exports.func = function (program, backup, resolve, reject) {
 
-      items = [['Last Joined', 'Last AutoJoined', 'SSID', 'BSSID', 'Security', 'Hidden', 'Enabled'], ['-', '-', '-', '-', '-', '-'], ...items]
-      items = normalizeCols(items).map(el => el.join(' | ').replace(/\n/g, '')).join('\n')
+  backup.getWifiList()
+    .then((items) => {
 
-      if (!program.color) { items = stripAnsi(items) }
+      var result = program.formatter.format(items['List of known networks'], {
+        program: program,
+        columns: {
+          'Last Joined': el => el.lastJoined,
+          'Last AutoJoined': el => el.lastAutoJoined,
+          'SSID': el => el.SSID_STR,
+          'BSSID': el => el.BSSID,
+          'Security': el => el.SecurityMode || '',
+          'Hidden': el => el.HIDDEN_NETWORK || '',
+          'Enabled': el => el.enabled
+        }
+      })
 
-      console.log(items)
-    })
-    .catch((e) => {
-      console.log('[!] Encountered an Error:', e)
+      resolve(result)
     })
+    .catch(reject)
 }

+ 28 - 16
tools/util/iphone_backup.js

@@ -45,16 +45,19 @@ class iPhoneBackup {
 
     // Parse manifest bplist files
     try {
+      if (global.verbose) console.log('parsing status', base)
       var status = bplist.parseBuffer(fs.readFileSync(path.join(base, 'Status.plist')))[0]
     } catch (e) {
       console.log('Cannot open Status.plist', e)
     }
     try {
+      if (global.verbose) console.log('parsing manifest', base)
       var manifest = bplist.parseBuffer(fs.readFileSync(path.join(base, 'Manifest.plist')))[0]
     } catch (e) {
       console.log('Cannot open Manifest.plist', e)
     }
     try {
+      if (global.verbose) console.log('parsing status', base)
       var info = plist.parse(fs.readFileSync(path.join(base, 'Info.plist'), 'utf8'))
     } catch (e) {
       console.log('Cannot open Info.plist', e)
@@ -63,6 +66,10 @@ class iPhoneBackup {
     return new iPhoneBackup(id, status, info, manifest, base)
   }
 
+  get iOSVersion () {
+    return this.manifest.Lockdown.ProductVersion
+  }
+
   getFileName (fileID, isAbsoulte) {
     isAbsoulte = isAbsoulte || false
 
@@ -88,6 +95,17 @@ class iPhoneBackup {
     }
   }
 
+  queryDatabase (databaseID, sql) {
+    return new Promise((resolve, reject) => {
+      var messagedb = this.getDatabase(databaseID)
+      messagedb.all(sql, async function (err, rows) {
+        if (err) reject(err)
+
+        resolve(rows)
+      })
+    })
+  }
+
   getName (messageDest) {
     return new Promise((resolve, reject) => {
       if (messageDest.indexOf('@') === -1) {
@@ -126,7 +144,7 @@ class iPhoneBackup {
     })
   }
 
-  getMessagesiOS9 (chatId, dumpAll) {
+  getMessagesiOS9 (chatId) {
     var backup = this
     return new Promise((resolve, reject) => {
       var messagedb = this.getDatabase(databases.SMS)
@@ -146,7 +164,6 @@ class iPhoneBackup {
        if (err) return reject(err)
 
        chats = chats || []
-       if (dumpAll) console.log(JSON.stringify(chats, null, 4))
 
         // Compute the user's name
        for (var i in chats) {
@@ -167,7 +184,7 @@ class iPhoneBackup {
     })
   }
 
-  getMessagesiOS10iOS11 (chatId, dumpAll) {
+  getMessagesiOS10iOS11 (chatId) {
     var backup = this
     return new Promise((resolve, reject) => {
       var messagedb = this.getDatabase(databases.SMS)
@@ -187,7 +204,6 @@ class iPhoneBackup {
        if (err) return reject(err)
 
        chats = chats || []
-       if (dumpAll) console.log(JSON.stringify(chats, null, 4))
 
         // Compute the user's name
        for (var i in chats) {
@@ -208,15 +224,15 @@ class iPhoneBackup {
     })
   }
 
-  getMessages (chatId, dumpAll) {
+  getMessages (chatId) {
     if (parseInt(this.manifest.Lockdown.BuildVersion) <= 13) {
-      return this.getMessagesiOS9(chatId, dumpAll)
+      return this.getMessagesiOS9(chatId)
     } else {
-      return this.getMessagesiOS10iOS11(chatId, dumpAll)
+      return this.getMessagesiOS10iOS11(chatId)
     }
   }
 
-  getConversationsiOS9 (dumpAll) {
+  getConversationsiOS9 () {
     var backup = this
     return new Promise((resolve, reject) => {
       var messagedb = this.getDatabase(databases.SMS)
@@ -263,32 +279,28 @@ class iPhoneBackup {
           return (a.date.getTime() || 0) - (b.date.getTime() || 0)
         })
 
-        if (dumpAll) console.log(JSON.stringify(rows, null, 4))
-
         resolve(rows)
       })
     })
   }
 
-  getConversationsiOS10iOS11 (dumpAll) {
+  getConversationsiOS10iOS11 () {
     return new Promise((resolve, reject) => {
       var messagedb = this.getDatabase(databases.SMS)
       messagedb.all(`SELECT *, datetime(last_read_message_timestamp / 1000000000 + 978307200, 'unixepoch') AS XFORMATTEDDATESTRING FROM chat ORDER BY last_read_message_timestamp ASC`, async function (err, rows) {
         if (err) return reject(err)
         rows = rows || []
 
-        if (dumpAll) console.log(JSON.stringify(rows, null, 4))
-
         resolve(rows)
       })
     })
   }
 
-  getConversations (dumpAll) {
+  getConversations () {
     if (parseInt(this.manifest.Lockdown.BuildVersion) <= 14) {
-      return this.getConversationsiOS9(dumpAll)
+      return this.getConversationsiOS9()
     } else {
-      return this.getConversationsiOS10iOS11(dumpAll)
+      return this.getConversationsiOS10iOS11()
     }
   }
 

+ 25 - 26
tools/util/normalize.js

@@ -1,35 +1,34 @@
 const stripAnsi = require('strip-ansi')
 
-module.exports = function normalizeOutput(rows, max) {
-    function padEnd(string, maxLength, fillString) {
-        while(stripAnsi(string).length < maxLength) {
-            string = string + fillString;
-        }    
-    
-        return string   
+module.exports = function normalizeOutput (rows, max) {
+  function padEnd (string, maxLength, fillString) {
+    while (stripAnsi(string).length < maxLength) {
+      string = string + fillString
     }
 
-    var widths = []
-    max = max || rows[0].length
+    return string
+  }
 
-    for(var i = 0; i < rows.length; i++) {
-        for(var j = 0; j < rows[i].length && j < max; j++) {
-            if(!widths[j] || widths[j] < stripAnsi(rows[i][j]).length) {
-                widths[j] = stripAnsi(rows[i][j]).length
-            }
-        }
+  var widths = []
+  max = max || rows[0].length
+
+  for (var i = 0; i < rows.length; i++) {
+    for (var j = 0; j < rows[i].length && j < max; j++) {
+      if (!widths[j] || widths[j] < stripAnsi(rows[i][j]).length) {
+        widths[j] = stripAnsi(rows[i][j]).length
+      }
     }
+  }
 
-    for(var i = 0; i < rows.length; i++) {
-        for(var j = 0; j < rows[i].length && j < max; j++) {
-            if(rows[i][j] == '-') {
-                rows[i][j] = padEnd(rows[i][j], widths[j], '-')
-            } else {
-                rows[i][j] = padEnd(rows[i][j], widths[j], ' ')
-            }
-        }
+  for (var i = 0; i < rows.length; i++) {
+    for (var j = 0; j < rows[i].length && j < max; j++) {
+      if (rows[i][j] == '-') {
+        rows[i][j] = padEnd(rows[i][j], widths[j], '-')
+      } else {
+        rows[i][j] = padEnd(rows[i][j], widths[j], ' ')
+      }
     }
+  }
 
-    return rows
-    
-}
+  return rows
+}

+ 74 - 0
tools/util/version_compare.js

@@ -0,0 +1,74 @@
+// Add zeros to an array until it reaches a specific length.
+function zeroPad (array, length) {
+  for (var i = array.length; i < length; i++) {
+    array[i] = 0
+  }
+
+  return array
+}
+
+function aLessB (a, b) {
+  var maxLength = a.length > b.length ? a.length : b.length
+
+  a = zeroPad(a.map(el => el === undefined ? 0 : parseInt(el)), maxLength)
+  b = zeroPad(b.map(el => el === undefined ? 0 : parseInt(el)), maxLength)
+
+  // Check if any of the declared items in a are less than their component in B.
+  for (let i = 0; i < a.length; i++) {
+    // If this component is less, the entire thing must be less
+    if (a[i] < b[i]) {
+      return true
+    }
+
+    // If this item is greater, the entire thing is greater.
+    if (a[i] > b[i]) {
+      return false
+    }
+  }
+
+  return true
+}
+
+// Check if two arrays are equal.
+function aEqualB (a, b) {
+  if (a.length !== b.length) return false
+
+  for (var i = 0; i < a.length; i++) {
+    if (a[i] !== b[i]) {
+      return false
+    }
+  }
+
+  return true
+}
+
+// Comparison types
+const comparisonFuncs = {
+  '>=': (a, b) => { return !aLessB(a, b) || aEqualB(a, b) },
+  '>': (a, b) => { return !aLessB(a, b) },
+  '<=': (a, b) => { return aLessB(a, b) || aEqualB(a, b) },
+  '<': (a, b) => { return aLessB(a, b) },
+  '=': (a, b) => { return aEqualB(a, b) }
+}
+
+function comparison (backup, declared) {
+  var backupComponents = /(>=|>|<|<=|=|~)?(?: +)?(\d+)(?:\.(\d+))?(?:\.(\d+))?/g.exec(backup)
+  var declaredComponents = /(>=|>|<|<=|=|~)?(?: +)?(\d+)(?:\.(\d+))?(?:\.(\d+))?/g.exec(declared)
+
+  const comparison = declaredComponents[1]
+
+  return comparisonFuncs[comparison](backupComponents.slice(2, 5), declaredComponents.slice(2, 5))
+}
+
+// Check if a version satisfies a declared version constraint
+module.exports.versionCheck = function (backup, declared) {
+  var declaredItems = declared.split(',').map(el => el.trim())
+
+  for (var i = 0; i < declaredItems.length; i++) {
+    if (!comparison(backup, declaredItems[i])) {
+      return false
+    }
+  }
+
+  return true
+}

Some files were not shown because too many files changed in this diff