Python-取证学习指南第二版-全-

Python 取证学习指南第二版(全)

原文:annas-archive.org/md5/46c71d4b3d6fceaba506eebc55284aa5

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在编写《学习 Python 取证》一书时,我们有一个目标:以一种方式教授 Python 在取证中的应用,使得没有编程经验的读者可以立即跟随并开发出可以用于案件工作中的实用代码。但这并不意味着本书仅适合 Python 新手;在整个过程中,我们会逐步让读者接触越来越具挑战性的代码,并最终将前面章节中的许多脚本纳入到一个取证框架中。本书对读者的编程经验做了一些假设,若有假设,通常会提供详细的解释、示例和资源列表,帮助弥合知识的差距。

本书的大部分内容将专注于开发针对各种取证痕迹的代码;然而,前两章将教授语言的基础知识。这将为所有技能水平的读者奠定基础。我们希望完全没有编程经验的读者能够在本书结束时开发出具有取证有效性和相关性的脚本。

就像在现实世界中一样,代码开发将遵循模块化设计。最初,一个脚本可能会用一种方式编写,随后又用另一种方式重写,以展示不同技术的优缺点。通过这种方式的沉浸,您将帮助自己构建并加强记住脚本设计过程所需的神经链接。为了使 Python 开发成为第二天性,请在每章中重新输入展示的练习,以便自己练习并学习常见的 Python 技巧。不要害怕修改代码,您不会破坏任何东西(除非是您自己的脚本版本),而且这样做可以让您更好地理解代码的内部工作原理。

本书适合谁阅读

如果您是取证学学生、爱好者或专业人士,并希望通过使用编程语言提高对取证的理解,那么这本书适合您。

您不需要具备编程经验即可学习并掌握本书中的内容。这些由取证专业人员编写的材料,具有独特的视角,旨在帮助考官学习编程。

本书涵盖的内容

第一章,现在开始完全不同的内容,介绍了常见的 Python 对象、内置函数和典型用法。我们还将涵盖基本的编程概念。

第二章,Python 基础知识,是上一章所学基础的延续,并且介绍了我们第一个取证脚本的开发。

第三章,解析文本文件,讨论了一个基本的 API 日志解析器,用于识别 USB 设备的首次使用时间,并介绍了迭代开发周期。

第四章,处理序列化数据结构,展示了如何使用 JSON 等序列化数据结构在 Python 中存储或检索数据。我们将解析来自比特币区块链的 JSON 格式数据,内容包括交易详情。

第五章,Python 中的数据库,展示了如何使用数据库通过 Python 存储和检索数据。我们将使用两个不同的数据库模块来演示不同版本的脚本,该脚本创建一个带有数据库后端的活动文件列表。

第六章,从二进制文件中提取伪影,是对 struct 模块的介绍,它将成为每个检查员的好帮手。我们使用 struct 模块将来自取证相关来源的二进制数据解析为 Python 对象。我们将解析注册表中的 UserAssist 键,以提取用户应用程序执行伪影。

第七章,模糊哈希,探索了如何生成与 ssdeep 兼容的哈希,并如何使用预构建的 ssdeep 模块执行相似性分析。

第八章,媒体时代,帮助我们理解嵌入式元数据,并从取证来源解析它们。在这一章中,我们介绍并设计了一个嵌入式元数据框架,用于 Python。

第九章,揭开时间的面纱,首次展示了如何使用 Python 开发图形用户界面(GUI)来解码常见的时间戳。这是我们对 GUI 和 Python 类开发的介绍。

第十章,快速分类系统,展示了如何使用 Python 从流行的操作系统中收集易失性和其他有用信息。这包括对非常强大的 Windows 专用 Python API 的介绍。

第十一章,解析 Outlook PST 容器,演示了如何读取、索引和报告 Outlook PST 容器中的内容。

第十二章,恢复已删除的数据库记录,介绍了 SQLite 的预写日志(Write-Ahead Logs),以及如何从这些文件中提取数据,包括已删除的数据。

第十三章,圆满收官,是将前面章节中的脚本聚合为一个取证框架。我们探索了设计这些大型项目的概念和方法。

获取本书最大收益

为了跟随本书中的示例,您需要以下设备:

  • 一台联网的计算机

  • Python 2.7.15 或 Python 3.7.1

  • 可选:Python 的集成开发环境(IDE)

除了这些要求外,您还需要安装一些我们将在代码中使用的第三方模块。我们将指明需要安装哪些模块,正确的版本以及如何安装它们。

下载示例代码文件

您可以从您的账户在 www.packt.com 下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问 www.packt.com/support 并注册,以便直接将文件通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 登录或注册 www.packt.com

  2. 选择“SUPPORT”标签。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书名,并按照屏幕上的说明操作。

文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:

  • 适用于 Windows 的 7-Zip/WinRAR

  • 适用于 Mac 的 Keka/Zipeg/iZip/UnRarX

  • 适用于 Linux 的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Learning-Python-for-Forensics-Second-Edition。如果代码有更新,更新内容将会在现有的 GitHub 仓库中发布。

我们还有其他来自我们丰富书籍和视频目录的代码包,您可以在github.com/PacktPublishing/查看!快来看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,包含本书中使用的截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789341690_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。以下是一个示例:“本章概述了 Python 的基础知识,从 Hello World 到核心脚本概念。”

代码块设置如下:

# open the database
    # read from the database using the sqlite3 library
    #     store in variable called records
    for record in records: 
        # process database records here

任何命令行输入或输出如下所示:

>>> type('what am I?')
<class 'str'>

粗体:表示新术语、重要词汇或在屏幕上看到的单词。例如,菜单或对话框中的单词将在文本中以这种方式呈现。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要说明以这种方式呈现。

提示和技巧以这种方式呈现。

与我们联系

我们非常欢迎读者的反馈。

一般反馈:如果您对本书的任何部分有疑问,请在邮件主题中注明书名,并发送邮件至customercare@packtpub.com

勘误:尽管我们已尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告。请访问 www.packt.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,并输入相关详情。

盗版:如果您在互联网上发现任何我们作品的非法复制品,恳请您提供相关网址或网站名称。请通过 copyright@packt.com 与我们联系,并附上相关材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,且有意写作或为书籍做贡献,请访问 authors.packtpub.com

评论

请留下评论。阅读并使用本书后,何不在购买该书的网站上留下评论?潜在读者可以参考您的公正评价来做出购买决策,我们 Packt 可以了解您对我们产品的看法,作者们也能看到您对他们书籍的反馈。谢谢!

想了解更多关于 Packt 的信息,请访问 packt.com

第一章:现在,呈现一些完全不同的内容

本书将 Python 作为优化数字取证分析的必要工具——从检查员的角度出发进行编写。在前两章中,我们将介绍 Python 的基础知识,为本书其余部分做准备,在后续章节中,我们将开发脚本以完成取证任务。虽然重点是将该语言作为工具使用,但我们也会探讨 Python 的优势,以及它如何帮助许多领域的从业者为复杂的取证挑战创建解决方案。就像 Python 的名字来源于 Monty Python 一样,接下来的 12 章旨在呈现一些完全不同的内容。

在这个快速发展的领域中,脚本语言以自动化的方式提供灵活的问题解决方案,使检查员能够有更多的时间去调查其他由于时间限制可能未能彻底分析的证据。诚然,Python 可能并不总是完成任务的最佳工具,但它是任何人的 DFIR 工具库中不可或缺的工具。如果你决定掌握 Python,它将大大回报你投入的时间,因为你将显著提高分析能力,并大大拓展你的技能。 本章概述了 Python 的基础知识,从Hello World到核心脚本概念。

本章将涵盖以下主题:

  • Python 介绍及健康的开发实践

  • 基本编程概念

  • 在 Python 中操作和存储对象

  • 创建简单的条件判断、循环和函数

何时使用 Python

Python 是一个强大的取证工具。然而,在决定开发脚本之前,重要的是要考虑所需的分析类型和项目的时间表。在接下来的例子中,我们将概述 Python 在某些情况下如何是不可或缺的工具,反之,在其他情况下它的开发工作可能并不值得。尽管快速开发使得在复杂情况下轻松部署解决方案,Python 并不总是实现任务的最佳工具。如果现有工具已经能够完成任务,并且可以使用,那么它可能是更合适的分析方法。

Python 是取证工作中常用的编程语言,因其易用性、库支持、详细文档以及跨操作系统的互操作性。编程语言主要有两种类型:解释型语言和编译型语言。编译代码可以将编程语言转换为机器语言,这种低级语言计算机更容易解释。解释型语言在运行时的速度不如编译型语言,但不需要编译,这样可以节省一些时间。由于 Python 是解释型语言,我们可以修改代码并立即运行查看结果。而对于编译型语言,我们必须等待代码重新编译后才能看到修改效果。因此,虽然 Python 的运行速度可能不如编译型语言,但它支持快速原型开发。

事件响应案例是一个极好的示例,展示了在实际环境中何时使用 Python。例如,假设客户打来电话,慌张地报告数据泄露,并且不确定过去 24 小时内有多少文件从他们的文件服务器中被外泄。到达现场后,你被指示执行最快的文件访问次数统计,因为这个统计数字和泄露文件的列表将决定下一步的行动。

Python 在这种情况下非常合适。只需要一台笔记本电脑,你就可以打开文本编辑器并开始编写解决方案。Python 可以在没有复杂编辑器或工具集的情况下进行构建和设计。你脚本的构建过程可能是这样的,每一步都建立在前一步的基础上:

  1. 让脚本读取单个文件的最后访问时间戳

  2. 编写一个循环,逐步遍历目录和子目录

  3. 测试每个文件,看看该时间戳是否来自过去 24 小时

  4. 如果文件在过去 24 小时内被访问过,则创建一个受影响文件的列表,显示文件路径和访问时间

这里的过程将生成一个脚本,该脚本会遍历整个服务器,并输出在过去 24 小时内最后一次访问时间的文件,以供人工审核。这个脚本可能只有大约 20 行代码,并且一个中级脚本员大约需要 10 分钟或更少的时间来开发和验证——显然,这比手动检查文件系统中的时间戳更高效。

在部署任何已开发的代码之前,必须首先验证其能力。由于 Python 不是编译型语言,我们可以在添加新代码行后轻松运行脚本,以确保没有破坏任何功能。这种方法被称为先测试后编码,是脚本开发中常用的方式。任何软件,不管是谁编写的,都应当经过仔细审查和评估,以确保准确性和精确性。验证确保代码正常运行,虽然这需要更多的时间,但它提供了可靠的结果,能够经得起法庭的考验,这是法医领域中的一个重要方面。

在一般案例分析中,Python 可能不是最佳工具。如果你拿到一个硬盘并被要求在没有额外线索的情况下寻找证据,那么使用已有的工具会是更好的解决方案。Python 在针对性解决方案中非常有价值,比如分析特定类型的文件并生成元数据报告。为某一文件系统开发一个定制的全能解决方案所需的时间太长,尤其是考虑到市面上已有的支持这种通用分析的工具,不论是付费还是免费。

Python 在预处理自动化中非常有用。如果你发现自己在处理每一份证据时都在重复相同的任务,那么开发一个自动化这些步骤的系统可能是值得的。一个很好的例子是 ManTech 的分析与分类系统(mantaray:github.com/mantarayforensics),它利用一系列工具生成通用报告,在数据范围不明确的情况下加快分析速度。

在考虑是否投入资源开发 Python 脚本时,无论是临时开发还是针对较大项目开发,都应考虑已经存在的解决方案、可用的开发时间以及通过自动化节省的时间。尽管有着最佳的意图,解决方案的开发可能会比最初设想的时间要长得多,尤其是当没有一个强有力的设计计划时。

开发生命周期

开发周期至少包括五个步骤:

  • 识别

  • 计划

  • 编程

  • 验证

  • 错误

第一步是不言自明的:在开发之前,你必须识别出需要解决的问题。规划可能是开发周期中最关键的一步:

良好的规划将有助于减少所需的代码量和 bug 数量,从而在后期节省时间。规划在学习过程中变得尤为重要。一个取证程序员必须开始回答以下问题:数据将如何被接收,哪些 Python 数据类型最为合适,是否需要第三方库,结果将如何呈现给检查员?刚开始时,就像写学期论文一样,最好先写一个大纲,或者画出程序的框架。随着 Python 熟练度的提高,规划将变成第二天性,但在最初阶段,建议先创建一个大纲或编写伪代码。

伪代码是一种在填充实际代码之前编写代码的非正式方式。伪代码可以表示程序的框架,如定义相关的变量和函数,并描述它们如何在脚本框架中组合在一起。一个函数的伪代码可能是这样的:

# open the database
  # read from the database using the sqlite3 library
  # store in variable called records
  for record in records: 
    # process database records here

在确定和规划之后,接下来的三个步骤构成了开发周期的最大部分。一旦程序的规划充分完成,就可以开始编写代码了!编写代码后,用尽可能多的测试数据来测试你的新程序。尤其在取证领域,彻底测试代码至关重要,而不是仅仅依赖一个例子的结果。如果没有全面的调试,代码在遇到意外情况时可能会崩溃,或者更糟糕的是,它可能会给检查员提供错误的信息,导致他们走错方向。代码测试完成后,就可以发布,并准备接受错误报告了。我们这里说的可不是昆虫!尽管程序员尽了最大努力,代码中总是会存在 bug。即便你修复了一个 bug,它们也有一种令人讨厌的方式会不断繁殖,导致编程周期不断地重复开始。

入门

在我们开始之前,你需要在机器上安装 Python。需要明白的是,在编写本书时,Python 有两个支持的版本:Python 2 和 3。我们将同时使用 Python 2 和 3 来开发我们的解决方案。从历史上看,许多有用的第三方取证库都是为 Python 2 开发的。目前,大部分库都与 Python 3 兼容,而 Python 3 提供了更强大的 Unicode 处理能力,解决了 Python 2 中的一大难题,还做了许多其他改进。本书中的所有代码都已经在最新的 Python 2(v. 2.7.15)或 3(v. 3.7.1)版本中进行过测试。在某些情况下,我们的代码兼容 Python 2 和 3,或者只兼容其中一个版本。每一章将会描述运行代码所需的 Python 版本。

此外,我们建议使用集成开发环境,简称IDE,例如 JetBrain 的 PyCharm。IDE 能够高亮显示错误并提供建议,帮助简化开发过程并促进编码时的最佳实践。如果无法安装 IDE,简单的文本编辑器也能使用。我们推荐像 Notepad++、Sublime Text 或 Visual Studio Code 这样的应用程序。对于喜欢命令行的用户,像 vim 或 nano 这样的编辑器也能使用。

在安装了 Python 后,让我们通过在命令提示符或终端中输入python来打开交互式提示符。我们将从介绍一些内置函数开始,以便用于故障排除。遇到本书中讨论的任何对象或函数,或在实际应用中遇到的函数时,第一步就是使用type()dir()help()这些内置函数。我们意识到我们尚未介绍常见的数据类型,因此以下代码可能看起来有些困惑。

然而,这正是本练习的重点。在开发过程中,你会遇到一些你不熟悉的数据类型,或者不确定如何与对象交互。这三个函数有助于解决这些问题。我们将在本章后面介绍基本数据类型。

type()函数在传入一个对象时,会返回其__name__属性,提供关于对象的类型识别信息。dir()函数在传入表示对象名称的字符串时,会返回其属性,显示该对象所属函数和参数的可用选项。help()函数可以通过其文档字符串展示这些方法的具体信息。文档字符串其实就是对一个函数的描述,详细说明了函数的输入、输出以及如何使用该函数。

让我们以str,即字符串对象,作为这三个函数的示例。在以下示例中,将一系列由单引号括起来的字符传递给type()函数,返回的类型是str,即字符串。

当我们展示示例时,如果输入的内容紧跟在>>>符号之后,这表示你应在 Python 交互式提示符中输入这些语句。你可以通过在命令提示符中输入python来访问 Python 交互式提示符。

这些基本函数在 Python 2 和 3 中的表现相似。除非另有说明,以下函数调用及其输出都是在 Python 3.7.1 环境下执行的。然而请注意,这些内置函数的用途在不同的 Python 版本中大体相同,输出也非常类似。

这是一个示例:

>>> type('what am I?')
<class 'str'> 

如果我们将一个对象传递给dir()函数,例如str,我们可以看到它的方法和属性。假设我们想知道其中一个函数,title(),是做什么的。我们可以使用help()函数,指定对象和其函数作为输入。

该函数的输出告诉我们不需要输入,输出是一个字符串对象,并且该函数将每个单词的第一个字符大写。让我们在what am I?字符串上使用title方法:

>>> dir(str) 
['__add__', '__class__', '__contains__', '__delattr__',
'__doc__', '__eq__', 
...
'swapcase', 'title', 'translate', 'upper', 'zfill']

>>> help(str.title)
Help on method_descriptor:

title(...)
 S.title() -> str

 Return a titlecased version of S, i.e. words start with title case characters, all remaining cased characters have lower case.

>>> 'what am I?'.title()
'What Am I?' 

接下来,输入number = 5。现在我们创建了一个名为number的变量,它的数值是5。使用type()函数查看该对象时,显示它是一个int(整数)。按照之前的步骤,我们可以看到整数对象的可用属性和函数。通过help()函数,我们可以查看__add__()函数在我们的number对象上执行了什么操作。从以下输出中,我们可以看到,这个函数等同于在两个值之间使用+符号:

>>> number = 5
>>> type(number)
<class 'int'>

>>> dir(number)
>>> ['__abs__', '__add__', __and__', '__class__', '__cmp__', '__coerce__',
...
'denominator', 'imag', 'numerator', 'real']

>>> help(number.__add__)
__add__(...)
x.__add__(y) <==> x+y

让我们比较__add__()函数和+符号之间的区别,以验证我们的假设。使用这两种方法将3加到number对象上时,返回的值是8,如预期那样。不幸的是,我们在演示这个例子时也违反了最佳实践规则:

>>> number.__add__(3)
8
>>> number + 3
8

请注意一些方法,例如__add__(),前后都有双下划线。这些被称为魔术方法,是 Python 解释器调用的方法,不应由程序员直接调用。这些魔术方法是通过用户间接调用的。例如,当在两个数字之间使用+符号时,整数的__add__()魔术方法会被调用。遵循前面的例子,你永远不应该运行number.__add__(3)来代替number + 3

这个规则在一些情况下被打破,我们将在本书中讲解这些情况,不过除非文档推荐使用魔术方法,否则最好避免使用它们。

Python 和其他编程语言一样,有特定的语法。与其他常见的编程语言相比,Python 更像英语,可以在脚本中相对轻松地阅读。这一特点吸引了许多人,包括法医学社区,使用这种语言。尽管 Python 的语言易于阅读,但它不容小觑,因为它功能强大并支持常见的编程范式。

大多数程序员从一个简单的Hello World脚本开始,这是一个测试,证明他们能够执行代码并将著名的消息打印到控制台窗口。在 Python 中,打印这个语句的代码是一行,如下所示,写在文件的第一行:

001 print("Hello World!")

请注意,当讨论脚本中的代码时,与交互式提示符中的代码不同,行号(从 001 开始)仅用于参考。请不要在您的脚本中包含这些行号。此脚本及所有脚本的代码可以在packtpub.com/books/content/support下载。

将这一行代码保存在名为hello.py的文件中。要运行此脚本,我们调用 Python 和脚本的名称。如果你使用的是 Python 3,Hello World!消息应该会显示在你的终端中:

让我们讨论一下为什么这个简单的脚本在某些版本的 Python 2 中无法成功执行。

无处不在的 print() 函数

在 Python 中打印是一项非常常见的技术,因为它允许开发者在脚本执行时将文本显示到控制台。虽然 Python 2 和 3 之间有许多差异,但打印调用方式是最明显的变化,也是我们之前的示例仅能在 Python 3 中运行的原因。到了 Python 3,print 变成了一个函数,而不再是像旧版 Python 2 那样的语句。让我们回顾一下之前的脚本,看看有何微小差异。

注意 Python 3 中的以下内容:

001 print("Hello World!")

注意 Python 2 中的以下内容:

001 print "Hello World!"

差异看起来微不足道。在 Python 2 中,print 是一个语句,你不需要将要打印的内容括在圆括号中。说这种差异只是语义上的问题并不公平;然而,目前只需理解,print 根据所使用的 Python 版本写法不同。这种微小变化的后果是,使用 print 作为语句的旧版 Python 2 脚本无法被 Python 3 执行。

在可能的情况下,我们的脚本将兼容 Python 2 和 3 两个版本。虽然由于 print 的差异,看似不可能实现这一目标,但可以通过导入一个名为 __future__ 的特殊 Python 库并将 print 语句更改为函数来实现。为此,我们需要从 __future__ 库中导入 print 函数,然后将所有 print 命令写为 function

以下脚本在 Python 2 和 3 中都能执行:

001 from __future__ import print_function
002 print("Hello World!") 

在上一个截图中,您可以看到在 Python 2.7.15 和 Python 3.7.1 中执行此脚本的结果。

标准数据类型

完成第一个脚本后,是时候理解 Python 的基本数据类型了。这些数据类型与其他编程语言中的类型类似,但通过简单的语法进行调用,详细描述见下表和相关章节。有关 Python 中可用的所有标准数据类型的完整列表,请访问官方文档:docs.python.org/3/library/stdtypes.html

数据类型 描述 示例
Str 字符串 str(), "Hello", 'Hello'
Unicode Unicode 字符 unicode(), u'hello', "world".encode('utf-8')
Int 整数 int(), 1, 55
Float 小数精度整数 float(), 1.0, .032
Bool 布尔值 bool(), True, False
List 元素的列表 list(), [3, 'asd', True, 3]
Dictionary 键值对集合,用于结构化数据 dict(), {'element': 'Mn', 'Atomic Number': 25, 'Atomic Mass': 54.938}
Set 唯一元素的集合 set(), [3, 4, 'hello']
元组 有序元素列表 tuple(), (2, 'Hello World!', 55.6, ['element1'])
文件 一个文件对象 open('write_output.txt', 'w')

我们即将深入了解 Python 中数据类型的使用,建议你根据需要反复阅读这一部分以帮助理解。虽然阅读数据类型如何处理很重要,但请确保在你第一次操作时使用可以运行 Python 的计算机。我们鼓励你在解释器中进一步探索数据类型并进行测试,看看它们能做些什么。

你会发现,我们的大多数脚本都可以仅使用 Python 提供的标准数据类型来完成。在我们查看其中一种最常见的数据类型——字符串之前,我们将介绍注释。

总是有人说,而且永远说不够的一点是:注释你的代码。在 Python 中,注释是由任何以井号(也就是现在称为“话题标签”)#符号开头的行形成的。当 Python 遇到这个符号时,它会跳过该行的其余部分,继续到下一行。对于跨多行的注释,我们可以使用三个单引号或双引号来标记注释的开始和结束,而不是为每一行都使用单个井号符号。以下是名为 comments.py 文件中不同类型注释的示例。运行此脚本时,我们只会看到 10 打印到控制台,因为所有的注释都被忽略了:

# This is a comment
print(5 + 5) # This is an inline comment.
# Everything to the right of the # symbol
# does not get executed
"""We can use three quotes to create 
multi-line comments."""  

输出如下:

当执行此代码时,我们只会看到前面的输出在控制台上显示。

字符串与 Unicode

字符串是一种包含任何字符的数据类型,包括字母数字字符、符号、Unicode 和其他编码。由于字符串可以存储大量信息,毫不奇怪它们是最常见的数据类型之一。字符串出现的常见场景包括命令行读取参数、用户输入、文件中的数据以及输出数据。首先,让我们来看一下如何在 Python 中定义一个字符串。

创建字符串有三种方式:使用单引号、双引号或内建的 str() 构造方法。请注意,单引号和双引号的字符串没有区别。能够通过多种方式创建字符串是有优势的,因为它允许我们在字符串中区分有意使用的引号。例如,在 'I hate when people use "air-quotes"!' 字符串中,我们使用单引号来标记主字符串的开始和结束,字符串中的双引号不会导致 Python 解释器出现问题。让我们通过 type() 函数来验证,单引号和双引号创建的是相同类型的对象:

>>> type('Hello World!')
<class 'str'>
>>> type("Foo Bar 1234")
<class 'str'> 

正如我们在注释中所看到的,可以通过三个单引号或双引号来定义块字符串,从而创建多行字符串。唯一的区别是是否对块引号值进行操作:

>>> """This is also a string""" 
This is also a string
>>> '''it 
 can span 
 several lines''' 
it\ncan span\nseveral lines 

返回行中的\n字符表示换行或新的一行。在解释器中,输出会显示这些换行符为\n,但当它被输入到文件或控制台时,会创建一个新行。\n字符是 Python 中的常见转义字符之一。转义字符由反斜杠和特定字符组合表示。其他常见的转义字符包括\t表示水平制表符,\r表示回车符,\'\"\\分别表示字面上的单引号、双引号和反斜杠等。字面字符让我们能够使用这些字符,而不会无意中触发它们在 Python 上下文中的特殊含义。

我们还可以使用加法(+)或乘法(*)运算符对字符串进行操作。加法运算符用于连接字符串,而乘法运算符则会重复提供的字符串值:

>>> 'Hello' + ' ' + 'World'
Hello World
>>> "Are we there yet? " * 3
Are we there yet? Are we there yet? Are we there yet?

让我们来看一些常见的字符串操作函数。我们可以使用strip()函数从字符串的开头或结尾删除字符。strip()函数需要我们提供要删除的字符作为输入,否则默认会删除空白字符。类似地,replace()函数接受两个输入:要替换的字符和替换后的字符。这两个函数的主要区别在于,strip()只处理字符串的开头和结尾:

# This will remove colon (`:`) from the beginning and end of the line
>>> ':HelloWorld:'.strip(':')
HelloWorld

# This will remove the colon (`:`) from the line and place a 
# space (` `) in it's place
>>> 'Hello:World'.replace(':', ' ')
Hello World 

我们可以使用in语句检查某个字符或字符是否存在于字符串中。或者,我们可以更具体地检查字符串是否startswith()endswith()某个特定字符(你知道一个语言是否容易理解,就看你能否用函数创造出通顺的句子)。这些方法会返回TrueFalse布尔值:

>>> 'a' in 'Chapter 2'
True
>>> 'Chapter 1'.startswith('Chapter')
True
>>> 'Chapter 1'.endswith('1')
True 

我们可以根据某些分隔符快速将一个字符串拆分为一个列表。这对于将以分隔符分隔的数据快速转换为列表非常有帮助。例如,逗号分隔值CSV)数据是以逗号分隔的,可以在该值上进行拆分:

>>> print("Hello, World!".split(','))
["Hello", " World!"] 

格式化参数可以应用于字符串,以根据提供的值操作和转换它们。使用.format()函数,我们可以将值插入到字符串中、填充数字并显示简单格式的模式。本章将展示一些.format()方法的示例,后续章节会介绍它的更复杂功能。.format()方法按照顺序用提供的值替换大括号中的内容:

这是将值动态插入字符串中的最基本操作:

>>> "{} {} {} {}".format("Formatted", "strings", "are", "easy!")
'Formatted strings are easy!'

我们的第二个示例展示了一些可以用来操作字符串的表达式。在大括号内,我们放置一个冒号,表示我们将为解释指定一个格式。在冒号后,我们指定应该打印至少六个字符。如果提供的输入不足六个字符,我们会在输入的前面添加零。最后,d字符表示输入将是一个十进制数:

>>> "{:06d}".format(42)
'000042' 

我们最后的例子演示了如何通过设置填充字符为等号符号,并加上插入符号(以将符号居中显示),以及重复符号的次数,轻松打印出一串20个等号字符。通过提供这个格式化字符串,我们可以快速在输出中创建可视化分隔符:

>>> "{:=²⁰}".format('')
'====================' 

虽然我们将介绍.format()方法的更多高级特性,但pyformat.info/网站是学习 Python 字符串格式化能力的一个很好的资源。

整数和浮动数

整数是另一种常用的有价值数据类型——整数是任何完整的正数或负数。浮动数数据类型类似,但允许我们使用需要小数精度的数字。通过整数和浮动数,我们可以使用标准的数学运算,如:+-*/。这些运算会根据对象的类型(例如,integerfloat)返回稍微不同的结果。

整数使用整数和四舍五入运算,例如,两个整数相除将得到另一个整数。但如果方程式中使用了浮动数,即使它的值与整数相同,结果也会是浮动数;例如,在 Python 中,3/2=13/2.0=1.5。以下是整数和浮动数运算的示例:

>>> type(1010)
<class 'int'>
>>> 127*66
8382
>>> 66/10
6
>>> 10 * (10 - 8)
20 

我们可以使用**将整数提升为某个幂。例如,在接下来的部分中,我们将11提高到2的幂。在编程中,确定两个整数之间除法的结果(即分子)是有帮助的。为此,我们使用模运算符或百分号(%)。在 Python 中,负数是带有负号字符(-)的数值。我们可以使用内置的abs()函数来获取整数或浮动数值的绝对值:

>>> 11**2
121
>>> 11 % 2 # 11 divided by 2 is 5.5 or 5 with a remainder of 1
1
>>> abs(-3)
3

浮动类型(float)是由任何带有小数的数字定义的。浮动类型遵循与整数相同的规则和操作,唯一的例外是之前提到的除法行为:

>>> type(0.123)
<class 'float'>
>>> 1.23 * 5.23
6.4329
>>> 27/8.0
3.375

布尔值和空值

整数01也可以在 Python 中表示布尔值。这些值分别是布尔值FalseTrue对象。要定义布尔值,我们可以使用bool()构造函数语句。这些数据类型在程序逻辑中被广泛使用,用于评估条件语句,如本章后面所述。

另一个内置的数据类型是空值类型,它由关键字None定义。当使用时,它表示一个空对象,并且当评估时返回False。这在初始化一个可能在执行过程中使用多种数据类型的变量时很有用。通过赋予一个空值,变量在重新赋值之前保持清洁:

>>> bool(0)
False
>>> bool(1)
True
>>> None
>>> 

结构化数据类型

有几种更复杂的数据类型,允许我们创建原始数据的结构。这些包括列表、字典、集合和元组。大多数这些结构是由前述的数据类型组成的。这些结构在创建强大的值单元时非常有用,可以以可管理的方式存储原始数据。

列表

列表是一个有序的元素集合。列表支持任何数据类型作为元素,并会保持数据在添加到列表时的顺序。元素可以通过位置调用,也可以使用循环逐个访问每个项目。在 Python 中,不像其他语言,打印一个列表只需要一行代码。在像 Java 或 C++ 这样的语言中,打印一个列表可能需要三行或更多的代码。Python 中的列表可以根据需要任意长,并且可以动态扩展或收缩,这是其他语言中不常见的特性。

我们可以通过使用方括号并用逗号分隔元素来创建列表。或者,我们可以使用 list() 类构造函数并传入一个可迭代对象。列表元素可以通过索引访问,其中 0 是第一个元素。要通过位置访问元素,我们将所需的索引放在方括号内,紧跟在列表对象后面。我们不需要知道列表的长度(可以通过 len() 函数获取),可以使用负数索引来根据列表末尾访问元素(即,-3 会获取倒数第三个元素):

>>> type(['element1', 2, 6.0, True, None, 234])
<class 'list'>
>>> list((4, 'element 2', None, False, .2))
[4, 'element 2', None, False, 0.2]
>>> len([0,1,2,3,4,5,6])
7
>>> ['hello_world', 'foo bar'][0]
hello_world
>>> ['hello_world', 'foo_bar'][-1]
foo_bar 

我们可以使用几种不同的函数来添加、删除或检查一个值是否在列表中。append() 方法将数据添加到列表的末尾。或者,insert() 方法允许我们在添加数据到列表时指定索引。例如,我们可以将字符串 fish 添加到列表的开头,或者 0 索引位置:

>>> ['cat', 'dog'].append('fish')
# The list becomes: ['cat', 'dog', 'fish']
>>> ['cat', 'dog'].insert(0, 'fish')
# The list becomes: ['fish', 'cat', 'dog']  

pop()remove() 函数可以分别通过索引或特定对象从列表中删除数据。如果在 pop() 函数中没有提供索引,则默认弹出列表中的最后一个元素。需要注意的是,remove() 函数只会删除列表中第一个匹配的对象:

>>> [0, 1, 2].pop()
2
# The list is now [0, 1]

>>> [3, 4, 5].pop(1)
4
# The list is now [3, 5]
>>> [1, 1, 2, 3].remove(1)
# The list becomes: [1, 2, 3] 

我们可以使用 in 语句来检查某个对象是否在列表中。count() 函数告诉我们某个对象在列表中出现了多少次:

>>> 'cat' in ['mountain lion', 'ox', 'cat']
True
>>> ['fish', 920.5, 3, 5, 3].count(3)
2 

如果我们想访问元素的子集,可以使用列表切片表示法。其他对象,如字符串,也支持这种切片表示法来获取数据的子集。切片表示法具有以下格式,其中 a 是我们的列表或字符串对象:

a[x:y:z]

在上述示例中,x表示切片的起始位置,y表示切片的结束位置,z表示切片的步长。请注意,每个部分由冒号分隔并括在方括号中。负步长是快速反转支持切片表示法的对象内容的方式,并由负数*z*触发。每个参数都是可选的。在第一个示例中,我们的切片返回从第二个元素到第五个元素(但不包括第五个元素)的子列表。只使用这些切片元素中的一个,则会返回一个包含从第二个索引开始或到第五个索引为止的所有元素的列表:

>>> [0,1,2,3,4,5,6][2:5]
[2, 3, 4]
>>> [0,1,2,3,4,5,6][2:]
[2, 3, 4, 5, 6]
>>> [0,1,2,3,4,5,6][:5]
[0, 1, 2, 3, 4] 

使用第三种切片元素,我们可以跳过每个其他元素,或者简单地通过负数反转列表。我们可以通过组合这些切片元素来指定如何从列表中提取数据子集:

>>> [0,1,2,3,4,5,6][::2]
[0, 2, 4, 6]
>>> [0,1,2,3,4,5,6][::-1]
[6, 5, 4, 3, 2, 1, 0]  

字典

字典,也称为dict,是另一种常见的 Python 数据容器。与列表不同,这种对象不会按线性方式添加数据。相反,数据以键值对的形式存储,您可以创建和命名唯一的键,以便作为存储值的索引。需要注意的是,在 Python 2 中,字典不会保留添加项的顺序。而从 Python 3.6.5 开始,字典会保持插入顺序,尽管通常我们不应依赖dict()对象为我们维持顺序。这些对象在取证脚本中被大量使用,因为它们允许我们通过名称在单一对象中存储数据;否则,我们可能不得不分配许多新变量。通过将数据存储在字典中,我们可以使一个变量包含非常结构化的数据。

我们可以通过使用大括号({})来定义字典,其中每个键值对由冒号分隔。此外,我们还可以使用dict()类构造函数来实例化字典对象。调用字典中的值是通过在字典对象后指定键并放在方括号中完成的。如果我们提供一个不存在的键,则会收到KeyError(请注意我们将字典赋值给了一个变量a)。虽然我们尚未介绍变量,但需要突出一些特定于字典的函数:

>>> type({'Key Lime Pie': 1, 'Blueberry Pie': 2})
<class 'dict'>
>>> dict((['key_1', 'value_1'],['key_2', 'value_2']))
{'key_1': 'value_1', 'key_2': 'value_2'}
>>> a = {'key1': 123, 'key2': 456}
>>> a['key1']
123 

我们可以通过指定一个键并将其设置为另一个对象来添加或修改字典中现有键的值。我们可以使用pop()函数删除对象,类似于列表的pop()函数,通过指定键而不是索引来从字典中删除项:

>>> a['key3'] = 789
>>> a
{'key1': 123, 'key2': 456, 'key3': 789}
>>> a.pop('key1')
123
>>> a
{'key2': 456, 'key3': 789} 

keys()values()函数返回字典中键和值的列表。我们可以使用items()函数返回包含每个键值对的元组列表。这三个函数通常用于条件语句和循环:

>>> a.keys()
dict_keys(['key2', 'key3'])
>>> a.values()
dict_values([456, 789])
>>> a.items()
dict_items([('key3', 789), ('key2', 456)])

集合和元组

集合与列表类似,它们包含一组元素,但集合中的元素必须是唯一的。因此,元素必须是不可变的,这意味着其值必须保持恒定。因此,集合最适合用于整数、字符串、布尔值、浮动值和元组作为元素。集合不对元素进行索引,因此我们不能通过它们在set中的位置访问元素。相反,我们可以通过使用与列表方法相同的pop()方法来访问和移除元素。元组也类似于列表,但它们是不可变的。使用括号而非方括号构建,元素不必是唯一的,可以是任何数据类型:

>>> type(set([1, 4, 'asd', True]))
<class 'set'>
>>> g = set(["element1", "element2"])
>>> g
{'element1', 'element2'}
>>> g.pop()
'element2'
>>> g
{'element1'}
>>> tuple('foo')
('f', 'o' , 'o')
>>> ('b', 'a', 'r')
('b', 'a', 'r')
>>> ('Chapter1', 22)[0]
Chapter1
>>> ('Foo', 'Bar')[-1]
Bar 

元组和列表的一个重要区别是元组是不可变的。这意味着我们不能改变元组对象。相反,我们必须完全替换该对象,或者将其转换为可变的列表。这个转换过程将在下一节中描述。替换对象非常慢,因为向元组添加值的操作是tuple = tuple + ('新值',),请注意,尾随的逗号是必需的,用于表示这是一个元组的添加操作。

数据类型转换

在某些情况下,初始数据类型可能不是所需的数据类型,并且需要在保留其内容的同时进行更改。例如,当用户从命令行输入参数时,这些输入通常会作为字符串捕获,有时这些用户输入需要变成整数。我们需要使用整数类构造函数来转换该字符串对象,然后再处理数据。假设我们有一个简单的脚本,它返回用户提供的整数的平方;我们需要先将用户输入转换为整数,然后再计算平方。最常见的数据类型转换方法之一是使用构造函数方法包装变量或字符串,如下所示,适用于每种数据类型:

>>> int('123456') # The string 123456
123456 # Is now the integer 123456
>>> str(45) # The integer 45
'45' # Is now the string 45
>>> float('37.5') # The string 37.5
37.5 # Is now the float 37.5 

无效的转换,例如将字母'a'转换为整数,将引发ValueError。该错误将指出指定的值无法转换为所需的类型。在这种情况下,我们需要使用内建的ord()方法,它将字符转换为基于 ASCII 值的整数等效值。在其他情况下,我们可能需要使用其他方法在数据类型之间进行转换。以下是我们可以在大多数场景中使用的常见内建数据类型转换方法的表格:

方法 描述
str()int()float()dict()list()set()tuple() 类构造函数方法
hex()oct() 将整数转换为 16 进制(hex)或 8 进制(octal)表示
chr()unichr() 将整数转换为 ASCII 或 Unicode 字符
ord() 将字符转换为整数

我们还可以互换列表、集合和元组类型中的有序集合或类型。由于集合对插入的数据有要求,通常我们不会将任何东西强制转换为集合。相反,更常见的做法是将集合转换为列表,以便按位置访问值:

>>> tuple_1 = (0, 1, 2, 3, 3)
>>> tuple_1
(0, 1, 2, 3, 3)
>>> set_1 = set(tuple_1)
>>> set_1
{0, 1, 2, 3}
>>> list_1 = list(tuple_1)
>>> list_1
[0, 1, 2, 3, 3]
>>> list_2 = list(set_1)
>>> list_2
[0, 1, 2, 3]

文件

我们经常创建文件对象来从文件中读取或写入数据。文件对象可以使用内置的 open() 方法创建。open() 函数接受两个参数:文件名和模式。这些模式决定了我们如何与文件对象进行交互。模式参数是可选的,如果未指定,则默认为只读模式。以下表格列出了可用的不同文件模式:

文件模式 描述
r 以只读模式打开文件(默认模式)。这并不提供法医写保护!请始终使用经过认证的过程来保护证据不被修改。
w 如果文件存在,则创建或覆盖该文件进行写入。
a 如果文件不存在,则创建该文件以进行写入。如果文件存在,则将文件指针置于文件末尾以附加写入内容。
rb, wb, 或 ab 以二进制模式打开文件进行读写。
r+, rb+, w+, wb+, a+, 或 ab+ 以标准模式或二进制模式打开文件进行读写。如果文件不存在,wa 模式会创建文件。

我们最常使用标准模式或二进制模式进行读写。让我们来看几个示例以及可能使用的一些常见函数。在本节中,我们将创建一个名为 file.txt 的文本文件,内容如下:

This is a simple test for file manipulation.
We will often find ourselves interacting with file objects.
It pays to get comfortable with these objects.

在以下示例中,我们打开一个已存在的文件对象 file.txt,并将其赋值给变量 in_file。由于未提供文件模式,文件默认以只读模式打开。我们可以使用 read() 方法将所有行作为一个连续的字符串读取。readline() 方法可用于逐行读取字符串。或者,readlines() 方法会为每一行创建一个字符串,并将其存储在列表中。这些函数接受一个可选参数,指定要读取的字节数。

readline()readlines() 函数使用 \n\r 换行符将文件的行分段。这对于大多数文件来说是有效的,但根据输入数据的不同,可能并不总是适用。例如,包含多行内容在单一单元格中的 CSV 文件,使用此类文件读取接口时可能无法正确显示。

Python 会跟踪我们在文件中的当前位置。为了说明我们描述的例子,我们需要使用seek()操作将光标移回文件开头,然后再运行下一个示例。seek()操作接受一个数字并将光标移动到该文件中的字符偏移量。例如,如果我们在没有将光标移动回文件开头的情况下使用read()方法,接下来的打印函数(展示readline()方法)将不会返回任何内容。这是因为光标在使用read()方法后已经位于文件的末尾:

>>> in_file = open('file.txt')
>>> print(in_file.read())
This is a simple test for file manipulation.
We will often find ourselves interacting with file objects.
It pays to get comfortable with these objects.
>>> in_file.seek(0)
>>> print(in_file.readline())
This is a simple test for file manipulation.
>>> in_file.seek(0)
>>> print(in_file.readlines())
['This is a simple test for file manipulation.\n', 'We will often find ourselves interacting with file objects.\n', 'It pays to get comfortable with these objects.'] 

类似地,我们可以使用w文件模式来创建、打开并覆盖现有文件。我们可以使用write()函数写入单个字符串,或使用writelines()方法将任何可迭代对象写入文件。writelines()函数本质上是对可迭代对象的每个元素调用write()方法。

例如,这相当于对列表的每个元素调用write()方法:

>>> out_file = open('output.txt', 'w')
>>> out_file.write('Hello output!')
>>> data = ['falken', 124, 'joshua']
>>> out_file.writelines(data) 

Python 能够自动很好地关闭文件对象的连接。然而,最佳实践要求我们在写入数据到文件后,应该使用flush()close()方法。flush()方法将缓冲区中剩余的数据写入文件,而close()方法则关闭与文件对象的连接:

>>> out_file.flush()
>>> out_file.close() 

变量

我们可以使用刚才介绍的数据类型为变量赋值。通过给变量赋值,我们可以通过变量名引用该值,无论它是一个包含 100 个元素的大列表。这不仅避免了程序员一遍又一遍地重复输入相同的值,还增强了代码的可读性,并且使得我们能够随着时间的推移更改变量的值。在本章中,我们已经通过=符号为变量赋值。技术上讲,变量名可以是任何东西,但我们建议遵循以下准则:

  • 变量名应该简短并且描述存储的内容或目的。

  • 变量名应以字母或下划线开头。

  • 常量变量应由大写字母组成。

  • 动态变量应该是由下划线分隔的小写字母单词。

  • 变量名永远不要是以下保留字或任何 Python 保留的名称:inputoutputtmptempinfornextfileTrueFalseNonestrintlist

  • 变量名中永远不要包含空格。Python 会认为定义了两个变量,并会抛出语法错误。使用下划线来分隔单词。

通常,程序员使用易记且具有描述性的名称,以表明它们所包含的数据。例如,在一个提示用户输入电话号码的脚本中,变量应为phone_number,这清楚地表明了该变量的目的和内容。另一种流行的命名风格是CamelCase,其中每个单词的首字母大写。这种命名约定通常与类名一起使用(本书稍后会介绍)。

变量赋值允许在脚本运行时修改值。一般的经验法则是,如果一个变量会再次使用,就将一个值分配给它。让我们通过创建变量并为其分配我们刚学到的数据类型来练习。虽然这很简单,但我们建议在交互式提示中跟着做,以养成分配变量的习惯。在这里的第一个示例中,我们将一个字符串分配给变量,然后打印该变量:

>>> print(hello_world)
Hello World! 

第二个示例引入了一些新的运算符。首先,我们将整数 5 分配给变量 our_number。然后,我们使用加法赋值运算符 (+=),作为 our_number = our_number + 20 的简写形式。除了加法赋值外,还有减法赋值 (-=)、乘法赋值 (*=) 和除法赋值 (/=):

>>> our_number = 5
>>> our_number += 20
>>> print(our_number)
25 

在以下代码块中,我们在打印之前分配了一系列变量。我们为变量使用的数据类型分别是 stringintegerfloatlistBoolean

>>> BOOK_TITLE = 'Learning Python for Forensics'
>>> edition = 2
>>> python2_version = 2.7.15
>>> python3_version = 3.7.1
>>> AUTHOR_NAMES = ['Preston Miller', 'Chapin Bryce']
>>> is_written_in_english = True
>>> print(BOOK_TITLE)
'Learning Python for Forensics'
>>> print(AUTHOR_NAMES)
['Preston Miller', 'Chapin Bryce']
>>> print(edition)
1
>>> print(python2_version)
2.7.15
>>> print(is_written_in_english)
True

注意 BOOK_TITLEAUTHOR_NAMES 变量。当一个变量是静态的,比如在脚本执行过程中不发生变化时,它被称为常量变量。与其他编程语言不同,Python 没有内置的保护常量不被覆盖的方法,因此我们使用命名约定来提醒自己不要替换其值。虽然一些变量如书籍的版本、语言或 Python 的版本可能会变化,但标题和作者应该是常量(我们希望如此)。如果在命名和样式约定上存在困惑,可以尝试在解释器中运行以下语句:

>>> import this  

如我们之前所见,我们可以对字符串使用 split() 方法将其转换为列表。我们还可以使用 join() 方法将列表转换为字符串。该方法包含一个包含所需公分母的字符串和列表作为唯一参数。在以下示例中,我们取一个包含两个字符串的列表,并将它们合并成一个字符串,元素之间由逗号分隔:

>>> print(', '.join(["Hello", "World!"]))
Hello, World!

理解脚本流程逻辑

流程控制逻辑允许我们通过根据一系列情况指定不同的程序执行路线来创建动态操作。在任何有价值的脚本中,都会有某种形式的流程控制。例如,创建一个根据用户选择的选项返回不同结果的动态脚本时,就需要流程逻辑。在 Python 中,有两种基本的流程逻辑:条件语句和循环语句。

流程运算符通常与流程逻辑一起使用。这些运算符可以串联在一起,创建更复杂的逻辑。下表展示了一个 真值表,并说明了基于 AB 变量布尔状态的各种流程运算符的值:

A B A 和 B A 或 B 非 A 非 B
F F F F T T
T F F T F T
F T F T T F
T T T T F F

逻辑 ANDOR 运算符是表格中的第三和第四列。只有当 AB 都为 True 时,AND 运算符才会返回 True。对于 OR 运算符,只需要其中一个变量为 True,它就会返回 Truenot 运算符只是将变量的布尔值切换为其相反值(例如,True 变为 False,反之亦然)。

掌握条件语句和循环将使我们的脚本达到一个新的层次。其核心是,流程逻辑仅依赖于两个值:TrueFalse。如前所述,在 Python 中,这两个值由布尔类型 TrueFalse 表示。

条件语句

当脚本遇到条件语句时,就像是站在一条岔路口。根据某些因素,比如更有前景的远方,你可能决定朝东而不是朝西走。计算机逻辑不那么任意,如果某件事为真,脚本就会按一种方式执行,如果为假,则按另一种方式执行。这些分岔口非常关键;如果程序决定偏离我们为它设计的路径,我们就会陷入严重的麻烦。

有三个语句用于构成条件块:ifelifelse。条件块指的是条件语句、它们的流程逻辑和代码。一个条件块以 if 语句开始,后面跟着流程逻辑、冒号和缩进的代码行。如果流程逻辑计算结果为 True,那么 if 语句后面缩进的代码将会被执行。如果计算结果不是 TruePython 虚拟机PVM)将跳过这些代码行并转到与 if 语句相同缩进级别的下一行。这通常是相应的 elif(else-if)或 else 语句。

在 Python 中,缩进非常重要。它用于标识在条件语句或循环中要执行的代码。本书中采用了四个空格的缩进标准,尽管你可能会遇到使用两个空格或使用制表符的代码。虽然这三种做法在 Python 中都被允许,但四个空格的缩进更受推崇,且更容易阅读。

在一个条件块中,一旦某个语句计算结果为 True,代码就会被执行,且 PVM 会退出该块,而不再评估其他语句。

# Conditional Block Pseudocode
if [logic]:
    # Line(s) of indented code to execute if logic evaluates to True.
elif [logic]:
    # Line(s) of indented code to execute if the 'if' 
    # statement is false and this logic is True.
else:
    # Line(s) of code to catch all other possibilities if
    # the 'if' and 'elif' statements are all False.

在我们定义函数之前,我们将坚持使用简单的 if 语句示例:

>>> a = 5
>>> b = 22
>>> a > 0
True
>>> a > b
False
>>> if a > 0:
...     print(str(a) + ' is greater than zero!')
...
5 is greater than zero!
>>> if a >= b:
...     print(str(a) + ' beats ' + str(b))
...
>>> 

注意,当流程逻辑计算结果为 True 时,if 语句后面缩进的代码会被执行。当其结果为 False 时,代码会被跳过。通常,当 if 语句为假时,你会有一个辅助语句,比如 elifelse,用于捕捉其他可能性,例如当 a 小于或等于 b 时。然而,值得注意的是,我们可以只使用 if 语句,而不使用任何 elifelse 语句。

ifelif 之间的区别很微妙。只有在我们使用多个 if 语句时,才能明显感觉到区别。elif 语句允许在第一个条件不成功时评估第二个条件。而第二个 if 语句会在第一个 if 语句的结果无论如何都被执行。

else 语句不需要任何流程逻辑,可以作为一种通用情况处理任何剩余的或未处理的情况。然而,这并不意味着在执行 else 语句中的代码时不会发生错误。不要依赖 else 语句来处理错误。

可以通过使用逻辑运算符 andor 来使条件语句更具综合性。这些运算符允许在单个条件语句中实现更复杂的逻辑:

>>> a = 5
>>> b = 22
>>> if a > 4 and a < b:
...     print('Both statements must be true to print this')
...
Both statements must be true to print this
>>> if a > 10 or a < b:
...     print('One of these statements must be true to print this')
...
Only one of these statements must be true to print this 

以下表格有助于理解常见操作符的工作方式:

操作符 描述 示例 结果
<, > 小于,大于 8 < 3 False
<=, >= 小于等于,大于等于 5 =< 5 True
==, != 等于,不等于 2 != 3 True
not 切换布尔值 not True False

循环

循环提供了另一种流程控制的方法,适用于执行迭代任务。循环会重复执行包含的代码,直到提供的条件不再为True或出现退出信号。有两种类型的循环:forwhile。对于大多数迭代任务,for 循环通常是最合适的选择。

for 循环

for 循环是最常见的循环方式,在大多数情况下,它是执行重复任务的首选方法。想象一下一个工厂流水线;对于传送带上的每个物品,都可以使用 for 循环对其执行某项任务,比如给物品贴上标签。通过这种方式,多个 for 循环可以在流水线的形式下协同工作,处理每个物品,直到它们准备好展示给用户。

和 Python 中的其他部分一样,for 循环在语法上非常简单,但功能强大。在一些语言中,for 循环需要初始化、计数器以及终止条件。而 Python 的 for 循环则更加动态,能够自动处理这些任务。这些循环包含缩进的代码,按行执行。如果被迭代的对象仍然有元素(例如,更多需要处理的项目),则 PVM 会将执行指针移回到循环的开头,并重复执行代码。

for 循环的语法会指定要迭代的对象,并定义如何调用对象中的每个元素。请注意,迭代对象必须是可迭代的。例如,listssetstuplesstrings 都是可迭代的,但整数不是。在下面的例子中,我们可以看到 for 循环如何处理字符串和列表,并帮助我们迭代可迭代对象中的每个元素:

>>> for character in 'Python':
...      print(character)
...
P
y
t
h
o
n
>>> cars = ['Volkswagon', 'Audi', 'BMW']
>>> for car in cars:
...      print(car)
...
Volkswagon
Audi
BMW 

还有其他更高级的方式来调用 for 循环。可以使用 enumerate() 函数来开始一个索引。当你需要跟踪当前循环的索引时,这个方法很有用。索引会在循环开始时递增。第一个对象的索引是 0,第二个是 1,依此类推。range() 函数可以执行一定次数的循环,并提供索引:

>>> numbers = [5, 25, 35]
>>> for i, x in enumerate(numbers):
...     print('Item', i, 'from the list is:', x)
...
Item 0 from the list is: 5
Item 1 from the list is: 25
Item 2 from the list is: 35
>>> for x in range(0, 100):
...     print(x)
0
1
# continues to print 0 to 100 (omitted in an effort to save trees)

while 循环

while 循环在 Python 中的出现频率较低。while 循环会在某个条件为真时一直执行。最简单的 while 循环就是 while True 语句。这种循环会永远执行,因为布尔值 True 始终为 True,所以缩进的代码会不断执行。

如果你不小心,可能会不经意地创建一个无限循环,这会破坏你脚本的预期功能。必须利用条件语句来覆盖所有的情况,如 ifelifelse 语句。如果没有做到这一点,脚本可能会进入一个无法预料的情况并崩溃。这并不是说 while 循环不值得使用。while 循环非常强大,并且在 Python 中有它自己的作用。

>>> guess = 0
>>> answer = 42
>>> while True:
...     if guess == answer:
...          print('You've found the answer to this loop: ' + str(answer))
...          break
...     else:
...          print(guess, 'is not the answer.')
...          guess += 1 

breakcontinuepass 语句与 forwhile 循环一起使用,可以创建更动态的循环。break 用于退出当前循环,而 continue 语句会导致 PVM 从循环的开头开始执行代码,跳过 continue 语句后的任何缩进代码。pass 语句字面上什么都不做,它作为占位符。如果你敢于尝试,或者无聊,或者更糟,二者兼而有之,移除前一个例子中的 break 语句,看看会发生什么。

函数

函数是创建更复杂 Python 代码的第一步。从高层次来看,它们是可以打包成可调用代码块的 Python 代码容器。一个简单的模型函数需要一个输入,对提供的数据进行操作,并返回一个输出。然而,这很快会变得更复杂,因为函数可以在没有输入或有可选输入的情况下运行,或者根本不需要返回输出。

函数是任何编程语言的一个重要组成部分,并且在本章中已经多次出现。例如,list.append() 中的 append 是一个需要输入以添加到列表中的函数。函数一旦创建,你可以通过它的名称调用它,并传递任何需要的输入。

在编写函数时,多一些总是更好的。相比于一个大型函数,处理和排查程序中的 bug 要容易得多,尤其是当程序有许多小型函数时。小函数使得代码更具可读性,也更容易找到问题逻辑。话虽如此,函数应该包含单一目的的代码,例如访问注册表文件中的某个键。没有必要为脚本中的每一行代码都创建函数。可以将函数视为逻辑代码块。有时它可能只有三行,有时则有 50 行;重要的是,函数的目的和操作应当清晰。

函数语法以定义开始,def,后面跟着函数名、括号内的输入参数以及冒号。按照这个格式,后面是缩进的代码行,当函数被调用时这些代码会执行。可选地,函数可以有一个返回语句,将信息传递回调用它的实例:

>>> def simple_function():
...      print('I am a simple function')
...
>>> simple_function()
I am a simple function 

在我们刚才看到的例子中,我们创建了一个名为simple_function()的函数,它不接收任何输入。这个函数不会返回任何东西,而是打印一个字符串。接下来,让我们看看更复杂的例子。

我们的第一个函数,square(),接收一个输入并对其进行平方。由于这个函数会返回一个值,因此我们在调用该函数时将其赋值给一个变量来捕获返回值。这个变量,squared_number,将等于函数返回的值。虽然这是一个非常简洁的函数,但如果给它传入错误的输入,函数会很容易出错。传入一个其他数据类型(如字符串)时,你将会收到一个TypeError

>>> def square(x):
...     return x**2
...
>>> squared_number = square(4)
>>> print(squared_number)
16

我们的第二个函数,even_or_odd,稍微复杂一点。这个函数首先检查传入的参数是否为整数类型。如果不是,它会立即返回并退出。如果是整数,它会执行一些逻辑,向用户显示该整数是偶数还是奇数。注意,当我们尝试给函数传入字符串'5'(与整数5不同)时,它什么也不返回,而在square函数中,由于缺乏输入验证检查,这将导致错误:

>>> def even_or_odd(value):
...     if isinstance(value, int):
...         if value % 2 == 0:
...               print('This number is even.')
...         else:
...              print('This number is odd.')
...      else:
...          return
...
>>> values = [1, 3, 4, 6, '5']
>>> for value in values:
...     even_or_odd(value)
...
This number is odd.
This number is odd.
This number is even.
This number is even.

渴望成为开发者的人应该养成写函数的习惯。像往常一样,函数应当有良好的注释,以帮助解释其目的。函数将在本书中频繁使用,尤其是在我们开始开发法医脚本时。

总结

本章涵盖了广泛的入门内容,为本书的后续章节提供了基础;到最后,你将熟练掌握 Python 开发。这些主题已被精心挑选,作为理解语言的基本内容,供我们向前推进时使用。我们已经涵盖了数据类型,它们是什么以及何时使用,变量命名及其相关规则和准则,逻辑与操作,基于值进行决策并进行处理,以及条件和循环,它们为我们的脚本提供了顺序组织,并构成了我们开发的基线。此项目的代码可以从 GitHub 或 Packt 下载,如前言所述。

请考虑重新阅读本章,并多次练习示例以帮助理解。就像任何事情一样,学习一门新语言需要大量的练习。

仅通过这些特性,我们就能创建基本的脚本。Python 是一种非常强大且复杂的语言,尽管其语法看起来简单。接下来的章节,我们将探讨更复杂的基础内容,并在此章节中建立的知识基础上继续扩展,然后再进行现实世界的例子。

第二章:Python 基础知识

我们已经探索了 Python 的基本概念和构建脚本所使用的基本元素。现在,我们将通过本书中的一系列脚本,使用我们在第一章中讨论的数据类型和内置函数。在开始开发脚本之前,让我们基于已有知识,再深入了解 Python 语言的一些其他重要特性。

本章将探索我们在构建取证 Python 脚本时将使用的更多高级特性。这些包括复杂的数据类型和函数、创建我们的第一个脚本、处理错误、使用库、与用户互动以及一些开发的最佳实践。完成本章后,我们将准备好进入实际案例,展示 Python 在取证工作中的应用。

本章将涵盖以下主题:

  • 高级特性,包括迭代器和 datetime 对象

  • 安装和使用模块

  • 使用 tryexceptraise 语句进行错误处理

  • 验证和访问用户提供的数据

  • 创建取证脚本以查找 USB 厂商和产品信息

高级数据类型和函数

本节重点介绍 Python 中的两个常见特性——迭代器和 datetime 对象,这些特性在取证脚本中将经常遇到。因此,我们将更详细地介绍这些对象和功能。

迭代器

你之前学习过几种可迭代对象,例如 listssetstuples。在 Python 中,如果定义了 __iter__ 方法,或者可以按顺序访问元素,那么一个数据类型就被认为是迭代器。这三种数据类型(即 listssetstuples)允许我们以简单且高效的方式遍历其内容。因此,我们在遍历文件中的行或目录列表中的文件条目时,或者在根据一系列文件签名识别文件时,经常使用这些数据类型。

iter 数据类型允许我们以不保留初始对象的方式遍历数据。这似乎不太理想;然而,当处理大数据集或在资源有限的机器上工作时,它非常有用。这是因为 iter 数据类型的资源分配方式,其中只有活动数据被存储在内存中。在逐行读取一个 3 GB 文件时,通过每次只加载一行来保持内存分配,避免了大量内存消耗,同时仍然按顺序处理每一行。

这里提到的代码块演示了迭代对象的基本用法。我们在一个可迭代对象上使用next()函数来获取下一个元素。一旦通过next()访问了某个对象,它就不再在iter()中可用,因为游标已经移过了该元素。如果我们已经到达了可迭代对象的末尾,对于任何额外的next()方法调用,我们将收到StopIteration。这个异常允许我们优雅地退出使用迭代器的循环,并提醒我们迭代器中没有更多内容可以读取:

>>> y = iter([1, 2, 3])
>>> next(y)
1
>>> next(y)
2
>>> next(y)
3
>>> next(y)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration 

在 Python 2.7 中,你可以使用obj.next()方法调用,获得与前面的示例相同的输出,方法是使用next()函数。为了简便和一致性,Python 3 将obj.next()重命名为obj.__next__(),并鼓励使用next()函数。因此,推荐使用next(y),如前所示,代替y.next()y.__next__()

reversed()内建函数可用于创建一个反向迭代器。在以下示例中,我们反转一个列表,并使用next()函数从迭代器中获取下一个对象:

>>> j = reversed([7, 8, 9])
>>> next(j)
9
>>> next(j)
8
>>> next(j)
7
>>> next(j)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration 

通过实现生成器,我们可以进一步利用iter数据类型。生成器是一种特殊类型的函数,它生成迭代器对象。生成器与函数相似,如在第一章中讨论的内容,现在开始完全不同的内容——不过,生成器不是返回对象,而是yield迭代器。生成器最适合用于处理大型数据集,这些数据集可能消耗大量内存,这类似于iter数据类型的使用场景。

这里提到的代码块展示了生成器的实现。在file_sigs()函数中,我们创建了一个包含元组的列表,存储在sigs变量中。然后我们遍历sigs中的每个元素,并yield一个tuple数据类型。这创建了一个生成器,使我们可以使用next()函数逐个获取每个元组,从而限制生成器对内存的影响。请参见以下代码:

>>> def file_sigs():
...     sigs = [('jpeg', 'FF D8 FF E0'),
...             ('png', '89 50 4E 47 0D 0A 1A 0A'),
...             ('gif', '47 49 46 38 37 61')]
...     for s in sigs:
...         yield s

>>> fs = file_sigs()
>>> next(fs)
('jpeg', 'FF D8 FF E0')
>>> next(fs)
('png', '89 50 4E 47 0D 0A 1A 0A')
>>> next(fs)
('gif', '47 49 46 38 37 61')

你可以在www.garykessler.net/library/file_sigs.html找到更多文件签名。

datetime 对象

调查人员经常被要求确定文件何时被删除、文本消息何时被读取,或者一系列事件的正确顺序。因此,大量分析工作围绕时间戳和其他时间性工件展开。理解时间可以帮助我们拼凑出谜题,并进一步理解工件周围的背景。出于这个原因,以及许多其他原因,让我们通过datetime模块来练习处理时间戳。

Python 的 datetime 模块支持时间戳的解析和格式化。该模块有许多功能,最显著的包括获取当前时间、确定两个时间戳之间的差异(或增量),以及将常见的时间戳格式转换为人类可读的日期。datetime.datetime() 方法创建一个 datetime 对象,并接受年份、月份、日期以及可选的小时、分钟、秒、毫秒和时区参数。timedelta() 方法通过存储天数、秒数和微秒数的差异,显示两个 datetime 对象之间的差异。

首先,我们需要导入 datetime 库,这样我们就可以使用该模块中的函数。我们可以使用 datetime.now() 方法查看当前日期。这会创建一个 datetime 对象,我们可以对其进行操作。例如,假设我们通过减去两个 datetime 对象来创建一个 timedelta 对象,它们相隔几秒钟。我们可以将 timedelta 对象加到或从 right_now 变量中减去,以生成另一个 datetime 对象:

>>> import datetime
>>> right_now = datetime.datetime.now()
>>> right_now
datetime.datetime(2018, 6, 30, 7, 48, 31, 576151)

>>> # Subtract time
>>> delta = datetime.datetime.now() - right_now
>>> delta
datetime.timedelta(0, 16, 303831)

>>> # Add datetime to time delta to produce second time
>>> right_now + delta
datetime.datetime(2018, 6, 30, 7, 48, 47, 879982)

输出可能会有所不同,因为你运行这些命令的时间与书中展示时的时间不同。

datetime 模块的另一个常用应用是 strftime(),它允许将 datetime 对象转换为自定义格式的字符串。该函数接受一个格式字符串作为输入。该格式字符串由以百分号开头的特殊字符组成。下表展示了我们可以与 strftime() 函数一起使用的格式化器示例:

描述 格式化器
年 (YYYY) %Y
月份 (MM) %m
日期 (DD) %d
24 小时 (HH) %H
12 小时 (HH) %I
分钟 (MM) %M
秒 (SS) %S
微秒 (SSSSSS) %f
时区 (Z) %z
上午/下午 %p

你可以在 strftime.org/ 或通过官方文档:docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior 查找更多时间戳格式化的信息。

此外,strptime() 函数(我们在这里没有展示)可以用于反向操作。strptime() 函数将接受包含日期和时间的字符串,并使用格式化字符串将其转换为 datetime 对象。我们还可以将表示为整数的纪元时间(也叫 Unix 或 POSIX 时间)解释为 UTC datetime 对象:

>>> epoch_timestamp = 874281600
>>> datetime_timestamp = datetime.datetime.utcfromtimestamp(epoch_timestamp)

我们可以打印这个新对象,它会自动转换为表示 datetime 对象的字符串。然而,假设我们不喜欢用连字符来分隔日期。相反,我们可以使用 strftime() 方法,以斜杠或任何已定义的格式化器来显示日期。最后,datetime 库还提供了一些预构建的格式化器,例如 isoformat(),我们可以使用它轻松生成标准时间戳格式:

>>> from __future__ import print_function
>>> print(datetime_timestamp)
1997-09-15 00:00:00
>>> print(datetime_timestamp.strftime('%m/%d/%Y %H:%M:%S'))
09/15/1997 00:00:00
>>> print(datetime_timestamp.strftime('%A %B %d, %Y at %I:%M:%S %p'))
Monday September 15, 1997 at 12:00:00 AM
>>> print(datetime_timestamp.isoformat())
1997-09-15T00:00:00

作为一个备注,我们已经将print_function导入到我们的解释器中,以便在 Python 2 和 Python 3 中都能打印这些日期值。

datetime库大大减轻了在 Python 中处理日期和时间值时的复杂性。这个模块也非常适合处理在调查过程中常见的时间格式。

库,或模块,加速了开发过程,使我们能够专注于脚本的预定目标,而不是从头开始开发所有功能。外部库可以节省大量的开发时间,坦率地说,它们通常比我们作为开发人员在调查过程中拼凑出来的代码更准确高效。库分为两类:标准库和第三方库。标准库随 Python 的每次安装而分发,包含了 Python 软件基金会支持的常用代码。标准库的数量和名称在不同版本的 Python 中有所不同,尤其是在 Python 2 和 Python 3 之间切换时。我们将尽力指出在 Python 2 和 3 之间导入或使用库的不同之处。在另一个类别中,第三方库引入了新的代码,增加或改进了标准 Python 安装的功能,并允许社区贡献模块。

安装第三方库

我们知道标准模块不需要安装,因为它们随 Python 一起提供,但第三方模块呢?Python 包索引(Python Package Index)是寻找第三方库的好地方。可以在pypi.org/找到它。该服务允许像pip这样的工具自动安装软件包。如果没有互联网连接或在 PyPi 上找不到软件包,通常可以使用setup.py文件手动安装模块。稍后将展示使用pipsetup.py的示例。像pip这样的工具非常方便,它们处理依赖项的安装,检查项目是否已安装,并在安装的是旧版本时建议升级。需要互联网连接来检查在线资源,如依赖项和模块的新版本;但是,pip也可以用于在离线计算机上安装代码。

这些命令在终端或命令提示符中运行,而不是在 Python 解释器中运行。请注意,在下面提到的示例中,如果你的 Python 可执行文件没有包含在当前环境的PATH变量中,可能需要使用完整路径。pip可能需要从提升权限的控制台运行,可以使用sudo或者提升权限的 Windows 命令提示符。有关pip的完整文档,请访问pip.pypa.io/en/stable/reference/pip/

$ pip install python-registry==1.0.4
Collecting python-registry
Collecting enum34 (from python-registry)
 Using cached https://files.pythonhosted.org/packages/af/42/cb9355df32c69b553e72a2e28daee25d1611d2c0d9c272aa1d34204205b2/enum34-1.1.6-py3-none-any.whl
Installing collected packages: enum34, python-registry
Successfully installed enum34-1.1.6 python-registry-1.0.4

$ pip install yarp==1.0.17
https://github.com/msuhanov/yarp/archive/1.0.17.tar.gz
Collecting https://github.com/msuhanov/yarp/archive/1.0.17.tar.gz
 Downloading https://github.com/msuhanov/yarp/archive/1.0.17.tar.gz
 \ 716kB 12.8MB/s
Building wheels for collected packages: yarp
 Running setup.py bdist_wheel for yarp ... done
 Stored in directory: C:\USERS\...\APPDATA\LOCAL\TEMP\pip-ephem-wheel-cache-78qdzfmy\wheels\........
Successfully built yarp
Installing collected packages: yarp
Successfully installed yarp-1.0.17

本书中的库

在本书中,我们使用了许多可以通过pipsetup.py方法安装的第三方库。然而,并不是所有第三方模块都能如此轻松地安装,有时需要你搜索互联网。正如你在之前的代码块中可能注意到的,某些第三方模块,如yarp模块,托管在像 GitHub 这样的源代码管理系统上。GitHub 和其他 SCM 服务允许我们访问公开的代码,并查看随时间推移所做的更改。或者,Python 代码有时会出现在博客或自托管的网站上。在本书中,我们将提供如何安装我们使用的任何第三方模块的说明。

Python 包

Python 包是一个包含 Python 模块和__init__.py文件的目录。当我们导入一个包时,__init__.py文件中的代码会被执行。此文件包含运行包中其他模块所需的导入语句和代码。这些包可以嵌套在子目录中。例如,__init__.py文件可以包含import语句,将目录中的每个 Python 文件以及所有可用的类或函数导入。当文件夹被导入时,所有内容都会被加载。以下是一个示例目录结构,下面是__init__.py文件,它展示了两者在导入时如何交互。以下代码块的最后一行导入了子目录__init__.py文件中指定的所有项目。

假设的文件夹结构如下:

| -- packageName/
    | -- __init__.py
    | -- script1.py
    | -- script2.py
    | -- subDirectory/
         | -- __init__.py
         | -- script3.py
         | -- script4.py

顶级__init__.py文件的内容如下:

from script1 import *
from script2 import function_name
from subDirectory import *

以下代码执行我们之前提到的__init__脚本,它将导入script1.py中的所有函数,仅导入script2.py中的function_name,以及从subDirectory/__init__.py中导入的任何附加规范:

import packageName  

类和面向对象编程

Python 支持面向对象编程OOP),使用内建的类关键字。面向对象编程允许使用高级编程技术,并能编写可持续的代码,以支持更好的软件开发。由于 OOP 在脚本编程中不常用,并且属于高于入门级的概念,本书将在掌握 Python 基本功能后,在后续章节中实现 OOP 及其一些特性。需要记住的是,Python 中的几乎所有东西,包括类、函数和变量,都是对象。类在多种情况下都很有用,允许我们设计自己的对象,以自定义方式与数据进行交互。

让我们看一下datetime模块,作为我们如何与类及其方法交互的一个示例。这个库包含几个类,如datetimetimedeltatzinfo。这些类处理与时间戳相关的不同功能。其中最常用的是datetime类,它可能会让人困惑,因为它是datetime模块的成员。这个类用于表示日期作为 Python 对象。其他两个提到的类通过timedelta类支持datetime类,允许对日期进行加减操作,通过tzinfo类表示时区。

重点关注datetime.datetime类,我们将查看如何使用这个对象创建多个日期实例并从中提取数据。首先,正如以下代码块所示,我们必须导入打印语句并导入此库,以访问datetime模块的类和方法。接下来,我们将参数传递给datetime类,并将datetime对象分配给date_1。我们的date_1变量包含表示 2018 年愚人节的值。由于我们在初始化类时没有指定时间值,因此该值将反映午夜时分,精确到毫秒。如我们所见,像函数一样,类也可以有参数。此外,类可以包含它们自己的函数,通常称为方法。一个方法的例子是调用now(),它允许我们获取本地计算机的当前时间戳,并将该值存储为date_2。这些方法让我们能够操作与类的特定实例相关的数据。我们可以通过在交互式提示符中打印它们,查看我们两个日期对象的内容:

>>> from __future__ import print_function
>>> import datetime
>>> date_1 = datetime.datetime(2018,04,01)
>>> date_2 = datetime.datetime.now()
>>> print(date_1, " | ", date_2)
2018-04-01 00:00:00.000  |  2018-04-01 15:56:10.012915 

我们可以通过调用特定的类属性来访问日期对象的属性。这些属性通常被类内部的代码用于处理数据,虽然我们也可以利用这些属性。例如,小时或年份属性允许我们从日期对象中提取小时或年份。尽管这看起来很简单,但在其他模块中访问从类实例中解析或提取的数据时,它变得更有用:

>>> date_2.hour
15
>>> date_1.year
2018

如前所述,我们可以随时运行dir()help()函数,以了解给定对象可用的方法和属性。如果我们运行以下代码,就可以看到我们能够提取星期几或使用 ISO 格式格式化日期。这些方法提供了关于我们datetime对象的额外信息,并让我们能够充分利用类对象提供的功能:

>>> dir(date_1)
['__add__', '__class__', '__delattr__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__ne__', '__new__', '__radd__', '__reduce__', '__reduce_ex__', '__repr__', '__rsub__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', 'astime zone', 'combine', 'ctime', 'date', 'day', 'dst', 'fromordinal', 'fromtimestamp', 'hour', 'isocalendar', 'isoformat', 'isoweekday', 'max', 'microsecond', 'min', 'minute', 'month', 'now', 'replace', 'resolution', 'second', 'strftime', 'strptime', 'time', 'timetuple', 'timetz', 'today', 'toordinal', 'tzinfo', 'tzname', 'utcfromtimestamp', 'utcnow', 'utcoffset', 'utctimetuple', 'weekday', 'year']
>>> date_1.weekday()
4
>>> date_2.isoformat()
2016-04-01T15:56:10.012915

尝试与异常处理

tryexcept语法用于捕获并安全处理运行时遇到的错误。作为新手开发者,你最终会习惯于别人告诉你你的脚本无法正常工作。在 Python 中,我们使用tryexcept块来防止可避免的错误使代码崩溃。请适度使用tryexcept块。不要把它们当作修补漏洞的创可贴来用——相反,要重新考虑你的原始设计,并思考调整逻辑,以更好地防止错误。一个很好的方法是通过命令行参数、文档或其他方式提供使用说明。正确使用这些将增强程序的稳定性。然而,错误使用将无法增加稳定性,并可能掩盖代码中的潜在问题。一个好的实践是,在tryexcept块中尽可能使用较少的代码行;这样,错误处理更为集中和有效。

例如,假设我们有一些代码,执行两个数值变量的数学计算。如果我们预见到用户可能会不小心输入非整数或浮动值,我们可能希望在计算过程中加入tryexcept,以捕获可能出现的TypeError异常。当我们捕获到错误时,可以尝试通过类构造方法将变量转换为整数,然后再进入tryexcept块。如果成功,我们就避免了程序因可预防的崩溃而中断,并且保持了特定性,防止程序接受如字典类型的输入。例如,在接收到字典对象时,我们希望脚本崩溃并向用户呈现调试信息。

任何有可能生成错误的代码行,都应该由独立的tryexcept块处理,并针对该特定行提供解决方案,以确保我们正确地处理了特定错误。tryexcept块有几种变体。简而言之,分为通用捕获、变量捕获和特定捕获类型的块。以下伪代码展示了这些块的构成示例:

# Basic try and except -- catch-all
try:
    # Line(s) of code
except:
    # Line(s) of error-handling code 

# Catch-As-Variable
try:
    # Line(s) of code
except TypeError as e:
    print(e.message)
    # Line(s) of error-handling code

# Catch-Specific
try:
    # Line(s) of code
except ValueError:
    # Line(s) of error-handling code for ValueError exceptions 

通用的或裸的except将捕获任何错误。这通常被认为是一种糟糕的编码实践,因为它可能导致程序出现不期望的行为。 捕获异常并将其作为变量是许多情况下非常有用的做法。通过调用e.message,存储在e中的异常错误信息可以被打印或写入日志——在大型多模块程序中,尤其有助于调试错误。此外,内建的isinstance()函数可以用来判断错误的类型。

为了支持 Python 2 和 Python 3,请使用如前所述的except Exception as error语法,而不是 Python 2 支持的except Exception, error语法。

在接下来要看的例子中,我们定义了两个函数:give_error()error_handler()give_error()函数尝试将5添加到my_list变量中。这个变量还没有实例化,因此会生成一个NameError实例。在except子句中,我们捕获了一个基类Exception,并将其存储在变量e中。然后,我们将这个异常对象传递给稍后定义的error_handler()函数。

error_handler()函数接收一个异常对象作为输入。它检查该错误是否为NameErrorTypeError的实例,若不是则跳过。根据异常类型,它将打印出异常类型和错误信息:

>>> from __future__ import print_function
>>> def give_error():
...     try:
...         my_list.append(5)
...     except Exception as e:
...         error_handler(e)
...
>>> def error_handler(error):
...     if isinstance(error, NameError):
...         print('NameError:', error.message)
...     elif isinstance(error, TypeError):
...         print('TypeError:', error.message)
...     else:
...         pass
...
>>> give_error()
NameError: global name 'my_list' is not defined

最后,特定异常捕获的tryexcept块可以用于捕获个别异常,并且为该特定错误提供有针对性的错误处理代码。一个可能需要使用特定异常捕获tryexcept块的场景是处理对象,比如列表或字典,这些对象可能在程序中某一时刻尚未实例化。

在下面的示例中,当函数中调用结果列表时,它并不存在。幸运的是,我们将追加操作包装在了tryexcept中,以捕获NameError异常。当我们捕获到此异常时,我们首先将结果列表实例化为空列表,然后再添加适当的数据,最后返回该列表。以下是示例:

>>> def double_data(data):
...     for x in data:
...         double_data = x*2
...         try:
...             # The results list does not exist the first time
...             # we try to append to it
...             results.append(double_data)
...         except NameError:
...             results = []
...             results.append(double_data)
...     return results
...
>>> my_results = doubleData(['a', 'b', 'c'])
>>> print my_results
['aa', 'bb', 'cc'] 

出于(希望)显而易见的原因,前面的代码示例旨在展示如何处理异常。我们应该始终确保在使用变量之前进行初始化。

raise函数

由于我们的代码在执行过程中可能会生成自己的异常,我们也可以使用内置的raise()函数手动触发异常。raise()方法通常用于将异常抛给调用它的函数。尽管这看起来似乎不必要,但在大型程序中,这实际上是非常有用的。

假设有一个函数function_b(),它接收从function_a()传递的解析数据包。我们的function_b()函数对数据包进行进一步处理,然后调用function_c()继续处理数据包。如果function_c()抛出异常返回给function_b(),我们可能会设计一些逻辑,提醒用户数据包格式错误,而不是尝试继续处理它,从而产生错误的结果。以下是表示这种场景的一些伪代码:

001 import module
002
003 def main():
004     function_a(data)
005
006 def function_a(data_in):
007     try:
008         # parse data into packet
009         function_b(parsed_packet)
010     except Exception as e:
011         if isinstance(e, ErrorA):
012             # Address this type of error
013             function_b(fixed_packet)
014         [etc.]
015 
016 def function_b(packet):
017     # Process packet and store in processed_packet variable
018     try:
019         module.function_c(processed_packet)
020     except SomeError:
021         # Error testing logic
022         if type 1 error:
023             raise ErrorA()
024         elif type 2 error:
025             raise ErrorB()
026         [etc.]
027
028 if __name__ == '__main__':
029     main() 

此外,在处理 Python 无法自动识别的异常时,抛出自定义或内置的异常是非常有用的。让我们回顾一下恶性数据包的例子。当第二个函数接收到抛出的错误时,我们可能会设计一些逻辑来测试一些可能的错误来源。根据这些结果,我们可能会抛出不同的异常回到调用函数function_a()

在引发内建异常时,确保使用最接近错误类型的异常。例如,如果错误涉及索引问题,应使用 IndexError 异常。在引发异常时,我们应该传入一个包含错误描述的字符串。这个字符串应当具有描述性,帮助开发者识别问题,而不像以下字符串那样简单。格言 做我们说的,不做我们做的 在这里适用,因为我们仅仅是在展示功能:

>>> def raise_error():
...     raise TypeError('This is a TypeError')
...
>>> raise_error()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "<stdin>", line 2, in raise_error
TypeError: This is a TypeError 

创建我们的第一个脚本 – unix_converter.py

我们的第一个脚本将执行一个常见的时间戳转换,这对于本书的内容非常有用。这个名为unix_converter.py的脚本将 Unix 时间戳转换为人类可读的日期和时间值。Unix 时间戳通常格式化为一个整数,表示自 1970-01-01 00:00:00 起的秒数。

在第一行,我们为用户提供了脚本的简要描述,使他们能够快速理解脚本的意图和用途。接下来的第二至第四行是导入语句。这些导入可能看起来很熟悉,分别为 Python 2 和 3 中打印信息、解析时间戳数据以及访问 Python 版本信息提供支持。然后,第六到第十二行使用 sys 库来检查调用脚本时使用的 Python 版本,以便正确处理用户输入。Python 2 使用 raw_input 函数在终端接受用户数据,而 Python 3 实现了 input 函数。接下来,这个 if/elif/else 语句在未指定的其他(未来)Python 版本中以 NotImplementedError 结束。为了简化起见,我们将这个条件语句设计得可以方便地插入到你的代码中。请参见以下描述的代码:

001 """Script to convert Unix timestamps."""
002 from __future__ import print_function
003 import datetime
004 import sys
005
006 if sys.version_info[0] == 3:
007     get_input = input
008 elif sys.version_info[0] == 2:
009     get_input = raw_input
010 else:
011     raise NotImplementedError(
012         "Unsupported version of Python used.")

在省略的许可证声明之后(请参阅源代码中的 MIT 许可证信息),我们提供了额外的脚本信息,供用户参考,并且标准化我们的脚本实现。然后,我们进入 main() 函数,提示用户输入一个时间戳进行转换,并打印从 Unix_converter() 函数转换后的时间戳结果。为了更详细地解析第 49 行,让我们从最内层的部分开始,get_input() 函数的调用。该函数接受一个字符串,显示在用户输入框前,允许用户输入数据。get_input() 函数返回用户在控制台输入的数据的字符串值,尽管我们需要将这个值转换为整数。我们使用 int 类初始化一个整数值,并将其存储在 unix_ts 变量中。

应用概念

我们如何重新设计第 49 行,以更好地处理用户输入以及可能出现的异常?

提示

这可能需要多行代码。

042 __authors__ = ["Chapin Bryce", "Preston Miller"]
043 __date__ = 20181027
044 __description__ = """Convert Unix formatted timestamps (seconds
045     since Epoch [1970-01-01 00:00:00]) to human readable."""
046
047
048 def main():
049     unix_ts = int(get_input('Unix timestamp to convert:\n>> '))
050     print(unix_converter(unix_ts))

在之前的代码块中的第 50 行,我们调用了unix_converter()函数,并提供了来自用户的整数输入。然后该函数,如以下代码第 53 行所定义,调用了datetime模块,并使用utcfromtimestamp()方法将整数读取为datetime对象。我们在这里使用utcfromtimestamp()方法,而不是名字类似的fromtimestamp()方法,因为utcfromtimestamp()版本不会对提供的数据应用时区修改,而是保持时间戳在原始时区。返回的datetime对象随后使用strftime()方法转换为人类可读的字符串,并将结果字符串返回给调用函数,最终将该值打印到控制台:

053 def unix_converter(timestamp):
054     date_ts = datetime.datetime.utcfromtimestamp(timestamp)
055     return date_ts.strftime('%m/%d/%Y %I:%M:%S %p')p')

我们的脚本以两行代码结束,如下所示,这将在我们脚本的结尾部分非常常见。第一行位于第 57 行,是一个条件语句,用于检查脚本是否作为脚本执行,而不是作为模块导入。这使我们能够根据代码的使用方式来改变其功能。在一个例子中,作为控制台版本的代码通常应该接受命令行参数,而作为库使用的版本则不需要提示用户输入这些细节,因为调用脚本可能只会使用此代码中的一部分功能。这意味着,第 58 行是我们希望在命令行调用此代码时执行的唯一逻辑,它启动了main()函数。如果此脚本作为模块导入到另一个脚本中,则不会发生任何操作,因为我们没有在导入时运行的进一步逻辑。如果它被导入,我们仍然可以使用这些函数,而无需担心导入时发生其他调用:

057 if __name__ == '__main__':
058     main()

我们现在可以通过在命令行调用unix_converter.py来执行此脚本。该脚本如以下截图所示运行,直到它需要用户输入。一旦输入值,脚本继续执行并将转换后的时间戳打印到控制台:

用户输入

允许用户输入增强了程序的动态性。最好向用户查询文件路径或值,而不是将这些信息显式写入代码文件。因此,如果用户想在另一个文件上使用相同的程序,他们可以简单地提供不同的路径,而无需编辑源代码。在大多数程序中,用户提供输入和输出位置,或者确定在运行时应使用哪些可选功能或模块。

用户输入可以在程序首次调用时或在运行时作为参数提供。对于大多数项目,建议使用命令行参数,因为在运行时要求用户输入会暂停程序执行,直到输入完成。

使用原始输入方法和系统模块 – user_input.py

input()sys.argv 都是获取用户输入的基本方法。请注意,这两种方法返回的都是字符串对象,正如我们之前讨论的 Python 2 中的 raw_input() 和 Python 3 中的 input() 函数一样。我们可以通过适当的类构造函数将字符串转换为所需的数据类型。

input() 函数类似于向某人提问并等待其回复。在此期间,程序的执行线程会暂停,直到收到回复为止。稍后我们将定义一个函数,询问用户一个数字并返回其平方值。如我们在第一个脚本中所见,当转换 Unix 时间戳时,我们必须等待用户提供值,脚本才能继续执行。尽管在那个非常简短的脚本中这不是问题,但较大的代码库或长时间运行的脚本应避免这种延迟。

在命令行提供的参数存储在 sys.argv 列表中。像任何列表一样,这些参数可以通过索引访问,索引从零开始。第一个元素是脚本的名称,而之后的每个元素代表一个以空格分隔的用户输入。我们需要导入 sys 模块才能访问这个列表。

在第 39 行,我们将 sys.argv 列表中的参数复制到一个名为 args 的临时列表变量中。这是首选方法,因为在第 41 行,我们打印出第一个元素后将其删除。对于 args 列表中的其余项,我们使用 for 循环并将列表包装在内建的 enumerate() 函数中。这为我们的循环提供了一个计数器 i,用来计算循环的迭代次数或本例中使用的参数数目。在第 43 和 44 行,我们打印出每个参数及其位置和数据类型。我们有如下代码:

001 """Replicate user input in the console."""
002 from __future__ import print_function
003 import sys
...
033 __authors__ = ["Chapin Bryce", "Preston Miller"]
034 __date__ = 20181027
035 __description__ = "Replicate user input in the console"
036 
037 
038 def main():
039     args = sys.argv
040     print('Script:', args[0])
041     args.pop(0)
042     for i, argument in enumerate(sys.argv):
043         print('Argument {}: {}'.format(i, argument))
044         print('Type: {}'.format(type(argument)))
045 
046 if __name__ == '__main__':
047     main()

在将此文件保存为 user_input.py 后,我们可以在命令行调用它并传入我们的参数。

如下例所示,参数是以空格分隔的,因此带有空格的参数需要用引号括起来。从以下示例中也可以清楚看出,所有来自 sys.argv 的参数值都作为字符串值存储。input() 函数也会将所有输入解释为字符串值:

对于没有很多命令行选项的小型程序,sys.argv 列表是一个快速简便的方式来获取用户输入,而不会阻塞脚本的执行。

包含空格的文件路径应该使用双引号括起来。例如,sys.argv 会将 C:/Users/LPF/misc/my books 分割成 C:/Users/LPF/misc/mybooks。这会在脚本尝试与该目录交互时导致 IOError 异常。此外,注意包含反斜杠字符 \ 的文件路径;我们需要转义此字符,以防止我们的命令行终端和代码误解输入。这个字符通过使用第二个反斜杠来转义,像这样:\\

理解 Argparse – argument_parser.py

argparse是标准库中的一个模块,本书中将多次使用它来获取用户输入。argparse有助于开发更复杂的命令行接口。默认情况下,argparse会创建一个-h开关或帮助开关,用于显示脚本的帮助和使用信息。在本节中,我们将构建一个示例的argparse实现,包含必需、可选和默认参数。

我们导入argparse模块,并按照常规的print_function和脚本描述进行设置。接着,我们指定通常的脚本头部信息,如__author____date____description__,因为我们将在argparse实现中使用这三者。在第 38 行,我们定义了一个过于简单的main()函数来打印解析后的参数信息,因为除了展示一些简洁的用户参数处理外,我们没有其他计划。为了实现这个目标,我们首先需要初始化ArgumentParser类实例,如第 43 到 48 行所示。注意,只有当脚本通过命令行调用时,才会执行这一部分,具体条件在第 42 行给出。

在第 43 行,我们用三个可选参数初始化ArgumentParser。第一个是脚本的描述,我们会从之前设置的__description__变量中读取。第二个参数是结尾的附加说明,或者说帮助信息部分末尾的细节。这可以是任意文本,和描述字段一样,尽管我们选择用它来提供作者和版本信息。为了开始,使用日期作为版本号对于用户参考很有帮助,也能避免编号方案的复杂性。最后一个可选参数是格式化器规格,它指示我们的参数解析器显示脚本设置的任何默认值,以便用户了解如果不通过参数修改,选项是否会被设置。建议养成习惯,强烈推荐包括这个:

001 """Sample argparse example."""
002 from __future__ import print_function
003 import argparse
...
033 __authors__ = ["Chapin Bryce", "Preston Miller"]
034 __date__ = 20181027
035 __description__ = "Argparse command-line parser sample"
036 
037 
038 def main(args):
039     print(args)
040 
041 
042 if __name__ == '__main__':
043     parser = argparse.ArgumentParser(
044         description=__description__, 
045         epilog='Built by {}. Version {}'.format(
046         ", ".join(__authors__), __date__),
047         formatter_class=argparse.ArgumentDefaultsHelpFormatter
048     )

我们现在可以利用新实例化的解析器对象来添加参数说明。首先,让我们讨论一些有关必需和可选参数的良好实践。默认情况下,argparse会通过参数名前是否有一个或两个破折号来判断该参数是否是可选的。如果参数说明前有破折号,它将被认为既是可选的,又是非位置性的;反之,如果没有破折号,则argparse会将该参数视为必需且位置性参数。

请参照以下示例;在这个脚本中,timezoneinput_file 参数是必需的,且必须按照这个顺序提供。此外,这两个参数不需要额外的参数说明符;argparse 会查找一个没有配对的值并将其分配给 timezone 参数,然后再查找另一个没有配对的值并将其分配给 input_file 参数。相反,--source--file-type-h(或 --help)和 -l(或 --log)等参数是非位置参数,可以按任何顺序提供,只要紧跟其后的值与相应的参数说明符配对即可。

为了让事情变得稍微复杂一点,但也更具可定制性,我们可以要求非位置参数。这样做有一个优势,我们可以允许用户以任意顺序输入参数,尽管其缺点是要求用户为脚本运行所需的字段进行额外的输入。你会注意到,在接下来的代码中,第 2 行的 --source 参数周围没有方括号。这是 argparse (微妙的)方式来指示这是一个必需的非位置参数。虽然一开始可能会让用户难以理解,但如果缺少此参数,argparse 会中止脚本执行并提示用户。你可能想在脚本中使用非位置的必需参数,或者完全避免使用它们——作为开发者,你需要根据用户的需求找到最舒适且合适的界面:

$ python argument_parser.py --help
usage: argument_parser.py [-h] --source SOURCE [-l LOG]
 [--file-type {E01,RAW,Ex01}]
 timezone input_file

Argparse command-line parser sample

positional arguments:
 timezone timezone to apply
 input_file

optional arguments:
 -h, --help show this help message and exit
 --source SOURCE source information (default: None)
 -l LOG, --log LOG Path to log file (default: None)
 --file-type {E01,RAW,Ex01}

Built by Chapin Bryce, Preston Miller. Version 20181027

稍微离题一下,接下来我们将开始向我们初始化的解析器对象添加参数。我们将从之前讨论过的一个位置参数开始。timezone 参数是通过 add_argument() 方法定义的,允许我们提供一个表示参数名称的字符串,并可以附加一些可选的参数来增加详细信息。在第 51 行,我们简单地提供了一些有用的信息,用以说明如何使用该参数:

050     # Add positional required arguments
051     parser.add_argument('timezone', help='timezone to apply')

我们在第 54 行添加的下一个参数是之前讨论过的非位置必需参数。注意,我们使用了 required=True 语句来表示,无论前面有没有连字符,这个参数在执行时都是必需的:

053     # Add non-positional required argument
054     parser.add_argument('--source', 
055         help='source information', required=True)

现在我们添加第一个非位置参数和可选的日志文件参数。在这里,我们提供了两种方式让用户指定该参数,-l--log。这是针对常见参数的推荐方式,因为它既为经常使用的用户提供了简短的命令,也为新手用户提供了参数使用的上下文:

057     # Add optional arguments, allowing shorthand argument
058     parser.add_argument('-l', '--log', help='Path to log file')

并非所有的参数都需要接受一个值;在某些情况下,我们只需要从参数中得到一个布尔值的答案。此外,我们可能希望允许多次指定该参数,或者在调用时实现自定义功能。为了支持这一点,argparse 库允许使用动作。我们在本书中常用的动作如下所示。

第一个有用的操作是 store_true,它是 store_false 的反义词。这对于获取脚本中启用或禁用功能的信息非常方便。正如下面代码块中第 61 到第 64 行所示,我们可以看到操作参数用于指定是否应该将 TrueFalse 存储为参数的结果。在这种情况下,这是重复的,两个参数中的一个可以用来决定是否应该发送此示例中的电子邮件。还有其他操作可用,例如 append,如第 66 和 67 行所示,其中每个电子邮件地址实例(在这个例子中)将被添加到一个列表中,我们可以遍历该列表并使用它。

以下代码中的最后一个操作示例用于计算某个参数被调用的次数。我们主要在增加冗余或调试信息时看到这种实现,但它也可以在其他地方以相同的方式使用:

060     # Using actions
061     parser.add_argument('--no-email', 
062         help='disable emails', action="store_false")
063     parser.add_argument('--send-email', 
064         help='enable emails', action="store_true")
065     # Append values for each argument instance.
066     parser.add_argument('--emails', 
067         help='email addresses to notify', action="append")
068     # Count the number of instances. i.e. -vvv
069     parser.add_argument('-v', help='add verbosity', action='count')

default 关键字决定了参数的默认值。我们还可以使用 type 关键字将我们的参数存储为特定的对象。现在,我们可以直接将输入存储为所需的对象,例如整数,而无需将字符串作为唯一输入,并且不再需要在脚本中进行用户输入转换:

071     # Defaults
072     parser.add_argument('--length', default=55, type=int)
073     parser.add_argument('--name', default='Alfred', type=str)

Argparse 可以直接用于打开文件进行读取或写入。在第 76 行,我们以读取模式打开所需的参数 input_file。通过将这个文件对象传递到主脚本中,我们可以立即开始处理我们感兴趣的数据。下一行会重复执行这个操作,处理文件写入的打开:

075     # Handling Files
076     parser.add_argument('input_file', type=argparse.FileType('r'))
077     parser.add_argument('output_file', type=argparse.FileType('w'))

我们将要讨论的最后一个关键字是 choices,它接受一个大小写敏感的选项列表,用户可以从中选择。当用户调用此参数时,他们必须提供有效选项之一。例如,--file-type RAW 将把 file-type 参数设置为 RAW 选项,如下所示:

079     # Allow only specified choices
080     parser.add_argument('--file-type', 
081         choices=['E01', 'RAW', 'Ex01'])

最后,一旦我们将所有所需的参数添加到 parser 中,我们可以解析这些参数。在第 84 行,我们调用 parse_args() 函数,它创建了一个 Namespace 对象。例如,要访问我们在第 72 行创建的 length 参数,我们需要像 arguments.length 这样调用 Namespace 对象。在第 85 行,我们将参数传递到 main() 函数中,该函数打印出 Namespace 对象中的所有参数。我们有以下代码:

083     # Parsing arguments into objects
084     arguments = parser.parse_args()
085     main(arguments)

这些 Namespace 对象可以重新分配给变量,以便更容易记住。

在掌握了 argparse 模块的基础知识后,我们现在可以为我们的脚本构建简单和更高级的命令行参数。因此,这个模块被广泛用于为我们将要构建的大多数代码提供命令行参数。当运行以下代码并使用 --help 开关时,我们应该能够看到脚本所需的必需参数和可选参数:

法医脚本最佳实践

取证最佳实践在我们的工作中占据着重要地位,传统上,它指的是处理或获取证据。然而,在编程方面,我们自己也定义了一些取证最佳实践,如下所示:

  • 不要修改你正在使用的原始数据

  • 在原始数据的副本上进行操作

  • 注释代码

  • 验证程序的结果(以及其他应用程序的结果)

  • 维护详细的日志记录

  • 以易于分析的格式返回输出(你的用户会感谢你)

取证的黄金法则是:尽量避免修改原始数据。尽可能在经过验证的取证副本上进行操作。然而,这对其他领域可能不可行,例如事故响应人员,因其参数和范围不同。如同往常一样,这要根据具体情况而定,但请记住在运行时系统或原始数据上工作的潜在后果。

在这些情况下,重要的是要考虑代码的作用以及它在运行时如何与系统互动。代码会留下什么样的痕迹?它是否可能无意间破坏了证据或与之相关的引用?程序是否在类似的条件下经过验证,以确保它能正常运行?这些是运行程序时必须考虑的因素,尤其是在实时系统上。

我们之前提到过代码注释,但再强调它的重要性也不会过分。很快,我们将创建第一个取证脚本usb_lookup.py,它的代码行数略超过 90 行。试想一下,如果没有任何解释或注释,直接交给你这段代码。即便是经验丰富的开发者,也可能需要几分钟时间才能理解它的具体功能。现在,想象一下一个大型项目的源代码,里面有成千上万行代码——这样你就能明白注释的价值了,这不仅对开发者很重要,也对之后查看代码的人至关重要。

验证本质上就是要了解代码的行为。显然,漏洞会被发现并解决。然而,漏洞有时会反复出现,最终是无法避免的,因为在开发过程中无法测试所有可能的情况。相反,我们可以建立对代码在不同环境和情况下行为的理解。掌握代码的行为非常重要,不仅是为了能确定代码是否能够完成任务,还因为当你被要求在法庭上解释其功能和内部工作时,了解这些也至关重要。

日志记录有助于跟踪运行时可能出现的错误,并充当程序执行过程的审计链。Python 在标准库中提供了一个强大的日志模块,名为logging。在本书中,我们将使用这个模块及其各种选项。

我们编写脚本的目的是自动化一些繁琐的重复任务,为分析人员提供可操作的知识。通常来说,后者指的是以易于操作的格式存储数据。在大多数情况下,CSV 文件是实现这一目标的最简单方式,因为它可以用多种不同的文本编辑器或工作簿编辑器打开。我们将在许多程序中使用 csv 模块。

开发我们的第一个取证脚本 – usb_lookup.py

既然我们已经开始编写第一个 Python 脚本,接下来让我们编写第一个取证脚本。在取证调查中,常常会看到通过 厂商标识符 (VID) 和 产品标识符 (PID) 值来引用外部设备;这些值由四个十六进制字符表示。如果没有标识出厂商和产品名称,检查员必须查找相关信息。一个这样的查找位置是以下网页:linux-usb.org/usb.ids。例如,在这个网页上,我们可以看到 Kingston DataTraveler G3 的 VID 是 0951,PID 是 1643。当我们试图通过已定义的标识符来识别厂商和产品名称时,我们将使用这个数据源。

首先,让我们来看一下我们将要解析的数据源。后面会提到一个假设的示例,来说明我们数据源的结构。数据源包含 USB 厂商,并且每个厂商下有一组 USB 产品。每个厂商或产品都有四位十六进制字符和一个名称。区分厂商和产品行的标识符是制表符,因为产品在其父厂商下往往是通过制表符缩进的。作为一名取证开发者,你会开始喜爱模式和数据结构,因为当数据遵循一套严格的规则时,真的是个开心的日子。正因为如此,我们可以简单地保留厂商和产品之间的关系。以下是上述假设的示例:

0001 Vendor Name
    0001 Product Name 1
    0002 Product Name 2
    ...
    000N Product Name N

这个脚本名为 usb_lookup.py,它接收用户提供的 VIDPID,并返回相应的厂商和产品名称。我们的程序使用 urllib 模块中的 urlopen 方法下载 usb.ids 数据库到内存,并创建一个包含 VIDs 及其产品的字典。由于这是 Python 2 和 3 版本之间改变过的库,我们在 tryexcept 块中引入了一些逻辑,以确保我们能够顺利调用 urlopen 方法,代码如下所示。我们还导入了 argparse 模块,以便接受用户提供的 VIDPID 信息:

001 """Script to lookup USB vendor and product values."""
002 from __future__ import print_function
003 try:
004     from urllib2 import urlopen
005 except ImportError:
006     from urllib.request import urlopen
007 import argparse

如果没有找到厂商和产品的组合,错误处理机制将通知用户任何部分结果,并优雅地退出程序。

main()函数包含了下载usb.ids文件、将其存储到内存中并创建 USB 字典的逻辑。USB 字典的结构有些复杂,它涉及将VID映射到一个列表,列表的第一个元素是供应商名称,第二个元素是一个产品字典,后者将 PID 映射到其名称。以下是包含两个供应商VendorId_1VendorId_2的 USB 字典示例,每个供应商都映射到一个包含供应商名称的列表,并且每个列表都包含一个用于存储产品 ID 和名称对的字典:

usbs = {
    VendorId_1: [
        VendorName_1,
        {ProductId_1: ProductName_1,
         ProductId_2: ProductName_2,
         ProductId_N: ProductName_N}
    ], VendorId_2: [
        VendorName_2,
        {ProductId_1: ProductName_1}
    ], ...
}

可能会有一种冲动,只是简单地在代码行中搜索VIDPID并返回名称,而不是创建一个将供应商与其产品链接的字典。然而,产品在不同的供应商之间可能会共享相同的 ID,这可能导致错误地返回来自其他供应商的产品。通过我们之前的数据结构,我们可以确保产品属于相关的供应商。

一旦 USB 字典被创建,search_key()函数就负责查询字典以匹配项。它首先赋值用户提供的两个参数,VIDPID,然后继续执行脚本。接下来,它在最外层字典中搜索VID匹配项。如果找到了VID,则会在最内层字典中搜索响应的PID。如果两个都找到了,解析出的名称将打印到控制台。最后,从第 81 行开始,我们定义了用户提供VIDPID值的参数,然后调用main()函数:

042 def main():
...
065 def search_key():
...
080 if __name__ == '__main__':
081     parser = argparse.ArgumentParser(
082         description=__description__,
083         epilog='Built by {}. Version {}'.format(
084             ", ".join(__authors__), __date__),
085         formatter_class=argparse.ArgumentDefaultsHelpFormatter
086     )
087     parser.add_argument('vid', help="VID value")
088     parser.add_argument('pid', help="PID value")
089     args = parser.parse_args()
090     main(args.vid, args.pid)

对于较大的脚本,像这样的脚本,查看一个展示这些函数如何连接在一起的图示是非常有帮助的。幸运的是,有一个名为code2flow的库,托管在 GitHub 上(github.com/scottrogowski/code2flow.git),它可以自动化这个过程。下面的示意图展示了从main()函数到search_key()函数的流程。还有其他库可以创建类似的流程图。然而,这个库在创建简单且易于理解的流程图方面做得非常好:

理解main()函数

让我们从检查 main() 函数开始,该函数在第 90 行被调用,如前面的代码块所示。这个函数在第 42 行需要用户参数提供的 vidpid 信息,以便在 usb.ids 数据库中解析。在第 43 到 46 行,我们创建了初始变量。url 变量存储包含 USB 数据源的 URL。我们使用 urllib 模块中的 urlopen() 函数从在线数据源创建字符串列表。我们将使用许多字符串操作,如 startswith()isalnum()islower()count(),来解析 usb.ids 文件结构,并将解析后的数据存储在 usbs 字典中。第 46 行定义的空字符串 curr_id 变量将用于跟踪我们当前在脚本中处理的供应商:

042 def main(vid, pid):
043     url = 'http://www.linux-usb.org/usb.ids'
044     usbs = {}
045     usb_file = urlopen(url)
046     curr_id = ''

在 Python 字符串操作中,一个重要的概念是编码。这是编写兼容 Python 2 和 Python 3 代码时最常见的问题之一。第 48 行的 for 循环开始迭代文件中的每一行,逐行进行检查。为了支持 Python 3,我们必须检查该行变量是否是字节类型的实例,这是一种原始数据类型(在此情况下)存储了编码的字符串数据。如果是这种情况,我们必须使用 decode() 方法并提供正确的编码——在此例中为 latin-1,如第 50 行所示。Python 2 从文件中读取数据时是以字符串形式读取的,因此不会进入这个条件判断,之后我们可以继续解析该行:

048     for line in usb_file:
049         if isinstance(line, bytes):
050             line = line.decode('latin-1')

我们接下来的条件判断会检查 usb.ids 文件中的注释行,跳过任何空行(只包含换行符或制表符)和以井号字符开头的注释行。为了检查注释行,我们可以使用 startswith() 字符串方法来判断提供的字符串(一个或多个字符)是否与我们检查的行相同。为了简化代码,我们还利用了 in 语句,它允许我们进行类似 or 的等式比较。这是一个方便的快捷方式,你将在各种脚本中看到它。如果这两个条件中的任何一个为真,我们将使用 continue 语句(如第 52 行所示)跳入下一次循环迭代:

051         if line.startswith('#') or line in ('\n', '\t'):
052             continue

我们条件判断的第二部分处理额外的行格式验证。我们希望确认我们正在检查的行是否符合供应商行的格式,这样我们就可以将与供应商相关的解析代码放入其中。为此,我们首先检查该行是否以制表符开头,并且第一个字符是否为字母数字字符,通过调用 isalnum() 来进行判断:

053         else:
054             if not(line.startswith('\t')) and line[0].isalnum():

知道该行通过了检查,确认它是供应商信息行后,我们可以开始提取所需的值,并填充我们的数据结构。在第 55 行,我们通过去除行两侧的空白字符,并使用split()方法从该行中提取uidname两个值。split()方法在这里使用了两个参数,一个是拆分字符,另一个是拆分次数。在这种情况下,我们是基于空格字符进行拆分,并且只在找到第一个空格后进行拆分。

这样做很有用,因为我们的供应商名称中可能包含空格,我们希望将这些详细信息保持在一起。由于我们预计返回两个值,因此可以使用第 55 行看到的赋值语句同时填充uidname变量的正确值,尽管如果split()方法仅返回一个对象,这可能会导致错误。在这种情况下,我们了解数据源,并已验证这应该始终返回两个值,尽管这是一个很好的位置,在你自己的代码版本中添加一个try-except块来处理可能出现的错误。

然后,我们将uid变量赋值为curr_id的值,以便在解析第 56 行的PID详细信息时使用。最后,在第 57 行,我们将这些信息添加到我们的数据结构usbs中。由于usbs结构是一个字典,我们将 VID 的uid值作为键,并将VID的通用名称作为第一个元素,产品详细信息的空字典作为第二个元素。在第 57 行,我们通过调用字符串的strip()方法来确保供应商名称没有多余的空白字符:

055                 uid, name = line.strip().split(' ', 1)
056                 curr_id = uid
057                 usbs[uid] = [name.strip(), {}]

现在我们已经处理了供应商数据模式,接下来将注意力转向产品数据模式。首先,我们将使用elif条件语句检查该行是否以制表符字符开始,并使用count()方法确保该行中只有一个制表符字符。在第 59 行,我们调用熟悉的strip()方法并将该行拆分为所需的值。在第 60 行,我们将产品信息添加到我们的数据结构中。作为一个简短的回顾,usbs是一个字典,其中的键是 VID。在每个 VID 的值中,是一个列表,列表的第一个元素是供应商名称,第二个元素是一个字典,用于存储 PID 详细信息。正如预期的那样,我们将使用uid值作为产品详细信息的键,并将产品名称分配给PID键。请注意,我们如何使用前一行供应商中的curr_id值来确保我们正确地关联 VID 和 PID:

058             elif line.startswith('\t') and line.count('\t') == 1:
059                 uid, name = line.strip().split(' ', 1)
060                 usbs[curr_id][1][uid] = name.strip()

然后,前面的行会在一个for循环中重复,直到文件结束,解析出供应商和产品的详细信息,并将其添加到usbs字典中。

我们快到了——main()函数的最后部分是调用search_key()函数,它接受用户提供的vidpid信息,以及我们新创建的usbs字典进行查找。注意,这个调用缩进了四个空格,将其置于for循环之外,确保我们只调用一次该方法,前提是usbs查找字典已经完成:

062     search_key(vid, pid, usbs)

这部分完成了main()函数中的逻辑。现在,让我们看看search_key()函数,了解如何查找我们的 VID 和 PID 值。

解释search_key()函数

search_key()函数最初在main()函数的第 62 行被调用,是我们查找用户提供的供应商和产品 ID 并将结果显示给用户的地方。此外,我们的所有错误处理逻辑都包含在这个函数中。

让我们练习访问嵌套的列表或字典。我们在main()函数中讨论过这个问题;然而,实际操作比仅仅听我们说更有帮助。访问嵌套结构需要我们使用多个索引,而不仅仅是一个。例如,让我们创建一个列表,并将其映射到字典中的key_1。要访问嵌套列表中的元素,我们需要先提供key_1来访问该列表,然后再提供一个数字索引来访问列表中的元素:

>>> inner_list = ['a', 'b', 'c', 'd']
>>> print(inner_list[0])
a
>>> outer_dict = {'key_1': inner_list}
>>> print(outer_dict['key_1'])
['a', 'b', 'c', 'd']
>>> print(outer_dict['key_1'][3])
d 

现在,让我们转回到当前任务,运用我们新学的技能,搜索字典中的供应商和产品 ID。search_key()函数在第 65 行定义,它接受用户提供的 VID 和 PID 以及我们解析出的usb_dict字典。然后,我们开始查询usb_dict中对应的vendor_key值,使用字典的get()方法来尝试获取请求的键的值,如果找不到该键,则返回None,如第 66 行所指定:

请注意,get()调用返回的数据(如果成功)是该键的整个值,或者在这个例子中是一个列表,其中元素零是供应商名称,元素一是包含产品详细信息的字典。然后,我们可以在第 67 行检查是否找到了该键;如果没有找到,我们会在第 68 和 69 行输出错误信息并退出,如下所示:

065 def search_key(vendor_key, product_key, usb_dict):
066     vendor = usb_dict.get(vendor_key, None)
067     if vendor is None:
068         print('Vendor ID not found')
069         exit()

然后,我们可以重复这个查找产品信息的逻辑,尽管我们首先需要导航到产品信息。在第 71 行,我们访问供应商列表的元素一,它包含产品详细信息字典,然后执行相同的get()方法调用,查找 PID 的任何名称解析。以相同的方式,我们检查查找是否失败,并提供任何可用的详细信息给用户;如果失败,至少我们可以提供供应商信息:

071     product = vendor[1].get(product_key, None)
072     if product is None:
073         print('Vendor: {}\nProduct Id not found.'.format(
074             vendor[0]))
075         exit(0)

如果一切顺利,我们可以将输出打印给用户,脚本也就完成了!请注意,在第 77 行的格式化语句中,我们必须调用厂商变量的第一个元素,因为 VID 键查找的值是一个列表,而 PID 键查找的值仅仅是产品名称。虽然这可能会让人有些困惑,但请随时参考之前的示例数据结构,并添加尽可能多的中间打印语句以帮助理解:

077     print('Vendor: {}\nProduct: {}'.format(vendor[0], product))

运行我们的第一个取证脚本

usb_lookup.py 脚本需要两个参数——目标 USB 设备的厂商 ID 和产品 ID。我们可以通过查看疑似的 HKLM\SYSTEM\%CurrentControlSet%\Enum\USB 注册表键来找到这些信息。例如,提供厂商 ID 0951 和产品 ID 1643(来自子键 VID_0951&PID_1643),可以让我们的脚本识别该设备为 Kingston DataTraveler G3:

我们的数据源并非一个包含所有数据的完整列表,如果你提供了一个在数据源中不存在的厂商或产品 ID,我们的脚本会打印出该 ID 未找到的消息。此示例和所有其他脚本的完整代码可以从 packtpub.com/books/content/support 下载。

故障排除

在你开发生涯的某个阶段——大概是在你写完第一个脚本之后——你肯定会遇到 Python 错误并收到 Traceback 消息。Traceback 提供了错误的上下文,并指出了引起问题的代码行。问题本身被描述为一个异常,通常会提供一个对人类友好的错误信息。

Python 有许多内建的异常,其目的是帮助开发者诊断代码中的错误。完整的内建异常列表可以在 docs.python.org/3/library/exceptions.html 找到。

让我们看看一个简单的异常示例,AttributeError,以及在这种情况下 Traceback 的样子:

>>> import math
>>> print(math.noattribute(5))
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AttributeError: 'module' object has no attribute 'noattribute'

Traceback 会指出错误发生的文件,在这个例子中是 stdin 或标准输入,因为这段代码是在交互式提示符中编写的。当在更大的项目中工作或只有一个脚本时,文件将是导致错误的脚本的名称,而不是 stdinin <module> 部分将是包含错误行代码的函数名,或者如果代码不在任何函数内,则显示 <module>

现在,让我们看看一个稍微复杂一点的问题。为此,我们将使用之前脚本中的数据结构。在下面的代码块中,我们并没有通过 get() 方法访问 VID 数据,而是希望它存在。为了示范,请暂时将 usb_lookup.py 脚本中的第 66 行替换为以下内容:

066     vendor = usb_dict[vendor_key]

现在,如果你使用有效的供应商密钥运行更新后的代码,你将得到预期的结果,尽管使用像ffff这样的密钥看看会发生什么。检查一下是否看起来像下面这样:

$ python usb_lookup.py ffff 1643
Traceback (most recent call last):
    File "usb_lookup.py", line 90, in <module>
        main(args.vid, args.pid)
    File "usb_lookup.py", line 62, in main
        search_key(vid, pid, usbs)
    File "usb_lookup.py", line 66, in search_key
        vendor = usb_dict[vendor_key]
KeyError: 'ffff'

这里的追踪信息有三个堆栈追踪。最底部的最后一条追踪就是我们的错误发生的位置。在这种情况下,是在usb_lookup.py文件的第 66 行,search_key()函数生成了一个KeyError异常。在 Python 文档中查找KeyError异常的定义会表明,这是由于字典中不存在该键。大多数情况下,我们需要在导致错误的特定行解决此问题。在我们的案例中,我们使用了字典的get()方法来安全地访问键元素。请将该行恢复到之前的状态,以防止此错误在未来再次发生!

挑战

我们建议通过实验代码来学习它是如何工作的,或者尝试改进它的功能。例如,我们如何进一步验证 VID 和 PID 输入,以确保它们是有效的?我们是否可以对第 55 和第 59 行返回的 UID 值进行相同的检查?

我们第一个脚本的另一个扩展是考虑离线环境。我们如何修改这段代码,以允许某人在隔离环境中运行?可以使用什么参数来根据用户的离线访问需求改变行为?

程序是不断发展的,永远不会是完全完成的产品。这里还有很多可以改进的地方,我们邀请你创建并分享对此脚本以及你所有其他取证 Python 脚本的修改。

总结

本章接续了上一章的内容,帮助我们为后续章节打下坚实的 Python 基础。我们涵盖了高级数据类型和面向对象编程,开发了我们的第一个脚本,并深入探讨了追踪信息。到目前为止,你应该已经开始熟悉 Python,尽管如此,还是建议你重复这两章并手动输入代码,帮助自己巩固对 Python 的掌握。我们强烈建议通过在交互式提示符中测试想法或修改我们编写的脚本来进行实践和实验。此项目的代码可以从 GitHub 或 Packt 下载,具体说明见前言部分。

随着我们逐步远离理论,进入本书的核心部分,我们将从简单的脚本开始,逐步发展成更为复杂的程序。这应该会自然地发展出编程和技能的理解。在下一章,你将学习如何解析 Windows 系统上的setupapi.dev.log文件,以识别 USB 安装时间。

第三章:解析文本文件

文本文件,通常来自应用程序或服务日志,是数字调查中常见的证据来源。日志文件可能非常大,或者包含难以人工检查的数据。手动检查可能会变成一系列的 grep 搜索,结果可能会有或没有成效;此外,预构建的工具可能不支持特定的日志文件格式。在这些情况下,我们需要开发自己的解决方案,正确解析并提取相关信息。在本章中,我们将分析 setupapi.dev.log 文件,该文件记录了 Windows 机器上的设备信息。由于该日志文件能够提取系统中 USB 设备的首次连接时间,因此它通常会被检查。

在本章中,我们将逐步讲解相同代码的几个迭代版本。尽管可能显得有些冗余,但我们鼓励你为自己编写每个版本的代码。通过重写代码,我们将一起推进学习,找到更合适的解决方案,学习如何处理 bugs,并实现效率提升。请为自己重写代码并测试每个迭代版本,以查看输出和代码处理的变化。

本章将涵盖以下主题:

  • 识别日志文件中 USB 设备条目的重复模式

  • 从文本文件中提取和处理证据

  • 迭代改进我们的脚本设计和功能

  • 以去重且易读的方式增强数据展示

本章的代码是在 Python 2.7.15 和 Python 3.7.1 环境下开发和测试的。

设置 API

setupapi.dev.log 文件是一个 Windows 日志文件,用于跟踪各种设备的连接信息,包括 USB 设备。由于 USB 设备信息在许多调查中通常扮演重要角色,我们的脚本将帮助识别机器上 USB 设备的最早安装时间。这个日志是全系统范围的,而不是用户特定的,因此仅提供 USB 设备首次连接系统的安装时间。除了记录这个时间戳外,日志还包含 供应商 ID (VID)、产品 ID (PID) 以及设备的序列号。有了这些信息,我们可以更好地了解可移动存储设备的活动。在 Windows XP 上,这个文件可以在 C:\Windows\setupapi.log 找到;在 Windows 7 到 10 上,这个文件可以在 C:\Windows\inf\setupapi.dev.log 找到。

介绍我们的脚本

在这一部分中,我们将构建 setupapi_parser.py,以解析 Windows 7 中的 setupapi.dev.log 文件。仅使用标准库中的模块,我们将打开并读取一个 setupapi.log 文件,识别并解析相关的 USB 信息,并将其显示在控制台中。正如在介绍中所提到的,我们将使用迭代构建过程来模拟自然的开发周期。每次迭代都会在前一次的基础上进行改进,同时我们探索新的功能和方法。我们鼓励开发额外的迭代,章节结尾处有挑战内容供读者尝试。

概述

在开发任何代码之前,让我们先识别出我们的脚本必须具备的需求和功能,以完成预期任务。我们需要执行以下步骤:

  1. 打开日志文件并读取所有行

  2. 在每一行中,检查是否有 USB 设备条目的指示符

  3. 解析响应行中的时间戳和设备信息

  4. 将结果输出给用户

现在,让我们检查感兴趣的日志文件,以确定我们可以在脚本中用作切入点的重复结构,以便解析相关数据。在以下示例的 USB 条目中,我们可以看到在文本 Device Install (Hardware initiated) 后,第 1 行包含设备信息。该设备信息包含 VID、PID、设备版本以及设备的唯一 ID。每个元素之间由 &_ 字符分隔,并且可能包含一些额外的无关字符。安装时间记录在第 2 行,在 Section start 文本后。对于我们的目的,我们只关心这两行。所有其他的周围行将被忽略,因为它们与操作系统驱动程序信息相关:

001 >>>  [Setup online Device Install (Hardware initiated) - pciven_15ad&dev_07a0&subsys_07a015ad&rev_013&18d45aa6&0&a9]
002 >>>  Section start 2010/11/10 10:21:12.593
003 ump: Creating Install Process: DrvInst.exe 10:21:12.593
004 ndv: Retrieving device info...
005 ndv: Setting device parameters...
006 ndv: Searching Driver Store and Device Path...
007 dvi: {Build Driver List} 10:21:12.640 

我们的第一次迭代 – setupapi_parser_v1.py

我们第一次迭代的目标是开发一个功能原型,在后续的迭代中对其进行改进。在所有脚本中,我们将继续看到以下代码块,它提供了有关脚本的基本文档,以及在 Python 2 和 3 版本中打印信息(第 2 行)和打开文件(第 3 行)的支持。以下是所有脚本中可以找到的许可信息和基本脚本描述符:

001 """First iteration of the setupapi.dev.log parser."""
002 from __future__ import print_function
003 from io import open
...
033 __authors__ = ["Chapin Bryce", "Preston Miller"]
034 __date__ = 20181027
035 __description__ = """This scripts reads a Windows 7 Setup API
036    log and prints USB Devices to the user"""

我们的脚本包含三个功能,具体如下。main() 函数通过调用 parse_setupapi() 函数启动脚本。此函数读取 setupapi.dev.log 文件,并提取 USB 设备和首次安装日期的信息。处理完成后,调用 print_output() 函数,将提取的信息打印到控制台上。print_output() 函数接收提取的信息,并将其打印给用户。这三个函数共同协作,使我们能够根据操作将代码分段:

039 def main():
...
054 def parse_setupapi():
...
071 def print_output(): 

要运行这个脚本,我们需要提供一些代码来调用main()函数。以下代码块展示了一个 Python 特性,我们将在本书中的几乎每个脚本中使用。随着本章的进行,这部分代码将变得更加复杂,因为我们将添加允许用户控制输入、输出并提供可选参数的功能:

第 82 行只是一个if语句,用来检查脚本是否是从命令行调用的。更详细地说,__name__属性允许 Python 告诉我们是哪个函数调用了这段代码。当__name__等于__main__字符串时,表示它是顶级脚本,因此很可能是在命令行执行。这个功能在设计可能被其他脚本调用的代码时尤其重要。其他人可能会将你的函数导入到他们的代码中,如果没有这个条件,脚本在导入时很可能会立即执行。我们有如下代码:

082 if __name__ == '__main__':
083     # Run the program
084     main()

如下图所示,主函数(我们整个脚本)调用main()函数,而main()函数又调用parse_setupapi(),最后调用print_output()函数:

设计main()函数

在第 39 行定义的main()函数在这个场景下相当简单。这个函数在调用parse_setup()之前处理初始变量赋值和设置。在接下来的代码块中,我们创建一个文档字符串,使用三个双引号括起来,其中记录了函数的目的以及它返回的数据,如第 40 到 43 行所示。看起来很简洁吧?随着开发的进行,我们会逐步增强文档,因为在开发初期,事情可能会发生剧烈变化:

039 def main():
040     """
041     Primary controller for script.
042     :return: None
043     """

在文档字符串之后,我们在第 45 行硬编码了setupapi.dev.log文件的路径。这意味着我们的脚本只有在与脚本位于同一目录下存在这个名称的日志文件时才能正常工作:

045     file_path = 'setupapi.dev.log'

在第 48 到 50 行,我们将脚本信息(包括名称和版本)打印到控制台,通知用户脚本正在运行。此外,我们还打印出 22 个等号,用以在设置信息和脚本的其他输出之间提供视觉上的区分:

047     # Print version information when the script is run
048     print('='*22)
049     print('SetupAPI Parser, v', __date__)
050     print('='*22)

最后,在第 51 行,我们调用下一个函数来解析输入文件。这个函数期望一个str对象,表示setupapi.dev.log的路径。虽然这似乎与main()函数的目的相违背,但我们将大部分功能放在一个单独的函数中。这使得我们能够在其他脚本中重用专门处理主要功能的代码,而main()函数则充当一个主要的控制器。这个例子将在代码的最终版本中展示。请参见以下代码行:

051     parse_setupapi(file_path) 

编写parse_setupapi()函数

在第 54 行定义的parse_setupapi()函数接受一个字符串输入,表示 Windows 7 setupapi.dev.log文件的完整路径,具体内容由第 55 至 59 行的文档字符串详细说明。在第 60 行,我们打开main()函数提供的文件路径,并将数据读取到名为in_file的变量中。此打开语句未指定任何参数,因此使用默认设置以只读模式打开文件。此模式防止我们意外地向文件写入。实际上,尝试向以只读模式打开的文件执行write()操作会导致以下错误和信息:

IOError: File not open for reading 

尽管它不允许向文件写入,但在处理数字证据时,应该使用源证据的副本或使用写入阻止技术。

如果对文件及其模式有任何疑问,请参阅第一章,现在换个话题,以获取更多信息。请参见以下代码:

054 def parse_setupapi(setup_file):
055     """
056     Interpret the file
057     :param setup_file: path to the setupapi.dev.log
058     :return: None
059     """
060     in_file = open(setup_file)

在第 61 行,我们使用文件对象的readlines()方法,将in_file变量中的每一行读取到一个名为data的新变量中。该方法返回一个列表,其中每个元素表示文件中的一行。列表中的每个元素都是文件中的文本字符串,以换行符(\n\r\n)字符分隔。在此换行符处,数据被拆分为一个新元素,并作为新条目添加到数据列表中:

061     data = in_file.readlines() 

通过将文件的内容存储在data变量中,我们开始一个for循环,遍历每一行。这个循环使用enumerate()函数,该函数为我们的迭代器添加了一个计数器,记录迭代次数。这是有用的,因为我们希望检查识别 USB 设备条目的模式,然后读取下一行以获取日期值。通过跟踪当前正在处理的元素,我们可以轻松地提取我们需要处理的下一行,即data [n + 1],其中n是当前正在处理行的枚举计数。

063     for i, line in enumerate(data): 

一旦进入循环,在第 64 行,我们评估当前行是否包含字符串device install (hardware initiated)。为了确保我们不会遗漏重要数据,我们将当前行设置为不区分大小写,使用.lower()方法将字符串中的所有字符转换为小写。如果符合条件,我们执行第 65 至 67 行。在第 65 行,我们使用当前迭代计数变量i来访问数据对象中的响应行:

064         if 'device install (hardware initiated)' in line.lower():
065             device_name = data[i].split('-')[1].strip()

访问到值后,我们在字符串上调用.split()方法,通过短横线(-)字符拆分值。拆分后,我们访问拆分列表中的第二个值,并将该字符串传递给strip()函数。.strip()函数在未提供任何值的情况下,将去除字符串两端的空白字符。我们处理响应行,以便它仅包含 USB 标识信息。

以下是处理前的日志条目,位于第 65 行之前:

>>> [Device Install (Hardware initiated) - pciven_8086&dev_100f&subsys_075015ad&rev_014&b70f118&0&0888]

以下是处理后的日志条目:

pciven_8086&dev_100f&subsys_075015ad&rev_014&b70f118&0&0888]

在转换setupapi.dev.log中的第一行 USB 条目后,我们在第 66 行访问数据变量,获取下一行中的日期信息。由于我们知道日期值位于设备信息数据之后的那一行,我们可以将迭代计数变量i加 1,以访问下一行并获取包含日期的行。与设备行解析类似,我们在start字符串上调用.split()函数,提取分割后的第二个元素,代表日期。在保存该值之前,我们需要调用.strip(),以去除字符串两端的空格:

066             date = data[i+1].split('start')[1].strip()

该过程去除了除了日期以外的其他字符。

以下是处理前的日志条目,位于第 66 行之前:

>>>  Section start 2010/11/10 10:21:14.656

以下是处理后的日志条目:

2010/11/10 10:21:14.656

在第 67 行,我们将提取的device_namedate值传递给print_output()函数。该函数会在循环中找到的任何响应行上重复调用。循环完成后,第 68 行的代码会执行,关闭我们最初打开的setupapi.dev.log文件,并释放该文件,供 Python 使用:

067             print_output(device_name, date)
068     in_file.close()

开发print_output()函数

在第 71 行定义的print_output()函数允许我们控制数据如何展示给用户。该函数需要两个字符串作为输入,分别代表 USB 名称和日期,正如文档字符串所定义的那样。在第 78 和 79 行,我们使用.format()方法打印 USB 数据。正如在第一章中讨论的,现在来点完全不同的东西,该函数将花括号({})替换为方法调用中提供的数据。像这样简单的例子并未展示.format()方法的全部威力。然而,该函数可以让我们轻松地进行复杂的字符串格式化。打印输入后,执行将返回到被调用的函数,脚本继续下一个循环的迭代,具体如下:

071 def print_output(usb_name, usb_date):
072     """
073     Print the information discovered
074     :param usb_name: String USB Name to print
075     :param usb_date: String USB Date to print
076     :return: None
077     """
078     print('Device: {}'.format(usb_name))
079     print('First Install: {}'.format(usb_date))

运行脚本

我们现在有一个脚本,可以处理在 Windows 7 中找到的setupapi.dev.log文件,并输出带有相关时间戳的 USB 条目。以下截图展示了如何使用提供的示例setupapi.dev.log文件来执行该脚本,您输出的内容可能会根据使用的setupapi.dev.log文件有所不同:

由于setupapi.dev.log包含大量条目,我们从命令的输出中提取了两个额外的片段,专注于 USB 和 USBSTOR 设备:

我们的第二个代码片段显示了一些 USBSTOR 条目的详细信息:

我们当前的迭代似乎通过提取一些并非仅与 USB 设备相关的响应行,生成了一些误报;我们来看一下如何解决这个问题。

我们的第二次迭代 – setupapi_parser_v2.py

在有了一个可行的原型后,我们现在需要进行一些清理工作。第一次迭代只是一个概念验证,用来展示如何解析setupapi.dev.log文件中的取证信息。通过第二次修订,我们将清理并重构代码,以便未来更容易使用。此外,我们将集成更强大的命令行接口,验证任何用户提供的输入,提高处理效率,并以更好的格式显示结果。

在第 2 到第 6 行之间,我们导入了为这些改进所需的库,同时也导入了一些熟悉的跨版本支持库。argparse是我们在第二章《Python 基础》中详细讨论过的一个库,用于实现和组织来自用户的参数。接下来,我们导入了os库,这是我们将在此脚本中使用的,用来在继续执行之前检查输入文件是否存在。这可以防止我们尝试处理不存在的文件。os模块用于以操作系统无关的方式访问常见的操作系统功能。也就是说,这些功能在不同操作系统中可能会有所不同,但它们都在同一个模块中处理。我们可以使用os模块递归地遍历目录、创建新目录,并修改对象的权限。

最后,我们导入了sys,它将在发生错误时用来退出脚本,避免错误或不正确的输出。导入完成后,我们保留了之前的许可和文档变量,并对它们进行了修改,以提供关于第二次迭代的详细信息:

001 """Second iteration of the setupapi.dev.log parser."""
002 from __future__ import print_function
003 import argparse
004 from io import open
005 import os
006 import sys
...
036 __authors__ = ["Chapin Bryce", "Preston Miller"]
037 __date__ = 20181027
038 __description__ = """This scripts reads a Windows 7 Setup API
039 log and prints USB Devices to the user"""

我们在之前的脚本中定义的函数仍然存在于这里。然而,这些函数包含了新的代码,使得处理方式得到了改进,并以不同的方式实现了逻辑流。以模块化的方式设计代码使我们能够在新的或更新的脚本中重复使用这些函数,从而避免了大规模的重构。这种分段处理也使得在检查函数中抛出的错误时,调试变得更加容易:

042 def main()
...
060 def parse_setupapi()
...
093 def print_output() 

if语句的作用与之前的迭代相同。此条件语句中的附加代码允许用户提供输入,以修改脚本的行为。在第 106 行,我们创建了一个ArgumentParser对象,包含描述、默认帮助格式和包含作者、版本及日期信息的epilog。结合参数选项,我们可以在运行-h开关时,向用户显示关于脚本的有用信息。请参见以下代码:

104 if __name__ == '__main__':
105     # Run this code if the script is run from the command line.
106     parser = argparse.ArgumentParser(
107         description=__description__,
108         epilog='Built by {}. Version {}'.format(
109             ", ".join(__authors__), __date__),
110         formatter_class=argparse.ArgumentDefaultsHelpFormatter
111     )

在定义ArgumentParser对象为parser之后,我们在第 113 行添加了IN_FILE参数,允许用户指定用于输入的文件。这样一来,我们的脚本在输入文件路径上增加了灵活性,而不是硬编码路径,提升了可用性。在第 115 行,我们解析任何提供的参数,并将它们存储在args变量中。最后,在第 118 行调用main()函数,传递表示setupapi.dev.log文件位置的字符串,如下所示:

113     parser.add_argument('IN_FILE',
114         help='Windows 7 SetupAPI file')
115     args = parser.parse_args()
116 
117     # Run main program
118     main(args.IN_FILE)

请注意我们的流程图有所不同。我们的脚本不再是线性的。main()函数调用并接收来自parse_setupapi()方法的返回数据(由虚线箭头指示)。调用print_output()方法将解析后的数据打印到控制台:

改进main()函数

在第 42 行,我们定义了main()函数,现在接受一个新的参数,我们称之为in_file。根据文档字符串定义,该参数是一个指向setupapi.dev.log文件的字符串路径,该文件将被分析:

042 def main(in_file):
043     """
044     Main function to handle operation
045     :param in_file: string path to Windows 7 setupapi.dev.log
046     :return: None
047     """

在第 48 行,我们使用os.path.isfile()函数对输入文件进行验证,确保文件路径和文件存在,如果是脚本可访问的文件,函数将返回true。顺便提一下,os.path.isdir()函数可以用于对目录进行相同类型的验证。这些函数适用于表示绝对路径或相对路径的字符串输入:

048     if os.path.isfile(in_file):

如果文件路径有效,我们会打印脚本的版本。这一次,我们使用.format()方法来创建我们想要的字符串。让我们看看我们在第 49 行和第 51 行使用的格式化符号,从冒号开始定义我们指定的格式。插入符号(^)表示我们希望将提供的对象居中,并使用等号作为填充,使填充字符数最少为 22 个。例如,字符串Hello World!会被夹在两侧的五个等号之间。在我们的脚本中,我们提供一个空字符串作为要格式化的对象,因为我们只希望使用 22 个等号来与输出产生视觉上的分隔。

注意,之前版本的"=" * 22逻辑更简单,我们已使用format()方法演示可用的功能。

在第 50 行,使用.format()方法打印脚本名称和版本字符串,如下所示:

049         print('{:=²²}'.format(''))
050         print('{} {}'.format('SetupAPI Parser, v', __date__))
051         print('{:=²²} \n'.format(''))

在第 52 行,我们调用parse_setupapi()函数并传入已知可用的setupapi.dev.log文件。该函数返回一个 USB 条目列表,每个条目代表一个被发现的设备。device_information中的每个条目由两个元素组成,即设备名称和关联的日期值。在第 53 行,我们使用for循环遍历此列表,并将每个条目传递给第 54 行的print_output()函数:

052         device_information = parse_setupapi(in_file)
053         for device in device_information:
054             print_output(device[0], device[1])

在第 55 行,我们处理提供的文件无效的情况。这是处理无效路径所生成错误的常见方式。在此条件中,我们在第 56 行打印出输入的文件无效。

如果我们想使用 Python 内置的Exception类,我们可以引发 IOError,并提供一个消息,指出输入文件在指定路径下不可用。

在第 57 行,我们调用sys.exit()以错误代码 1 退出程序。你可以在这里放置任何数字;然而,由于我们将其定义为 1,我们将在退出时知道错误发生的地方:

055     else:
056         print('Input is not a file.')
057         sys.exit(1)

调整parse_setupapi()函数

parse_setupapi()函数接受setupapi.dev.log文件的路径作为唯一输入。在打开文件之前,我们必须在第 68 行初始化device_list变量,以便将提取的设备记录存储在一个列表中:

060 def parse_setupapi(setup_log):
061     """
062     Read data from provided file for Device Install Events for
063         USB Devices
064     :param setup_log: str - Path to valid setup api log
065     :return: list of tuples - Tuples contain device name and date
066     in that order
067     """
068     device_list = list()

从第 69 行开始,我们以一种新颖的方式打开输入文件;with语句将文件作为in_file打开,并允许我们在不需要担心关闭文件的情况下操作文件中的数据。在这个with循环内是一个for循环,它遍历每一行,提供了更优的内存管理。在之前的迭代中,我们使用.readlines()方法按行读取整个文件到一个列表中;虽然在较小的文件上不太显眼,但在较大文件上,.readlines()方法会在资源有限的系统上造成性能问题:

069     with open(setup_log) as in_file:
070         for line in in_file:

for循环内,我们利用类似的逻辑来判断该行是否包含我们的设备安装指示符。如果响应,我们将以之前讨论的方式提取设备信息。

通过在第 74 行定义lower_line变量,我们可以通过防止连续调用.lower()方法来截断剩余的代码。请注意,第 73 行到第 75 行反映的是一行换行代码:

在第 73 行,反斜杠(\)字符告诉 Python 忽略换行符,并继续在下一行读取。然后,在第 74 行末尾,我们可以不使用反斜杠直接返回任何位置,因为我们的条件语句已在括号内。

071             lower_line = line.lower()
072             # if 'Device Install (Hardware initiated)' in line:
073             if 'device install (hardware initiated)' in \
074                 lower_line and ('ven' in lower_line or
075                                 'vid' in lower_line):

如第一次迭代中所述,我们的输出中出现了相当数量的误报。这是因为该日志包含与许多类型硬件设备相关的信息,包括与 PCI 接口的设备,而不仅仅是 USB 设备。为了去除这些噪音,我们将检查它是何种类型的设备。

我们可以在第 78 和第 79 行使用反斜杠字符进行分割,以访问device_name变量的第一个分割元素并查看它是否包含usb字符串。如第一章中提到的Now for Something Completely Different,我们需要使用另一个反斜杠来转义单个反斜杠,这样 Python 就能将其视为字面量反斜杠字符。这将响应文件中标记为 USB 和 USBSTOR 的设备。由于鼠标、键盘和集线器可能也会显示为 USB 设备,因此会存在一些误报;然而,我们不希望过度过滤而错过相关的文物。如果我们发现条目不包含usb字符串,我们执行continue语句,告诉 Python 跳过本次迭代,进入for循环的下一次迭代:

078                 if 'usb' not in device_name.split(
079                         '\\')[0].lower():
080                     continue

为了获取日期,我们需要使用不同的程序来获取下一行,因为我们没有调用enumerate()函数。为了解决这个问题,我们在第 87 行使用next()函数跳到文件中的下一行。然后,我们按照之前讨论的方式处理这行内容:

087                 date = next(in_file).split('start')[1].strip()

处理完设备的名称和日期后,我们将其作为元组追加到device_list中,其中设备的名称是第一个值,日期是第二个值。我们需要使用双层括号,以确保数据正确追加。外层括号由.append()函数使用,内层括号允许我们构建一个元组并作为一个值追加。如果没有内层括号,我们将把两个元素作为单独的参数传递给append()函数,而不是作为一个元组元素。所有行在for循环中处理完毕后,with循环将结束并关闭文件。在第 90 行,返回device_list并退出函数。

088                 device_list.append((device_name, date))
089
090     return device_list 

修改print_output()函数

该函数与之前的版本相同,唯一的区别是在第 101 行添加了换行符\n。这有助于在控制台输出中用额外的空格分隔每个条目。在迭代代码时,我们会发现,并非所有函数都需要更新以提高用户体验、准确性或代码效率。只有修改现有函数才能带来某种益处:

093 def print_output(usb_name, usb_date):
094     """
095     Print the information discovered
096     :param usb_name: String USB Name to print
097     :param usb_date: String USB Date to print
098     :return: None
099     """
100     print('Device: {}'.format(usb_name))
101     print('First Install: {}\n'.format(usb_date))

运行脚本

在这一轮迭代中,我们解决了概念验证中的几个问题。这些变化包括以下内容:

  • 通过遍历文件而非将整个文件读取到变量中来改进资源管理

  • 增加了一个参数,允许用户提供setupapi.dev.log文件以进行解析

  • 用户输入文件的验证

  • 过滤响应性命中的内容以减少输出中的噪声

  • 为了便于审核,改进了输出格式

以下截图显示了我们脚本执行后的输出片段:

最后但同样重要的是,我们在之前的设计基础上取得了显著的性能提升。以下截图显示了对机器内存利用率的影响。第一迭代显示在左侧,第二迭代显示在右侧。红色线条标出了我们脚本的开始和结束时间。正如我们所见,通过在文件的每一行上使用 for 循环迭代 readlines() 方法,我们减少了资源的使用。这是一个小规模的资源管理示例,但更大的输入文件将对系统产生更为显著的影响:

我们的最终迭代——setupapi_parser.py

在我们的最终迭代中,我们将继续通过添加去重处理和改进输出内容来优化脚本。尽管第二次迭代引入了过滤非 USB 设备的逻辑,但它并未去重响应的数据。我们将基于设备名称去重,确保每个设备只有一个条目。此外,我们将整合来自第二章 Python 基础usb_lookup.py 脚本,通过显示已知设备的 USB VID 和 PID 来提高脚本的实用性。

我们必须修改 usb_lookup.py 脚本中的代码,以便与 setupapi 脚本正确集成。两者版本之间的差异是微妙的,重点是减少函数调用次数并提高返回数据的质量。在这一迭代过程中,我们将讨论如何实现我们的自定义 USB VID/PID 查找库,以解决 USB 设备名称的问题。在第 4 行,我们导入了 usb_lookup 脚本,如下所示:

001 """Third iteration of the setupapi.dev.log parser."""
002 from __future__ import print_function
003 import argparse
004 from io import open
005 import os
006 import sys
007 import usb_lookup
...
037 __authors__ = ["Chapin Bryce", "Preston Miller"]
038 __date__ = 20181027
039 __description__ = """This scripts reads a Windows 7 Setup API
040     log and prints USB Devices to the user"""

如下代码块所示,我们添加了三个新函数。我们之前的函数进行了少量修改,以适应新功能。大部分修改都集中在我们的新函数中:

  • parse_device_info() 函数负责提取必要的信息,以便在线查找 VID/PID 值,并将原始字符串格式化为标准格式进行比较。

  • 接下来的函数 prep_usb_lookup() 准备并解析数据库,将其转换为支持查询的格式。

  • get_device_names() 函数将匹配的设备信息与数据库相关联。

借助这些新函数,我们为调查人员提供了更多的背景信息:

042 def main():
...
068 def parse_setupapi():
...
092 def parse_device_info():
...
137 def prep_usb_lookup():
...
151 def get_device_names():
...
171 def print_output():  

在调用 main() 函数之前,我们将为解析器添加一个参数。198 行和 199 行定义的 --local 参数允许我们指定一个本地的 usb.ids 文件,以便在离线环境中进行解析。以下代码块展示了我们如何实现这些参数,并分成几行以便于阅读:

187 if __name__ == '__main__':
188     # Run this code if the script is run from the command line.
189     parser = argparse.ArgumentParser(
190         description=__description__,
191         epilog='Built by {}. Version {}'.format(
192             ", ".join(__authors__), __date__),
193         formatter_class=argparse.ArgumentDefaultsHelpFormatter
194     )
195 
196     parser.add_argument('IN_FILE',
197         help='Windows 7 SetupAPI file')
198     parser.add_argument('--local',
199         help='Path to local usb.ids file')
200 
201     args = parser.parse_args()
202 
203     # Run main program
204     main(args.IN_FILE, args.local)

和之前的迭代一样,我们生成了一个流程图来映射脚本的逻辑流程。请注意,它使用与其他流程图相同的图例,尽管由于图形的宽度,我们省略了图例。我们的main()函数执行并直接调用了其他五个函数。这个布局是在第二次迭代中非线性设计的基础上构建的。在每次迭代中,我们继续在main()函数内增加更多控制逻辑。这个函数依赖其他函数来执行任务并返回数据,而不是自己完成工作。这为我们的脚本提供了一种高层次的组织方式,并通过线性执行一个函数接一个函数,帮助保持简洁:

扩展 main()函数

main()函数基本保持不变,只增加了查找 USB VID 和 PID 信息的功能,并为最终用户提供了更优的输出。我们简化了这个查找过程,其中一种方式是通过提供一个文件路径作为local_usb_ids参数,这使得我们可以使用离线文件作为 VID/PID 查找数据库。为了减少输出的杂乱,我们选择移除了脚本名称和版本的打印。在第 51 行,我们新增了一个函数调用prep_usb_info(),用于初始化 VID/PID 查找设置。第 52 行的循环已经重新配置,将每个处理过的设备条目交给第 53 行的parse_device_info()函数。这个新函数负责从日志文件中读取原始字符串,并尝试拆分 VID 和 PID 值以进行查找:

042 def main(in_file, local_usb_ids=None):
043     """
044     Main function to handle operation
045     :param in_file: Str - Path to setupapi log to analyze
046     :return: None
047     """
048 
049     if os.path.isfile(in_file):
050         device_information = parse_setupapi(in_file)
051         usb_ids = prep_usb_lookup(local_usb_ids)
052         for device in device_information:
053             parsed_info = parse_device_info(device)

第 54 行的if语句检查parsed_info变量的值,确保其已经正确解析,并可以与已知值进行比较。如果未准备好进行比较,则不查询或打印信息。请参阅以下代码:

054             if isinstance(parsed_info, dict):
055                 parsed_info = get_device_names(usb_ids,
056                     parsed_info)

第 57 行的附加逻辑检查parsed_info值是否不等于None。如果parse_device_info()函数发现设备没有记录为 USB 设备,parsed_info值将被赋为None,从而消除误报:

057             if parsed_info is not None:
058                 print_output(parsed_info)

最后,在第 59 行,我们向控制台打印日志文件解析完成的信息。在第 62 行到第 65 行,我们处理setupapi.dev.log文件无效或无法通过脚本访问的情况,并在退出前通知用户该情况。退出脚本前打印的消息比之前的版本更为详细。我们提供给用户的细节越多,特别是关于潜在错误的详细信息,用户就越能自行判断并纠正错误:

059         print('\n\n{} parsed and printed successfully.'.format(
060             in_file))
061
062     else:
063         print("Input: {} was not found. Please check your path "
064             "and permissions.".format(in_file))
065         sys.exit(1)

添加到 parse_setup_api()函数

该函数做了少许修改,重点是从日志文件中存储唯一条目。我们在第 76 行创建了一个名为unique_list的新变量,它是一个set数据类型。回想一下,set必须由可哈希且唯一的元素组成,这使其非常适合此解决方案。虽然拥有一个列表和一个集合存储相似数据似乎有些重复,但为了方便比较和演示,我们创建了第二个变量:

068 def parse_setupapi(setup_log):
069     """
070     Read data from provided file for Device Install Events for
071         USB Devices
072     :param setup_log: str - Path to valid setup api log
073     :return: tuple of str - Device name and date
074     """
075     device_list = list()
076     unique_list = set()
077     with open(setup_log) as in_file:
078         for line in in_file:

在第 79 行,我们将行转换为小写,以确保比较时不区分大小写。此时,我们在第 83 到 84 行使用相同的逻辑来处理device_namedate值。我们已将第二次迭代中验证设备类型的代码移至新的parse_device_info()函数中:

079         lower_line = line.lower()
080         if 'device install (hardware initiated)' in \
081                 lower_line and ('vid' in lower_line or
082                                 'ven' in lower_line):
083             device_name = line.split('-')[1].strip()
084             date = next(in_file).split('start')[1].strip()

在我们将device_namedate信息存储到device_list之前,我们检查device_name是否已经存在于unique_list中。如果不存在,我们会在第 86 行添加包含device_namedate的元组。然后,我们通过将该条目添加到unique_list,防止相同的设备再次被处理。在第 89 行,我们返回构建好的元组列表,供下一阶段的处理使用:

085             if device_name not in unique_list:
086                 device_list.append((device_name, date))
087                 unique_list.add(device_name)
088 
089     return device_list

创建parse_device_info()函数

该函数解释来自setupapi.dev.log的原始字符串,并将其转换为一个包含 VID、PID、修订版、唯一 ID 和日期值的字典。此过程在第 94 到 98 行的文档字符串中有描述。文档之后,我们在第 101 到 104 行初始化将在此函数中使用的变量。这些初始化提供了默认的占位符值,以防在无法为这些变量赋值的情况下,字典出现问题:

092 def parse_device_info(device_info):
093     """
094     Parses Vendor, Product, Revision and UID from a Setup API
095         entry
096     :param device_info: string of device information to parse
097     :return: dictionary of parsed information or original string
098         if error
099     """
100     # Initialize variables
101     vid = ''
102     pid = ''
103     rev = ''
104     uid = ''

初始化后,我们将从parse_setup_api()函数传递的device_info值按单个反斜杠进行分割,作为分隔符。为了将其作为字面意义的反斜杠字符进行解释,我们需要使用另一个反斜杠来转义它。第 107 行的分割将设备类型段与包含 VID 和 PID 信息的字符串分开。在此分割之后,我们检查设备类型条目是否反映了 USB 设备。如果设备不是 USB 设备,我们返回None,以确保该设备不被此函数进一步处理,也避免我们为该设备解析 VID 或 PID。通过添加此逻辑,我们避免了花费额外时间和资源处理不相关的条目:

106     # Split string into segments on \
107     segments = device_info[0].split('\\')
108 
109     if 'usb' not in segments[0].lower():
110         return None

接下来,我们访问 segments 列表的第二个元素,该元素包含由 & 符号分隔的 VID、PID 和修订数据。通过 .split(),我们可以在第 114 行的 for 循环中独立访问这些值。我们将该行转为小写,允许我们以不区分大小写的方式,通过一系列条件判断来确定每个项的含义。在第 116 行,我们检查每一项,看看它是否包含 venvid 关键字。如果行中包含这些指示符之一,我们只在第一个下划线字符处进行分割(由整数 1 指定为第二个参数)。这使我们能够从原始字符串中提取 VID。注意我们使用 lower_item 进行比较,而使用 item 变量来存储值,从而保持数据的原始大小写。这个过程对于 pid 变量也是如此,使用 devprodpid 指示符,以及 rev 变量,使用 revmi 指示符,在第 118 至 122 行,如下所示:

114     for item in segments[1].split('&'):
115         lower_item = item.lower()
116         if 'ven' in lower_item or 'vid' in lower_item:
117             vid = item.split('_', 1)[-1]
118         elif 'dev' in lower_item or 'pid' in lower_item or \
119                 'prod' in lower_item:
120             pid = item.split('_', 1)[-1]
121         elif 'rev' in lower_item or 'mi' in lower_item:
122             rev = item.split('_', 1)[-1]

在解析 VID、PID 和修订信息后,我们尝试从 segments 变量中提取唯一 ID,通常这是字符串中的最后一个元素。由于整行内容被括号包裹,我们在第 125 行从 segment 的最右边条目中去掉了右括号。这样就去除了括号,确保它不会包含在我们的唯一 ID 字符串中:

124     if len(segments) >= 3:
125         uid = segments[2].strip(']')

在第 127 行,我们使用 if 语句来判断 vidpid 是否在初始化后获得了值,并在第 128 至 132 行构建一个字典,若这些值未填写,我们返回原始字符串,以允许输出没有额外格式化的条目,如第 134 行所示,确保我们没有因格式化错误而遗漏任何数据:

127     if vid != '' or pid != '':
128         return {'Vendor ID': vid.lower(),
129             'Product ID': pid.lower(),
130             'Revision': rev,
131             'UID': uid,
132             'First Installation Date': device_info[1]}
133     # Unable to parse data, returning whole string
134     return device_info

构建 prep_usb_lookup() 函数

在这个函数中,我们调用了 usb_lookup.py 脚本的 .get_usb_file() 函数。利用提供的 local_usb_ids 参数,我们可以确认是否有已知的 usb.ids 文件路径需要用于此次查询,或者我们是否需要访问在线资源 linux-usb.org/usb.ids,将已知的 USB 信息读取到第 147 行的 usb_file 变量中。这个数据库是一个开源项目,托管了 VID/PID 查找数据库,允许用户参考并扩展该数据库:

137 def prep_usb_lookup(local_usb_ids=None):
138     """
139     Prepare the lookup of USB devices through accessing the most
140     recent copy of the database at http://linux-usb.org/usb.ids
141     or using the provided file and parsing it into a queriable
142     dictionary format.
143     """
144     if local_usb_ids:
145         usb_file = open(local_usb_ids, encoding='latin1')
146     else:
147         usb_file = usb_lookup.get_usb_file()

下载或使用本地副本后,我们将文件对象传递给 .parse_file() 函数进行处理,然后返回 USB VID/PID 数据,作为一个 Python 字典。为了实现这一功能,我们无需创建新的变量,只需在函数调用前加上 return 关键字即可立即返回值,如第 148 行所示:

148     return usb_lookup.parse_file(usb_file) 

构建 get_device_names() 函数

该函数的目的是将 VID 和 PID 信息传入usb_lookup库,并返回解析后的 USB 名称。如后文所述的文档字符串所定义,此函数接受两个字典——第一个包含prep_usb_lookup()中的已知设备数据库,第二个包含parse_device_info()中提取的设备条目。提供这些数据后,我们将返回一个字典,更新为解析后的供应商和产品名称:

151 def get_device_names(usb_dict, device_info):
152     """
153     Query `usb_lookup.py` for device information based on VID/PID.
154     :param usb_dict: Dictionary from usb_lookup.py of known
155         devices.
156     :param device_info: Dictionary containing 'Vendor ID' and
157         'Product ID' keys and values.
158     :return: original dictionary with 'Vendor Name' and
159         'Product Name' keys and values
160     """

该函数调用usb_lookup.search_key()函数,传入处理过的在线 USB 字典以及一个包含设备 VID 和 PID 的两元素列表,分别作为第一个和第二个元素。.search_key()函数返回一个响应匹配项,如果没有匹配项,则返回Unknown字符串。这些数据以元组形式返回,并分配给第 161 行的device_name变量。接着,我们将在第 165 和 166 行将这两个解析后的值拆分为device_info字典的新键。一旦我们扩展了device_info,就可以将其返回,以便打印到控制台。请参见以下行:

161     device_name = usb_lookup.search_key(
162         usb_dict, [device_info['Vendor ID'],
163         device_info['Product ID']])
164 
165     device_info['Vendor Name'] = device_name[0]
166     device_info['Product Name'] = device_name[1]
167 
168     return device_info

增强了print_output()函数

在这个函数中,我们做了一些调整,以改善输出到控制台的效果。通过在第 178 行添加分隔符,我们现在可以在每个条目之间插入 15 个短横线,从而可视化地分隔输出。如我们所见,我们借用了第一次迭代中的相同格式字符串来添加这个分隔符:

171 def print_output(usb_information):
172     """
173     Print formatted information about USB Device
174     :param usb_information: dictionary containing key/value
175         data about each device or tuple of device information
176     :return: None
177     """
178     print('{:-¹⁵}'.format(''))

我们还修改了代码,允许输出更多灵活的字段。在此函数中,我们需要处理两种不同的数据类型:元组和字典,因为有些条目没有解析出的供应商或产品名称。为了处理这些不同的格式,我们必须在第 180 行使用isinstance()函数测试usb_information变量的数据类型。如果值是字典类型,我们将在第 182 行逐行打印字典中的每一个键值对。这是通过第 181 行的for循环与字典的items()方法结合实现的。此方法返回一个包含元组的列表,其中第一个元素是键,第二个元素是值。通过这种方法,我们可以快速提取键值对,如第 181 和 182 行所示:

180     if isinstance(usb_information, dict):
181         for key_name, value_name in usb_information.items():
182             print('{}: {}'.format(key_name, value_name))

如果我们需要打印一个元组,可以使用两个print语句,类似于前一次迭代的输出。由于这些数据来自无法解析的设备,它具有与我们先前迭代相同的固定格式。请参见以下行:

183     elif isinstance(usb_information, tuple):
184         print('Device: {}'.format(usb_information[0]))
185         print('Date: {}'.format(usb_information[1]))

运行脚本

自我们编写第一个脚本以来,我们已经取得了长足进展,现在这个版本执行以下操作:

  • 提供关于 Windows 7 中设备首次安装时间的 USB 设备信息

  • 使用 VID 和 PID 数据解析更多的设备信息

  • 将输出以可读且富有信息的格式打印到控制台

以下是脚本执行的示例及输出说明:

下面的屏幕截图已包含以突出显示我们更深入了解的一些存储设备输出:

挑战

对于本章,我们建议添加对setupapi.log的 Windows XP 格式的支持。用户可以在命令行中提供一个开关来指示将处理哪种类型的日志。对于一个更困难的任务,我们的脚本可以通过指纹识别仅在 Windows XP 与 Windows 7 版本中找到的唯一结构,自动识别日志文件的类型。

改进我们在本章中使用的去重过程将是一个受欢迎的补充。正如我们所发现的,一些条目在设备条目中嵌入了 UID 值。该值通常由制造商分配,并可以用于去重条目。正如您在输出中可能注意到的那样,UID 可能包含额外的和可能不重要的和符号,这些符号建议它们的来源。通过应用一些简单的逻辑,可能是在一个新函数中,我们可以基于 UID 来改进去重功能。

最后,我们可以考虑我们的输出格式。虽然在控制台友好的格式中显示东西很有用,但在处理完本书其余章节之后,我们应考虑添加对 CSV 或其他报告的支持。这可能是一个好功能,重新审视一下。

摘要

在本章中,您学习了如何使用 Python 解析普通文本文件。这个过程可以用于其他日志文件,包括防火墙、Web 服务器或其他应用程序和服务的日志文件。按照这些步骤,我们可以识别出适合脚本的重复数据结构,处理它们的数据,并将结果输出给用户。通过我们的迭代构建过程,我们实现了一个测试-然后-编码的方法,其中我们建立了一个工作原型,然后不断改进它成为一个可行且可靠的取证工具。

除了我们在这里探讨的文本格式外,还有一些文件具有更具体的结构,并以序列化格式存储。其他文件,如 HTML、XML 和 JSON,以一种可以轻松转换为一系列 Python 对象的方式存储数据。 该项目的代码可以从 GitHub 或 Packt 下载,如前言中所述。

在接下来的章节中,我们将探讨在 Python 中解析、操作和与这些结构化格式交互的方法。

第四章:处理序列化数据结构

在本章中,我们将通过操作JavaScript 对象表示法JSON)结构化数据,进一步提高处理嵌套列表和字典的技能。我们的研究对象是原始比特币账户数据,其中包含发送和接收的所有交易列表等信息。我们将使用网页应用程序接口API)访问此数据集,并以有利于分析的方式进行解析。

API 是为软件产品创建的,允许程序员以定义的方式与软件进行交互。并非所有给定软件的公共 API 都是可用的。当可用时,API 通过提供与软件交互的方法,加速了代码开发,因为 API 将处理低级的实现细节。开发人员实现 API 旨在鼓励他人构建支持程序,并进一步控制其他开发人员的代码如何与他们的软件交互。通过创建 API,开发人员为其他程序员提供了与他们的程序进行受控交互的方式。

在本章中,我们将使用来自www.blockchain.info的网页 API 来查询并接收给定比特币地址的比特币账户信息。该 API 生成的 JSON 数据可以使用标准库中的 JSON 模块转换为 Python 对象。有关其 API 的说明和示例,可以在www.blockchain.info/api/blockchain_api找到。

在本章中,我们将涵盖以下内容:

  • 讨论和操作包括可扩展标记语言XML)和 JSON 数据在内的序列化结构

  • 使用 Python 创建日志

  • 以 CSV 输出格式报告结果

本章的代码是使用 Python 2.7.15 和 Python 3.7.1 开发和测试的。bitcoin_address_lookup.v1.pybitcoin_address_lookup.v2.py 脚本是为了与 Python 3.7.1 而非 Python 2.7.15 一起使用而开发的。

序列化数据结构

序列化是一个过程,通过该过程,数据对象在计算机系统的存储过程中被保留。序列化数据保留了对象的原始类型。也就是说,我们可以将字典、列表、整数或字符串序列化到一个文件中。稍后,当我们反序列化这个文件时,这些对象将仍然保持它们的原始数据类型。序列化非常好,因为如果我们将脚本对象存储到文本文件中,我们就无法轻松地将这些对象恢复到适当的数据类型。如我们所知,读取文本文件时,读取的数据是字符串。

XML 和 JSON 是两种常见的纯文本编码序列化格式。你可能已经习惯于在法医调查中分析这些文件。熟悉移动设备取证的分析师可能会识别包含帐户或配置详细信息的特定应用程序 XML 文件。让我们看看如何利用 Python 解析 XML 和 JSON 文件。

我们可以使用xml模块解析任何包括 XML 和 HTML 数据的标记语言。以下book.xml文件包含关于这本书的详细信息。如果你之前从未见过 XML 数据,第一件你可能会注意到的是它与 HTML 结构相似,HTML 是另一种标记语言,其中内容被打开和关闭标签包围,如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<root>
  <authors>Preston Miller &amp; Chapin Bryce</authors>
  <chapters>
   <element>
     <chapterNumber>1</chapterNumber>
     <chapterTitle>Now for Something Completely Different</chapterTitle>
     <pageCount>30</pageCount>
   </element>
   <element>
     <chapterNumber>2</chapterNumber>
     <chapterTitle>Python Fundamentals</chapterTitle>
     <pageCount>25</pageCount>
   </element>
  </chapters>
  <numberOfChapters>13</numberOfChapters>
  <pageCount>500</pageCount>
  <publisher>Packt Publishing</publisher>
  <title>Learning Python for Forensics</title>
</root> 

对于分析师来说,XML 和 JSON 文件很容易阅读,因为它们是纯文本格式。然而,当文件包含数千行时,手动审核变得不切实际。幸运的是,这些文件具有高度的结构化,更棒的是,它们是为程序使用而设计的。

要探索 XML,我们需要使用xml模块中的ElementTree类,它将解析数据并允许我们遍历根节点的子节点。为了解析数据,我们必须指定正在解析的文件。在这个例子中,我们的book.xml文件位于与 Python 交互式提示符相同的工作目录中。如果情况不是这样,我们需要在文件名外指定文件路径。如果你使用的是 Python 2,请确保从__future__导入print_function。我们使用getroot()函数访问根级节点,如下所示:

>>> import xml.etree.ElementTree as ET
>>> tree = ET.parse('book.xml')
>>> root = tree.getroot() 

通过根元素,让我们使用find()函数在 XML 文件中搜索第一个authors标签实例。每个元素都有不同的属性,如tagattribtexttag元素是一个描述数据的字符串,在这个例子中是authors。如果存在,属性(attrib)会存储在字典中。属性是赋值给标签内的值。例如,我们可以创建一个chapter标签:

<chapter number=2, title="Python Fundamentals", count=20 /> 

该对象的属性将是一个字典,包含键numbertitlecount及其各自的值。要访问标签之间的内容(例如chapterNumber),我们需要使用text属性。

我们可以使用findall()函数查找指定子标签的所有出现实例。在以下示例中,我们正在查找数据集中所有出现的chapters/element。一旦找到,我们可以使用列表索引访问element父标签中的特定标签。在这种情况下,我们只想访问element中前两个位置的章节号和标题。请看以下示例:

>>> print(root.find('authors').text)
Preston Miller & Chapin Bryce
>>> for element in root.findall('chapters/element'):
... print('Chapter #{}'.format(element[0].text))
... print('Chapter Title: {}'.format(element[1].text))
...
Chapter #1
Chapter Title: Now for Something Completely Different
Chapter #2
Chapter Title: Python Fundamentals 

我们可以使用许多其他方法来处理标记语言文件,利用 xml 模块。完整的文档,请参考 docs.python.org/3/library/xml.etree.elementtree.html.

在介绍完 XML 后,让我们来看一下将相同示例存储为 JSON 数据,并且更重要的是,我们如何使用 Python 来解释这些数据。稍后,我们将创建一个名为 book.json 的 JSON 文件;请注意使用的键,例如 title、authors、publisher,它们的关联值通过冒号分隔。这与 Python 中字典的结构类似。此外,注意 chapters 键使用了方括号,然后如何通过逗号分隔嵌套的类似字典的结构。在 Python 中,加载后,这个 chapters 结构会被解释为一个包含字典的列表,前提是使用 json 模块:

{
  "title": "Learning Python Forensics",
  "authors": "Preston Miller & Chapin Bryce",
  "publisher": "Packt Publishing",
  "pageCount": 500,
  "numberOfChapters": 13,
  "chapters":
  [
   {
    "chapterNumber": 1,
    "chapterTitle": "Now for Something Completely Different",
    "pageCount": 30
   },
   {
    "chapterNumber": 2,
    "chapterTitle": "Python Fundamentals",
    "pageCount": 25
   }
  ]
}

为了使用 json 模块解析该数据结构,我们使用 loads() 函数。与 XML 示例不同,我们需要首先打开一个文件对象,然后才能使用 loads() 将数据转换。在下一个代码块中,book.json 文件被打开,它与交互式提示位于同一工作目录,并将其内容读取到 loads() 方法中。顺便说一下,我们可以使用 dump() 函数执行反向操作,将 Python 对象转换为 JSON 格式进行存储。与 XML 代码块类似,如果你使用的是 Python 2,请从 __future__ 导入 print_function

>>> import json
>>> jsonfile = open('book.json', 'r')
>>> decoded_data = json.loads(jsonfile.read())
>>> print(type(decoded_data))
<class'dict'>
>>> print(decoded_data.keys())
dict_keys(['title', 'authors', 'publisher', 'pageCount', 'numberOfChapters', 'chapters'])

该模块的 loads() 方法读取 JSON 文件的字符串内容,并将数据重构为 Python 对象。如前面的代码所示,整个结构被存储在一个包含键值对的字典中。JSON 能够存储对象的原始数据类型。例如,pageCount 被反序列化为整数,title 被反序列化为字符串对象。

并非所有数据都以字典的形式存储。chapters 键被重构为一个列表。我们可以使用 for 循环遍历 chapters 并打印出任何相关的细节:

>>> for chapter in decoded_data['chapters']:
... number = chapter['chapterNumber']
... title = chapter['chapterTitle']
... pages = chapter['pageCount']
... print('Chapter {}, {}, is {} pages.'.format(number, title, pages))
... 
Chapter 1, Now For Something Completely Different, is 30 pages.
Chapter 2, Python Fundamentals, is 25 pages. 

为了明确,chapters 键在 JSON 文件中被存储为一个列表,并包含每个 chapter 元素的嵌套字典。当遍历这些字典列表时,我们存储并打印与字典键相关的值。我们将使用这个技术在更大规模的 Bitcoin JSON 数据解析中。有关 json 模块的更多细节,请参阅 docs.python.org/3/library/json.html。本节中使用的 XML 和 JSON 示例文件可以在本章的代码包中找到。还有其他模块,如 pickleshelve,可以用于数据序列化,但它们不在本书范围内。

一个简单的 Bitcoin 网络 API

比特币已经席卷全球,并成为新闻头条;它是最成功、最著名的——或者说是臭名昭著的——去中心化加密货币。比特币被视为一种“匿名”的在线现金替代品。曾经已经关闭的 Tor 网络上的非法市场 SilkRoad 接受比特币作为支付手段,用于非法商品或服务的交易。自从比特币获得广泛关注以来,一些网站和实体商店也开始接受比特币支付。它也因其价值远超预期而获得了广泛的公众关注。

比特币为个人分配地址以存储他们的比特币。这些用户可以通过指定他们想使用的地址来发送或接收比特币。在比特币中,地址由 34 个区分大小写的字母数字字符表示。幸运的是,所有交易都公开存储在区块链上。区块链记录了每笔交易的时间、输入、输出和金额。此外,每笔交易都被分配了一个唯一的交易哈希。

区块链浏览器是允许个人搜索区块链的程序。例如,我们可以搜索特定的地址或交易。一个这样的区块链浏览器位于www.blockchain.com/explorer,我们将使用它来生成我们的数据集。让我们看看一些需要解析的数据。

我们的脚本将处理 JSON 结构的交易数据,处理后将这些信息以分析准备好的状态输出给审查人员。在用户输入感兴趣的地址后,我们将使用blockchain.info API 查询区块链,并拉取相关账户数据,包括所有关联的交易,具体如下:

https://blockchain.info/address/%btc_address%?format=json 

我们将通过替换%btc_address%为实际的感兴趣地址来查询前述 URL。在本次练习中,我们将调查125riCXE2MtxHbNZkRtExPGAfbv7LsY3Wa地址。如果你打开浏览器并将%btc_address%替换为感兴趣的地址,我们可以看到我们的脚本负责解析的原始 JSON 数据:

{
 "hash160":"0be34924c8147535c5d5a077d6f398e2d3f20e2c",
 "address":"125riCXE2MtxHbNZkRtExPGAfbv7LsY3Wa",
 "n_tx":25,
 "total_received":80000000,
 "total_sent":80000000,
 "final_balance":0,
 "txs":
  [
   ...
  ]
} 

这是我们之前 JSON 示例的一个更复杂版本;然而,适用的规则相同。从hash160开始,包含一般账户信息,如地址、交易数量、余额、已发送和已接收的总额。接下来是交易数组,用方括号表示,包含该地址涉及的每一笔交易。

看一笔单独的交易,有几个键值很突出,比如输入和输出列表中的addr值、时间和哈希。当我们遍历txs列表时,这些键值将被用来重建每笔交易,并向审查员展示这些信息。我们有如下的交易:

"txs":[{
  "lock_time":0,
  "result":0,
  "ver":1,
  "size":225,
  "inputs":[
   {
     "sequence":4294967295,
     "prev_out":{
      "spent":true,
      "tx_index":103263818,
      "type":0,
      "addr":"125riCXE2MtxHbNZkRtExPGAfbv7LsY3Wa",
      "value":51498513,
      "n":1,
        "script":"76a9140be34924c8147535c5d5a077d6f398e2d3f20e2c88ac"
    },
  "script":"4730440220673b8c6485b263fa15c75adc5de55c902cf80451c3c54f8e49df4357ecd1a3ae022047aff8f9fb960f0f5b0313869b8042c7a81356e4cd23c9934ed1490110911ce9012103e92a19202a543d7da710af28c956807c13f31832a18c1893954f905b339034fb"
  }],
  "time":1442766495,
  "tx_index":103276852,
  "vin_sz":1,
    "hash":"f00febdc80e67c72d9c4d50ae2aa43eec2684725b566ec2a9fa9e8dbfc449827",
  "vout_sz":2,
  "relayed_by":"127.0.0.1",
  "out":
   {
     "spent":false,
     "tx_index":103276852,
     "type":0,
     "addr":"12ytXWtNpxaEYW6ZvM564hVnsiFn4QnhAT",
     "value":100000,
     "n":0,
     "script":"76a91415ba6e75f51b0071e33152e5d34c2f6bca7998e888ac"
   }

和前一章一样,我们将以模块化的方式进行这项任务,通过迭代构建我们的脚本。除了处理序列化数据结构外,我们还将引入创建日志和将数据写入 CSV 文件的概念。像argparse一样,loggingcsv模块将在我们的取证脚本中频繁出现。

我们的第一次迭代 – bitcoin_address_lookup.v1.py

我们脚本的第一次迭代将主要集中在适当地获取和处理数据。在这个脚本中,我们将把账户的交易摘要打印到控制台。在后续的迭代中,我们将添加日志记录并输出数据到 CSV 文件。此脚本已专门为 Python 3.7.1 编写和测试。urllib库(我们用来发起 HTTP 请求的库)在 Python 2 和 3 中结构不同。在此脚本的最终版本中,我们将展示必要的代码,使该脚本兼容 Python 2 和 3。

在脚本的初始版本中,我们将使用五个模块。argparsejsonurllibsys模块都是标准库的一部分。unix_converter模块是我们在[第二章《Python 基础》中编写的几乎未修改的脚本,用于将 Unix 时间戳转换为比特币交易数据。本章提供的代码中包含了该模块的具体版本。argparseurllib分别在之前用于用户输入和网页请求。json模块负责将我们的交易数据加载到 Python 对象中,以便我们进行处理:

001 """First iteration of the Bitcoin JSON transaction parser."""
002 import argparse
003 import json
004 import urllib.request
005 import unix_converter as unix
006 import sys
...
036 __authors__ = ["Chapin Bryce", "Preston Miller"]
037 __date__ = 20181027
038 __description__ = """This scripts downloads address transactions
039     using blockchain.info public APIs"""

我们脚本的逻辑由五个函数处理。main()函数定义在第 42 行,作为其他四个函数之间的协调者。首先,我们将用户提供的地址传递给get_address()函数。该函数负责使用urllib调用blockchain.info API,并返回包含该地址交易的 JSON 数据。

接下来,调用print_transactions()遍历嵌套的字典和列表,打印出交易详情。在print_transactions()中,调用了print_header()get_inputs()print_header()函数负责打印非交易数据,如交易数量、当前余额、总发送和接收值:

042 def main():
...
053 def get_address():
...
070 def print_transactions():
...
098 def print_header():
...
116 def get_inputs():

如前所见,我们使用argparse创建一个ArgumentParser对象,并添加相应的参数。我们唯一的参数ADDR是一个位置参数,代表感兴趣的比特币地址。我们在第 145 行调用main()函数并传递ADDR参数:

128 if __name__ == '__main__':
129     # Run this code if the script is run from the command line.
130     parser = argparse.ArgumentParser(
131         description=__description__,
132         epilog='Built by {}. Version {}'.format(
133             ", ".join(__authors__), __date__),
134         formatter_class=argparse.ArgumentDefaultsHelpFormatter
135     )
136     parser.add_argument('ADDR', help='Bitcoin Address')
137     args = parser.parse_args()
138 
139     # Print Script Information
140     print('{:=²²}'.format(''))
141     print('{}'.format('Bitcoin Address Lookup'))
142     print('{:=²²} \n'.format(''))
143 
144     # Run main program
145     main(args.ADDR)

我们脚本的流程图如下所示:

探索 main()函数

main()函数相对简单。首先,在第 48 行,我们调用get_address()函数并将结果存储在一个名为raw_account的变量中。该变量包含我们的 JSON 格式的交易数据。为了操作这些数据,我们使用json.loads()函数将 JSON 数据反序列化并存储在 account 变量中。此时,我们的 account 变量是一个字典和列表的系列,可以开始遍历,正如我们在第 50 行调用的print_transactions()函数所做的那样:

042 def main(address):
043     """
044     The main function handles coordinating logic
045     :param address: The Bitcoin Address to lookup
046     :return: Nothing
047     """
048     raw_account = get_address(address)
049     account = json.loads(raw_account.read())
050     print_transactions(account)

理解get_address()函数

这是我们脚本中的一个重要组成部分,尽管它可能容易出错,因为它依赖于用户正确提供数据。代码本身只是一个简单的数据请求。然而,在处理用户提供的参数时,我们不能假设用户给了脚本正确的数据。考虑到比特币地址的长度和看似随机的序列,用户可能会提供一个不正确的地址。我们将捕获来自urllib.error模块的任何URLError实例,以处理格式错误的输入。URLError不是我们之前提到的内置异常,而是urllib模块定义的自定义异常:

053 def get_address(address):
054     """
055     The get_address function uses the blockchain.info Data API
056     to pull pull down account information and transactions for
057     address of interest
058     :param address: The Bitcoin Address to lookup
059     :return: The response of the url request
060     """

在第 62 行,我们使用字符串format()方法将用户提供的地址插入到blockchain.info API 调用中。然后,我们尝试使用urllib.request.urlopen()函数返回请求的数据。如果用户提供了无效的地址,或者用户没有互联网连接,则会捕获到URLError。一旦捕获到错误,我们会通知用户并退出脚本,在第 67 行调用sys.exit(1)

061     url = 'https://blockchain.info/address/{}?format=json'
062     formatted_url = url.format(address)
063     try:
064         return urllib.request.urlopen(formatted_url)
065     except urllib.error.URLError:
066         print('Received URL Error for {}'.format(formatted_url))
067         sys.exit(1)

使用print_transactions()函数

这个函数处理我们代码中大部分的处理逻辑。这个函数遍历从加载的 JSON 数据中嵌入的字典组成的交易列表,或称txs列表。

对于每个交易,我们将打印出其相对交易编号、交易哈希和交易时间。哈希和时间键容易访问,因为它们的值存储在最外层的字典中。交易的输入和输出细节存储在一个内嵌字典中,该字典映射到输入和输出键。

如常见情况一样,时间值是以 Unix 时间存储的。幸运的是,在第二章,Python 基础知识中,我们编写了一个脚本来处理这种转换,再次我们将通过调用unix_converter()方法重用这个脚本。对这个函数做的唯一更改是移除了 UTC 标签,因为这些时间值存储的是本地时间。

因为我们将unix_converter模块导入为unix,所以我们必须使用unix来引用该模块。

让我们快速看一下我们正在处理的数据结构。想象一下,如果我们能够在执行过程中暂停代码并检查变量的内容,例如我们的账户变量。在本书的这一部分,我们将仅展示执行阶段account变量的内容。稍后在本书中,我们将更正式地讨论如何使用pdb模块进行 Python 调试。

关于 Python 调试器(pdb)的更多信息可以在文档中找到:docs.python.org/3/library/pdb.html

在下面的示例中,我们可以看到account字典中第一个交易的键映射到txs列表中的内容。hashtime键分别映射到字符串和整数对象,我们可以将它们保留为脚本中的变量:

>>> print(account['txs'][0].keys())
dict_keys(['ver', 'inputs', 'weight', 'block_height', 'relayed_by',
 'out', 'lock_time', 'result', 'size', 'time', 'tx_index', 'vin_sz',
 'hash', 'vout_sz'])

接下来,我们需要访问交易的输入和输出细节。让我们查看out字典。通过查看键,我们可以立即识别出地址addr和作为重要信息的value。了解了布局和我们希望向用户呈现的数据后,让我们来看一下如何处理txs列表中的每一笔交易:

>>> print(account['txs'][0]['out'][0].keys())
dict_keys(['spent', 'tx_index', 'type', 'addr', 'value', 'n',
 'script'])

在打印每个交易的详细信息之前,我们通过第 77 行的print_header()辅助函数调用并打印基本账户信息到控制台。在第 79 行,我们开始遍历txs列表中的每一笔交易。我们使用enumerate()函数来包装列表,以更新我们的计数器,并在for循环中的第一个变量i中跟踪我们正在处理的交易:

070 def print_transactions(account):
071     """
072     The print_transaction function is responsible for presenting
073     transaction details to end user.
074     :param account: The JSON decoded account and transaction data
075     :return:
076     """
077     print_header(account)
078     print('Transactions')
079     for i, tx in enumerate(account['txs']):

对于每个交易,我们打印相对的交易编号、hashtime。正如我们之前看到的,我们可以通过提供适当的键来访问hashtime。记住,我们确实需要转换存储在time键中的 Unix 时间戳。我们通过将该值传递给unix_converter()函数来完成这个任务:

080         print('Transaction #{}'.format(i))
081         print('Transaction Hash:', tx['hash'])
082         print('Transaction Date: {}'.format(
083             unix.unix_converter(tx['time'])))

在第 84 行,我们开始遍历外部字典中的输出列表。这个列表由多个字典组成,每个字典表示一个给定交易的输出。我们感兴趣的键是这些字典中的addrvalue键:

084         for outputs in tx['out']:

请注意,value值(这不是错别字)以整数而非浮点数形式存储,因此一个 0.025 BTC 的交易会存储为 2,500,000。我们需要将此值乘以 10^(-8),以准确反映交易的价值。让我们在第 85 行调用我们的辅助函数get_inputs()。此函数将单独解析交易的输入,并以列表形式返回数据:

085              inputs = get_inputs(tx)

在第 86 行,我们检查是否有多个输入地址。这个条件将决定我们打印语句的样子。基本上,如果有多个输入地址,每个地址将通过与号连接,以清楚地表示额外的地址。

第 87 行和 91 行的print语句使用字符串格式化方法,在控制台中适当显示我们处理过的数据。在这些字符串中,我们使用花括号来表示三个不同的变量。我们使用join()函数通过某个分隔符将列表转换为字符串。第二和第三个变量分别是输出的addrvalue键:

086             if len(inputs) > 1:
087                 print('{} --> {} ({:.8f} BTC)'.format(
088                     ' & '.join(inputs), output['addr'],
089                     outputs['value'] * 10**-8))
090             else:
091                 print('{} --> {} ({:.8f} BTC)'.format(
092                     ''.join(inputs), outputs['addr'],
093                     outputs['value'] * 10**-8))
094 
095         print('{:=²²}\n'.format(''))

注意值对象的表示方式与其他不同。由于我们的值是浮动的,我们可以使用字符串格式化来正确显示数据的精确度。在格式说明符{:.8f}中,8表示我们希望允许的十进制位数。如果超过八位小数,则值会四舍五入到最接近的数字。fformat()方法知道输入的数据类型是浮动类型。这个函数虽然负责将结果打印给用户,但它使用了两个辅助函数来完成其任务。

print_header() 辅助函数

print_header() 辅助函数在打印交易信息之前,将账户信息打印到控制台。具体来说,会显示地址、交易数量、当前余额,以及发送和接收的比特币总量。请查看以下代码:

098 def print_header(account):
099     """
100     The print_header function prints overall header information
101     containing basic address information.
102     :param account: The JSON decoded account and transaction data
103     :return: Nothing
104     """

在第 105 到 113 行之间,我们使用字符串格式化方法打印出我们感兴趣的值。在程序设计过程中,我们选择将其作为一个独立函数,以提高代码的可读性。从功能上讲,这段代码本可以很容易地,且最初确实是在print_transactions()函数中实现。它被分离出来是为了将执行的不同阶段进行模块化。第 113 行的打印语句的目的是创建一行 22 个左对齐的等号,用来在控制台中将账户信息与交易信息分开:

105     print('Address:', account['address'])
106     print('Current Balance: {:.8f} BTC'.format(
107         account['final_balance'] * 10**-8))
108     print('Total Sent: {:.8f} BTC'.format(
109         account['total_sent'] * 10**-8))
110     print('Total Received: {:.8f} BTC'.format(
111         account['total_received'] * 10**-8))
112     print('Number of Transactions:', account['n_tx'])
113     print('{:=²²}\n'.format(''))

get_inputs() 辅助函数

这个辅助函数负责获取发送交易的地址信息。此信息位于多个嵌套字典中。由于可能有多个输入,我们必须遍历输入列表中的一个或多个元素。当我们找到输入地址时,会将其添加到一个在第 123 行实例化的输入列表中,如以下代码所示:

116 def get_inputs(tx):
117     """
118     The get_inputs function is a small helper function that returns
119     input addresses for a given transaction
120     :param tx: A single instance of a Bitcoin transaction
121     :return: inputs, a list of inputs
122     """
123     inputs = []

对于每个输入,都有一个字典键prev_out,它的值是另一个字典。我们需要的信息被映射到该内层字典中的addr键。我们将这些地址附加到输入列表中,并在for循环执行结束后在第 126 行返回该列表:

124     for input_addr in tx['inputs']:
125         inputs.append(input_addr['prev_out']['addr'])
126     return inputs

运行脚本

现在,让我们运行脚本,看看我们的劳动成果。在后面提到的输出中,我们可以看到首先是打印给用户的头部信息,接着是若干交易。数值对象已正确表示,并具有适当的精度。对于这个特定的例子,有四个输入值。使用' & '.join(inputs)语句可以更清晰地将不同的输入值分开:

随着概念验证的完成,我们现在可以遍历并解决当前版本中的一些固有问题。一个问题是我们没有记录任何关于脚本执行的数据。例如,审查员的笔记应包括时间、任何错误或问题以及取证过程的结果。在第二次迭代中,我们将使用日志模块解决这个问题。此模块将存储我们程序执行的日志,以便分析人员记录程序开始、停止的时间,以及与该过程相关的任何其他数据。

我们的第二次迭代 – bitcoin_address_lookup.v2.py

这次迭代通过记录执行的详细信息解决了脚本中的一个问题。实际上,我们使用日志来创建脚本的“证据链”。我们的证据链将告诉其他方我们的脚本在不同时间点做了什么,以及遇到的任何错误。我们提到过,传统的日志记录目的是用于调试吗?然而,我们经过取证的日志在任何情况下都适用。这将通过一个实际示例简要介绍日志模块的基本用法。有关更多示例和参考,请参见docs.python.org/3/library/logging.html文档。

我们已将两个模块添加到导入中:oslogging。如果用户提供了日志文件目录,我们将使用os模块附加该目录并更新日志的路径。为了写入日志,我们将使用logging模块。这两个模块都是标准库的一部分。请参阅以下代码:

001 """Second iteration of the Bitcoin JSON transaction parser."""
002 import argparse
003 import json
004 import logging
005 import sys
006 import os
007 import urllib.request
008 import unix_converter as unix
...
038 __authors__ = ["Chapin Bryce", "Preston Miller"]
039 __date__ = 20181027
040 __description__ = """This scripts downloads address transactions
041     using blockchain.info public APIs"""

由于新增的代码,我们的函数定义在脚本后面。然而,它们的流程和目的与之前相同:

044 def main():
...
059 def get_address():
...
081 def print_transactions():
...
116 def print_header():
...
134 def get_inputs():

我们在第 155 行添加了一个新的可选参数-l。此可选参数可用于指定要将日志写入的目录。如果未提供,则日志将在当前工作目录中创建:

146 if __name__ == '__main__':
147     # Run this code if the script is run from the command line.
148     parser = argparse.ArgumentParser(
149     description=__description__,
150     epilog='Built by {}. Version {}'.format(
151         ", ".join(__authors__), __date__),
152     formatter_class=argparse.ArgumentDefaultsHelpFormatter
153     )
154     parser.add_argument('ADDR', help='Bitcoin Address')
155     parser.add_argument('-l', help="""Specify log directory.
156         Defaults to current working directory.""")
157     args = parser.parse_args()

在第 159 行,我们检查用户是否提供了可选参数-l。如果提供了,我们使用os.path.join()函数将所需的日志文件名附加到提供的目录,并将其存储在名为log_path的变量中。如果没有提供可选参数,我们的log_path变量将只是日志文件的文件名:

159     # Set up Log
160     if args.l:
161         if not os.path.exists(args.l):
162             os.makedirs(args.l)
163         log_path = os.path.join(args.l, 'btc_addr_lookup.log')
164     else:
165         log_path = 'btc_addr_lookup.log'

日志对象是在第 165 行通过logging.basicConfig()方法创建的。该方法接受多种关键字参数。filename关键字参数指定了我们的日志文件的路径和名称,该路径存储在log_path变量中。level关键字设置日志的级别。日志有五个不同的级别,按从最低到最高的紧急程度顺序排列:

  • DEBUG

  • INFO

  • WARN(默认级别)

  • ERROR

  • CRITICAL

如果没有提供级别,日志默认使用WARN级别。日志的级别非常重要。只有当消息的级别与日志级别相同或更高时,日志才会记录该条目。通过将日志设置为DEBUG级别,即最低级别,我们可以将任何级别的消息写入日志:

165     logging.basicConfig(
166         filename=log_path, level=logging.DEBUG,
167         format='%(asctime)s | %(levelname)s | %(message)s',
168         filemode='w')

每个级别都有不同的意义,应该根据情况正确使用。DEBUG级别应在记录程序执行的技术细节时使用。INFO级别可用于记录程序的启动、停止以及各个执行阶段的成功情况。其余级别可用于检测潜在的异常执行、生成错误时,或在关键失败时使用。

format 关键字指定我们希望如何构建日志本身。我们的日志将采用以下格式:

time | level | message 

例如,这种格式会创建一个日志文件,其中包含添加条目时的本地时间、适当的级别以及任何消息,所有信息之间用管道符号分隔。要在日志中创建条目,我们可以调用日志对象的debug()info()warn()error()critical()方法,并将消息作为字符串传入。例如,基于以下代码,我们预计会在日志中生成如下条目:

logging.error("Blah Blah function has generated an error from the following input: xyz123.")  

以下是日志记录:

2015-11-06 19:51:47,446 | ERROR | Blah Blah function has generated an error from the following input: xyz123. 

最后,filemode='w'参数用于在每次脚本执行时覆盖日志中的先前条目。这意味着日志中只会保存最近一次执行的条目。如果我们希望将每次执行周期追加到日志的末尾,可以省略此关键字参数。省略时,默认的文件模式为a,正如你在第一章《完全不同的东西》中学到的那样,它允许我们追加到现有文件的底部。

配置完成后,我们可以开始向日志中写入信息。在第 172 行和 173 行,我们记录了程序执行前用户系统的详细信息。由于内容的技术性较低,我们将此信息写入DEBUG级别的日志:

171     logging.info('Starting Bitcoin Lookup v. {}'.format(__date__))
172     logging.debug('System ' + sys.platform)
173     logging.debug('Version ' + sys.version.replace("\n", " "))
174 
175     # Print Script Information
176     print('{:=²²}'.format(''))
177     print('{}'.format('Bitcoin Address Lookup'))
178     print('{:=²²} \n'.format(''))
179 
180     # Run main program
181     main(args.ADDR)

我们的脚本版本基本相同,遵循与之前相同的流程示意图。

修改main()函数

在第 44 行定义的main()函数大体未做更改。我们在第 50 行和第 52 行添加了两个INFO级别的日志消息,关于脚本执行的情况。其余部分与第一次迭代中的方法一致:

044 def main(address):
045     """
046     The main function handles coordinating logic
047     :param address: The Bitcoin Address to lookup
048     :return: Nothing
049     """
050     logging.info('Initiated program for {} address'.format(
051         address))
052     logging.info(
053         'Obtaining JSON structured data from blockchain.info')
054     raw_account = get_address(address)
055     account = json.loads(raw_account.read())
056     print_transactions(account)

改进get_address()函数

使用get_address()方法时,我们继续向脚本中添加日志信息。这次,当捕获到URLError时,我们将Exception对象存储为e,以便提取更多调试信息:

059 def get_address(address):
060     """
061     The get_address function uses the blockchain.info Data API
062     to pull pull down account information and transactions for
063     address of interest
064     :param address: The Bitcoin Address to lookup
065     :return: The response of the url request
066     """

对于URLError,我们希望记录codeheadersreason属性。这些属性包含信息,例如 HTML 错误代码——例如,404表示页面未找到——以及错误代码的描述。我们将存储这些数据,以便保留错误发生时的上下文:

067     url = 'https://blockchain.info/address/{}?format=json'
068     formatted_url = url.format(address)
069     try:
070         return urllib.request.urlopen(formatted_url)
071     except urllib.error.URLError as e:
072         logging.error('URL Error for {}'.format(formatted_url))
073         if hasattr(e, 'code') and hasattr(e, 'headers'):
074             logging.debug('{}: {}'.format(e.code, e.reason))
075             logging.debug('{}'.format(e.headers))
076         print('Received URL Error for {}'.format(formatted_url))
077         logging.info('Program exiting...')
078         sys.exit(1)

详细说明 print_transactions()函数

我们在第 81 行定义了print_transaction()函数。我们对该函数进行了些许修改,从第 88 行开始,添加了一条记录当前执行阶段的日志。请看以下函数:

081 def print_transactions(account):
082     """
083     The print_transaction function is responsible for presenting
084     transaction details to end user.
085     :param account: The JSON decoded account and transaction data
086     :return: Nothing
087     """
088     logging.info(
089         'Printing account and transaction data to console.')
090     print_header(account)
091     print('Transactions')
092     for i, tx in enumerate(account['txs']):
093         print('Transaction #{}'.format(i))
094         print('Transaction Hash:', tx['hash'])
095         print('Transaction Date: {}'.format(
096             unix.unix_converter(tx['time'])))

对于第 99 行开始的条件语句,我们使用ifelifelse语句添加了不同的情况来处理输入值大于、等于或其他情况。虽然很少见,但例如第一个比特币交易就没有输入地址。当缺少输入地址时,理想的做法是记录日志,提示没有检测到输入,并将此信息打印给用户,具体如下:

097         for output in tx['out']:
098             inputs = get_inputs(tx)
099             if len(inputs) > 1:
100                 print('{} --> {} ({:.8f} BTC)'.format(
101                     ' & '.join(inputs), output['addr'],
102                     output['value'] * 10**-8))
103             elif len(inputs) == 1:
104                 print('{} --> {} ({:.8f} BTC)'.format(
105                     ''.join(inputs), output['addr'],
106                     output['value'] * 10**-8))
107             else:
108                 logging.warn(
109                     'Detected 0 inputs for transaction {}').format(
110                         tx['hash'])
111                 print('Detected 0 inputs for transaction.')
112 
113         print('{:=²²}\n'.format(''))

运行脚本

剩余的函数print_header()get_inputs()与之前的版本没有变化。不同版本之间的整个代码不需要修改。通过构建一个强大的输出模块,我们避免了对报告进行任何调整。

虽然结果仍然显示在控制台中,但我们现在有了程序执行的书面日志。通过指定-l选项运行脚本,可以将日志存储在指定目录中。否则,默认使用当前工作目录。以下是脚本完成后日志的内容:

完成日志记录后,我们又找到了代码可以改进的地方。对于这个特定地址,我们有一定数量的交易会打印到控制台。试想一下,如果一个地址有成百上千的交易,浏览这些输出并找到一个特定的感兴趣交易就不那么简单了。

掌握我们的最终版本 —— bitcoin_address_lookup.py

在最终版本中,我们将把脚本的输出写入 CSV 文件,而不是打印到控制台。这使得检查人员能够快速筛选和排序数据,便于分析。

在第 4 行,我们导入了标准库中的csv模块。与其他输出格式相比,写入 CSV 文件相对简单,而且大多数检查人员非常熟悉操作电子表格。

正如本章前面提到的,在脚本的最终版本中,我们添加了必要的逻辑来检测是使用 Python 2 还是 Python 3 来调用脚本。根据 Python 版本,适当的 urlliburllib2 函数会被导入到脚本中。请注意,我们直接导入了我们计划在脚本中直接调用的函数 urlopen()URLError。这样,我们可以避免后续使用额外的条件语句来判断是否调用 urlliburllib2

001 """Final iteration of the Bitcoin JSON transaction parser."""
002 from __future__ import print_function
003 import argparse
004 import csv
005 import json
006 import logging
007 import sys
008 import os
009 if sys.version_info[0] == 2:
010     from urllib2 import urlopen
011     from urllib2 import URLError
012 elif sys.version_info[0] == 3:
013     from urllib.request import urlopen
014     from urllib.error import URLError
015 else:
016     print("Unsupported Python version. Exiting..")
017     sys.exit(1)
018 import unix_converter as unix
...
048 __authors__ = ["Chapin Bryce", "Preston Miller"]
049 __date__ = 20181027
050 __description__ = """This scripts downloads address transactions
051     using blockchain.info public APIs"""

本次迭代的重点是新增的 csv_writer() 函数。该函数负责将 parse_transactions() 返回的数据写入 CSV 文件。我们需要修改当前版本的 print_transactions(),使其返回解析后的数据,而不是将其打印到控制台。虽然这不是 csv 模块的深入教程,但我们将讨论在当前上下文中使用该模块的基本内容。在本书的后续章节中,我们将广泛使用 csv 模块,并探索其附加功能。有关 csv 模块的文档可以在docs.python.org/3/library/csv.html找到。

首先,我们打开一个交互式提示符来练习创建和写入 CSV 文件。首先,让我们导入 csv 模块,它将允许我们创建 CSV 文件。接下来,我们创建一个名为 headers 的列表,用于存储 CSV 文件的列头:

>>> import csv
>>> headers = ['Date', 'Name', 'Description'] 

接下来,我们将使用内置的open()方法以适当的文件模式打开一个文件对象。在 Python 2 中,CSV 文件对象应以 rbwb 模式分别用于读取和写入。在这种情况下,我们将写入 CSV 文件,因此我们将以 wb 模式打开文件。w 代表写入,b 代表二进制模式。

在 Python 3 中,CSV 文件应以 w 模式打开,并指定换行符,如下所示:open('test.csv', 'w', newline='')

通过我们与文件对象 csvfile 的连接,我们现在需要创建一个写入器或读取器(取决于我们的目标),并将文件对象传入。有两个选项——csv.writer()csv.reader() 方法;它们都需要文件对象作为输入,并接受各种关键字参数。列表对象与 csv 模块非常契合,写入数据到 CSV 文件的代码量非常少。写入字典和其他对象到 CSV 文件并不困难,但超出了本章的范围,将在后续章节中讲解:

>>> with open('test.csv', 'wb') as csvfile:
...     writer = csv.writer(csvfile) 

writer.writerow() 方法将使用提供的列表写入一行。列表中的每个元素将依次放置在同一行的不同列中。例如,如果再次调用 writerow() 函数并传入另一个列表输入,那么数据将写入到上一行下面:

...     writer.writerow(headers) 

在实际操作中,我们发现使用嵌套列表是遍历和写入每一行最简单的方法之一。在最后一次迭代中,我们将交易详细信息存储在一个列表中,并将其追加到另一个列表中。然后,我们可以在遍历每个交易时,将其详细信息逐行写入 CSV 文件。

与任何文件对象一样,务必将缓冲区中的数据刷新到文件中,然后再关闭文件。忘记这些步骤并不会导致灾难,因为 Python 通常会自动处理,但我们强烈建议执行这些步骤。执行完这些代码行后,一个名为test.csv的文件将在你的工作目录中创建,文件的第一行将包含DateNameDescription三个标题。相同的代码也适用于 Python 3 中的csv模块,唯一需要修改的是之前展示的open()函数:

...     csvfile.flush()
...     csvfile.close() 

我们将print_transactions()函数重命名为parse_transactions(),以更准确地反映其功能。此外,在第 159 行,我们添加了一个csv_writer()函数,用于将交易结果写入 CSV 文件。其他所有函数与之前的版本类似:

053 def main():
...
070 def get_address():
...
091 def parse_transactions():
...
123 def print_header():
...
142 def get_inputs():
...
159 def csv_writer():

最后,我们添加了一个名为OUTPUT的新位置参数。这个参数表示 CSV 输出的名称和/或路径。在第 230 行,我们将这个输出参数传递给main()函数:

195 if __name__ == '__main__':
196     # Run this code if the script is run from the command line.
197     parser = argparse.ArgumentParser(
198     description=__description__,
199     epilog='Built by {}. Version {}'.format(
200         ", ".join(__authors__), __date__),
201     formatter_class=argparse.ArgumentDefaultsHelpFormatter
202     )
203 
204     parser.add_argument('ADDR', help='Bitcoin Address')
205     parser.add_argument('OUTPUT', help='Output CSV file')
206     parser.add_argument('-l', help="""Specify log directory.
207         Defaults to current working directory.""")
208 
209     args = parser.parse_args()
210 
211     # Set up Log
212     if args.l:
213         if not os.path.exists(args.l):
214             os.makedirs(args.l) # create log directory path
215         log_path = os.path.join(args.l, 'btc_addr_lookup.log')
216     else:
217         log_path = 'btc_addr_lookup.log'
218     logging.basicConfig(
219         filename=log_path, level=logging.DEBUG,
220         format='%(asctime)s | %(levelname)s | %(message)s',
221         filemode='w')
222 
223     logging.info('Starting Bitcoid Lookup v. {}'.format(__date__))
224     logging.debug('System ' + sys.platform)
225     logging.debug('Version ' + sys.version.replace("\n", " "))
226 
227     # Print Script Information
228     print('{:=²²}'.format(''))
229     print('{}'.format('Bitcoin Address Lookup'))
230     print('{:=²²} \n'.format(''))
231 
232     # Run main program
233     main(args.ADDR, args.OUTPUT)

以下流程图展示了前两次迭代与最终版本之间的区别:

增强parse_transactions()函数

这个之前名为print_transactions()的函数用于处理交易数据,以便能被我们的csv_writer()函数接收。请注意,print_header()函数的调用现在已被移到main()函数中。我们现在也将输出参数传递给parse_transactions()

091 def parse_transactions(account, output_dir):
092     """
093     The parse_transactions function appends transaction data into a
094     nested list structure so it can be successfully used by the
095     csv_writer function.
096     :param account: The JSON decoded account and transaction data
097     :param output_dir: The output directory to write the CSV
098     results
099     :return: Nothing
100     """

正如我们之前所见,我们必须首先遍历transactions列表。在遍历数据时,我们将其追加到一个在第 104 行创建的交易列表中。该列表表示一个给定的交易及其数据。完成追加交易数据后,我们将此列表追加到作为所有交易容器的transactions列表中:

101     msg = 'Parsing transactions...'
102     logging.info(msg)
103     print(msg)
104     transactions = []
105     for i, tx in enumerate(account['txs']):
106         transaction = []

为了将输出地址与其值匹配,我们在第 107 行创建了一个outputs字典。在第 114 行,我们创建了一个表示地址和其接收值的键。请注意,在第 115 行至第 117 行时,我们使用了换行符\n,将多个输出地址及其值合并在一起,以便它们在一个单元格内视觉上分开。我们在get_inputs()函数中也进行了相同的操作,以处理多个输入值。这是我们做出的设计选择,因为我们发现可能存在多个输出地址。与其将这些地址放在各自的列中,我们选择将它们全部放在一列中:

107         outputs = {}
108         inputs = get_inputs(tx)
109         transaction.append(i)
110         transaction.append(unix.unix_converter(tx['time']))
111         transaction.append(tx['hash'])
112         transaction.append(inputs)
113         for output in tx['out']:
114             outputs[output['addr']] = output['value'] * 10**-8
115         transaction.append('\n'.join(outputs.keys()))
116         transaction.append(
117             '\n'.join(str(v) for v in outputs.values()))

在第 118 行,我们使用内置的sum()函数创建了一个新值,将输出值加起来。sum()函数非常方便,接受一个intfloat类型的列表作为输入并返回总和:

118         transaction.append('{:.8f}'.format(sum(outputs.values())))

现在,我们在transaction列表中有了所有期望的交易详细信息。我们将交易添加到第 119 行的transactions列表中。当所有交易都添加到transactions列表后,我们调用csv_writer()方法,并传入transactions列表和output目录:

119         transactions.append(transaction)
120     csv_writer(transactions, output_dir)

再次提醒,我们没有对print_header()get_address()函数做任何修改。

开发csv_writer()函数

在第 159 行,我们定义了csv_writer()函数。在将交易数据写入 CSV 文件之前,我们记录当前的执行阶段并创建一个headers变量。这个headers列表代表了电子表格中的列,将是写入文件的第一行,如下所示:

159 def csv_writer(data, output_dir):
160     """
161     The csv_writer function writes transaction data into a CSV file
162     :param data: The parsed transaction data in nested list
163     :param output_dir: The output directory to write the CSV
164     results
165     :return: Nothing
166     """
167     logging.info('Writing output to {}'.format(output_dir))
168     print('Writing output.')
169     headers = ['Index', 'Date', 'Transaction Hash',
170         'Inputs', 'Outputs', 'Values', 'Total']

与任何用户提供的数据一样,我们必须考虑到提供的数据可能不正确或会引发异常。例如,用户可能会在输出路径参数中指定一个不存在的目录。在第 173 行和第 175 行,我们根据所使用的 Python 版本以适当的方式打开csvfile,并在一个tryexcept语句块下写入我们的 CSV 数据。如果用户提供的输出存在问题,我们将收到IOError异常。

我们在第 177 行创建了写入对象,并在迭代交易列表之前写入headers。交易列表中的每个交易都会单独写入一行。最后,在第 181 行和第 182 行,我们刷新并关闭了 CSV 文件:

171     try:
172         if sys.version_info[0] == 2:
173             csvfile = open(output_dir, 'wb')
174         else:
175             csvfile = open(output_dir, 'w', newline='')
176        with csvfile:
177            writer = csv.writer(csvfile)
178            writer.writerow(headers)
179            for transaction in data:
180                writer.writerow(transaction)
181            csvfile.flush()
182            csvfile.close()

如果生成了IOError,我们会将错误消息和相关信息写入日志,然后以错误(任何非零退出)退出。如果没有错误,我们会记录脚本完成的情况,并在没有错误的情况下退出(也称为零退出),如第 191 至 193 行所示:

183     except IOError as e:
184         logging.error("""Error writing output to {}.
185         \nGenerated message: {}.""".format(e.filename,
186         e.strerror))
187         print("""Error writing to CSV file.
188         Please check output argument {}""".format(e.filename))
189         logging.info('Program exiting.')
190         sys.exit(1)
191     logging.info('Program exiting.')
192     print('Program exiting.')
193     sys.exit(0)

运行脚本

这一版本最终解决了我们识别出的剩余问题,即如何将数据处理到一个准备好供检查的状态。现在,如果一个地址有数百或数千个交易,检查员可以比在控制台中显示时更高效地分析这些数据。

话虽如此,和大多数事情一样,总有改进的空间。例如,我们处理多个输入和输出值的方式意味着一个特定的单元格中会有多个地址。这在试图筛选特定地址时可能会很麻烦。这里的要点是,脚本从来不会真正完成开发,它总是一个不断发展的过程。

要运行脚本,我们现在必须提供两个参数:比特币地址和期望的输出。以下是运行脚本时使用的示例及输出结果:

transactions.csv 文件将按照指定的路径写入当前工作目录。以下截图展示了该电子表格可能的样子:

挑战

对于额外的挑战,修改脚本,使得每个输出和输入地址都有自己独立的单元格。我们建议通过确定交易列表中输入值或输出地址的最大数量来解决这一问题。了解这些值后,你可以构建一个条件语句来修改表头,使其具有适当的列数。此外,当没有多个输入或输出值时,你还需要编写逻辑跳过这些列,以确保数据的正确间距。

尽管这些例子特定于比特币,但在实际情况中,当两个或多个数据点之间存在动态关系时,可能需要类似的逻辑。解决这一挑战将帮助我们培养一种逻辑且实用的方法论,这种方法可以应用于未来的场景。

总结

在这一章中,我们更加熟悉了常见的序列化结构、比特币和 CSV 文件格式,并学习了如何处理嵌套列表和字典。能够操作列表和字典是一个重要的技能,因为数据通常存储在混合的嵌套结构中。记住,始终使用type()方法来确定你正在处理的数据类型。

对于这个脚本,我们(作者)在编写脚本之前,先在 Python 交互式提示符中尝试了 JSON 数据结构。这让我们在编写任何逻辑之前,能够正确理解如何遍历数据结构以及最佳的实现方式。Python 交互式提示符是一个非常好的沙盒,用于实现新特性或测试新代码。该项目的代码可以从 GitHub 或 Packt 下载,具体信息请参见前言

在下一章中,我们将讨论存储结构化数据的另一种方法。在学习如何将数据库集成到我们的脚本中时,我们将创建一个活动文件列表脚本,该脚本将所有数据以 SQLite3 格式存储。这样做将帮助我们更熟悉使用两种不同模块从数据库中存储和检索数据。

第五章:Python 中的数据库

在本章中,我们将在脚本中利用数据库,以便在处理大量数据时能够完成有意义的任务。通过一个简单的例子,我们将展示在 Python 脚本中使用数据库后端的功能和优势。我们将把从给定根目录递归索引的文件元数据存储到数据库中,然后查询该数据以生成报告。虽然这看起来是一个简单的任务,但本章的目的是展示我们如何通过创建一个活跃的文件列表与数据库进行交互。

在本章中,我们将深入探讨以下主题:

  • SQLite3 数据库的基本设计与实现

  • 使用 Python 的内置模块和第三方模块处理这些数据库

  • 理解如何在 Python 中递归遍历目录

  • 理解文件系统元数据以及使用 Python 访问它的方法

  • 为了方便最终用户的审阅,制作 CSV 和 HTML 格式的报告

本章的代码是在 Python 2.7.15 和 Python 3.7.1 环境下开发和测试的。file_lister.py 脚本是为了支持 Python 3.7.1 开发的。file_lister_peewee.py 脚本则在 Python 2.7.15 和 Python 3.7.1 中都进行了开发和测试。

数据库概述

数据库提供了一种高效的方式,以结构化的方式存储大量数据。数据库有许多种类型,通常分为两大类:SQLNoSQLSQL(即 结构化查询语言)旨在作为一种简单的语言,使用户能够操作存储在数据库中的大型数据集。这包括常见的数据库,如 MySQL、SQLite 和 PostgreSQL。NoSQL 数据库也非常有用,通常使用 JSON 或 XML 来存储具有不同结构的数据,这两者在上一章中作为常见的序列化数据类型进行了讨论。

使用 SQLite3

SQLite3 是 SQLite 的最新版本,是应用开发中最常见的数据库之一。与其他数据库不同,它被存储为一个单一的文件,不需要运行或安装服务器实例。因此,它因其可移植性而广泛应用于移动设备、桌面应用程序和 Web 服务中。SQLite3 使用稍微修改过的 SQL 语法,虽然 SQL 有许多变种,它仍然是其中实现较为简单的一种。自然,这种轻量级数据库也有一些限制。包括一次只能有一个写操作连接到数据库、存储限制为 140 TB,并且它不是基于客户端-服务器模式的。由于我们的应用不会同时执行多个写操作、使用的存储小于 140 TB,且不需要客户端-服务器的分布式配置,因此我们将在本章中使用 SQLite 来进行示例。

使用 SQL

在开发我们的代码之前,让我们先看看我们将使用的基本 SQL 语句。这将帮助我们了解如何即使不使用 Python 也能与数据库互动。在 SQL 中,命令通常使用大写字母书写,尽管它们对大小写不敏感。为了提高可读性,本练习中我们将使用大写字母。所有 SQL 语句必须以分号结尾才能执行,因为分号表示语句的结束。

如果你想一起操作,可以安装一个 SQLite 管理工具,比如命令行工具 sqlite3。这个工具可以从 www.sqlite.org/download.html 下载。本节展示的输出是通过 sqlite3 命令行工具生成的,尽管给出的语句在大多数其他 sqlite3 图形应用程序中也会生成相同的数据库。如果有疑问,使用官方的 sqlite3 命令行工具。

首先,我们将创建一个表,这是任何数据库的基本组成部分。如果我们将数据库比作 Excel 工作簿,那么表就相当于工作表。表包含命名的列,以及与这些列相对应的数据行。就像 Excel 工作簿可以包含多个工作表一样,数据库也可以包含多个表。要创建一个表,我们将使用 CREATE TABLE 命令,指定表名,然后在括号中列出列名及其数据类型,并以逗号分隔。最后,我们用分号结束 SQL 语句:

>>> CREATE TABLE custodians (id INTEGER PRIMARY KEY, name TEXT);

如我们在 CREATE TABLE 语句中所见,我们在 custodians 表中指定了 idname 列。id 字段是整数且为主键。在 SQLite3 中使用 INTEGER PRIMARY KEY 的指定将自动创建一个索引,该索引会对每个添加的行按顺序递增,从而创建唯一的行标识符索引。name 列的数据类型为 TEXT,允许任何字符作为文本字符串存储。SQLite 支持五种数据类型,其中两种我们已经介绍过:

  • INTEGER

  • TEXT

  • REAL

  • BLOB

  • NULL

REAL 数据类型允许浮动小数(例如小数)。BLOB二进制大对象的缩写)数据类型保持任何输入数据的原样,不会将其转换为特定类型。NULL 数据类型只是存储一个空值。

在创建表格后,我们可以开始向其中添加数据。如以下代码块所示,我们可以使用 INSERT INTO 命令将数据插入到表中。此命令后的语法指定了表名、要插入数据的列,然后是 VALUES 命令,指定要插入的值。列和数据必须用括号括起来,如下面的代码所示。使用 null 语句作为值时,SQLite 的自动增量功能会介入,并用下一个可用的唯一整数填充此值。记住,只有当我们指定该列为 INTEGER PRIMARY KEY 时,这种自动增量才会生效。作为一般规则,表格中只能有一个列被指定为此类型:

>>> INSERT INTO custodians (id, name) VALUES (null, 'Chell');
>>> INSERT INTO custodians (id, name) VALUES (null, 'GLaDOS');

我们已经插入了两个监护人,ChellGLaDOS,并让 SQLite 为它们分配了 ID。数据插入后,我们可以使用 SELECT 命令选择并查看这些信息。基本语法是调用 SELECT 命令,后面跟着要选择的列(或者用星号 * 来表示选择所有列)以及 FROM 语句,后面跟着表名,最后是一个分号。正如我们在下面的代码中看到的,SELECT 将打印出以管道符(|)分隔的存储值列表:

>>> SELECT * FROM custodians;
1|Chell
2|GLaDOS

除了仅显示我们表格中所需的列外,我们还可以基于一个或多个条件来筛选数据。WHERE 语句允许我们筛选结果并仅返回符合条件的项。为了本章脚本的目的,我们将坚持使用简单的 where 语句,并仅使用等号运算符返回符合条件的值。执行时,SELECT-WHERE 语句仅返回 id 值为 1 的监护人信息。此外,注意列的顺序反映了它们被指定的顺序:

>>> SELECT name,id FROM custodians WHERE id = 1;
Chell|1 

虽然还有更多操作和语句可以与 SQLite3 数据库进行交互,但前面的操作已经涵盖了我们脚本所需的所有内容。我们邀请你在 SQLite3 文档中探索更多操作,文档可以在 sqlite.org 找到。

设计我们的脚本

我们脚本的第一次迭代专注于以更手动的方式使用标准模块 sqlite3 执行当前任务。这意味着我们需要写出每个 SQL 语句并执行它们,就像你直接与数据库打交道一样。虽然这不是一种非常 Pythonic 的数据库处理方式,但它展示了与 Python 一起操作数据库时所使用的方法。我们的第二次迭代使用了两个第三方库:peeweejinja2

Peewee 是一个对象关系映射器ORM),这是一个用于描述使用对象处理数据库操作的软件套件的术语。简而言之,这个 ORM 允许开发者在 Python 中调用函数并定义类,这些函数和类会被解释为数据库命令。这个抽象层帮助标准化数据库调用,并且允许轻松地更换多个数据库后端。Peewee 是一个轻量级的 ORM,因为它只是一个支持 PostgreSQL、MySQL 和 SQLite3 数据库连接的 Python 文件。如果我们需要将第二个脚本从 SQLite3 切换到 PostgreSQL,只需要修改几行代码;而第一个脚本则需要更多的注意来处理这个转换。也就是说,我们的第一个版本除了标准的 Python 安装外,不需要任何额外的依赖项来支持 SQLite3,这对于设计为在现场使用的便携且灵活的工具来说是一个非常有吸引力的特点。

我们的 file_lister.py 脚本是一个按监护人收集元数据和生成报告的脚本。这在事件响应或调查的发现阶段非常重要,因为它存储有关系统或指定目录中由监护人名称标识的活动文件的信息。监护人分配系统允许通过单个监护人名称索引和分类多个机器、目录路径或网络共享,无论该监护人是用户、机器还是设备。为了实现这个系统,我们需要提示用户输入监护人名称、要使用的数据库路径以及输入或输出的信息。

通过允许检查员将多个监护人或路径添加到同一个数据库中,他们可以将找到的文件添加到单个监护人下,或者随意添加多个监护人。这对于收藏来说非常有用,因为调查人员可以根据需要保留任意数量的路径,我们都知道一旦进入现场,不可预见的设备就会出现。此外,我们可以使用相同的脚本来创建文件列表报告,无论收集的文件或监护人数量如何,只要监护人至少有一个收集的文件。

在我们的设计阶段,我们不仅考虑脚本,还考虑将要使用的数据库和关系模型。在我们的案例中,我们处理两个独立的项目:监护人和文件。它们都是很好的表格,因为它们是独立的条目,并且共享一个共同的关系。在我们的场景中,文件有一个监护人,而监护人可能有一个或多个文件;因此,我们希望创建一个外键,将文件与特定的监护人关联。外键是指向另一个表中主键的引用。主键和外键引用通常是一个唯一值或索引,用于将数据连接在一起。

以下图表示了我们数据库的关系模型。我们有两个表:custodians(管理员)和 files(文件),它们之间存在一对多关系。如前所述,这种一对多关系将允许我们将多个文件分配给单个管理员。通过这种关系,我们可以确保脚本以结构化且易于管理的方式正确地分配信息:

在这个关系模型中,例如,我们可能有一个名为 JPriest 的管理员,他拥有位于APB/文件夹中的文件。在这个根文件夹下,有 40,000 个文件分布在 300 个子目录中,我们需要将这 40,000 个文件都分配给 JPriest。由于管理员的名字可能很长或很复杂,我们希望为 JPriest 分配一个标识符,如整数 5,并将其写入存储在Files表中的每一行数据中。这样,我们实现了三件事:

  • 我们节省了空间,因为我们在每一行的 40,000 个数据行中只存储了一个字符(5),而不是七个字符(JPriest)

  • 我们维护了 JPriest 用户与其文件之间的关联

  • 如果我们以后需要重命名 JPriest,我们只需更改Custodians表中的一行,从而更新所有关联行中的管理员名称

使用 Python 手动操作数据库 – file_lister.py

作为说明,脚本将仅在 Python 3 中工作,并且已经在 Python 3.7.1 版本中进行了测试。如果您在完成本节后希望查看 Python 2 版本的代码,请访问github.com/PacktPublishing/Learning-Python-for-Forensics以查看之前的版本。

在脚本的第一次迭代中,我们使用了几个标准库来完成整个操作所需的所有功能。像之前的脚本一样,我们实现了argparsecsvlogging,用于各自的常规功能,包括参数处理、编写 CSV 报告和记录程序执行。对于日志记录,我们在第 43 行定义了我们的日志处理器logger。我们导入了sqlite3模块来处理所有数据库操作。与我们下一次迭代不同,这个脚本只支持 SQLite 数据库。os模块使我们能够递归地遍历目录及其子目录中的文件。最后,sys模块允许我们收集有关系统的日志信息,datetime模块用于格式化系统中遇到的时间戳。这个脚本不需要任何第三方库。我们有以下代码:

001 """File metadata capture and reporting utility."""
002 import argparse
003 import csv
004 import datetime
005 import logging
006 import os
007 import sqlite3
008 import sys
...
038 __authors__ = ["Chapin Bryce", "Preston Miller"]
039 __date__ = 20181027
040 __description__ = '''This script uses a database to ingest and
041    report meta data information about active entries in
042     directories.'''
043 logger = logging.getLogger(__name__)

在导入语句之后,我们有 main() 函数,它接受以下用户输入:保管人名称、目标输入目录或输出文件,以及要使用的数据库路径。main() 函数处理一些高层操作,如添加和管理保管人、错误处理和日志记录。它首先初始化数据库和表格,然后检查保管人是否在数据库中。如果不在,系统会将该保管人添加到数据库中。该函数允许我们处理两种可能的运行选项:递归地导入基础目录,捕获所有子对象及其元数据,或从数据库中读取捕获的信息并使用我们的写入函数生成报告。

init_db() 函数由 main() 调用,若数据库及默认表格不存在,它将创建这些表格。get_or_add_custodian() 函数以类似的方式检查保管人是否存在。如果存在,它将返回保管人的 ID,否则它会创建保管人表格。为了确保保管人存在于数据库中,在添加新条目后,get_or_add_custodian() 函数会再次运行。

在数据库创建并且保管人表格存在之后,代码会检查源是否为输入目录。如果是,它会调用 ingest_directory() 函数,遍历指定的目录并扫描所有子目录,以收集与文件相关的元数据。捕获到的元数据将存储在数据库的 Files 表中,并通过外键与 Custodians 表关联,从而将每个保管人与其文件绑定。在收集元数据的过程中,我们会调用 format_timestamp() 函数,将收集到的时间戳转换为标准的字符串格式。

如果源是输出文件,则会调用 write_output() 函数,传入打开的数据库游标、输出文件路径和保管人名称作为参数。脚本接着会检查保管人是否在 Files 表中有任何相关结果,并根据输出文件路径的扩展名将其传递给 write_html()write_csv() 函数。如果扩展名为 .html,则调用 write_html() 函数,使用 Bootstrap CSS 创建一个 HTML 表格,显示该保管人的所有响应结果。否则,如果扩展名为 .csv,则调用 write_csv() 函数,将数据写入以逗号分隔的文件。如果输出文件路径中没有提供这两种扩展名,则不会生成报告,并且会抛出错误,提示无法解析文件类型:

046 def main(custodian, target, db):
...
081 def init_db(db_path):
...
111 def get_or_add_custodian(conn, custodian):
...
132 def get_custodian(conn, custodian):
...
148 def ingest_directory(conn, target, custodian_id):
...
207 def format_timestamp(timestamp):
...
219 def write_output(conn, target, custodian):
...
254 def write_csv(conn, target, custodian_id):
...
280 def write_html(conn, target, custodian_id, custodian_name):

现在,让我们来看看这个脚本所需的参数和设置。在第 321 行到第 339 行之间,我们构建了 argparse 命令行接口,其中包括必需的位置参数 CUSTODIANDB_PATH,以及可选的参数 --input--output-l

320 if __name__ == '__main__':
321     parser = argparse.ArgumentParser(
322     description=__description__,
323     epilog='Built by {}. Version {}'.format(
324         ", ".join(__authors__), __date__),
325     formatter_class=argparse.ArgumentDefaultsHelpFormatter
326     )
327     parser.add_argument(
328         'CUSTODIAN', help='Name of custodian collection is of.')
329     parser.add_argument(
330         'DB_PATH', help='File path and name of database to '
331                         'create or append metadata to.')
332     parser.add_argument(
333         '--input', help='Base directory to scan.')
334     parser.add_argument(
335         '--output', help='Output file to write to. use `.csv` '
336                          'extension for CSV and `.html` for HTML')
337     parser.add_argument(
338         '-l', help='File path and name of log file.')
339     args = parser.parse_args()

在第 341 到 347 行,我们检查用户是否提供了--input--output参数。我们创建了一个变量arg_source,它是一个元组,包含操作模式和由参数指定的相应路径。如果两个模式参数都没有提供,则会引发ArgumentError并提示用户提供输入或输出。这确保了当存在一个或多个选项时,用户提供了所需的参数:

341     if args.input:
342         arg_source = ('input', args.input)
343     elif args.output:
344         arg_source = ('output', args.output)
345     else:
346        raise argparse.ArgumentError(
347            'Please specify input or output')

在第 349 到 368 行,我们可以看到我们在前几章中使用的日志配置,并检查-l参数,根据需要创建日志路径。我们还在第 366 到 368 行记录了脚本版本和操作系统信息:

349     if args.l:
350         if not os.path.exists(args.l):
351             os.makedirs(args.l) # create log directory path
352         log_path = os.path.join(args.l, 'file_lister.log')
353     else:
354         log_path = 'file_lister.log'
355 
356     logger.setLevel(logging.DEBUG)
357     msg_fmt = logging.Formatter("%(asctime)-15s %(funcName)-20s"
358         "%(levelname)-8s %(message)s")
359     strhndl = logging.StreamHandler(sys.stdout)
360     strhndl.setFormatter(fmt=msg_fmt)
361     fhndl = logging.FileHandler(log_path, mode='a')
362     fhndl.setFormatter(fmt=msg_fmt)
363     logger.addHandler(strhndl)
364     logger.addHandler(fhndl)
365 
366     logger.info('Starting File Lister v.' + str(__date__))
367     logger.debug('System ' + sys.platform)
368     logger.debug('Version ' + sys.version)

完成日志配置后,我们可以创建一个字典,定义通过 kwargs 传递给main()函数的参数。Kwargs(关键字参数)提供了一种以字典键值对的形式传递参数的方法,其中键与参数名称匹配,并赋予相应的值。为了将字典作为 kwargs 而不是单一值传递给函数或类,我们必须在字典名称前加上两个星号,如第 373 行所示。如果没有使用 kwargs,我们就必须将args.custodianarg_sourceargs.db_path参数作为单独的位置参数传递。kwargs 具有更高级的功能,相关示例可以在docs.python.org/3.7/faq/programming.html找到。我们有以下代码:

370     args_dict = {'custodian': args.CUSTODIAN,
371                  'target': arg_source, 'db': args.DB_PATH}
372 
373     main(**args_dict)

请参考以下流程图,了解每个功能是如何相互连接的:

构建main()函数

main()函数分为两个阶段:数据库初始化和输入/输出(I/O)处理。数据库初始化(包括文档字符串)发生在第 46 到 57 行,在这部分我们定义并记录了函数的输入。请注意,输入变量与作为关键字参数传递给函数的args_dict的键匹配。如果args_dict没有定义这些确切的键,我们在调用函数时会收到TypeError。参见以下代码:

046 def main(custodian, target, db):
047     """
048     The main function creates the database or table, logs
049         execution status, and handles errors
050     :param custodian: The name of the custodian
051     :param target: tuple containing the mode 'input' or 'output'
052         as the first elemnet and a file path as the second
053     :param db: The filepath for the database
054     :return: None
055     """

在第 57 行,我们调用init_db()函数,传递数据库的路径,并将返回的数据库连接赋值给conn变量。数据库连接对象由sqlite3 Python 库处理。我们使用这个对象与数据库进行通信,将所有的 Python 调用转化为 SQL 语句。通过连接对象,我们可以调用游标对象。游标是用于通过连接发送和接收数据的对象;我们将在需要与数据库交互的函数中定义它,因为我们希望将游标的作用范围限制,而可以在不同函数间共享数据库连接:

056     logger.info('Initiating SQLite database: ' + db)
057     conn = init_db(db)

在额外的日志记录后,我们调用get_or_add_custodian(),将连接对象和托管人名称传递给该函数。通过传递打开的连接,我们允许该函数与数据库交互并定义自己的游标。如果找到custodian_id,我们继续执行并跳过第 61 行的while循环;否则,我们重新运行get_or_add_custodian()函数,直到我们添加托管人并获取托管人 ID:

058     logger.info('Initialization Successful')
059     logger.info('Retrieving or adding custodian: ' + custodian)
060     custodian_id = get_or_add_custodian(conn, custodian)
061     while not custodian_id:
062         custodian_id = get_or_add_custodian(conn, custodian)
063     logger.info('Custodian Retrieved')

一旦我们有了一个托管人 ID 需要处理,我们需要确定源是指定为输入还是输出。如果在第 64 行源是input,则我们运行ingest_directory()函数,该函数遍历提供的根目录并收集有关任何子文件的相关元数据。完成后,我们将更改提交(保存)到数据库并记录完成情况:

064     if target[0] == 'input':
065         logger.info('Ingesting base input directory: {}'.format(
066             target[1]))
067         ingest_directory(conn, target[1], custodian_id)
068         conn.commit()
069         logger.info('Ingest Complete')

如果源是output,则调用write_output()函数来处理以指定格式写入输出。如果无法确定源类型,我们将引发argparse.ArgumentError错误,声明无法解释参数。运行所需模式后,我们通过关闭数据库连接并记录脚本完成情况来结束函数,如下所示:

070     elif target[0] == 'output':
071         logger.info('Preparing to write output: ' + target[1])
072         write_output(conn, target[1], custodian)
073     else:
074         raise argparse.ArgumentError(
075             'Could not interpret run time arguments')
076 
077     conn.close()
078     logger.info('Script Completed')

使用 init_db()函数初始化数据库

init_db()函数在main()函数的第 87 行被调用,用于执行创建数据库和初始化结构的基本任务。首先,我们需要检查数据库是否已经存在,如果存在,则连接到它并返回连接对象。无论文件是否存在,我们都可以使用sqlite3库的connect()方法打开或创建一个文件作为数据库。这个连接用于允许 Python 对象与数据库之间的通信。我们还专门使用一个游标对象,在第 94 行分配为cur,来跟踪我们在已执行语句中的位置。这个游标是与数据库交互所必需的:

081     def init_db(db_path):
082     """
083     The init_db function opens or creates the database
084     :param db_path: The filepath for the database
085     :return: conn, the sqlite3 database connection
086     """
087     if os.path.exists(db_path):
088         logger.info('Found Existing Database')
089         conn = sqlite3.connect(db_path)
090     else:
091         logger.info('Existing database not found. '
092                     'Initializing new database')
093         conn = sqlite3.connect(db_path)
094         cur = conn.cursor()

如果数据库不存在,那么我们必须创建一个新的数据库,连接到它,并初始化表格。如本章 SQL 部分所述,我们必须使用CREATE TABLE语句创建这些表格,并后跟列名及其数据类型。在Custodians表中,我们需要创建一个自动递增的id列,用于为name列提供标识符,该列将存储托管人的名称。

要做到这一点,我们必须首先在第 96 行的sql变量中构建查询。赋值后,我们将这个变量传递给cur.execute()方法,通过游标对象执行我们的 SQL 语句。此时,游标与之前的连接对象进行通信,后者再与数据库进行交流。请查看以下代码:

096         sql = """CREATE TABLE Custodians (
097                  cust_id INTEGER PRIMARY KEY, name TEXT);"""
098         cur.execute(sql)

在第 99 行,我们使用PRAGMA创建另一个 SQL 查询,它允许我们修改数据库的配置。默认情况下,在 SQLite3 中,外键是禁用的,这阻止了我们在一个表中引用另一个表中的数据。通过使用PRAGMA语句,我们可以通过将foreign_keys设置为1来启用此功能:

099         cur.execute('PRAGMA foreign_keys = 1;')

我们重复创建Files表的过程,添加更多字段以记录文件的元数据。在第 100 到 105 行,我们列出了字段名称及其关联的数据类型。我们可以通过使用三重引号将该字符串跨越多行,并让 Python 将其解释为一个单一的字符串值。正如我们已经看到的,我们需要列来存储 ID(与Custodians表类似),文件名、文件路径、扩展名、大小、修改时间、创建时间、访问时间、模式和 inode 号。

mode属性指定文件的权限,基于 UNIX 权限标准,而inode属性是 UNIX 系统中唯一标识文件系统对象的编号。这两个元素将在理解 ingest_directory()函数部分中进一步描述,在那里它们从文件中提取。在创建了两个表并定义了它们的结构后,我们在第 106 行执行最终的 SQL 语句并返回连接对象:

100         sql = """CREATE TABLE Files(id INTEGER PRIMARY KEY,
101             custodian INTEGER NOT NULL, file_name TEXT,
102             file_path TEXT, extension TEXT, file_size INTEGER,
103             mtime TEXT, ctime TEXT, atime TEXT, mode TEXT,
104             inode INTEGER, FOREIGN KEY (custodian)
105             REFERENCES Custodians(cust_id));"""
106         cur.execute(sql)
107         conn.commit()
108     return conn

使用 get_or_add_custodian()函数检查保管人

此时,数据库已初始化并准备好进行进一步的交互。调用get_or_add_custodian()函数来检查保管人是否存在,并在找到时传递其 ID。如果保管人不存在,函数将把保管人添加到Custodians表中。在第 120 行,我们调用get_custodian()函数来检查保管人是否存在。在第 122 行,我们使用条件语句检查id是否为空,如果不是,则将保管人的 ID 赋值给cust_id变量。SQLite 库返回的是元组,以确保向后兼容,第一个元素将是我们关心的 ID:

111 def get_or_add_custodian(conn, custodian):
112     """
113     The get_or_add_custodian function checks the database for a
114         custodian and returns the ID if present;
115         Or otherwise creates the custodian
116     :param conn: The sqlite3 database connection object
117     :param custodian: The name of the custodian
118     :return: The custodian ID or None
119     """
120     cust_id = get_custodian(conn, custodian)
121     cur = conn.cursor()
122     if cust_id:
123         cust_id = cust_id[0]

如果没有找到保管人,我们会将其插入表中以供将来使用。在第 125-126 行,我们编写一个 SQL 语句将保管人插入到Custodians表中。注意VALUES部分的null字符串;它被 SQLite 解释为NoneType对象。SQLite 将主键字段中的NoneType对象转换为自增整数。紧随其后的null值是我们的保管人字符串。SQLite 要求字符串值用引号括起来,类似于 Python。

我们必须使用双引号将包含单引号的查询括起来。这可以防止由于引号错误导致字符串断裂的任何问题。如果在代码的这一部分看到语法错误,请务必检查第 125-126 行中使用的引号。

最后,我们执行此语句并返回空的cust_id变量,这样main()函数就必须再次检查数据库中的保管员,并重新运行该函数。下一次执行应该能检测到我们插入的值,并允许main()函数继续执行。我们有以下代码:

124     else:
125         sql = """INSERT INTO Custodians (cust_id, name) VALUES
126             (null, '{}') ;""".format(custodian)
127         cur.execute(sql)
128         conn.commit()
129     return cust_id

尽管我们可以在此处调用get_custodian()函数(或在插入后获取 ID)进行验证,但我们让main()函数再次检查保管员。可以自由实现这些替代解决方案之一,看看它们如何影响代码的性能和稳定性。

使用get_custodian()函数获取保管员

get_custodian()函数被调用以从 SQLite 数据库中检索保管员 ID。使用简单的SELECT语句,我们从Custodian表中选择id列,并根据用户提供的名称与name列进行匹配。我们使用字符串的format()方法将保管员名称插入到 SQL 语句中。请注意,我们仍然需要将插入的字符串用单引号包裹起来,如下所示:

132     def get_custodian(conn, custodian):
133     """
134     The get_custodian function checks the database for a
135         custodian and returns the ID if present
136     :param conn: The sqlite3 database connection object
137     :param custodian: The name of the custodian
138     :return: The custodian ID
139     """
140     cur = conn.cursor()
141     sql = "SELECT cust_id FROM Custodians "\
142         "WHERE name='{}';".format(custodian)

执行此语句后,我们在第 144 行使用fetchone()方法从语句中返回一个结果。这是我们的脚本首次从数据库请求数据。为了获取数据,我们使用fetchone()fetchmany()fetchall()中的任何一个方法,从执行的语句中收集数据。这三个方法仅适用于游标对象。在这里,fetchone()方法是更好的选择,因为我们预期该语句返回一个单一的保管员。这个保管员 ID 被捕获并存储在data变量中:

143     cur.execute(sql)
144     data = cur.fetchone()
145     return data

理解ingest_directory()函数

ingest_directory()函数处理我们脚本的输入模式,并递归捕获用户提供的根目录中文件的元数据。在第 158 行,我们在count变量之前设置了数据库游标,该变量将记录存储在Files表中的文件数量:

148     def ingest_directory(conn, target, custodian_id):
149     """
150     The ingest_directory function reads file metadata and stores
151         it in the database
152     :param conn: The sqlite3 database connection object
153     :param target: The path for the root directory to
154         recursively walk
155     :param custodian_id: The custodian ID
156     :return: None
157     """
158     cur = conn.cursor()
159     count = 0

该函数最重要的部分是第 160 行的for循环。此循环使用os.walk()方法将提供的目录路径拆分成一个可迭代数组,供我们逐步遍历。os.walk()方法有三个组成部分,它们通常命名为rootfoldersfilesroot值是一个字符串,表示我们在特定循环迭代过程中当前遍历的基本目录路径。当我们遍历子文件夹时,它们会被附加到root值中。foldersfiles变量分别提供当前根目录内文件夹和文件名的列表。尽管可以根据需要重命名这些变量,但这是一个良好的命名约定,有助于避免覆盖 Python 语句,例如filedir,这些在 Python 中已经被使用。不过,在此实例中,我们不需要os.walk()中的folders列表,因此我们将其命名为一个单下划线(_):

160     for root, _, files in os.walk(target):

这是为未在代码中使用的变量赋值的常见做法。因此,仅使用单个下划线表示未使用的数据。尽可能地,尝试重新设计代码,避免返回不需要的值。

在循环中,我们开始遍历files列表,以访问每个文件的信息。在第 162 行,我们创建了一个文件专属的字典meta_data,用来存储收集到的信息,具体如下:

161         for file_name in files:
162             meta_data = dict()

在第 163 行,我们使用 try-except 语句块来捕捉任何异常。我们知道我们曾说过不要这么做,但请先听我们解释。这种通用的异常捕获机制是为了确保在发现的文件中出现的任何错误不会导致脚本崩溃和停止执行。相反,文件名和错误信息将被写入日志,然后跳过该文件并继续执行。这有助于检查人员快速定位和排除特定文件的故障。这一点非常重要,因为某些错误可能在 Windows 系统上由于文件系统标志和命名规则而导致 Python 出现错误。而在 macOS 和 Linux/UNIX 系统上则可能会出现不同的错误,这使得很难预测脚本会在哪些情况下崩溃。这正是日志记录重要性的一个极好例子,因为我们可以回顾脚本生成的错误。

在 try-except 语句块中,我们将文件元数据的不同属性存储到字典的键中。首先,我们在第 163 行和第 164 行记录文件名和完整路径。注意,字典的键与它们在Files表中所属列的名称相同。这个格式将使我们在脚本后续的操作中更加方便。文件路径使用os.path.join()方法进行存储,该方法将不同的路径合并为一个,使用操作系统特定的路径分隔符。

在第 167 行,我们通过使用os.path.splitext()方法来获取文件扩展名,该方法会根据文件名中最后一个.之后的部分进行分割。由于第 167 行的函数会创建一个列表,我们选择最后一个元素以确保我们保存扩展名。在某些情况下,文件可能没有扩展名(例如.DS_Store文件),在这种情况下,返回列表中的最后一个值是一个空字符串。请注意,这个脚本并没有检查文件签名来确认文件类型是否与扩展名匹配;检查文件签名的过程可以自动化:

163             try:
164                 meta_data['file_name'] = file_name
165                 meta_data['file_path'] = os.path.join(root,
166                                                       file_name)
167                 meta_data['extension'] = os.path.splitext(
168                     file_name)[-1]

探索os.stat()方法

在第 170 行,我们使用os.stat()来收集文件的元数据。此方法访问系统的stat库以收集提供的文件的信息。默认情况下,该方法返回一个对象,包含有关每个文件的所有可用数据。由于这些信息在平台之间有所不同,我们仅选择了脚本中最具跨平台特性的属性,如os库文档中所定义的;更多信息可以在docs.python.org/3/library/os.html#os.stat_result中找到。此列表包括创建时间、修改时间、访问时间、文件模式、文件大小、inode 号和模式。SQLite 将接受字符串格式的数据类型,尽管我们将以正确的数据类型将其存储在脚本中,以防我们需要修改它们或使用特定类型的特殊特性。

文件模式最好以八进制整数形式显示,因此我们必须使用 Python 的oct()函数将其转换为可读状态,如第 171 行所示:

170                 file_stats = os.stat(meta_data['file_path'])
171                 meta_data['mode'] = str(oct(file_stats.st_mode))

文件模式是一个三位数的整数,表示文件对象的读、写和执行权限。权限定义在下表中,使用数字 0-7 来确定分配的权限。每一位数字表示文件所有者、文件所属组以及所有其他用户的权限。例如,数字 777 表示任何人都具有完全权限,而 600 意味着只有所有者可以读取和写入文件。除了每个数字,八进制表示法允许我们通过添加数字来为文件分配额外的权限。例如,值 763 授予所有者完全权限(700),授予组读取和写入权限(040 + 020),并授予其他人写和执行权限(002 + 001)。你可能永远不会看到 763 作为权限设置,尽管它在这里是一个有趣的例子:

权限 描述
700 文件所有者完全权限
400 所有者具有读权限
200 所有者具有写权限
100 所有者具有执行权限
070 完全组权限
040 组具有读权限
020 组具有写权限
010 组具有执行权限
007 其他人(不在该组或所有者之外)具有完全权限
004 其他人具有读权限
002 其他人具有写权限
001 其他人具有执行权限

下表显示了由 Python 的os.stat()方法提供的额外文件类型信息。表中的三个井号表示我们刚刚讨论的文件权限在数字中的位置。下表的前两行不言自明,符号链接表示指向文件系统中其他位置的引用。例如,在下表中,值 100777 表示一个常规文件,所有者、组和其他用户都具有完全权限。虽然可能需要一些时间来适应,但这个系统对于识别文件权限及其访问权限非常有用:

文件类型 描述
040### 目录
100### 常规文件
120### 符号链接

inode值是文件系统对象的唯一标识符,这是我们将在第 172 行捕获的下一个值。尽管这是仅在 Linux/UNIX/macOS 系统中找到的特性,但 Python 会将 NTFS 的记录号转换为相同的对象,以保持一致性。在第 173 行,我们为文件大小赋值,文件大小以分配的字节数作为整数表示。在第 174 行到第 179 行,我们按顺序将访问、修改和创建的时间戳赋值给字典。每个时间戳都使用我们的format_timestamps()函数将浮动值转换为字符串。我们现在已经收集了完成Files表中一行所需的数据:

172                 meta_data['inode'] = int(file_stats.st_ino)
173                 meta_data['file_size'] = int(file_stats.st_size)
174                 meta_data['atime'] = format_timestamp(
175                     file_stats.st_atime)
176                 meta_data['mtime'] = format_timestamp(
177                     file_stats.st_mtime)
178                 meta_data['ctime'] = format_timestamp(
179                     file_stats.st_ctime)

本节前面提到的异常在第 180 行定义,并记录在收集元数据过程中遇到的任何错误:

180             except Exception as e:
181                 logger.error(
182                     'Error processing file: {} {}'.format(
183                         meta_data.get('file_path', None),
184                         e.__str__()))

最后,在我们的try-except语句块之外,我们将custodian_id添加到我们的meta_data字典中,这样我们就可以将其与记录一起存储。现在,我们可以构造用于插入新文件元数据记录的 SQL 语句。正如我们之前所看到的,我们将在第 186 行构造一个插入语句,并为列名和值名添加占位符。使用.format()方法,我们将插入我们的meta_data键和值数据。在第 187 行,我们将meta_data的键连接成一个字符串,每个键之间用双引号和逗号分隔。在第 188 行,我们将一个由逗号分隔的列表连接起来,为每个值插入一个问号作为execute()调用的占位符。以下是生成的sql变量中的字符串示例:

INSERT INTO Files
    ("custodian","ctime","mtime","extension","inode",
     "file_size","file_name","mode","atime","file_path")
VALUES 
    (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

这样,我们就可以在第 189-190 行的try块中看到我们值的列表,并将其传递给 SQLite3 Python 库,以便为数据库生成正确的插入语句。我们需要将字典值转换为元组,以便 SQLite3 支持,如第 190 行的调用所示:

185             meta_data['custodian'] = custodian_id
186             sql = 'INSERT INTO Files ("{}") VALUES ({})'.format(
187                 '","'.join(meta_data.keys()),
188                 ', '.join('?' for x in meta_data.values()))
189             try:
190                 cur.execute(sql, tuple(meta_data.values()))

现在,我们可以关闭我们的except语句块,并为 SQLite3 库的错误提供错误处理和日志记录,错误发生在第 191 行到第 197 行。在错误处理之后,我们将文件处理计数增加 1,并继续处理下一个文件,该文件可以在我们的两个for循环中的任意一个中找到:

191             except (sqlite3.OperationalError,
192                     sqlite3.IntegrityError) as e:
193                 logger.error(
194                     "Could not insert statement {}"
195                     " with values: {}".format(
196                         sql, meta_data.values()))
197                 logger.error("Error message: {}".format(e))
198             count += 1

一旦我们最内层的for循环完成,我们使用commit()方法将新记录保存到数据库中。在外层for循环完成后,我们再次运行commit()方法,然后记录目录处理完成,并向用户提供已处理文件的数量:

199         conn.commit()
200     conn.commit()
201     logger.info('Stored meta data for {} files.'.format(count))

开发format_timestamp()辅助函数

这个相对较小的函数将整数时间戳转换为人类可读的字符串。由于 Python 的os.stat()模块返回的是自纪元(1970 年 1 月 1 日)以来的秒数,我们需要使用datetime库来进行转换。通过使用datetime.datetime.fromtimestamp()函数,我们可以将浮动时间戳解析为datetime对象,本文中我们将其命名为ts_datetime,并在第 211 行进行赋值。将日期作为datetime对象后,我们可以在第 212 行使用strftime()方法按所需格式YYYY-MM-DD HH:MM:SS格式化日期。字符串准备好后,我们将其返回给调用函数,以便插入到数据库中:

204 def format_timestamp(timestamp):
205     """
206     The format_timestamp function formats an integer to a string
207     timestamp
208     :param timestamp: An integer timestamp
209     :return: ts_format, a formatted (YYYY-MM-DD HH:MM:SS) string
210     """
211     ts_datetime = datetime.datetime.fromtimestamp(timestamp)
212     ts_format = ts_datetime.strftime('%Y-%m-%d %H:%M:%S')
213     return ts_format

这样的小型实用函数在较大的脚本中非常有用。一个优势是,如果我们想更新日期格式,只需在一个地方更改,而不必逐一查找所有strftime()的用法。这个小函数还提高了代码的可读性。ingest_directory()函数已经相当庞大,如果将这一逻辑重复三次,可能会使下一个审查代码的人感到困惑。这些函数在字符串格式化或常见转换中非常有用,但在设计自己的脚本时,可以考虑创建哪些实用函数来简化工作。

配置write_output()函数

如果用户指定了输出目标,则会调用write_output()函数。调用后,我们使用get_custodian()函数从数据库中选择看护人 ID,该函数在第 225 行被调用。如果找到了看护人,我们需要构建一个新的查询来确定与看护人关联的文件数量,使用COUNT() SQL 函数。如果未找到看护人,会记录错误,提示用户看护人未响应,具体内容见第 234 到 237 行:

216 def write_output(conn, target, custodian):
217     """
218     The write_output function handles writing either the CSV or
219     HTML reports
220     :param conn: The sqlite3 database connection object
221     :param target: The output filepath
222     :param custodian: Name of the custodian
223     :return: None
224     """
225     custodian_id = get_custodian(conn, custodian)
226     cur = conn.cursor()
227     if custodian_id:
228         custodian_id = custodian_id[0]
229         sql = "SELECT COUNT(id) FROM Files "\
230               "where custodian = {}".format(
231                   custodian_id)
232         cur.execute(sql)
233         count = cur.fetchone()
234     else:
235         logger.error(
236             'Could not find custodian in database. Please check '
237             'the input of the custodian name and database path')

如果找到了看护人并且存储的文件数量大于零,我们将检查要生成哪种类型的报告。从第 239 行开始的条件语句检查count的大小和源文件的扩展名。如果count不大于零或没有值,则会在第 240 行记录错误。否则,我们在第 241 行检查 CSV 文件扩展名,在第 243 行检查 HTML 文件扩展名,如果找到匹配项,则调用相应的函数。如果源文件扩展名既不是 CSV 也不是 HTML,则会记录错误,表明无法确定文件类型。最后,如果代码执行到第 247 行的else语句,则会记录一个未知错误发生的事实。我们可以在以下代码中看到这一过程:

239     if not count or not count[0] > 0:
240         logger.error('Files not found for custodian')
241     elif target.endswith('.csv'):
242         write_csv(conn, target, custodian_id)
243     elif target.endswith('.html'):
244         write_html(conn, target, custodian_id, custodian)
245     elif not (target.endswith('.html')or target.endswith('.csv')):
246         logger.error('Could not determine file type')
247     else:
248         logger.error('Unknown Error Occurred')

设计 write_csv() 函数

如果文件扩展名是 CSV,我们可以开始迭代存储在文件表中的条目。第 261 行的 SQL 语句使用 WHERE 语句仅识别与特定保管员相关的文件。返回的 cur.description 值是一个元组的元组,每个嵌套元组中有八个元素,表示我们的列名。每个元组中的第一个值是列名,其余七个是空字符串,作为向后兼容的目的保留。通过在第 265 行使用列表推导式,我们遍历这些元组,选择返回元组中每个项的第一个元素,构建列名列表。这条单行语句使我们能够将一个简单的 for 循环压缩成一条生成所需列表的语句:

251 def write_csv(conn, target, custodian_id):
252     """
253     The write_csv function generates a CSV report from the
254     Files table
255     :param conn: The Sqlite3 database connection object
256     :param target: The output filepath
257     :param custodian_id: The custodian ID
258     :return: None
259     """
260     cur = conn.cursor()
261     sql = "SELECT * FROM Files where custodian = {}".format(
262         custodian_id)
263     cur.execute(sql)
264 
265     cols = [description[0] for description in cur.description]

列表推导式是一种简洁的通过单行 for 循环生成列表的方法。通常用于过滤列表内容或进行某种形式的转换。在第 265 行,我们使用它进行结构转换,从 cur.description 列表的每个元素中仅提取第一个项,并将其存储为列名。这是因为 Python 的 SQLite 绑定将列名作为嵌套元组返回,其中每个子元组的第一个元素是列名。

在准备好列名后,我们记录下正在写入 CSV 报告,并在第 267 行以 wb 模式打开输出文件。然后,我们通过在第 268 行调用 csv.writer() 方法并传递文件对象来初始化写入器。打开文件后,我们通过调用 csv_writer 对象的 writerow() 方法写入列行,每次写入一行。

此时,我们将通过迭代游标来循环结果,游标将在每次循环迭代时返回一行,直到没有更多的行响应原始查询为止。对于每一行返回的结果,我们需要再次调用 writerow() 方法,如第 272 行所示。然后,我们在第 273 行将新数据刷新到文件中,以确保数据写入磁盘。最后,我们记录报告已完成并存储在用户指定的位置。我们有以下代码:

266     logger.info('Writing CSV report')
267     with open(target, 'w', newline="") as csv_file:
268         csv_writer = csv.writer(csv_file)
269         csv_writer.writerow(cols)
270 
271         for entry in cur:
272             csv_writer.writerow(entry)
273         csv_file.flush()
274     logger.info('CSV report completed: ' + target)

编写 write_html() 函数

如果用户指定了 HTML 报告,则会调用 write_html() 函数从数据库读取数据,生成数据的 HTML 标签,并使用 Bootstrap 样式创建包含文件元数据的表格。由于这是 HTML 格式,我们可以自定义它,创建一个专业外观的报告,该报告可以转换为 PDF 或通过任何具有网页浏览器的用户查看。如果在你的报告版本中额外的 HTML 元素证明是有用的,它们可以轻松地添加到以下字符串中,并通过添加徽标、扩展高亮、响应式表格、图表等进行自定义,只要你使用各种网页样式和脚本,这一切都是可能的。

由于本书专注于 Python 脚本的设计,我们不会详细讲解 HTML、CSS 或其他网页设计语言。在使用这些功能时,我们将描述它们的基本用途和如何实现它们,尽管如果你有兴趣,我们建议你使用相关资源(例如www.w3schools.com)来深入了解这些话题。

这个函数的开始与 write_csv() 类似:我们在 287 行的 SQL 语句中选择属于托管人的文件。执行后,我们再次在 291 行使用列表推导式收集 cols。通过列名,我们使用 join() 函数在 292 行定义 table_header HTML 字符串,并通过 <th></th> 标签将每个值分隔开。除了第一个和最后一个元素外,其他每个元素都会被 <th>{{ element }}</th> 标签包围。现在,我们需要关闭第一个和最后一个元素的标签,以确保它们形成正确的表头。对于字符串的开始部分,我们添加 <tr><th> 标签来定义整个行的表格行 <tr> 和第一个条目的表头 <th>。同样,我们在 293 行字符串的末尾关闭表头和表格行标签,内容如下:

277 def write_html(conn, target, custodian_id, custodian_name):
278     """
279     The write_html function generates an HTML report from the
280         Files table
281     :param conn: The sqlite3 database connection object
282     :param target: The output filepath
283     :param custodian_id: The custodian ID
284     :return: None
285     """
286     cur = conn.cursor()
287     sql = "SELECT * FROM Files where custodian = {}".format(
288         custodian_id)
289     cur.execute(sql)
290 
291     cols = [description[0] for description in cur.description]
292     table_header = '</th><th>'.join(cols)
293     table_header = '<tr><th>' + table_header + '</th></tr>'
294 
295     logger.info('Writing HTML report')

在 297 行,我们以 w 模式打开 HTML 文件,将其赋值给 html_file 变量。文件打开后,我们开始构建 HTML 代码,从 298 行的 <html><body> 标签开始,这些标签用于初始化 HTML 文档。接着,我们连接到托管在线的自定义样式表,以为表格提供 Bootstrap 样式。我们通过使用 <link> 标签来实现这一点,指定样式表的类型和来源,样式表位于 www.bootstrapcdn.com/

现在,让我们定义 HTML 报告的头部,以确保它包含托管人 ID 和姓名。我们将使用<h1></h1>或标题 1 标签来实现这一点。对于我们的表格,我们在 302 行使用表格标签,并使用我们想要实现的 Bootstrap 样式(tabletable-hovertable-striped)。

获取有关 Bootstrap 的更多信息,请访问getbootstrap.com。虽然本脚本使用的是 Bootstrap CSS 版本 3.3.5,但你可以探索 Bootstrap 的最新更新,并查看是否能在你的代码中实现新的功能。

在 HTML 字符串中加入这些头部信息后,我们可以将其写入文件,首先在 304 行写入 HTML 头部和样式表信息,然后在 305 行写入表格的列名,内容如下:

297     with open(target, 'w') as html_file:
298         html_string = """<html><body>\n
299             <link rel="stylesheet"
300             href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
301             <h1>File Listing for Custodian ID: {}, {}</h1>\n
302             <table class='table table-hover table-striped'>\n
303             """.format(custodian_id, custodian_name)
304             html_file.write(html_string)
305             html_file.write(table_header)

现在,让我们遍历数据库中的记录,将它们作为单独的行写入表格。我们首先通过连接表格数据标签(<td></td>)来指定表格单元格的内容,使用列表推导式在 308 行对数据进行连接,并将其转换为 join() 方法所需的字符串值:

307         for entry in cur:
308             row_data = "</td><td>".join(
309                 [str(x) for x in entry])

在第 310 行,我们添加了一个换行符(\n),然后是一个<tr>表格行标签和初始的<td>标签,用于打开第一个元素的表格数据。换行符减少了某些 HTML 查看器的加载时间,因为它将数据分割成多行。我们还需要在第 310 行的末尾关闭最后一个表格数据标签和整个表格行。行数据会在第 311 行写入文件。最后,在表格行的循环中,我们使用.flush()方法将内容刷新到文件。随着表格数据的构建,我们可以在第 313 行关闭表格、主体和 HTML 标签。跳出for循环后,我们在第 315 行记录报告的状态和位置:

310             html_string = "\n<tr><td>" + row_data + "</td></tr>"
311             html_file.write(html_string)
312             html_file.flush()
313         html_string = "\n</table>\n</body></html>"
314         html_file.write(html_string)
315     logger.info('HTML Report completed: ' + target)

运行脚本

在这一版本中,我们突出展示了递归读取目录中所有文件元数据、将其存储到数据库、从数据库中提取并基于数据生成报告的过程。本版本使用了基本的库来手动处理必要的 SQL 和 HTML 操作。下一版本将专注于使用 Python 对象执行相同的功能。这两个版本都是脚本的最终版本,并且功能完全可用。不同的版本展示了实现相同任务的不同方法。

要运行我们的脚本,首先需要提供管理员的名称、要创建或读取的数据库位置以及所需的模式。在第一个示例中,我们指定了输入模式并传递了根目录以进行索引。在第二个示例中,我们以输出模式创建了一个 CSV 报告,并提供了适当的文件路径:

前面脚本的输出可以通过以下截图查看。在这里,我们仅仅创建了一个通用的 CSV 报告,包含了本章管理员的索引文件的捕获元数据:

进一步自动化数据库 – file_lister_peewee.py

在这一版本中,我们将使用第三方 Python 模块进一步自动化我们的 SQL 和 HTML 设置。这会引入额外的开销;然而,我们的脚本会更简洁,实施起来更加流畅,这使得我们能够轻松地开发更多功能。面向未来的开发可以帮助我们避免为了每个小的功能请求而重写整个脚本。

我们已经导入了前一版本所需的大部分标准库,并添加了第三方unicodecsv模块(版本 0.14.1)。这个模块是对内置csv模块的封装,自动为 CSV 输出提供 Unicode 支持。为了保持熟悉的使用方式,我们甚至可以通过在第 8 行使用import...as...语句将其命名为csv。正如本章之前提到的,peewee(版本 2.8.0)和jinja2(版本 2.8)是处理 SQLite 和 HTML 操作的两个库。由于这最后三个导入是第三方库,它们需要在用户的机器上安装才能让我们的代码正常运行,安装方法可以使用pip

001 """File metadata capture and reporting utility."""
002 import argparse
003 import datetime
004 from io import open
005 import logging
006 import os
007 import sys
008 import unicodecsv as csv
009 import peewee
010 import jinja2

在导入语句和许可证之后,我们定义了我们的通用脚本元数据和日志处理器。在第 46 行,我们添加了database_proxy对象,用于为CustodianFiles类表创建 Peewee 基本模型。我们还添加了get_template()函数,该函数使用jinja2构建一个模板 HTML 表格。其他函数在很大程度上与之前版本的对应函数类似,只做了一些小调整。然而,我们已经删除了get_custodian()函数,因为 Peewee 已经内建了该功能:

040 __authors__ = ["Chapin Bryce", "Preston Miller"]
041 __date__ = 20181027
042 __description__ = '''This script uses a database to ingest and
043     report meta data information about active entries in
044     directories.'''
045 logger = logging.getLogger(__name__)
046 database_proxy = peewee.Proxy()
047 
048 class BaseModel(peewee.Model):
...
052 class Custodians(BaseModel):
...
055 class Files(BaseModel):
...
069 def get_template():
...
106 def main(custodian, target, db):
...
138 def init_db(db):
...
150 def get_or_add_custodian(custodian):
...
167 def ingest_directory(source, custodian_model):
...
216 def format_timestamp(ts):
...
226 def write_output(source, custodian_model):
...
253 def write_csv(source, custodian_model):
...
282 def write_html(source, custodian_model):

定义命令行参数并设置日志记录的if __name__ == '__main__'条件下的代码块与之前的版本相同。我们在此不再重复这些实现细节,因为我们可以直接从前一版本中复制粘贴该部分,节省了一些纸张。尽管该部分保持不变,但我们脚本的整体流程略有修改,具体变化如以下流程图所示:

Peewee 设置

Peewee 是本章开头提到的对象关系管理库,它在 Python 中的数据库管理非常出色。它使用 Python 类定义数据库的设置,包括表格配置、数据库位置以及如何处理不同的 Python 数据类型。在第 46 行,我们必须首先使用 Peewee 的Proxy()类创建一个匿名的数据库连接,这样我们就可以将信息重定向到之前指定的格式。根据 Peewee 的文档,该变量必须在进行任何 Peewee 操作之前声明(docs.peewee-orm.com/en/3.6.0/)。

在代理初始化之后,我们定义了本书中使用的第一个 Python 类,从而创建了一个BaseModel类,该类定义了要使用的数据库。作为 Peewee 规范的一部分,我们必须将database_proxy链接到BaseModel对象的Meta类中的database变量。

虽然这一配置可能现在看起来不太明了,但请继续阅读本章其余内容,并在完成并运行脚本后回到这一部分,因为这些模块的目的会变得更加清晰。此外,上述文档在展示 Peewee 的功能和使用方面做得非常出色。

我们必须包括在第 48 行至第 50 行定义的基础模型,作为 Peewee 创建数据库的最小设置:

046 database_proxy = peewee.Proxy()
047 
048 class BaseModel(peewee.Model):
049     class Meta:
050         database = database_proxy

接下来,我们在第 60 行定义并创建了Custodians表。这个表继承了BaseModel的属性,因此在其括号内包含了BaseModel类。这通常用于定义函数所需的参数,但在类中,它也可以让我们分配一个父类,以便继承数据。在这个脚本中,BaseModel类是peewee.Model的子类,也是Custodians表和(稍后将讨论的)Files表的父类。请记住,Peewee 将表描述为类模型,库会为我们创建一个名为Custodians的表;稍后会详细说明。

初始化后,我们在第 61 行向Custodians表添加了一个文本字段nameunique=True关键字创建了一个自动递增的索引列,除了我们的name列之外。这个表配置将用于稍后创建表、插入数据并从中检索信息:

052 class Custodians(BaseModel):
053     name = peewee.TextField(unique=True)

Files表有更多的字段和几种新的数据类型。正如我们已经知道的,SQLite 只管理文本、整数、空值和 BLOB 数据类型,因此其中一些数据类型可能看起来不太对。以DateTimeField为例,Peewee 可以处理任何 Python datedatetime对象。Peewee 会自动将其存储为数据库中的文本值,甚至可以保留其原始时区。当数据从表中调用时,Peewee 会尝试将这个值转换回datetime对象或格式化的字符串。尽管日期仍然以文本值的形式存储在数据库中,Peewee 在数据传输过程中会进行转换,以提供更好的支持和功能。尽管我们可以像在之前的脚本中那样手动复制这一功能,但这是 Peewee 打包的一些有用功能之一。

在第 56 行到第 66 行之间,我们创建了具有类型的列,这些列反映了主键、外键、文本、时间戳和整数。PrimaryKeyField指定唯一的主键属性,并分配给id列。ForeignKeyFieldCustodians类作为参数,因为 Peewee 使用它将其与我们定义的Custodians类中的索引关联起来。紧接着这两个特殊键字段的是一系列我们在本章前面描述的字段:

055 class Files(BaseModel):
056     id = peewee.PrimaryKeyField(unique=True, primary_key=True)
057     custodian = peewee.ForeignKeyField(Custodians)
058     file_name = peewee.TextField()
059     file_path = peewee.TextField()
060     extension = peewee.TextField()
061     file_size = peewee.IntegerField()
062     atime = peewee.DateTimeField()
063     mtime = peewee.DateTimeField()
064     ctime = peewee.DateTimeField()
065     mode = peewee.TextField()
066     inode = peewee.IntegerField()

这完成了我们之前使用 SQL 查询在第一个脚本中创建的数据库的整个设置。虽然与之前相比它更为冗长,但它确实避免了我们必须编写自己的 SQL 查询,并且在处理更大的数据库时,它显得尤为重要。例如,一个包含多个模块的大型脚本,将通过使用 Peewee 来定义和处理数据库连接受益匪浅。它不仅能为模块提供统一性,还能实现与不同数据库后端的跨兼容性。本章稍后将展示如何在 PostgreSQL、MySQL 和 SQLite 之间更改数据库类型。虽然 Peewee 设置较为冗长,但它提供了许多功能,免去了我们自己编写处理数据库事务函数的麻烦。

Jinja2 设置

现在,让我们讨论一下其他新模块的配置。Jinja2 允许我们使用 Python 风格的语法来创建强大的文本模板,以实现文本扩展和逻辑评估。模板还使我们能够开发一个可重用的文本块,而不是在 Python 脚本的 for 循环中逐行构建表格的行和列。尽管前一个脚本通过从字符串形成 HTML 文件采取了简单的方式,但这个模板更为强大、动态,并且最重要的是,更具可持续性。

这个函数定义了一个变量 html_string,它包含我们的 Jinja2 模板。这个字符串捕获了所有的 HTML 标签和数据,将由 Jinja2 处理。尽管我们将这些信息放在一个变量中,但我们也可以将文本放在文件中,以避免在代码中增加额外的行数。在第 76 和 77 行,我们可以看到与之前版本的 write_html() 函数相同的信息:

069 def get_template():
070     """
071     The get_template function returns a basic template for our
072     HTML report
073     :return: Jinja2 Template
074     """
075     html_string = """
076         <html>\n<head>\n<link rel="stylesheet"
077         href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/
css/bootstrap.min.css">

在第 78 至第 80 行,我们打开了<body><h1>标题标签,接着是一个包含两个 Python 对象的字符串,且这两个对象被空格包裹的双花括号({{ ... }})包含。Jinja2 会查找与花括号内字符串匹配的字典键或对象名称。在第 79 和 80 行的情况下,custodian 变量是一个具有 idname 属性的对象。使用与 Python 中相同的语法,我们可以调用对象的属性,并在模板执行时将它们插入到 HTML 中:

078         </head>\n<body>\n<h1>
079         File Listing for Custodian {{ custodian.id }},
080         {{ custodian.name }}</h1>\n

第 81 行的 <table> 标签指定了我们用来为表格添加样式的 Bootstrap CSS 类。在第 82 行,我们打开了表格行 <tr> 标签,后面跟着一个换行符 \n 和一个新的模板操作符。围绕百分号的花括号({% ... %})表示 Jinja2 模板包含一个操作,比如循环,需要进行求值。在我们的例子中,在第 83 行我们开始了一个 for 循环,语法类似于 Python 的 for 循环,只是缺少闭合的冒号。跳到第 85 行,我们使用相同的语法将 endfor 语句包围起来,通知 Jinja2 循环已结束。我们必须这样做,因为 HTML 不敏感于制表符或空格,不能像 Python 的缩进代码一样自动确定循环的边界。

在 Jinja2 模板语法和我们希望 Jinja2 插入的值之间加入空格是一个好习惯。例如,{{ Document_Title }}{{Document_Title}} 更易读。

在第 84 行,我们将新定义的 header 变量包裹在表格头 <th> 标签中。循环完成后,在第 86 行关闭表格行 <tr> 标签。通过这个循环,我们生成了一个包含表头列表 <th> 的表格行 <tr>,如下所示:

081         <table class="table table-hover table-striped">\n
082         <tr>\n
083         {% for header in table_headers %}
084             <th>{{ header }}</th>
085         {% endfor %}
086         </tr>\n

接下来,我们打开一个新的循环,遍历每一列数据,为每列创建一个新的表格行 <tr>,并将每个元素包裹在表格数据 <td> 标签中。由于数据库的每一列都是 Peewee 返回的行对象的属性,我们可以使用以下格式来指定列名:entry.column_name。通过这个简单的 for 循环,我们构建了一个易于阅读和扩展的表格格式:

087         {% for entry in file_listing %}
088             <tr>
089                 <td>{{ entry.id }}</td>
090                 <td>{{ entry.custodian.name }}</td>
091                 <td>{{ entry.file_name }}</td></td>
092                 <td>{{ entry.file_path }}</td>
093                 <td>{{ entry.extension }}</td>
094                 <td>{{ entry.file_size }}</td>
095                 <td>{{ entry.atime }}</td>
096                 <td>{{ entry.mtime }}</td>
097                 <td>{{ entry.ctime }}</td>
098                 <td>{{ entry.mode }}</td>
099                 <td>{{ entry.inode }}</td>
100             </tr>\n
101         {% endfor %}

{% endfor %} 语句之后,我们可以通过关闭打开的 HTML 标签并使用三个双引号关闭多行字符串来完成此 HTML 模板。构建好 html_string 后,我们调用 Jinja2 模板引擎来解释构建的字符串。为此,我们在第 103 行调用并返回 jinja2.Template() 函数的输出。这使我们能够在需要生成 HTML 报告时使用该模板。我们也可以使用相同标记语言的 HTML 文件来加载 Jinja2 模板,这在构建更复杂或多页面的 HTML 内容时特别有用:

102         </table>\n</body>\n</html>"""
103     return jinja2.Template(html_string)

更新 main() 函数

这个函数与我们在上一轮迭代中看到的 main() 函数几乎相同,尽管有一些例外。首先,在第 117 行,我们不需要捕获 init_db() 的返回值,因为 peewee 在初始化后会为我们处理这一点。当调用 get_or_add_custodian 时,我们也去除了 while 循环,因为该函数的逻辑已经由 Peewee 补充,使得合理性检查变得不再必要。我们将返回的 custodian 表格赋值给一个名为 custodian_model 的变量,因为 Peewee 将每个表格称为模型。

在我们的例子中,CustodiansFiles 类是 Peewee 中的模型,分别代表 SQLite 中的 CustodiansFiles 表。在 Peewee 中,模型返回的数据集被称为模型实例。

第 120 行返回的数据本质上与脚本先前实例中通过 SELECT 语句返回的数据相同,尽管它是由 Peewee 处理的模型实例。

106 def main(custodian, target, db):
107     """
108     The main function creates the database or table, logs
109         execution status, and handles errors
110     :param custodian: The name of the custodian
111     :param target: tuple containing the mode 'input' or 'output'
112         as the first element and its arguments as the second
113     :param db: The file path for the database
114     :return: None
115     """
116     logger.info('Initializing Database')
117     init_db(db)
118     logger.info('Initialization Successful')
119     logger.info('Retrieving or adding custodian: ' + custodian)
120     custodian_model = get_or_add_custodian(custodian)

第三项修改涉及如何处理脚本的不同模式。现在,我们只需要提供 targetcustodian_model 变量,因为我们可以通过已经构建的 peewee 模型类访问数据库。此行为将在每个函数中展示,以演示如何在表中插入和访问数据。其余函数与之前的版本保持不变:

121     if target[0] == 'input':
122         logger.info('Ingesting base input directory: {}'.format(
123             target[1]))
124         ingest_directory(target[1], custodian_model)
125         logger.info('Ingesting Complete')
126     elif target[0] == 'output':
127         logger.info(
128             'Preparing to write output for custodian: {}'.format(
129                 custodian))
130         write_output(target[1], custodian_model)
131         logger.info('Output Complete')
132     else:
133         logger.error('Could not interpret run time arguments')
134 
135     logger.info('Script Complete')

调整 init_db() 函数

init_db() 函数是我们定义数据库类型的地方(例如 PostgreSQL、MySQL 或 SQLite)。虽然我们在这个例子中使用 SQLite,但我们可以使用其他数据库类型,在第 144 行调用单独的 peewee 函数,如 PostgresqlDatabase()MySQLDatabase()。在第 144 行,我们必须传递要写入数据库的文件路径。如果我们只希望数据库是临时的,可以传递特殊字符串 :memory:,让 Peewee 在内存中托管 SQLite 数据库。内存选项有两个缺点:一是脚本退出后数据库不会持久化,二是数据库的内容必须能适应内存,这在旧机器或大型数据库中可能不可行。在我们的用例中,我们必须将数据库写入磁盘,因为我们可能希望再次运行脚本以便对同一数据库进行其他保存或报告:

138 def init_db(db):
139     """
140 The init_db function opens or creates the database
141     :param db_path: The file path for the database
142     :return: conn, the sqlite3 database connection
143     """
144     database = peewee.SqliteDatabase(db)

在创建数据库对象后,我们必须初始化第 46 行创建的 database_proxy,并更新它以引用新创建的 SQLite 数据库。这个代理连接告诉 Peewee 如何将数据从模型路由到我们的 SQLite 实例。

我们之前必须创建这个代理,以便在启动数据库连接之前指定模型数据。使用这个代理还允许我们询问用户希望将数据库存储在哪里,通过代理,我们可以创建一个占位符,稍后将其分配给 SQLite(或其他)数据库处理器。

有关代理使用的更多信息,请参考 Peewee 文档中的 docs.peewee-orm.com/en/3.6.0/peewee/database.html?highlight=proxy#dynamically-defining-a-database

一旦连接到代理,我们可以创建所需的表,因此需要调用我们 Peewee 数据库对象上的 create_tables() 方法。正如你所看到的,我们必须先创建模型的列表,以便在调用 create_tables() 时可以引用这些表(及其模式)进行创建。

safe=True 参数在此处是必需的,因为我们希望在数据库中如果已存在该表时忽略它,以免覆盖或丢失数据。如果我们要扩展工具的功能或需要另一个表,我们需要记住在第 146 行将其添加到列表中,以便该表会被创建。如main()函数中所提到的,我们不需要在此处返回任何连接或游标对象,因为数据通过我们之前定义的peewee模型类流动:

145 database_proxy.initialize(database)
146 table_list = [Custodians, Files] # Update with any new tables
147 database.create_tables(table_list, safe=True)

修改 get_or_add_custodian() 函数

这个函数比之前的版本简单得多。我们只需调用 Custodians 模型上的 get_or_create() 方法,并传入字段标识符 name 及其对应的值 custodian。通过此调用,我们将获得该模型的实例以及一个布尔值,表示该行是新创建的还是已存在。利用这个 created 布尔值,我们可以添加日志语句,提醒用户某个托管人是已添加到数据库中,还是已检索到现有的托管人。在第 164 行,我们将模型实例返回给调用函数,如下所示:

150 def get_or_add_custodian(custodian):
151     """
152     The get_or_add_custodian function gets the custodian by name
153         or adds it to the table
154     :param custodian: The name of the custodian
155     :return: custodian_model, custodian peewee model instance
156     """
157     custodian_model, created = Custodians.get_or_create(
158         name=custodian)
159     if created:
160         logger.info('Custodian added')
161     else:
162         logger.info('Custodian retrieved')
163 
164     return custodian_model

改进 ingest_directory() 函数

尽管这是脚本中较为复杂的函数之一,但它与之前的版本几乎完全相同,因为收集这些信息的方法没有变化。此处的新内容包括在第 177 行初始化一个我们将用于收集文件元数据字典的列表,以及将传递的 custodian_model 实例赋值给托管人,而不是使用整数值。我们还生成了 ddate 值,默认设置为时间戳,用于插入到 peewee 中,以防脚本无法获取日期值并需要存储部分记录。默认时间戳值将设置为 Python datetime 库的最小值,以确保日期编码和解码仍然正常工作。

在第 207 行,我们将 meta_data 字典添加到 file_data 列表中。然而,缺少的部分是构建复杂 SQL 插入语句和列名及其值的列表的代码。相反,我们遍历 file_data 列表,并以更高效的方式写入数据,正如稍后所描述的;现在,我们有如下代码:

167 def ingest_directory(source, custodian_model):
168     """
169     The ingest_directory function reads file metadata and stores
170         it in the database
171     :param source: The path for the root directory to
172         recursively walk
173     :param custodian_model: Peewee model instance for the
174         custodian
175     :return: None
176     """
177     file_data = []
178     for root, _, files in os.walk(source):
179         for file_name in files:
180             ddate = datetime.datetime.min
181             meta_data = {
182                 'file_name': None, 'file_path': None,
183                 'extension': None, 'mode': -1, 'inode': -1,
184                 'file_size': -1, 'atime': ddate, 'mtime': ddate,
185                 'ctime': ddate, 'custodian': custodian_model.id}
186             try:
187                 meta_data['file_name'] = os.path.join(file_name)
188                 meta_data['file_path'] = os.path.join(root,
189                     file_name)
190                 meta_data['extension'] = os.path.splitext(
191                     file_name)[-1]
192 
193                 file_stats = os.stat(meta_data['file_path'])
194                 meta_data['mode'] = str(oct(file_stats.st_mode))
195                 meta_data['inode'] = str(file_stats.st_ino)
196                 meta_data['file_size'] = str(file_stats.st_size)
197                 meta_data['atime'] = format_timestamp(
198                     file_stats.st_atime)
199                 meta_data['mtime'] = format_timestamp(
200                     file_stats.st_mtime)
201                 meta_data['ctime'] = format_timestamp(
202                     file_stats.st_ctime)
203             except Exception as e:
204                 logger.error(
205                     'Error processing file: {} {}'.format(
206                         meta_data['file_path'], e.__str__()))
207             file_data.append(meta_data)

在第 209 行,我们开始将文件元数据插入数据库。由于我们的列表中可能有几千行数据,因此我们需要将插入操作批量处理,以防止资源耗尽的问题。第 209 行的循环使用了range函数,从0开始,按50的增量遍历file_data列表的长度。这意味着x会以50为增量,直到达到最后一个元素时,它会包含所有剩余项。

通过这样做,在第 210 行,我们可以使用.insert_many()方法将数据插入Files。在插入过程中,我们通过xx+50来访问条目,每次插入50个元素。这种方法与之前逐行插入的做法有很大不同,在这里,我们是通过简化的语句批量插入数据,执行INSERT操作。最后,在第 211 行,我们需要执行每个已执行的任务,将条目提交到数据库。函数结束时,我们记录已插入文件的数量,如下所示:

209     for x in range(0, len(file_data), 50):
210         task = Files.insert_many(file_data[x:x+50])
211         task.execute()
212     logger.info('Stored meta data for {} files.'.format(
213         len(file_data)))

随意调整每次插入时执行的 50 行的单位。根据你的系统调整这个数字可能会提升性能,尽管这个最佳值通常会根据可用资源的不同而有所变化。

你可能还想考虑在file_data列表达到一定长度时插入记录,以帮助内存管理。例如,如果file_data列表超过 500 条记录,可以暂停数据收集,插入整个列表(即每次插入 50 条记录),清空列表,然后继续收集元数据。对于较大的数据集合,你应该会注意到内存使用显著减少。

更详细地查看format_timestamp()函数

这个函数与之前的版本作用相同,但它返回一个datetime对象,因为 Peewee 使用这个对象来写入datetime值。正如我们在之前的迭代中看到的那样,通过使用fromtimestamp()方法,我们可以轻松地将整数日期值转换为datetime对象。我们可以直接返回datetime对象,因为 Peewee 会处理剩余的字符串格式化和转换工作。以下是代码示例:

216 def format_timestamp(ts):
217     """
218     The format_timestamp function converts an integer into a
219     datetime object
220     :param ts: An integer timestamp
221     :return: A datetime object
222     """
223     return datetime.datetime.fromtimestamp(ts)

转换write_output()函数

在这个函数中,我们可以看到如何查询peewee模型实例。在第 235 行,我们需要选择文件计数,其中监护人等于监护人的id。我们首先在模型上调用select()来表示我们希望选择数据,接着使用where()方法指定列名Files.custodian和评估值custodian_model.id。接下来是count()方法,它返回符合条件的结果的数量(一个整数)。注意,count变量是一个整数,而不是像前一次迭代那样的元组:

226 def write_output(source, custodian_model):
227     """
228     The write_output function handles writing either the CSV or
229         HTML reports
230     :param source: The output filepath
231     :param custodian_model: Peewee model instance for the
232         custodian
233     :return: None
234     """
235     count = Files.select().where(
236         Files.custodian == custodian_model.id).count()
237 
238     logger.info("{} files found for custodian.".format(count))

在第 240 行,我们沿用之前迭代的相同逻辑,检查哪些行是响应的,然后通过语句验证输出扩展,确保调用正确的写入者或提供用户准确的错误信息。注意,这次我们传递的是监护人模型实例,而不是id或名称,在第 243 行和 247 行使用了此方法,因为 Peewee 在现有模型实例上执行操作效果最佳:

240     if not count:
241         logger.error('Files not found for custodian')
242     elif source.endswith('.csv'):
243         write_csv(source, custodian_model)
244     elif source.endswith('.html'):
245         write_html(source, custodian_model)
246     elif not (source.endswith('.html') or \
247         source.endswith('.csv')):
248         logger.error('Could not determine file type')
249     else:
250         logger.error('Unknown Error Occurred')

简化 write_csv() 函数

write_csv() 函数使用了peewee库中的新方法,允许我们以字典形式从数据库中检索数据。通过使用熟悉的Files.select().where()语句,我们附加了dicts()方法,将结果转换为 Python 字典格式。这个字典格式非常适合作为我们的报告输入,因为内置的 CSV 模块有一个名为DictWriter的类。顾名思义,该类允许我们将字典信息作为一行数据写入 CSV 文件。现在,查询已经准备好,我们可以向用户日志输出,告知我们开始编写 CSV 报告:

253 def write_csv(source, custodian_model):
254     """
255     The write_csv function generates a CSV report from the Files
256         table
257     :param source: The output filepath
258     :param custodian_model: Peewee model instance for the
259         custodian
260     :return: None
261     """
262     query = Files.select().where(
263         Files.custodian == custodian_model.id).dicts()
264     logger.info('Writing CSV report')

接下来,我们定义 CSV 写入器的列名,并使用with...as...语句打开用户指定的输出文件。为了初始化csv.DictWriter类,我们传递打开的文件对象和与表格列名相对应的列头(因此也是字典键名)。初始化后,我们调用writeheader()方法,并在电子表格的顶部写入表头。最后,为了写入行内容,我们在查询对象上打开一个for循环,遍历各行并使用.writerow()方法将其写入文件。通过使用enumerate方法,我们可以每 10,000 行向用户提供一次状态更新,让他们知道在处理较大文件报告时,我们的代码正在努力工作。在写入这些状态更新(当然,还有行内容)之后,我们会添加一些额外的日志消息给用户,并退出该函数。虽然我们调用了csv库,但请记住,实际上是我们导入了unicodecsv。这意味着在生成输出时,我们会遇到比使用标准csv库更少的编码错误:

266     cols = [u'id', u'custodian', u'file_name', u'file_path',
267         u'extension', u'file_size', u'ctime', u'mtime',
268         u'atime', u'mode', u'inode']
269 
270     with open(source, 'wb') as csv_file:
271         csv_writer = csv.DictWriter(csv_file, cols)
272         csv_writer.writeheader()
273         for counter, row in enumerate(query):
274             csv_writer.writerow(row)
275             if counter % 10000 == 0:
276                 logger.debug('{:,} lines written'.format(counter))
277         logger.debug('{:,} lines written'.format(counter))
278 
279     logger.info('CSV Report completed: ' + source)

简化 write_html() 函数

我们将需要之前设计的get_template()函数来生成 HTML 报告。在第 291 行,我们调用了这个预构建的 Jinja2 模板对象,并将其存储在template变量中。在引用模板时,我们需要提供一个包含三个键的字典:table_headersfile_listingcustodian。这三个键是必需的,因为它们是我们在模板中选择的占位符。在第 292 行,我们将表头构建为一个字符串列表,按照希望显示的顺序格式化:

282 def write_html(source, custodian_model):
283     """
284     The write_html function generates an HTML report from the
285         Files table
286     :param source: The output file path
287     :param custodian_model: Peewee model instance for the
288         custodian
289     :return: None
290     """
291     template = get_template()
292     table_headers = [
293         'Id', 'Custodian', 'File Name', 'File Path',
294         'File Extension', 'File Size', 'Created Time',
295         'Modified Time', 'Accessed Time', 'Mode', 'Inode']

随后,我们通过使用与 CSV 函数中类似的 select 语句,创建了第 296 行的 file_data 列表,该列表为 file_listing 键提供数据。这个列表使我们能够在模板中单独访问各个属性,正如之前所指定的那样。我们本可以将这一逻辑也放入模板文件中,但我们认为最好将可变逻辑放在函数中,而不是模板中。看看第 296 和 297 行:

296     file_data = Files.select().where(
297         Files.custodian == custodian_model.id)

收集了这三项元素后,我们创建了一个字典,其键与第 299 行模板中的数据匹配。在日志语句之后,我们使用 with...as... 语句打开源文件。为了写入模板数据,我们在 template 对象上调用 render() 方法,将我们已构建的字典作为 kwarg 传递到第 307 行。render() 方法会评估模板中的语句和逻辑,并将提供的数据放置在正确的位置,以生成 HTML 报告。此方法还会将原始 HTML 作为字符串返回,因此我们将其封装在 write() 调用中,立即将数据写入文件。写入完成后,我们记录源文件的路径以及其成功完成的信息:

299     template_dict = {
300         'custodian': custodian_model,
301         'table_headers': table_headers,
302         'file_listing': file_data}
303 
304     logger.info('Writing HTML report')
305 
306     with open(source, 'w') as html_file:
307         html_file.write(template.render(**template_dict))
308 
309     logger.info('HTML Report completed: ' + source)

运行我们新的改进版脚本

这一迭代突出了使用额外的 Python 第三方库来处理我们之前以更手动方式执行的许多操作。在这个实例中,我们使用了 Peewee 和 Jinja2 来进一步自动化数据库管理和 HTML 报告生成。这两个库是处理此类数据的流行方法,并且已打包到其他 Python 套件中,或者有移植版本,例如 Flask 和 Django。

此外,这一迭代与第一次迭代非常相似,旨在更清楚地展示两种方法之间的差异。本书的目标之一是尽可能多地介绍 Python 中执行任务的方法。本章的目的不是创建一个更好的迭代版本,而是展示不同的方法来完成相同的任务,并为我们的工具箱添加新技能。这是我们将创建多个脚本迭代的最后一章;接下来的章节将专注于更具扩展性的单一脚本,随着我们开始扩展法医编码能力。

请注意,我们执行脚本的方式没有改变。我们仍然需要指定一个管理员、数据库路径和模式类型。你可能会注意到,这个脚本比我们之前的脚本要慢得多。有时,使用自动化解决方案时,我们的代码可能会受到额外开销或模块实现效率低下的影响。在这里,通过放弃更简洁的手动处理过程,我们失去了一些效率。然而,这个脚本更易于维护,并且不需要开发人员深入了解 SQL。

在这一轮中,我们选择生成基于 Bootstrap 的 HTML 报告。尽管该报告在分析能力方面有所欠缺,但它在可移植性和简洁性上有了提升。得益于 Bootstrap,这是一个专业的页面,可以搜索特定的感兴趣文件,或打印出来供喜欢纸笔方法的人使用:

挑战

一如既往,我们鼓励你为这个脚本添加新功能,并利用你掌握的知识和可用资源来扩展它。在本章中,我们首先挑战你使用 MD5 或 SHA1 对索引文件进行哈希处理,并将该信息存储在数据库中。你可以使用内置的hashlib库来处理哈希操作;更多关于哈希处理和其他技术的内容,请参考第七章,模糊哈希

此外,考虑为集合添加用户指定的过滤器,针对特定文件扩展名进行筛选。这些功能可以在不对代码进行重大修改的情况下实现,尽管你可能会发现从头开始并结合这些新功能来构建脚本对你理解更为容易且更有益。

我们可以向代码中添加的一个扩展是将文件的模式解析为单独的列,以便于在数据库和报告中查询。尽管我们存储的数字是紧凑的,且格式通常是可以理解的,但将值拆分为单独的列可以帮助非技术人员审查这些文件的属性,并在我们想要识别具有特定权限集的所有文件时,方便地对数据库进行查询。我们可以在集合模块中执行此操作,或保持当前的数据库架构,在生成报告时解释这些模式。

总结

本章重点讨论了在脚本开发中使用数据库。我们探索了如何在 Python 中使用和操作 SQLite 数据库,以存储和检索文件列表信息。我们讨论了何时以及如何使用数据库作为存储这些信息的正确解决方案,因为它具有固定的数据结构,并且可能是一个大型数据集。

此外,我们讨论了与数据库交互的多种方法,包括手动过程,展示数据库如何在较低层次上工作,以及一个更加 Pythonic 的示例,其中第三方模块为我们处理这些低级别的交互。我们还探索了一种新的报告类型,使用 HTML 创建一种可以在没有额外软件的情况下查看的不同输出,并根据需要操作它,以添加新的样式和功能。总体而言,本节的内容建立在展示我们如何使用 Python 及其支持库来解决取证挑战的基础目标之上。本项目的代码可以从 GitHub 或 Packt 下载,具体说明请见前言

在下一章中,我们将学习如何使用第三方库解析二进制数据和注册表蜂窝。学习如何解析二进制数据将成为数字取证开发者的一项基础技能,并且将在本书剩余章节中介绍的许多库中得到应用。

第六章:从二进制文件中提取工件

解析二进制数据是一个不可或缺的技能。我们不可避免地会遇到需要分析的不熟悉或未记录的工件。当感兴趣的文件是二进制文件时,这个问题更加复杂。与分析类文本文件不同,我们通常需要使用我们最喜欢的十六进制编辑器来开始逆向工程文件的内部二进制结构。逆向工程二进制文件的底层逻辑超出了本章的讨论范围。相反,我们将使用一个已经熟知结构的二进制对象。这将使我们能够突出展示如何在理解内部结构后,使用 Python 自动解析这些二进制结构。在本章中,我们将检查来自 NTUSER.DAT 注册表配置单元的 UserAssist 注册表项。

本章展示了如何从二进制数据中提取 Python 对象并生成自动化 Excel 报告。我们将使用三个模块来完成这项任务:structyarpxlsxwriter。虽然 struct 模块包含在 Python 的标准安装中,但 yarpxlsxwriter 必须单独安装。我们将在各自的章节中讲解如何安装这些模块。

struct 库用于将二进制对象解析为 Python 对象。一旦我们从二进制对象中解析出数据,就可以将我们的发现写入报告中。在过去的章节中,我们已经将结果报告在 CSV 或 HTML 文件中。在本章中,我们将创建一个包含数据表格和汇总图表的 Excel 报告。

本章将涵盖以下主题:

  • 理解 UserAssist 工件及其二进制结构

  • ROT-13 编码与解码简介

  • 使用 yarp 模块安装和操作注册表文件

  • 使用struct从二进制数据中提取 Python 对象

  • 使用 xlsxwriter 创建工作表、表格和图表

本章的代码是在 Python 2.7.15 和 Python 3.7.1 上开发和测试的

UserAssist

UserAssist 工件标识 图形用户界面GUI)应用程序在 Windows 机器上的执行。根据 Windows 操作系统的版本,这个工件存储的信息量不同。为了识别特定应用程序的数据,我们必须解码注册表项名称,因为它是作为 ROT13 编码的路径和应用程序名称存储的。例如,Windows XP 和 Vista 的 UserAssist 值数据长度为 16 字节,存储如下信息:

  • 最后执行时间的 UTC 信息(以 FILETIME 格式)

  • 执行计数

  • 会话 ID

最后执行时间信息以 Windows FILETIME 对象的形式存储。这是另一种常见的时间表示方法,区别于我们在之前章节中看到的 UNIX 时间戳。我们将在本章后面展示如何在 Python 中解读这个时间戳并以人类可读的形式显示。执行计数表示应用程序被启动的次数。

Windows 7 及更高版本存储的数据比它们的前身更多。Windows 7 的UserAssist值长度为 72 字节,并且除了前述的三个艺术品外,还存储以下内容:

  • 焦点计数

  • 焦点时间

焦点计数是将应用程序点击以将其带回焦点的次数。例如,当您打开两个应用程序时,只有一个应用程序在给定时间内处于焦点状态。另一个应用程序在再次点击它之前处于非活动状态。焦点时间是给定应用程序处于焦点状态的总时间,以毫秒表示。

此注册表项不存储基于命令行的程序或 Windows 启动程序的 GUI 应用程序的执行。

UserAssist注册表键位于每个用户主目录的根文件夹中找到的NTUSER.DAT注册表中。在这个注册表中,UserAssist键位于SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\UserAssistUserAssist键的子键包括已知的 GUID 及其各自的计数子键。在每个 GUID 的计数子键中,可能有与程序执行相关的多个值。该结构如下所示:

SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\UserAssist 
.{GUID_1} 
..Count 
.{GUID_2} 
..Count 

计数子键内的值存储了我们感兴趣的应用程序执行信息。计数子键下每个值的名称表示了 ROT-13 编码的可执行文件的路径和名称。这使得一眼难以识别可执行文件。让我们修复这个问题。

理解 ROT-13 替换密码 - rot13.py

ROT-13 是一种简单的替换密码,它转换文本并用后面的十三个字符替换每个字符。例如,字母a将被替换为字母n,反之亦然。数字、特殊字符和字符的大小写在密码的影响下不变。虽然 Python 确实提供了一种内置的解码 ROT-13 的方式,但我们假装它不存在,并手动解码 ROT-13 数据。我们将在脚本中使用内置的 ROT-13 解码方法。

在我们假装这个功能不存在之前,让我们快速使用它来说明如何使用 Python 2 对 ROT-13 数据进行编码和解码:

>>> original_data = 'Why, ROT-13?' 
>>> encoded_data = original_data.encode('rot-13') 
>>> print encoded_data 
Jul, EBG-13? 
>>> print encoded_data.decode('rot-13') 
Why, ROT-13? 

在 Python 3 中,使用原生的codecs库对 ROT-13 进行解码或编码需要稍微不同的方法:

>>> import codecs
>>> enc = codecs.getencoder('rot-13')
>>> enc('Why, ROT-13?')
('Jul, EBG-13?', 12)
>>> enc('Why, ROT-13?')[0]
'Jul, EBG-13?'

现在,让我们看看如果没有内置功能,您可能会如何处理这个问题。虽然你不应该重复造轮子,但我们希望借此机会练习列表操作并介绍一个用于审计代码的工具。本章代码包中的rot13.py脚本中的代码如下所示。

第 32 行定义的rot_code()函数接受一个经过 ROT-13 编码或解码的字符串。在第 39 行,我们有rot_chars,一个包含字母表中字符的列表。当我们遍历输入的每个字符时,我们将使用该列表将字符替换为其相隔 13 个元素的对照字符。在执行替换时,我们会将其存储在第 43 行初始化的替换列表中:

032 def rot_code(data):
033    """
034     The rot_code function encodes/decodes data using string
035     indexing
036     :param data: A string
037     :return: The rot-13 encoded/decoded string
038     """
039     rot_chars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
040     'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
041     'u', 'v', 'w', 'x', 'y', 'z']
042 
043     substitutions = []

在第 46 行,我们开始遍历数据字符串中的每个字符c。在第 49 行,我们使用条件语句来判断字符是大写字母还是小写字母。这样做是为了在处理时保留字符的大小写:

045     # Walk through each individual character
046     for c in data:
047 
048         # Walk through each individual character
049         if c.isupper():

在第 54 行,我们尝试识别字符在列表中的索引。如果字符是非字母字符,我们将收到一个ValueError异常。数字或特殊字符等非字母字符将不做任何修改地添加到替换列表中,因为这些类型的值不会被 ROT-13 编码:

051             try:
052                 # Find the position of the character in
053                 # rot_chars list
054                 index = rot_chars.index(c.lower())
055             except ValueError:
056                 substitutions.append(c)
057                 continue

一旦找到字符的索引,我们就可以通过减去 13 来计算相隔 13 个字符的对应索引。对于小于 13 的值,这将是一个负数。幸运的是,列表索引支持负数,并且在这里表现得非常好。在将对应的字符添加到替换列表之前,我们使用字符串的upper()函数将字符恢复为其原始大小写:

059             # Calculate the relative index that is 13
060             # characters away from the index
061             substitutions.append(
062             (rot_chars[(index-13)]).upper())

条件语句的else部分处理小写字符。以下代码块的功能与我们刚刚讨论的基本相同。不同之处在于,我们从不使用小写或大写字母,因为字符已经处于正确的大小写格式,便于处理:

064         else:
065 
066             try:
067                 # Find the position of the character in
068                 # rot_chars list
069                 index = rot_chars.index(c)
070             except ValueError:
071                 substitutions.append(c)
072                 continue
073 
074             substitutions.append(rot_chars[((index-13))])

最后,在第 76 行,我们使用join()方法将替换列表合并为一个字符串。我们在空字符串上进行连接,这样列表中的每个元素都会被附加,而没有任何分隔符。如果从命令行调用此脚本,它将输出处理后的字符串Jul, EBG-13?,我们知道它对应的是ROT-13?。我们有以下代码:


076     return ''.join(substitutions)
077 
078 if __name__ == '__main__':
079     print(rot_code('Jul, EBG-13?'))

以下截图展示了我们如何导入rot13模块并调用rot_code()方法来解码或编码字符串:

确保 Python 交互式提示符与rot13.py脚本在同一目录下打开。否则,将会生成ImportError错误。

使用 timeit 评估代码

现在让我们审视一下我们的模块,看看它是否优于内置方法(剧透:并不是!)我们曾提到,除非绝对必要,否则你不应重新发明轮子。这样做是有充分理由的:大多数内置或第三方解决方案已经过性能和安全性优化。我们的rot_code()函数与内置函数相比如何呢?我们可以使用timeit模块来计算一个函数或代码行执行所需的时间。

让我们比较两种解码 ROT-13 值的方法之间的差异。通过向 Python 解释器提供-m选项,如果其父目录在sys.path列表中,则会执行指定的模块。timeit模块可以直接从命令行通过-m选项调用。

我们可以通过导入sys模块并打印sys.path来查看哪些目录在作用范围内。为了扩展sys.path中可用的项目,我们可以使用列表属性(如 append 或 extend)将新项目追加到其中。

timeit模块支持多种开关,并可以用于运行单行代码或整个脚本。-v开关打印更详细的输出,且当提供更多的v开关时,输出会越来越详细。-n开关是执行代码或脚本的次数(例如,每个测量周期内的执行次数)。我们可以使用-r开关来指定重复测量的次数(默认为3)。增加这个值可以让我们计算出更精确的平均执行速度。最后,-s开关是一个在第一次执行时运行的语句,用于让我们导入制作的脚本。有关更多文档,请访问docs.python.org/3/library/timeit.html或运行python -m timeit -h

当我们在计算两种方法的执行时间时,计算机生成的输出如以下截图所示。性能可能会因机器不同而有所差异。在我们的第一次测试中,我们测量了运行三次一百万次脚本所需的时间。在第一次循环中,我们先导入了我们的模块rot13,然后才调用它。在第二次测试中,我们同样测量了 Python 2 内置decode()函数的三次一百万循环:

事实证明,不必重新发明轮子是有充分理由的。我们的自定义rot_code()函数在运行一千次时明显比内置方法慢。我们可能不会调用这个函数一千次;对于UserAssist键,这个函数可能只会调用几百次。然而,如果我们处理更多数据或脚本特别慢,我们就可以开始对单独的函数或代码行进行计时,找出那些优化不良的代码。

顺便提一句,你还可以在函数调用前后使用time.time()函数,通过减去两个时间点来计算经过的时间。这种替代方法实现起来稍微简单一些,但没有那么健壮。

你现在已经了解了UserAssist工件、ROT-13 编码以及审计我们代码的机制。让我们转移焦点,看看本章中将要使用的其他模块。其中一个模块yarp将用于访问和交互UserAssist键和值。

使用 yarp 库

yarp(即Yet Another Registry Parser)库可以用来从注册表 hive 中获取键和值。Python 提供了一个名为_winreg的内置注册表模块;然而,这个模块仅在 Windows 机器上工作。_winreg模块与运行该模块的系统上的注册表进行交互。它不支持打开外部的注册表 hive。

yarp库允许我们与提供的注册表 hive 进行交互,并且可以在非 Windows 机器上运行。yarp库可以从github.com/msuhanov/yarp下载。在该项目的 GitHub 页面上,点击发布部分,可以看到所有稳定版本的列表,并下载所需的版本。对于本章,我们使用的是 1.0.25 版本。下载并解压缩归档文件后,我们可以运行其中的setup.py文件来安装模块。在命令提示符中,在模块的顶级目录执行以下代码:

python setup.py install 

这应该能够成功地在你的机器上安装yarp库。我们可以通过打开 Python 交互式提示符并输入import yarp来确认。如果模块没有成功安装,我们将收到一个错误。安装了yarp后,接下来我们可以开始学习如何利用这个模块满足我们的需求。

首先,我们需要从yarp模块中导入Registry类。然后,使用RegistryHive函数并将其传递给我们想要查询的注册表对象。在这个例子中,我们已将NTUSER.DAT注册表文件复制到当前工作目录,这样我们只需要提供文件名而不需要路径。接下来,我们使用find_key方法来导航到我们感兴趣的键。在这个例子中,我们关注的是RecentDocs注册表键。这个键包含按扩展名分隔的最近活动文件:

>>> from yarp import Registry
>>> reg_file = open('NTUSER.DAT', 'rb')
>>> reg = Registry.RegistryHive(reg_file) 
>>> recent_docs = reg.find_key('SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\RecentDocs')

如果我们打印recent_docs变量,我们可以看到它包含 151 个值和 75 个子键,这些子键可能包含附加的值和子键。此外,我们还可以使用last_written_timestamp()方法查看注册表键的最后写入时间:

>>> print(recent_docs) 
RegistryKey, name: RecentDocs, subkeys: 75, values: 151

>>> print(recent_docs.last_written_timestamp()) # Last Written Time 
datetime.datetime(2018, 11, 20, 3, 14, 40, 286516) 

我们可以使用subkeys()函数在recent_docs键的值上进行迭代,使用 for 循环遍历。对于每个值,我们可以访问name()value()values_count()方法等。访问值时(与访问子键不同),我们还可以通过使用raw_data()函数访问该值的原始数据。对于我们的目的,当我们想处理底层的二进制数据时,会使用raw_data()函数。我们有如下代码:

>>> for i, value in enumerate(recent_docs.subkeys()): 
...     print('{}) {}: {}'.format(i, value.name(), value.values_count())) 
...  
0) .001: 2
1) .1: 2
2) .7z: 2
3) .AAE: 2
...  

yarp模块的另一个有用功能是提供了查询特定子键或值的方式。这可以通过subkey()value()find_key()函数来实现。如果使用subkey()函数时找不到子键,则会生成None值:

>>> if recent_docs.subkey('.docx'): 
...     print('Found docx subkey.') 
...  
Found docx subkey. 
>>> if recent_docs.subkey('.1234abcd') is None: 
...     print('Did not find 1234abcd subkey.') 
...  
None

find_key() 函数接受一个路径,并能够通过多级递归查找子键。subkey()value() 函数仅搜索子元素。我们可以使用这些函数在尝试导航到键或值之前确认它们是否存在。yarp 还提供了其他一些相关功能,本文未涵盖,包括恢复已删除的注册表键和值、提取注册表键和值以及支持事务日志文件。

使用 yarp 模块,查找键及其值非常简单。然而,当值不是字符串而是二进制数据时,我们必须依赖另一个模块来理清数据。对于所有二进制相关的需求,struct 模块是一个极好的选择。

介绍 struct 模块

struct 模块是 Python 标准库的一部分,非常有用。struct 库用于将 C 结构转换为二进制数据,或将二进制数据转换为 C 结构。该模块的完整文档可以在 docs.python.org/3/library/struct.html 找到。

出于取证目的,struct 模块中最重要的函数是 unpack() 方法。该方法接受一个格式字符串,表示要从二进制数据中提取的对象。格式字符串指定的大小必须与传递给函数的二进制数据的大小相符。

格式字符串告诉 unpack() 函数二进制对象中包含的数据类型及其解释方式。如果我们没有正确识别数据类型或尝试解包的数据比提供的数据多或少,struct 模块将抛出异常。以下是构建格式字符串时最常用字符的表格。标准大小列表示二进制对象的预期大小(以字节为单位):

字符 Python 对象 标准大小(字节)
h 整数 2
i 整数 4
q 整数 8
s 字符串 1
x 不适用 不适用

格式字符串中还可以使用其他字符。例如,其他字符可以将二进制数据解释为浮点数、布尔值以及其他各种 C 结构。x 字符仅仅是一个填充字符,用于忽略我们不关心的字节。

此外,还可以使用可选的起始字符来定义字节顺序、大小和对齐方式。默认情况下是本地字节顺序、大小和对齐方式。由于我们无法预测脚本可能运行的环境,因此通常不建议使用任何本地选项。相反,我们可以使用 <> 符号分别指定小端或大端字节顺序和标准大小。让我们通过几个例子来实践。

首先,打开一个交互式提示符并导入struct模块。接下来,我们将 0x01000000 赋值给一个变量。在 Python 3 中,十六进制表示法通过转义字符和每两个十六进制字符前的x来指定。我们的十六进制数据长度为四个字节,为了将其解释为一个整数,我们使用i字符。将十六进制数据解释为小端整数时,返回的值是1

>>> import struct 
>>> raw_data = b'\x01\x00\x00\x00' # Integer (1) 
>>> print(struct.unpack('<i', raw_data)) # Little-Endian 
(1,) 

<i>i表示字符串格式。我们告诉unpack()方法将raw_data解释为一个四字节整数,按照小端或大端字节顺序。struct模块将解包的数据作为元组返回。默认情况下,Python 会将一个单一元素的元组打印在括号中,并带有尾随逗号,如以下输出所示:

>>> print(struct.unpack('>i', raw_data)) # Big-Endian 
(16777216,) 
>>> print(type(struct.unpack('>i', raw_data))) 
<class 'tuple'> 

让我们看另一个例子。我们可以通过使用三个i字符将rawer_data解释为三个 4 字节整数。或者,我们可以在格式字符前加上数字,以便按顺序解析多个值。在这两种情况下,当按小端方式解释时,我们得到整数154。如果我们不关心中间的整数,可以使用4x字符跳过它:

>>> rawer_data = b'\x01\x00\x00\x00\x05\x00\x00\x00\x04\x00\x00\x00' 
>>> print(struct.unpack('<iii', rawer_data)) 
(1, 5, 4) 
>>> print(struct.unpack('<3i', rawer_data)) 
(1, 5, 4) 
>>> print(struct.unpack('<i4xi', rawer_data)) # "skip" 4 bytes 
(1, 4) 

我们在本节早些时候提到了使用struct时可能出现的错误。现在,让我们故意制造一些错误,以理解它们的含义。以下两个例子会出现错误,因为我们试图unpack()的数据量比rawer_data变量中实际存在的值多或少。这在尝试解包大量二进制数据时可能会导致一些初步的挫败感。务必检查数学计算、字节顺序以及大小是否为标准或本地格式:

>>> print(struct.unpack('<4i', rawer_data)) 
struct.error: unpack requires a buffer of 16 bytes
>>> print(struct.unpack('<2i', rawer_data)) 
struct.error: unpack requires a buffer of 8 bytes 

让我们更进一步,使用struct模块解析一个UserAssist值。我们将解析一个 Windows XP 的值,这是最简单的情况,因为它仅为 16 个字节。Windows XP UserAssist 值的字节偏移量记录在以下表中:

字节偏移量 对象
0-3 会话 ID 整数
4-7 计数 整数
8-15 FILETIME 整数

以下十六进制转储保存到文件Neguhe Qrag.bin中。该文件与代码包一起提供,可以从packtpub.com/books/content/support下载:

0000: 0300 0000 4800 0000  |....H... 
0010: 01D1 07C4 FA03 EA00  |........ 

在从文件对象解包数据时,我们需要以rb模式打开文件,而不是默认的r模式,以确保可以将数据作为字节读取。获取原始数据后,我们可以使用特定的字符格式解析它。我们知道前 8 个字节是两个 4 字节整数(2i),然后是一个 8 字节整数(q),表示UserAssist值的 FILETIME。我们可以对返回的元组进行索引,打印出每个提取的整数:

>>> rawest_data = open('Neguhe Qrag.bin', 'rb').read()
>>> parsed_data = struct.unpack('<2iq', rawest_data)
>>> print('Session ID: {}, Count: {}, FILETIME: {}'.format(parsed_data[0], parsed_data[1], parsed_data[2]))
...
Session ID: 3, Count: 72, FILETIME: 6586952011847425 

一旦我们在脚本中解析了UserAssist值,我们将以报告准备好的格式呈现结果。过去,我们使用了 CSV 和 HTML 格式的输出报告。报告通常会以电子表格格式进行审查,使用像 Microsoft Excel 这样的软件。为了提供充分利用此软件的报告,我们将学习如何将 XSLX 格式的电子表格作为脚本的输出。

使用 xlsxwriter 模块创建电子表格

xlsxwriter(版本 1.1.2)是一个有用的第三方模块,可以将数据写入 Excel 电子表格。Python 有很多支持 Excel 的模块,但我们选择了这个模块,因为它非常强大且文档齐全。顾名思义,这个模块只能用于写入 Excel 电子表格。xlsxwriter模块支持单元格和条件格式、图表、表格、过滤器和宏等功能。这个模块可以通过pip安装:

pip install xlsxwriter==1.1.2 

向电子表格中添加数据

让我们快速创建一个名为simplexlsx.v1.py的脚本来演示这个例子。在第 2 和第 3 行,我们导入了xlsxwriterdatetime模块。我们将要绘制的数据,包括列名,存储在school_data变量中的嵌套列表中。每个列表是一行信息,我们希望将其存储在 Excel 电子表格中,第一项包含列名:

002 import xlsxwriter
003 from datetime import datetime
...  
033 school_data = [['Department', 'Students', 'Cumulative GPA',
034                 'Final Date'],
035                ['Computer Science', 235, 3.44,
036                datetime(2015, 7, 23, 18, 0, 0)],
037                ['Chemistry', 201, 3.26,
038                datetime(2015, 7, 25, 9, 30, 0)],
039                ['Forensics', 99, 3.8,
040                datetime(2015, 7, 23, 9, 30, 0)],
041                ['Astronomy', 115, 3.21,
042                datetime(2015, 7, 19, 15, 30, 0)]]

write_xlsx()函数定义在第 45 行,负责将数据写入电子表格。首先,我们必须使用Workbook()函数创建 Excel 电子表格,并提供所需的文件名作为输入。在第 53 行,我们使用add_worksheet()函数创建工作表。此函数可以接受所需的工作表标题,或者使用默认名称Sheet N,其中N表示一个数字:

045 def write_xlsx(data):
046     """
047     The write_xlsx function creates an XLSX spreadsheet from a
048     list of lists
049     :param data: A list of lists to be written in the spreadsheet
050     :return: Nothing
051     """
052     workbook = xlsxwriter.Workbook('MyWorkbook.xlsx')
053     main_sheet = workbo
ok.add_worksheet('MySheet')

date_format变量存储我们将用来显示datetime对象的自定义数字格式,以便以所需的易读格式显示它们。在第 58 行,我们开始遍历数据以进行写入。第 59 行的条件用于处理数据列表中的第一项,即列名。我们使用write()函数并提供数值型的行和列。例如,我们也可以使用 Excel 符号表示法,如A1,表示数据应写入第一列和第一行,而不是使用数值来表示列和行:

055     date_format = workbook.add_format(
056     {'num_format': 'mm/dd/yy hh:mm:ss AM/PM'})
057 
058     for i, entry in enumerate(data):
059         if i == 0:
060             main_sheet.write(i, 0, entry[0])
061             main_sheet.write(i, 1, entry[1])
062             main_sheet.write(i, 2, entry[2])
063             main_sheet.write(i, 3, entry[3])

write()方法将尝试在检测到数据类型时写入适当的对象数据类型。然而,我们可以使用不同的写入方法来指定正确的格式。这些专用的写入方法会保留数据类型,以便我们可以使用适当的数据类型特定的 Excel 函数处理对象。由于我们知道条目列表中的数据类型,我们可以手动指定何时使用通用的write()函数,何时使用write_number()函数:

064         else:
065             main_sheet.write(i, 0, entry[0])
066             main_sheet.write_number(i, 1, entry[1])
067             main_sheet.write_number(i, 2, entry[2])

对于列表中的第四个条目,即 datetime 对象,我们向 write_datetime() 函数传入第 55 行定义的 date_format。数据写入工作簿后,我们使用 close() 函数关闭并保存电子表格。在第 73 行,我们调用 write_xlsx() 函数,并将之前构建的 school_data 列表传入,代码如下:

068             main_sheet.write_datetime(i, 3, entry[3], date_format)
069 
070     workbook.close()
071 
072 
073 write_xlsx(school_data)

下表列出了 write 函数及其保留的对象:

函数 支持的对象
write_string str
write_number intfloatlong
write_datetime datetime 对象
write_boolean bool
write_url str

当脚本在命令行中被调用时,会创建一个名为 MyWorkbook.xlsx 的电子表格。当我们将其转换为表格时,我们可以按任何值进行排序,并使用我们都熟悉的 Excel 函数和功能。如果我们没有保留数据类型,像日期这样的值可能会显示得与预期不同:

构建表格

能够将数据写入 Excel 文件并保留对象类型,已经比 CSV 有所改进,但我们还能做得更好。通常,检查员在处理 Excel 电子表格时首先会做的就是将数据转换为表格,并开始对数据集进行排序和筛选。但我们可以使用 xlsxwriter 将数据范围转换为表格。事实上,用 xlsxwriter 写入表格可以说比逐行写入要容易。本文讨论的代码体现在 simplexlsx.v2.py 文件中。

在这一版本中,我们删除了 school_data 变量中包含列名的初始列表。我们新的 write_xlsx() 函数单独写入表头,稍后我们将看到这一点:

034 school_data = [['Computer Science', 235, 3.44,
035                 datetime(2015, 7, 23, 18, 0, 0)],
036                 ['Chemistry', 201, 3.26,
037                 datetime(2015, 7, 25, 9, 30, 0)],
038                 ['Forensics', 99, 3.8,
039                 datetime(2015, 7, 23, 9, 30, 0)],
040                 ['Astronomy', 115, 3.21,
041                 datetime(2015, 7, 19, 15, 30, 0)]]

第 44 到 55 行与函数的前一版本相同。我们将表格写入电子表格的操作在第 58 行完成。请查看以下代码:

044 def write_xlsx(data):
045     """
046     The write_xlsx function creates an XLSX spreadsheet from a
047     list of lists
048     :param data: A list of lists to be written in the spreadsheet
049     :return: Nothing
050     """
051     workbook = xlsxwriter.Workbook('MyWorkbook.xlsx')
052     main_sheet = workbook.add_worksheet('MySheet')
053 
054     date_format = workbook.add_format(
055     {'num_format': 'mm/dd/yy hh:mm:ss AM/PM'})

add_table() 函数接受多个参数。首先,我们传入一个表示表格左上角和右下角单元格的字符串,采用 Excel 表示法。我们使用在第 56 行定义的length变量来计算表格所需的长度。第二个参数稍微有些复杂;这是一个字典,包含两个键:data 和 columns。data 键对应我们的 data 变量的值,在这个例子中,它可能命名得不好。columns 键定义了每个列头,并且可以选择性地定义其格式,如第 62 行所示:

056     length = str(len(data) + 1)
057 
058     main_sheet.add_table(('A1:D' + length),
059     {'data': data,
060     'columns': [{'header': 'Department'}, {'header': 'Students'},
061                 {'header': 'Cumulative GPA'},
062                 {'header': 'Final Date', 'format': date_format}]})
063 
064     workbook.close()

与前一个示例相比,我们用更少的代码行创建了一个更有用的输出,且它是以表格形式构建的。现在,我们的电子表格已经将指定的数据转换成了表格,并准备好进行排序。

在构建表格时,还可以提供更多的键和值。有关高级用法的更多详细信息,请查阅文档(xlsxwriter.readthedocs.org)。

当我们处理表示工作表每一行的嵌套列表时,这个过程非常简单。对于不符合这种格式的数据结构,我们需要结合我们之前迭代中演示的两种方法,以实现相同的效果。例如,我们可以定义一个跨越特定行和列的表格,然后使用 write() 函数写入这些单元格。然而,为了避免不必要的麻烦,我们建议在可能的情况下保持数据为嵌套列表格式。

使用 Python 创建图表

最后,让我们使用 xlsxwriter 创建一个图表。该模块支持多种不同类型的图表,包括线形图、散点图、条形图、柱状图、饼图和区域图。我们使用图表来以有意义的方式总结数据。这在处理大数据集时特别有用,能够让分析人员在深入分析之前先对数据有个初步的了解。

让我们再次修改上一个版本,以显示图表。我们将把这个修改过的文件保存为 simplexlsx.v3.py。在第 65 行,我们将创建一个名为 department_grades 的变量。这个变量将是我们通过 add_chart() 方法创建的图表对象。对于这个方法,我们传入一个字典,指定键和值。在这种情况下,我们指定图表的类型为柱状图:

065     department_grades = workbook.add_chart({'type':'column'})

在第 66 行,我们使用 set_title() 函数,再次传入一个参数字典。我们将名称键设置为我们想要的标题。此时,我们需要告诉图表要绘制哪些数据。我们通过 add_series() 函数来完成这一点。每个类别键都映射到 Excel 表示法,用于指定横轴数据。纵轴由 values 键表示。数据指定完成后,我们使用 insert_chart() 函数在电子表格中绘制数据。我们给这个函数传递一个字符串,表示将作为锚点的单元格,图表的左上角将绘制在此处:

066     department_grades.set_title(
067     {'name':'Department and Grade distribution'})
068     department_grades.add_series(
069     {'categories':'=MySheet!$A$2:$A$5',
070     'values':'=MySheet!$C$2:$C$5'})
071     main_sheet.insert_chart('A8', department_grades)
072     workbook.close()

运行此版本的脚本将把我们的数据转换为表格并生成一个柱状图,按部门比较它们的累计成绩。我们可以清楚地看到,不出意料,物理系在学校项目中拥有最高的 GPA 获得者。对于这样一个小型数据集,这些信息足够直观。但在处理更大规模的数据时,创建总结性图表将特别有助于理解全貌:

请注意,xlsxwriter 模块中有大量额外的功能,我们在脚本中不会使用到。这个模块非常强大,我们推荐在任何需要写入 Excel 电子表格的操作中使用它。

UserAssist 框架

我们的UserAssist框架由三个脚本组成,分别是userassist_parser.pycsv_writer.pyxlsx_writer.pyuserassist_parser.py脚本处理大部分的处理逻辑,然后将结果传递给 CSV 或 XLSX 写入器。我们框架的目录结构如下所示。我们的写入器包含在一个名为Writers的目录中。请记住,为了让 Python 能够搜索到一个目录,它需要包含__init__.py文件。该文件可以为空,包含函数和类,或者包含在导入时执行的代码:

  |-- userassist_parser.py 
  |-- Writers 
      |-- __init__.py 
      |-- csv_writer.py 
      |-- xlsx_writer.py 

开发我们的 UserAssist 逻辑处理器 – userassist_parser.py

userassist_parser.py脚本负责处理用户输入、创建日志文件,并解析来自NTUSER.DAT文件的UserAssist数据。在第 2 到第 9 行,我们导入了熟悉的和新的模块来促进我们的任务。yarpstruct模块将分别允许我们访问并提取来自UserAssist二进制数据的对象。我们导入了位于Writers目录中的xlsx_writercsv_writer模块。其他使用的模块在之前的章节中已经介绍过:

001 """UserAssist parser leveraging the YARP library."""
002 from __future__ import print_function
003 import argparse
004 import struct
005 import sys
006 import logging
007 import os
008 from Writers import xlsx_writer, csv_writer
009 from yarp import Registry

在第 45 行定义的KEYS变量作为一个空列表,将存储解析后的UserAssist值。第 48 行定义的main()函数将处理所有协调逻辑。它调用函数解析UserAssist键,并随后写入结果。create_dictionary()函数使用Registry模块查找并将UserAssist值名称和原始数据存储在每个 GUID 的字典中。

在第 134 行,我们定义了parse_values()函数,该函数使用struct处理每个UserAssist值的二进制数据。在此方法中,我们根据数据的长度来确定我们正在处理的是 Windows XP 还是 Windows 7 的UserAssist数据。get_name()函数是一个小函数,用于从完整路径中分离出可执行文件的名称:

045 KEYS = [] 
...  
048 def main(): 
... 
085 def create_dictionary(): 
... 
134 def parse_values(): 
...  
176 def get_name(): 

在第 202 到 212 行,我们创建了一个参数解析对象,它接受两个位置参数和一个可选参数。我们的REGISTRY输入是感兴趣的NTUSER.DAT文件。OUTPUT参数是所需输出文件的路径和文件名。可选的-l开关是日志文件的路径。如果没有提供此路径,日志文件将创建在当前工作目录中:

202 if __name__ == '__main__':
203     parser = argparse.ArgumentParser(description=__description__,
204                                      epilog='Developed by ' +
205                                      __author__ + ' on ' +
206                                      __date__)
207     parser.add_argument('REGISTRY', help='NTUSER Registry Hive.')
208     parser.add_argument('OUTPUT',
209     help='Output file (.csv or .xlsx)')
210     parser.add_argument('-l', help='File path of log file.')
211 
212     args = parser.parse_args()

如果用户提供了日志路径,我们会在第 215 行检查该路径是否存在。如果路径不存在,我们使用os.makedirs()函数创建日志目录。无论哪种情况,我们都会使用提供的目录和日志文件实例化log_path变量。在第 220 行,我们创建日志并像之前的章节一样写入启动细节,然后在第 227 行调用main()

214     if args.l:
215         if not os.path.exists(args.l):
216             os.makedirs(args.l)
217         log_path = os.path.join(args.l, 'userassist_parser.log')
218     else:
219         log_path = 'userassist_parser.log'
220     logging.basicConfig(filename=log_path, level=logging.DEBUG,
221                         format=('%(asctime)s | %(levelname)s | '
222                         '%(message)s'), filemode='a')
223 
224     logging.info('Starting UserAssist_Parser')
225     logging.debug('System ' + sys.platform)
226     logging.debug('Version ' + sys.version)
227     main(args.REGISTRY, args.OUTPUT)

以下流程图描述了我们UserAssist框架内相互关联的函数。在此图中,我们可以看到main()函数如何调用并接收来自create_dictionary()parse_values()函数的数据。parse_values()函数分别调用了get_name()函数:

评估main()函数

main()函数在调用适当的方法以写入out_file之前,先将注册表文件发送进行处理。在第 61 行,我们调用create_dictionary()函数,创建一个包含UserAssist数据并映射到可执行文件名的字典列表:

048 def main(registry, out_file):
049     """
050     The main function handles main logic of script.
051     :param registry: Registry Hive to process
052     :param out_file: The output path and file
053     :return: Nothing.
054     """
055     if os.path.basename(registry).lower() != 'ntuser.dat':
056         print(('[-] {} filename is incorrect (Should be '
057                'ntuser.dat)').format(registry))
058         logging.error('Incorrect file detected based on name')
059         sys.exit(1)
060     # Create dictionary of ROT-13 decoded UA key and its value
061     apps = create_dictionary(registry)

接下来,这个字典被传递给parse_values()方法,该方法将解析后的数据附加到我们在第 45 行创建的KEYS列表中。此函数返回一个整数,表示解析的UserAssist数据类型。该函数对于 Windows XP 的UserAssist值返回0,对于 Windows 7 的返回1。我们记录此信息以便进行故障排除:

062     ua_type = parse_values(apps)
063 
064     if ua_type == 0:
065         logging.info('Detected XP-based Userassist values.')
066 
067     else:
068         logging.info(('Detected Win7-based Userassist values. '
069                       'Contains Focus values.'))

一旦数据处理完成,它可以被发送到我们的写入程序。我们使用endswith()方法来识别用户提供的输出文件的扩展名。如果输出以.xlsx.csv结尾,我们分别将数据发送到excel_writer()csv_writer()函数,具体如下:

071     # Use .endswith string function to determine output type
072     if out_file.lower().endswith('.xlsx'):
073         xlsx_writer.excel_writer(KEYS, out_file)
074     elif out_file.lower().endswith('.csv'):
075         csv_writer.csv_writer(KEYS, out_file)

如果用户没有在输出文件中包括扩展名,我们会向日志写入警告,并将数据写入当前工作目录中的 CSV 文件。我们选择 CSV 输出,因为它代表了我们支持的输出格式中最简单且最具可移植性的选项。此外,如果用户希望在电子表格应用程序中检查数据,他们可以轻松导入并将 CSV 文档转换为 XLSX 格式:

076     else:
077     print(('[-] CSV or XLSX extension not detected in '
078            'output. Writing CSV to current directory.'))
079     logging.warning(('.csv or .xlsx output not detected. '
080                      'Writing CSV file to current '
081                      'directory.'))
082     csv_writer.csv_writer(KEYS, 'Userassist_parser.csv')

两个写入程序接受相同的参数:KEYSout_fileKEYS列表(在第 45 行定义)是一个包含UserAssist字典的容器。我们将数据打包成字典列表,以便使用字典键来决定哪些头部是存在的。out_file是期望输出的路径和名称。

定义create_dictionary()函数

create_dictionary()函数为处理准备UserAssist数据。此函数提取每个UserAssist GUID 键中的所有值。它创建一个字典,其中键是经过 ROT-13 解码的可执行文件名,值是相应的二进制数据。现在提取这些二进制数据,以便稍后在其他函数中处理:

085 def create_dictionary(registry):
086     """
087     The create_dictionary function creates a list of dictionaries
088     where keys are the ROT-13 decoded app names and values are
089     the raw hex data of said app.
090     :param registry: Registry Hive to process
091     :return: apps_list, A list containing dictionaries for
092     each app
093     """

在第 97 行,我们尝试打开用户提供的注册表文件。如果访问输入文件时发生错误,我们会捕获该错误,记录日志,并以错误代码2优雅地退出:

094     try:
095         # Open the registry file to be parsed
096         registry_file = open(registry, "rb")
097         reg = Registry.RegistryHive(registry_file)
098     except (IOError, UnicodeDecodeError) as e:
099         msg = 'Invalid NTUSER.DAT path or Registry ID.'
100         print('[-]', msg)
101         logging.error(msg)
102         sys.exit(2)

如果我们能够打开注册表文件,我们接着尝试导航到UserAssist键。我们使用条件语句来捕捉UserAssist键未在提供的注册表文件中找到的情形。注意,对于此错误,我们使用整数3来区分与先前的退出情形:

104     # Navigate to the UserAssist key
105     ua_key = reg.find_key(
106     ('SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer'
107     '\\UserAssist'))
108     if ua_key is None:
109         msg = 'UserAssist Key not found in Registry file.'
110         print('[-]', msg)
111         logging.error(msg)
112         sys.exit(3)

在第 113 行,我们创建了一个名为 apps_list 的列表,用于存储 UserAssist 字典。如果我们能够找到 UserAssist 键,我们就会遍历每个 ua_subkey,一个 GUID,并检查它们的计数子键。这是一个重要的步骤;随着 Windows 的发展,UserAssist 键中添加了更多的 GUID。为了避免硬编码这些值,可能会遗漏未来 Windows 版本中新添加的 GUID,我们选择了一个更动态的过程,该过程将发现并处理跨多个版本的 Windows 中的新 GUID:

113     apps_list = []
114     # Loop through each subkey in the UserAssist key
115     for ua_subkey in ua_key.subkeys():
116         # For each subkey in the UserAssist key, detect a subkey
117         # called Count that has more than 0 values to parse.

该过程涉及检查每个具有名为Count的子键的 GUID,该子键存储实际的 UserAssist 应用程序值。在第 118 行,我们确定该 GUID 是否具有名为 Count 的子键,并且该子键具有一个或多个值。这确保我们找到了系统上所有的 UserAssist 值:

118         if(ua_subkey.subkey('Count') and
119         ua_subkey.subkey('Count').values_count() > 0):

我们在第 120 行创建了一个应用程序字典,并开始循环遍历 Count 子键下的每个值。对于每个值,我们将解码后的 ROT-13 名称作为键,并将其 raw_data 作为值进行关联。一旦 GUID 中的所有值都添加到字典中,它就会被附加到 apps_list 中,循环再次开始。一旦所有 GUID 都被处理完,我们的列表会返回到 main() 函数:

120             apps = {}
121             for v in ua_subkey.subkey('Count').values():
122                 if sys.version_info[0] == 2:
123                     apps[v.name().encode('utf-8').decode(
124                     'rot-13')] = v.data_raw()
125                 elif sys.version_info[0] == 3:
126                     import codecs
127                     enc = codecs.getencoder('rot-13')
128                     apps[enc(str(v.name()))[0]] = v.data_raw()
129 
130                 apps_list.append(apps)
131     return apps_list

使用 parse_values() 函数提取数据

parse_values() 函数将 GUID 字典列表作为输入,并使用 struct 解析二进制数据。正如我们所讨论的,我们将支持两种类型的 UserAssist 键:Windows XP 和 Windows 7。以下两个表格分解了我们将解析的相关数据结构。基于 Windows XP 的键是 16 字节长,包含会话 ID、计数和 FILETIME 时间戳:

字节偏移量 对象
0-3 会话 ID 整数
4-7 计数 整数
8-15 FILETIME 整数

基于 Windows 7 的数据是 72 字节长,包含会话 ID、计数、专注计数/时间和 FILETIME 时间戳:

字节偏移量 对象
0-3 会话 ID 整数
4-7 计数 整数
8-11 专注计数 整数
12-15 专注时间 整数
16-59 ??? 不适用
60-67 FILETIME 整数
68-71 ??? 不适用

在第 143 行到第 146 行,我们通过实例化 ua_type 变量并记录执行状态来设置我们的函数。这个 ua_type 变量将用于记录我们正在处理的 UserAssist 值的类型。在第 148 行和第 149 行,我们遍历每个字典中的每个值,以识别其类型并进行解析:

134 def parse_values(data):
135     """
136     The parse_values function uses struct to unpack the raw value
137     data from the UA key
138     :param data: A list containing dictionaries of UA
139     application data
140     :return: ua_type, based on the size of the raw data from
141     the dictionary values.
142     """
143     ua_type = -1
144     msg = 'Parsing UserAssist values.'
145     print('[+]', msg)
146     logging.info(msg)
147 
148     for dictionary in data:
149         for v in dictionary.keys():

在第 151 行和第 159 行,我们使用 len() 函数来识别 UserAssist 键的类型。对于基于 Windows XP 的数据,我们需要提取两个 4 字节的整数,后跟一个 8 字节的整数。我们还希望使用标准大小以小端字节顺序解释这些数据。在第 152 行,我们通过 <2iq 作为 struct 格式字符串来实现这一点。传递给 unpack 方法的第二个参数是来自 GUID 字典的特定键的原始二进制数据:

150             # WinXP based UA keys are 16 bytes
151             if len(dictionary[v]) == 16:
152                 raw = struct.unpack('<2iq', dictionary[v])
153                 ua_type = 0
154                 KEYS.append({'Name': get_name(v), 'Path': v,
155                 'Session ID': raw[0], 'Count': raw[1],
156                 'Last Used Date (UTC)': raw[2],
157                 'Focus Time (ms)': '', 'Focus Count': ''})

基于 Windows 7 的数据稍微复杂一些。二进制数据的中间和末尾部分包含我们不感兴趣的字节,但由于结构体的性质,我们必须在格式中考虑到它们。我们为此任务使用的格式字符串是 <4i44xq4x,它包含了四个 4 字节整数、44 字节的中间空隙、一个 8 字节整数,以及我们将忽略的剩余 4 字节:

158             # Win7 based UA keys are 72 bytes
159             elif len(dictionary[v]) == 72:
160                 raw = struct.unpack('<4i44xq4x', dictionary[v])
161                 ua_type = 1
162                 KEYS.append({'Name': get_name(v), 'Path': v,
163                 'Session ID': raw[0], 'Count': raw[1], 
164                 'Last Used Date (UTC)': raw[4],
165                 'Focus Time (ms)': raw[3],'Focus Count': raw[2]})

在解析 UserAssist 记录时,我们将它们附加到 KEYS 列表中进行存储。当我们附加解析值时,我们用大括号将它们包裹起来,创建我们内部的字典对象。我们还对 UserAssist 值名调用 get_name() 函数,以分离可执行文件和其路径。请注意,无论 UserAssist 键的类型如何,我们都会在字典中创建相同的键。这将确保所有字典具有相同的结构,并有助于简化我们的 CSV 和 XLSX 输出函数。

如果 UserAssist 值不是 16 或 72 字节(这种情况是可能发生的),那么该值将被跳过,并且用户会被通知跳过的名称和大小。根据我们的经验,这些值与法医取证无关,因此我们决定跳过它们。在第 173 行,UserAssist 类型被返回到 main() 函数:

166             else:
167                 # If the key is not WinXP or Win7 based -- ignore.
168                 msg = 'Ignoring {} value that is {} bytes'.format(
169                 str(v), str(len(dictionary[v])))
170                 print('[-]', msg)
171                 logging.info(msg)
172                 continue
173     return ua_type

使用 get_name() 函数处理字符串

get_name() 函数使用字符串操作将可执行文件与路径名分离。从测试中我们发现路径中包含冒号、反斜杠或两者兼有。由于这种模式的存在,我们可以尝试使用这些字符来分割路径信息,从中提取出文件名:

176 def get_name(full_name):
177     """
178     the get_name function splits the name of the application
179     returning the executable name and ignoring the
180     path details.
181     :param full_name: the path and executable name
182     :return: the executable name
183     """

在第 185 行,我们检查 full_name 变量中是否同时包含冒号和反斜杠。如果为真,我们使用 rindex() 函数获取两个元素的最右侧子字符串的索引。在第 187 行,我们检查冒号的最右侧索引是否出现在反斜杠的后面。使用最大索引的元素作为 split() 函数的分隔符。为了获得列表中的最后一个子字符串(即我们的可执行文件名),我们使用 -1 索引:

184     # Determine if '\\' and ':' are within the full_name
185     if ':' in full_name and '\\' in full_name:
186         # Find if ':' comes before '\\'
187         if full_name.rindex(':') > full_name.rindex('\\'):
188             # Split on ':' and return the last element 
189             # (the executable)
190             return full_name.split(':')[-1]
191         else:
192             # Otherwise split on '\\'
193             return full_name.split('\\')[-1]

在第 196 行和第 198 行,我们处理了替代场景,并在冒号或反斜杠处进行分割,然后返回子字符串列表中的最后一个元素:

194     # When just ':' or '\\' is in the full_name, split on
195     # that item and return the last element (the executable)
196     elif ':' in full_name:
197         return full_name.split(':')[-1]
198     else:
199         return full_name.split('\\')[-1]

这完成了我们在 userassist_parser.py 脚本中的逻辑。现在,让我们将注意力转向负责以有用格式写入解析数据的两个写入函数。

写入 Excel 电子表格 – xlsx_writer.py

xlsx_writer.py脚本包含了创建包含我们处理过的UserAssist值的 Excel 文档的逻辑。除此之外,这个脚本还创建了一个包含我们数据摘要图表的额外工作表。xlsxwriter在第 1 行导入,是我们用来创建 Excel 文档的第三方模块。第 3 行导入的itemgetter函数将在本节后面的排序函数中使用并解释。我们已经在前面的章节中见过datetimelogging模块:

001 from __future__ import print_function
002 import xlsxwriter
003 from operator import itemgetter
004 from datetime import datetime, timedelta
005 import logging

xlsx_writer.py脚本中有六个函数。协调逻辑由第 36 行定义的excel_writer()函数处理。该函数创建我们的 Excel 工作簿对象,然后将其交给dashboard_writer()userassist_writer()函数,分别用于创建仪表板和UserAssist工作表。

剩下的三个函数,file_time()sort_by_count()sort_by_date(),是仪表板和UserAssist写入器使用的辅助函数。file_time()函数负责将我们从原始UserAssist数据中解析出的 FILETIME 对象转换为datetime对象。排序函数用于根据计数或日期对数据进行排序。我们使用这些排序函数来回答一些关于数据的基本问题。最常用的应用程序是什么?最不常用的应用程序是什么?在计算机上最后使用的 10 个应用程序是什么(根据UserAssist数据)?

036 excel_writer(): 
... 
071 dashboard_writer(): 
... 
156 userassist_writer(): 
... 
201 file_time(): 
... 
214 sort_by_count(): 
... 
227 sort_by_date():

使用excel_writer()函数控制输出

excel_writer()函数是这个脚本的核心。在第 47 行的 headers 列表中,包含了我们所需的列名。这些列名也方便地与我们将要写入的UserAssist字典中的键相对应。在第 49 行,我们创建了将要写入的Workbook对象。在下一行,我们创建了title_format,它控制了电子表头的颜色、字体、大小和其他样式选项。我们有以下代码:

036 def excel_writer(data, out_file):
037     """
038     The excel_writer function handles the main logic of writing
039     the excel output
040     :param data: the list of lists containing parsed UA data
041     :param out_file: the desired output directory and filename
042     for the excel file
043     :return: Nothing
044     """
045     print('[+] Writing XLSX output.')
046     logging.info('Writing XLSX to ' + out_file + '.')
047     headers = ['Name', 'Path', 'Session ID', 'Count',
048     'Last Used Date (UTC)', 'Focus Time (ms)', 'Focus Count']
049     wb = xlsxwriter.Workbook(out_file)
050     title_format = wb.add_format({'bold': True,
051     'font_color': 'white', 'bg_color': 'black', 'font_size': 30,
052     'font_name': 'Calibri', 'align': 'center'})

title_format与我们之前讨论的xlsxwriter模块时创建的date_format相似。这个格式是一个包含关键词和值的字典。具体来说,我们将在创建标题和副标题行时使用这个格式,以使其从电子表格中的其他数据中突出显示。

在第 54 到 59 行,我们将字典转换回列表。看起来这可能会让你觉得我们在存储数据时选择了错误的数据类型,也许你有一定的理由。然而,将数据存储在列表中会极大简化生成 XSLX 输出的过程。一旦我们看到 CSV 写入器如何处理数据,为什么最初使用字典会变得更加清晰。此外,使用字典可以让我们轻松理解存储的数据,而无需查看代码或文档:

054     # A temporary list that will store dictionary values
055     tmp_list = []
056     for dictionary in data:
057         # Adds dictionary values to a list ordered by the headers
058         # Adds an empty string is the key does not exist
059         tmp_list.append([dictionary.get(x, '') for x in headers])

我们使用列表推导将字典中的数据按正确的顺序追加到数据中。让我们逐步分解。在第 59 行,我们迭代每个UserAssist字典。正如我们所知,字典不按索引存储数据,而是按键映射存储数据。但是,我们希望按照我们的标题列表指定的顺序写入数据。标题循环中的x允许我们迭代该列表。对于每个x,我们使用get()方法返回字典中x对应的值,如果在字典中找到的话,否则返回空字符串。

在第 61 和 62 行,我们为仪表板和UserAssist数据调用两个工作表编写器。在这些函数的最后退出后,我们关闭工作簿对象的close()方法。关闭工作簿非常重要。如果不这样做,将会抛出异常,可能会阻止我们将 Excel 文档从内存传输到磁盘。请看下面的代码:

061     dashboard_writer(wb, tmp_list, title_format)
062     userassist_writer(wb, tmp_list, headers, title_format)
063 
064     wb.close()
065     msg =('Completed writing XLSX file. '
066           'Program exiting successfully.')
067     print('[*]', msg)
068     logging.info(msg)

使用dashboard_writer()函数总结数据

dashboard_writer()函数的目的是向分析师或审核人员提供一些汇总我们UserAssist数据的图形。我们选择向用户展示前 10、后 10 和最近的 10 个可执行文件。这个函数是我们最长的函数,需要最多的逻辑。

在第 81 行,我们将我们的仪表板工作表对象添加到工作簿中。接下来,我们将从AQ列合并第一行,并使用我们在excelWriter()函数中创建的标题格式写入我们的公司名称XYZ Corp。类似地,在第 83 行,我们创建一个副标题行,以标识这个工作表作为我们的仪表板。

071 def dashboard_writer(workbook, data, ua_format):
072     """
073     the dashboard_writer function creates the 'Dashboard'
074     worksheet, table, and graphs
075     :param workbook: the excel workbook object
076     :param data: the list of lists containing parsed UA data
077     :param ua_format: the format object for the title and
078     subtitle row
079     :return: Nothing
080     """
081     dashboard = workbook.add_worksheet('Dashboard')
082     dashboard.merge_range('A1:Q1', 'XYZ Corp', ua_format)
083     dashboard.merge_range('A2:Q2', 'Dashboard', ua_format)

在第 87 行,我们创建并向工作簿添加date_format以正确格式化我们的日期。在第 92 和 93 行,我们调用两个排序函数。我们使用列表切片来切割排序后的数据,创建我们的子列表:toptenleasttenlastten。对于按计数使用的topten可执行文件,我们从排序列表中获取最后 10 个元素。对于leastten,我们只需执行相反操作。对于lastten,我们获取排序日期列表中的前 10 个结果,如下所示:

085     # The format to use to convert datetime object into a human
086     # readable value
087     date_format = workbook.add_format({
088     'num_format': 'mm/dd/yy h:mm:ss'})
089 
090     # Sort our original input by count and date to assist with
091     # creating charts.
092     sorted_count = sort_by_count(data)
093     sorted_date = sort_by_date(data)
094 
095     # Use list slicing to obtain the most and least frequently
096     # used UA apps and the most recently used UA apps
097     topten = sorted_count[-10:]
098     leastten = sorted_count[:10]
099     lastten = sorted_date[:10]

在第 103 行,我们迭代处理lastten列表中的元素。我们必须将每个时间戳转换为datetime对象。datetime对象存储在我们创建的UserAssist列表的第一个索引中,并通过file_time()函数进行转换:

101     # For the most recently used UA apps, convert the FILETIME
102     # value to datetime format
103     for element in lastten:
104         element[1] = file_time(element[1])

在第 108 行到 116 行之间,我们为顶部、底部和最新的数据点创建了三个表格。注意这些表格从第 100 行开始。我们选择将它们放置得离电子表格顶部较远,这样用户会先看到我们将要添加的表格,而不是原始数据。正如我们在xlsxwriter部分描述表格时所看到的,add_table()函数的第二个参数是一个字典,包含标题名称和格式的关键字。还可以提供其他关键字来实现更多功能。例如,我们使用format关键字确保我们的datetime对象按照我们指定的date_format变量显示。我们有以下代码:

106     # Create a table for each of the three categories, specifying
107     # the data, column headers, and formats for specific columns
108     dashboard.add_table('A100:B110',
109     {'data': topten, 'columns': [{'header': 'App'},
110     {'header': 'Count'}]})
111     dashboard.add_table('D100:E110',
112     {'data': leastten, 'columns': [{'header': 'App'},
113     {'header': 'Count'}]})
114     dashboard.add_table('G100:H110',
115     {'data': lastten, 'columns': [{'header': 'App'},
116     {'header': 'Date (UTC)', 'format': date_format}]})

在第 118 行到 153 行之间,我们为三个表格创建了图表。在将top_chart实例化为饼图后,我们设置了标题以及X轴和Y轴的刻度。在测试过程中,我们意识到图表的尺寸太小,无法充分展示所有信息,因此我们使用了更大的比例:

118     # Create the most used UA apps chart
119     top_chart = workbook.add_chart({'type': 'pie'})
120     top_chart.set_title({'name': 'Top Ten Apps'})
121     # Set the relative size to fit the labels and pie chart within
122     # chart area
123     top_chart.set_size({'x_scale': 1, 'y_scale': 2})

在第 127 行,我们为饼图添加了系列;识别类别和值是非常直接的。我们需要做的只是定义我们想要绘制的行和列。data_labels键是一个附加选项,可以用来指定绘制数据的值格式。在此情况下,我们选择了'percentage'选项,如第 130 行所示,代码如下:

125     # Add the data as a series by specifying the categories and
126     # values
127     top_chart.add_series(
128     {'categories': '=Dashboard!$A$101:$A$110',
129     'values': '=Dashboard!$B$101:$B$110',
130     'data_labels': {'percentage': True}})
131     # Add the chart to the 'Dashboard' worksheet
132     dashboard.insert_chart('A4', top_chart)

使用此设置,我们的饼图将根据使用次数进行分割,图例将包含可执行文件的名称,百分比将显示与其他九个可执行文件相比的相对执行情况。创建图表后,我们调用insert_chart()将其添加到仪表盘工作表中。least_chart的创建方式与此相同,如下所示:

134     # Create the least used UA apps chart
135     least_chart = workbook.add_chart({'type': 'pie'})
136     least_chart.set_title({'name': 'Least Used Apps'})
137     least_chart.set_size({'x_scale': 1, 'y_scale': 2})
138 
139     least_chart.add_series(
140     {'categories': '=Dashboard!$D$101:$D$110',
141     'values': '=Dashboard!$E$101:$E$110',
142     'data_labels': {'percentage': True}})
143     dashboard.insert_chart('J4', least_chart)

最后,我们创建并将last_chart添加到电子表格中。为了节省纸张,这一过程与我们之前讨论的方式相同。不过,这次我们的图表是柱状图,并且我们调整了比例,以便适合该类型的图表:

145     # Create the most recently used UA apps chart
146     last_chart = workbook.add_chart({'type': 'column'})
147     last_chart.set_title({'name': 'Last Used Apps'})
148     last_chart.set_size({'x_scale': 1.5, 'y_scale': 1})
149 
150     last_chart.add_series(
151     {'categories': '=Dashboard!$G$101:$G$110',
152     'values': '=Dashboard!$H$101:$H$110'})
153     dashboard.insert_chart('D35', last_chart)

在 userassist_writer()函数中编写的内容

userassist_writer()函数与之前的仪表盘函数类似。不同之处在于,这个函数创建了一个包含原始数据的单一表格,没有任何附加的装饰。在第 167 行到 169 行之间,我们创建了UserAssist工作表对象,并将标题和副标题添加到电子表格中。在第 173 行,我们再次创建了一个date_format,以便正确显示日期,如下所示:

156 def userassist_writer(workbook, data, headers, ua_format):
157     """
158     The userassist_writer function creates the 'UserAssist'
159     worksheet and table
160     :param workbook: the excel workbook object
161     :param data: the list of lists containing parsed UA data
162     :param headers: a list of column names for the spreadsheet
163     :param ua_format: the format object for the title and subtitle
164     row
165     :return: Nothing
166     """
167     userassist = workbook.add_worksheet('UserAssist')
168     userassist.merge_range('A1:H1', 'XYZ Corp', ua_format)
169     userassist.merge_range('A2:H2', 'Case ####', ua_format)
170 
171     # The format to use to convert datetime object into a
172     # human readable value
173     date_format = workbook.add_format(
174     {'num_format': 'mm/dd/yy h:mm:ss'})

在第 178 行,我们遍历外部列表,并使用我们预先构建的函数将 FILETIME 对象转换为datetime对象。我们还在列表的开头添加了一个整数,以便检查人员通过查看索引可以快速确定有多少个UserAssist记录:

176     # Convert the FILETIME object to datetime and insert the 'ID'
177     # value as the first element in the list
178     for i, element in enumerate(data):
179         element[4] = file_time(element[4])
180         element.insert(0, i + 1)

在第 188 行,我们开始创建 UserAssist 表。我们使用第 184 行创建的长度变量来确定距离表格右下角的适当位置。注意,长度是列表的长度加三。我们将三加到该长度上,是因为我们需要考虑标题和副标题行,占用了前两列,以及 Python 和 Excel 计数方式的差异。我们有如下代码:

182     # Calculate how big the table should be. Add 3 to account for
183     # the title and header rows.
184     length = len(data) + 3
185 
186     # Create the table; depending on the type (WinXP v. Win7) add
187     # additional headers
188     userassist.add_table(('A3:H' + str(length)),
189                          {'data': data,
190                          'columns': [{'header': 'ID'},
191                          {'header': 'Name'},
192                          {'header': 'Path'},
193                          {'header': 'Session ID'},
194                          {'header': 'Count'},
195                          {'header': 'Last Run Time (UTC)',
196                          'format': date_format},
197                          {'header': 'Focus Time (MS)'},
198                          {'header': 'Focus Count'}]})

定义 file_time() 函数

这是一个非常小的帮助函数。我们使用 struct 库解析的 FILETIME 对象是一个 8 字节的整数,表示自 1601 年 1 月 1 日以来 100 纳秒单位的计数。这个日期是大多数微软操作系统和应用程序作为公共时间参考点所使用的。

因此,为了获得它所表示的日期,我们需要将 FILETIME 值加到代表 1601 年 1 月 1 日的 datetime 对象上,并使用 timedelta() 函数。timedelta 函数计算一个整数所代表的天数和小时数。然后,我们可以将 timedelta() 函数的输出直接加到 datetime 对象中,得到正确的日期。为了得到正确的量级,我们需要将 FILETIME 值除以 10,如下所示:

201 def file_time(ft):
202     """
203     The file_time function converts the FILETIME objects into
204     datetime objects
205     :param ft: the FILETIME object
206     :return: the datetime object
207     """
208     if ft is not None and ft != 0:
209         return datetime(1601, 1, 1) + timedelta(microseconds=ft / 10)
210     else:
211         return 0

使用 sort_by_count() 函数处理整数

sort_by_count() 函数根据执行计数值对内部列表进行排序。这是一个相对复杂的单行代码,让我们一步一步地拆解。首先,先关注 sorted(data, key=itemgetter(3)) 这一部分。Python 提供了内置的 sorted() 方法,用于根据键(通常是整数)对数据进行排序。我们可以为 sorted() 函数提供一个键,告诉它按照什么排序,并返回一个新的排序后的列表。

和任何新的代码一样,让我们在交互式提示符下看一个简单的例子:

>>> from operator import itemgetter 
>>> test = [['a', 2], ['b', 5], ['c', -2], ['d', 213], ['e', 40], ['f', 1]] 
>>> print(sorted(test, key=itemgetter(1)))
[['c', -2], ['f', 1], ['a', 2], ['b', 5], ['e', 40], ['d', 213]] 
>>> print(sorted(test, key=itemgetter(1), reverse=True)) 
[['d', 213], ['e', 40], ['b', 5], ['a', 2], ['f', 1], ['c', -2]] 
>>> print(sorted(test, key=itemgetter(0))) 
[['a', 2], ['b', 5], ['c', -2], ['d', 213], ['e', 40], ['f', 1]] 

在前面的例子中,我们创建了一个外部列表,包含了包含两个元素的内部列表:一个字符和一个数字。接下来,我们对这个列表进行排序,并使用内部列表中第一个索引的数字作为排序的键。默认情况下,sorted() 会按升序排序。要按降序排序,你需要提供 reverse=True 参数。如果我们想按字母排序,则可以向 itemgetter() 提供值 0,以指定按该位置的元素排序。

现在,剩下的就是理解 x[0:5:3] 的含义了。我们为什么要这么做?我们使用列表切片仅抓取第一个和第三个元素,即可执行文件的名称和计数,供我们用于表格。

记住,切片表示法支持三个可选的组件:List[x:y:z],其中,x = 起始索引,y = 结束索引,z = 步长。

在这个例子中,我们从索引 0 开始,到索引 5 结束,步长为 3。如果这样做,我们将只获取列表中第零和第三个位置的元素,直到到达末尾。

现在,x[0:5:3]语句会对sorted(data, key=itemgetter(3))中的x进行遍历,并仅保留每个列表中的零和第三位置元素。我们然后将整个语句用一对方括号括起来,以便保留xlsxwriter所偏好的外部和内部列表结构。

列表对象也有一个sort()方法,它在语法上类似于sorted()函数。然而,sort()函数更节省内存,因为它不会创建新列表,而是就地排序当前列表。由于数据集的内存消耗不是大问题,最多可能包含几百条记录,并且由于我们不想修改原始列表,所以选择使用sorted()函数。我们有以下代码:

214 def sort_by_count(data):
215     """
216     The sort_by_count function sorts the lists by their count
217     element
218     :param data: the list of lists containing parsed UA data
219     :return: the sorted count list of lists
220     """
221     # Return only the zero and third indexed item (the name and
222     # count values) in the list after it has been sorted by the
223     # count
224     return [x[0:5:3] for x in sorted(data, key=itemgetter(3))]

使用 sort_by_date()函数处理 datetime 对象

sort_by_date()函数与sort_by_count()函数非常相似,唯一不同的是它使用了不同的索引。由于datetime对象实际上只是一个数字,我们也可以轻松地按此排序。提供reverse=True允许我们按降序排序。

再次,我们首先通过位置 4 的 datetime 来创建一个新的排序列表。然后我们只保留每个列表中的零和第四位置元素,并将所有内容包装在另一个列表中,以保留我们的嵌套列表结构:

227 def sort_by_date(data):
228     """
229     The sort_by_date function sorts the lists by their datetime
230     object
231     :param data: the list of lists containing parsed UA data
232     :return: the sorted date list of lists
233     """
234     # Supply the reverse option to sort by descending order
235     return [x[0:6:4] for x in sorted(data, key=itemgetter(4),
236     reverse=True)]

编写通用电子表格 – csv_writer.py

csv_writer.py脚本相比我们之前编写的两个脚本要简单得多。这个脚本负责输出UserAssist数据的 CSV 格式。csv_writer.py脚本有两个函数:csv_writer()和辅助函数file_time()。我们在xlsx_writer部分已解释过file_time()函数,由于它的实现相同,这里不再重复。

理解 csv_writer()函数

csv_writer()函数定义在第 38 行,与我们在前几章中创建 CSV 输出的方式略有不同。我们通常会首先创建标题列表,创建写入对象,并写入标题列表以及数据变量中的每个子列表。这次,我们不再使用writer(),而是使用DictWriter()方法来帮助我们写入UserAssist字典:

001 from __future__ import print_function
002 import sys
003 if sys.version_info[0] == 2:
004     import unicodecsv as csv
005 elif sys.version_info[0] == 3:
006     import csv
007 from datetime import datetime, timedelta
008 import logging
...
038 def csv_writer(data, out_file):
039     """
040     The csv_writer function writes the parsed UA data to a csv
041     file
042     :param data: the list of lists containing parsed UA data
043     :param out_file: the desired output directory and filename
044     for the csv file
045     :return: Nothing
046     """
047     print('[+] Writing CSV output.')
048     logging.info('Writing CSV to ' + out_file + '.')

在第 49 行,我们仍然像往常一样创建我们的标题列表。然而,这个列表起到了更重要的作用。这个列表包含了每个键的名称,这些键将出现在我们的UserAssist字典中,并且是我们希望显示的顺序。DictWriter()方法将允许我们根据这个列表来排序字典,以确保数据按适当的顺序展示。根据代码是由 Python 2 还是 3 运行,我们会使用合适的方法来打开csvfile。请看下面的代码:

049     headers = ['ID', 'Name', 'Path', 'Session ID', 'Count',
050     'Last Used Date (UTC)', 'Focus Time (ms)', 'Focus Count']
051 
052     if sys.version_info[0] == 2:
053         csvfile = open(out_file, "wb")
054     elif sys.version_info[0] == 3:
055         csvfile = open(out_file, "w", newline='',
056         encoding='utf-8')

我们首先创建csvfile对象和写入器。DictWriter()方法需要一个文件对象作为必需的参数,还可以使用可选的关键字参数。fieldnames参数将确保字典的键按照适当的顺序写入。extrasaction关键字设置为忽略字典中包含但不在fieldnames列表中的键。如果没有设置这个选项,如果字典中有额外的未记录键,我们将会遇到异常。在我们的场景中,由于我们已经硬编码了这些键,应该不会遇到这个问题。然而,如果因为某种原因出现了额外的键,我们宁愿让DictWriter()忽略它们,而不是崩溃,代码如下:

058     with csvfile:
059         writer = csv.DictWriter(csvfile, fieldnames=headers,
060         extrasaction='ignore')

使用DictWriter()对象,我们可以调用writeheader()方法自动写入提供的字段名:

061         # Writes the header from list supplied to fieldnames
062         # keyword argument
063         writer.writeheader()

请注意,我们在写入每个字典之前会进行一些额外的处理。首先,我们将 ID 键添加到当前的循环计数中。接下来,在第 71 行,我们调用fileTime()方法将 FILETIME 对象转换为datetime格式。最后,在第 73 行,我们将字典写入 CSV 输出文件:

065         for i, dictionary in enumerate(data):
066             # Insert the 'ID' value to each dictionary in the
067             # list. Add 1 to start ID at 1 instead of 0.
068             dictionary['ID'] = i + 1
069             # Convert the FILETIME object in the fourth index to
070             # human readable value
071             dictionary['Last Used Date (UTC)'] = file_time(
072             dictionary['Last Used Date (UTC)'])
073             writer.writerow(dictionary)

所有字典写入完成后,我们对csvfile对象调用flush()并执行close()操作。至此,我们记录下 CSV 脚本成功完成的日志。此时,剩下的就是实际运行我们新的框架:

075         csvfile.flush()
076         csvfile.close()
077         msg = 'Completed writing CSV file. Program exiting successfully.'
078         print('[*]', msg)
079         logging.info(msg)

运行 UserAssist 框架

我们的脚本能够解析基于 Windows XP 和 Windows 7 的UserAssist注册表项。然而,我们将重点关注 CSV 和 XLSX 输出选项之间的差异。使用xlsxwriter模块并查看输出结果应该能清楚地展示直接写入 Excel 文件相对于 CSV 文件的优势。虽然你失去了 CSV 文档的可移植性,但你获得了更多的功能。以下是运行userassist.py脚本对 Vista 系统的NTUSER.DAT文件进行处理并创建 XLSX 输出的截图:

CSV 输出无法保存 Python 对象或创建可供报告使用的电子表格。CSV 报告的优点,除了其可移植性之外,就是模块的编写非常简单。与超过 100 行代码的 Excel 文档相比,我们能够用仅仅几行代码写出主要逻辑,显然后者开发起来花费了更多时间。

能够编写自定义的 Excel 报告是很棒的,但它需要时间成本。对于法医开发人员来说,这可能并不总是一个可行的附加功能,因为时间限制往往在开发周期中起着重要作用,并决定了你能做什么和不能做什么。然而,如果时间允许,这可以节省检查员手动执行此过程的麻烦,并为分析留出更多时间。

挑战

我们已经详细讨论了 Windows 7 对 UserAssist 工件所做的新增内容。然而,仍有一些我们在当前 UserAssist 框架实现中未考虑到的变化。随着 Windows 7 的发布,一些常见文件夹名称被 GUID 替换。以下是一些文件夹及其对应 GUID 的示例表:

文件夹 GUID
UserProfiles {0762D272-C50A-4BB0-A382-697DCD729B80}
Desktop {B4BFCC3A-DB2C-424C-B029-7FE99A87C641}
Documents {FDD39AD0-238F-46AF-ADB4-6C85480369C7}
Downloads {374DE290-123F-4565-9164-39C4925E467B}

对我们的脚本进行改进,可能涉及到查找这些和其他常见文件夹 GUID,并将其替换为真实路径。微软 MSDN 网站上提供了一些常见 GUID 的列表,网址为 msdn.microsoft.com/en-us/library/bb882665.aspx

或者,我们选择用来绘制最后 10 个可执行文件的图表,可能不是展示日期的最佳方式。创建一个更侧重时间线的图表可能更能有效地展示这些数据。尝试使用一些其他内置图表及其功能,以更好地熟悉 xlsxwriter 的图表功能。

总结

本章是一个模块为中心的章节,我们向工具包中添加了三个新模块。此外,我们还了解了 UserAssist 工件以及如何解析它。虽然这些概念很重要,但我们对 timeit 的短暂探讨可能在今后的工作中更具价值。

作为开发者,我们有时会遇到脚本执行效率不足,或者在处理大数据集时花费大量时间的情况。在这些情况下,像 timeit 这样的模块可以帮助审计和评估代码,以找出更高效的解决方案。该项目的代码可以从 GitHub 或 Packt 下载,具体说明见 前言

在下一章,我们将介绍如何在 Python 中进行文件哈希。具体来说,我们将重点讲解如何对数据块进行哈希,以识别相同或相似的文件。这被称为“模糊哈希”。当评估共享相似根源的对象时,这种技术非常有用,例如恶意软件。我们可以获取我们怀疑已在系统中使用的已知恶意软件样本,进行模糊哈希,并在系统中搜索匹配项。我们不再寻找完全匹配,而是在一个不显眼的文件上找到了 90%的匹配,进一步检查后,发现这其实是恶意软件的新变种,否则可能会被忽略。我们将介绍实现这一功能的多种方法和背后的逻辑。

第七章:模糊哈希

哈希是 DFIR 中最常见的处理过程之一。这个过程允许我们总结文件内容,并分配一个代表文件内容的独特且可重复的签名。我们通常使用 MD5、SHA1 和 SHA256 等算法对文件和内容进行哈希。这些哈希算法非常有价值,因为我们可以用它们进行完整性验证——即使文件内容只改动了一个字节,生成的哈希值也会完全改变。这些哈希也常用于形成白名单,排除已知或不相关的内容,或者用于警报列表,快速识别已知的感兴趣文件。然而,在某些情况下,我们需要识别近似匹配的文件——而这正是 MD5、SHA1 和 SHA256 无法独立处理的任务。

协助相似性分析的最常见工具之一是 ssdeep,由 Jessie Kornblum 开发。这个工具实现了 spamsum 算法,该算法由 Andrew Tridgell 博士开发,用于生成一个 base64 编码的签名,表示文件内容。无论文件内容如何,这些签名都可以用来帮助确定两个文件的相似度。这使得这两个文件的比较可以在计算上更轻便,并且生成相对较短的签名,便于共享或存储。

本章中,我们将做以下内容:

  • 使用 Python 对数据进行 MD5、SHA1 和 SHA256 算法哈希

  • 讨论如何对数据流、文件和文件目录进行哈希

  • 探索 spamsum 算法的工作原理,并在 Python 中实现一个版本

  • 通过 Python 绑定利用已编译的 ssdeep 库,提高性能和功能

本章的代码是在 Python 2.7.15 和 Python 3.7.1 环境下开发和测试的。

哈希背景

哈希数据是法医社区中常用的技术之一,用于为文件生成指纹。通常,我们会创建整个文件的哈希;然而,在本章稍后的脚本中,我们将对文件的各个片段进行哈希,以评估两个文件之间的相似性。在深入研究模糊哈希的复杂性之前,让我们先了解如何使用 Python 生成加密哈希值,如 MD5 和 SHA1 值。

在 Python 中哈希文件

如前所述,DFIR(数字取证与事件响应)社区和工具常用多种算法。在生成文件哈希之前,我们必须决定使用哪种算法。这是一个困难的问题,因为有多个因素需要考虑。消息摘要算法 5MD5)生成一个 128 位的哈希值,是法医工具中最常用的加密哈希算法之一。该算法相对轻量,生成的哈希值长度较短,相比其他算法,具有更小的系统资源占用。由于加密哈希有固定的输出长度,选择一个较短长度的算法可以帮助减少对系统资源的影响。

然而,MD5 的主要问题是哈希碰撞的概率。哈希碰撞是指两个不同的输入值产生相同的哈希值,这是因为哈希值的长度是固定的。对于法医领域来说,这是一个问题,因为我们依赖哈希算法作为表示数据完整性的唯一指纹。如果算法存在已知的碰撞问题,那么哈希值可能不再是唯一的,也不能保证数据的完整性。因此,在大多数法医情况下,不推荐将 MD5 作为主要的哈希算法。

除了 MD5 外,还有一些其他常见的加密哈希算法,包括安全哈希算法SHA)系列。SHA 系列包括 SHA-1(160 位)、SHA-256(256 位)和 SHA-512(512 位),这些都是在法医领域常用的算法之一。SHA-1 算法通常与 MD5 哈希一起出现在大多数法医工具中。最近,一个研究小组发现了 SHA-1 算法的碰撞问题,并在他们的网站上分享了他们的发现,shattered.io/。像 MD5 一样,SHA-1 在这个领域的流行度也在下降。

在 Python 中,利用这些哈希算法非常简单。在以下代码块中,我们将在解释器中演示使用 MD5、SHA-1 和 SHA-256 算法进行哈希处理的示例。

为了实现这一点,我们需要导入标准库hashlib,并提供数据来生成哈希值。在导入hashlib后,我们使用md5()方法创建一个哈希对象。定义为m后,我们可以使用.update()函数向算法中添加数据,使用hexdigest()方法生成我们常见的十六进制哈希值。这个过程可以通过以下一行代码完成:

>>> import hashlib
>>> m = hashlib.md5()
>>> m.update('This will be hashed!')
>>> m.hexdigest()
'0fc0cfd05cc543be3a2f7e7ed2fe51ea'
>>> hashlib.md5('This will be hashed!').hexdigest()
'0fc0cfd05cc543be3a2f7e7ed2fe51ea'
>>> hashlib.sha1('This will be hashed!').hexdigest()
'5166bd094f3f27762b81a7562d299d887dbd76e3'
>>> hashlib.sha256('This will be hashed!').hexdigest()
'03bb6968581a6d6beb9d1d863b418bfdb9374a6ee23d077ef37df006142fd595' 

在前面的示例中,我们对一个字符串对象进行了哈希处理。但文件呢?毕竟,这才是我们真正感兴趣的操作。

要对文件进行哈希处理,我们需要将文件的内容传递给哈希对象。如代码块所示,我们首先打开并写入一个文件,生成一些样本数据供我们进行哈希处理。在设置完成后,我们关闭并重新打开文件以供读取,使用read()方法将文件的完整内容读取到buffer变量中。此时,我们将buffer的值提供为哈希数据,生成我们独特的哈希值。请参见以下代码:

>>> output_file = open('output_file.txt', 'w')
>>> output_file.write('TmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXA=')
>>> output_file.close()
>>> input_file = open('output_file.txt', 'r')
>>> buffer = input_file.read()
>>> hashlib.sha1(buffer).hexdigest()
'aa30b352231e2384888e9c78df1af47a9073c8dc'
>>> hashlib.md5(buffer).hexdigest()
'1b49a6fb562870e916ae0c040ea52811'
>>> hashlib.sha256(buffer).hexdigest()
'89446e08f985a9c201fa969163429de3dbc206bd7c7bb93e490631c308c653d7' 

这里展示的哈希方法适用于小文件或数据流。如果我们希望更灵活地处理文件,则需要调整方法。

哈希大文件 — hashing_example.py

本章中的第一个脚本简短明了;它将允许我们使用指定的加密算法对提供的文件内容进行哈希处理。这段代码可能更适合作为一个大型脚本中的功能,例如我们的文件列出工具;我们将演示一个独立示例,以便了解如何以节省内存的方式处理文件的哈希处理。

首先,我们只需要导入两个库,argparsehashlib。通过这两个内置库,我们能够生成哈希,如前面的例子所示。在第 33 行,我们列出了支持的哈希算法。这个列表应该只包含作为模块存在于hashlib中的算法,因为我们将从列表中调用(例如)md5作为hashlib.md5()。第二个常量定义在第 34 行,是BUFFER_SIZE,用于控制每次读取多少文件内容。这个值应该较小,在本例中为 1MB,以节省每次读取所需的内存,尽管我们也希望它足够大,以减少对文件的读取次数。你可能会发现这个数字会根据你选择的运行系统进行调整。为此,你可以考虑将其指定为参数,而不是常量:

001 """Sample script to hash large files effiently."""
002 import argparse
003 import hashlib
...
033 HASH_LIBS = ['md5', 'sha1', 'sha256', 'sha512']
034 BUFFER_SIZE = 1024**3

接下来,我们定义我们的参数。这里非常简洁,因为我们只接受一个文件名和一个可选的算法规格:

036 parser = argparse.ArgumentParser()
037 parser.add_argument("FILE", help="File to hash")
038 parser.add_argument("-a", "--algorithm",
039     help="Hash algorithm to use", choices=HASH_LIBS,
040     default="sha512")
041 args = parser.parse_args()

一旦我们知道了指定的参数,我们将把选定的算法从参数转换为一个可以调用的函数。为此,我们使用第 43 行中显示的getattr()方法。这个内置函数允许我们从对象中检索函数和属性(例如,来自库的方法,如下面的代码所示)。我们在行尾加上(),因为我们希望调用指定算法的初始化方法,并创建一个可以用于生成哈希的alg对象实例。这一行代码等价于调用alg = hashlib.md5()(例如),但是以适应参数的方式执行:

043 alg = getattr(hashlib, args.algorithm)()

在第 45 行,我们打开文件进行读取,从第 47 行开始将第一个缓冲区长度读取到buffer_data变量中。然后我们进入一个while循环,在第 49 行更新我们的哈希算法对象,然后在第 50 行获取下一个数据缓冲区。幸运的是,Python 会从input_file读取所有数据,即使BUFFER_SIZE大于文件中剩余的内容。此外,Python 在读取到文件末尾时会退出循环,并在退出with上下文时为我们关闭文件。最后,在第 52 行,我们打印我们计算的哈希值的.hexdigest()

045 with open(args.FILE, 'rb') as input_file:
046 
047     buffer_data = input_file.read(BUFFER_SIZE)
048     while buffer_data:
049         alg.update(buffer_data)
050         buffer_data = input_file.read(BUFFER_SIZE)
051 
052 print(alg.hexdigest())

创建模糊哈希

现在我们已经掌握了如何生成加密哈希,让我们开始生成模糊哈希。我们将讨论一些可以用于相似性分析的技术,并通过一个简单的例子展示 ssdeep 和 spamsum 如何使用滚动哈希来帮助生成更强健的签名。

不言而喻,我们进行相似性分析的最准确方法是将两个文件的字节内容并排比较,查看差异。虽然我们可以使用命令行工具或差异分析工具(如 kdiff3)来完成这一工作,但这仅适用于小规模的比较。当我们从比较两个小文件转向比较多个小文件,或者几个中等大小的文件时,我们需要一种更高效的方法。此时,签名生成就派上了用场。

要生成签名,我们必须弄清楚以下几点:

  • 我们希望为签名使用哪个字母表

  • 我们希望如何将文件分割成可总结的块

  • 将我们的块摘要转换为字母表中字符的技术

虽然字母表是一个可选组件,但它使我们人类能够更好地回顾和理解数据。我们始终可以将其存储为整数,并节省一些计算资源。Base64 是字母表的常见选择,并被 spamsum 和 ssdeep 使用。

对于上述的第二和第三项,让我们讨论一些技术,如何将我们的文件切割并生成哈希值。在这个示例中(为了保持简单),我们将以下字符序列作为文件内容:

abcdefghijklmnopqrstuvwxyz01

我们的第一种方法是将文件切割成相等大小的块。以下示例中的第一行是我们的文件内容,第二行是每个字符的数字 ASCII 值。为了这个示例,我们决定将文件切割为 4 字节块,并使用竖线和颜色编码的数字 ASCII 值:

然后,我们通过将四个字符的 ASCII 值相加来总结这些 4 字节的块,如表格的第三行所示。接着,我们通过将 394 对 64 取模(394 % 64),得到 10,或者说是 Base64 字母表中的 K。这个 Base64 值,正如你可能猜到的,在第四行显示。

字母 K 成为我们第一个块的总结,字母 a 代表第二个,依此类推,直到我们得到完整的文件签名 Kaq6KaU。

在下图中,有一个略微修改过的版本的原始文件。如图所示,有人将 jklmn 替换为 hello。现在我们可以对这个文件运行我们的哈希算法,看看两个版本之间发生了多少变化:

使用相同的技术,我们计算 Kai6KaU 的新哈希值。如果我们想要比较两个文件的相似性,我们应该能够利用我们的签名来促进比较,对吗?所以在这个例子中,我们的签名之间有一个字母的差异,这意味着我们的两个文件流大致相似!

正如你可能已经注意到的,这里存在一个问题:我们在使用算法时发现了哈希冲突。在之前的示例中,每个文件的第四个块不同;第一个是 mnop,第二个是 loop。由于我们正在汇总文件内容来确定签名值,我们注定会得到不健康的哈希冲突。这些冲突可能会让我们误以为文件是相似的,实际上并非如此,而这种情况不幸的是由于在没有使用加密哈希算法的情况下总结文件内容所导致的。因此,我们必须在总结文件内容和遇到哈希冲突之间找到更好的平衡。

我们的下一个示例演示了插入发生时会发生什么。正如您在下面的图表中看到的,字母 h 被插入到 mn 后面,文件增加了一个字节,并且整个内容向右移动了一个位置。在这个例子中,我们的最后一个块只包含数字 1,尽管一些实现可能会有不同的处理方式:

使用相同的公式,我们计算了 KaqyGUbx 的哈希。这个哈希与 Kaq6KaU 完全不同。事实上,一旦我们到达包含变化的块,哈希值完全不同,即使文件后半部分的内容是相似的。

这也是为什么使用固定块大小不是进行相似性分析的最佳方法的主要原因之一。任何内容的移动都会使数据跨越边界,导致我们为相似内容计算完全不同的哈希值。为了解决这个问题,我们需要以另一种方式设置这些边界。

上下文触发的分段哈希(CTPH)

正如你可能猜到的,这就是 CTPH 的作用所在。本质上,我们的目标是使用这种技术来计算重置点。在这种情况下,重置点是类似于我们在先前示例中使用的 4 字节边界,因为我们使用这些重置点来确定我们想要总结的文件部分。一个显著的例外是,我们根据文件内容(即我们的上下文触发)而不是固定窗口来选择边界。这意味着我们使用滚动哈希(由 ssdeep 和 spamsum 使用)来计算文件中的值;当找到这个特定值时,会画出边界线,并总结自上一个边界以来的内容(分段哈希)。在以下示例中,我们使用简化的计算来确定是否达到了重置点。

虽然 spamsum 和 ssdeep 都会为每个文件计算重置点数字,但在我们的示例中,我们将使用 7 来简化问题。这意味着每当我们的滚动哈希值为 7 时,我们将总结此边界和之前之间的内容。额外说明一下,这种技术适用于超过 28 字节的文件,因此我们的哈希值在这里会非常短,因此在我们的示例之外用途不大。

在进入示例之前,让我们先讨论一下什么是滚动哈希。我们将再次使用之前相同的示例文件内容。然后,我们使用所谓的滚动哈希来计算文件中每个字节的值。滚动哈希的工作原理是:在文件的某个窗口内计算所有字符的哈希值。在我们的例子中,窗口的大小为 3。我们文件中的窗口移动如下所示,经过前四次迭代:

  • ['a', '', ''] = [97, 0, 0]

  • ['a', 'b', ''] = [97, 98, 0]

  • ['a', 'b', 'c'] = [97, 98, 99]

  • ['b', 'c', 'd'] = [98, 99, 100]

如你所见,滚动窗口会继续遍历文件,每次迭代添加一个新的字节,并以 FIFO 的方式删除最旧的字节。为了生成该窗口的哈希值,我们需要对窗口中的值执行一系列进一步的计算。

对于这个例子,如你可能猜到的那样,我们将对 ASCII 值求和以保持简单。这个求和结果显示在下一个示例的第一行。为了使数字更小,我们将对求和后的 ASCII 值(S)进行模 8 运算(S % 8),并使用这个整数来寻找文件内容中的边界。这个数字可以在下图的第二行找到。如果S % 8 == 7,我们已经达到了重置点,可以创建之前块的汇总。

ssdeep 和 spamsum 算法在处理滚动窗口计算时有所不同,尽管计算的结果在使用方式上是相同的。我们简化了计算过程,以便更容易讨论这个过程。

由于我们的重置点是 7,如前所述,我们将在每次滚动哈希计算返回 7 时,定义文件的一个块。下图通过水平线显示了我们在文件中设置的块。

对于每个块,我们将以与之前相同的方式计算签名:将整个块内的 ASCII 整数值求和(如第四行所示),然后应用模 64 运算得到签名的字符(如最后一行所见)。请记住,在这个例子中,第 2 行和第 4 行之间的唯一关系是,第 2 行告诉我们何时设置重置点并计算第 4 行中显示的数字。这两个哈希在算法上是独立的。第 4 行仍然是对a + b + c + d + e + f的 ASCII 值求和,而不是滚动哈希输出的和:

这生成了签名 VUUD。尽管短得多,但我们现在得到了上下文触发的哈希。如前所述,我们通过使用滚动哈希来定义边界(即上下文触发),并通过对块的求和(逐块哈希)来识别文件中的共同块,从而将其与具有相似重置点大小的文件进行比较(或者其他重置点为 7 的文件)。

在我们的最终示例中,让我们回顾一下当我们插入字母 h 时发生了什么。使用我们的滚动哈希来计算基于上下文的块(如第一行所示),我们可以使用相同的算法计算块的摘要,并生成签名 VUH1D:

如你所见,这项技术对插入更具韧性,允许我们比使用固定块更准确地比较文件之间的差异。在这种情况下,我们的签名显示两个文件的差异比实际差异更大,尽管这种技术比我们的固定块计算更准确,因为它理解文件的尾部在我们两个版本之间是相同的。

显然,这项技术需要大于 28 字节的文件才能产生准确的结果,尽管希望这个简化过程能够帮助说明这些模糊哈希是如何生成的。理解这一点后,让我们开始编写我们的脚本。

实现 fuzzy_hasher.py

该脚本已在 Python 版本 2.7.15 和 3.7.1 上进行了测试,并且没有使用任何第三方库。

在我们深入了解模糊哈希算法之前,让我们像之前一样开始脚本。我们从导入开始,所有这些都是我们之前使用过的标准库,接下来会展示在代码中。我们还定义了一组常量,从第 36 行到第 47 行。第 37 行和第 38 行定义了我们的签名字母表,在这个例子中是所有 base64 字符。下一组常量用于 spamsum 算法生成哈希。CONTEXT_WINDOW 定义了我们将读取多少文件内容用于滚动哈希。FNV_PRIME 用于计算哈希,而 HASH_INIT 为我们的哈希设置初始值。接下来是 SIGNATURE_LEN,它定义了我们的模糊哈希签名应该有多长。最后,OUTPUT_OPTS 列表用于与我们的参数解析一起显示支持的输出格式——更多内容稍后介绍:

001 """Spamsum hash generator."""
002 import argparse
003 import logging
004 import json
005 import os
006 import sys
007
008 """ The original spamsum algorithm carries the following license:
009 Copyright (C) 2002 Andrew Tridgell <tridge@samba.org>
010 
011 This program is free software; you can redistribute it and/or
012 modify it under the terms of the GNU General Public License
013 as published by the Free Software Foundation; either version 2
014 of the License, or (at your option) any later version.
015 
016 This program is distributed in the hope that it will be useful,
017 but WITHOUT ANY WARRANTY; without even the implied warranty of
018 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
019 GNU General Public License for more details.
020 
021 You should have received a copy of the GNU General Public License
022 along with this program; if not, write to the Free Software
023 Foundation, Inc.,
024 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
025 
026 CHANGELOG:
027 Implemented in Python as shown below by Chapin Bryce &
028 Preston Miller
029 """
030
031 __authors__ = ["Chapin Bryce", "Preston Miller"]
032 __date__ = 20181027
033 __description__ = '''Generate file signatures using
034     the spamsum algorithm.'''
035 
036 # Base64 Alphabet
037 ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
038 ALPHABET += 'abcdefghijklmnopqrstuvwxyz0123456789+/'
039 
040 # Constants for use with signature calculation
041 CONTEXT_WINDOW = 7
042 FNV_PRIME = 0x01000193
043 HASH_INIT = 0x28021967
044 SIGNATURE_LEN = 64
045 
046 # Argument handling constants
047 OUTPUT_OPTS = ['txt', 'json', 'csv']
048 logger = logging.getLogger(__file__)

这个脚本有三个功能:main()fuzz_file()output()main() 函数作为我们的主要控制器,处理目录和单个文件的处理,并调用 output() 函数来显示哈希结果。fuzz_file() 函数接受文件路径并生成一个 spamsum 哈希值。然后,output() 函数接受哈希值和文件名,并以指定的格式显示这些值:

051 def main(file_path, output_type):
...
087 def fuzz_file(file_path):
...
188 def output(sigval, filename, output_type='txt'):

我们脚本的结构相当直接,正如下图所示。由虚线所示,fuzz_file() 函数是唯一返回值的函数。这是因为我们的 output() 函数将内容显示在控制台上,而不是返回给 main()

最后,我们的脚本以参数处理和日志初始化结束。对于命令行参数,我们接受一个文件或文件夹的路径以及输出格式。我们的输出将写入控制台,当前支持文本、CSV 和 JSON 输出类型。我们的日志参数是标准的,与我们其他实现非常相似,唯一不同的是我们将日志消息写入sys.stderr,以便用户仍然可以与通过sys.stdout生成的输出进行交互:

204 if __name__ == '__main__':
205     parser = argparse.ArgumentParser(
206         description=__description__,
207         epilog='Built by {}. Version {}'.format(
208             ", ".join(__authors__), __date__),
209         formatter_class=argparse.ArgumentDefaultsHelpFormatter
210     )
211     parser.add_argument('PATH',
212         help='Path to file or folder to generate hashes for. '
213              'Will run recursively.')
214     parser.add_argument('-o', '--output-type',
215         help='Format of output.', choices=OUTPUT_OPTS,
216         default="txt")
217     parser.add_argument('-l', help='specify log file path',
218         default="./")
219 
220     args = parser.parse_args()
221 
222     if args.l:
223         if not os.path.exists(args.l):
224             os.makedirs(args.l) # create log directory path
225             log_path = os.path.join(args.l, 'fuzzy_hasher.log')
226     else:
227         log_path = 'fuzzy_hasher.log'
228 
229     logger.setLevel(logging.DEBUG)
230     msg_fmt = logging.Formatter("%(asctime)-15s %(funcName)-20s"
231         "%(levelname)-8s %(message)s")
232     strhndl = logging.StreamHandler(sys.stderr) # Set to stderr
233     strhndl.setFormatter(fmt=msg_fmt)
234     fhndl = logging.FileHandler(log_path, mode='a')
235     fhndl.setFormatter(fmt=msg_fmt)
236     logger.addHandler(strhndl)
237     logger.addHandler(fhndl)
238 
239     logger.info('Starting Fuzzy Hasher v. {}'.format(__date__))
240     logger.debug('System ' + sys.platform)
241     logger.debug('Version ' + sys.version.replace("\n", " "))
242 
243     logger.info('Script Starting')
244     main(args.PATH, args.output_type)
245     logger.info('Script Completed')

有了这个框架,让我们来探讨一下main()函数是如何实现的。

main()函数开始

我们的主函数接受两个参数:文件路径和输出类型。我们首先检查输出类型,确保它在OUTPUT_OPTS列表中,以防函数是从没有验证的其他代码中调用的。如果是一个未知的输出格式,我们将抛出错误并退出脚本:

051 def main(file_path, output_type):
052     """
053     The main function handles the main operations of the script
054     :param file_path: path to generate signatures for
055     :param output_type: type of output to provide
056     :return: None
057     """
058 
059     # Check output formats
060     if output_type not in OUTPUT_OPTS:
061         logger.error(
062             "Unsupported output format '{}' selected. Please "
063             "use one of {}".format(
064                 output_type, ", ".join(OUTPUT_OPTS)))
065         sys.exit(2)

然后,我们开始处理文件路径,在第 67 行获取其绝对路径,在第 69 行检查它是否是一个目录。如果是,我们就开始遍历目录和子目录,查找并处理其中的所有文件。第 71 到第 73 行的代码应该在第五章,Python 中的数据库中见过。第 74 行,我们调用fuzz_file()函数生成我们的哈希值sigval。然后,这个sigval值连同文件名和输出格式一起传递给我们的output()函数:

067     # Check provided file path
068     file_path = os.path.abspath(file_path)
069     if os.path.isdir(file_path):
070         # Process files in folders
071         for root, _, files in os.walk(file_path):
072             for f in files:
073                 file_entry = os.path.join(root, f)
074                 sigval = fuzz_file(file_entry)
075                 output(sigval, file_entry, output_type)

我们的main()函数的其余部分处理单个文件的处理和无效路径的错误处理。如第 76 行到第 79 行所示,如果路径是一个文件,我们将按照之前的方式处理它,通过fuzz_file()生成哈希值,并将值传递给output()函数。最后,在第 80 行到第 84 行,我们处理访问指定文件或文件夹路径时的错误:

076     elif os.path.isfile(file_path):
077         # Process a single file
078         sigval = fuzz_file(file_path)
079         output(sigval, file_path, output_type)
080     else:
081         # Handle an error
082         logger.error("Error - path {} not found".format(
083             file_path))
084         sys.exit(1)

创建我们的模糊哈希

在我们深入讨论fuzz_file()函数的代码之前,让我们简要讨论一下其中的工作部分:

  • 一个滚动哈希

  • 一个从文件大小得出的计算重置点

  • 两个传统的哈希,在本例中利用了 FNV 算法

滚动哈希与我们之前的示例相似,用于识别我们将使用传统哈希进行总结的边界。对于 ssdeep 和 spamsum,滚动哈希比较的重置点(在我们之前的示例中设置为7)是基于文件的大小来计算的。我们稍后会展示用于确定这个值的确切函数,不过我们想强调的是,这意味着只有具有相同块大小的文件才能进行比较。虽然在概念上还有更多要讨论的,但让我们开始通过代码来实践这些概念。

现在我们进入有趣的部分:fuzz_file()函数。这个函数接受一个文件路径,并使用文件开头找到的常量来处理签名的计算:

087 def fuzz_file(file_path):
088     """
089     The fuzz_file function creates a fuzzy hash of a file
090     :param file_path (str): file to read.
091     :return (str): spamsum hash
092     """

生成我们的滚动哈希

以下代码块是我们的滚动哈希函数。现在,函数内部再嵌套一个函数可能看起来有些奇怪,但这种设计有一些优点。首先,它有助于组织代码。这个滚动哈希代码块仅由我们的 fuzz_file() 函数使用,通过将其嵌套在这个函数内部,我们可以告知下一个阅读我们代码的人情况就是如此。其次,通过将这个函数放置在 fuzz_file() 内部,我们可以确保任何导入我们代码作为模块的人不会误用滚动哈希函数。虽然选择这种设计还有其他多个效率和管理方面的理由,但我们希望在这个脚本中引入这一特性,让你了解这一概念。正如你在我们的其他脚本中看到的,这并不总是用于特殊功能,但它是你可以在脚本中使用的工具,以优化其设计。

这个嵌套函数接受两个参数,分别缩写为 nb(代表 new_byte)和 rh(代表我们的滚动哈希追踪字典)。在我们之前的示例中,为了计算滚动哈希,我们将整个窗口的 ASCII 值相加。在这个函数中,我们将执行一系列计算,帮助我们生成一个更大的 7 字节窗口的滚动哈希:

095     def update_rolling_hash(nb, rh):
096         """
097         Update the rolling hash value with the new byte
098         :param nb (int): new_byte as read from file
099         :param rh (dict): rolling hash tracking dictionary
100         :return: computed hash value to compare to reset_point
101         """

rh 滚动哈希追踪字典用于监控此滚动哈希中的移动部分。这里存储了三个数字,分别是 r1r2r3。这些数字需要进行额外的计算,如下方代码块所示,三者的和作为整数返回,代表该文件帧的滚动哈希。

字典追踪的其他两个元素是 rnrwrn 键保存滚动哈希在文件中的偏移位置,用于确定窗口中哪个字符被 nbnew_byte 值替换。这个窗口,正如你猜到的那样,存储在 rw 中。与我们之前的示例不同,在那里每次计算滚动哈希时,窗口中的每个字符都向左移动,这个实现只会替换数组中的最旧字符。这提高了效率,因为它只需进行一次操作,而不是八次:

102         # Calculate R2
103         rh['r2'] -= rh['r1']
104         rh['r2'] += (CONTEXT_WINDOW * nb)
105 
106         # Calculate R1
107         rh['r1'] += nb
108         rh['r1'] -= rh['rw'][rh['rn'] % CONTEXT_WINDOW]
109 
110         # Update RW and RN
111         rh['rw'][rh['rn'] % CONTEXT_WINDOW] = nb
112         rh['rn'] += 1
113 
114         # Calculate R3
115         rh['r3'] = (rh['r3'] << 5) & 0xFFFFFFFF
116         rh['r3'] = rh['r3'] ^ nb
117 
118         # Return the sum of R1 + R2 + R3
119         return rh['r1'] + rh['r2'] + rh['r3']

这个逻辑在计算上与 ssdeep 和 spamsum 使用的逻辑相同。首先,我们通过减去 r1 并加上 CONTEXT_WINDOWnew_byte 的乘积来计算 r2 值。然后,我们通过加上 new_byte 并减去窗口中最旧的字节来更新 r1 值。这意味着 r1 存储整个窗口的总和,类似于我们在前一个示例中的整个滚动哈希算法。

在第 111 行,我们开始更新窗口,用 new_byte 字符替换最旧的字节。之后,我们递增 rn 值,以准确追踪文件中的偏移量。

最后,我们计算r3值,它使用了一些我们尚未介绍的操作符。<<运算符是一个按位运算符,它将我们的值向左移动,在这个例子中是移动五位。这等同于我们将值乘以 2**5。第 115 行的第二个新的按位运算符是&,它在 Python 中是按位的AND运算符。这个运算符逐位比较两边的值,如果两个值在某一位上都是1,则该位置在输出中为1,否则为0。需要注意的是,在按位AND运算中,两边在同一位置上都是0时,结果不会是1。第 116 行的第三个新按位运算符是^,即排它的OR运算符,也称为 XOR 操作。它的工作原理大致与按位AND相反,即如果两个值在同一位置的比特不同,则该位置返回1;如果相同,则返回0

有关 Python 中按位运算符的更多信息,请访问wiki.python.org/moin/BitwiseOperators

处理完按位运算后,我们返回r1r2r3的总和,用于进一步的模糊哈希计算。

准备签名生成

回到我们的fuzz_file()函数,我们评估提供的文件,看看它是否包含内容,如果有,则打开文件。我们将该文件的大小存储起来,以供后续使用:

122     fsize = os.stat(file_path).st_size
123     if fsize == 0:
124         logger.warning("File is 0-bytes. Skipping...")
125         return ""
126     open_file = open(file_path, 'rb')

我们现在开始哈希算法中的第一个因素——重置点。这个值被标记为签名中的第一个值,因为它用于确定可以进行比较的哈希值。为了计算这个数字,我们从3开始,这个值在 spamsum 算法中被选为最小的重置点。然后我们将重置点加倍,如第 130 行所示,直到它大于filesize / 64

129     reset_point = 3
130     while reset_point * 64 < fsize:
131         reset_point *= 2

一旦我们有了初始的重置点,我们将文件读入内存作为bytearray,因为我们希望将每个字符作为字节读取,这样我们可以进行解释。然后我们设置while循环,如果需要调整reset_point的大小,就在这里进行——稍后会详细讨论:

134     complete_file = bytearray(open_file.read())
135     done = False
136     while not done:

一旦进入我们的while循环,我们将初始化哈希对象。第一个对象是rolling_hash,这是一个包含五个键的字典。r1r2r3键用于计算哈希值;rn键跟踪文件中光标的位置;rw键保存一个大小为CONTEXT_WINDOW常量的列表。这个字典在我们的update_rolling_hash()函数中被大量引用。现在你已经看过rolling_hash字典的结构,重新阅读这一部分可能会有所帮助。

紧接着这个字典,我们初始化了trad_hash1trad_hash2,并赋予它们HASH_INIT常量的值。最后,我们初始化了两个签名:sig1sig2。变量trad_hash1用于填充sig1的值,类似地,trad_hash2用于填充sig2的值。稍后我们将展示如何计算这些传统哈希并更新签名:

138         rolling_hash = {
139             'r1': 0,
140             'r2': 0,
141             'r3': 0,
142             'rn': 0,
143             'rw': [0 for _ in range(CONTEXT_WINDOW)]
144         }
145         trad_hash1 = HASH_INIT
146         trad_hash2 = HASH_INIT
147         sig1 = ""
148         sig2 = ""

一旦我们初始化了哈希值,就可以开始按行遍历文件,如第 151 行所示。在第 153 行,我们使用文件中的最新字节和rolling_hash字典来计算滚动哈希。记住,字典可以作为参数传递给函数并进行更新,而且更新后的值可以在函数外部保留,而无需返回。这使得与滚动哈希函数的接口更简洁。该函数仅返回计算出的滚动哈希值,之前已经讨论过,它的形式是一个整数。这个滚动哈希使我们能够通过字节流对数据的移动(或滚动)窗口进行哈希,并用于确定在文件中何时应向签名中添加字符:

151         for new_byte in complete_file:
152             # Calculate our rolling hash
153             rh = update_rolling_hash(new_byte, rolling_hash)

计算滚动哈希值后,我们需要更新传统哈希。这些哈希使用Fowler–Noll–VoFNV)哈希算法,其中我们将哈希的前一个值与固定的素数相乘,这个素数是我们常量之一,然后与新的数据字节进行异或运算(之前讨论过的^)。与滚动哈希不同,这些哈希值会随着每个新字节的加入而不断增加,直到我们到达某个边界。

156             trad_hash1 = (trad_hash1 * FNV_PRIME) ^ new_byte
157             trad_hash2 = (trad_hash2 * FNV_PRIME) ^ new_byte

这些边界通过两个条件语句进行评估,每个哈希/签名对一个条件。第 161 行到 164 行的功能等同于第 165 行到 168 行,唯一不同的是使用了不同的传统哈希和签名。为了简化,我们先来分析第一个。

在第 161 行和 162 行(由于换行),我们有第一个条件语句,它判断我们的滚动哈希与reset_point的乘积对reset_point取模后,是否等于reset_point - 1。我们还确保整体签名长度小于最大签名长度减去 1。如果这些条件满足,就表示我们已到达边界,并会将传统哈希值转换为签名字符,如第 163 行所示。在向签名中添加字符后,我们会将传统哈希值重置为初始值,这意味着下一个数据块的哈希值将从与前一个数据块相同的位置开始。

如前所述,这对于第二个签名是重复的,显著的例外是第二个签名正在修改reset_point(将其乘以 2)和最大签名长度(将其除以 2)。第二个重置点的添加是为了满足 spamsum 签名较短的需求——默认 64 个字符。这意味着主签名可能被截断,文件的尾部可能只代表签名的一个字符。为了应对这个问题,spamsum 添加了第二个签名来生成一个代表更多(如果不是全部)文件的值。第二个签名实际上有一个reset_point,其值是第一个签名的两倍:

159             # Check if our rolling hash reaches a reset point
160             # If so, update sig and reset trad_hash
161            if (rh % reset_point == reset_point - 1
162                     and len(sig1) < SIGNATURE_LEN - 1):
163                 sig1 += ALPHABET[trad_hash1 % 64]
164                 trad_hash1 = HASH_INIT
165             if (rh % (reset_point * 2) == (reset_point * 2) - 1
166                     and len(sig2) < (SIGNATURE_LEN / 2) - 1):
167                 sig2 += ALPHABET[trad_hash2 % 64]
168                 trad_hash2 = HASH_INIT

这是我们for循环的结束;该逻辑将重复,直到我们到达文件末尾,尽管签名的长度将分别只增长到 63 和 31 个字符。在我们的for循环退出后,我们会评估是否应该重新开始while循环(从第 136 行开始)。如果我们的第一个签名少于 32 个字符,且我们的reset_point不是默认值 3,我们希望这样做。如果签名过短,我们将reset_point值减半并重新运行整个计算。这意味着我们在while循环中需要每一分效率,因为我们可能会反复处理内容:

170         # If sig1 is too short, change block size and recalculate
171         if len(sig1) < SIGNATURE_LEN / 2 and reset_point > 3:
172             reset_point = reset_point // 2
173             logger.debug("Shortening block size to {}".format(
174                 reset_point))
175         else:
176             done = True

如果我们的签名长度大于 32 个字符,我们退出while循环并生成签名的最后一个字符。如果我们的滚动哈希值的乘积不等于零,我们会将最后一个字符添加到每个签名中,如第 180 行和第 181 行所示:

178     # Add any values from the tail to our hash
179     if rh != 0:
180         sig1 += ALPHABET[trad_hash1 % 64]
181         sig2 += ALPHABET[trad_hash2 % 64]
182 
183     # Close the file and return our new signature
184     open_file.close()
185     return "{}:{}:{}".format(reset_point, sig1, sig2)

到此为止,我们可以关闭文件并返回完整的 spamsum/ssdeep 签名。这个签名有三个, hopefully 可识别的部分:

  • 我们的reset_point

  • 主要签名

  • 次要签名

提供输出

幸运的是,我们的最后一个函数比前一个要简单得多。这个函数提供了以一种支持的格式输出签名和文件名。在过去,我们编写了单独的函数来处理不同的格式,然而在这个情况下,我们选择将它们都放在同一个函数中。这个设计决策的原因是我们希望能够接近实时地提供结果,特别是当用户正在处理多个文件时。由于我们的日志被重定向到STDERR,我们可以使用print()函数将结果提供到STDOUT。这样可以为用户提供灵活性,用户可以将输出通过管道传送到另一个程序(例如 grep),并对结果进行额外处理:

188 def output(sigval, filename, output_type='txt'):
189     """Write the output of the script in the specified format
190     :param sigval (str): Calculated hash
191     :param filename (str): name of the file processed
192     :param output_type (str): Formatter to use for output
193     """
194     if output_type == 'txt':
195         print("{} {}".format(sigval, filename))
196     elif output_type == 'json':
197         print(json.dumps({"sig": sigval, "file": filename}))
198     elif output_type == 'csv':
199         print("{},\"{}\"".format(sigval, filename))
200     else:
201         raise NotImplementedError(
202             "Unsupported output type: {}".format(output_type))

运行 fuzzy_hasher.py

以下截图展示了我们如何在目录中的一组文件上生成模糊哈希并对输出进行后处理。在这种情况下,我们通过将STDERR发送到/dev/null来隐藏日志消息。然后,我们将输出通过管道传输到jq,一个格式化和查询 JSON 数据的工具,来以漂亮的格式呈现输出:

在这个输出中,你可能会注意到一些事情。首先,我们要强调的是文件没有按照字母顺序排列。这是因为我们的os.walk函数在遍历路径时默认不会保持字母顺序。第二个问题是,尽管这些文件的大小相同,但它们的块大小不同。这意味着一些文件(包含随机内容)没有足够的块,因此签名太短。这意味着我们需要将块大小减半并重新尝试,这样当我们进入比较部分时,就能比较具有足够相似块的文件。另一方面,具有 3,072 个块的文件(file_2file_4)的第二个签名可以部分与其他块大小为 6,144 的文件的第一个签名进行比较。

我们提供了这些测试文件供你使用和比较,以确认我们的实现与你的实现相符,并且与下一个脚本的输出一致。

在 Python 中使用 ssdeep – ssdeep_python.py

这个脚本已经在 Python 2.7.15 和 3.7.1 版本中进行了测试,并且需要 ssdeep 版本 3.3 的第三方库。

正如你可能已经注意到的,之前的实现几乎慢得不可忍受。在这种情况下,最好利用像 C 语言这样的语言,它能够更快速地执行这个操作。幸运的是,spamsum 最初是用 C 语言编写的,后来通过 ssdeep 项目进一步扩展,依然是 C 语言实现的。ssdeep 项目提供的扩展之一是 Python 绑定。这些绑定允许我们仍然使用熟悉的 Python 函数调用,同时将繁重的计算任务卸载到已编译的 C 代码中。我们的下一个脚本涵盖了在 Python 模块中实现 ssdeep 库,以产生相同的签名并处理比较操作。

在这个模糊哈希的第二个示例中,我们将使用 ssdeep Python 库实现一个类似的脚本。这使我们能够利用 ssdeep 工具和 spamsum 算法,后者在数字取证和信息安全领域得到了广泛的应用和接受。这段代码将是大多数场景下模糊哈希的首选方法,因为它在资源使用上更高效,且能产生更准确的结果。这个工具在社区中得到了广泛的支持,许多 ssdeep 签名可以在线获取。例如,VirusShare 和 VirusTotal 网站上托管了来自 ssdeep 的哈希值。这些公开的信息可以用来检查已知的恶意文件,它们可能与主机机器上的可执行文件匹配或相似,而无需下载恶意文件。

ssdeep 的一个弱点是它仅提供匹配百分比的信息,并且无法比较具有显著不同块大小的文件。这可能是一个问题,因为 ssdeep 会根据输入文件的大小自动生成块大小。这个过程使得 ssdeep 比我们的脚本运行更高效,并且在扩展性方面表现得更好;然而,它并没有提供手动指定块大小的解决方案。我们可以拿之前的脚本并硬编码块大小,尽管这会引入其他(之前讨论过的)问题。

这个脚本与另一个脚本相同,唯一的不同是新增了 ssdeep 库的导入。要安装此库,请运行 pip install ssdeep==3.3,如果失败,可以按照 pypi.python.org/pypi/ssdeep 上的文档运行 BUILD_LIB=1 pip install ssdeep==3.3。这个库并不是 ssdeep 的开发者创建的,而是社区的另一位成员创建的,提供了 Python 与 C 基于的库之间需要的绑定。安装完成后,可以像第 7 行所示那样导入:

001 """Example script that uses the ssdeep python bindings."""
002 import argparse
003 import logging
004 import os
005 import sys
006 
007 import ssdeep

这个版本的结构与我们之前的版本相似,尽管我们将所有计算工作交给了 ssdeep 库。虽然我们可能缺少了哈希和比较函数,但我们仍然以非常相似的方式使用我们的 mainoutput 函数:

047 def main():
...
104 def output(): 

我们的程序流程与之前的迭代相似,尽管它缺少了我们在上一次迭代中开发的内部哈希函数。如流程图所示,我们仍然在 main() 函数中调用 output() 函数:

我们的参数解析和日志配置几乎与之前的脚本相同。主要的区别是我们引入了一个新的文件路径参数,并重命名了接受文件或文件夹的参数。在第 134 行,我们再次创建了 argparse 对象来处理我们的两个位置参数和两个可选的输出格式及日志标志。该代码块的其余部分与之前的脚本一致,唯一的区别是我们重命名了日志文件:

134 if __name__ == '__main__':
135     parser = argparse.ArgumentParser(
136         description=__description__,
137         epilog='Built by {}. Version {}'.format(
138             ", ".join(__authors__), __date__),
139         formatter_class=argparse.ArgumentDefaultsHelpFormatter
140     )
141     parser.add_argument('KNOWN',
142         help='Path to known file to use to compare')
143     parser.add_argument('COMPARISON',
144         help='Path to file or directory to compare to known. '
145         'Will recurse through all sub directories')

重访 main() 函数

这个 main() 函数与之前的脚本非常相似,虽然它添加了一些额外的代码行,因为我们增加了一些功能。该脚本从检查输出类型是否为有效格式开始,如第 56 行到第 62 行所示。然后,我们在第 63 行添加了另一个条件,使我们能够打印 CSV 表头行,因为这个输出比上一个版本更复杂:

047 def main(known_file, comparison, output_type):
048     """
049     The main function handles the main operations of the script
050     :param known_file: path to known file
051     :param comparison: path to look for similar files
052     :param output_type: type of output to provide
053     :return: None
054     """
055 
056     # Check output formats
057     if output_type not in OUTPUT_OPTS:
058         logger.error(
059             "Unsupported output format '{}' selected. Please "
060             "use one of {}".format(
061                 output_type, ", ".join(OUTPUT_OPTS)))
062         sys.exit(2)
063     elif output_type == 'csv':
064         # Special handling for CSV headers
065         print('"similarity","known_file","known_hash",'
066               '"comp_file","comp_hash"')

现在我们已经处理了输出格式的验证,让我们转向文件比较部分。首先,我们会获取已知文件和比较路径的绝对路径,以便与之前的脚本保持一致。然后,在第 73 行,我们检查已知文件是否存在。如果存在,我们会在第 78 行计算 ssdeep 哈希值。这个计算完全由 ssdeep 处理;我们需要做的只是提供一个有效的文件路径给hash_from_file()方法。此方法返回一个包含 ssdeep 哈希值的字符串,结果与我们在之前脚本中的fuzz_file()函数相同。这里的主要区别是通过使用高效的 C 代码在ssdeep模块中运行,从而提升了速度:

068     # Check provided file paths
069     known_file = os.path.abspath(known_file)
070     comparison = os.path.abspath(comparison)
071
072     # Generate ssdeep signature for known file
073     if not os.path.exists(known_file):
074         logger.error("Error - path {} not found".format(
075             comparison))
076         sys.exit(1)
077
078     known_hash = ssdeep.hash_from_file(known_file)

现在我们有了已知的哈希值,可以评估比较路径。如果该路径是一个目录,如第 81 行所示,我们将遍历该文件夹及其子文件夹,寻找要处理的文件。在第 86 行,我们生成这个比较文件的哈希值,方法与已知文件相同。下一行引入了compare()方法,允许我们提供两个哈希值进行评估。该比较方法返回一个介于 0 到 100(包括 0 和 100)之间的整数,表示这两个文件内容相似的可信度。然后,我们将所有部分(包括文件名、哈希值和结果相似度)提供给我们的输出函数,并附上我们的格式化规范。这段逻辑会一直进行,直到我们递归处理完所有文件:

080     # Generate and test ssdeep signature for comparison file(s)
081     if os.path.isdir(comparison):
082         # Process files in folders
083         for root, _, files in os.walk(comparison):
084             for f in files:
085                 file_entry = os.path.join(root, f)
086                 comp_hash = ssdeep.hash_from_file(file_entry)
087                 comp_val = ssdeep.compare(known_hash, comp_hash)
088                 output(known_file, known_hash,
089                        file_entry, comp_hash,
090                        comp_val, output_type)

我们的下一个条件处理相同的操作,但只针对一个文件。如你所见,它使用与目录操作相同的hash_from_file()compare()函数。一旦所有的值都被分配,我们会以相同的方式将它们传递给我们的output()函数。我们的最终条件处理输入错误的情况,通知用户并退出:


092     elif os.path.isfile(comparison):
093         # Process a single file
094         comp_hash = ssdeep.hash_from_file(comparison)
095         comp_val = ssdeep.compare(known_hash, comp_hash)
096         output(known_file, known_hash, file_entry, comp_hash,
097                comp_val, output_type)
098     else:
099         logger.error("Error - path {} not found".format(
100             comparison))
101         sys.exit(1)

重新设计我们的output()函数

我们的最后一个函数是output();这个函数接收多个值,并将它们整齐地呈现给用户。就像我们之前的脚本一样,我们将支持 TXT、CSV 和 JSON 输出格式。为了展示这种类型的函数的不同设计,我们将使用特定格式的条件来构建一个模板。然后,使用这个模板以格式化的方式打印内容。如果将来我们打算将输出函数(在本例中是print())更换为其他输出函数,这种方法就会非常有用。

104 def output(known_file, known_hash, comp_file, comp_hash, comp_val,
105            output_type='txt'):
106     """Write the output of the script in the specified format
107     :param sigval (str): Calculated hash
108     :param filename (str): name of the file processed
109     :param output_type (str): Formatter to use for output
110     """

首先,我们需要将我们的整数值comp_val转换为字符串,以便与模板兼容。在第 112 行完成此操作后,我们将构建文本格式的模板。文本格式让我们能够自由地以适合视觉审查的方式展示数据。以下是一个选项,但你可以根据需要进行修改。

在第 113 和第 114 行,我们通过使用大括号包围占位符标识符来构建带有命名占位符的模板。跳到第 127 到第 132 行,你可以看到当我们调用 msg.format() 时,我们通过与占位符相同的名称提供我们的值作为参数。这告诉 format() 方法应该用哪个值填充哪个占位符。命名占位符的主要优势在于,我们在调用 format() 方法时,可以按任何顺序安排值,甚至可以让模板格式中的元素在不同位置:

111     comp_val = str(comp_val)
112     if output_type == 'txt':
113         msg = "{similarity} - {known_file} {known_hash} | "
114         msg += "{comp_file} {comp_hash}"

接下来是我们的 JSON 格式化。json.dumps() 方法是输出字典为 JSON 内容的首选方式,尽管在这个例子中我们将探讨如何通过其他方式实现类似的目标。通过使用相同的模板方法,我们构建了一个字典,其中键是固定的字符串,值是占位符。由于模板语法使用单个大括号来表示占位符,我们必须使用第二个大括号来转义单个大括号。这意味着我们的整个 JSON 对象被额外的大括号包裹——别担心,只有两个大括号中的一个会在打印时显示:

115     elif output_type == 'json':
116         msg = '{{"similarity": {similarity}, "known_file": '
117         msg += '"{known_file}", "known_hash": "{known_hash}", '
118         msg += '"comparison_file": "{comp_file}", '
119         msg += '"comparison_hash": "{comp_hash}"}}'

最后,我们有了我们的 CSV 输出,再次使用了命名占位符模板。正如你可能注意到的,我们将每个值都用双引号包围,以确保值中的任何逗号不会导致格式问题:

120     elif output_type == 'csv':
121         msg = '"{similarity}","{known_file}","{known_hash}"'
122         msg += '"{comp_file}","{comp_hash}"'

我们的 msg 变量在此处出现在多行的唯一原因是为了换行。除此之外,没有任何东西阻止你将整个格式模板放在一行。最后,我们有了 else 条件,它会捕捉到任何不支持的输出类型:

123     else:
124         raise NotImplementedError(
125             "Unsupported output type: {}".format(output_type))

在条件语句后,我们打印出已应用值的模板,以替代占位符。如果我们想支持一种新的或替代的格式,我们可以在上方添加新的条件,并创建所需的模板,而无需重新实现这个 print() 函数:

127     print(msg.format(
128         similarity=comp_val,
129         known_file=known_file,
130         known_hash=known_hash,
131         comp_file=comp_file,
132         comp_hash=comp_hash))

运行 ssdeep_python.py

现在我们可以运行脚本,例如,提供 test_data/file_3 作为已知文件,并将整个 test_data/ 文件夹作为比较集。再次使用 JSON 输出,我们可以在接下来的两个截图中看到我们模板化的结果:

以下是我们继续的输出:

你还会注意到,使用 ssdeep 库的这个脚本,产生了与我们之前实现相同的签名!需要注意的一点是这两个脚本之间的速度差异。通过使用工具时间,我们运行了两个脚本,对相同文件夹中的这六个文件进行了处理。正如接下来的截图所示,使用我们导入的 ssdeep 模块,性能有了显著提升:

额外的挑战

你已经创建了一个实现 spamsum 算法的脚本,用来生成与 ssdeep 兼容的哈希值!接下来,还有一些额外的挑战等着你。

首先,我们提供了六个示例文件,这些文件位于前面提到的test_data/目录中。这些文件可用于确认你是否获得了与打印的哈希值相同的值,并且可以让你进行一些额外的测试。file_1file_2file_3文件是我们的原始文件,而附加了a的文件是原始文件的修改版本。随附的README.md文件包含了我们所做的修改说明,简而言之,我们进行了以下操作:

  • file_1 将部分文件内容移至文件的后半部分

  • file_2 在文件的前半部分插入内容

  • file_3 移除文件的开头部分

我们鼓励你进行额外的测试,了解 ssdeep 如何应对不同类型的修改。随意修改原始文件并与社区分享你的发现!

另一个挑战是研究 ssdeep 或 spamsum 代码,了解它如何处理比较组件,目标是将其加入到第一个脚本中。

我们还可以开发代码来展示,例如,Word 文档的内容,并生成该文档内容的 ssdeep 哈希,而不是二进制文件的哈希。这可以应用于其他类型的文件,不仅限于文本内容。例如,如果我们发现某个可执行文件被打包了,我们可能还想生成解包后字节内容的模糊哈希。

最后,市面上还有其他相似度分析工具。举例来说,sdhash工具采用了一种不同的方法来识别两个文件之间的相似性。我们建议你花些时间使用这个工具,将其应用于你和我们提供的测试数据,看看它如何应对不同类型的修改和变化。有关sdhash的更多信息,请访问网站:roussev.net/sdhash/sdhash.html

参考文献

概述

哈希是 DFIR 工作流程中的一个关键组成部分。虽然大多数哈希的应用场景集中在完整性检查上,但相似性分析的使用使我们能够了解更多关于近似匹配和文件关系的信息。这个过程可以为恶意软件检测、识别未授权位置的受限文档以及仅基于内容发现紧密相关的项目提供深入的见解。通过使用第三方库,我们能够利用 C 语言背后的强大功能,同时享受 Python 解释器的灵活性,构建出既适合用户又适合开发者的强大工具。这个项目的代码可以从 GitHub 或 Packt 下载,具体信息见前言

模糊哈希是一种元数据的形式,或者说是关于数据的数据。元数据还包括嵌入的属性,如文档编辑时间、图像地理位置信息和源应用程序。在下一章中,您将学习如何从各种文件中提取嵌入的元数据,包括图像、音频文件和办公文档。

第八章:媒体时代

元数据,或描述数据的数据,是调查员可以利用的强大工具,帮助回答调查问题。广义来说,元数据可以通过检查文件系统和嵌入的元素来找到。文件权限、MAC 时间戳和文件大小记录在文件系统级别。然而,对于特定的文件类型,如 JPEG,额外的元数据会嵌入到文件本身中。

嵌入式元数据更特定于相关对象。这个嵌入式元数据可以提供额外的时间戳、特定文档的作者,甚至是照片的 GPS 坐标。像 Phil Harvey 的 ExifTool 这样的完整软件应用程序存在,用于从文件中提取嵌入式元数据,并将其与文件系统元数据合并。

本章将涵盖以下主题:

  • 使用第一方和第三方库从文件中提取元数据

  • 理解 可交换图像文件格式EXIF)、ID3 和 Microsoft Office 嵌入式元数据

  • 学习构建框架以促进脚本的快速开发和集成

本章的代码在 Python 2.7.15 和 Python 3.7.1 上开发和测试。

在 Python 中创建框架

框架对于大型 Python 项目非常有用。我们在第六章中曾提到过 UserAssist 脚本是一个框架,从二进制文件中提取数据;然而,它实际上并不完全符合这个模型。我们将构建的框架将有一个抽象的顶层,这个顶层将作为程序的控制器。这个控制器将负责执行插件和写入程序。

插件是包含在单独脚本中的代码,它为框架添加特定功能。开发完成后,插件应该能够通过几行代码轻松集成到现有框架中。插件还应该能够执行独立的功能,而不需要修改控制器来操作。例如,我们将编写一个插件专门处理 EXIF 元数据,另一个插件处理 Office 元数据。框架模型的一个优点是,它允许我们以有组织的方式将多个插件组合在一起,并为共同的目标执行它们,例如从文件中提取各种类型的嵌入式元数据。

构建框架需要一些前瞻性思维和规划。规划和测试你希望在框架中使用的数据结构类型是至关重要的。不同的数据结构适合不同的任务。考虑框架将处理的输入和输出类型,并以此为依据,选择合适的数据类型。发现更优的数据结构后重新编写框架可能是一个令人沮丧且耗时的任务。

如果没有这一步,框架可能会迅速变得无法控制,变成一团糟。想象一下每个插件都需要自己独特的参数,更糟糕的是,返回不同类型的数据,需要特殊处理。例如,一个插件可能返回一个字典的列表,而另一个插件可能返回一个字典中的字典。你的代码大部分会写成将这些数据类型转换为一个通用格式,以供编写器使用。为了保持理智,我们建议创建标准化的输入输出,每个插件都应遵循。这将使你的框架更容易理解,并避免不必要的转换错误,从而使其更稳定。

编写器从插件获取处理后的数据,并将其写入输出文件。我们熟悉的一种编写器是 CSV 编写器。在前面的章节中,我们的 CSV 编写器将处理后的数据输入并写入文件。在更大的项目中,比如这个项目,我们可能会有多种类型的编写器用于输出。例如,在本章中,我们将开发一个 Google Earth KML 编写器,以绘制我们从嵌入式 EXIF 元数据中提取的 GPS 数据。

EXIF 元数据介绍

EXIF 元数据是一种标准,用于图像和音频文件标签,这些标签由设备和应用程序创建。最常见的,这种嵌入式元数据与 JPEG 文件相关联。然而,EXIF 元数据也存在于 TIFF、WAV 和其他文件中。在 JPEG 文件中,EXIF 元数据可以包含用于拍摄照片的技术相机设置,如快门速度、光圈值和 ISO 值。这些可能对检查员没有直接用处,但包含照片的制造商、型号和 GPS 位置的标签可以用于将某个人与犯罪联系起来。每个元素都与一个标签相关联。例如,制造商元数据是 EXIF 标签 271 或0x010F。标签的完整列表可以在www.exiv2.org/tags.html找到。

EXIF 元数据存储在 JPEG 图像的开头,如果存在,它位于字节偏移量 24 处。EXIF 头以十六进制0x45786966开始,这是“Exif”在 ASCII 中的表示。以下是 JPEG 图像前 52 个字节的十六进制转储:

请注意,从偏移量 24 开始的 EXIF 头。跟随其后的十六进制0x4D4D代表摩托罗拉或大端字节对齐。位于字节偏移 40 的0x010F标签 ID 是 EXIF 的Make元数据标签。每个标签由四个组件组成:

字节偏移量 名称 描述
0-1 ID 代表特定 EXIF 元数据元素的标签 ID
2-3 类型 数据类型(整数、字符串等)
4-7 长度 数据的长度
8-11 偏移量 从字节对齐值的偏移量

在前面的表格中,Make 标签的数据类型为 2,对应于 ASCII 字符串,长度为 6 字节,位于字节对齐值 0x4D4D 之后 2206 字节的位置。第二个截图显示了从文件开始 2206 字节位置开始的 52 字节数据切片。在这里,我们可以看到 Nokia,这是拍摄该照片时使用的手机品牌,作为一个长 6 字节的 ASCII 字符串:

如果我们有兴趣,也可以使用 struct 解析头部并获取相关的 EXIF 元数据。幸运的是,第三方 Python Imaging Library(PIL)模块已经支持 EXIF 元数据,并且使得这项任务变得更加简便。

介绍 Pillow 模块

Pillow(版本 5.3.0)是一个活跃维护的 Python 图像库的分支,是一个功能强大的模块,可以存档、显示和处理图像文件。可以在 www.pillow.readthedocs.io 阅读该模块的详细说明。可以使用 pip 如下安装此库:

pip install pillow==5.3.0

PIL 提供一个名为 _getexif() 的函数,它返回一个标签及其值的字典。标签以十进制格式存储,而不是十六进制格式。将大端格式的 0x010F 解释为十进制值 271 对应于 Make 标签。我们无需通过 struct 繁琐地操作,只需简单地查询某个标签是否存在,如果存在,则处理其值:

>>> from PIL import Image 
>>> image = Image.open('img_42.jpg') 
>>> exif = image._getexif() 
>>> if 271 in exif.keys(): 
...     print('Make:', exif[271]) 
...  
Make: Nokia

ID3 元数据介绍

ID3 元数据容器通常与 MP3 文件相关联。嵌入结构有两个版本:ID3v1 和 ID3v2。ID3v1 版本是文件的最后 128 字节,其结构与更新格式不同。我们将重点介绍的新版位于文件的开头,长度是可变的。

与 EXIF 标签相比,ID3 标签具有更简单的结构。前 16 字节均匀地分配在标签 ID 和元数据的长度之间。之后是元数据本身。以下截图显示了一个 MP3 文件的前 144 字节:

MP3 文件的文件签名是 ASCII ID3。紧接着签名后,我们可以看到不同的标签,如 TP1、TP2 和 TCM。这些分别是艺术家、乐队和作曲家的元数据标签。紧接在 TP1 后的 8 字节表示由十六进制 0x0B 或 11 表示的长度。接下来是 2 字节的缓冲区,之后是曾用名为 The Artist 的艺术家的数据。The Artist 长度为 10 字节,并且在数据前加上一个空字节(0x00),总长度为 11 字节。我们将使用名为 Mutagen 的模块来加载文件并读取任何存在的 ID3 标签。

一些 MP3 文件可能没有嵌入 ID3 元数据。在这种情况下,我们在前面截图中看到的标签可能并不存在。

介绍 Mutagen 模块

Mutagen(版本 1.41.1)能够读取和写入不同的音频元数据格式。Mutagen 支持多种嵌入式音频格式,如 ASF、FLAC、M4A 和 MP3(ID3)。该模块的完整文档可以在www.mutagen.readthedocs.io找到。我们可以通过以下方式使用pip安装该模块:

pip install mutagen==1.41.1 

使用 Mutagen 非常简单。我们需要通过打开 MP3 文件创建一个 ID3 对象,然后像使用 PIL 一样,在字典中查找特定的标签,如下所示:

>>> from mutagen import id3 
>>> id = id3.ID3('music.mp3') 
>>> if 'TP1' in id.keys(): 
...     print('Artist:', id['TP1']) 
... 
Artist: The Artist 

Office 元数据简介

随着 Office 2007 的发布,微软为其 Office 产品引入了一种新的专有格式,如.docx.pptx.xlsx文件。这些文档实际上是一个包含 XML 和二进制文件的压缩目录。这些文档包含大量嵌入的元数据,存储在文档中的 XML 文件中。我们将要查看的两个 XML 文件是core.xmlapp.xml,它们存储不同类型的元数据。

core.xml文件存储与文档相关的元数据,例如作者、修订号以及最后修改文档的人。app.xml文件存储更具体的文件内容相关的元数据。例如,Word 文档存储页面、段落、行、单词和字符计数,而 PowerPoint 演示文稿存储与幻灯片、隐藏幻灯片和注释计数等相关的信息。

要查看这些数据,使用你选择的归档工具解压现有的 2007 或更高版本的 Office 文档。你可能需要在文件末尾添加.zip扩展名,以便使用你选择的工具解压该归档文件。以下是解压后的 Word 文档内容的截图:

docProps文件夹中,我们可以看到两个 XML 文件,它们包含与我们特定 Word 文档相关的元数据。Word 目录包含实际的 Word 文档本身,存储在document.xml中,以及任何插入的媒体,存储在媒体子目录中。现在,让我们来看一下core.xml文件:

在第四章,处理序列化数据结构中,我们讨论了序列化数据,并提到 XML 是一种流行的数据序列化格式。XML 基于指令、命名空间和标签的概念,与另一种流行的标记语言 HTML 类似。大多数 XML 文件以头部指令开始,详细说明版本、编码和解析器的任何指令。

core.xml文件还包含五个命名空间,这些命名空间只在文件开始时声明一次,之后通过它们分配的命名空间变量进行引用。命名空间的主要目的是避免名称冲突,它们是通过xmlns属性创建的。

在命名空间之后,我们有各种标签,类似于 HTML,如标题、主题和创建者。我们可以使用 XML 解析器,如 lxml,来遍历这些标签并处理它们。

介绍 lxml 模块

lxml(版本 4.2.5)是一个第三方模块,提供了对 C 语言 libxml2libxslt 库的 Python 绑定。这个模块由于其速度快,是非常流行的 XML 解析器,并且可以用来解析 HTML 文件。我们将使用该模块遍历每个 child 标签,并打印出我们感兴趣的内容。该库的完整文档可以在 www.lxml.de 找到。再次提醒,使用 pip 安装库非常简单:

pip install lxml==4.2.5 

让我们来看看如何在交互式提示符中遍历 core.xml 文件。etree 或元素树 API 提供了一种简单的机制来遍历 XML 文件中的子元素。首先,我们需要将 XML 文件解析为元素树。接下来,我们获取树中的根元素。通过根元素,我们可以使用 root.iter() 函数遍历每个子元素,并打印出标签和文本值。请注意,标签包含了完整展开的命名空间。在短短几行代码中,我们就可以轻松使用 lxml 解析基本的 XML 文件:

>>> import lxml.etree.ElementTree as ET 
>>> core = ET.parse('core.xml') 
>>> root = core.getroot() 
>>> for child in root.iter(): 
...     print(child.tag, ':', child.text) 
...  
{http://purl.org/dc/elements/1.1/}title : Metadata Title 
{http://purl.org/dc/elements/1.1/}subject : Digital Forensics 
{http://purl.org/dc/elements/1.1/}creator : Preston Miller & Chapin Bryce 
... 

Metadata_Parser 框架概述

现在我们已经理解了框架的概念以及我们所处理的数据类型,我们可以详细探讨框架实现的具体内容。与流程图不同,我们使用高级图示来展示脚本之间如何相互作用:

该框架将由 metadata_parser.py 脚本控制。这个脚本将负责启动我们的三个插件脚本,然后将返回的数据传递给相应的写入插件。在处理过程中,插件会调用处理器来帮助验证数据或执行其他处理功能。我们有两个写入插件,一个用于 CSV 输出,另一个用于使用 Google Earth 的 KML 格式绘制带地理标记的数据。

每个插件将以一个单独的文件作为输入,并将解析后的元数据标签存储在字典中。然后,这个字典会返回到 metadata_parser.py 中,并附加到一个列表中。一旦所有输入文件处理完毕,我们将这些字典列表发送给写入插件。我们使用 csv 模块中的 DictWriter 来将字典输出写入到 CSV 文件中。

类似于 第六章,从二进制文件中提取文档,我们将有多个 Python 目录来以逻辑的方式组织我们的代码。为了使用这些包,我们需要通过 __init__.py 脚本使目录可搜索,然后在代码中导入该目录:

  |-- metadata_parser.py 
  |-- plugins 
      |-- __init__.py 
      |-- exif_parser.py 
      |-- id3_parser.py 
      |-- office_parser.py 
  |-- processors 
      |-- __init__.py 
      |-- utility.py 
  |-- writers 
      |-- __init__.py 
      |-- csv_writer.py 
      |-- kml_writer.py 

我们的主要框架控制器 – metadata_parser.py

metadata_parser.py脚本包含一个单独的函数main(),位于第 45 行,该函数负责协调我们插件和写入器之间的逻辑。在脚本顶部,我们调用了本章将要使用的导入内容。在第 8 行和第 9 行,我们特别导入了我们创建的插件和写入器目录,如下所示:

001 """EXIF, ID3, and Office Metadata parser."""
002 from __future__ import print_function
003 import argparse
004 import os
005 import sys
006 import logging
007 
008 import plugins
009 import writers
...
045 def main(input_dir, output_dir):

在第 133 行,我们为程序设置参数。此脚本接受两个位置参数,一个输入目录和一个输出目录,还有一个可选的日志参数,用于更改日志文件的目录和名称。第 142 到 154 行专注于设置日志,和前面章节一样。代码如下:

131 if __name__ == '__main__':
132 
133     parser = argparse.ArgumentParser(description=__description__,
134                                 epilog='Developed by ' +
135                                 __author__ + ' on ' +
136                                 __date__)
137     parser.add_argument('INPUT_DIR', help='Input Directory')
138     parser.add_argument('OUTPUT_DIR', help='Output Directory')
139     parser.add_argument('-l', help='File path of log file.')
140     args = parser.parse_args()
141 
142     if args.l:
143         if not os.path.exists(args.l):
144             os.makedirs(args.l)
145         log_path = os.path.join(args.l, 'metadata_parser.log')
146     else:
147         log_path = 'metadata_parser.log'
148     logging.basicConfig(filename=log_path, level=logging.DEBUG,
149                         format=('%(asctime)s | %(levelname)s | '
150                         '%(message)s'), filemode='a')
151 
152     logging.info('Starting Metadata_Parser')
153     logging.debug('System ' + sys.platform)
154     logging.debug('Version ' + sys.version) 

在第 156 行,如果提供的输出目录不存在,我们将创建该输出目录。这个输出目录是通过makedirs()函数创建的。该函数接受一个表示文件路径的字符串,并创建目录以及路径中不存在的任何中间目录。在第 159 行,我们检查提供的输入是否是一个目录并且是否存在。如果是的话,在第 161 行,调用main()函数,并传入输入和输出目录的参数。如果输入不存在或不是一个目录,我们将记录并打印错误,并以状态码 1 退出。我们有以下代码:

156     if not os.path.exists(args.OUTPUT_DIR):
157         os.makedirs(args.OUTPUT_DIR)
158 
159     if(os.path.exists(args.INPUT_DIR) and
160     os.path.isdir(args.INPUT_DIR)):
161         main(args.INPUT_DIR, args.OUTPUT_DIR)
162     else:
163         msg =('Supplied input directory doesn't exist or is'
164         'not a directory')
165         print('[-]', msg)
166         logging.error(msg)
167         sys.exit(1)

使用main()函数控制我们的框架

在第 57 到 59 行,我们创建了列表,用来存储从插件调用中返回的字典。但在我们调用插件之前,需要从用户的输入目录参数生成文件列表。我们在第 65 行使用了os.walk()函数,这在前面的章节中已经使用过。一个新的参数topdown被传递到我们的目录遍历循环中。这使我们能够控制迭代的流程,从顶级目录到最深层级逐步遍历。虽然这是默认行为,但也可以指定以确保预期行为。对于每个文件,我们需要使用join()函数将其与根路径连接,生成文件的完整路径:

045 def main(input_dir, output_dir):
046     """
047     The main function generates a file listing, sends files to be
048     processed, and output written.
049     :param input_dir: The input directory to scan for suported
050         embedded metadata containing files
051     :param output_dir: The output directory to write metadata
052         reports to
053     :return: Nothing.
054     """
055     # Create lists to store each supported embedded metadata
056     # before writing to output
057     exif_metadata = []
058     office_metadata = []
059     id3_metadata = []
060 
061     # Walk through list of files
062     msg = 'Generating file listing and running plugins.'
063     print('[+]', msg)
064     logging.info(msg)
065     for root, subdir, files in os.walk(input_dir, topdown=True):
066         for file_name in files:
067             current_file = os.path.join(root, file_name)
068             ext = os.path.splitext(current_file)[1].lower() 

最后,在第 68 行,我们使用os.path.splitext()函数将扩展名与完整路径分离。splitext()函数接受一个表示文件路径的字符串,并返回一个列表,列表的第一个元素是路径,第二个元素是扩展名。我们也可以使用split()函数,通过分割点来拆分路径,并获取新列表中的最后一个元素:

>>> '/path/to/metadata_image.jpg'.split('.')[-1] 
jpg 

在获得 current_file 之后,我们会在第 71、83 和 96 行查看其扩展名,以确定我们现有的插件是否合适。如果文件是 JPEG 图像,那么第 71 行的条件将评估为 True。在第 73 行,我们调用我们的 exif_parser() 函数,该函数位于插件子目录中的 exif_parser.py 脚本中。因为我们只匹配扩展名,所以这个函数调用被包装在 tryexcept 中,以处理由于文件签名不匹配而在 exif_parser() 函数中引发错误的情况:

070             # PLUGINS
071             if ext == '.jpeg' or ext == '.jpg':
072                 try:
073                     ex_metadata, exif_headers = plugins.exif_parser.exif_parser(
074                     current_file)
075                     exif_metadata.append(ex_metadata)
076                 except TypeError:
077                     print(('[-] File signature mismatch. '
078                         'Continuing to next file.'))
079                     logging.error((('JPG & TIFF File Signature '
080                     'check failed for ' + current_file)))
081                     continue

如果函数没有引发错误,它将返回该特定文件的 EXIF 元数据以及 CSV 写入器的头信息。在第 75 行,我们将 EXIF 元数据结果附加到我们的 exif_metadata 列表,并继续处理其他输入文件:

083             elif ext == '.docx' or ext == '.pptx' or ext == '.xlsx':
084                 try:
085                     of_metadata, office_headers = plugins.office_parser.office_parser(
086                     current_file)
087                     office_metadata.append(of_metadata)
088                 except TypeError:
089                     print(('[-] File signature mismatch. '
090                         'Continuing to next file.'))
091                     logging.error((('DOCX, XLSX, & PPTX File '
092                     'Signature check failed for ' + current_file))
093                     )
094                     continue
095 
096             elif ext == '.mp3':
097                 try:
098                     id_metadata, id3_headers = plugins.id3_parser.id3_parser(
099                     current_file)
100                     id3_metadata.append(id_metadata)
101                     except TypeError:
102                         print(('[-] File signature mismatch. '
103                             'Continuing to next file.'))
104                         logging.error((('MP3 File Signature check '
105                             'failed for ' + current_file)))
106                         continue

请注意,其他两个插件采用了相似的结构。所有插件只接受一个输入 current_file,并返回两个输出值:元数据字典和 CSV 头信息。仅需要八行代码来正确调用并存储每个插件的结果。还需要多写几行代码,将存储的数据写入输出文件。

一旦遍历了所有文件,我们就可以开始写入必要的输出。在第 113、119 和 123 行,我们检查元数据列表中是否包含字典。如果包含,我们会调用 csv_writer() 函数,该函数位于 writers 子目录中的 csv_writer.py 脚本中。对于 EXIF 元数据,我们还会在第 114 行调用 kml_writer() 函数以绘制 GPS 坐标:

108     # WRITERS
109     msg = 'Writing output to ' + output_dir
110     print('[+]', msg)
111     logging.info(msg)
112 
113     if len(exif_metadata) > 0:
114         writers.kml_writer.kml_writer(exif_metadata,
115             output_dir, 'exif_metadata.kml')
116         writers.csv_writer.csv_writer(exif_metadata, exif_headers,
117             output_dir, 'exif_metadata.csv')
118 
119     if len(office_metadata) > 0:
120         writers.csv_writer.csv_writer(office_metadata,
121             office_headers, output_dir, 'office_metadata.csv')
122 
123     if len(id3_metadata) > 0:
124         writers.csv_writer.csv_writer(id3_metadata, id3_headers,
125             output_dir, 'id3_metadata.csv')
126 
127     msg = 'Program completed successfully -- exiting..'
128     print('[*]', msg)
129     logging.info(msg)

这完成了我们框架的控制器逻辑。主要的处理发生在每个独立的插件文件中。现在,让我们看看第一个插件。

解析 EXIF 元数据 – exif_parser.py

exif_parser 插件是我们首先开发的插件,由于依赖 PIL 模块,它相对简单。该脚本中有三个函数:exif_parser()get_tags()dms_to_decimal()。第 39 行的 exif_parser() 函数是该插件的入口点,它唯一的输入是一个表示文件名的字符串。此函数主要充当插件的协调逻辑。

第 62 行的 get_tags() 函数负责解析输入文件的 EXIF 标签。最后,第 172 行的 dms_to_decimal() 函数是一个小助手函数,负责将 GPS 坐标转换为十进制格式。请看以下代码:

001 from datetime import datetime
002 import os
003 from time import gmtime, strftime
004 
005 from PIL import Image
006 
007 import processors
...
039 def exif_parser(): 
... 
062 def get_tags(): 
... 
172 def dms_to_decimal(): 

理解 exif_parser() 函数

该函数有三个用途:验证输入文件、提取标签并将处理后的数据返回给 metadata_parser.py。为了验证输入值,我们会根据已知签名评估其文件签名。我们不依赖文件的扩展名,因为它可能不正确,而是检查签名,以避免其他错误来源。

检查文件签名,有时被称为文件的魔术数字,通常是通过检查文件的前几个字节,并将其与该文件类型的已知签名进行比较。Gary Kessler 在他的网站上有一份详细的文件签名列表,网址是www.garykessler.net/library/file_sigs.html

039 def exif_parser(filename):
040     """
041     The exif_parser function confirms the file type and sends it
042     to be processed.
043     :param filename: name of the file potentially containing EXIF
044     metadata.
045     :return: A dictionary from get_tags, containing the embedded
046     EXIF metadata.
047     """

在第 50 行,我们创建了一个已知的 JPEG 图像文件签名列表。在第 52 行,我们调用了位于processors子目录下的utility.py脚本中的check_header()函数。如果文件的头部与提供的已知签名之一匹配,该函数将返回True

049     # JPEG signatures
050     signatures = ['ffd8ffdb','ffd8ffe0', 'ffd8ffe1', 'ffd8ffe2',
051         'ffd8ffe3', 'ffd8ffe8']
052     if processors.utility.check_header(
053             filename,signatures, 4) == True:
054         return get_tags(filename)
055     else:
056         print(('File signature doesn't match known '
057             'JPEG signatures.'))
058         raise TypeError(('File signature doesn't match ' 
059             'JPEG object.'))

如果我们确实拥有一个合法的 JPEG 文件,我们将在第 54 行调用并返回get_tags()函数的结果。或者,如果check_header()返回False,则说明存在不匹配的情况,我们会向父脚本metadata_parser.py引发一个TypeError异常,以便适当处理这种情况。

开发get_tags()函数

get_tags()函数借助 PIL 模块,从我们的 JPEG 图像中解析 EXIF 元数据标签。在第 72 行,我们创建了一个 CSV 输出的标题列表。这个列表包含所有可能在 EXIF 字典中创建的键,并按照我们希望它们在 CSV 文件中显示的顺序排列。由于并非所有 JPEG 图像都包含相同或任何嵌入的 EXIF 标签,我们会遇到某些字典标签比其他字典更多的情况。通过向写入器提供按顺序排列的键列表,我们可以确保字段按照适当的顺序和列顺序写入:

062 def get_tags(filename):
063     """
064     The get_tags function extracts the EXIF metadata from the data
065     object.
066     :param filename: the path and name to the data object.
067     :return: tags and headers, tags is a dictionary containing
068     EXIF metadata and headers are the order of keys for the
069     CSV output.
070     """
071     # Set up CSV headers
072     headers = ['Path', 'Name', 'Size', 'Filesystem CTime',
073     'Filesystem MTime', 'Original Date', 'Digitized Date', 'Make',
074     'Model', 'Software', 'Latitude', 'Latitude Reference',
075     'Longitude', 'Longitude Reference', 'Exif Version', 'Height',
076     'Width', 'Flash', 'Scene Type']

在第 77 行,我们使用Image.open()函数打开 JPEG 文件。再次执行最后一步验证,使用verify()函数。如果文件损坏,它将引发错误。如果没有问题,在第 84 行,我们调用_getexif()函数,该函数返回一个 EXIF 元数据字典:

077     image = Image.open(filename)
078 
079     # Detects if the file is corrupt without decoding the data
080     image.verify()
081 
082     # Descriptions and values of EXIF tags
083     # http://www.exiv2.org/tags.html
084     exif = image._getexif()

在第 86 行,我们创建了一个字典tags,用于存储关于文件对象的元数据。在第 87 行到第 94 行,我们向字典中填充了一些文件系统元数据,如完整路径、名称、大小以及创建和修改时间戳。os.path.basename()函数获取完整路径名并返回文件名。例如,os.path.basename('Users/LPF/Desktop/myfile.txt')将简单地返回myfile.txt

使用getsize()函数将返回文件的字节大小。数字越大,对人类越不直观。我们更习惯于看到带有常见前缀的大小,如 MB、GB 和 TB。convert_size()处理函数正是为了这个目的,使数据对人类分析师更加有用。

在第 91 行和第 93 行,我们将 os.path.getctime() 返回的整数值转换为表示自纪元以来以秒为单位的创建时间。纪元 01/01/1970 00:00:00 可以通过调用 time.gmtime(0) 来确认。我们使用 gmtime() 函数将这些秒数转换为时间结构对象(类似于 datetime)。我们使用 strftime 来将时间对象格式化为我们所需的日期字符串:

086     tags = {}
087     tags['Path'] = filename
088     tags['Name'] = os.path.basename(filename)
089     tags['Size'] = processors.utility.convert_size(
090         os.path.getsize(filename))
091     tags['Filesystem CTime'] = strftime('%m/%d/%Y %H:%M:%S',
092         gmtime(os.path.getctime(filename)))
093     tags['Filesystem MTime'] = strftime('%m/%d/%Y %H:%M:%S',
094         gmtime(os.path.getmtime(filename)))

在第 95 行,我们检查 exif 字典中是否存在任何键。如果存在,我们遍历每个键并检查其值。我们查询的值来自于 www.exiv2.org/tags.html 中描述的 EXIF 标签。EXIF 标签有很多,但我们只查询一些与法医分析相关的标签。

如果 exif 字典中确实存在某个特定标签,那么我们会将该值转移到我们的标签字典中。某些标签需要额外处理,例如时间戳、场景、闪光和 GPS 标签。时间戳标签的显示格式与我们表示其他时间戳的格式不一致。例如,第 99 行的标签 36867 所代表的时间,使用冒号分隔,并且顺序不同:

2015:11:11 10:32:15

在第 100 行,我们使用 strptime 函数将现有的时间字符串转换为 datetime 对象。在接下来的下一行,我们使用 strftime 函数将其转换为我们所需的日期字符串格式:

095     if exif:
096         for tag in exif.keys():
097             if tag == 36864:
098                 tags['Exif Version'] = exif[tag]
099             elif tag == 36867:
100                 dt = datetime.strptime(exif[tag],
101                 '%Y:%m:%d %H:%M:%S')
102                 tags['Original Date'] = dt.strftime(
103                 '%m/%d/%Y %H:%M:%S')
104             elif tag == 36868:
105                 dt = datetime.strptime(exif[tag],
106                 '%Y:%m:%d %H:%M:%S')
107                 tags['Digitized Date'] = dt.strftime(
108                 '%m/%d/%Y %H:%M:%S')

场景标签(41990)和闪光标签(37385)具有整数值,而不是字符串。如前所述,在线文档 (www.exiv2.org/tags.html) 解释了这些整数代表什么。在这两种情况下,我们创建一个字典,包含潜在的整数作为键,以及它们的描述作为值。我们检查标签的值是否是字典中的键。如果存在,我们将描述存储在标签字典中,而不是存储整数。同样,这样做是为了便于分析人员的分析。看到场景或闪光标签的字符串描述比看到代表该描述的数字更有价值:

109             elif tag == 41990:
110                 # Scene tags
111                 # http://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/scenecapturetype.html
112                 scenes = {0: 'Standard', 1: 'Landscape',
113                 2: 'Portrait', 3: 'Night Scene'}
114                 if exif[tag] in scenes:
115                     tags['Scene Type'] = scenes[exif[tag]]
116                 else:
117                     pass
118             elif tag == 37385:
119                 # Flash tags
120                 # http://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/flash.html
121                 flash = {0: 'Flash did not fire',
122                 1: 'Flash fired',
123                 5: 'Strobe return light not detected',
124                 7: 'Strobe return light detected',
125                 9: 'Flash fired, compulsory flash mode',
126                 13: 'Flash fired, compulsory flash mode, return light not detected',
127                 15: 'Flash fired, compulsory flash mode, return light detected',
128                 16: 'Flash did not fire, compulsory flash mode',
129                 24: 'Flash did not fire, auto mode',
130                 25: 'Flash fired, auto mode',
131                 29: 'Flash fired, auto mode, return light not detected',
132                 31: 'Flash fired, auto mode, return light detected',
133                 32: 'No flash function',
134                 65: 'Flash fired, red-eye reduction mode',
135                 69: 'Flash fired, red-eye reduction mode, return light not detected',
136                 71: 'Flash fired, red-eye reduction mode, return light detected',
137                 73: 'Flash fired, compulsory flash mode, red-eye reduction mode',
138                 77: 'Flash fired, compulsory flash mode, red-eye reduction mode, return light not detected',
139                 79: 'Flash fired, compulsory flash mode, red-eye reduction mode, return light detected',
140                 89: 'Flash fired, auto mode, red-eye reduction mode',
141                 93: 'Flash fired, auto mode, return light not detected, red-eye reduction mode',
142                 95: 'Flash fired, auto mode, return light detected, red-eye reduction mode'}
143                 if exif[tag] in flash:
144                     tags['Flash'] = flash[exif[tag]]
145             elif tag == 271:
146                 tags['Make'] = exif[tag]
147             elif tag == 272:
148                 tags['Model'] = exif[tag]
149             elif tag == 305:
150                 tags['Software'] = exif[tag]
151             elif tag == 40962:
152                 tags['Width'] = exif[tag]
153             elif tag == 40963:
154                 tags['Height'] = exif[tag]

最后,在第 155 行,我们查找存储在键 34853 下的 GPS 标签,这些标签是作为嵌套字典存储的。如果纬度和经度标签存在,我们将它们传递给 dms_to_decimal() 函数,以将其转换为更适合 KML 写入器的格式:

155             elif tag == 34853:
156                 for gps in exif[tag]:
157                     if gps == 1:
158                         tags['Latitude Reference'] = exif[tag][gps]
159                     elif gps == 2:
160                         tags['Latitude'] = dms_to_decimal(
161                         exif[tag][gps])
162                     elif gps == 3:
163                         tags['Longitude Reference'] = exif[tag][gps]
164                     elif gps == 4:
165                         tags['Longitude'] = dms_to_decimal(
166                         exif[tag][gps])
167                     else:
168                         pass
169     return tags, headers

添加 dms_to_decimal() 函数

dms_to_decimal() 函数将 GPS 坐标从度分秒格式转换为十进制格式。存在一个简单的公式可以在这两种格式之间进行转换。我们从 EXIF 元数据中提取的 GPS 数据包含三个元组,位于另一个元组内。每个内部元组表示度、分或秒的分子和分母。首先,我们需要将嵌套元组中的度、分、秒的分子与分母分开。下图展示了如何将提取的 GPS 数据转换为十进制格式:

在第 178 行,我们使用列表推导式创建一个包含元组中每个元素第一个元素的列表。然后,我们将这个列表解包为三个元素:degminsec。我们使用的公式依赖于度数值是正数还是负数。

如果 deg 为正,则我们将加上分钟和秒数。我们将秒数除以 360,0000 而不是 3,600,因为最初我们没有将秒数值除以其分母。如果 deg 为负,我们则按如下方式减去分钟和秒数:

172 def dms_to_decimal(dms):
173     """
174     Converts GPS Degree Minute Seconds format to Decimal format.
175     :param dms: The GPS data in Degree Minute Seconds format.
176     :return: The decimal formatted GPS coordinate.
177     """
178     deg, min, sec = [x[0] for x in dms]
179     if deg > 0:
180         return "{0:.5f}".format(deg + (min / 60.) + (
181             sec / 3600000.))
182     else:
183         return "{0:.5f}".format(deg - (min / 60.) - (
184             sec / 3600000.))

解析 ID3 元数据 – id3_parser.py

id3_parser 与我们之前讨论的 exif_parser 类似。第 37 行定义的 id3_parser() 函数检查文件签名,然后调用 get_tags() 函数。get_tags() 函数依赖于 mutagen 模块来解析 MP3 和 ID3 标签:

001 import os
002 from time import gmtime, strftime
003 
004 from mutagen import mp3, id3
005 
006 import processors
..   
037 def id3_parser(): 
... 
059 def get_tags(): 

理解 id3_parser() 函数

该函数与 exif_parser() 函数相同,唯一的区别是用于检查文件头的签名。MP3 格式只有一个文件签名 0x494433,与 JPEG 格式不同。当我们调用 check_header() 函数时,我们需要提供文件、已知签名和要读取的头部字节数。如果签名匹配,我们就会调用并返回 get_tags() 函数的结果,如下所示:

037 def id3_parser(filename):
038     """
039     The id3_parser function confirms the file type and sends it to
040     be processed.
041     :param filename: name of the file potentially containing exif
042     metadata.
043     :return: A dictionary from get_tags, containing the embedded
044     EXIF metadata.
045     """

尽管每个插件中看到相同类型的逻辑可能很无聊,但这大大简化了我们框架的逻辑。在大型框架的场景中,以相同的统一方式创建内容有助于那些维护代码的人保持理智。复制和粘贴现有插件并从中进行工作,通常是一种确保开发方式一致的好方法。请查看以下代码:

047     # MP3 signatures
048     signatures = ['494433']
049     if processors.utility.check_header(
050     filename, signatures, 3) == True:
051         return get_tags(filename)
052     else:
053         print(('File signature doesn't match known '
054         'MP3 signatures.'))
055         raise TypeError(('File signature doesn't match ' 
056         'MP3 object.')) 

重新审视 get_tags() 函数

get_tags() 函数遵循了我们为 EXIF 插件使用的相同逻辑。像所有好的程序员一样,我们复制了那个脚本,并做了一些修改以适应 ID3 元数据。在 get_tags() 函数中,我们首先需要在第 69 行创建我们的 CSV 表头。这些表头代表我们的字典可能拥有的键以及我们希望它们在 CSV 输出中出现的顺序:

059 def get_tags(filename):
060     """
061     The get_tags function extracts the ID3 metadata from the data
062     object.
063     :param filename: the path and name to the data object.
064     :return: tags and headers, tags is a dictionary containing ID3
065     metadata and headers are the order of keys for the CSV output.
066     """
067 
068     # Set up CSV headers
069     header = ['Path', 'Name', 'Size', 'Filesystem CTime',
070     'Filesystem MTime', 'Title', 'Subtitle', 'Artist', 'Album',
071     'Album/Artist', 'Length (Sec)', 'Year', 'Category',
072     'Track Number', 'Comments', 'Publisher', 'Bitrate',
073     'Sample Rate', 'Encoding', 'Channels', 'Audio Layer']

在第 74 行,我们创建了我们的标签字典,并以与 EXIF 插件相同的方式填充一些文件系统元数据,如下所示:

074     tags = {}
075     tags['Path'] = filename
076     tags['Name'] = os.path.basename(filename)
077     tags['Size'] = processors.utility.convert_size(
078         os.path.getsize(filename))
079     tags['Filesystem CTime'] = strftime('%m/%d/%Y %H:%M:%S',
080         gmtime(os.path.getctime(filename)))
081     tags['Filesystem MTime'] = strftime('%m/%d/%Y %H:%M:%S',
082         gmtime(os.path.getmtime(filename)))

Mutagen 有两个类可以用来从 MP3 文件中提取元数据。第一个类 MP3 存储了一些常见的 MP3 文件元数据,例如比特率、声道和时长(秒)。Mutagen 提供了内置函数来访问这些信息。首先,我们需要创建一个 MP3 对象,这可以通过第 85 行使用 mp3.MP3() 函数来完成。接下来,我们可以使用 info.bitrate() 函数,例如,来返回 MP3 文件的比特率。我们将在第 88 行至第 92 行将这些值存储在我们的标签字典中,如下所示:

084     # MP3 Specific metadata
085     audio = mp3.MP3(filename)
086     if 'TENC' in audio.keys():
087         tags['Encoding'] = audio['TENC'][0]
088         tags['Bitrate'] = audio.info.bitrate
089         tags['Channels'] = audio.info.channels
090         tags['Audio Layer'] = audio.info.layer
091         tags['Length (Sec)'] = audio.info.length
092         tags['Sample Rate'] = audio.info.sample_rate 

第二个类 ID3 从 MP3 文件中提取 ID3 标签。我们需要首先使用 id3.ID3() 函数创建一个 ID3 对象。这将返回一个字典,其中 ID3 标签作为键。听起来很熟悉吧?这正是我们在前一个插件中看到的。唯一的区别是,字典中的值以稍有不同的格式存储:

{'TPE1': TPE1(encoding=0, text=[u'The Artist']),...} 

要访问 The Artist 的值,我们需要将其作为列表处理,并指定第零索引处的元素。

以类似的方式,我们查找每个感兴趣的标签,并将第一个元素存储在标签字典的值中。经过这一过程后,我们将标签和头信息对象返回给 id3_parser(),然后它再返回给 metadata_parser.py 脚本:

094     # ID3 embedded metadata tags
095     id = id3.ID3(filename)
096     if 'TPE1' in id.keys():
097         tags['Artist'] = id['TPE1'][0]
098     if 'TRCK' in id.keys():
099         tags['Track Number'] = id['TRCK'][0]
100     if 'TIT3' in id.keys():
101         tags['Subtitle'] = id['TIT3'][0]
102     if 'COMM::eng' in id.keys():
103         tags['Comments'] = id['COMM::eng'][0]
104     if 'TDRC' in id.keys():
105         tags['Year'] = id['TDRC'][0]
106     if 'TALB' in id.keys():
107         tags['Album'] = id['TALB'][0]
108     if 'TIT2' in id.keys():
109         tags['Title'] = id['TIT2'][0]
110     if 'TCON' in id.keys():
111         tags['Category'] = id['TCON'][0]
112     if 'TPE2' in id.keys():
113         tags['Album/Artist'] = id['TPE2'][0]
114     if 'TPUB' in id.keys():
115         tags['Publisher'] = id['TPUB'][0]
116 
117     return tags, header

解析 Office 元数据 – office_parser.py

最后一个插件 office_parser.py 解析 DOCX、PPTX 和 XLSX 文件,提取嵌入的元数据 XML 文件。我们使用标准库中的 zipfile 模块解压并访问 Office 文档的内容。此脚本有两个函数,office_parser()get_tags()

001 import zipfile
002 import os
003 from time import gmtime, strftime
004 
005 from lxml import etree
006 import processors
...   
037 def office_parser(): 
...
059 def get_tags(): 

评估 office_parser() 函数

office_parser() 函数首先检查输入文件是否符合已知的文件签名。所有 Office 文档共享相同的文件签名 0x504b0304140006000,如果输入文件匹配,则由 get_tags() 函数进一步处理,具体如下:

037 def office_parser(filename):
038     """
039     The office_parser function confirms the file type and sends it
040     to be processed.
041     :param filename: name of the file potentially containing
042     embedded metadata.
043     :return: A dictionary from get_tags, containing the embedded
044     metadata.
045     """
046 
047     # DOCX, XLSX, and PPTX signatures
048     signatures = ['504b030414000600']
049     if processors.utility.check_header(
050     filename, signatures, 8) == True:
051         return get_tags(filename)
052     else:
053         print(('File signature doesn't match known '
054         'signatures.'))
055         raise TypeError(('File signature doesn't match ' 
056         'Office objects.'))

最后一次调用 get_tags() 函数

在第 70 行,我们为潜在的字典创建标题列表。第 81 行是所谓的“魔法发生”的地方。内置的 zipfile 库用于读取、写入、追加和列出 ZIP 文件中的内容。在第 81 行,我们创建了一个 ZIP 文件对象,允许我们读取其中包含的文档。见下列代码:

059 def get_tags(filename):
060     """
061     The get_tags function extracts the office metadata from the
062     data object.
063     :param filename: the path and name to the data object.
064     :return: tags and headers, tags is a dictionary containing
065     office metadata and headers are the order of keys for the CSV
066     output.
067     """
068 
069     # Set up CSV headers
070     headers = ['Path', 'Name', 'Size', 'Filesystem CTime',
071     'Filesystem MTime', 'Title', 'Author(s)','Create Date',
072     'Modify Date', 'Last Modified By Date', 'Subject', 'Keywords',
073     'Description', 'Category', 'Status', 'Revision',
074     'Edit Time (Min)', 'Page Count', 'Word Count',
075     'Character Count', 'Line Count',
076     'Paragraph Count', 'Slide Count', 'Note Count',
077     'Hidden Slide Count', 'Company', 'Hyperlink Base']
078 
079     # Create a ZipFile class from the input object
080     # This allows us to read or write to the 'Zip archive'
081     zf = zipfile.ZipFile(filename)

具体来说,在第 86 和 87 行,我们读取核心和应用程序 XML 文件,并将其转换为 XML 元素树。etree.fromstring() 方法允许我们从字符串构建元素树,这是完成本章早些时候描述的相同任务的另一种方法,后者使用了 ElementTree.parse() 函数:

083     # These two XML files contain the embedded metadata of
084     # interest
085     try:
086         core = etree.fromstring(zf.read('docProps/core.xml'))
087         app = etree.fromstring(zf.read('docProps/app.xml'))
088     except KeyError as e:
089         assert Warning(e)
090         return {}, headers

与前面的部分一样,我们创建了标签字典,并用一些文件系统元数据填充它:

092     tags = {}
093     tags['Path'] = filename
094     tags['Name'] = os.path.basename(filename)
095     tags['Size'] = processors.utility.convert_size(
096         os.path.getsize(filename))
097     tags['Filesystem CTime'] = strftime('%m/%d/%Y %H:%M:%S',
098         gmtime(os.path.getctime(filename)))
099     tags['Filesystem MTime'] = strftime('%m/%d/%Y %H:%M:%S',
100         gmtime(os.path.getmtime(filename)))

从第 104 行开始,我们通过使用 iterchildren() 函数迭代核心 XML 文档的子元素。每当我们迭代一个子元素时,我们会在 child.tag 字符串中查找各种关键词。如果找到了,我们将 child.text 字符串与标签字典中的适当键关联起来。

core.xmlapp.xml 文件中的这些标签并不总是存在,这就是为什么我们必须先检查它们是否存在才能提取它们的原因。某些标签,例如修订标签,仅存在于特定的 Office 文档中。我们将在 app.xml 文件中看到更多这种情况:

102     # Core Tags
103 
104     for child in core.iterchildren():
105 
106         if 'title' in child.tag:
107             tags['Title'] = child.text
108         if 'subject' in child.tag:
109             tags['Subject'] = child.text
110         if 'creator' in child.tag:
111             tags['Author(s)'] = child.text
112         if 'keywords' in child.tag:
113             tags['Keywords'] = child.text
114         if 'description' in child.tag:
115             tags['Description'] = child.text
116         if 'lastModifiedBy' in child.tag:
117             tags['Last Modified By Date'] = child.text
118         if 'created' in child.tag:
119             tags['Create Date'] = child.text
120         if 'modified' in child.tag:
121             tags['Modify Date'] = child.text
122         if 'category' in child.tag:
123             tags['Category'] = child.text
124         if 'contentStatus' in child.tag:
125             tags['Status'] = child.text
126 
127         if (filename.endswith('.docx') or
128         filename.endswith('.pptx')):
129             if 'revision' in child.tag:
130                 tags['Revision'] = child.text

app.xml 文件包含特定于给定应用程序的元数据。在第 133 行,当我们遍历元素树的子元素时,我们仅检查特定扩展名的标签。

例如,DOCX 文件包含页面和行数的元数据,这对 PPTX 和 XLSX 文件没有意义。因此,我们根据文件扩展名来区分我们需要查找的标签。TotalTime 标签特别有用,它表示编辑文档所花费的时间(以分钟为单位)。请参见以下代码:

132     # App Tags
133     for child in app.iterchildren():
134 
135         if filename.endswith('.docx'):
136             if 'TotalTime' in child.tag:
137                 tags['Edit Time (Min)'] = child.text
138             if 'Pages' in child.tag:
139                 tags['Page Count'] = child.text
140             if 'Words' in child.tag:
141                 tags['Word Count'] = child.text
142             if 'Characters' in child.tag:
143                 tags['Character Count'] = child.text
144             if 'Lines' in child.tag:
145                 tags['Line Count'] = child.text
146             if 'Paragraphs' in child.tag:
147                 tags['Paragraph Count'] = child.text
148             if 'Company' in child.tag:
149                 tags['Company'] = child.text
150             if 'HyperlinkBase' in child.tag:
151                 tags['Hyperlink Base'] = child.text
152 
153         elif filename.endswith('.pptx'):
154             if 'TotalTime' in child.tag:
155                 tags['Edit Time (Min)'] = child.text
156             if 'Words' in child.tag:
157                 tags['Word Count'] = child.text
158             if 'Paragraphs' in child.tag:
159                 tags['Paragraph Count'] = child.text
160             if 'Slides' in child.tag:
161                 tags['Slide Count'] = child.text
162             if 'Notes' in child.tag:
163                 tags['Note Count'] = child.text
164             if 'HiddenSlides' in child.tag:
165                 tags['Hidden Slide Count'] = child.text
166             if 'Company' in child.tag:
167                 tags['Company'] = child.text
168             if 'HyperlinkBase' in child.tag:
169                 tags['Hyperlink Base'] = child.text
170         else:
171             if 'Company' in child.tag:
172                 tags['Company'] = child.text
173             if 'HyperlinkBase' in child.tag:
174                 tags['Hyperlink Base'] = child.text
175 
176     return tags, headers

接下来是我们的写入器

在 writers 目录下,我们有两个脚本:csv_writer.pykml_writer.py。这两个写入器根据在 metadata_parser.py 框架中处理的数据类型来调用。

编写电子表格 – csv_writer.py

在本章中,我们将使用 csv.DictWriter 代替 csv.writer,就像在 第五章,Python 中的数据库 和 第六章,从二进制文件中提取文档 中做的那样。提醒一下,区别在于 DictWriter 将字典对象写入 CSV 文件,而 csv.writer 函数更适合写入列表。

csv.DictWriter 的优点在于,在创建写入器对象时,它需要一个参数 fieldnamesfieldnames 参数应该是一个列表,表示输出列的期望顺序。此外,所有可能的键必须包含在 fieldnames 列表中。如果某个键存在,但不在列表中,则会引发异常。另一方面,如果某个键不在字典中,但在 fieldnames 列表中,那么该列将被跳过:

001 from __future__ import print_function
002 import sys
003 import os
004 if sys.version_info[0] == 2:
005     import unicodecsv as csv
006 elif sys.version_info[0] == 3:
007     import csv
008 import logging
...
040 def csv_writer(output_data, headers, output_dir, output_name):
041     """
042     The csv_writer function uses the csv DictWriter module to
043     write the list of dictionaries. The DictWriter can take
044     a fieldnames argument, as a list, which represents the
045     desired order of columns.
046     :param output_data: The list of dictionaries containing
047     embedded metadata.
048     :param headers: A list of keys in the dictionary that
049     represent the desired order of columns in the output.
050     :param output_dir: The folder to write the output CSV to.
051     :param output_name: The name of the output CSV.
052     :return:
053     """
054     msg = 'Writing ' + output_name + ' CSV output.'
055     print('[+]', msg)
056     logging.info(msg)
057
058     out_file = os.path.join(output_dir, output_name)
059 
060     if sys.version_info[0] == 2:
061         csvfile = open(out_file, "wb")
062     elif sys.version_info[0] == 3:
063         csvfile = open(out_file, "w", newline='',
064             encoding='utf-8')

在第 69 行,我们创建了 csv.DictWriter 函数,传入输出文件和作为 fieldnames 列表的头部信息,这个列表来自我们的插件函数。为了写入 CSV 文件的头部,我们可以简单地调用 writeheader 函数,它使用 fieldnames 列表作为头部信息。最后,我们需要遍历元数据容器列表中的每个字典,并使用第 76 行的 writerow() 函数写入它们,如下所示:

066     with csvfile:
067         # We use DictWriter instead of Writer to write
068         # dictionaries to CSV.
069         writer = csv.DictWriter(csvfile, fieldnames=headers)
070 
071         # Writerheader writes the header based on the supplied
072         # headers object
073         writer.writeheader()
074         for dictionary in output_data:
075             if dictionary:
076                 writer.writerow(dictionary)

使用 Google Earth 绘制 GPS 数据 – kml_writer.py

kml_writer.py 脚本使用 simplekml 模块(版本 1.3.1)快速生成我们的 KML 输出。此模块的完整文档可以在 simplekml.com 找到。可以使用 pip 安装此模块:

pip install simplekml==1.3.1

使用这个模块,我们可以通过三行代码创建并添加地理标记点并保存 KML 文件:

001 from __future__ import print_function
002 import os
003 import logging
004 
005 import simplekml
...
036 def kml_writer(output_data, output_dir, output_name):
037     """
038     The kml_writer function writes JPEG and TIFF EXIF GPS data to
039     a Google Earth KML file. This file can be opened
040     in Google Earth and will use the GPS coordinates to create
041     'pins' on the map of the taken photo's location.
042     :param output_data: The embedded EXIF metadata to be written
043     :param output_dir: The output directory to write the KML file.
044     :param output_name: The name of the output KML file.
045     :return:
046     """

在第 51 行,我们使用 simplekml.Kml() 调用创建了 KML 对象。此函数接受一个可选的关键字参数 name,表示 KML 文件的名称。第 52-71 行检查是否存在原始的日期键,并准备将我们的 GPS 点添加到 KML 对象中:

047     msg = 'Writing ' + output_name + ' KML output.'
048     print('[+]', msg)
049     logging.info(msg)
050     # Instantiate a Kml object and pass along the output filename
051     kml = simplekml.Kml(name=output_name)
052     for exif in output_data:
053         if ('Latitude' in exif.keys() and
054                 'Latitude Reference' in exif.keys() and
055                 'Longitude Reference' in exif.keys() and
056                 'Longitude' in exif.keys()):
057 
058             if 'Original Date' in exif.keys():
059                 dt = exif['Original Date']
060             else:
061                 dt = 'N/A'
062 
063             if exif['Latitude Reference'] == 'S':
064                 latitude = '-' + exif['Latitude']
065             else:
066                 latitude = exif['Latitude']
067 
068             if exif['Longitude Reference'] == 'W':
069                 longitude = '-' + exif['Longitude']
070             else:
071                 longitude = exif['Longitude']

我们的 GPS 坐标来自exif_parser.py脚本,格式为十进制。然而,在这个脚本中,我们没有考虑参考点的问题。参考点决定了 GPS 坐标的符号。南纬参考会使纬度为负数,同样,西经参考会使经度为负数。

一旦这些问题解决,我们就可以创建带有地理标签的点,传入点的名称、描述和坐标。如果纬度和经度的 EXIF 标签检查返回False,那么第 76 行和 77 行的else语句会被执行。虽然这两行代码可以省略,但它们应该被保留下来,作为实现逻辑的提示。创建所有点之后,我们可以通过调用kml.save()函数,传入所需的输出路径和文件名,保存 KML 文件。以下是第 73 行到 78 行的代码:

073             kml.newpoint(name=exif['Name'],
074             description='Originally Created: ' + dt,
075             coords=[(longitude, latitude)])
076         else:
077             pass
078     kml.save(os.path.join(output_dir, output_name))

使用处理器支持我们的框架

processors目录包含一个脚本,utility.py。这个脚本包含一些辅助函数,当前所有插件都在使用这些函数。我们将这些函数集中在一个脚本中,而不是为每个插件分别编写。

创建框架级别的工具函数 – utility.py

这个脚本包含两个函数,check_header()convert_size()。前者执行文件签名匹配,而后者将表示文件字节大小的整数转换为人类可读的格式,如下所示:

001 import binascii
002 import logging
...
033 def check_header(filename, headers, size):
034     """
035     The check_header function reads a supplied size of the file
036     and checks against known signatures to determine the file
037     type.
038     :param filename: The name of the file.
039     :param headers: A list of known file signatures for the
040     file type(s).
041     :param size: The amount of data to read from the file for
042     signature verification.
043     :return: Boolean, True if the signatures match;
044     otherwise, False.
045     """

check_header()函数定义在第 33 行,它接受文件名、已知签名的列表以及读取文件的字节数作为参数。在第 46 行,我们打开输入文件,然后根据传入的大小参数读取前几个字节。在第 48 行,我们将数据的 ASCII 表示转换为十六进制字符串。在第 49 行,我们遍历每个已知签名并将其与hex_header进行比较。如果匹配,我们返回True,否则返回False并记录警告,具体如下:

046     with open(filename, 'rb') as infile:
047         header = infile.read(size)
048         hex_header = binascii.hexlify(header).decode('utf-8')
049         for signature in headers:
050             if hex_header == signature:
051                 return True
052             else:
053                 pass
054         logging.warn(('The signature for {} ({}) doesn't match '
055             'known signatures: {}').format(
056                 filename, hex_header, headers))
057         return False

convert_size()函数是一个有用的工具函数,它将字节大小的整数转换为人类可读的格式。在第 66 行,我们创建了一个潜在前缀的列表。注意,我们假设用户在未来几年内不会遇到需要TB前缀的文件:

059 def convert_size(size):
060     """
061     The convert_size function converts an integer representing
062     bytes into a human-readable format.
063     :param size: The size in bytes of a file
064     :return: The human-readable size.
065     """
066     sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']

我们使用while循环不断将大小除以 1024,直到结果小于 1024 为止。每次除法操作后,我们将索引加一。当大小小于 1024 时,索引指向size列表中适当前缀的位置。

在第 71 行,我们使用字符串格式化函数format,以所需的方式返回浮点数和前缀。{:.2f}告诉格式化函数,第一个参数是浮点数,并且我们希望四舍五入到小数点后两位:

067     index = 0
068     while size > 1024:
069         size /= 1024.
070         index += 1
071     return '{:.2f} {}'.format(size, sizes[index])

如下图所示,我们可以在目录中运行框架,并生成一个输出报告供我们审查。在这个例子中,我们对一个包含地理位置信息的图像文件夹运行了代码。

我们的输出报告如下所示,尽管我们已将列进行换行,以确保其适合一页。

我们的脚本还生成了可在 Google Earth 中查看的 KML 输出,如下所示:

框架总结

框架在组织多个脚本集合方面非常有用,可以将它们集中在一个地方。使用框架也会面临一些挑战,主要是在项目发展过程中保持标准化的操作。我们的 metadata_parser.py 框架处于第一版本,如果我们继续开发它,可能会发现当前的设置只适用于较小的规模。

例如,随着我们实现越来越多的功能,我们可能会意识到框架的效率开始下降。到那时,我们需要回到设计阶段,确定是否正在使用正确的数据类型,或者是否选择了最佳的方法来编写插件和编写器。

额外挑战

我们在决定本章的两个主要挑战之间遇到了困难。我们可以添加额外的插件,或者完善当前已存在的功能。在实际开发中,您的时间将花费在平衡这两个目标上,随着框架的不断发展。对于本章,我们提出了一个基于递归的挑战。

请记住,在解释 Office 2007 文档格式时,我们曾确定附加的媒体文件存储在文档的媒体子目录中。在当前版本中,当遇到一个 Office 文档时,那个媒体子目录(它可能包含嵌入的元数据文件的副本)不会被处理。这里的挑战是将新发现的文件添加到当前的文件列表中。

我们可能通过将新发现的文件列表返回到 metadata_parser.py 来解决这个问题。另一种方法是在 office_parser.py 脚本中检查文件扩展名,并立即将它们传递给适当的插件。后者方法虽然更容易实现,但并不理想,因为它从 metadata_parser.py 脚本中移除了一些控制权。最终,开发人员需要确定完成此挑战的最有效和最合理的方法。

除此之外,还可以取得一些其他的效率成就。例如,我们不需要每次调用插件时都返回插件的头信息。由于头信息始终相同,我们只需要创建/返回一次即可。或者,该框架受到它支持的编写器类型的限制。可以考虑为 Excel 电子表格添加一个编写器,以创建更有用的报告。

总结

在本章中,你学习了如何处理一些流行的嵌入式元数据格式,执行基本的文件签名分析,并在 Python 中创建框架。随着程序复杂性的增加,框架成为一种常见的编程解决方案。这个项目的代码可以从 GitHub 或 Packt 下载,如在前言中所述。

在下一章中,你将学习如何使用 Python 中的 TkInter 模块开发一个基本的图形用户界面(GUI)。这个 GUI 将负责将各种类型的时间戳转换为人类可读的格式。

第九章:揭开时间的面纱

时间戳以多种格式存储,这些格式通常是由负责生成它们的操作系统或应用程序所独有的。在取证中,转换这些时间戳可能是调查的重要部分。

作为示例,我们可以汇总转换后的时间戳,创建一个综合事件时间线,确定跨平台的动作顺序。这种时间评估有助于我们判断行动是否在定义的范围内,并为我们提供关于两个事件之间关系的洞察。

为了解读这些格式化的时间戳,我们可以使用工具来解释原始值,并将其转换为人类可读的时间。大多数取证工具在解析已知的伪造数据结构时都会默默地执行此操作(类似于我们的脚本经常解析 Unix 时间戳的方式)。

在某些情况下,我们没有能够正确或统一处理特定时间戳的工具,必须依靠我们的聪明才智来解读时间值。

我们将使用常见的库来解析用户输入的时间戳,并将其转换为所需的格式。利用 TkInter 库,我们将设计一个图形用户界面GUI),用户可以通过该界面展示日期信息。我们将使用 Python 类来更好地组织我们的 GUI,并处理诸如用户点击 GUI 上按钮等事件。

在本章中,我们将构建一个图形界面,借助以下主题将时间戳在机器可读格式和人类可读格式之间进行转换。

  • 在 Python 中创建跨平台的图形用户界面

  • 常见原始时间戳值在机器可读格式和人类可读格式之间的转换。

  • Python 类设计与实现的基础,允许灵活地添加更多时间格式。

本章代码在 Python 2.7.15 和 Python 3.7.1 环境下开发并测试。

关于时间戳

时间戳格式通常归结为两个组成部分:一个参考点和用来表示从该参考点起已过的时间量的约定或算法。大多数时间戳都有相应的文档,可以帮助我们确定将原始时间数据转换为人类可读时间戳的最佳方式。

如介绍中所述,时间戳格式种类繁多,其中一些我们已经遇到过,如 Unix 时间和 Windows FILETIME。这使得转换过程变得更加复杂,因为我们开发的取证脚本可能需要准备好处理多种时间格式。

Python 附带了几个标准库,可以帮助我们转换时间戳。我们以前使用过 datetime 模块来正确处理时间值并将其存储在 Python 对象中。我们将介绍两个新库——time(它是标准库的一部分)和第三方库 dateutil

我们可以通过运行pip install python-dateutil==2.7.5来下载并安装dateutil(版本 2.7.5)。这个库将用于将字符串解析为datetime对象。dateutil库中的parser()方法接受一个字符串作为输入,并尝试自动将其转换为datetime对象。与strptime()方法不同,后者需要显式声明时间戳的格式,dateutil.parser可以将不同格式的时间戳转换为datetime对象,而无需开发者输入。

一个示例字符串可以是2015 年 12 月 8 日星期二 18:0412/08/2015 18:04,这两者都会被parser()方法转换成相同的datetime对象。下面的代码块演示了这一功能,适用于 Python 2.7.15 和 Python 3.7.1:

>>> from dateutil import parser as duparser
>>> d = duparser.parse('Tuesday December 8th, 2015 at 6:04 PM')
>>> d.isoformat()
'2015-12-08T18:04:00'
>>> d2 = duparser.parse('12/08/2015 18:04')
>>> d2.isoformat()
'2015-12-08T18:04:00' 

在代码块的第一行,我们导入dateutil解析器并创建一个别名duparser,因为parser这个函数名是通用术语,可能会与其他变量或函数冲突。然后,我们调用parse()方法并传递一个表示时间戳的字符串。将解析后的值赋给变量d,我们使用isoformat()函数查看其 ISO 格式。接着,我们用第二个格式不同的时间戳重复这些步骤,观察到相同的结果。

请参考文档,获取有关parse()方法的更多细节,访问 dateutil.readthedocs.org/en/latest/parser.html

什么是纪元?

纪元是一个时间点,被标记为给定时间格式的起始时间,通常用作跟踪时间流逝的参考点。尽管我们在这里省略了任何与时间度量相关的哲学讨论,但我们将在本章中使用并参考纪元作为给定时间格式的起点。

大多数时间戳关联着两个主要的纪元时间:1970-01-01 00:00:001601-01-01 00:00:00。第一个纪元从 1970 年开始,传统上被称为 POSIX 时间,因为它是 Unix 及类 Unix 系统中常见的时间戳。在大多数 Unix 系统中,时间戳是从 POSIX 时间开始计算的秒数。这个概念也延伸到了某些应用中,存在使用从同一纪元起的毫秒数的变种。

第二个纪元,基于 1601 年,通常出现在基于 Windows 的系统中,之所以使用这个时间点,是因为它是格里高利历中第一个包含闰年的 400 年周期的起始点。1601 年开始的 400 年周期是第一个存在数字文件的周期,因此这个值成为另一个常见的纪元。在 Windows 系统中,常见的时间戳是从这个纪元起计算的 100 纳秒时间段的计数。这个值通常以十六进制或整数形式存储。

下一个代码块描述了将不同纪元的时间戳进行转换的过程。正如我们在前面的章节中所看到的,我们可以使用datetime模块的fromtimestamp()方法来转换 Unix 时间戳,因为它使用的是 1970 年纪元。对于基于 1601 年的时间戳,我们需要在使用fromtimestamp()函数之前先进行转换。

为了简化这个转换过程,我们来计算这两个日期之间的常数,并利用这个常数在两个纪元之间进行转换。在第一行,我们导入datetime库。接下来,我们将两个时间戳相减,以确定1970-01-011601-01-01之间的时间差。这个语句生成一个datetime.timedelta对象,存储两个值之间以天、秒和微秒计的时间差。

在这个例子中,1970 年和 1601 年时间戳之间的差值恰好是 134,774 天。我们需要将这个差值转换成微秒时间戳,以便能够在转换中准确地使用它。因此,在第三行中,我们将天数(time_diff.days)转换为微秒,通过将其乘以8640000000024 小时 x 60 分钟 x 60 秒 x 1,000,000 微秒的积)并打印常数值11644473600000000。请查看以下代码:

>>> import datetime
>>> time_diff = datetime.datetime(1970,1,1) - datetime.datetime(1601,1,1)
>>> print (time_diff.days * 86400000000)
11644473600000000 

使用这个值,我们就可以在这两个纪元之间转换时间戳,并正确处理基于 1601 年的纪元时间戳。

使用 GUI

在本章中,我们将使用 GUI 将时间戳在原始格式和人类可读格式之间进行转换。时间戳转换是一个很好的借口来探索编程 GUI,因为它提供了一个解决常见调查活动的方案。通过使用 GUI,我们大大提高了脚本的可用性,尤其是对于那些被命令提示符及其各种参数和开关所吓退的用户。

Python 中有许多 GUI 开发的选项,但在本章中,我们将重点介绍 TkInter。TkInter 库是一个跨平台的 Python GUI 开发库,它与操作系统的Tcl/Tk库结合使用,支持 Windows、macOS 以及多个 Linux 平台。

这个跨平台框架允许我们构建一个平台无关的通用界面。虽然 TkInter 的 GUI 界面可能看起来不那么现代,但它们让我们能够以相对简单的方式快速构建一个功能性界面进行交互。

在这里,我们只会介绍 TkInter GUI 开发的基础知识。有关更详细的信息,可以通过在线资源或专门讲解 TkInter 开发过程和特定功能的书籍找到。在 www.python.org/ 网站上有一个详细的资源列表,可以学习和使用 TkInter,更多信息请见 wiki.python.org/moin/TkInter

TkInter 对象的基础知识

我们将使用 TkInter 的几个不同功能来展示我们的 GUI。每个 TkInter GUI 需要的第一个元素是根窗口,也叫做主窗口,它作为我们添加到 GUI 中的任何其他元素的顶级父窗口。在这个窗口中,我们将结合多个对象来允许用户与我们的界面进行互动,例如LabelEntryButton等元素:

  • Label对象允许我们在界面上放置无法编辑的文本标签。这使得我们可以添加标题或为指示应写入或显示到字段中的对象提供描述。

  • Entry对象允许用户输入一行文本作为应用程序的输入。

  • Button对象允许我们在按下时执行命令。在我们的例子中,按钮将调用适当的函数来转换特定格式的时间戳,并使用返回值更新界面。

使用这三个功能,我们已经介绍了界面所需的所有 GUI 元素。还有更多可用的对象,详细信息可以在 TkInter 文档中找到,网址为docs.python.org/3/library/tkinter.html

我们将以兼容 Python 2 和 Python 3 的方式编写代码。因此,在 Python 2(例如,版本 2.7.15)中,我们将按如下方式导入Tkinter

>>> from Tkinter import *

对于 Python 3,例如版本 3.7.1,我们将按如下方式导入:

>>> from tkinter import *

为了简化这个过程,我们可以使用sys模块来检测 Python 版本并导入相应的模块,如下所示:

import sys
if sys.version_info[0] == 2:
    from Tkinter import *
elif sys.version_info[0] == 3:
    from tkinter import *

实现 TkInter GUI

本节展示了一个创建 TkInter GUI 的简单示例。在前七行中,我们导入了创建界面所需的两个模块。这种导入方式虽然复杂,但可以让我们以 Python 2 或 Python 3 特定的方式导入这两个模块。

第一个模块导入了所有 TkInter GUI 设计所需的默认对象。ttk模块导入了主题 TkInter 包,根据主机操作系统应用额外的界面格式化,是改善界面外观的简单方法。在最后一行,我们创建了根窗口。

当在 Python 解释器中输入时,执行最后一行应显示一个空白的 200 像素×200 像素的方形窗口,位于屏幕的左上角。尺寸和位置是默认设置,可以修改。请参见以下代码块:

>>> import sys
>>> if sys.version_info[0] == 2:
>>>     from Tkinter import *
>>>     import ttk
>>> elif sys.version_info[0] == 3:
>>>     from tkinter import *
>>>     import tkinter.ttk as ttk
>>> root = Tk() 

以下截图展示了在 macOS 系统上执行代码块时创建的 TkInter 根窗口:

创建根窗口后,我们可以开始向界面添加元素。一个好的开始元素是标签。在后面提到的代码块中,我们将从主题ttk包中添加一个标签到窗口:

>>> first_label = ttk.Label(root, text="Hello World")

Label 参数需要两个参数:要显示的父窗口和显示的文本。可以为标签分配其他属性,如字体和文本大小。

请注意,在执行代码块的第一行后,窗口不会更新。相反,我们必须指定如何在窗口内显示对象,使用其中一个可用的布局管理器。

TkInter 使用布局管理器来确定对象在窗口中的位置。常见的布局管理器有三种:gridpackplace

  • grid 布局管理器根据行和列的规范来放置元素。

  • pack 布局管理器更简单,它将元素彼此放置,无论是垂直还是水平,具体取决于指定的配置。

  • 最后,place 布局管理器使用 xy 坐标来放置元素,并且需要最多的维护和设计工作。

对于此示例,我们选择使用 pack 方法,如代码块的第二行所示。一旦我们描述了要使用的布局管理器,界面会更新,并显示标签:

>>> first_label.pack()

以下截图显示了将标签添加到我们的 GUI 中的效果:

如前面的截图所示,根窗口已经缩小以适应其元素的大小。此时,我们可以通过拖动边缘来调整窗口的大小,缩小或增大主窗口的尺寸。

让我们在 Label 对象周围添加一些空间。我们可以通过两种不同的技术来实现。第一种方法是在 Label 对象周围添加内边距,使用 .config() 方法。为了添加内边距,我们必须为 xy 轴提供一个像素值的元组。

在此示例中,我们在 xy 轴上都添加了 10 像素的内边距。当执行以下行时,它会在 GUI 中自动更新,因为布局管理器已经配置好:

>>> first_label.config(padding=(10,10)) 

内边距显示在以下截图中:

这仅仅是为标签本身添加了内边距,而不是整个根窗口。要更改根窗口的尺寸,我们需要调用 geometry() 方法,并提供宽度、高度、距离屏幕左侧的距离和距离屏幕顶部的距离。

在以下示例中,我们将设置宽度为 200 像素,高度为 100 像素,距离屏幕左侧 30 像素,距离屏幕顶部 60 像素的偏移量:

>>> root.geometry('200x100+30+60') 

新的 GUI 分辨率显示在以下截图中:

根据你的操作系统,GUI 中的默认颜色可能会因可用的主题包而有所不同。

让我们介绍一下其他两个我们将使用的 GUI 元素:EntryButton。现在我们将初始化Entry对象,它允许用户输入文本,程序可以捕获并使用这些文本。在第一行中,我们初始化了一个StringVar()变量,它将与Entry对象一起使用。与之前的脚本不同,我们需要设置特定的变量,以响应 GUI 接口的事件驱动特性:

>>> text = StringVar()

TkInter 支持多种特殊变量,例如用于字符串的StringVar()函数、用于布尔值的BooleanVar()、用于浮动数值的DoubleVar(),以及用于整数的IntVar()。每个这些对象都允许通过set()方法设置值,并通过get()方法获取值。上述代码展示了StringVar()的初始化,将其设置为默认值,并将其分配给创建的Entry元素,然后将其打包到根窗口中。最后,我们可以通过get()方法获取用户输入:

>>> text.set("Enter Text Here")
>>> text_entry = ttk.Entry(root, textvariable=text)
>>> text_entry.pack()
>>> text.get()
'Hello World!' 

以下两个连续的截图展示了我们实现的新代码块对 GUI 的更新:

上面的截图展示了Entry框中的默认文本,而下面的截图展示了修改值后的样子:

请注意,我们在执行text.get()方法之前,将Hello World!写入了Entry对象中。

Button对象用于在点击按钮时触发事件。为了启动操作,我们需要调用一个函数。

在下一个示例中,我们定义了clicked()函数,它会打印一个字符串,如以下代码块所示。在这个函数之后,我们使用ttk主题包定义了按钮,将按钮文本设置为Go,并将函数名作为command参数。将按钮打包到根窗口后,我们可以点击它,并在终端中看到打印的语句,如下面代码块的最后一行所示。虽然这个功能不是非常实用,但它演示了按钮如何调用一个操作。我们的脚本将进一步展示Button对象及其命令参数的用途:

>>> def clicked(): 
...     print "The button was clicked!" 
...  
>>> go = ttk.Button(root, text="Go", command=clicked) 
>>> go.pack() 
The button was clicked! 

添加此按钮的效果如下截图所示:

使用框架对象

TkInter 提供了另一个我们将使用的对象,名为frame。框架是我们可以放置信息的容器,它提供了额外的组织结构。在我们的最终界面中,将有两个框架。第一个是输入框架,包含所有用户交互的对象;第二个是输出框架,显示脚本处理的所有信息。在本章的最终代码中,两个frame对象将是根窗口的子对象,并充当其中LabelEntry和/或Button对象的父对象。

frame对象的另一个好处是,每个框架可以使用自己的几何管理器。由于每个父对象只能使用一个几何管理器,这使得我们可以在整个 GUI 中使用多个不同的管理器。

在我们的脚本中,我们将使用pack()管理器来组织根窗口中的框架,并使用grid()管理器来组织每个框架内的元素。

在 TkInter 中使用类

在本书中我们尚未直接使用类;然而,类是设计 GUI 的首选方式。类允许我们构建一个可以包含函数和属性的对象。事实上,我们经常在不自觉中使用类。我们熟悉的对象,如datetime对象,就是包含函数和属性的类。

尽管本书中类的内容并不多,但它们可能会让新开发者感到困惑,但对于更高级的脚本来说,它们是推荐的。我们将在本章简要介绍类,并建议随着你对 Python 的理解加深,进一步研究类。本章中涉及的类内容仅限于 GUI 示例。

类的定义语法与函数类似,我们使用class关键字代替def。一旦定义类,我们将函数嵌套在constructor类中,以使这些函数可以通过class对象调用。这些嵌套的函数称为方法,与我们从库中调用的方法是同义的。方法允许我们像函数一样执行代码。到目前为止,我们主要是将方法和函数交替使用。对此我们表示歉意,这样做是为了避免让你和我们都厌烦重复相同的词汇。

到目前为止,类看起来不过是函数的集合。那么到底是什么原因呢?类的真正价值在于,我们可以创建同一个类的多个实例,并为每个实例分配不同的值。进一步来说,我们可以对每个实例单独运行预定义的方法。举个例子,假设我们有一个时间类,其中每个时间都有一个关联的datetime变量。我们可能决定将其中一些转换为 UTC 时间,而将其他保持在当前时区。这种隔离性使得在类中设计代码变得非常有价值。

类在 GUI 设计中非常有用,因为它们允许我们在函数之间传递值,而不需要额外的重复参数。这是通过self关键字实现的,它允许我们在类中指定可在类实例及其所有方法中使用的值。

在下一个示例中,我们创建了一个名为SampleClass的类,它继承自object。这是类定义的基本设置,虽然还有更多可用的参数,但我们将在本章中专注于基础内容。在第 2 行,我们定义了第一个名为__init__()的方法,这是一个特殊的函数。你可能会注意到,它有双前导和尾部下划线,类似于我们在脚本中创建的if __name__ == '__main__'语句。如果类中存在__init__()方法,它将在类初始化时执行。

在示例中,我们定义了__init__()方法,传递selfinit_cost作为参数。self参数必须是任何方法的第一个参数,并允许我们引用存储在self关键字下的值。接下来,init_cost是一个变量,必须在类首次被用户调用时设置。在第 3 行,我们将用户提供的init_cost值赋给self.cost。将参数(除了self)赋值为类实例化时的类变量是一种惯例。在第 4 行,我们定义了第二个方法number_of_nickels(),并将self值作为其唯一参数。在第 5 行,我们通过返回self.cost * 20的整数来完成类的定义,如下所示:

>>> class SampleClass(object):
...     def __init__(self, init_cost):
...         self.cost = init_cost
...     def number_of_nickels(self):
...         return int(self.cost * 20)
... 

接下来,我们将s1初始化为SampleClass类的一个实例,并将初始值设置为24.60。然后,我们通过使用s1.cost属性来调用它的值。s1变量引用了SampleClass的一个实例,并授予我们访问类内方法和值的权限。我们在s1上调用number_of_nickels()方法,并将其存储的值更改为15,这会更新number_of_nickels()方法的结果。接着,我们定义了s2并为其分配了不同的值。即使我们运行相同的方法,我们也只能查看与特定类实例相关的数据:

>>> s1 = SampleClass(24.60)
>>> s1.cost
24.6
>>> s1.number_of_nickels()
492
>>> s1.cost = 15
>>> s1.number_of_nickels()
300
>>> s2 = SampleClass(10)
>>> s2.number_of_nickels()
200 

开发日期解码器 GUI - date_decoder.py

该脚本已在 Python 2.7.15 和 3.7.1 版本中进行了测试,并使用了python-dateutil(版本 2.7.5)第三方库,可以通过以下命令使用pip进行安装:

  • pip install python-dateutil==2.7.5

在介绍完时间戳、GUI 开发和 Python 类之后,让我们开始开发date_decoder.py脚本。我们将设计一个具有两个主要功能的 GUI,供最终用户进行交互。

首先,GUI 允许用户输入来自文物的时间戳原生格式,并将其转换为人类可读的时间。第二个功能允许用户输入人类可读的时间戳,并选择一个选项将其转换为相应的机器时间。为了构建这个功能,我们将使用一个输入框、几个标签以及不同类型的按钮供用户与界面进行交互。

所有通过此代码处理的日期都假定使用本地机器时间作为时区。请确保将所有时间戳来源转换为统一的时区,以简化分析。

与其他脚本一样,代码从导入语句开始,后跟作者信息。在导入datetimelogging之后,我们根据 Python 2 和 Python 3 的条件导入 TkInter 和主题资源模块。然后我们导入dateutil,正如之前讨论的,它将处理日期解析和转换操作。接着我们设置脚本的许可协议、文档和日志记录值:

001 """Example usage of Tkinter to convert dates."""
002 import datetime
003 import logging
004 import sys
005 if sys.version_info[0] == 2:
006     from Tkinter import *
007     import ttk
008 elif sys.version_info[0] == 3:
009     from tkinter import *
010     import tkinter.ttk as ttk
011 from dateutil import parser as duparser
...
042 __authors__ = ["Chapin Bryce", "Preston Miller"]
043 __date__ = 20181027
044 __description__ = '''This script uses a GUI to show date values
045     interpreted by common timestamp formats'''
046 logger = logging.getLogger(__name__)

我们首先定义了 GUI 的属性,如窗口的尺寸、背景和标题,并创建了根窗口。在配置完 GUI 的基础设置后,我们用之前讨论的控件填充 GUI。界面设计完成后,我们创建处理事件的方法,如转换时间戳并将结果显示在 GUI 中。我们没有使用通常的main()函数,而是创建了这个类的实例,它将在执行时启动 GUI 窗口。

代码从声明DateDecoder类及其__init__()方法开始。该方法不需要传递任何参数,因为我们将通过 GUI 接收所有输入值和设置。接下来定义的函数是第 74 行的run()控制器。这个控制器调用设计 GUI 的函数,并启动该 GUI:

049 class DateDecoder(object):
...
054     def __init__():
...
074     def run():

为了以结构化的方式展示 GUI,我们需要将 GUI 分成多个功能单元。在第 84 和 119 行的方法中,我们创建了组成 GUI 的输入和输出框架。这些框架包含与其功能相关的控件,并且由自己的几何布局控制:

084     def build_input_frame():
...
119     def build_output_frame():

界面设计完成后,我们可以专注于处理逻辑操作和按钮点击事件的功能。convert() 方法用于调用时间戳转换器,将值解析为日期。

这些转换器是针对每个受支持的时间戳定义的,定义在第 175、203 和 239 行。我们的最后一个类方法output()用于更新界面。这个方法可能会让人误解,因为我们脚本中的之前的output()函数通常会创建某种报告。在这个例子中,我们将使用这个output()方法来更新 GUI,向用户展示信息,以一种有组织且有帮助的方式:

151     def convert():
...
175     def convert_unix_seconds():
...
203     def convert_win_filetime_64():
...
239     def convert_chrome_time():
...
183     def output():

与前几章不同,这个函数不需要处理命令行参数。然而,我们仍然设置了日志记录,并实例化并运行我们的图形用户界面(GUI)。此外,从第 202 行开始,我们使用基本的日志记录约定初始化一个日志记录器。由于没有传递命令行参数给该脚本,我们将日志文件路径硬编码。在第 211 和 212 行,初始化类并调用run()方法,以便创建并显示我们的 GUI,如下所示:

286 if __name__ == '__main__':
287     """
288     This statement is used to initialize the GUI. No
289     arguments needed as it's a graphic interface
290     """
291     # Initialize Logging
292     log_path = 'date_decoder.log'
293 
294     logger.setLevel(logging.DEBUG)
295     msg_fmt = logging.Formatter("%(asctime)-15s %(funcName)-20s"
296         "%(levelname)-8s %(message)s")
297     fhndl = logging.FileHandler(log_path, mode='a')
298     fhndl.setFormatter(fmt=msg_fmt)
299     logger.addHandler(fhndl)
300 
301     logger.info('Starting Date Decoder v. {}'.format(__date__))
302     logger.debug('System ' + sys.platform)
303     logger.debug('Version ' + sys.version.replace("\n", " "))
304 
305     # Create Instance and run the GUI
306     dd = DateDecoder()
307     dd.run()

由于流程图过宽,我们将其拆分为两张截图。第一张截图展示了设置DateDecoder类和初始run()调用的流程,后者创建了我们的框架:

第二张截图显示了操作代码的流程,其中我们的转换函数调用特定的时间转换函数,然后调用我们的output()函数将其显示给用户:

DateDecoder 类的设置和__init__()方法

我们使用class关键字初始化我们的类,后跟类名,并将object参数作为第 49 行所示的参数传递。最佳实践是使用 camelCase 约定命名类,并使用下划线命名方法以避免混淆。在第 50 行,我们定义了前面描述的__init__()特殊方法,只有self参数。此类在初始化时不需要任何用户输入,因此我们不需要考虑添加额外的参数。请看以下代码:

049 class DateDecoder(object):
050     """
051     The DateDecoder class handles the construction of the GUI
052     and the processing of date & time values
053     """
054     def __init__(self):
055         """
056         The __init__ method initializes the root GUI window and
057         variable used in the script
058         """

在第 60 行,我们创建了 GUI 的根窗口,并将其赋值给self对象内的一个值。这使我们能够在类中的其他方法中引用它和使用self创建的任何其他对象,而无需将其作为参数传递,因为self参数存储了整个类实例中可用的值。在第 61 行,我们定义了窗口的大小,宽度为 500 像素,高度为 180 像素,并在屏幕的顶部和左侧各偏移了 40 像素。

为了改善界面的外观,我们已经添加了背景颜色以反映 macOS 上显示的主题,尽管这可以设置为任何十六进制颜色,如第 62 行所示。最后,我们修改了根窗口的标题属性,给它一个显示在 GUI 窗口顶部的名称:

059         # Init root window
060         self.root = Tk()
061         self.root.geometry("500x180+40+40")
062         self.root.config(background = '#ECECEC')
063         self.root.title('Date Decoder')

在初始化 GUI 定义之后,我们需要为重要变量设置基础值。虽然这不是必需的,但通常最好的做法是在__init__()方法中创建共享值,并使用默认值定义它们。在定义将存储我们处理过的时间值的三个类变量后,我们还定义了基于 1601 年和 1970 年的时间戳的时代常量。代码如下:

065         # Init time values
066         self.processed_unix_seconds = None
067         self.processed_windows_filetime_64 = None
068         self.processed_chrome_time = None
069 
070         # Set Constant Epoch Offset
071         self.epoch_1601 = 11644473600000000
072         self.epoch_1970 = datetime.datetime(1970,1,1)

__init__()方法应该用于初始化类属性。在某些情况下,您可能希望该类还运行类的主要操作,但我们不会在我们的代码中实现该功能。我们将运行时操作分离到一个名为run()的新方法中,以允许我们启动特定于运行主代码的操作。这允许用户在启动 GUI 之前更改类配置信息。

执行 run() 方法

以下方法非常简短,由对我们稍后讨论的其他方法的函数调用组成。这包括为 GUI 构建输入和输出帧以及启动主事件监听器循环。由于类已经初始化了__init__()方法中找到的变量,我们可以安全地引用这些对象,如下所示:

074     def run(self):
075         """
076         The run method calls appropriate methods to build the
077         GUI and set's the event listener loop.
078         """
079         logger.info('Launching GUI')
080         self.build_input_frame()
081         self.build_output_frame()
082         self.root.mainloop() 

实现 build_input_frame() 方法

build_input_frame()方法是frame小部件的第一次实例化,定义在第 90 到 92 行。与我们在早期示例中定义此元素的方式类似,我们调用了主题化的frame小部件,并将self.root对象作为此框架的父级窗口。在第 91 行,我们在框架的X轴周围添加了30像素的填充,然后在第 92 行使用pack()几何管理器。由于每个窗口或框架只能使用一个几何管理器,因此我们现在必须在添加到root对象的任何附加框架或小部件上使用pack()管理器:

084     def build_input_frame(self):
085         """
086         The build_input_frame method builds the interface for
087         the input frame
088         """
089         # Frame Init
090         self.input_frame = ttk.Frame(self.root)
091         self.input_frame.config(padding = (30,0))
092         self.input_frame.pack()

创建框架后,我们开始向框架中添加小部件以供用户输入。在第 95 行,我们使用新的input_frame作为父级创建一个标签,文本为Enter Time Value。此标签被放置在网格的第一行和第一列。使用网格管理器时,第一个位置将是左上角位置,其他所有元素将围绕它布局。由于我们之后不需要调用这个标签,所以不将其分配给变量,而是可以直接调用.grid()方法将其添加到我们的 GUI 中:

094         # Input Value
095         ttk.Label(self.input_frame,
096             text="Enter Time Value").grid(row=0, column=0)

在第 98 行,我们初始化StringVar(),该变量用于存储用户输入的字符串。我们将在代码的各个地方引用此对象和信息,因此希望将其分配给对象self.input_time

在第 99 行,我们创建另一个小部件,这次是Entry,并且同样不将其分配给变量,因为在创建后我们不需要操作该元素。我们需要从该元素获取的信息将存储在self.input_time变量中。为了指示Entry对象将值存储在此对象中,我们必须将对象名称作为textvariable参数传递。我们还将字段的宽度指定为 25 个字符,使用grid()方法将其添加到 GUI 中,并将其放置在标签的下一列:

098         self.input_time = StringVar()
099         ttk.Entry(self.input_frame, textvariable=self.input_time,
100             width=25).grid(row=0, column=1, padx=5)

在创建输入区域后,我们必须为用户提供选项,以便指定输入类型。这使得用户可以选择源是机器可读格式还是人类可读格式。我们创建另一个StringVar()变量来保存用户选择的值。

由于我们希望默认操作是将原始时间戳转换为格式化时间戳,我们在第 104 行调用set()方法,在self.time_type变量上自动选择第 106 行创建的raw单选按钮。

在第 106 行,我们创建第一个单选按钮,将输入框架作为父级,单选按钮标签设置为Raw Value,并将反映用户是否选择了该单选按钮的变量设置为self.time_type。最后,我们使用网格管理器显示此按钮。在第 110 行,我们创建第二个单选按钮,文本和值设置为反映格式化的时间戳输入。此外,我们将此单选按钮放置在与第一个单选按钮相邻的同一行的列中。请查看以下代码:

102         # Radiobuttons
103         self.time_type = StringVar()
104         self.time_type.set('raw')
105 
106         ttk.Radiobutton(self.input_frame, text="Raw Value",
107             variable=self.time_type, value="raw").grid(row=1,
108                 column=0, padx=5)
109 
110         ttk.Radiobutton(self.input_frame, text="Formatted Value",
111             variable=self.time_type, value="formatted").grid(
112                 row=1, column=1, padx=5)

最后,我们构建用于提交来自 Entry 字段的数据进行处理的按钮。该按钮的设置与其他小部件类似,唯一不同的是添加了 command 关键字,当按钮被点击时,它会执行指定的方法。然后,我们将 convert() 方法分配为按钮的点击动作。

该方法在没有额外参数的情况下启动,因为它们被存储在 self 属性中。我们通过网格管理器将此元素添加到界面中,使用 columnspan 属性将信息跨越两个或更多列。我们还使用 pady(垂直间距)属性在输入字段和按钮之间提供一些垂直空间:

114         # Button
115         ttk.Button(self.input_frame, text="Run",
116             command=self.convert).grid(
117                 row=2, columnspan=2, pady=5)

创建 build_output_frame() 方法

输出框架的设计类似于输入框架。不同之处在于,我们需要将小部件保存到变量中,以确保在处理日期值时可以更新它们。在方法和文档字符串定义之后,我们创建 output_frame 并配置框架的高度和宽度。因为我们在根窗口中使用了 pack() 管理器,所以必须继续使用它将该框架添加到 GUI 的根窗口中:

119     def build_output_frame(self):
120         """
121         The build_output_frame method builds the interface for
122         the output frame
123         """
124         # Output Frame Init
125         self.output_frame = ttk.Frame(self.root)
126         self.output_frame.config(height=300, width=500)
127         self.output_frame.pack()

初始化之后,我们向 output_frame 添加各种小部件。所有输出小部件都是标签,因为它们允许我们轻松地向用户显示字符串值,而不会增加额外的负担。完成此任务的另一种方法是将输出放入文本输入框中并将其标记为只读。或者,我们可以创建一个大型文本区域,方便用户复制。两者都是本章末尾指定的挑战,供您在自己的 GUI 实现中进行更多实验。

第一个标签元素名为 转换结果,并通过 pack(fill=X) 方法在第 134 行居中。此方法填充 x 轴的区域,并垂直堆叠所有已打包的兄弟元素。在第 131 行创建标签后,我们使用 config() 方法配置字体大小,并将一个元组传递给 font 关键字。此参数的第一个元素应该是字体名称,第二个元素是字体大小。如果省略字体名称,我们将保留默认字体,并仅修改大小:

129         # Output Area
130         ## Label for area
131         self.output_label = ttk.Label(self.output_frame,
132             text="Conversion Results")
133         self.output_label.config(font=("", 16))
134         self.output_label.pack(fill=X)

以下三个标签代表支持的每个时间戳的结果。所有三个标签都将输出框架作为它们的父窗口,并将其文本设置为反映时间戳类型以及默认的 N/A 值。最后,每个标签都调用 pack(fill=X) 方法,以便在框架内正确居中和垂直排列值。我们必须将这三个标签分配给变量,以便在处理后更新它们的值,反映转换后的时间戳。标签在此处设置:

136         ## For Unix Seconds Timestamps
137         self.unix_sec = ttk.Label(self.output_frame,
138             text="Unix Seconds: N/A")
139         self.unix_sec.pack(fill=X)
140 
141         ## For Windows FILETIME 64 Timestamps
142         self.win_ft_64 = ttk.Label(self.output_frame,
143             text="Windows FILETIME 64: N/A")
144         self.win_ft_64.pack(fill=X)
145 
146         ## For Chrome Timestamps
147         self.google_chrome = ttk.Label(self.output_frame,
148             text="Google Chrome: N/A")
149         self.google_chrome.pack(fill=X)

构建 convert() 方法

一旦用户点击输入框中的按钮,convert() 方法就会被调用。这个方法负责验证输入、调用转换器,并将结果写入上一节中构建的标签中。可以说,这个方法替代了通常的 main() 方法。在初步定义和文档字符串之后,我们记录了用户提供的时间戳和格式(原始或格式化)。这有助于跟踪活动并排查可能出现的错误:

151     def convert(self):
152         """
153         The convert method handles the event when the button is
154         pushed. It calls to the converters and updates the
155         labels with new output.
156         """
157         logger.info('Processing Timestamp: {}'.format(
158             self.input_time.get()))
159         logger.info('Input Time Format: {}'.format(
160             self.time_type.get()))

首先,在第 163 行到第 165 行之间,我们将三个时间戳变量的值重置为 N/A,以清除当应用程序重新运行时可能存在的任何残留值。然后,我们在第 168 行到第 170 行调用了处理时间戳转换的三个方法。这些方法是独立的,它们会更新三个时间戳参数的值,而不需要我们返回任何值或传递参数。

正如你所看到的,self 关键字确实帮助我们简化了类的定义,因为它提供了访问共享类变量的方式。在第 173 行,我们调用了 output() 方法,将新转换的格式写入到图形界面中:

162         # Init values every instance
163         self.processed_unix_seconds = 'N/A'
164         self.processed_windows_filetime_64 = 'N/A'
165         self.processed_chrome_time = 'N/A'
166 
167         # Use this to call converters
168         self.convert_unix_seconds()
169         self.convert_win_filetime_64()
170         self.convert_chrome_time()
171 
172         # Update labels
173         self.output()

定义 convert_unix_seconds() 方法

Unix 时间戳是我们在本章中转换的三种时间戳中最直接的一种。在第 175 行到第 179 行之间,我们定义了方法及其文档字符串,然后进入了一个 if 语句。第 180 行的 if 语句判断之前描述的单选按钮的值是否等于 raw 字符串或 formatted。如果设置为 raw,我们将解析时间戳为自 1970-01-01 00:00:00.0000000 以来的秒数。这相对简单,因为这是 datetime.datetime.fromtimestamp() 方法使用的纪元。在这种情况下,我们只需将输入转换为浮动数值,如第 182 行和第 183 行所示,再进行转换。

然后,在第 183 行和第 184 行之间,我们将新创建的 datetime 对象格式化为 YYYY-MM-DD HH:MM:SS 格式的字符串。第 182 行的逻辑被包裹在一个 try-except 语句中,以捕获任何错误并将其报告到日志文件和用户界面中,以简化的形式显示出来。这使得我们能够在输入日期时测试每个公式。第 188 行概述了当我们无法成功转换时间戳时,转换错误将被显示出来。这将提醒用户发生了错误,并让他们判断是否是预期的错误。

175     def convert_unix_seconds(self):
176         """
177         The convert_unix_seconds method handles the conversion of
178         timestamps per the Unix seconds format
179         """
180         if self.time_type.get() == 'raw':
181             try:
182                 dt_val = datetime.datetime.fromtimestamp(
183                     float(self.input_time.get())).strftime(
184                         '%Y-%m-%d %H:%M:%S')
185                 self.processed_unix_seconds = dt_val
186             except Exception as e:
187                 logger.error(str(type(e)) + "," + str(e))
188                 self.processed_unix_seconds = str(
189                     type(e).__name__)

如果时间戳是格式化值,我们首先需要解析输入,然后尝试将其转换为 Unix 时间戳,因为它可能不符合预期格式。一旦通过 dateutil.parser 进行转换,我们就可以使用预定义的纪元对象计算时间戳与纪元之间的秒数差异,如第 195 行到第 197 行所示。如果发生错误,它将像前面的 if 语句一样被捕获,记录下来并显示给用户,如下所示:

191         elif self.time_type.get() == 'formatted':
192             try:
193                 converted_time = duparser.parse(
194                     self.input_time.get())
195                 self.processed_unix_seconds = str(
196                         (converted_time - self.epoch_1970
197                     ).total_seconds())
198             except Exception as e:
199                 logger.error(str(type(e)) + "," + str(e))
200                 self.processed_unix_seconds = str(
201                     type(e).__name__)

使用 convert_win_filetime_64() 方法进行转换

Microsoft Windows 的 FILETIME 值的转换稍微复杂一些,因为它使用1601-01-01 00:00:00作为纪元,并从那个时间点起以 100 纳秒为单位计时。为了正确转换这个时间戳,我们需要比前面的部分多进行几个步骤。

这个方法与上一个方法相同,包含if-else语法来识别时间戳类型。如果是原始格式,我们必须将输入的十六进制字符串转换为基于 10 的十进制数,这在第 210 和 211 行通过int(value, 16)类型转换实现。这允许我们告诉int()将基数为 16 的值转换为十进制(基数为 10)。基数为 16 的值通常被称为十六进制值。

一旦转换,整数就表示自纪元以来 100 纳秒为单位的时间,因此我们所要做的就是将微秒转换为datetime值,然后加上纪元的datetime对象。在第 212 到 214 行,我们使用datetime.timedelta()方法生成一个可以用于添加到纪元datetime的对象。一旦转换完成,我们需要将datetime对象格式化为时间字符串,并将其赋值给相应的标签。错误处理与之前的转换器相同,转换错误将显示如下:

203     def convert_win_filetime_64(self):
204         """
205         The convert_win_filetime_64 method handles the
206         conversion of timestamps per the Windows FILETIME format
207         """
208         if self.time_type.get() == 'raw':
209             try:
210                 base10_microseconds = int(
211                     self.input_time.get(), 16) / 10
212                 datetime_obj = datetime.datetime(1601,1,1) + \
213                     datetime.timedelta(
214                         microseconds=base10_microseconds)
215                 dt_val = datetime_obj.strftime(
216                     '%Y-%m-%d %H:%M:%S.%f')
217                 self.processed_windows_filetime_64 = dt_val
218             except Exception as e:
219                 logger.error(str(type(e)) + "," + str(e))
220                 self.processed_windows_filetime_64 = str(
221                     type(e).__name__)

如果输入的时间戳是格式化的值,我们需要反向转换。我们之前在第 212 行使用datetime.timedelta()方法时采取了一些捷径。当反向转换时,我们需要手动计算微秒数,然后再转换为十六进制。

首先,在第 225 行,我们将数据从字符串转换为datetime对象,以便开始处理这些值。然后,我们从转换后的时间中减去纪元值。减法之后,我们将datetime.timedelta对象转换为微秒值,基于存储的三个值。我们需要将秒数乘以一百万,将天数乘以 864 亿,以将每个值转换为微秒。最后,在第 229 到 231 行,我们几乎准备好将时间戳转换,经过加和这三个值之后:

223         elif self.time_type.get() == 'formatted':
224             try:
225                 converted_time = duparser.parse(
226                     self.input_time.get())
227                 minus_epoch = converted_time - \
228                     datetime.datetime(1601,1,1)
229                 calculated_time = minus_epoch.microseconds + \
230                     (minus_epoch.seconds * 1000000) + \
231                     (minus_epoch.days * 86400000000)

在第 232 和 233 行,我们通过将最内层的calculated_time转换为整数来执行转换。在整数状态下,它会乘以 10,转换为 100 纳秒组数,然后通过hex()类型转换将其转换为十六进制。由于代码要求输出为字符串,我们将十六进制值转换为字符串,如第 232 行的外部包装所示,然后将其赋值给self.processed_windows_filetime_64变量。

与其他转换函数类似,我们在第 234 到 237 行将错误处理加入到转换器中:

232                 self.processed_windows_filetime_64 = str(
233                     hex(int(calculated_time)*10))
234             except Exception as e:
235                 logger.error(str(type(e)) + "," + str(e))
236                 self.processed_windows_filetime_64 = str(
237                 type(e).__name__)

使用 convert_chrome_time()方法进行转换

我们展示的最后一个时间戳是 Google Chrome 时间戳,它与之前提到的两个时间戳类似。这个时间戳是自1601-01-01 00:00:00纪元以来的微秒数。我们将利用前面定义的self.unix_epcoh_offset值来帮助转换。在第 248 行,我们开始通过一系列函数转换原始时间戳。

首先,我们将时间戳转换为浮动数,并减去 1601 纪元常量。接下来,我们将该值除以一百万,将微秒转换为秒,以便datetime.datetime.fromtimestamp()方法可以正确地解释该值。最后,在第 251 行,我们使用strftime()函数将converted_time格式化为字符串。在第 253 到 255 行,我们处理可能由于无效值而引发的异常,正如之前部分所示:

239     def convert_chrome_time(self):
240         """
241         The convert_chrome_time method handles the
242         conversion of timestamps per the Google Chrome
243         timestamp format
244         """
245         # Run Conversion
246         if self.time_type.get() == 'raw':
247             try:
248                 dt_val = datetime.datetime.fromtimestamp(
249                         (float(self.input_time.get()
250                     )-self.epoch_1601)/1000000)
251                 self.processed_chrome_time = dt_val.strftime(
252                     '%Y-%m-%d %H:%M:%S.%f')
253             except Exception as e:
254                 logger.error(str(type(e)) + "," + str(e))
255                 self.processed_chrome_time = str(type(e).__name__)

当传递一个格式化的值作为输入时,我们必须逆转该过程。与其他函数一样,我们使用duparser.parse()方法将输入从字符串转换为datetime对象。一旦转换完成,我们通过将 1601 纪元常量加到total_seconds()方法来计算秒数。

这些秒数会乘以一百万,转换为微秒。计算完成后,我们可以将该整数值转换为字符串,并在我们的 GUI 中显示。如果发生任何错误,我们会在第 264 到 266 行捕获它们,就像之前的方法一样:

257         elif self.time_type.get() == 'formatted':
258             try:
259                 converted_time = duparser.parse(
260                     self.input_time.get())
261                 chrome_time = (converted_time - self.epoch_1970
262                     ).total_seconds()*1000000 + self.epoch_1601
263                 self.processed_chrome_time = str(int(chrome_time))
264             except Exception as e:
265                 logger.error(str(type(e)) + "," + str(e))
266                 self.processed_chrome_time = str(type(e).__name__)

设计输出方法

该类的最后一个方法是output()方法,它更新了 GUI 底部框架上的标签。这个简单的结构允许我们评估处理后的值,并在它们是字符串类型时显示出来。正如第 273 行所见,在方法定义和文档字符串之后,我们检查self.processed_unix_seconds的值是否为字符串类型。

如果是字符串类型,则我们通过调用text属性作为字典键来更新标签,如第 274 行和 275 行所示。这也可以通过使用config()方法来实现,但在此实例中,使用这种方式更简单。当该属性发生变化时,标签会立即更新,因为该元素已经由几何管理器设置。这个行为会对每个需要更新的标签重复,如第 277 行到 283 行所示:

268     def output(self):
269         """
270         The output method updates the output frame with the
271         latest value.
272         """
273         if isinstance(self.processed_unix_seconds, str):
274             self.unix_sec['text'] = "Unix Seconds: " + \
275                 self.processed_unix_seconds
276 
277         if isinstance(self.processed_windows_filetime_64, str):
278             self.win_ft_64['text'] = "Windows FILETIME 64: " + \
279                 self.processed_windows_filetime_64
280 
281         if isinstance(self.processed_chrome_time, str):
282             self.google_chrome['text'] = "Google Chrome: " + \
283                 self.processed_chrome_time

运行脚本

使用完整代码后,我们可以执行 GUI 并开始将日期从机器格式转换为人类可读格式,反之亦然。正如下面的截图所示,完成的 GUI 反映了我们的设计目标,并允许用户轻松地交互和处理日期:

上面的截图还展示了我们输入一个格式化的时间值,并从我们的函数中获取三个转换后的原始时间戳。接下来,我们提供一个 Unix 秒格式的原始输入,并可以看到我们的 Unix 秒解析器返回了正确的日期:

额外挑战

本脚本介绍了 GUI 以及我们通过 TkInter 模块可用的一些时间戳转换方法。这个脚本可以通过多种方式进行扩展。我们建议那些希望更好地了解 Python 中 GUI 开发的人尝试以下挑战。

如本章所述,我们只指定了三种在法医领域常见的格式的转换,并使用几种不同的方法提供转换。尝试为 FAT 目录时间戳条目添加支持,将其转换为原始格式和从原始格式转换。该脚本的设计使得添加额外的格式化器变得非常简单,只需定义原始和格式化的处理程序,添加标签到输出框架,并将方法名添加到 convert() 中。

此外,可以考虑将输出标签替换为输入字段,以便用户可以复制和粘贴结果。这个挑战的提示是查看 Entry 小部件的 set()read-only 属性。

我们展示的最后一个挑战让用户可以指定一个时区,既可以通过命令行也可以通过 GUI 界面。pytz 库可能在这个任务中派上大用场。

总结

在这一章中,我们讲解了如何在机器可读和人类可读的时间戳之间进行转换,并在 GUI 中显示这些信息。法医开发者的主要目标是能够快速设计和部署能够为调查提供洞察的工具。

然而,在这一章中,我们更多地关注了最终用户,花了一些额外的时间为用户构建了一个便于操作和互动的漂亮界面。本项目的代码可以从 GitHub 或 Packt 下载,如 前言 中所述。

在下一章中,我们将探讨分诊系统,以及如何使用 Python 从系统中收集重要的实时和易变数据。

第十章:快速筛查系统

在今天这个充满挑战的新时代里,事件往往会在没有快速有效响应的情况下迅速失控,因此,DFIR 专业人士必须能够查询主机的相关信息,如系统上运行的进程和服务,从而做出明智的调查决策,快速遏制事件。虽然我们通常可以在机器的取证镜像上收集这些信息,但其中一些信息是易失性的,或者可能需要快速收集,而不是等待创建取证镜像。

在本章中,我们将开发一个与现代操作系统兼容的单一脚本,并使用各种第一方和第三方库,提取脚本所运行系统的有用信息。通过一些修改,这个脚本可以在一个环境中使用,通过将其部署到多个主机,收集可能对调查有价值的基本系统信息。例如,在涉及恶意软件的事件中,如果恶意软件在成功感染主机时创建了一个新进程,那么可以使用这些信息快速确定被感染的主机范围,并在进一步调查时找出最早被感染的机器。

为了实现一个跨操作系统兼容的脚本,我们将依赖一个名为psutil的第三方模块来获取运行中进程的信息,而对于 Windows 操作系统的更多操作系统特定情报,将使用Windows 管理界面WMI)进行提取。

在本章中,我们将涵盖以下主题:

  • 使用psutil提取与操作系统无关的进程信息

  • 使用 Python 及wmipywin32模块通过查询 WMI 与 Windows 系统交互

  • 创建一个多平台的初步筛查文档收集脚本

本章的代码是使用 Python 2.7.15 和 Python 3.7.1 开发和测试的。

理解系统信息的价值

那么,为什么要收集系统信息呢?并非所有的调查都围绕用户及其在系统上采取的行动展开,而是关注系统本身以及它的行为。例如,在上一节中,我们讨论了运行中的进程和创建的服务如何根据特定场景的妥协指示符提供信息。然而,正如 DFIR 专业人士所知,系统信息的来源也可以为用户活动提供洞察,比如当前连接到机器的磁盘或查询事件日志中的用户登录信息。

在本书的第一版中,本章最初展示了一个我们开发的 keylogger 脚本,目的主要是演示如何使用操作系统 API。对于第二版,我们决定保持这一重点不变,但以一种更具法医相关性的方式应用它。让我们深入探讨并讨论第三方库。我们将需要从 psutil 开始开发这个脚本。

使用 psutil 查询操作系统无关的进程信息

psutil 模块(版本 5.4.5)是一个跨平台库,能够收集不同操作系统的各种系统信息,适用于 32 位和 64 位架构。虽然我们使用此库从运行脚本的主机系统中提取进程信息,但请注意,这个库能够提取的系统信息远不止运行中的进程。

让我们通过一些示例来了解,虽然其中一些我们在脚本中不会利用,但首先使用 pip 安装该库:

pip install psutil==5.4.5

我们可以使用 pids() 函数获取活动进程 ID 的列表,然后使用 PID 收集该进程的更多信息。例如,在以下代码块中,我们选择 PID 列表中的第一个 PID,PID 为 62,创建一个 PID 为 62 的进程对象,并使用各种函数来显示其名称、父 PID 和打开的文件。

请注意,对于某些函数,例如 open_files() 方法,您需要在提升权限的命令提示符下运行命令:

>>> import psutil
>>> pids = psutil.pids()
>>> pids[0]
62
>>> proc = psutil.Process(pids[0])
>>> proc.is_running()
True
>>> proc.name()
syslogd
>>> proc.ppid()
1
>>> proc.parent().name()
launchd
>>> proc.open_files()[0]
popenfile(path='/private/var/run/utmpx', fd=3)

虽然我们使用此库打印进程的详细信息,但我们也可以用它来执行其他任务。

例如,我们可以使用 disk_partitions() 函数收集有关连接磁盘的信息:

>>> for part in psutil.disk_partitions():
...     print("Device: {}, Filesystem: {}, Mount: {},"
...           " Size: {}, Disk Used: {}%".format(
...                 part[0], part[2], part[1], 
...                 psutil.disk_usage(part[1])[0], 
...                 psutil.disk_usage(part[1])[3]))
...
Device: /dev/disk1s1, Filesystem: apfs, Mount: /, Size: 500068036608, Disk Used: 82.9%

此外,我们还可以使用 users() 函数识别系统上的用户配置文件以及用户会话的启动时间:

>>> psutil.users()[0].name
PyForensics
>>> psutil.users()[0].started
1548086912.0
>>> from datetime import datetime
>>> print(datetime.utcfromtimestamp(psutil.users()[0].started))
2019-01-21 16:08:32

您可以通过阅读文档页面了解更多关于此库的信息:pypi.org/project/psutil/

使用 WMI

wmi 库由 Tim Golden 维护,是对下一个部分中将介绍的 pywin32 模块的封装,允许程序员与 WMI API 进行交互,并为程序员提供大量与 Windows 系统相关的重要信息。您甚至可以使用此库查询网络上的其他 Windows 系统。

首先,在命令提示符下执行以下命令使用 pip 安装 WMI:

pip install WMI==1.4.9

不言而喻,我们将在这里讨论的示例只适用于 Windows 系统,因此应在 Windows 系统上执行。让我们首先看看如何查询正在运行的服务。

我们需要创建一个 WMI 对象,然后使用 query() 方法来识别正在运行的服务:

>>> import wmi
>>> conn = wmi.WMI()
>>> for service in conn.query(
...        "SELECT * FROM Win32_Service WHERE State='Running'"):
...    print("Service: {}, Desc: {}, Mode: {}".format(
...         service.Name, service.Description, service.StartMode))
...
Service: PlugPlay, Desc: Enables a computer to recognize and adapt to hardware changes with little or no user input. Stopping or disabling this service will result in system instability., Mode: Manual

例如,我们可以使用该模块识别与系统关联的已安装打印机。

以下示例中的部分输出,表示为字符串 [...],已被清除:

>>> for printer in conn.Win32_Printer():
...    print(printer.Name)
...
Microsoft XPS Document Writer
Microsoft Print to PDF
HP[...] (HP ENVY Photo 6200 series)
Fax

最后,本文中使用的这个库的一个非常有用的功能是,它允许我们查询 Windows 事件日志。

在下面的示例中,我们查询OAlerts.evtx文件,这是一个存储 Microsoft Office 警报的事件日志,并打印出每个事件的消息和事件生成的时间。为了简洁起见,这里仅展示一条这样的消息:

>>> for event in conn.query(
            "SELECT * FROM Win32_NTLogEvent WHERE Logfile='OAlerts'"):
...     print(event.Message, event.TimeGenerated)
...
Microsoft Excel
Want to save your changes to 'logonevent.csv'?
P1: 153042
P2: 15.0.5101.1000
P3:
P4:
20190121031627.589966-000

我们可以讨论这个库的许多其他功能;然而,我们邀请你探索并尝试它的功能。在本章的脚本中,我们将介绍更多这个库的示例。

本模块需要pywin32库,它是一个非常强大的库,能够让开发者访问多个不同的 Windows API,相关内容将在下一节简要介绍。需要理解的是,我们只是初步接触这些库的表面,重点是我们的脚本目标。花些时间阅读这些库的文档并尝试它们的功能,因为在任何与 Windows 操作系统交互的脚本中,你可能会发现这些库非常有用。

在文档页面pypi.org/project/WMI/上了解更多关于wmi库及其功能的信息。使用wmi库的示例食谱可以在此处找到:timgolden.me.uk/python/wmi/cookbook.html

pywin32模块的功能是什么?

对 Python 来说,最通用的 Windows API 库之一是pywin32(版本 224)。该项目由 Mark Hammond 托管在 GitHub(之前托管在 SourceForge)上,是一个开源项目,社区成员共同贡献。通过这个库,Windows 提供了许多不同的 API。这些功能允许开发者为应用程序构建 GUI,利用内置的身份验证方法,并与硬盘和其他外部设备进行交互。

pywin32模块可以通过在命令提示符中执行以下命令使用pip安装:

pip install pywin32==224

Windows 定义了一个组件对象模型COM),它允许应用程序之间共享信息。COM 可以是动态链接库DLL)或其他二进制文件格式。这些模块的设计使得任何编程语言都可以解读这些信息。例如,这一套指令可以让基于 C++的程序和基于 Java 的程序共享同一资源,而无需为每种语言提供一个单独的版本。COM 通常只存在于 Windows 平台上,尽管如果需要,它们也可以移植到 UNIX 平台上。win32com库是pywin32库的一部分,允许我们在 Windows 中与 COM 交互,并由wmi库用于获取我们请求的信息。

pywin32库可以在 GitHub 上找到:github.com/mhammond/pywin32

快速检查系统状态 – pysysinfo.py

在我们已经介绍了收集易失性信息的重要性以及我们将使用的库之后,现在我们准备深入本章的重点——pysysinfo.py 脚本。该脚本由多个函数组成,其中大多数与 psutil 库相关,但其核心首先识别它运行的系统类型,如果该系统使用的是 Windows 操作系统,则会运行一个额外的函数,使用之前讨论过的 WMI API。您可以在下图中看到各个函数是如何相互作用并组成本章剩余部分讨论的代码:

该脚本是在 Python 2.7.15 和 3.7.1 上开发和测试的。和我们开发的任何脚本一样,我们必须从导入必要的库开始,才能成功执行我们编写的代码。你会注意到一些常见的导入;然而,有一些特别引人注目——尤其是第 5 行和第 8 行的 platform 模块和 psutil。你可能还会注意到,这组导入中缺少了 wmi。你将在脚本的后面几段理解为什么稍后会导入这个库。该脚本包含七个不同的函数,其中大多数用于处理来自 psutil 库的数据。

请注意,return_none() 函数将在下一个代码块中介绍,而不是新开一个小节,因为它是一个一行的函数,简单地返回 None 给调用代码:

002 from __future__ import print_function
003 import argparse
004 import os
005 import platform
006 import sys
007 
008 import psutil
009     if sys.version_info[0] == 2:
010 import unicodecsv as csv
011     elif sys.version_info[0] == 3:
012 import csv
...
050 def return_none():
051    """
052    Returns a None value, but is callable.
053    :return: None.
054    """
055    return None
...
058 def read_proc_connections(proc):
...
081 def read_proc_files(proc):
...
101 def get_pid_details(pid):
...
158 def get_process_info():
...
172 def wmi_info(outdir):
...
279 def csv_writer(data, outdir, name, headers, **kwargs):

platform 模块是我们之前没有涉及的,它是标准库的一部分,也提供了一些关于运行该脚本的系统的信息。在本例中,我们仅使用此库来确定执行脚本的主机系统的操作系统。

通过阅读文档页面了解更多关于 platform 模块的信息:docs.python.org/3/library/platform.html

接下来是脚本设置,我们有参数解析器,与其他章节相比,它显得相当简单,仅包含一个位置参数 OUTPUT_DIR,用于指定写入处理后数据的输出目录。

如果目标输出目录不存在,我们将在第 323 行使用 os.makedirs() 函数创建它:

313 if __name__ == '__main__':
314     parser = argparse.ArgumentParser(description=__description__,
315                     epilog='Developed by ' +
316                     __author__ + ' on ' +
317                     __date__)
318     parser.add_argument('OUTPUT_DIR',
319     help="Path to output directory. Will create if not found.")
320     args = parser.parse_args()
321
322     if not os.path.exists(args.OUTPUT_DIR):
323         os.makedirs(args.OUTPUT_DIR)

这里的做法与常规稍有不同。在第 325 行,使用 platform.system() 函数,我们检查脚本是否在 Windows 系统上执行。如果是,我们尝试导入 wmi 模块,如果导入成功,则调用 wmi_info() 方法。正如之前提到的,我们在这里导入 wmi 库是有原因的。当导入 wmi 库时,它还会加载 pywin32 模块,特别是 win32com.client 模块。在非 Windows 系统上,由于没有安装 pywin32 库,这可能会导致 ImportError 异常。因此,我们只有在知道脚本在 Windows 机器上执行时才会尝试导入 wmi。仅在需要时导入库也是个不错的主意:

325     if 'windows' in platform.system().lower():
326         try:
327             import wmi
328         except ImportError:
329             print("Install the wmi and pywin32 modules. "
330             "Exiting...")
331             sys.exit(1)
332         wmi_info(args.OUTPUT_DIR)

无论系统是否为 Windows,我们都会运行下一个代码块中的代码。在第 336 行,我们调用 get_process_info() 方法,最终以字典的形式返回进程数据。在第 337 行,我们创建了一个列表,包含所需的列名和 pid_data 字典的键。最后,在第 341 行,我们调用 csv_writer() 方法,传入数据、期望的输出目录、输出名称、fields 列表以及一个关键字参数。

稍后我们将看到该关键字参数的作用:

334     # Run data gathering function
335     print("[+] Gathering current active processes information")
336     pid_data = get_process_info()
337     fields = ['pid', 'name', 'exe', 'ppid', 'cmdline',
338         'username', 'cwd', 'create_time', '_errors']
339 
340     # Generate reports from gathered details
341     csv_writer(pid_data, args.OUTPUT_DIR, 'pid_summary.csv',
342         fields, type='DictWriter')

正如你可能已经注意到的,我们并没有为这个脚本编写 main() 函数,而是直接跳入了对 get_process_info() 方法的回顾。我们将在本章的最后讨论特定于 Windows 的函数 wmi_info()

理解 get_process_info() 函数

就功能而言,get_process_info() 函数相对简单,主要用于设置其余代码的执行。在第 166 行,我们创建了 pid_info 字典,最终将在第 336 行返回给调用函数,并包含提取的进程数据。接下来,我们使用 psutil.pids() 方法作为迭代器,正如我们在之前展示该库时所展示的那样,我们将每个进程 ID 传递给 get_pid_details() 方法,并将返回的数据存储在 pid_info 字典中,PID 作为字典的键。

接下来,让我们看一下 get_pid_details() 函数:

158 def get_process_info():
159     """
160     Gather details on running processes within the system.
161     :return pid_info: A dictionary containing details of
162     running processes.
163     """
164 
165     # List of PIDs
166     pid_info = {}
167     for pid in psutil.pids():
168         pid_info[pid] = get_pid_details(pid)
169     return pid_info

了解 get_pid_details() 函数

get_pid_details() 方法开始收集每个传递给它的 PID 的信息。对于每个 PID,我们创建一个字典 details,该字典预先填充了我们可以使用 psutil 库提取值的相关键。字典的键被初始化为占位符值,大多数是空字符串和空列表:

101 def get_pid_details(pid):
102     """
103     Gather details on a specific pid.
104     :param pid: an integer value of a pid to query for
105     additional details.
106     :return details: a dictionary of gathered information
107     about the pid.
108     """
109     details = {'name': '', 'exe': '', 'cmdline': '', 'pid': pid,
110                'ppid': 0, 'status': '', 'username': '',
111                'terminal': '', 'cwd': '', 'create_time': '',
112                'children': [], # list of pid ints
113                'threads': [], # list of thread ints
114                'files': [], # list of open files
115                'connections': [], # list of network connections
116                '_errors': []
117     }

接下来,在第 118 行,我们进入一个tryexcept块,尝试为每个提供的 PID 创建一个Process对象。在这种情况下,第 120 行和第 124 行有两个不同的异常处理子句,分别处理没有与提供的 PID 匹配的进程(例如,如果进程在脚本执行后立即关闭)或操作系统错误的情况。在发生这种异常时,错误会被附加到details字典中,并将该字典返回给调用函数。

与其因某个进程的问题导致脚本崩溃或停止,脚本会继续执行,并将在脚本生成的 CSV 报告中提供这些错误作为一列。

118     try:
119         proc = psutil.Process(pid)
120     except psutil.NoSuchProcess:
121         details['_errors'].append(
122             (pid, 'Process no longer found'))
123         return details
124     except OSError:
125         details['_errors'].append((pid, 'OSError'))
126         return details

如果为提供的 PID 创建了一个Process对象,我们接着会遍历第 128 行中details字典中的每个键,如果该键不是pid_errors,我们会尝试使用第 144 行的getattr()函数获取与该键关联的值。不过,这里有一些例外情况;例如,我们为childrenthreadsconnectionsfiles这些键编写了特定的elif语句。在处理childrenthreads键时,我们在第 134 行和第 138 行使用了列表推导式,分别将子进程的 PID 和线程的 ID 与childrenthreads键相关联。

对于connectionsfiles这两个键,我们开发了单独的函数来提取所需的信息,并将返回的数据存储到details字典中的相应键下。最后,在第 145、148 和 151 行,我们创建了可能在条件语句中出现的异常,处理例如缺少足够权限的情况(例如,如果脚本在非提升权限的提示符下运行)、进程不存在或操作系统发生错误的情况:

128     for key in details:
129         try:
130             if key in ('pid', '_errors'):
131                 continue
132             elif key == 'children':
133                 children = proc.children()
134                 details[key] = [c.pid for c in children]
135 
136             elif key == 'threads':
137                 threads = proc.threads()
138                 details[key] = [t.id for t in threads]
139             elif key == 'connections':
140                 details[key] = read_proc_connections(proc)
141             elif key == 'files':
142                 details[key] = read_proc_files(proc)
143             else:
144                 details[key] = getattr(proc, key, return_none)()
145         except psutil.AccessDenied:
146             details[key] = []
147             details['_errors'].append((key, 'AccessDenied'))
148         except OSError:
149             details[key] = []
150             details['_errors'].append((key, 'OSError'))
151         except psutil.NoSuchProcess:
152             details['_errors'].append(
153             (pid, 'Process no longer found'))
154             break

如前所述,对于connectionsfiles这两个键,我们调用了单独的函数来处理它们。现在我们来看第一个函数的实现。

使用read_proc_connections()函数提取进程连接属性

read_proc_connections()函数定义在第 58 行,首先创建一个空的列表conn_details,该列表将存储每个 PID 连接的详细信息:

058 def read_proc_connections(proc):
059     """
060     Read connection properties from a process.
061     :param proc: An object representing a running process.
062     :return conn_details: A list of process connection
063     properties.
064     """
065     conn_details = []

对于提供的每个连接,我们会创建一个conn_items字典,并在其中存储每个连接的详细信息,包括连接状态、本地和远程的 IP 地址和端口。如前所述,我们使用getattr()方法,查询指定对象的命名属性,并将返回的值存储到我们的字典中。如果命名对象不存在,我们使用None或空字符串作为默认值,这些默认值被定义为getattr()函数的第三个输入。

然后,我们将每个连接的详细信息字典追加到conn_details列表中,在此过程完成后,conn_details列表本身将被返回给调用函数:

066     for conn in proc.connections():
067         conn_items = {}
068         conn_items['fd'] = getattr(conn, 'fd', None)
069         conn_items['status'] = getattr(conn, 'status', None)
070         conn_items['local_addr'] = "{}:{}".format(
071             getattr(conn.laddr, 'ip', ""), getattr(
072                 conn.laddr, 'port', ""))
073         conn_items['remote_addr'] = "{}:{}".format(
074             getattr(conn.raddr, 'ip', ""), getattr(
075                 conn.raddr, 'port', ""))
076 
077         conn_details.append(conn_items)
078     return conn_details

使用 read_proc_files()函数获取更多的进程信息

在 81 行定义的read_proc_files()方法遵循了与前面讨论的类似模式。基本上,在 88 行,我们遍历与进程相关的所有打开的文件,并使用getattr()方法尝试提取每个打开文件的信息,如其路径和模式。

我们在提取每个打开文件的所有值并将数据插入到file_details列表后返回该列表:

081 def read_proc_files(proc):
082     """
083     Read file properties from a process.
084     :param proc: An object representing a running process.
085     :return file_details: a list containing process details.
086     """
087     file_details = []
088     for handle in proc.open_files():
089         handle_items = {}
090         handle_items['fd'] = getattr(handle, 'fd', None)
091         handle_items['path'] = getattr(handle, 'path', None)
092         handle_items['position'] = getattr(
093             handle, 'position', None)
094         handle_items['mode'] = getattr(handle, 'mode', None)
095 
096         file_details.append(handle_items)
097 
098     return file_details

使用 wmi_info()函数提取 Windows 系统信息

在 172 行定义的wmi_info()函数,首先定义了一个字典,用来存储通过 WMI API 查询到的各种信息类型。

类似地,在 185 行,我们创建了 WMI 对象并将其赋值给变量conn,这就是我们将要进行查询的对象:

172 def wmi_info(outdir):
173     """
174     Gather information available through Windows Management
175     Interface. We recommend extending this script by adding
176     support for other WMI modules -- Win32_PrintJob,
177     Win32_NetworkAdapterConfiguration, Win32_Printer,
178     Win32_PnpEntity (USB).
179     :param outdir: The directory to write CSV reports to.
180     :return: Nothing.
181     """
182 
183     wmi_dict = {"Users": [], "Shares": [], "Services": [],
184         "Disks": [], "Event Log": []}
185     conn = wmi.WMI()

在这些代码块中,你会注意到我们调用了conn对象的特定方法,但在其他地方,我们使用了query()方法。请注意,在某些情况下,两者都是可行的。例如,我们可以调用conn.Win32_UserAccount(),也可以使用conn.query("SELECT * from Win32_UserAccount")query()方法为我们提供了额外的灵活性,因为我们可以为查询提供更多的逻辑,这将在查询特定事件日志条目时看到。

从 190 行的print语句开始,我们开始使用wmi库收集信息。在 191 行通过遍历每个用户配置文件时,我们将用户帐户的各种属性追加到wmi_dictusers列表中:

187     # See attributes for a given module like so: for user in
188     # conn.Win32_UserAccount(); user._getAttributeNames()
189 
190     print("[+] Gathering information on Windows user profiles")
191     for user in conn.Win32_UserAccount():
192         wmi_dict["Users"].append({
193             "Name": user.Name, "SID": user.SID,
194             "Description": user.Description,
195             "InstallDate": user.InstallDate,
196             "Domain": user.Domain,
197             "Local Account": user.LocalAccount,
198             "Password Changeable": user.PasswordChangeable,
199             "Password Required": user.PasswordRequired,
200             "Password Expires": user.PasswordExpires,
201             "Lockout": user.Lockout
202         })

我们在下面的代码块中开始使用query()方法,在 205 行列出所有的(*)共享。对于每个共享,我们将其各种详细信息追加到wmi_dict字典中的相应列表。在 213 行,我们再次使用query()方法,这次是针对服务,但仅捕获当前正在运行的服务。

希望你能理解query()方法的价值,因为它为开发者提供了很大的灵活性,可以只返回符合指定标准的数据,从而过滤掉大量无用数据:

204     print("[+] Gathering information on Windows shares")
205     for share in conn.query("SELECT * from Win32_Share"):
206         wmi_dict["Shares"].append({
207             "Name": share.Name, "Path": share.Path,
208             "Description": share.Description,
209             "Status": share.Status,
210             "Install Date": share.InstallDate})
211
212     print("[+] Gathering information on Windows services")
213     for service in conn.query(
214             "SELECT * FROM Win32_Service WHERE State='Running'"):
215         wmi_dict["Services"].append({
216             "Name": service.Name,
217             "Description": service.Description,
218             "Start Mode": service.StartMode,
219             "State": service.State,
220             "Path": service.PathName,
221             "System Name": service.SystemName})

在 224 行,我们开始通过使用conn.Win32_DiskDrive()函数迭代每个驱动器来收集已连接驱动器的详细信息。为了收集我们想提取的所有信息,我们还需要迭代每个磁盘的每个分区和逻辑卷;因此,225 行和 227 行的额外for循环。

一旦我们获得了diskpartitionlogical_disk对象,我们就使用它们,并将一个字典追加到wmi_dict字典中相应的列表中,字典包含每个磁盘、分区和卷的各种属性:

223     print("[+] Gathering information on connected drives")
224     for disk in conn.Win32_DiskDrive():
225         for partition in disk.associators(
226                 "Win32_DiskDriveToDiskPartition"):
227             for logical_disk in partition.associators(
228                     "Win32_LogicalDiskToPartition"):
229                 wmi_dict["Disks"].append({
230                     "Physical Disk Name": disk.Name,
231                     "Bytes Per Sector": disk.BytesPerSector,
232                     "Sectors": disk.TotalSectors,
233                     "Physical S/N": disk.SerialNumber,
234                     "Disk Size": disk.Size,
235                     "Model": disk.Model,
236                     "Manufacturer": disk.Manufacturer,
237                     "Media Type": disk.MediaType,
238                     "Partition Name": partition.Name,
239                     "Partition Desc.": partition.Description,
240                     "Primary Partition": partition.PrimaryPartition,
241                     "Bootable": partition.Bootable,
242                     "Partition Size": partition.Size,
243                     "Logical Name": logical_disk.Name,
244                     "Volume Name": logical_disk.VolumeName,
245                     "Volume S/N": logical_disk.VolumeSerialNumber,
246                     "FileSystem": logical_disk.FileSystem,
247                     "Volume Size": logical_disk.Size,
248                     "Volume Free Space": logical_disk.FreeSpace})

接下来,在 253 行,我们创建了一个变量wmi_query,用来存储我们将用来从Security事件日志中提取事件 ID 为 4624 的所有事件的字符串。

请注意,在测试中观察到,脚本需要从提升的命令提示符运行,以便能够从Security事件日志中提取信息。

与其他查询类似,我们迭代返回的结果并将各种属性追加到wmi_dict字典中的相应列表中:

250     # Query for logon events type 4624
251     print("[+] Querying the Windows Security Event Log "
252     "for Event ID 4624")
253     wmi_query = ("SELECT * from Win32_NTLogEvent WHERE Logfile="
254         "'Security' AND EventCode='4624'")
255     for logon in conn.query(wmi_query):
256         wmi_dict["Event Log"].append({
257             "Event Category": logon.CategoryString,
258             "Event ID": logon.EventIdentifier,
259             "Time Generated": logon.TimeGenerated,
260             "Message": logon.Message})

最后,在提取所有信息并将其存储在wmi_dict字典中后,我们开始调用csv_writer()函数,将每种数据类型的电子表格写入输出目录。传递给csv_writer()的大部分值不言自明,包括特定工件的数据(即Users键下的用户配置文件)、输出目录和输出文件名。最后一个参数是一个按字母顺序排序的工件特定数据的键列表,作为我们 CSV 文件的列标题。

你还会注意到,我们有一个tryexcept块来处理写入事件日志数据。这是因为,如前所述,如果脚本没有从提升的命令提示符运行,Event Log键可能会包含一个空列表:

262     csv_writer(wmi_dict["Users"], outdir, "users.csv",
263         sorted(wmi_dict["Users"][0].keys()))
264     csv_writer(wmi_dict["Shares"], outdir, "shares.csv",
265         sorted(wmi_dict["Shares"][0].keys()))
266     csv_writer(wmi_dict["Services"], outdir, "services.csv",
267         sorted(wmi_dict["Services"][0].keys()))
268     csv_writer(wmi_dict["Disks"], outdir, "disks.csv",
269         sorted(wmi_dict["Disks"][0].keys()))
270     try:
271         csv_writer(wmi_dict["Event Log"],outdir, "logonevent.csv", 
272                    sorted(wmi_dict["Event Log"][0].keys()))
273     except IndexError:
274         print("No Security Event Log Logon events (Event ID "
275             "4624). Make sure to run the script in an escalated "
276             "command prompt")

使用csv_writer()函数写入我们的结果

我们定义的csv_writer(),在第 279 行正常开始,通过根据正在执行脚本的 Python 版本创建一个csvfile文件对象。不同之处在于,函数定义中列出了**kwargs参数。该参数的**部分表示这个函数接受关键字参数。在 Python 中,按惯例,关键字参数被称为kwargs

我们在这个函数中使用关键字参数,以区分使用常规的csv.writer()方法和csv.DictWriter()方法。这是必要的,因为来自wmi_info()get_process_info()函数的 CSV 调用分别传递了列表和字典。

csv_writer()方法中使用额外的逻辑解决了我们的问题,我们也可以通过让wmi_info()get_process_info()函数返回结构相似的对象来解决这个问题:

279 def csv_writer(data, outdir, name, headers, **kwargs):
280     """
281     The csv_writer function writes WMI or process information
282     to a CSV output file.
283     :param data: The dictionary or list containing the data to
284         write to the CSV file.
285     :param outdir: The directory to write the CSV report to.
286     :param name: the name of the output CSV file.
287     :param headers: the CSV column headers.
288     :return: Nothing.
289     """
290     out_file = os.path.join(outdir, name)
291 
292     if sys.version_info[0] == 2:
293         csvfile = open(out_file, "wb")
294     elif sys.version_info[0] == 3:
295         csvfile = open(out_file, "w", newline='',
296             encoding='utf-8')

正如你在第 298 行看到的,我们检查是否传入了名为type的关键字参数。由于我们只在第 341 行调用此函数时才这样做,因此我们知道这意味着什么。我们应该使用csv.DictWriter方法。在第 341 行,你会注意到我们将type关键字参数分配给了DictWriter字符串。然而,在这种情况下,我们本可以传递任何任意的字符串,因为我们在这里根本没有使用它的值。实际上,我们只需要知道type关键字参数已经被赋值即可。

对于get_process_info()函数返回的字典,我们可以使用列表推导式来写出字典中每个条目的值。对于wmi_info()函数,我们需要首先遍历提供的列表中的每个条目,然后将每个提供的表头相关联的值写入 CSV 文件:

298     if 'type' in kwargs:
299         with csvfile:
300             csvwriter = csv.DictWriter(csvfile, fields,
301                 extrasaction='ignore')
302             csvwriter.writeheader()
303             csvwriter.writerows([v for v in data.values()])
304 
305     else:
306         with csvfile:
307             csvwriter = csv.writer(csvfile)
308             csvwriter.writerow(headers)
309             for row in data:
310                 csvwriter.writerow([row[x] for x in headers])

执行 pysysinfo.py

在下面的截图中,你可以看到在 Windows 系统上运行此脚本时输出的结果:

此外,在 Windows 系统上执行脚本后,连接的驱动器、共享、服务、进程、用户和登录事件的 CSV 文件将被创建在指定的输出目录中。以下是其中一个电子表格——用户配置文件电子表格的内容截图:

挑战

使用 WMI部分所提到的,考虑通过能够查询远程 Windows 主机来扩展脚本的功能。类似地,wmipsutil都提供了可以访问的附加信息,值得收集。尝试这两个库并收集更多信息,尤其是专注于收集非 Windows 系统的系统信息,这在当前版本的脚本中得到了更好的支持,感谢wmi库。

最后,对于一个更具挑战性的任务,考虑开发一个更有用的存储库来收集和查询数据。以我们为少数几个系统收集和展示数据的方式来说,效果很好,但当在数百台系统上运行时,这种方式的扩展性如何?想象一下,在一个网络上针对多个主机部署并运行修改版的此脚本,并将处理后的信息存储在一个集中式数据库中进行存储,更重要的是,作为更高效的查询收集数据的手段。

总结

在本章中,我们确认了系统信息的价值以及如何在实时系统上提取这些信息。通过使用psutil库,我们学习了如何以操作系统无关的方式提取进程信息。我们还简要介绍了如何使用 WMI API 从 Windows 操作系统获取更多信息。本项目的代码可以从 GitHub 或 Packt 下载,具体信息请参考前言部分。

在下一章中,我们将学习如何使用 Python 处理 Outlook 归档的.pst文件并创建其内容列表。

第十一章:解析 Outlook PST 容器

电子邮件email)继续是工作场所中最常见的通信方式之一,在当今世界的新通信服务中生存下来。电子邮件可以从计算机、网站和遍布全球口袋的手机发送。这种媒介可以可靠地以文本、HTML、附件等形式传输信息。因此,毫不奇怪,电子邮件在特别是涉及工作场所的调查中扮演了重要角色。在本章中,我们将处理一种常见的电子邮件格式,个人存储表PST),由 Microsoft Outlook 用于将电子邮件内容存储在单个文件中。

我们将在本章中开发的脚本介绍了一系列通过 Joachim Metz 开发的libpff库可用的操作。这个库允许我们以 Pythonic 方式打开 PST 文件并探索其内容。此外,我们构建的代码演示了如何创建动态的基于 HTML 的图形,以提供电子表格报告的附加背景。对于这些报告,我们将利用第五章中引入的 Jinja2 模块,Python 中的数据库,以及 D3.js 框架来生成我们的动态基于 HTML 的图表。

D3.js 项目是一个 JavaScript 框架,允许我们设计信息丰富且动态的图表而不需要太多努力。本章使用的图表是框架的开源示例,与社区共享在github.com/d3/d3。由于本书不专注于 JavaScript,也不介绍该语言,因此我们不会详细介绍创建这些图表的实现细节。相反,我们将演示如何将我们的 Python 结果添加到预先存在的模板中。

最后,我们将使用一个示例 PST 文件,该文件跨时间包含大量数据,用于测试我们的脚本。与往常一样,我们建议在案件中使用任何代码之前针对测试文件运行以验证逻辑和功能覆盖范围。本章使用的库处于活跃开发状态,并由开发者标记为实验性。

本章涵盖以下主题:

  • 理解 PST 文件的背景

  • 利用libpff及其 Python 绑定pypff来解析 PST 文件

  • 利用 Jinja2 和 D3.js 创建信息丰富且专业的图表

本章的代码是使用 Python 2.7.15 开发和测试的。

PST 文件格式

PST 格式是一种个人文件格式PFF)的类型。PFF 文件的另外两种类型包括用于存储联系人的个人通讯录PAB)和存储离线电子邮件、日历和任务的脱机存储表OST)。默认情况下,Outlook 将缓存的电子邮件信息存储在 OST 文件中,这些文件可以在下表中指定的位置找到。如果归档了 Outlook 中的项目,它们将存储在 PST 文件中:

Windows 版本 Outlook 版本 OST 位置
Windows XP Outlook 2000/2003/2007 C:\Documents and Settings\USERPROFILE%\Local Settings\Application Data\Microsoft\Outlook
Windows Vista/7/8 Outlook 2007 C:\Users\%USERPROFILE%\AppData\Local\Microsoft\Outlook
Windows XP Outlook 2010 C:Documents and Settings\%USERPROFILE%\My Documents\Outlook Files
Windows Vista/7/8 Outlook 2010/2013 C:\Users\%USERPROFILE%\Documents\Outlook Files

来自: forensicswiki.org/wiki/Personal_Folder_File_(PAB,_PST,_OST)。OST 文件的默认位置。

%USERPROFILE%字段是动态的,会被计算机上的用户账户名称替换。PFF 文件可以通过十六进制文件签名0x2142444E或 ASCII 中的!BDN来识别。在文件签名之后,PFF 文件的类型由偏移量 8 处的 2 个字节表示:

类型 十六进制签名 ASCII 签名
PST 534D SM
OST 534F SO
PAB 4142 AB

来自 www.garykessler.net/library/file_sigs.html

内容类型(例如 32 位或 64 位)在字节偏移量 10 处定义。PFF 文件格式的结构已经由 Joachim Metz 在多个文献中详细描述,这些文献记录了技术结构以及如何在 GitHub 上的项目代码库中手动解析这些文件:github.com/libyal/libpff

本章我们只处理 PST 文件,可以忽略 OST 和 PAB 文件的差异。默认情况下,PST 归档有一个根区域,包含一系列文件夹和消息,具体取决于归档时如何创建。例如,用户可能会将视图中的所有文件夹归档,或者只归档某些特定的文件夹。所有选定内容中的项目将导出到 PST 文件中。

除了用户手动归档内容,Outlook 还具有一个自动归档功能,它将在指定时间后将项目存储在 PST 文件中,具体时间根据以下表格定义。一旦达到这个过期时间,项目将会被包括在下一个创建的归档中。自动归档默认将 PST 文件存储在 Windows 7 中的%USERPROFILE%\Documents\Outlook、Vista 中的%APPDATA%\Local\Microsoft\Outlook,以及 XP 中的%APPDATA%\Local Settings\Microsoft\Outlook。这些默认位置可以由用户或在域环境中的组策略设置。这个自动归档功能为调查人员提供了大量的通讯信息,可以在我们的调查中访问和解读:

文件夹 默认老化周期
收件箱和草稿箱 6 个月
已发送项目和已删除项目 2 个月
发件箱 3 个月
日历 6 个月
任务 6 个月
备注 6 个月
日志 6 个月

表 11.1:Outlook 项目的默认老化(https://support.office.com/en-us/article/Automatically-move-or-delete-older-items-with-AutoArchive-e5ce650b-d129-49c3-898f-9cd517d79f8e)

libpff 简介

libpff库允许我们以编程方式引用和浏览 PST 对象。root_folder()函数允许我们引用RootFolder,它是 PST 文件的基础,也是我们递归分析电子邮件内容的起点。在RootFolder中包含文件夹和消息。文件夹可以包含其他子文件夹或消息。文件夹有一些属性,包括文件夹名称、子文件夹数量和子消息数量。消息是表示消息的对象,并具有包括主题行、所有参与者的名称以及若干时间戳等属性。

如何安装 libpff 和 pypff

安装一些第三方库比运行pip install <library_name>更为复杂。在libpffpypff绑定的情况下,我们需要采取一些步骤并遵循 GitHub 项目仓库中列出的指示。libpff的 wiki(位于github.com/libyal/libpff/wiki/Building)描述了我们需要采取的步骤来构建libpff

我们将简要介绍如何在 Ubuntu 18.04 系统上构建这个库。在下载并安装 Ubuntu 18.04(最好是在虚拟机中)后,你需要通过运行以下命令来安装依赖项:

sudo apt-get update
sudo apt-get install git python-dev python-pip autoconf automake \
 autopoint libtool pkg-config

这将安装我们脚本和pypff绑定所需的包。接下来,我们需要通过运行以下命令来下载libpff代码:

git clone https://github.com/libyal/libpff

一旦git clone命令完成,我们将进入新的libpff目录,并运行以下命令来下载其他依赖项,配置并安装我们需要的库组件:

cd libpff
./synclibs.ps1
./autogen.ps1
./configure --enable-python
make
make install

额外的构建选项在libpff的 wiki 页面中有更详细的描述。

到此为止,你应该能够运行以下语句并获得相同的输出,尽管你的版本可能会有所不同:

python
>>> import pypff
>>> pypff.get_version()
u'20180812'

为了简化这个过程,我们已经预构建了pypff绑定,并创建了一个 Dockerfile 来为你运行整个设置。如果你不熟悉 Docker,它是一个虚拟化环境,可以让我们以最小的努力运行虚拟机。虽然 Docker 通常用于托管应用程序,但我们将更多地将它作为传统的虚拟机使用。对我们来说,这种方式的优势在于,我们可以分发一个配置文件,你可以在系统上运行它,从而生成与我们测试的环境相同的环境。

首先,请按照docs.docker.com/install/上的说明在你的系统上安装 Docker。安装并运行后,导航到你系统上的Chapter 11代码文件夹,并运行docker build命令。该命令将根据一系列预配置的步骤生成一个系统:

这将创建一个名为lpff-ch11、版本号为 20181130 的新镜像。在 Docker 中,镜像就是它的字面意思:一个基本安装,您可以用它来创建运行中的机器。这样,您可以拥有多个基于相同镜像的机器。每个机器称为容器,为了从这个镜像创建容器,我们将使用docker run语句:

docker run命令中的-it标志要求 Docker 在创建容器后连接到 bash shell。-P参数要求 Docker 为我们提供网络连接,在我们的案例中,就是运行在容器中的 Web 服务器。最后,--name参数允许我们为容器指定一个熟悉的名称。然后,我们传入镜像名称和版本并运行该命令。如你所见,一旦 Docker 实例完成,我们就会获得一个 root shell。

关于之前提到的 Web 服务器,我们已经包含了lighttpd,以便我们能够将 HTML 生成的报告作为网页提供。这不是必需的,不过我们希望强调如何使这些报告在内部系统上可访问。

请不要在公共网络上运行此 Docker 容器,因为它将允许任何能够访问您机器 IP 地址的人查看您的 HTML 报告。

在前面的截图中,我们通过运行server lighttpd start启动了 Web 服务器,然后列出了当前目录的内容。如您所见,我们有两个文件,一个是我们即将构建的pst_indexer.py脚本,另一个是我们将用来生成报告的stats_template.html。现在让我们开始构建 Python 脚本。

探索 PST 文件 – pst_indexer.py

在这个脚本中,我们将收集 PST 文件的信息,记录每个文件夹中的邮件,并生成关于词汇使用、频繁发送者以及所有邮件活动的热图统计数据。通过这些指标,我们可以超越初步的邮件收集和报告,探索使用的语言趋势或与特定人员的沟通模式。统计部分展示了如何利用原始数据并构建信息图表以帮助审查员。我们建议根据您的具体调查定制逻辑,以提供尽可能有用的报告。例如,对于词汇统计,我们只查看字母数字且长度大于四个字符的前十个词汇,以减少常见的词汇和符号。这可能不适用于您的调查,可能需要根据您的具体情况进行调整。

概览

本章的脚本是为 Python 2.7.15 版本编写的,并且需要上一节中提到的第三方库。请考虑在使用此脚本时同时使用 Docker 镜像。

与我们其他章节一样,本脚本通过导入我们在顶部使用的库开始。在本章中,我们使用了两个新的库,其中一个是第三方库。我们之前已经介绍过 pypff,它是 libpff 库的 Python 绑定。pypff 模块指定了允许我们访问已编译代码的 Python 绑定。在第 8 行,我们引入了 unicodecsv,这是一个我们在第五章《Python 中的数据库》中曾使用过的第三方库。这个库允许我们将 Unicode 字符写入 CSV 文件,因为原生的 CSV 库对 Unicode 字符的支持并不理想。

在第 6 行,我们导入了一个名为 collections 的标准库,它提供了一系列有用的接口,包括 CounterCounter 模块允许我们向其提供值,并处理计数和存储对象的逻辑。除此之外,collections 库还提供了 OrderedDict,当你需要按指定顺序创建键的字典时,它非常有用。尽管在本书中没有利用 OrderedDict 模块,但当你希望以有序的方式使用键值对时,它在 Python 中确实有其用武之地:

001 """Index and summarize PST files"""
002 import os
003 import sys
004 import argparse
005 import logging
006 from collections import Counter
007 
008 import jinja2
009 import pypff
010 import unicodecsv as csv

在设定了许可和脚本元数据后,我们将设置一些全局变量。这些变量将帮助我们减少需要传递到函数中的变量数量。第一个全局变量是第 46 行定义的 output_directory,它将存储用户设置的字符串路径。第 47 行定义的 date_dictionary 使用字典推导式创建了键 1 到 24,并将它们映射到整数 0。然后,我们在第 48 行使用列表推导式将这个字典的七个实例附加到 date_list。这个列表被用来构建热图,显示在 PST 文件中按七天 24 小时列划分的活动信息:

040 __authors__ = ["Chapin Bryce", "Preston Miller"]
041 __date__ = 20181027
042 __description__ = '''This scripts handles processing and
043     output of PST Email Containers'''
044 logger = logging.getLogger(__name__)
045 
046 output_directory = ""
047 date_dict = {x:0 for x in range(1, 25)}
048 date_list = [date_dict.copy() for x in range(7)]

这个热图将建立基线趋势,并帮助识别异常活动。例如,它可以显示在工作日午夜时段活动的激增,或者在星期三业务日开始前的过度活动。date_list 包含七个字典,每个字典代表一天,它们是完全相同的,包含一个小时的键值对,默认值为 0

date_dict.copy() 在第 48 行的调用是必需的,以确保我们可以在单个日期内更新小时数。如果省略了 copy() 方法,所有的日期都会被更新。这是因为字典通过对原始对象的引用相互关联,而在没有使用 copy() 方法的情况下,我们生成的是对象的引用列表。当我们使用此函数时,它允许我们通过创建一个新对象来复制值,从而可以创建不同对象的列表。

构建了这些变量后,我们可以在其他函数中引用并更新它们的值,而不需要再次传递它们。全局变量默认是只读的,必须使用特殊的 global 命令才能在函数中进行修改。

以下函数概述了我们脚本的操作。像往常一样,我们有main()函数来控制行为。接下来是make_path()函数,这是一个帮助我们收集输出文件完整路径的工具。folder_traverse()check_for_msgs()函数用于迭代可用项并开始处理:

051 def main():
...
078 def make_path():
...
089 def folder_traverse():
...
103 def check_for_msgs(): 

我们的其余函数专注于处理 PST 中的数据并生成报告。process_message()函数读取消息并返回报告所需的属性。第一个报告函数是folder_report()函数。此代码为 PST 中找到的每个文件夹创建 CSV 输出,并描述每个文件夹中的内容。

这个函数还通过将消息主体写入单一文本文件来处理其余报告的数据,存储每组日期,并保存发送者列表。通过将这些信息缓存到文本文件中,接下来的函数可以轻松读取文件,而不会对内存产生重大影响。

我们的word_stats()函数读取并将信息导入到一个集合中。Counter()对象在我们的word_report()函数中使用。当生成单词计数报告时,我们将集合的Counter()对象读取到 CSV 文件中,该文件将被我们的 JavaScript 代码读取。sender_report()date_report()函数也将数据刷新到分隔文件中,供 JavaScript 在报告中进行解释。最后,我们的html_report()函数打开报告模板,并将自定义报告信息写入输出文件夹中的 HTML 文件:

118 def process_msg():
...
138 def folder_report():
...
193 def word_stats():
...
208 def word_report():
...
235 def sender_report():
...
260 def date_report():
...
277 def html_report():

与我们所有的脚本一样,我们在第 302 行的if __name__ == "__main__":条件语句下处理参数、日志和main()函数调用。我们定义了必需的参数PST_FILEOUTPUT_DIR,用户可以指定可选参数--title-l,用于自定义报告标题和日志路径:

302 if __name__ == "__main__":
303     parser = argparse.ArgumentParser(
304         description=__description__,
305         epilog='Built by {}. Version {}'.format(
306             ", ".join(__authors__), __date__),
307        formatter_class=argparse.ArgumentDefaultsHelpFormatter
308     )
309     parser.add_argument('PST_FILE',
310         help="PST File Format from Microsoft Outlook")
311     parser.add_argument('OUTPUT_DIR',
312         help="Directory of output for temporary and report files.")
313     parser.add_argument('--title', default="PST Report",
314         help='Title of the HTML Report.')
315     parser.add_argument('-l',
316         help='File path of log file.')
317     args = parser.parse_args()

在定义了我们的参数后,我们开始处理它们,以便以标准化和安全的方式将它们传递给main()函数。在第 319 行,我们将输出位置转换为绝对路径,以确保在脚本中访问正确的位置。注意,我们正在调用output_directory全局变量并为其分配一个新值。这只有在我们不在函数内时才可能。如果我们在函数内部修改全局变量,就需要在第 318 行写上global output_directory

319     output_directory = os.path.abspath(args.OUTPUT_DIR)
320 
321     if not os.path.exists(output_directory):
322         os.makedirs(output_directory)

在修改 output_directory 变量后,我们确保路径存在(如果不存在,则创建),以避免后续代码出现错误。完成后,我们在第 331 到 339 行使用标准的日志记录代码片段来配置脚本的日志记录。在第 341 到 345 行,我们记录执行脚本的系统的调试信息,然后再调用 main() 函数。在第 346 行,我们调用 main() 函数,并传入 args.PST_FILEargs.title 参数。我们无需传递 output_directory 值,因为可以全局引用它。在传递参数并且 main() 函数执行完成后,我们在第 347 行记录脚本已完成执行。

331     logger.setLevel(logging.DEBUG)
332     msg_fmt = logging.Formatter("%(asctime)-15s %(funcName)-20s"
333         "%(levelname)-8s %(message)s")
334     strhndl = logging.StreamHandler(sys.stderr) # Set to stderr
335     strhndl.setFormatter(fmt=msg_fmt)
336     fhndl = logging.FileHandler(log_path, mode='a')
337     fhndl.setFormatter(fmt=msg_fmt)
338     logger.addHandler(strhndl)
339     logger.addHandler(fhndl)
340     
341     logger.info('Starting PST Indexer v. {}'.format(__date__))
342     logger.debug('System ' + sys.platform)
343     logger.debug('Version ' + sys.version.replace("\n", " "))
344 
345     logger.info('Starting Script')
346     main(args.PST_FILE, args.title)
347     logger.info('Script Complete')

以下流程图展示了各个函数之间的交互方式。这个流程图可能看起来有些复杂,但它概括了我们脚本的基本结构。

main() 函数调用递归的 folder_traverse() 函数,该函数依次查找、处理并汇总根文件夹中的消息和文件夹。之后,main() 函数生成包含单词、发送者和日期的报告,并通过 html_report() 函数生成一个 HTML 报告进行显示。需要注意的是,虚线代表返回值的函数,而实线代表没有返回值的函数:

开发 main() 函数

main() 函数控制脚本的主要操作,从打开和初步处理文件、遍历 PST 文件,到生成报告。在第 62 行,我们使用 os.path 模块从路径中分离出 PST 文件名。

如果用户没有提供自定义标题,我们将使用 pst_name 变量。在下一行,我们使用 pypff.open() 函数创建一个 PST 对象。通过 get_root_folder() 方法获取 PST 的根文件夹,从而开始迭代过程,发现文件夹中的项:

051 def main(pst_file, report_name):
052     """
053     The main function opens a PST and calls functions to parse
054     and report data from the PST
055     :param pst_file: A string representing the path to the PST
056     file to analyze
057     :param report_name: Name of the report title
058         (if supplied by the user)
059     :return: None
060     """
061     logger.debug("Opening PST for processing...")
062     pst_name = os.path.split(pst_file)[1]
063     opst = pypff.open(pst_file)
064     root = opst.get_root_folder()

提取根文件夹后,我们在第 67 行调用 folder_traverse() 函数,开始遍历 PST 容器中的目录。我们将在下一部分讨论该函数的具体内容。遍历文件夹后,我们开始使用 word_stats()sender_report()date_report() 函数生成报告。在第 74 行,我们传入报告名称、PST 名称以及包含最常见单词和发送者的列表,为 HTML 仪表板提供统计数据,如下所示:

066     logger.debug("Starting traverse of PST structure...")
067     folder_traverse(root)
068 
069     logger.debug("Generating Reports...")
070     top_word_list = word_stats()
071     top_sender_list = sender_report()
072     date_report()
073 
074     html_report(report_name, pst_name, top_word_list,
075         top_sender_list)

评估 make_path() 辅助函数

为了简化操作,我们开发了一个辅助函数make_path(),定义在第 78 行。辅助函数允许我们在脚本中重复利用通常需要多次编写的代码,只需一次函数调用即可。通过这段代码,我们接受一个表示文件名的输入字符串,并根据用户提供的output_directory值返回文件在操作系统中的绝对路径。在第 85 行,进行了两项操作;首先,我们使用os.path.join()方法将file_nameoutput_directory值按正确的路径分隔符连接起来。

接下来,这个值将通过os.path.abspath()方法进行处理,该方法提供操作系统环境中的完整文件路径。然后我们将此值返回给最初调用它的函数。如我们在流程图中所见,许多函数会调用make_path()函数:

078     def make_path(file_name):
079     """
080     The make_path function provides an absolute path between the
081     output_directory and a file
082     :param file_name: A string representing a file name
083     :return: A string representing the path to a specified file
084     """
085     return os.path.abspath(os.path.join(output_directory,
086         file_name))

使用folder_traverse()函数进行迭代

这个函数递归地遍历文件夹,以解析消息项,并间接地生成文件夹的摘要报告。该函数最初通过根目录提供,经过通用开发,可以处理传递给它的任何文件夹项。这使得我们可以在每次发现子文件夹时重用该函数。在第 97 行,我们使用for循环递归遍历从我们的pypff.folder对象生成的sub_folders迭代器。在第 98 行,我们检查文件夹对象是否有任何额外的子文件夹,如果有,则在检查当前文件夹中的新消息之前再次调用folder_traverse()函数。只有在没有新子文件夹的情况下,我们才会检查是否有新消息:

089 def folder_traverse(base):
090     """
091     The folder_traverse function walks through the base of the
092     folder and scans for sub-folders and messages
093     :param base: Base folder to scan for new items within
094         the folder.
095     :return: None
096     """
097     for folder in base.sub_folders:
098         if folder.number_of_sub_folders:
099             folder_traverse(folder) # Call new folder to traverse
100         check_for_msgs(folder)

这是一个递归函数,因为我们在函数内部调用了相同的函数(某种形式的循环)。这个循环可能会无限运行,因此我们必须确保数据输入有一个结束点。PST 应该有有限数量的文件夹,因此最终会退出递归循环。这基本上是我们 PST 特定的os.walk()函数,它遍历文件系统目录。由于我们处理的是文件容器中的文件夹和消息,我们必须自己实现递归。递归可能是一个难以理解的概念;为了帮助你理解,在阅读接下来的解释时,请参考以下图示:

在上面的图示中,PST 层次结构中有五个级别,每个级别包含蓝色文件夹和绿色消息的混合。在第 1级,我们有根文件夹,这是folder_traverse()循环的第一次迭代。由于此文件夹有一个子文件夹个人文件夹顶部,如第 2级所示,我们在探索消息内容之前重新运行该函数。当我们重新运行该函数时,我们现在评估个人文件夹顶部文件夹,并发现它也有子文件夹。

在每个子文件夹上再次调用 folder_traverse() 函数时,我们首先处理第3级的 Deleted Items 文件夹。在第 4 级的 Deleted Items 文件夹中,我们发现这里只包含消息,并首次调用 check_for_msgs() 函数。

check_for_msgs() 函数返回后,我们回到第 3 级的 folder_traverse() 函数的上一调用,并评估 Sent Items 文件夹。由于 Sent Items 文件夹也没有子文件夹,我们在返回第 3 级之前处理它的消息。

然后,我们到达第 3 级的 Inbox 文件夹,并在第 4 级的 Completed Cases 子文件夹上调用 folder_traverse() 函数。现在我们进入第 5 级,处理 Completed Cases 文件夹中的两条消息。处理完这两条消息后,我们返回到第 4 级,处理 Inbox 文件夹中的两条消息。完成这些消息的处理后,我们就完成了第 3、4 和 5 级的所有项目,最终可以返回到第 2 级。在 Root Folder 中,我们可以处理那里的三条消息项,之后函数执行结束。我们的递归在这种情况下是自下而上的。

这四行代码允许我们遍历整个 PST 并对每个文件夹中的每条消息执行额外的处理。虽然这种功能通常通过 os.walk() 等方法提供,但有些库原生不支持递归,要求开发者使用库中的现有功能来实现。

使用 check_for_msgs() 函数识别消息

该函数会为每个发现的文件夹在 folder_traverse() 函数中调用,并处理消息。第 110 行,我们记录文件夹的名称,以提供已处理内容的记录。接下来,我们在第 111 行创建一个列表来附加消息,并在第 112 行开始迭代文件夹中的消息。

在这个循环中,我们调用 process_msg() 函数,将相关字段提取到字典中。在每个消息字典被附加到列表后,我们调用 folder_report() 函数,该函数将生成该文件夹内所有消息的汇总报告:

103 def check_for_msgs(folder):
104     """
105     The check_for_msgs function reads folder messages if
106     present and passes them to the report function
107     :param folder: pypff.Folder object
108     :return: None
109     """
110     logger.debug("Processing Folder: " + folder.name)
111     message_list = []
112     for message in folder.sub_messages:
113         message_dict = process_msg(message)
114         message_list.append(message_dict)
115     folder_report(message_list, folder.name)

process_msg() 函数中处理消息

这个函数是调用最频繁的函数,因为它会为每个发现的消息执行。当你考虑如何提高代码库的效率时,这些就是需要关注的函数。即使是对频繁调用的函数进行微小的效率优化,也能对脚本产生很大的影响。

在这种情况下,函数很简单,主要用于去除另一个函数中的杂乱内容。此外,它将消息处理封装在一个函数中,使得排查与消息处理相关的错误更加容易。

第 126 行的返回语句将一个字典传递给调用函数。该字典为每个pypff.message对象的属性提供一个键值对。请注意,subjectsendertransport_headersplain_text_body属性是字符串类型。creation_timeclient_submit_timedelivery_time属性是 Python 的datetime.datetime对象,而number_of_attachments属性是整数类型。

subject属性包含消息中的主题行,sender_name包含发送消息的发件人名称的单一字符串。发件人名称可能反映电子邮件地址或联系人名称,具体取决于接收者是否解析了该名称。

transport_headers包含与任何消息一起传输的电子邮件头数据。由于新数据会被添加到头部的顶部,因此应该从底部向上读取这些数据,以便随着消息在邮件服务器之间的移动,我们能够追踪消息的路径。我们可以利用这些信息,通过主机名和 IP 地址可能追踪消息的流动。plain_text_body属性返回纯文本形式的正文,虽然我们也可以使用rtf_bodyhtml_body属性分别以 RTF 或 HTML 格式显示消息。

creation_timesdelivery_times反映了消息的创建时间和接收到的消息被交付到正在检查的 PST 的时间。client_submit_time值是消息发送的时间戳。最后显示的属性是number_of_attachments属性,它用于查找要提取的额外数据。

118 def process_msg(message):
119     """
120     The process_msg function processes multi-field messages
121     to simplify collection of information
122     :param message: pypff.Message object
123     :return: A dictionary with message fields (values) and
124         their data (keys)
125     """
126     return {
127         "subject": message.subject,
128         "sender": message.sender_name,
129         "header": message.transport_headers,
130         "body": message.plain_text_body,
131         "creation_time": message.creation_time,
132         "submit_time": message.client_submit_time,
133         "delivery_time": message.delivery_time,
134         "attachment_count": message.number_of_attachments,
135     }

此时,pypff模块不支持与附件的交互,尽管libpff库可以使用其pffexportpffinfo工具提取相关数据。要构建这些工具,我们必须在构建时运行./configure命令时,在命令行中包含--enable-static-executables参数。

使用这些选项构建后,我们可以运行前面提到的工具,将 PST 附件导出到一个结构化的目录中。开发人员已表示将会在未来的版本中添加pypff对附件的支持。如果该功能发布,我们将能够与消息附件进行交互,并对发现的文件执行额外的处理。如果分析需要此功能,我们可以通过ossubprocess库在 Python 中调用pffexport工具来增加支持。

folder_report()函数中汇总数据

到此为止,我们已经收集了大量关于消息和文件夹的信息。我们使用此代码块将数据导出为一个简单的报告以供审查。为了创建这个报告,我们需要message_listfolder_name变量。在 146 行,我们检查message_list中是否有条目;如果没有,我们记录一个警告并返回该函数,以防止剩余的代码继续执行。

如果message_list中有内容,我们开始创建 CSV 报告。我们首先通过将所需的文件名传入make_path()函数来生成输出目录中的文件名,从而获取我们希望写入的文件的绝对路径。使用该文件路径,我们以wb模式打开文件,以便写入 CSV 文件,并防止在报告的行与行之间添加额外的空行(见第 152 行)。在接下来的行中,我们定义了输出文档的头部列表。

此列表应反映我们希望报告的列的顺序列表。可以自由修改第 153 行和 154 行,以反映首选顺序或额外的行。所有附加的行必须是message_list变量中所有字典的有效键。

在写入头部后,我们在第 155 行启动csv.DictWriter类。如果你记得我们脚本开始时导入了unicodecsv库,以处理在写入 CSV 时的 Unicode 字符。在这个导入过程中,我们使用as关键字将模块从unicodecsv重命名为csv,以便在脚本中使用。该模块提供与标准库相同的方法,因此我们可以继续使用我们在csv库中见过的熟悉的函数调用。在初始化DictWriter()时,我们传递了打开的文件对象、字段名称以及一个参数,告诉该类如何处理message_list字典中未使用的信息。由于我们并未使用message_list列表中字典的所有键,因此我们需要告诉DictWriter()类忽略这些值,如下所示:

138 def folder_report(message_list, folder_name):
139     """
140     The folder_report function generates a report per PST folder
141     :param message_list: A list of messages discovered
142         during scans
143     :folder_name: The name of an Outlook folder within a PST
144     :return: None
145     """
146     if not len(message_list):
147         logger.warning("Empty message not processed")
148         return
149 
150     # CSV Report
151     fout_path = make_path("folder_report_" + folder_name + ".csv")
152     fout = open(fout_path, 'wb')
153     header = ['creation_time', 'submit_time', 'delivery_time',
154         'sender', 'subject', 'attachment_count']
155     csv_fout = csv.DictWriter(fout, fieldnames=header,
156         extrasaction='ignore')
157     csv_fout.writeheader()
158     csv_fout.writerows(message_list)
159     fout.close()

初始化并配置好csv_fout变量后,我们可以开始使用第 157 行的writeheaders()方法调用来写入头部数据。接下来,我们使用writerows()方法将感兴趣的字典字段写入文件。写入所有行后,我们关闭fout文件,将其写入磁盘,并释放对象的句柄(见第 159 行)。

在第 119 行到第 141 行之间,我们准备了来自message_list的字典,用于生成 HTML 报告统计数据。我们需要调用第 162 行中的global语句,以便我们可以编辑date_list全局变量。然后我们打开两个文本文件,记录所有主体内容和发件人名称的原始列表。这些文件将在后续部分用于生成我们的统计数据,并以不会消耗大量内存的方式收集这些数据。这两个文本文件(见第 163 和第 164 行)以a模式打开,如果文件不存在则会创建该文件,如果文件存在,则会将数据追加到文件末尾。

在第 165 行,我们启动一个for循环,遍历message_list中的每个消息m。如果消息体键有值,则将其值写入输出文件,并使用两个换行符分隔此内容。接着,在第 168 和 169 行,我们对发件人键及其值执行类似的过程。在这种情况下,我们只使用一个换行符,以便稍后在另一个函数中更方便地迭代:

162     global date_list # Allow access to edit global variable
163     body_out = open(make_path("message_body.txt"), 'a')
164     senders_out = open(make_path("senders_names.txt"), 'a')
165     for m in message_list:
166         if m['body']:
167             body_out.write(m['body'] + "\n\n")
168         if m['sender']:
169             senders_out.write(m['sender'] + '\n')

在收集完消息内容和发件人信息后,我们开始收集日期信息。为了生成热力图,我们将所有三个活动日期合并为一个总计数,形成一个单一的图表。在确认有有效的日期值后,我们获取星期几的信息,以确定在date_list列表中的哪个字典需要更新。

Python 的datetime.datetime库有一个weekday()方法和一个.hour属性,它们允许我们以整数形式访问这些值,并处理繁琐的转换。weekday()方法返回一个从 0 到 6 的整数,其中 0 代表星期一,6 代表星期天。.hour属性返回一个 0 到 23 之间的整数,表示 24 小时制的时间,尽管我们用于热力图的 JavaScript 要求一个 1 到 24 之间的整数才能正确处理。因此,我们在第 175、181 和 187 行中对每个小时值加 1。

现在我们拥有了更新date_list中值所需的正确星期几和时间段键。在完成循环后,我们可以在第 189 和 190 行关闭两个文件对象:

171         # Creation Time
172         c_time = m['creation_time']
173         if c_time isn't None:
174             day_of_week = c_time.weekday()
175             hour_of_day = c_time.hour + 1
176             date_list[day_of_week][hour_of_day] += 1
177         # Submit Time
178         s_time = m['submit_time']
179         if s_time isn't None:
180             day_of_week = s_time.weekday()
181             hour_of_day = s_time.hour + 1
182             date_list[day_of_week][hour_of_day] += 1
183         # Delivery Time
184         d_time = m['delivery_time']
185         if d_time isn't None:
186             day_of_week = d_time.weekday()
187             hour_of_day = d_time.hour + 1
188             date_list[day_of_week][hour_of_day] += 1
189     body_out.close()
190     senders_out.close()

理解word_stats()函数

在将消息内容写入文件后,我们现在可以使用它来计算单词使用频率。我们使用从 collections 库中导入的Counter模块,以高效的方式生成单词计数。

我们将word_list初始化为一个Counter()对象,这使得我们可以在调用时给它分配新单词,并跟踪每个单词的总体计数。在初始化后,我们在第 200 行启动一个for循环,打开文件,并使用readlines()方法逐行迭代:

193 def word_stats(raw_file="message_body.txt"):
194     """
195     The word_stats function reads and counts words from a file
196     :param raw_file: The path to a file to read
197     :return: A list of word frequency counts
198     """
199     word_list = Counter()
200     for line in open(make_path(raw_file), 'r').readlines():

此时,我们需要使用split()方法将行拆分成单个单词的列表,以生成正确的计数。通过不向split()传递参数,我们将按所有空白字符进行拆分,在这种情况下,这对我们有利。在第 201 行的拆分之后,我们使用条件语句确保只有长度大于四个字符的单词被包含在我们的列表中,以去除常见的填充词或符号。此逻辑可以根据您的环境进行调整,例如,您可能希望包括少于四个字母的单词或其他过滤标准。

如果条件判断为真,我们将单词添加到计数器中。在第 204 行,我们将单词在列表中的值增加 1。遍历完message_body.txt文件的每一行和每个单词后,我们将这个单词列表传递给word_report()函数:

201         for word in line.split():
202             # Prevent too many false positives/common words
203             if word.isalnum() and len(word) > 4:
204                 word_list[word] += 1
205     return word_report(word_list)

创建 word_report() 函数

一旦 word_listword_stats() 函数传递过来,我们就可以使用提供的数据生成报告。为了更好地控制数据的展示方式,我们将手动生成 CSV 报告,而不依赖 csv 模块。首先,在第 216 行,我们需要确保 word_list 中有值。如果没有,函数会记录一个警告并返回。在第 220 行,我们以 wb 模式打开一个新文件对象以创建 CSV 报告。在第 221 行,我们将 CountWord 表头写入第一行,并使用换行符确保所有其他数据写入下面的行:

208 def word_report(word_list):
209     """
210     The word_report function counts a list of words and returns
211     results in a CSV format
212     :param word_list: A list of words to iterate through
213     :return: None or html_report_list, a list of word
214         frequency counts
215     """
216     if not word_list:
217         logger.debug('Message body statistics not available')
218         return []
219 
220     fout = open(make_path("frequent_words.csv"), 'wb')
221     fout.write("Count,Word\n")

然后,我们使用 for 循环和 most_common() 方法调用每个单词及其对应的计数值。如果元组的长度大于 1,我们就将这些值按相反的顺序写入 CSV 文档,以便正确对齐列与值,并加上换行符。在这个循环完成后,我们关闭文件并将结果刷新到磁盘,正如第 225 行所示:

222     for e in word_list.most_common():
223         if len(e) > 1:
224             fout.write(str(e[1]) + "," + str(e[0]) + "\n")
225     fout.close()

紧接着这个循环,我们会生成前 10 个单词的列表。通过将整数 10 传递给 most_common() 方法,我们只选择 Counter 中最常见的前 10 项。我们将结果的字典追加到临时列表中,该列表返回给 word_stats() 函数,并随后用于我们的 HTML 报告:

227     html_report_list = []
228     for e in word_list.most_common(10):
229         html_report_list.append(
230             {"word": str(e[0]), "count": str(e[1])})
231 
232     return html_report_list

构建 sender_report() 函数

sender_report() 函数类似于 word_report(),它为发送电子邮件的个人生成 CSV 和 HTML 报告。这个函数展示了另一种将值读取到 Counter() 方法中的方式。在第 242 行,我们打开并读取文件的行到 Counter() 方法中。

我们可以这样实现,因为输入文件的每一行代表一个单独的发件人。以这种方式统计数据简化了代码,并且通过简化写作,也为我们节省了几行代码。

word_stats() 函数并不适用这种方法,因为我们必须将每一行拆分成单独的单词,然后在计数之前执行额外的逻辑操作。如果我们想对发件人统计信息应用逻辑,我们需要创建一个类似于 word_stats() 中的循环。例如,我们可能想排除所有来自 Gmail 的项,或是发件人姓名或地址中包含 noreply 字样的项:

235 def sender_report(raw_file="senders_names.txt"):
236     """
237     The sender_report function reports the most frequent_senders
238     :param raw_file: The file to read raw information
239     :return: html_report_list, a list of the most
240         frequent senders
241     """
242     sender_list = Counter(
243         open(make_path(raw_file), 'r').readlines())

在生成发件人计数之后,我们可以打开 CSV 报告并将表头写入其中。此时,我们将在第 247 行看到的 for 循环中迭代每一个最常见的项,如果元组包含多个元素,我们就将其写入文件。

这是另一个可以根据发件人姓名过滤值的地方。写入后,文件被关闭并刷新到磁盘。在第 252 行,我们通过生成一个包含元组值的字典列表来为最终报告生成前五名发件人的统计数据。为了在 HTML 报告功能中访问它,我们返回这个列表。请参见以下代码:

245     fout = open(make_path("frequent_senders.csv"), 'wb')
246     fout.write("Count,Sender\n")
247     for e in sender_list.most_common():
248         if len(e) > 1:
249             fout.write(str(e[1]) + "," + str(e[0]))
250     fout.close()
251 
252     html_report_list = []
253     for e in sender_list.most_common(5):
254         html_report_list.append(
255             {"label": str(e[0]), "count": str(e[1])})
256 
257     return html_report_list

使用 date_report() 函数完善热力图

本报告提供了生成活动热力图的数据。为了确保其正常运行,文件名和路径必须与 HTML 模板中指定的相同。该文件的默认模板名为heatmap.tsv,并与输出的 HTML 报告位于同一目录下。

打开文件并加载第 267 行中的默认设置后,我们写入标题,使用制表符分隔日期、小时和值列,并以换行符结尾。此时,我们可以通过两个 for 循环开始遍历我们的字典列表,访问每个包含字典的列表。

在第一个 for 循环中,我们使用 enumerate() 方法捕获循环的迭代次数。这个数字恰好对应我们正在处理的日期,使我们能够使用该值写入日期值:

260 def date_report():
261     """
262     The date_report function writes date information in a
263     TSV report. No input args as the filename
264     is static within the HTML dashboard
265     :return: None
266     """
267     csv_out = open(make_path("heatmap.tsv"), 'w')
268     csv_out.write("day\thour\tvalue\n")
269     for date, hours_list in enumerate(date_list):

在第二个 for 循环中,我们遍历每个字典,使用 items() 方法分别提取小时和计数值,返回的键值对作为元组。通过这些值,我们可以将日期、小时和计数赋值给制表符分隔的字符串,并写入文件。

在第 271 行,我们将日期值加 1,因为热力图图表使用的是 1 到 7 的范围,而我们的列表使用的是 0 到 6 的索引来表示一周的七天。

在遍历小时数据后,我们将数据刷新到磁盘,然后继续处理下一个小时的数据字典。完成七天的数据遍历后,我们可以关闭此文档,它已准备好与我们的热力图图表一起在 html_report() 函数中使用:

270         for hour, count in hours_list.items():
271             to_write = "{}\t{}\t{}\n".format(date+1, hour, count)
272             csv_out.write(to_write)
273         csv_out.flush()
274     csv_out.close()

编写 html_report() 函数

html_report() 函数是将从 PST 中收集的所有信息组合成最终报告的地方,充满期待地生成此报告。为了生成该报告,我们需要传入指定报告标题、PST 名称以及最常见单词和发件人的计数等参数:

277 def html_report(report_title, pst_name, top_words, top_senders):
278     """
279     The html_report function generates the HTML report from a
280         Jinja2 Template
281     :param report_title: A string representing the title of
282         the report
283     :param pst_name: A string representing the file name of
284         the PST
285     :param top_words: A list of the top 10 words
286     :param top_senders: A list of the top 10 senders
287     :return: None
288     """

首先,我们打开模板文件,并将其内容读取到一个变量中,作为字符串传入我们的 jinja2.Template 引擎,处理成模板对象 html_template,该操作发生在第 290 行。

接下来,我们创建一个字典,将值传入模板的占位符,并在第 292 行使用 context 字典保存这些值。字典创建完毕后,我们在第 295 行渲染模板并提供 context 字典。渲染后的数据是 HTML 数据字符串,正如你在网页中看到的一样,所有占位符逻辑都被评估并转化为静态 HTML 页面。

我们将渲染后的 HTML 数据写入用户指定目录中的输出文件,如第 297 到 299 行所示。HTML 报告写入输出目录后,报告完成,并可以在输出文件夹中查看:

289     open_template = open("stats_template.html", 'r').read()
290     html_template = jinja2.Template(open_template)
291 
292     context = {"report_title": report_title, "pst_name": pst_name,
293         "word_frequency": top_words,
294         "percentage_by_sender": top_senders}
295     new_html = html_template.render(context)
296 
297     html_report_file = open(make_path("pst_report.html"), 'w')
298     html_report_file.write(new_html)
299     html_report_file.close()

HTML 模板

本书重点介绍 Python 在法医学中的应用。尽管 Python 提供了许多很棒的方法来操作和应用逻辑于数据,但我们仍然需要依赖其他资源来支持我们的脚本。在本章中,我们构建了一个 HTML 仪表板来展示关于这些 PST 文件的统计信息。

在本节中,我们将回顾 HTML 的各个部分,重点关注数据插入模板的部分,而不是 HTML、JavaScript 和其他 Web 语言的复杂细节。如需更多关于 HTML、JavaScript、D3.js 和其他 Web 资源的使用和实现的信息,请访问packtpub.com查找相关书籍,或访问w3schools.com查阅入门教程。由于我们不会深入探讨 HTML、CSS 或其他 Web 设计方面的问题,我们的重点将主要放在 Python 脚本与这些部分的交互上。

这个模板利用了几个常见的框架,允许快速设计专业外观的网页。第一个是 Bootstrap 3,它是一个 CSS 样式框架,能够将 HTML 组织和样式化,使其无论在哪种设备上查看页面,都能保持一致和整洁。第二个是 D3.js 框架,它是一个用于图形可视化的 JavaScript 框架。

如前所示,我们将数据插入的模板项包含在双括号{{ }}中。我们将在第 39 行和第 44 行插入 HTML 仪表板的报告标题。此外,我们将在第 48 行、第 55 行和第 62 行插入 PST 文件的名称。第 51 行、第 58 行和第 65 行的div id标签作为图表的变量名,可以在模板的后续部分通过 JavaScript 插入这些图表,一旦代码处理了输入:

...
038   </style>
039   <title>{{ report_title }}</title>
040 </head>
041 <body>
042   <div class="container">
043     <div class="row">
044       <h1>{{ report_title }}</h1>
045     </div>
046     <div class="row">
047         <div class="row">
048           <h3>Top 10 words in {{ pst_name }}</h3>
049         </div>
050         <div class="row">
051           <div id="wordchart">
052           </div>
053         </div>
054         <div class="row">
055           <h3>Top 5 Senders in {{ pst_name }}</h3>
056         </div>
057         <div class="row">
058           <div id="piechart">
059           </div>
060         </div>
061         <div class="row">
062           <h3>Heatmap of all date activity in {{ pst_name }}</h3>
063         </div>
064         <div class="row">
065           <div id="heatmap"></div>
066         </div>
067       </div>
068     </div>
... 

div占位符元素就位后,第 69 行到 305 行的 JavaScript 将提供的数据处理为图表。第 92 行放置了第一个位置数据,在该行,{{ word_frequency }}短语被字典列表替换。例如,可以替换为[{'count': '175', 'word': 'message'}, {'count': '17', 'word': 'iPhone'}]。这个字典列表被转换为图表值,形成 HTML 报告中的垂直条形图:

...
088         .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
089
090         data = {{ word_frequency }}
091
092       function processData(data) {x.domain(data.map(function(d) {
093         return d;
094       }
...

在第 132 行,我们将上下文字典中的percentage_by_sender值插入到 JavaScript 中。此替换将与word_frequency插入的例子类似。通过这些信息,甜甜圈图表将在 HTML 报告中生成:

...  
129     (function(d3) { 
130       'use strict'; 
131  
132       var dataset = {{ percentage_by_sender }}; 
133  
134       var width = 960; 
...

我们将使用一种新的方式来插入热图数据。通过提供上一节中讨论的文件名,我们可以提示代码在与此 HTML 报告相同的目录中查找heatmap.tsv文件。这样做的好处是,我们能够一次生成报告,并在像 Excel 这样的程序中以及在仪表板中使用 TSV 文件,尽管缺点是该文件必须与 HTML 报告一起传输,以便正确显示,因为图表将在重新加载时重新生成。

这个图表在某些浏览器上可能无法正常渲染,因为不同浏览器对 JavaScript 的解释方式不同。测试表明,Chrome、Firefox 和 Safari 都能正常查看该图形。请确保浏览器插件不会干扰 JavaScript,并且浏览器没有阻止 JavaScript 与本地文件的交互。如果您的浏览器不允许这样做,可以考虑在 Docker 实例中运行脚本,启动 lighttpd 服务,并将输出放置在 /var/www/html 中。当您访问 Docker 实例的 IP 地址时,您将能够浏览报告,因为服务器将为您提供对资源的访问:

174         times = ["1a", "2a", "3a", "4a", "5a", "6a", "7a", "8a", "9a", "10a", "11a", "12a", "1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", "10p", "11p", "12p"];
175
176       datasets = ["heatmap.tsv"];
177
178       var svg = d3.select("#heatmap").append("svg") 

模板的其余部分可以在代码库中找到,如果 Web 编程语言是您的强项,或者值得进一步探索,它可以轻松地被引用和修改。D3.js 库使我们能够创建更多的信息图形,并为我们的报告工具箱添加了另一个相对简单且便于移植的工具。以下图形展示了我们创建的三个图表中每个图表的数据示例。

第一个图表表示 PST 文件中使用频率最高的单词。频率绘制在 y 轴上,单词则绘制在 x 轴上:

以下图表显示了向用户发送电子邮件的前五个账户。请注意,圆形图表有助于识别数据集中最频繁的参与者。此外,文本标签提供了地址的名称和该地址接收到的电子邮件数量:

最后,以下热力图将所有电子邮件聚合为每小时的单元格,按天分组。这对于识别数据集中的趋势非常有用。

例如,在这种情况下,我们可以快速识别出大多数电子邮件是在清晨时分发送或接收的,特别是在每周二的早上 6 点。图形底部的条形图表示电子邮件的数量。例如,周二早上 6 点的单元格颜色表示在该时段内发送或接收了超过 1,896 封电子邮件:

运行脚本

代码已经完成,包括脚本和 HTML 模板,我们准备好执行代码了!在我们的 Ubuntu 环境中,我们需要运行以下命令并提供 PST 进行分析。如果您的 Ubuntu 机器已配置了 Web 服务器,则可以将输出放置在 Web 目录中,并作为网站提供给其他用户浏览。

如果您打算使用 Docker 容器方法来运行此代码,则需要使用以下命令将 PST 文件复制到容器中。请注意,以下语法为 docker cp src_file container_name:/path/on/container,更多功能请参见 docker cp --help

$ docker cp sample.pst pst_parser:/opt/book

现在我们的 PST 文件已位于容器中,我们可以按如下方式运行脚本:

上面的截图显示我们使用/var/www/html作为输出目录。这意味着如果我们在 Docker 容器中运行lighttpd服务,我们将能够浏览到容器的 IP 地址,并在系统的浏览器中查看内容。你需要运行docker container ls pst_parser来获取 Web 服务器所在的正确端口。

额外的挑战

对于这个项目,我们邀请你实现一些改进,使我们的脚本更加多功能。如本章前面提到的,pypff目前并不原生支持提取或直接与附件交互。然而,我们可以在 Python 脚本中调用pffexportpffinfo工具来实现这一功能。我们建议查看subprocess模块以实现这一目标。进一步扩展这个问题,我们如何将其与上一章中介绍的代码连接起来?一旦我们访问了附件,可能会有哪些数据可用?

考虑一些方法,允许用户提供过滤选项,以便收集感兴趣的特定邮件,而不是整个 PST 文件。一个可能帮助用户提供更多配置选项的库是ConfigParser,可以通过pip安装。

最后,另一个挑战是通过添加更多的图表和图形来改进 HTML 报告。一个例子是解析transit_headers并提取 IP 地址。通过这些 IP 地址,你可以进行地理定位,并使用 D3.js 库将其绘制在地图上。这种信息可以通过从所有潜在数据点提取尽可能多的信息,提升报告的实用性。

总结

电子邮件文件包含大量有价值的信息,使得法医检查员能够更深入地了解通讯内容以及用户随时间变化的活动。利用开源库,我们能够探索 PST 文件并提取其中关于邮件和文件夹的信息。我们还检查了邮件的内容和元数据,以收集关于频繁联系人、常用词汇和活动的异常热点的额外信息。通过这一自动化过程,我们可以更好地理解我们审查的数据,并开始识别隐藏的趋势。该项目的代码可以从 GitHub 或 Packt 下载,具体请参见前言

识别隐藏信息在所有调查中都非常重要,这是数据恢复成为法医调查过程重要基石的众多原因之一。

在下一章中,我们将介绍如何从一个难以处理的来源——数据库中恢复数据。通过使用多个 Python 库,我们将能够恢复本来可能丢失的数据,并获得有关那些数据库不再跟踪的记录的宝贵见解。

第十二章:恢复临时数据库记录

在本章中,我们将重新审视 SQLite 数据库,并检查一种叫做 预写日志 (WAL) 的日志文件类型。由于其底层结构的复杂性,解析 WAL 文件比我们之前处理 SQLite 数据库时的任务要更具挑战性。没有现成的模块可以像我们使用 sqlite3peewee 与 SQLite 数据库那样直接与 WAL 文件交互。相反,我们将依赖 struct 库以及理解二进制文件的能力。

一旦成功解析 WAL 文件,我们将利用 Python 中的正则表达式库 re 来识别潜在的相关取证数据。最后,我们将简要介绍使用第三方 tqdm 库创建进度条的另一种方法。通过几行代码,我们将实现一个功能齐全的进度条,能够向用户反馈程序执行情况。

WAL 文件可以包含已不再存在或尚未添加到 SQLite 数据库中的数据。它还可能包含修改记录的前一个副本,并为取证调查员提供有关数据库如何随时间变化的线索。

本章我们将探讨以下主题:

  • 解析复杂的二进制文件

  • 学习并利用正则表达式来定位指定的数据模式

  • 通过几行代码创建一个简单的进度条

  • 使用内建的 Python 调试器 pdb 快速排除代码故障

本章的代码在 Python 2.7.15 和 Python 3.7.1 中开发和测试。

SQLite WAL 文件

在分析 SQLite 数据库时,检查员可能会遇到额外的临时文件。SQLite 有九种类型的临时文件:

  • 回滚日志

  • 主日志

  • 语句日志

  • WAL

  • 共享内存文件

  • TEMP 数据库

  • 视图和子查询物化

  • 临时索引

  • 临时数据库

更多关于这些文件的详细信息,请参考 www.sqlite.org/tempfiles.html,该页面对这些文件进行了更详细的描述。WAL 是这些临时文件之一,并且参与原子提交和回滚场景。只有设置了 WAL 日志模式的数据库才会使用预写日志方法。配置数据库使用 WAL 日志模式的 SQLite 命令如下:

PRAGMA journal_mode=WAL; 

WAL 文件与 SQLite 数据库位于同一目录中,并且文件名会在原始 SQLite 数据库文件名后附加 -wal。当连接到 SQLite 数据库时,会临时创建一个 WAL 文件。此 WAL 文件将包含对数据库所做的任何更改,而不会影响原始的 SQLite 数据库。使用 WAL 文件的优点包括并发和更快速的读/写操作。有关 WAL 文件的具体信息,请参见 www.sqlite.org/wal.html

默认情况下,当 WAL 文件达到 1000 个页面或最后一个连接关闭时,WAL 文件中的记录会被提交到原始数据库。

WAL 文件在取证中具有相关性,原因有二:

  • 审查数据库活动的时间线

  • 恢复删除或更改的记录

Epilog 的创建者们写了一篇详细的文章,讲解了 WAL 文件在取证中的具体意义,文章可以在 digitalinvestigation.wordpress.com/2012/05/04/the-forensic-implications-of-sqlites-write-ahead-log/ 阅读。通过了解 WAL 文件的重要性、为什么使用它们以及它们在取证中的相关性,让我们一起来分析其底层结构。

WAL 格式和技术规格

WAL 文件是由包含嵌入式 B 树页面的帧组成,这些 B 树页面对应于实际数据库中的页面。我们不会深入讨论 B 树的工作原理。相反,我们将关注一些重要字节偏移量,以便更好地理解代码,并进一步展示 WAL 文件的取证相关性。

WAL 文件的主要组件包括以下内容:

  • WAL 头部(32 字节)

  • WAL 帧(页面大小)

  • 帧头部(24 字节)

  • 页面头部(8 字节)

  • WAL 单元格(可变长度)

请注意,WAL 帧的大小由页面大小决定,该页面大小可以从 WAL 头部提取。

以下图表展示了 WAL 文件的高层次结构:

让我们来看看 WAL 文件的每个高层次类别。一些结构的描述可以参考 www.sqlite.org/fileformat2.html

WAL 头部

32 字节的 WAL 头部包含诸如页面大小、检查点数量、WAL 文件大小,以及间接地,WAL 文件中帧的数量等属性。以下表格详细列出了头部中存储的 8 个大端 32 位整数的字节偏移量和描述:

字节偏移量 描述
0-3 文件签名 这是 0x377F06820x377F0683
4-7 文件版本 这是 WAL 格式的版本,当前版本为 3007000。
8-11 数据库页面大小 这是数据库中页面的大小,通常为 1024 或 4096。
12-15 检查点编号 这是已发生的提交数量。
16-19 盐值-1 这是一个随每次提交递增的随机整数。
20-23 盐值-2 这是一个随每次提交而变化的随机整数。
24-27 校验和-1 这是头部校验和的第一部分。
28-31 校验和-2 这是头部校验和的第二部分。

文件签名应该始终是 0x377F06820x377F0683。数据库页面大小是一个非常重要的值,因为它允许我们计算 WAL 文件中有多少个帧。例如,在使用 4,096 字节页面的 20,632 字节 WAL 文件中,有 5 个帧。为了正确计算帧的数量,我们需要在以下公式中考虑 32 字节的 WAL 头部和 24 字节的 WAL 帧头部:

(WAL File Size - 32) / (WAL Page Size + 24) 
20,600 / 4,120 = 5 frames 

检查点编号表示触发了多少次提交,可能是自动触发的,也可能是通过执行 PRAGMA wal_checkpoint 手动触发的。现在,让我们关注 Salt-1 值。在创建数据库活动时间线时,这是头部中最重要的值。Salt-1 值会随着每次提交而增加。除此之外,每个帧在提交时会在自己的头部存储当前的盐值。如果记录被修改并重新提交,较新的记录会有比前一个版本更大的 Salt-1 值。因此,我们可能会在 WAL 文件中看到某个记录在不同时间点的多个快照。

假设我们有一个包含一个表的数据库,存储与员工姓名、职位、薪水等相关的数据。早期,我们有一个记录,记载了 23 岁的自由职业摄影师彼得·帕克,薪水为 45,000 美元。几次提交后,帕克的薪水变为 150,000 美元,而且在同一次提交中,帕克的名字更新为蜘蛛侠:

Salt-1 行 ID 员工姓名 职位 薪水
0 -977652151 123 蜘蛛侠? 自由职业 150,000
1 -977652151 123 彼得·帕克 自由职业 150,000
2 -977652150 123 彼得·帕克 自由职业 45,000

因为这些条目共享相同的行 ID,我们知道这是在主表中记录 123 的三个不同版本。为了识别该记录的最新版本,我们需要检查 Salt-1 值。根据之前的讨论和记录的 Salt-1 值,我们知道帧 0 和 1 中的记录是最新的记录,并且自从该记录第一次添加到数据库后,已经进行了两次提交。

我们如何知道帧 0 和帧 1 中哪个记录是最新的?如果我们处理的是同一次提交中有两个记录的情况,较早的帧中的记录被认为是最新的。这是因为 WAL 文件会将新帧添加到文件的开头,而不是结尾。因此,帧 0 中的记录是最新的,而帧 2 中的记录是最旧的。

请注意,每个帧中可以有多个记录。较新的记录位于帧的开头。

在数据库中,我们只会看到该记录的最新版本,但在 WAL 文件中,我们可以看到之前的版本。只要 WAL 文件存在,我们仍然可以看到这些信息,即使带有行 ID 123 的记录已经从主数据库中删除。

WAL 帧

WAL 帧本质上是一个 B 树结构的页面,包含一个帧头。帧头包含 6 个大端 32 位整数:

字节偏移量 描述
0-3 页面编号 这是 WAL 文件中的帧或页面编号。
4-7 数据库大小 这是提交记录中数据库的页面数大小。
8-11 Salt-1 这是从 WAL 头部在写入帧时复制过来的。
12-15 Salt-2 这是从 WAL 头部在写入帧时复制过来的。
16-19 校验和-1 这是包括此帧在内的累计校验和。
20-23 校验和-2 这是校验和的第二部分。

Salt-1 值只是创建帧时从 WAL 头部复制的 Salt-1 值。我们使用存储在帧中的这个值来确定前一个示例中的事件时间。页面编号是从零开始的整数,其中零是 WAL 文件中的第一个帧。

在帧头之后是数据库中单个页面的内容,从页面头部开始。页面头部由两个 8 位和三个 16 位的大端整数组成:

字节偏移量 描述
0 B-树标志 这是 B 树节点的类型
1-2 自由块 这是页面中的自由块数量。
3-4 单元格数量 这是页面中的单元格数量。
5-6 单元格偏移量 这是相对于该头部开始位置的第一个单元格的字节偏移量。
7 碎片 这些是页面中碎片化的自由块数量。

有了这些信息,我们现在知道了我们处理的单元格数量和第一个单元格的偏移量。在该头部之后,是N个大端 16 位整数,指定每个单元格的偏移量。单元格的偏移量是相对于页面头部的开始位置的。

WAL 单元格和 varints

每个单元格由以下组件组成:

  • 负载长度(varint)

  • 行 ID(varint)

  • 负载头部:

    • 负载头部长度(varint)

    • 序列类型数组(varints)

  • 负载

负载长度描述了单元格的总体长度。行 ID 是实际数据库中对应该记录的唯一键。负载头部中的序列类型数组包含负载中数据的长度和类型。我们可以通过减去负载头部长度来确定单元格中实际记录的数据的字节数。

请注意,这些值大多数是变长整数(varints),即可变长度整数。SQLite 中的变长整数是一种根据每个字节的第一个位大小变化,范围从 1 到 9 字节不等的整数。如果第一个位被设置为 1,则下一个字节是变长整数的一部分。这个过程会持续,直到你得到一个 9 字节的变长整数,或者字节的第一个位没有被设置。对于所有小于 128 的 8 位整数,第一个位没有被设置。这使得在这种文件格式中,较大的数字能够灵活地存储。关于变长整数的更多细节可以参考www.sqlite.org/src4/doc/trunk/www/varint.wiki

例如,如果处理的第一个字节是0x030b00000011,我们知道变长整数仅为一个字节,值为 3。如果处理的第一个字节是0x9A0b10011010,则第一个位被设置,变长整数至少为两个字节长,具体取决于下一个字节,使用相同的决策过程。对于我们的用途,我们只支持长度为 2 字节的变长整数。关于如何解析 WAL 文件的详细教程可以阅读www.forensicsfromthesausagefactory.blogspot.com/2011/05/analysis-of-record-structure-within.html。强烈建议在尝试开发代码之前,使用十六进制编辑器手动解析页面。通过在十六进制编辑器中检查变长整数,能更轻松地理解数据库结构,并帮助巩固你的理解。

大多数变长整数(varint)都可以在序列类型数组中找到。该数组紧接在有效负载头长度之后,值为 1。变长整数值的表格决定了单元格的大小和数据类型:

变长整数值 大小(字节) 数据类型
0 0 Null
1 1 8 位整数
2 2 大端 16 位整数
3 3 大端 24 位整数
4 4 大端 32 位整数
5 6 大端 48 位整数
6 8 大端 64 位整数
7 8 大端 64 位浮点数
8 0 整数常量:0
9 0 整数常量:1
10, 11 未使用
X >= 12 且为偶数 (X-12)/2 长度为(X-12)/2 的 BLOB
X >= 13 且为奇数 (X-13)/2 长度为(X-13)/2 的字符串

有效负载紧接着最后一个序列类型开始。我们来看一下如何使用变长整数正确地解析有效负载的内容。例如,假设给定以下序列类型:0、2、6、8 和 25,我们期望得到一个 16 字节的有效负载,包含一个Null值、一个 2 字节的 16 位整数、一个 8 字节的 64 位整数、一个常量 0 和一个 6 字节的字符串。字符串的大小是通过公式(25-13)/2 计算的。以下伪代码演示了这个过程:

Serial Types = 0, 2, 6, 8, and 25 
Payload = 0x166400000009C5BA3649737069646572 
Split_Payload = N/A , 0x1664, 0x00000009C5BA3649, N/A, 0x737069646572 
Converted_Payload = Null, 5732, 163953206, 0, "spider" 

上述示例说明了如何使用已知的序列类型解码 16 字节的有效载荷。我们将在开发程序时采用相同的方法。注意,序列类型 0、8 和 9 不需要在有效载荷中占用空间,因为它们的值是静态的。

在 Python 中操作大型对象

在开发任何脚本之前,尤其是处理大型复杂结构的脚本时,选择合适的数据类型至关重要。对于我们的解决方案,我们将使用字典和有序字典。字典和有序字典的区别在于,有序字典会保留添加项的顺序。这个功能对于我们的脚本并不重要,只是作为一种方便的功能使用。

字典允许我们将 WAL 文件的结构映射为键值对。最终,我们将创建一个大的嵌套字典对象,它可以轻松保存为 JSON 文件,供其他程序使用。这个数据类型的另一个优点是,我们可以通过描述性键来遍历多个字典。这可以用来在 WAL 文件的不同部分之间进行分区,并帮助保持处理过的数据有序。这涵盖了我们编写 WAL 文件解析脚本所需了解的所有高级细节。在此之前,让我们简要介绍正则表达式和TQDM进度条模块。

Python 中的正则表达式

正则表达式允许我们通过使用通用的搜索模式来识别数据模式。例如,查找文档中所有可能的XXX-XXX-XXXX类型的电话号码,可以通过一个正则表达式轻松完成。我们将创建一个正则表达式模块,它将对处理过的 WAL 数据运行一组默认的表达式或用户提供的表达式。默认表达式的目的是识别相关的取证信息,如 URL 或个人身份信息PII)。

虽然本节并不是正则表达式的入门教程,但我们将简要介绍其基础知识,以便理解其优势和代码中使用的正则表达式。在 Python 中,我们使用re模块对字符串进行正则表达式匹配。首先,我们必须编译正则表达式,然后检查字符串中是否有匹配项:

>>> import re 
>>> phone = '214-324-5555' 
>>> expression = r'214-324-5555' 
>>> re_expression = re.compile(expression) 
>>> if re_expression.match(phone): print(True)
...  
True

使用相同的字符串作为我们的表达式会得到一个正匹配。然而,这样做并不能捕获其他电话号码。正则表达式可以使用各种特殊字符,这些字符要么表示一组字符,要么定义前面的元素如何解释。我们使用这些特殊字符来引用多个字符集,并创建一个通用的搜索模式。

方括号[]用于表示字符范围,例如09az。在正则表达式后使用大括号{n}表示必须匹配前面正则表达式的 n 个副本,才能认为是有效的。通过这两个特殊字符,我们可以创建一个更通用的搜索模式:

>>> expression = r'[0-9]{3}-[0-9]{3}-[0-9]{4}' 

这个正则表达式匹配任何符合XXX-XXX-XXXX模式的内容,且仅包含 0 到 9 之间的整数。它不会匹配像+1 800.234.5555这样的电话号码。我们可以构建更复杂的表达式来包括这些类型的模式。

另一个我们要看的例子是匹配信用卡号码。幸运的是,已经存在一些主要卡片(如 Visa、万事达卡、美国运通卡等)的标准正则表达式。以下是我们可以用来识别任何 Visa 卡的表达式。变量expression_1匹配以四开始,后跟任何 15 个数字(0-9)的数字。第二个表达式expression_2匹配以 4 开始,后跟任何 15 个数字(0-9),这些数字可选地由空格或破折号分隔:

>>> expression_1 = r'⁴\d{15}$' 
>>> expression_2 = r'⁴\d{3}([\ \  -]?)\d{4}\1\d{4}\1\d{4}$' 

对于第一个表达式,我们引入了三个新的特殊字符:^d$。插入符号(^)表示字符串的起始位置位于开头。同样,$要求模式的结束位置位于字符串或行的末尾。结合起来,这个模式只有在我们的信用卡是该行中唯一的元素时才会匹配。d字符是[0-9]的别名。这个表达式可以捕获像 4111111111111111 这样的信用卡号码。请注意,在正则表达式中,我们使用r前缀来创建一个原始字符串,这样反斜杠就不会被当作 Python 的转义字符来处理。由于正则表达式使用反斜杠作为转义字符,我们必须在每个反斜杠出现的地方使用双反斜杠,以避免 Python 将其解释为自己的转义字符。

在第二个表达式中,我们使用圆括号和方括号来可选地匹配四位数字之间的空格或破折号。注意反斜杠,它作为空格和破折号的转义字符,而空格和破折号本身是正则表达式中的特殊字符。如果我们没有在这里使用反斜杠,解释器将无法理解我们是想使用字面意义上的空格和破折号,而不是它们在正则表达式中的特殊含义。在定义了圆括号中的模式后,我们可以使用 1,而不是每次都重新编写它。同样,由于^$,这个模式只有在它是行或整个字符串中唯一的元素时才会匹配。这个表达式将匹配诸如 4111-1111-1111-1111 的 Visa 卡,并捕获expression_1会匹配的任何内容。

掌握正则表达式可以让用户创建非常彻底和全面的模式。为了本章的目的,我们将坚持使用相对简单的表达式来完成我们的任务。与任何模式匹配一样,将大量数据集应用于模式可能会生成误报。

TQDM – 一个更简单的进度条

tqdm模块(版本 4.23.2)可以为任何 Python 迭代器创建进度条:

在前面的例子中,我们将由range(100)创建的迭代器包装在tqdm中。仅此就能创建显示在图片中的进度条。另一种方法是使用trange()函数,它使我们的任务更加简单。我们将使用该模块为处理每个 WAL 帧创建进度条。

以下代码创建了与前面截图中相同的进度条。trange() tqdm(xrange())的别名,使得创建进度条更加简单:

>>> from tqdm import trange 
>>> from time import sleep 
>>> for x in trange(100): 
...     sleep(1) 

解析 WAL 文件 – wal_crawler.py

现在我们理解了 WAL 文件的结构以及用于存储数据的数据类型,我们可以开始规划脚本。由于我们处理的是一个大型二进制对象,我们将大力使用struct库。我们在第六章《从二进制文件提取数据》中首次介绍了struct,并且在处理二进制文件时多次使用它。因此,我们不会在本章重复struct的基础内容。

我们的wal_crawler.py脚本的目标是解析 WAL 文件的内容,提取并将单元格内容写入 CSV 文件,并可选择性地对提取的数据运行正则表达式模块。由于我们正在解析的底层对象的复杂性,这个脚本被认为是更高级的。然而,我们在这里所做的,只是将之前章节中学到的知识应用于更大规模的任务:

002 from __future__ import print_function
003 import argparse
004 import binascii
005 import logging
006 import os
007 import re
008 import struct
009 import sys
010 from collections import namedtuple
011 if sys.version_info[0] == 2:
012     import unicodecsv as csv
013 elif sys.version_info[0] == 3:
014     import csv
015 
016 from tqdm import trange

与我们开发的任何脚本一样,在第 1-11 行我们导入了脚本中将使用的所有模块。我们在前几章中已经遇到过大部分这些模块,并且在相同的上下文中使用它们。我们将使用以下模块:

  • binascii:用于将从 WAL 文件读取的数据转换为十六进制格式

  • tqdm:用于创建一个简单的进度条

  • namedtuple:这个来自 collections 模块的数据结构,将在使用struct.unpack()函数时简化创建多个字典键和值的过程。

main()函数将验证 WAL 文件输入,解析 WAL 文件头部,然后遍历每一帧并使用frame_parser()函数处理它。所有帧处理完毕后,main()函数可选择运行正则表达式regular_search()函数,并通过csv_writer()函数将处理后的数据写入 CSV 文件:

055 def main() 
... 
133 def frame_parser(): 
... 
173 def cell_parser(): 
... 
229 def dict_helper(): 
... 
243 def single_varint(): 
... 
273 def multi_varint(): 
... 
298 def type_helper(): 
... 
371 def csv_writer(): 
... 
428 def regular_search(): 

frame_parser() 函数解析每个帧,并通过识别 B-tree 类型执行进一步的验证。在数据库中有四种类型的 B-tree:0x0D0x050x0A0x02。在这个脚本中,我们只关注 0x0D 类型的帧,其他类型的帧将不进行处理。因为 0x0D 类型的 B-tree 同时包含行 ID 和负载,而其他类型的 B-tree 只包含其中之一。验证完帧后,frame_parser() 函数会通过 cell_parser() 函数处理每个单元格。

cell_parser() 函数负责处理每个单元格及其所有组件,包括负载长度、行 ID、负载头和负载。frame_parser()cell_parser() 函数都依赖于各种辅助函数来完成它们的任务。

dict_helper() 辅助函数从元组返回 OrderedDictionary。这个函数允许我们在一行中处理和存储结构结果到数据库中。single_varint()multi_varint() 函数分别用于处理单个和多个 varint。最后,type_helper() 函数处理序列类型数组并将原始数据解释为适当的数据类型:

481 if __name__ == '__main__':
482 
483     parser = argparse.ArgumentParser(description=__description__,
484                     epilog='Developed by ' +
485                     __author__ + ' on ' +
486                     __date__)
487 
488     parser.add_argument('WAL', help='SQLite WAL file')
489     parser.add_argument('OUTPUT_DIR', help='Output Directory')
490     parser.add_argument('-r', help='Custom regular expression')
491     parser.add_argument('-m', help='Run regular expression module',
492     action='store_true')
493     parser.add_argument('-l', help='File path of log file')
494     args = parser.parse_args()

在第 483 行,我们创建了参数解析器,指定了必需的输入值,包括 WAL 文件和输出目录,以及可选的输入值,执行预构建的或自定义的正则表达式和日志输出路径。在第 496 到 508 行,我们执行了与前几章相同的日志设置:

496     if args.l:
497         if not os.path.exists(args.l):
498             os.makedirs(args.l)
499         log_path = os.path.join(args.l, 'wal_crawler.log')
500     else:
501         log_path = 'wal_crawler.log'
502     logging.basicConfig(filename=log_path, level=logging.DEBUG,
503                         format=('%(asctime)s | %(levelname)s | '
504                         '%(message)s'), filemode='a')
505 
506     logging.info('Starting Wal_Crawler')
507     logging.debug('System ' + sys.platform)
508     logging.debug('Version ' + sys.version)

在执行 main() 函数之前,我们进行一些基本检查并验证输入。第 510 行,我们检查并(可选)创建输出目录,如果它不存在的话。在执行 main() 函数之前,我们通过使用 os.path.exists()os.path.isfile() 函数来验证输入文件,检查文件是否存在且是否为文件。否则,我们在退出程序之前,将错误信息写入控制台和日志中。在 main() 函数中,我们将进一步验证 WAL 文件:

510     if not os.path.exists(args.OUTPUT_DIR):
511         os.makedirs(args.OUTPUT_DIR)
512 
513     if os.path.exists(args.WAL) and os.path.isfile(args.WAL):
514         main(args.WAL, args.OUTPUT_DIR, r=args.r, m=args.m)
515     else:
516         msg = 'Supplied WAL file does not exist or isn't a file'
517         print('[-]', msg)
518         logging.error(msg)
519         sys.exit(1)

以下流程图突出显示了不同函数之间的交互,并展示了我们的代码如何处理 WAL 文件:

理解 main() 函数

这个函数比我们通常的 main() 函数复杂,它开始解析 WAL 文件,而不是作为脚本的控制器。在这个函数中,我们将执行文件验证,解析 WAL 文件头,识别文件中的帧数量,并调用函数处理这些帧:

055 def main(wal_file, output_dir, **kwargs):
056     """
057     The main function parses the header of the input file and
058     identifies the WAL file. It then splits the file into the
059     appropriate frames and send them for processing. After
060     processing, if applicable, the regular expression modules are
061     ran. Finally the raw data output is written to a CSV file.
062     :param wal_file: The filepath to the WAL file to be processed
063     :param output_dir: The directory to write the CSV report to.
064     :return: Nothing.
065     """

在第 70 行,我们创建了wal_attributes字典,它是我们在解析 WAL 文件时会扩展的字典。初始时,它存储了文件大小,以及两个空字典分别用于文件头和帧。接下来,我们以rb模式(即二进制读取模式)打开输入文件,并读取前 32 个字节作为文件头。在第 79 行,我们尝试解析文件头,并将所有键及其值添加到文件头字典中。此操作执行了另一个有效性检查,因为如果文件小于 32 字节,struct 会抛出错误。我们使用>4s7i作为我们的格式字符串,解析出一个 4 字节的字符串和七个 32 位大端整数(>在格式字符串中指定了字节序):

066     msg = 'Identifying and parsing file header'
067     print('[+]', msg)
068     logging.info(msg)
069 
070     wal_attributes = {'size': os.path.getsize(wal_file),
071     'header': {}, 'frames': {}}
072     with open(wal_file, 'rb') as wal:
073 
074         # Parse 32-byte WAL header.
075         header = wal.read(32)
076 
077         # If file is less than 32 bytes long: exit wal_crawler.
078         try:
079             wal_attributes['header'] = dict_helper(header,'>4s7i',
080             namedtuple('struct',
081             'magic format pagesize checkpoint '
082             'salt1 salt2 checksum1 checksum2'))
083         except struct.error as e:
084             logging.error('STRUCT ERROR:', e.message)
085             print('[-]', e.message + '. Exiting..')
086             sys.exit(2)

请注意dict_helper()函数的使用。我们将在后续章节中解释这个函数的具体工作原理,但它允许我们使用 struct 解析从 WAL 文件中读取的数据,并返回包含键值对的OrderedDict。这大大减少了必须将返回的 struct 元组中的每个值添加到字典中的代码量。

在解析完 WAL 头后,我们可以将文件魔数或签名与已知值进行比较。我们使用binascii.hexlify将原始数据转换为十六进制。在第 92 行,我们使用if语句来比较magic_hex值。如果它们不匹配,我们停止程序执行。如果匹配,我们会在日志中记录,并继续处理 WAL 文件:

088         # Do not proceed in the program if the input file isn't a
089         # WAL file.
090         magic_hex = binascii.hexlify(
091         wal_attributes['header']['magic']).decode('utf-8')
092         if magic_hex != "377f0682" and magic_hex != "377f0683":
093             logging.error(('Magic mismatch, expected 0x377f0682 '
094             'or 0x377f0683 | received {}'.format(magic_hex)))
095             print(('[-] File does not have appropriate signature '
096             'for WAL file. Exiting...'))
097             sys.exit(3)
098 
099         logging.info('File signature matched.')
100         logging.info('Processing WAL file.')

使用文件大小,我们可以在第 103 行计算帧的数量。请注意,我们需要考虑 32 字节的 WAL 头和 24 字节的帧头,以及每个帧内的页面大小:

102         # Calculate number of frames.
103         frames = int((
104         wal_attributes['size'] - 32) / (
105         wal_attributes['header']['pagesize'] + 24))
106         print('[+] Identified', frames, 'Frames.')

在第 111 行,我们使用来自tqdmtrange创建进度条,并开始处理每一帧。我们首先在第 114 行创建一个索引键,表示为x,并为我们的帧创建一个空字典。这个索引最终会指向处理过的帧数据。接下来,我们读取 24 字节的帧头。在第 116 行,我们解析从帧头读取的六个 32 位大端整数,并通过调用我们的dict_helper()函数将适当的键值对添加到字典中:

108         # Parse frames in WAL file. Create progress bar using
109         # trange(frames) which is an alias for tqdm(xrange(frames)).
110         print('[+] Processing frames...')
111         for x in trange(frames):
112 
113             # Parse 24-byte WAL frame header.
114             wal_attributes['frames'][x] = {}
115             frame_header = wal.read(24)
116             wal_attributes['frames'][x]['header'] = dict_helper(
117             frame_header, '>6i', namedtuple('struct',
118             'pagenumber commit salt1'
119             ' salt2 checksum1'
120             ' checksum2'))

在解析完帧头之后,我们在第 122 行读取 WAL 文件中的整个帧。然后,我们将这个帧传递给frame_parser()函数,同时传入wal_attributes字典和x,后者表示当前帧的索引:

121             # Parse pagesize WAL frame.
122             frame = wal.read(wal_attributes['header']['pagesize'])
123             frame_parser(wal_attributes, x, frame)

frame_parser()函数调用内部的其他函数,而不是返回数据并让main()调用下一个函数。一旦 WAL 文件的解析完成,主函数会在用户提供mr开关的情况下调用regular_search()函数,并调用csv_writer()函数将解析后的数据写入 CSV 文件以供审查:

125         # Run regular expression functions.
126         if kwargs['m'] or kwargs['r']:
127             regular_search(wal_attributes, kwargs)
128 
129         # Write WAL data to CSV file.
130         csv_writer(wal_attributes, output_dir)

开发frame_parser()函数

frame_parser()函数是一个中间函数,它继续解析帧,识别帧内的单元格数量,并调用cell_parser()函数完成解析工作:

133 def frame_parser(wal_dict, x, frame):
134     """
135     The frame_parser function processes WAL frames.
136     :param wal_dict: The dictionary containing parsed WAL objects.
137     :param x: An integer specifying the current frame.
138     :param frame: The content within the frame read from the WAL
139     file.
140     :return: Nothing.
141     """

如前所述,WAL 页面头是帧头之后的前 8 个字节。页面头包含两个 8 位和三个 16 位的大端整数。在 struct 字符串中,>b3hbb解析 8 位整数,h解析 16 位整数。解析了这个头之后,我们现在知道页面内有多少个单元格:

143     # Parse 8-byte WAL page header
144     page_header = frame[0:8]
145     wal_dict['frames'][x]['page_header'] = dict_helper(
146     page_header, '>b3hb', namedtuple('struct',
147     'type freeblocks cells offset'
148     ' fragments'))

在第 150 行,我们检查帧的类型是否为0x0D(该值在解释为 16 位整数时等于 13)。如果帧不是适当类型,我们记录此信息,并在返回函数之前使用pop()从字典中移除该帧。我们返回函数,以防止继续处理我们不感兴趣的帧:

149     # Only want to parse 0x0D B-Tree Leaf Cells
150     if wal_dict['frames'][x]['page_header']['type'] != 13:
151         logging.info(('Found a non-Leaf Cell in frame {}. Popping '
152         'frame from dictionary').format(x))
153         wal_dict['frames'].pop(x)
154         return

无论如何,在第 156 行,我们创建了一个新的嵌套字典,名为 cells,并用它来跟踪单元格,方式与我们跟踪帧的方式完全相同。我们还打印每个帧中识别到的单元格数量,以便向用户提供反馈:

155     # Parse offsets for "X" cells
156     cells = wal_dict['frames'][x]['page_header']['cells']
157     wal_dict['frames'][x]['cells'] = {}
158     print('[+] Identified', cells, 'cells in frame', x)
159     print('[+] Processing cells...')

最后,在第 161 行,我们遍历每个单元格并解析它们的偏移量,然后将其添加到字典中。我们知道N 2 字节单元格偏移量紧跟在 8 字节的页面头之后。我们使用第 162 行计算出的 start 变量来识别每个单元格的偏移量起始位置:

161     for y in range(cells):
162         start = 8 + (y * 2)
163         wal_dict['frames'][x]['cells'][y] = {}
164 
165         wal_dict['frames'][x]['cells'][y] = dict_helper(
166         frame[start: start + 2], '>h', namedtuple(
167         'struct', 'offset'))

在第 163 行,我们创建一个索引键和一个空字典来存储单元格。然后,我们使用dict_helper()函数解析单元格偏移量,并将内容存储到特定的单元格字典中。一旦偏移量被识别,我们调用cell_parser()函数来处理单元格及其内容。我们将wal_attributes字典、frame 和单元格索引xy,以及 frame 数据传递给它:

169         # Parse cell content
170         cell_parser(wal_dict, x, y, frame)

使用cell_parser()函数处理单元格

cell_parser()函数是我们程序的核心。它负责实际提取存储在单元格中的数据。正如我们将看到的,varints 给代码增加了额外的复杂性;然而,大部分情况下,我们仍然是在使用 struct 解析二进制结构,并根据这些值做出决策:

173 def cell_parser(wal_dict, x, y, frame):
174     """
175     The cell_parser function processes WAL cells.
176     :param wal_dict: The dictionary containing parsed WAL objects.
177     :param x: An integer specifying the current frame.
178     :param y: An integer specifying the current cell.
179     :param frame: The content within the frame read from the WAL
180     file.
181     :return: Nothing.
182     """

在开始解析单元格之前,我们实例化几个变量。我们在第 183 行创建的 index 变量用于跟踪当前单元格的位置。请记住,我们不再处理整个文件,而是处理表示单元格的文件子集。frame 变量是从数据库中读取的与页面大小相对应的数据量。例如,如果页面大小为 1,024,那么 frame 变量就是 1,024 字节的数据,对应于数据库中的一个页面。struct 模块要求解析的数据长度必须与 struct 字符串中指定的数据类型长度完全一致。基于这两个事实,我们需要使用字符串切片来提供仅我们想要解析的数据:

183     index = 0 

在第 186 行,我们创建了 cell_root,它本质上是指向 wal_attributes 字典中嵌套单元字典的快捷方式。这不仅仅是为了懒惰;它有助于提高代码可读性,并通过引用指向嵌套字典的变量,减少每次都要打出完整路径的冗余。出于同样的原因,我们在第 187 行创建了 cell_offset 变量:

184     # Create alias to cell_root to shorten navigating the WAL
185     # dictionary structure.
186     cell_root = wal_dict['frames'][x]['cells'][y]
187     cell_offset = cell_root['offset']

从第 191 行开始,我们遇到了单元有效载荷长度中的第一个 varint。这个 varint 将决定单元的整体大小。为了提取这个 varint,我们调用 single_varint() 辅助函数,传入 9 字节的数据切片。这个函数,稍后我们将解释,会检查第一个字节是否大于或等于 128;如果是,它会处理第二个字节。除了 varint 外,single_varint() 辅助函数还会返回 varint 占用的字节数。这样,我们就可以跟踪当前在帧数据中的位置。我们使用返回的索引以类似的方式解析行 ID 的 varint:

189     # Parse the payload length and rowID Varints.
190     try:
191         payload_len, index_a = single_varint(
192         frame[cell_offset:cell_offset + 9])
193         row_id, index_b = single_varint(
194         frame[cell_offset + index_a: cell_offset + index_a + 9])
195     except ValueError:
196         logging.warn(('Found a potential three-byte or greater '
197         'varint in cell {} from frame {}').format(y, x))
198         return

处理完前两个 varint 后,我们将键值对添加到 wal_attributes 字典中。在第 204 行,我们更新了索引变量,以保持当前在帧数据中的位置。接下来,我们手动提取 8 位有效载荷头长度值,而不使用 dict_helper() 函数。我们这样做有两个原因:

  • 我们只处理一个值

  • cell_root 设置为 dict_helper() 输出的结果,发现它会清除 cell_root 所描述的单元嵌套字典中的所有其他键,这显然不是理想的做法。

以下代码块展示了此功能:

200     # Update the index. Following the payload length and rowID is
201     # the 1-byte header length.
202     cell_root['payloadlength'] = payload_len
203     cell_root['rowid'] = row_id
204     index += index_a + index_b
205     cell_root['headerlength'] = struct.unpack('>b',
206     frame[cell_offset + index: cell_offset + index + 1])[0]

解析了有效载荷长度、行 ID 和有效载荷头长度后,我们现在可以解析序列类型数组。提醒一下,序列类型数组包含 N 个 varint,长度为 1 字节的 headerlength。在第 210 行,我们通过加 1 更新索引,以考虑在第 205 行解析的 1 字节头。接下来,我们通过调用 multi_varint() 函数提取位于适当范围内的所有 varint。该函数返回一个元组,包含序列类型列表和当前索引。在第 218 行和第 219 行,我们分别更新 wal_attributesindex 对象:

208     # Update the index with the 1-byte header length. Next process
209     # each Varint in "headerlength" - 1 bytes.
210     index += 1
211     try:
212         types, index_a = multi_varint(
213         frame[cell_offset + index:cell_offset+index+cell_root['headerlength']-1])
214     except ValueError:
215         logging.warn(('Found a potential three-byte or greater '
216             'varint in cell {} from frame {}').format(y, x))
217         return
218     cell_root['types'] = types
219     index += index_a

一旦序列类型数组解析完毕,我们就可以开始提取单元中存储的实际数据。回想一下,单元有效载荷是有效载荷长度与有效载荷头长度之间的差值。第 224 行计算出的这个值用于将单元的其余内容传递给 type_helper() 辅助函数,后者负责解析数据:

221     # Immediately following the end of the Varint headers begins
222     # the actual data described by the headers. Process them using
223     # the typeHelper function.
224     diff = cell_root['payloadlength'] - cell_root['headerlength']
225     cell_root['data'] = type_helper(cell_root['types'],
226     frame[cell_offset + index: cell_offset + index + diff])

编写 dict_helper() 函数

dict_helper() 函数是一个单行函数,且文档少于六行。它利用了 named_tuple 数据结构,keys 变量传入其中,并调用 _make()_asdict() 函数,在结构体解析值后创建我们的有序字典:

229 def dict_helper(data, format, keys):
230     """
231     The dict_helper function creates an OrderedDictionary from
232     a struct tuple.
233     :param data: The data to be processed with struct.
234     :param format: The struct format string.
235     :param keys: A string of the keys for the values in the struct
236     tuple.
237     :return: An OrderedDictionary with descriptive keys of
238     struct-parsed values.
239     """
240     return keys._asdict(keys._make(struct.unpack(format, data)))

与大多数紧凑的单行代码一样,当在一行中调用更多函数时,代码的可读性会降低,从而可能使函数的含义变得模糊。我们将在这里引入并使用内置的 Python 调试器,以便查看发生了什么。

Python 调试器 – pdb

Python 有很多优点,我们现在不需要再赘述其中的细节。其中一个非常优秀的功能是内置的调试模块pdb。这个模块虽然简单,但在识别麻烦的 bug 或在执行过程中查看变量时非常有用。如果你使用的是集成开发环境(强烈推荐)来开发脚本,那么很可能已经内置了调试支持。然而,如果你在简单的文本编辑器中编写代码,不用担心;你依然可以使用pdb来调试你的代码。

在这个例子中,我们将检查dict_helper()的每个组件,以便充分理解这个函数。我们不会覆盖pdb的所有用法和命令,而是通过示例进行说明,若需要更多信息,可以参考docs.python.org/3/library/pdb.html

首先,我们需要修改现有代码,并在希望检查的代码处创建一个调试点。在第 240 行,我们导入pdb并在同一行调用pdb.set_trace()

240     import pdb; pdb.set_trace() 
241     return keys._asdict(keys._make(struct.unpack(format, data))) 

使用分号可以让我们在一行中分隔多个语句。通常我们不会这样做,因为这会影响可读性。然而,这只是为了测试,最终代码中会去除这一部分。

现在,当我们执行代码时,会看到pdb提示符,下面的截图显示了这一点。pdb提示符类似于 Python 解释器。我们可以访问当前作用域中的变量,例如dataformatkeys。我们也可以创建自己的变量并执行简单的表达式:

pdb提示符的第一行包含文件的位置、当前文件中的行号和正在执行的当前函数。第二行是即将执行的下一行代码。Pdb提示符与 Python 解释器中的>>>提示符具有相同的意义,是我们可以输入自己命令的地方。

在这个例子中,我们正在解析文件头,因为这是第一次调用dict_helper()。回忆一下,我们使用的结构字符串是>4s7i。正如我们在下面的示例中看到的,unpack()返回的是一个元组结果。然而,我们希望返回一个字典,将所有值与其相关的键匹配,以便不必手动执行此任务:

(Pdb) struct.unpack(format, data) 
('7x7fx06x82', 3007000, 32768, 9, -977652151, 1343711549, 670940632, 650030285) 

请注意,keys._make会创建一个对象,其中为每个值设置了适当的字段名称。它通过将我们在第 41 行创建keys变量时提供的字段名称与结构元组中的每个值相关联来实现这一点:

(Pdb) keys._make(struct.unpack(format, data)) 
struct(magic='7x7fx06x82', format=3007000, pagesize=32768, checkpoint=9, salt1=-977652151, salt2=1343711549, checksum1=670940632, checksum2=650030285) 

最后,我们可以使用pdb验证keys._asdict()函数是否将我们的namedtuple转换为OrderedDict,这也是我们返回的内容:

(Pdb) keys._asdict(keys._make(struct.unpack(format, data))) 
OrderedDict([('magic', '7x7fx06x82'), ('format', 3007000), ('pagesize', 32768), ('checkpoint', 9), ('salt1', -977652151), ('salt2', 1343711549), ('checksum1', 670940632), ('checksum2', 650030285)]) 

以这种方式使用pdb可以帮助我们查看当前变量的状态,并逐个执行函数。当程序在某个特定函数中遇到错误时,这非常有用,因为你可以逐行和逐函数地执行,直到找到问题所在。我们建议你熟悉pdb,因为它能加速调试过程,并且比使用打印语句进行故障排除更有效。按下 q 和Enter退出pdb,并确保始终从最终代码中移除调试语句。

使用 single_varint()函数处理 varint

single_varint函数在提供的数据中找到第一个 varint,并使用索引跟踪其当前位置。当它找到 varint 时,它会返回该值以及索引。这告诉调用函数 varint 的字节数,并用于更新它自己的索引:

243 def single_varint(data, index=0):
244     """
245     The single_varint function processes a Varint and returns the
246     length of that Varint.
247     :param data: The data containing the Varint (maximum of 9
248     bytes in length as that is the maximum size of a Varint).
249     :param index: The current index within the data.
250     :return: varint, the processed varint value,
251     and index which is used to identify how long the Varint was.
252     """

对于此脚本,我们做了一个简化假设,即 varint 永远不会超过 2 个字节。这个假设是简化的,并不适用于所有情况。这有两个可能的情形:

  • 第一个字节的十进制值小于 128

  • 第一个字节大于或等于 128

根据结果,将会发生以下两种情况之一。如果字节大于或等于 128,则 varint 长度为 2 字节。否则,长度为 1 字节。在第 256 行,我们使用ord()函数将字节的值转换为整数:

254     # If the decimal value is => 128 -- then first bit is set and
255     # need to process next byte.
256     if ord(data[index:index+1]) >= 128:
257         # Check if there is a three or more byte varint
258         if ord(data[index + 1: index + 2]) >= 128:
259             raise ValueError

如果值大于 128,我们知道第二个字节也是必需的,并且必须应用以下通用公式,其中x是第一个字节,y是第二个字节:

Varint = ((x - 128) * 128) + y

我们在将索引加 2 后返回这个值:

260         varint = (ord(data[index:index+1]) - 128) * 128 + ord(
261         data[index + 1: index + 2])
262         index += 2
263         return varint, index

如果第一个字节小于 128,我们只需返回该字节的整数值并将索引加 1:

265     # If the decimal value is < 128 -- then first bit isn't set 
266     # and is the only byte of the Varint.
267     else:
268         varint = ord(data[index:index+1])
269         index += 1
270         return varint, index

使用 multi_varint()函数处理 varint

multi_varint()函数是一个循环函数,它会重复调用single_varint(),直到提供的数据中没有更多的 varint。它返回一个 varint 的列表和一个指向父函数的索引。在第 282 和 283 行,我们初始化了 varint 的列表,并将本地索引变量设置为零:

273 def multi_varint(data):
274     """
275     The multi_varint function is similar to the single_varint
276     function. The difference is that it takes a range of data
277     and finds all Varints within it.
278     :param data: The data containing the Varints.
279     :return: varints, a list containing the processed varint
280     values, and index which is used to identify how long the
281     Varints were.
282     """
283     varints = []
284     index = 0

我们使用while循环直到数据的长度为 0。在每次循环中,我们调用single_varint(),将得到的 varint 附加到列表中,更新索引,并使用字符串切片缩短数据。通过执行第 293 行,使用single_varint()函数返回的 varint 大小,我们可以逐渐缩短数据,直到长度为 0。到达这一点时,我们可以确认已经提取了字符串中的所有 varint:

286     # Loop forever until all Varints are found by repeatedly
287     # calling singleVarint.
288     while len(data) != 0:
289         varint, index_a = single_varint(data)
290         varints.append(varint)
291         index += index_a
292         # Shorten data to exclude the most recent Varint.
293         data = data[index_a:]
294 
295     return varints, index

使用 type_helper()函数转换序列类型

type_helper()函数负责根据数据中值的类型提取有效负载。尽管它由许多行代码组成,但实际上不过是一系列条件语句,如果某一条语句为True,则决定数据如何处理:

298 def type_helper(types, data):
299     """
300     The type_helper function decodes the serial type of the
301     Varints in the WAL file.
302     :param types: The processed values of the Varints.
303     :param data: The raw data in the cell that needs to be
304     properly decoded via its varint values.
305     :return: cell_data, a list of the processed data.
306     """

在第 307 行和 308 行,我们创建了一个列表,用来存储提取的有效负载数据和索引。索引用于表示数据中的当前位置。在第 313 行,我们开始遍历每种序列类型,检查每种类型应该如何处理:

307     cell_data = []
308     index = 0

前十种类型相对简单。我们使用序列类型表来识别数据类型,然后使用struct进行解包。某些类型,如 0、8 和 9 是静态的,不需要我们解析数据或更新索引值。类型 3 和 5 是struct不支持的数据类型,需要使用其他方法提取。让我们看一下支持和不支持的类型,确保我们理解发生了什么:

310     # Value of type dictates how the data should be processed. 
311     # See serial type table in chapter for list of possible
312     # values.
313     for type in types:
314 
315         if type == 0:
316             cell_data.append('NULL (RowId?)')
317         elif type == 1:
318             cell_data.append(struct.unpack('>b',
319                 data[index:index + 1])[0])
320             index += 1
321         elif type == 2:
322             cell_data.append(struct.unpack('>h',
323                 data[index:index + 2])[0])
324             index += 2
325         elif type == 3:
326             # Struct does not support 24-bit integer
327             cell_data.append(int(binascii.hexlify(
328                 data[index:index + 3]).decode('utf-8'), 16))
329             index += 3
330         elif type == 4:
331             cell_data.append(struct.unpack(
332                 '>i', data[index:index + 4])[0])
333             index += 4
334         elif type == 5:
335             # Struct does not support 48-bit integer
336             cell_data.append(int(binascii.hexlify(
337                 data[index:index + 6]).decode('utf-8'), 16))
338             index += 6

从序列类型表中我们知道,类型 6(第 339 行)是一个 64 位大端整数。struct中的q字符用于解析 64 位整数,这使得我们的工作相对简单。我们必须确保只将组成 64 位整数的数据传递给struct。我们可以通过使用当前索引的字符串切片,截取前 8 个字节来实现。之后,我们需要将索引递增 8,以便下一个类型能够从正确的位置开始:

如果struct不支持某个变量类型,比如类型 3(一个 24 位整数),我们需要以更迂回的方式提取数据。这需要我们使用binascii.hexlify()函数将数据字符串转换为十六进制。然后,我们简单地将int()对象构造函数包裹在十六进制值上,将其转换为整数值。请注意,我们需要明确告诉int函数值转换的进制,在本例中是 16 进制,因为该值是十六进制的。

339         elif type == 6:
340             cell_data.append(struct.unpack(
341                 '>q', data[index:index + 8])[0])
342             index += 8
343         elif type == 7:
344             cell_data.append(struct.unpack(
345                 '>d', data[index:index + 8])[0])
346             index += 8
347         # Type 8 == Constant 0 and Type 9 == Constant 1\. Neither of these take up space in the actual data.
348         elif type == 8:
349             cell_data.append(0)
350         elif type == 9:
351             cell_data.append(1)
352         # Types 10 and 11 are reserved and currently not implemented.

对于类型 12 和 13,我们必须首先通过应用适当的公式来确定值的实际长度。接下来,我们可以将提取的字符串直接追加到cell_data列表中。我们还需要根据计算出的字符串大小递增索引:

353         elif type > 12 and type % 2 == 0:
354             b_length = int((type - 12) / 2)
355             cell_data.append(data[index:index + b_length])
356             index += b_length
357         elif type > 13 and type % 2 == 1:
358             s_length = int((type - 13) / 2)
359             cell_data.append(
360                 data[index:index + s_length].decode('utf-8'))
361             index += s_length

在第 363 行,我们创建了一个else分支来捕获任何意外的序列类型,并打印和记录错误。所有类型处理完毕后,cell_data列表会在第 368 行返回:

363         else:
364             msg = 'Unexpected serial type: {}'.format(type)
365             print('[-]', msg)
366             logging.error(msg)
367 
368     return cell_data

使用csv_writer()函数写入输出

csv_writer()函数与我们之前的许多 CSV 写入器类似。由于写入文件的数据比较复杂,因此需要做一些特殊处理。此外,我们只将部分数据写入文件,其他数据会被丢弃。将数据转储到一个序列化的数据结构(如 JSON)中留给读者作为挑战。像任何csv_writer一样,我们首先创建一个包含标题的列表,打开csvfile,创建写入对象,然后将标题写入第一行:

371 def csv_writer(data, output_dir):
372     """
373     The csv_writer function writes frame, cell, and data to a CSV
374     output file.
375     :param data: The dictionary containing the parsed WAL file.
376     :param output_dir: The directory to write the CSV report to.
377     :return: Nothing.
378     """
379     headers = ['Frame', 'Salt-1', 'Salt-2', 'Frame Offset',
380         'Cell', 'Cell Offset', 'ROWID', 'Data']
381 
382     out_file = os.path.join(output_dir, 'wal_crawler.csv')
383 
384     if sys.version_info[0] == 2:
385         csvfile = open(out_file, "wb")
386     elif sys.version_info[0] == 3:
387         csvfile = open(out_file, "w", newline='',
388             encoding='utf-8')
389 
390     with csvfile:
391         writer = csv.writer(csvfile)
392         writer.writerow(headers)

由于我们的结构是嵌套的,我们需要创建两个for循环来遍历该结构。在第 399 行,我们检查单元格是否实际包含任何数据。在开发过程中我们注意到,有时会生成空单元格并且它们会被丢弃在输出中。然而,在某些特定的调查中,可能需要包括空单元格,在这种情况下,我们将删除条件语句:

394         for frame in data['frames']:
395 
396             for cell in data['frames'][frame]['cells']:
397 
398             # Only write entries for cells that have data.
399             if ('data' in data['frames'][frame]['cells'][cell].keys() and
400             len(data['frames'][frame]['cells'][cell]['data']) > 0):

如果有数据,我们计算相对于文件开头的frame_offsetcell_offset。我们之前解析的偏移量是相对于文件中当前位置的。这种相对值对于需要回溯以查找相对偏移位置的检查人员来说不会很有帮助。

对于我们的帧偏移,我们需要加上文件头大小(32 字节)、总页大小(帧数 * 页大小)和总帧头大小(帧数 * 24 字节)。单元格偏移则稍微简单些,是帧偏移加上帧头大小,再加上从wal_attributes字典中解析出的单元格偏移:

401                 # Convert relative frame and cell offsets to
402                 # file offsets.
403                 frame_offset = 32 + (
404                     frame * data['header']['pagesize']) + (
405                     frame * 24)
406                     cell_offset = frame_offset + 24 + data['frames'][frame]['cells'][cell]['offset']

接下来,我们在第 411 行创建一个列表cell_identifiers,用于存储要写入的行数据。该列表包含帧编号、salt-1salt-2帧偏移、单元格编号、单元格偏移和行 ID:

408                 # Cell identifiers include the frame #, 
409                 # salt-1, salt-2, frame offset,
410                 # cell #, cell offset, and cell rowID.
411                 cell_identifiers = [frame, data['frames'][frame]['header']['salt1'],
412                     data['frames'][frame]['header']['salt2'],
413                     frame_offset, cell, cell_offset,
414                     data['frames'][frame]['cells'][cell]['rowid']]

最后,在第 418 行,我们将行数据和负载数据一起写入 CSV 文件:

416                 # Write the cell_identifiers and actual data
417                 # within the cell
418                 writer.writerow(
419                     cell_identifiers + data['frames'][frame]['cells'][cell]['data'])

如果单元格没有负载,则执行继续块并进入下一个单元格。一旦外层的for循环执行完成,也就是所有的帧已写入 CSV 文件,我们将刷新所有剩余的缓冲内容到 CSV,并关闭文件句柄:

421             else:
422                 continue
423 
424     csvfile.flush()
425     csvfile.close()

从 WAL 文件生成的 CSV 输出示例在下图中可以看到:

regular_search()函数中使用正则表达式

regular_search()函数是一个可选函数。如果用户提供了-m-r开关,则会执行该函数。该函数使用正则表达式在 WAL 文件中识别相关信息,并且如果识别到,则将数据打印到终端:

428 def regular_search(data, options):
429     """
430     The regular_search function performs either default regular
431     expression searches for personal information or custom
432     searches based on a supplied regular expression string.
433     :param data: The dictionary containing the parsed WAL file.
434     :param options: The options dictionary contains custom or
435     pre-determined regular expression searching
436     :return: Nothing.
437     """

我们将使用一个包含正则表达式模式的字典来运行。这将使得识别哪个类别的表达式(例如 URL 或电话号码)与数据匹配并打印出来提供上下文变得更加容易。

首先,我们必须识别用户指定的开关。如果仅指定了args.r,那么我们只需要使用提供的自定义正则表达式创建正则字典。因为args.rargs.m至少有一个是提供的才能进入此函数,所以如果第一个ifFalse,那么至少args.m必须已被提供:

438     msg = 'Initializing regular expression module.'
439     print('\n{}\n[+]'.format('='*20), msg)
440     logging.info(msg)
441     if options['r'] and not options['m']:
442         regexp = {'Custom': options['r']}
443     else:
444         # Default regular expression modules include: Credit card
445         # numbers, SSNs, Phone numbers, URLs, IP Addresses.
446         regexp = {'Visa Credit Card': r'⁴\d{3}([\ \-]?)\d{4}\1\d{4}\1\d{4}$',
447             'SSN': r'^\d{3}-\d{2}-\d{4}$',
448             'Phone Number': r'^\d{3}([\ \. \-]?)\d{3}\1\d{4}$',
449             'URL': r"(http[s]?://)|(www.)(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+",
450         'IP Address': r'^\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}$'}

如果是这种情况,我们需要构建包含正则表达式模式的正则表达式字典。默认情况下,我们已经包括了之前的信用卡和电话号码示例,以及 SSN、URL 和 IP 地址的模式。此外,在第 452 行,我们需要检查是否同时传递了args.rargs.m。如果传递了,我们将自定义表达式添加到我们的字典中,该字典已经包含了args.m表达式:

452         if options['r']:
453             regexp['Custom'] = options['r']

在我们的字典中,对于每个表达式,我们需要在使用匹配函数之前进行编译。当我们编译每个表达式时,我们会使用更多的循环来遍历wal_attributes字典,并检查每个单元格是否存在匹配项:

455     # Must compile each regular expression before seeing if any
456     # data "matches" it.
457     for exp in regexp.keys():
458         reg_exp = re.compile(regexp[exp])

从第 457 行开始,我们创建了一个三重for循环来获取每个数据点。在csv_writer()中,我们只使用了两个for循环,因为我们不需要与每个数据点交互。然而,在这种情况下,我们需要这样做才能成功地使用正则表达式识别匹配项。

请注意,match 函数周围的 try 和 except。match 函数期望一个字符串或缓冲区。如果它尝试将表达式匹配到一个整数时,它会出错。因此,我们决定捕获这个错误,并在遇到错误时跳到下一个数据点。我们也可以通过使用str()函数将数据转换为字符串来解决这个问题:

460         for frame in data['frames']:
461 
462             for cell in data['frames'][frame]['cells']:
463 
464                 for datum in range(len(
465                 data['frames'][frame]['cells'][cell]['data'])):
466                     # TypeError will occur for non-string objects
467                     # such as integers.
468                     try:
469                         match = reg_exp.match(
470                         data['frames'][frame]['cells'][cell]['data'][datum])
471                     except TypeError:
472                         continue
473                     # Print any successful match to user.
474                     if match:
475                         msg = '{}: {}'.format(exp,
476                         data['frames'][frame]['cells'][cell]['data'][datum])
477                         print('[*]', msg)
478     print('='*20)

执行 wal_crawler.py

现在我们已经编写了脚本,接下来是实际运行它。最简单的方式是提供输入 WAL 文件和输出目录:

可选地,我们可以使用-m-r开关来启用正则表达式模块。以下截图显示了正则表达式输出的示例:

请注意,在通过-r开关提供自定义正则表达式时,请用双引号将表达式括起来。如果没有这样做,由于正则表达式中的特殊字符引发的混乱,可能会遇到错误。

挑战

这个脚本有几个可能的发展方向。正如我们之前提到的,有大量潜在有用的数据我们并没有写入文件。将整个字典结构存储到一个 JSON 文件中可能会很有用,这样其他人可以轻松导入并操作数据。这将允许我们在一个单独的程序中利用解析后的结构,并从中创建额外的报告。

我们可以开发的另一个有用功能是为用户提供时间线报告或图形。该报告会列出每个记录的当前内容,然后显示从当前记录内容到其旧版本甚至不存在的记录的演变过程。树形图或流程图可能是可视化特定数据库记录变化的一个好方法。

最后,添加一个支持处理大于 2 字节的变长整数(varint)的功能。在我们的脚本中,我们做出了一个简化假设,认为不太可能遇到大于 2 字节的变长整数。然而,遇到更大变长整数并非不可能,因此可能值得添加这个功能。

总结

在本章中,我们学习了 WAL 文件的取证意义以及如何解析它。我们还简要介绍了如何在 Python 中使用re模块通过正则表达式创建通用的搜索模式。最后,我们利用tqdm模块通过一行代码创建了一个进度条。该项目的代码可以从 GitHub 或 Packt 下载,如前言所述。

在下一章中,我们将把本书中所学的所有知识结合成一个框架。我们将设计一个框架,用于对我们已经涵盖的常见数据进行基本的预处理。我们将展示框架设计和开发过程,并揭示你在本书中默默构建的框架。

第十三章:完整回顾

在本章中,我们将回顾前几章中构建的脚本,以创建一个原型取证框架。该框架将接受一个输入目录,例如已挂载镜像的根文件夹,并针对文件运行我们的插件,以返回每个插件的系列电子表格报告。

到目前为止,我们在每一章中都开发了独立的脚本,从未在前一章的工作基础上进行扩展。通过开发一个框架,我们将演示如何将这些脚本整合在一起,并在一个上下文中执行它们。

在第八章《媒体时代》中,我们创建了一个用于解析各种类型嵌入式元数据的迷你框架。我们将借鉴该设计,并在其基础上加入面向对象编程。使用类简化了我们的框架,通过为插件和编写器创建抽象对象。

此外,在我们的框架中,我们将展示一些外部库的使用,这些库主要服务于美学目的,而非功能性。这些库包括 colorama 和 FIGlet,分别允许我们轻松地将彩色文本打印到标准输出并创建 ASCII 艺术。此外,我们的框架还要求使用我们在前几章中使用的所有第三方模块。

本章将讨论以下主题:

  • 框架基础、挑战和结构

  • 使用 Colorama 和 FIGlet 为我们的程序增添美学元素

本章的代码在 Python 2.7.15 和 Python 3.7.1 版本中进行开发和测试。

框架

为什么要构建框架?这个问题可能是,为什么要开发脚本?通常,我们会对给定的证据执行相同的步骤。例如,我们常常为 LNK、prefetch 和 jumplist 文件准备报告,检查注册表键,并确定外部设备和网络活动以回答取证问题。正如我们所看到的,我们可以开发一个脚本来解析这些工件,并以有助于快速分析的格式显示数据。为什么不编写一系列脚本,每个脚本负责一个工件,然后通过一个脚本来控制它们,一次性执行,从而进一步自动化我们的分析呢?

一个框架可以开发成一次性运行一系列脚本并同时解析多个工件。此类框架的输出可能是一系列分析就绪的电子表格。这样,检查员可以跳过同样繁琐的步骤,直接开始回答有关证据的有意义问题。

框架通常包含三个主要组件:

  • 一个主控制器

  • 插件

  • 编写者

主控制器与我们的main()函数并没有太大区别,基本上是对一些输入调用一系列插件,这些插件解析特定的工件,存储返回的结果,然后将结果发送到写入程序进行输出。我们的插件是执行特定任务的脚本,例如一个解析UserAssist工件的脚本。与我们的csv_writer()函数类似,写入程序接受插件的输出并将其写入磁盘。尽管这个过程看起来相当直接,但开发框架比开发单个脚本要复杂得多。原因在于,我们需要担心构建一个既简洁又高效的结构,同时保持插件之间的数据标准化。

构建一个持久的框架

开发框架时的一个挑战是如何在不断增加更多功能的同时,保持代码的简洁和高效。你可能会发现,尽管框架的结构最初看起来合理,但它并不支持你日益复杂的需求,这就要求你重新考虑并重建框架的内部结构。不幸的是,没有任何神奇的方法能够使框架在未来不出问题,通常在开发周期中会进行多次修订。

这与普通脚本开发没有什么不同。在本书的早期章节中,我们迭代了多个版本的脚本。这样做是为了说明你在开发过程中会发现的迭代构建过程。这个相同的迭代过程也可以在框架的开发中大规模应用。虽然我们在这一章中没有强调这个过程,但请记住,如果以后添加更多插件,效率开始下降,你可能需要重写这里开发的框架。通过迭代开发,我们能够不断改进原始设计,打造出一个稳定且高效的程序。

数据标准化

开发框架时最大的挑战之一就是数据标准化。意思是要标准化每个插件的输入输出数据,以保持简单性。例如,假设有一个插件返回一个字典列表,而另一个插件仅返回一个列表。为了正确处理这些结果,你需要在写入程序中加入逻辑,以处理这两种情况。通过使每个插件返回相同的数据结构,可以简化代码,减少处理各种特殊情况所需的额外逻辑。

话虽如此,可能会有一些特殊场景需要你为每个插件考虑。在我们的框架中,例如,我们会看到有些插件返回的是字典的列表,而有些插件返回的是单个字典。考虑一下第三章中的setupapi_parser.py解析文本文件——它能够识别多个不同的 USB 设备,并为每个设备生成一个字典,而我们的exif_parser.py只返回一个字典,其中包含单个文件中的嵌入式元数据。在这种情况下,我们不需要重写插件来遵循我们的规则,而是通过逻辑来处理额外的递归。

法医框架

有很多法医框架,其中很多都是开源的,允许任何人参与它们的开发。这些框架非常好,不仅可以贡献代码,还可以学习经验丰富的开发者是如何构建框架的。一些流行的开源法医框架包括以下内容:

在一个积极开发的项目中贡献代码,无论这个项目是否是框架,都是学习良好的编程技巧和发展未来项目协作关系的好方法。

在为任何项目开发之前,一定要阅读贡献规则。

已经讨论了很多关于框架的内容,现在让我们来讨论一下我们将使用的第三方模块,来增强我们框架的美观性。

Colorama

colorama模块(版本 0.4.1)允许我们轻松创建带颜色的终端文本。我们将使用它来向用户突出显示好坏事件。例如,当一个插件无错误地完成时,我们会用绿色字体显示它。类似地,我们会用红色字体打印遇到的错误。可以使用 pip 安装 colorama 模块:

pip install colorama==0.4.1  

传统上,在 Linux 或 macOS 系统上,打印彩色文本是通过一系列转义字符实现的。然而,这对于 Windows 操作系统不起作用。以下是在 Linux 或 macOS 终端中使用 ANSI 转义字符来创建彩色文本的示例:

颜色格式是转义字符\033,后跟一个左括号,然后是所需的颜色代码。我们不仅可以更改前景色,还可以更改背景色,甚至可以同时更改这两者,通过用分号分隔代码来实现。颜色代码31m将前景文本设置为红色。颜色代码47m将背景设置为白色。在第二个示例中,在前面的截图中,m指定了颜色代码的结束,因此它应该仅跟在最后一个颜色代码后面。

我们可以使用内置变量coloramacall,它们是所需 ANSI 代码的别名。这使我们的代码更具可读性,而且最重要的是,在调用colorama.init()后,它与 Windows 命令提示符兼容:

colorama模块有三个主要的格式化选项:ForeBackStyle。这些选项分别允许我们更改前景或背景文本的颜色及其样式。前景色和背景色的可用颜色包括:黑色、红色、绿色、黄色、蓝色、洋红色、青色和白色。

使用 ANSI 转义字符,我们还可以更改其他文本属性,例如将文本变暗或变亮。关于 ANSI 颜色代码和colorama库的更多信息,请访问pypi.python.org/pypi/colorama

FIGlet

FIGlet 及其 Python 扩展pyfiglet(版本 0.8.post0)是生成 ASCII 艺术的简单方法。我们只需向 FIGlet 提供一个我们选择的字符串和一个字体样式,后者决定了文本的设计。我们将使用此模块在程序执行开始时打印框架的标题,以赋予其个性。我们可以使用 pip 来安装 pyfiglet:

pip install pyfiglet==0.8.post0

要使用 FIGlet,我们需要创建一个 FIGlet 对象,并指定我们希望使用的字体类型。然后,我们调用该对象的renderText方法,并传入要进行样式化的字符串。字体的完整列表可以在www.figlet.org/examples.html找到:

>>> from pyfiglet import Figlet 
>>> f = Figlet(font='banner') 
>>> print(f.renderText('Forensics')) 
#######                                                     
#        ####  #####  ###### #    #  ####  #  ####   ####   
#       #    # #    # #      ##   # #      # #    # #       
#####   #    # #    # #####  # #  #  ####  # #       ####   
#       #    # #####  #      #  # #      # # #           #  
#       #    # #   #  #      #   ## #    # # #    # #    #  
#        ####  #    # ###### #    #  ####  #  ####   ####  

引入所需的第三方模块后,我们开始逐步讲解框架代码本身。

探索框架 – framework.py

我们的框架接受一个输入目录,递归索引所有文件,运行一系列插件以识别取证文物,然后将一系列报告写入指定的输出目录。这个理念是,检查人员可以使用 FTK Imager 等工具挂载.E01.dd文件,然后将框架应用于挂载的目录。

框架的布局是实现简洁设计的重要第一步。我们建议将写作人员和插件放在框架控制器下适当标记的子目录中。我们的框架布局如下:

  |-- framework.py 
  |-- requirements.txt 
  |-- plugins 
      |-- __init__.py 
      |-- exif.py 
      |-- id3.py 
      |-- office.py 
      |-- pst_indexer.py 
      |-- setupapi.py 
      |-- userassist.py 
      |-- wal_crawler.py 
      |-- helper 
          |-- __init__.py 
          |-- utility.py 
          |-- usb_lookup.py 
  |-- writers 
      |-- __init__.py 
      |-- csv_writer.py 
      |-- xlsx_writer.py 
      |-- kml_writer.py 

我们的framework.py脚本包含了框架的主要逻辑——处理所有插件的输入和输出值。requirements.txt文件包含每一行框架使用的第三方模块。采用这种格式,我们可以使用pip安装所有列出的模块。pip会尝试安装模块的最新版本,除非在模块名称后立即指定了版本,并使用两个等号(例如,colorama==0.4.1)。我们可以通过以下代码从requirements.txt文件安装第三方模块:

pip install -r requirements.txt 

插件和写入器存储在各自的目录中,并有一个__init__.py文件,以确保 Python 能够找到该目录。在插件目录中,我们的框架将支持七个初始插件。我们将包括以下插件:

  • 来自第八章的 EXIF、ID3 和 Office 嵌入式元数据解析器,媒体时代

  • 来自第十一章的 PST 解析器,解析 Outlook PST 容器

  • 来自第三章的 Setupapi 解析器,解析文本文件

  • 来自第六章的 UserAssist 解析器,从二进制文件中提取工件

  • 来自第十二章的 WAL 文件解析器,恢复临时数据库记录

还有一个helper目录,包含一些插件所需的辅助脚本。目前,我们的框架支持三种输出格式:CSV、XLSX 和 KML。只有exif插件会使用kml_writer来创建带有 EXIF GPS 数据的 Google Earth 地图,正如我们在第八章中看到的,媒体时代

现在我们了解了框架的工作原理、原因和布局,让我们深入看看一些代码。在第 2 行到第 11 行之间,我们导入了我们计划使用的模块。请注意,这仅是当前脚本所需的模块列表,未包括各种插件所需的依赖项。插件特定的导入将在各自的脚本中完成。

除了新增的coloramapyfiglet之外,之前章节中的大部分导入应该都很熟悉。在第 7 行和第 8 行,我们导入了插件和写入器子目录,这些目录包含了插件和写入器的脚本。第 13 行的colorama.init()调用是一个前提,允许我们在 Windows 命令提示符中打印彩色文本:

002 from __future__ import print_function
003 import os
004 import sys
005 import logging
006 import argparse
007 import plugins
008 import writers
009 import colorama
010 from datetime import datetime
011 from pyfiglet import Figlet
012 
013 colorama.init()

在第 49 行,我们定义了Framework类。该类包含多种方法,所有这些方法都处理框架的初始化和执行。run()方法充当我们的典型主函数,并调用_list_files()_run_plugins()方法。_list_files()方法会遍历用户提供目录中的文件,并根据文件名或扩展名,将文件添加到特定插件的处理列表中。然后,_run_plugins()方法将这些列表传递给每个插件执行,存储结果,并调用相应的写入器:

049 class Framework(object): 
... 
051     def __init__(): 
... 
061     def run(): 
... 
074     def _list_files(): 
... 
115     def _run_plugins(): 

Framework类中,有两个子类:PluginWriterPlugin类负责实际运行插件,记录插件完成的日志,并将数据发送以供写入。run()方法会对插件处理列表中的每个文件反复执行每个函数。它将返回的数据附加到一个列表中,并将其映射到字典中的键。这个字典还存储着电子表格所需的字段名称。write()方法会创建特定插件的输出目录,并根据指定的输出类型,适当地调用Writer类:

207     class Plugin(object): 
... 
209         def __init__(): 
... 
215         def run(): 
... 
236         def write(): 

Writer类是三者中最简单的类。它的run()方法只是简单地执行所需的写入器并提供正确的输入:

258     class Writer(object): 
... 
260         def __init__(): 
... 
271         def run(): 

和我们所有的脚本一样,我们使用argparse来处理命令行选项。在第 285 和 287 行,我们为输入和输出目录创建了两个位置参数。在第 288 和 290 行的两个可选参数分别指定了 XLSX 输出和所需的日志路径:

279 if __name__ == '__main__':
280 
281     parser = argparse.ArgumentParser(description=__description__,
282                                 epilog='Developed by ' +
283                                 __author__ + ' on ' +
284                                 __date__)
285     parser.add_argument('INPUT_DIR',
286         help='Base directory to process.')
287     parser.add_argument('OUTPUT_DIR', help='Output directory.')
288     parser.add_argument('-x', help='Excel output (Default CSV)',
289         action='store_true')
290     parser.add_argument('-l',
291         help='File path and name of log file.')
292     args = parser.parse_args()

我们可以在第 297 行看到首次使用colorama库。如果提供的输入和输出目录是文件,我们会向控制台打印一条红色的错误信息。对于框架的其余部分,我们使用红色文本显示错误信息,使用绿色文本显示成功信息:

294     if(os.path.isfile(args.INPUT_DIR) or
295             os.path.isfile(args.OUTPUT_DIR)):
296         msg = 'Input and Output arguments must be directories.'
297         print(colorama.Fore.RED + '[-]', msg)
298         sys.exit(1)

在第 300 行,我们检查是否提供了日志文件的可选目录路径。如果提供了,我们会创建这些目录(如果它们不存在),并将日志文件的文件名存储在log_path变量中:

300     if args.l:
301         if not os.path.exists(args.l):
302             os.makedirs(args.l) # create log directory path
303         log_path = os.path.join(args.l, 'framework.log')
304     else:
305         log_path = 'framework.log'

在第 307 和 309 行,我们创建了Framework对象,并调用了它的run()方法。我们将以下参数传递给Framework构造函数以实例化该对象:INPUT_DIROUTPUT_DIRlog_pathexcel。在下一节中,我们将更详细地检查Framework类:

307     framework = Framework(args.INPUT_DIR, args.OUTPUT_DIR,
308     log_path, excel=args.x)
309     framework.run()

以下流程图突出显示了framework.py脚本中不同方法的交互方式。请记住,这个流程图只展示了脚本内部的交互,并没有考虑到各种插件、写入器和工具脚本:

探索 Framework 对象

我们设计我们的框架时采用面向对象的编程设计。这使我们能够创建分隔和可重用的对象。在我们的 Framework 对象中包含 PluginWriter 对象,在接下来的章节中我们将对其进行探讨。Framework 类定义在第 49 行,并且扩展了 object 类。在 Python 2.X 中,从一个 object 继承取代了之前传统上的空继承,这在 Python 3.X 中已成为标准:

049 class Framework(object): 

理解框架的 __init__() 构造函数

框架的 __init__() 方法在第 51 行定义。在这个构造函数中,我们将传递给构造函数的参数赋值为实例变量。我们还在第 55 行配置了日志模块。让我们看一下 run() 方法,在我们看到的情况下,它是在 Framework 对象实例化后立即调用的:

051     def __init__(self, input_directory, output_directory, log,
052     **kwargs):
053         self.input = input_directory
054         self.output = output_directory
055         logging.basicConfig(filename=log, level=logging.DEBUG,
056                         format=('%(asctime)s | %(levelname)s | '
057                         '%(message)s'), filemode='a')
058         self.log = logging.getLogger(log)
059         self.kwargs = kwargs

创建框架的 run() 方法

run() 方法定义在第 61 行,用几行代码执行我们框架的整体逻辑。第 62 到 68 行仅用于打印和记录调试目的的启动信息。请注意,在第 65 和 66 行使用 Figlet 打印我们框架的标题到控制台:

061     def run(self):
062         msg = 'Initializing framework'
063         print('[+]', msg)
064         self.log.info(msg)
065         f = Figlet(font='doom')
066         print(f.renderText('Framework'))
067         self.log.debug('System ' + sys.platform)
068         self.log.debug('Version ' + sys.version) 

在第 69 行,我们检查输出目录是否存在。如果不存在,我们使用 os.makedirs() 方法创建它。最后,在第 71 和 72 行,我们调用 _list_files()_run_plugins() 方法对输入目录文件进行索引,并运行我们的插件:

069         if not os.path.exists(self.output):
070             os.makedirs(self.output)
071         self._list_files()
072         self._run_plugins() 

使用框架的 _list_files() 方法迭代文件

_list_files() 方法用于递归遍历输入目录中的每个文件。它将文件存储到处理列表中,供基于文件名或扩展名的插件使用。这种方法的一个缺点是,我们依赖于文件扩展名是否正确,而不是使用文件的签名本身。我们可以通过使用 struct 在框架中实现这个功能,就像我们在前几章中所做的那样。

请注意,_list_files() 方法前面有一个单个下划线。这是 Python 中声明内部方法的方式。这里的意思是,我们声明 _list_files() 方法不应该被导入,一般情况下也不应该由用户直接调用。例如,在第 309 行实例化我们的 Framework 对象后,我们不应该调用 Framework_list_files()。相反,我们可以调用 run() 方法,该方法又会调用 _list_files() 方法。

_list_files() 方法在第 74 行定义,并打印和记录当前的执行状态。在第 79 到 85 行,我们创建了一系列针对每个插件特定的列表。这些列表用于存储任何与插件兼容的文件,以便稍后处理:

074     def _list_files(self):
075         msg = 'Indexing {}'.format(self.input)
076         print('[+]', msg)
077         logging.info(msg)
078 
079         self.wal_files = []
080         self.setupapi_files = []
081         self.userassist_files = []
082         self.exif_metadata = []
083         self.office_metadata = []
084         self.id3_metadata = []
085         self.pst_files = []

从第 87 行开始,我们使用os.walk()方法(我们在前几章中使用过)来遍历输入目录。对于每个文件,我们创建两个变量,一个存储当前文件的名称,另一个存储当前文件的扩展名:

087         for root, subdir, files in os.walk(self.input,
088         topdown=True):
089             for file_name in files:
090                 current_file = os.path.join(root, file_name)
091                 if not os.path.isfile(current_file):
092                     logging.warning((u'Could not parse file {}...'
093                     ' Skipping...').format((current_file)))
094                     continue
095                 ext = os.path.splitext(current_file)[1].lower()

使用我们的current_fileext变量,我们使用一系列条件语句来识别插件需要处理的文件。例如,在第 96 行,我们检查文件名中是否包含ntuser.dat,因为这很可能表明它是一个用户的注册表配置单元,并且会被添加到我们的userassist_files列表中。

类似地,在第 100 行,任何以.jpeg.jpg结尾的文件很可能是带有嵌入式 EXIF 元数据的照片,并且会被添加到我们的exif_metadata列表中。如果当前文件不符合任何要求,那么我们无法使用当前的插件解析它,我们将使用continue开始下一个循环:

096                 if current_file.lower().endswith('ntuser.dat'):
097                     self.userassist_files.append(current_file)
098                 elif 'setupapi.dev.log' in current_file.lower():
099                     self.setupapi_files.append(current_file)
100                 elif ext == '.jpeg' or ext == '.jpg':
101                     self.exif_metadata.append(current_file)
102                 elif(ext == '.docx' or
103                         ext == '.pptx' or
104                         ext == '.xlsx'):
105                     self.office_metadata.append(current_file)
106                 elif ext == '.mp3':
107                     self.id3_metadata.append(current_file)
108                 elif ext == '.pst' or ext == '.ost':
109                     self.pst_files.append(current_file)
110                 elif ext.endswith('-wal'):
111                     self.wal_files.append(current_file)
112                 else:
113                     continue

开发框架的 _run_plugins()方法

_run_plugins()是另一个内部方法,处理调用每个插件的逻辑,然后将返回的结果发送到相应的写入器。处理每个插件时有两个关键的变化。我们在两个插件中强调了这些不同的变化。我们不会涵盖其他五个插件,以减少解释相同代码的重复。

第一个插件示例是wal_crawler插件。在第 117 行,我们检查是否需要为wal_crawler创建Plugin对象,因为如果wal_files列表为空,就没有东西可以对插件进行操作。如果不为空,我们将在第 118 行创建一个Plugin对象。

接下来,我们创建wal_output,它存储我们插件的输出目录。在第 121 行,我们调用Plugin类的run()方法,然后根据是否指定了excel输出选项,使用write()方法写入插件结果,并在必要时传递excel关键字参数。

回顾一下,wal_crawler脚本返回一个包含字典的列表,每个字典包含一行数据。当我们调用插件时,我们将结果放入另一个列表中。默认情况下,写入器只期望一个字典列表来遍历并写入相应的报告。由于我们将字典列表添加到另一个列表中,因此我们需要告诉写入器它需要另一个for循环来访问字典列表。我们通过将递归关键字参数传递给插件的write()方法来实现这一点。我们将recursion值设置为1,以标记它已启用:

115     def _run_plugins(self):
116         # Run Wal Crawler
117         if len(self.wal_files) > 0:
118             wal_plugin = Framework.Plugin('wal_crawler',
119             self.wal_files, self.log)
120             wal_output = os.path.join(self.output, 'wal')
121             wal_plugin.run(plugins.wal_crawler.main)
122             if self.kwargs['excel'] is True:
123                 wal_plugin.write(wal_output, recursion=1, excel=1)
124             else:
125                 wal_plugin.write(wal_output, recursion=1)

与前面的示例不同,我们的 ID3 元数据脚本返回一个单一的字典,该字典会被添加到一个列表中。在这些情况下,我们不需要指定recursion关键字参数,如第 190 行和 193 行所示。除了这一点不同,插件的处理方式与前一个插件相同。

记住,我们框架的目标之一是能够以尽可能少的代码行禁用或添加新的插件。

这提高了框架的简洁性,使得它更容易维护。我们通过保持逻辑一致性并使用关键字参数来处理微小的变化,努力保持这一点:

182         # Run ID3 metadata parser
183         if len(self.id3_metadata) > 0:
184             id3_metadata_plugin = Framework.Plugin('id3_metadata',
185             self.id3_metadata, self.log)
186             id3_metadata_output = os.path.join(self.output,
187                 'metadata')
188             id3_metadata_plugin.run(plugins.id3.main)
189             if self.kwargs['excel'] is True:
190                 id3_metadata_plugin.write(id3_metadata_output,
191                     excel=1)
192             else:
193                 id3_metadata_plugin.write(id3_metadata_output)

探索 Plugin 对象

在第 207 行,我们看到了 Plugin 子类的开始。这个类包含了 run()write() 方法,这些方法用于处理每个插件的执行以及对写入器的调用:

207     class Plugin(object): 

理解 Plugin 的 __init__() 构造函数

Plugin 构造方法非常简单。我们为插件名称、待处理文件、日志以及一个包含插件结果的字典创建实例变量。结果字典包含一个数据列表,该列表存储每次插件调用返回的实际结果。headers 键最终将包含一个列表,存储在写入器中使用的字段名:

209         def __init__(self, plugin, files, log):
210             self.plugin = plugin
211             self.files = files
212             self.log = log
213             self.results = {'data': [], 'headers': None} 

使用 Plugin 的 run() 方法

在第 215 行定义的 run() 方法负责在插件列表中的每个文件上执行插件。此外,该方法会打印出与插件执行相关的各种状态消息。

传递给 run() 方法的函数参数是插件中的入口方法名称。我们为插件文件列表中的每个文件调用这个入口方法。例如,wal_crawler 插件的入口方法是 plugins.wal_crawler.main

215         def run(self, function):
216             msg = 'Executing {} plugin'.format(self.plugin)
217             print(colorama.Fore.RESET + '[+]', msg)
218             self.log.info(msg)

在第 220 行,我们开始遍历插件文件列表中的每个文件。在第 222 行,我们调用函数变量,并将文件作为参数传递给它进行处理。这将所有插件限制为单一文件输入。我们对现有插件所做的一些修改,涉及到调整它们的必需参数,使其能够在框架的限制范围内工作。例如,在前面的章节中,我们可能会将输出文件或目录作为脚本的参数之一传递。然而现在,写入器作为框架的一个独立部分处理输出,因此插件只需要专注于处理数据并将其返回给框架。

请注意,函数调用被包裹在 tryexcept 中。在插件本身中,我们可以看到当插件出现错误时会抛出 TypeError;在发生错误时,插件会记录实际错误,同时框架会继续处理下一个文件。

在第 223 和 224 行,我们将插件返回的结果附加到数据列表中,并为插件设置 headers。返回的 headers 列表是一个常量字段名列表,每当插件成功返回时都会设置:

220             for f in self.files:
221                 try:
222                     data, headers = function(f)
223                     self.results['data'].append(data)
224                     self.results['headers'] = headers
225 
226                 except TypeError:
227                     self.log.error(('Issue processing {}. '
228                         'Skipping...').format(f))
229                     continue

最后,在第 231 到 234 行,我们打印并记录插件的成功完成,包括当前时间:

231             msg = 'Plugin {} completed at {}'.format(self.plugin,
232             datetime.now().strftime('%m/%d/%Y %H:%M:%S'))
233             print(colorama.Fore.GREEN + '[*]', msg)
234             self.log.info(msg)

使用 Plugin 的 write() 方法处理输出

write()方法首先在第 236 行定义。此方法创建插件特定的输出目录,并调用适当的写入器来生成插件报告。在第 241 和 242 行,在向用户打印状态消息后,如果插件输出目录尚不存在,则创建该目录:

236         def write(self, output, **kwargs):
237             msg = 'Writing results of {} plugin'.format(
238             self.plugin)
239             print(colorama.Fore.RESET + '[+]', msg)
240             self.log.info(msg)
241             if not os.path.exists(output):
242                 os.makedirs(output)

在第 243 行,我们检查excel关键字参数是否在函数调用中指定。如果指定了,我们调用xlsx_writer并传递输出目录、所需的文件名、字段名和要写入的数据。

如果没有提供excel关键字参数,则调用默认的csv_writer。此函数与xlsx_writer具有相同的参数。在第 253 行,我们检查插件名称是否为exif_metadata。如果是,我们调用kml_writer来绘制 Google Earth 的 GPS 数据:

243             if 'excel' in kwargs.keys():
244                 Framework.Writer(writers.xlsx_writer.writer,
245                     output, self.plugin + '.xlsx',
246                     self.results['headers'],
247                     self.results['data'], **kwargs)
248             else:
249                 Framework.Writer(writers.csv_writer.writer,
250                     output, self.plugin + '.csv',
251                     self.results['headers'],
252                     self.results['data'], **kwargs)
253             if self.plugin == 'exif_metadata':
254                 Framework.Writer(writers.kml_writer.writer,
255                     output, '', self.plugin + '.kml',
256                     self.results['data'])

探索 Writer 对象

Writer对象在第 258 行定义。此类负责为每个插件创建报告。该类有一个主要方法run(),它简单地调用在plugin.write方法中描述的写入器:

258     class Writer(object): 

理解 Writer 的 init()构造函数

构造方法实例化会话变量,包括报告的输出文件名、表头和要写入的数据。如果存在recursion关键字参数,我们在调用run()方法之前设置会话变量:

260         def __init__(self, writer, output, name, header, data,
261         **kwargs):
262             self.writer = writer
263             self.output = os.path.join(output, name)
264             self.header = header
265             self.data = data
266             self.recursion = None
267             if 'recursion' in kwargs.keys():
268                 self.recursion = kwargs['recursion']
269             self.run()

理解 Writer 的 run()方法

run()方法非常直接。根据是否指定了递归,我们调用指定的写入器,并传递recursion关键字参数:

271         def run(self):
272             if self.recursion:
273                 self.writer(self.output, self.header, self.data,
274                     recursion=self.recursion)
275             else:
276                 self.writer(self.output, self.header, self.data)

我们的最终 CSV 写入器 – csv_writer.py

每个写入器的工作方式基本相同。在讨论更复杂的xlsx_writer脚本之前,让我们简要讨论一下csv_writer方法。根据框架是使用 Python 2.X 还是 3.X 运行,我们导入原生的csvunicodecsv模块来处理 Unicode 字符串。unicodecsv模块首次出现在第五章,Python 中的数据库中:

001 from __future__ import print_function
002 import sys
003 import os
004 if sys.version_info[0] == 2:
005     import unicodecsv as csv
006 elif sys.version_info[0] == 3:
007     import csv 

我们的写入器非常简单。在第 61 行,我们创建一个csv.DictWriter对象,并传入输出文件名和表头列表。像往常一样,我们指示写入器忽略那些在提供的表头中没有指定的键:

038 def writer(output, headers, output_data, **kwargs):
039     """
040     The writer function uses the csv.DictWriter module to write
041     list(s) of dictionaries. The DictWriter can take a fieldnames
042     argument, as a list, which represents the desired order of
043     columns.
044     :param output: The name of the output CSV.
045     :param headers: A list of keys in the dictionary that
046     represent the desired order of columns in the output.
047     :param output_data: The list of dictionaries containing
048     embedded metadata.
049     :return: None
050     """
051 
052     if sys.version_info[0] == 2:
053         csvfile = open(output, "wb")
054     elif sys.version_info[0] == 3:
055         csvfile = open(output, "w", newline='',
056             encoding='utf-8')
057 
058     with csvfile:
059         # We use DictWriter instead of writer to write
060         # dictionaries to CSV.
061         w = csv.DictWriter(csvfile, fieldnames=headers,
062             extrasaction='ignore')

创建DictWriter对象后,我们可以使用内置的writerheader()方法将字段名作为电子表格的第一行写入。请注意,我们将其包装在 try 和 except 中,这是我们以前没有做过的。假设有一个插件需要处理的文件,并且在处理中遇到错误并提前返回。在这种情况下,表头列表将为 None,这会导致错误。这个最后的检查可以让我们避免在这种情况下写入无效的输出文件:

064         # Writerheader writes the header based on the supplied
065         # headers object
066         try:
067             w.writeheader()
068         except TypeError:
069             print(('[-] Received empty headers...\n'
070                 '[-] Skipping writing output.'))
071             return

接下来,在第 73 行,如果提供了recursion关键字参数,我们在调用字典的writerow方法之前使用两个for循环。否则,在第 79 行,我们只需使用一个for循环来访问数据并进行写入:

073         if 'recursion' in kwargs.keys():
074             for l in output_data:
075                 for data in l:
076                     if data:
077                         w.writerow(data)
078         else:
079             for data in output_data:
080                 if data:
081                     w.writerow(data)

写入器 – xlsx_writer.py

xlsx_writer函数是xlsx_writer的略微修改版本,我们在第六章,从二进制文件中提取文档中创建了它。我们使用相同的xlsxwriter第三方模块来处理 Excel 输出。在第 32 行,我们使用列表推导式创建一个包含从AZ的大写字母的列表。我们将使用此列表根据提供的字段头长度指定列字母。此方法在字段名称少于 26 个时有效,对于当前插件集是成立的:

001 from __future__ import print_function
002 import xlsxwriter
...
032 ALPHABET = [chr(i) for i in range(ord('A'), ord('Z') + 1)]

在第 44 行,我们创建xlsxwriter工作簿并提供输出文件名以保存。在继续之前,我们检查提供的头部是否为空。这个检查是必要的,正如在csv_writer中一样,避免因为错误的写入器调用而写入无效数据。在第 52 行,我们将title_length设置为最右侧列的字母,以防有超过 26 列的情况。目前,我们将最右侧的值设置为Z

035 def writer(output, headers, output_data, **kwargs):
036     """
037     The writer function writes excel output for the framework
038     :param output: the output filename for the excel spreadsheet
039     :param headers: the name of the spreadsheet columns
040     :param output_data: the data to be written to the excel
041     spreadsheet
042     :return: Nothing
043     """
044     wb = xlsxwriter.Workbook(output)
045 
046     if headers is None:
047         print('[-] Received empty headers... \n'
048             '[-] Skipping writing output.')
049         return
050 
051     if len(headers) <= 26:
052         title_length = ALPHABET[len(headers) - 1]
053     else:
054         title_length = 'Z'

接下来,在第 56 行,我们创建工作表。与csv_writer函数类似,如果指定了递归,我们将遍历列表,为每个额外的列表添加一个工作表,以防止它们相互覆盖。然后,我们使用列表推导式根据字段名称的顺序快速排序字典值。在csv_writer中,DictWriter对象的writerow方法会自动对数据进行排序。对于xlsx_writer,我们需要使用列表推导式来重新创建相同的效果:

056     ws = add_worksheet(wb, title_length)
057 
058     if 'recursion' in kwargs.keys():
059         for i, data in enumerate(output_data):
060             if i > 0:
061                 ws = add_worksheet(wb, title_length)
062             cell_length = len(data)
063             tmp = []
064             for dictionary in data:
065                 tmp.append(
066                 [str(dictionary[x]) if x in dictionary.keys() else '' for x in headers]
067                 )

在第 69 行,我们创建一个从A3XY的表格,其中X是表示字段名称列表长度的字母字符,Youtput_data列表的长度。例如,如果我们有一个数据集,包含六个字段名称和 10 个条目,我们希望表格从A3F13。此外,我们将排序后的数据传递给表格,并再次使用列表推导式指定每一列,确保每个标题对应一个键值对的字典:

069             ws.add_table(
070                 'A3:' + title_length + str(3 + cell_length),
071                 {'data': tmp,
072                 'columns': [{'header': x} for x in headers]})

在第 74 行,我们处理没有提供recursion关键字参数的情况。在这种情况下,我们处理相同的执行流程,只是没有额外的for循环。最后,在第 84 行,我们关闭工作簿:

074     else:
075         cell_length = len(output_data)
076         tmp = []
077         for data in output_data:
078             tmp.append([str(data[x]) if x in data.keys() else '' for x in headers])
079         ws.add_table(
080             'A3:' + title_length + str(3 + cell_length),
081             {'data': tmp,
082             'columns': [{'header': x} for x in headers]})
083 
084     wb.close()

add_worksheet()方法在第 56 行和第 61 行被调用。这个函数用于创建工作表并写入电子表格的前两行。在第 96 行,我们创建了title_format样式,其中包含我们想要的两个标题行的文本属性。在第 101 行和第 103 行,我们创建了这两行标题。目前,这些标题行的值是硬编码的,但可以通过将它们作为可选开关添加到argparse中,编程到框架中:

087 def add_worksheet(wb, length, name=None):
088     """
089     The add_worksheet function creates a new formatted worksheet
090     in the workbook
091     :param wb: The workbook object
092     :param length: The range of rows to merge
093     :param name: The name of the worksheet
094     :return: ws, the worksheet
095     """
096     title_format = wb.add_format({'bold': True,
097     'font_color': 'black', 'bg_color': 'white', 'font_size': 30,
098     'font_name': 'Arial', 'align': 'center'})
099     ws = wb.add_worksheet(name)
100 
101     ws.merge_range('A1:' + length + '1', 'XYZ Corp',
102         title_format)
103     ws.merge_range('A2:' + length + '2', 'Case ####',
104         title_format)
105     return ws 

对插件所做的更改

我们已经讨论了框架、它的子类和两个主要的写入脚本。那么我们在前几章中对插件脚本所做的修改呢?大部分情况下,它们的核心功能没有变化。我们所做的修改包括删除打印和日志语句,删除argparse和日志设置部分,以及删除一些不必要的函数,例如脚本的输出写入器(因为框架已经处理了这个问题)。

我们不逐一讲解每个插件,而是邀请你自己查看源文件并进行对比。你会发现这些文件与前面的脚本大致相同。请记住,当我们最初编写这些脚本时,我们心里已经预想到它们最终会被添加到框架中。虽然框架版本和非框架版本的脚本之间的相似性是故意为之,但仍然需要进行修改才能使一切正常工作。

执行框架

要运行框架,最少需要提供输入和输出目录。可选地,我们还可以提供日志输出路径参数,并启用 XLSX 输出,而不是默认的 CSV 输出。第一个示例和随后的截图突出显示了运行框架所需的最小参数。第二个示例展示了我们可以在框架中调用的额外开关:

python framework.py /mnt/evidence ~/Desktop/framework_output 
python framework.py /mnt/evidence ~/Desktop/framework_output -l ~/Desktop/logs -x 

在运行框架后,用户将看到各种输出文本,详细说明框架的执行状态:

每当一个插件成功处理时,都会在该插件的输出文件夹中生成报告。我们决定通过将每个插件报告存储在单独的文件夹中来组织输出,以便检查人员可以轻松地查看自己感兴趣的插件:

附加挑战

我们的框架有很多潜在的改进机会。显然,我们可以继续为框架添加更多插件和写入器。例如,虽然我们通过Setupapi插件已经实现了 USB 设备的基本证据,但可以通过使用第六章中的Registry模块解析各种与 USB 相关的注册表项来扩展它,从二进制文件中提取证据。或者,可以考虑添加我们已经创建的其他脚本。例如,使用第五章中的脚本生成活动文件列表可能会很有用,Python 中的数据库。这将使我们能够监控框架已处理的文件。

此外,添加新的用户活动证据源,例如预取解析器,将增强框架的内在价值。预取文件的文件格式可以在forensicswiki.org/wiki/Windows_Prefetch_File_Format中找到。与任何二进制文件一样,我们建议使用struct模块来解析该文件。

最后,对于那些寻求挑战的人,可以考虑通过使用libewf (github.com/libyal/libewf) 或 libtsk (github.com/py4n6/pytsk)来添加对E01dd的支持。这将无需在运行框架之前挂载镜像文件。这将是一个较为艰巨的任务,可能需要重写框架。然而,挑战越大,完成后你得到的收获也会越多。

该实现的示例可在 Packt 出版的《Python 数字取证手册》中找到,链接为www.packtpub.com/networking-and-servers/python-digital-forensics-cookbook

总结

这是最后一章,我们在这里学习了如何利用之前编写的脚本开发自己的取证框架。这是建立你自己的自动化取证解决方案的第一步,大大提升你的午休时间,或者对于更为严谨的用户,提升效率。我们学会了如何平衡代码复杂性和效率,开发一个可持续的框架来帮助我们回答调查性问题。该项目的代码可以从 GitHub 或 Packt 下载,具体内容见前言

本书开头时,我们希望通过展示越来越复杂的脚本来教会调查员 Python 的优势。在这个过程中,我们介绍了常见的技术、最佳实践以及大量的第一方和第三方模块。我们希望,在这个阶段,你已经能够开发自己的脚本,理解 Python 的基本知识,并且已经在成为一名取证开发者的道路上迈出了坚实的步伐。

当我们结束这本书时,我们想列出一些建议。如果您还没有这样做,请尝试解决各种挑战。有些问题很简单,而其他一些则更具挑战性,但无论如何,它们都将帮助您进一步发展技能。此外,不要仅仅是跟随本书提供的代码。找到一个经常遇到的问题或任务,并从头开始编写脚本。还有,像往常一样,请朋友帮忙,使用互联网,阅读更多书籍,并与他人合作继续学习。我们学习的能力只会因我们追求学习的努力不足而受到阻碍。

posted @ 2025-07-07 14:34  绝不原创的飞龙  阅读(48)  评论(0)    收藏  举报