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?')

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

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

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

与我们联系

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

一般反馈:如果您对本书的任何部分有疑问,请在邮件主题中注明书名,并发送邮件至[email protected]

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

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

如果你有兴趣成为作者:如果你在某个领域有专业知识,且有意写作或为书籍做贡献,请访问 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 脚本时,无论是临时开发还是针对较大项目开发,都应考虑已经存在的解决方案、可用的开发时间以及通过自动化节省的时间。尽管有着最佳的意图,解决方案的开发可能会比最初设想的时间要长得多,尤其是当没有一个强有力的设计计划时。

开发生命周期

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

  • 识别

  • 计划

  • 编程

  • 验证

  • 错误

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

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/289dd91f-6deb-4ef5-bb00-85fa1c63de51.png

良好的规划将有助于减少所需的代码量和 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?')
 

如果我们将一个对象传递给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)


>>> 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!消息应该会显示在你的终端中:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/383f350b-0090-40e5-88f1-cdc79124cb68.png

让我们讨论一下为什么这个简单的脚本在某些版本的 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!") 

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/514ef83c-f121-4986-8c6f-afe695d17374.png

在上一个截图中,您可以看到在 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."""  

输出如下:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/11aa2e04-ae60-46ee-9779-591806708404.png

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

字符串与 Unicode

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

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

>>> type('Hello World!')

>>> type("Foo Bar 1234")
 

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

>>> """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)

>>> 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)

>>> 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])

>>> 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})

>>> 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]))

>>> 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 "", line 1, in 
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 "", line 1, in 
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 "", line 1, in 
 File "", 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来执行此脚本。该脚本如以下截图所示运行,直到它需要用户输入。一旦输入值,脚本继续执行并将转换后的时间戳打印到控制台:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/9a19d5dc-c394-4e33-930c-487382ce4ca0.png

用户输入

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

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

使用原始输入方法和系统模块 – 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() 函数也会将所有输入解释为字符串值:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/11dcec26-1ede-4643-9c05-8daa53de02c5.png

对于没有很多命令行选项的小型程序,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 开关时,我们应该能够看到脚本所需的必需参数和可选参数:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/10198d66-121b-4449-a597-a10870d68445.png

法医脚本最佳实践

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

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

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

  • 注释代码

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

  • 维护详细的日志记录

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

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

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

我们之前提到过代码注释,但再强调它的重要性也不会过分。很快,我们将创建第一个取证脚本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()函数的流程。还有其他库可以创建类似的流程图。然而,这个库在创建简单且易于理解的流程图方面做得非常好:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/2ed8a4fd-93ec-4620-a007-9ef795ebbab2.png

理解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:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/9cf95fc4-970a-4d46-86ab-3025ec84fbbc.png

我们的数据源并非一个包含所有数据的完整列表,如果你提供了一个在数据源中不存在的厂商或产品 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 "", line 1, in 
AttributeError: 'module' object has no attribute 'noattribute'

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

现在,让我们看看一个稍微复杂一点的问题。为此,我们将使用之前脚本中的数据结构。在下面的代码块中,我们并没有通过 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 
        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()函数:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/7f8802db-8038-41d3-9d2e-1100d3445b8b.png

设计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文件有所不同:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/fd0d7ec0-c1ba-472c-b53b-cfb780f8fa95.png

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

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/e0a7af39-e28b-49c8-9e83-af8c7cdd65cf.png

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

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/9f38a21a-037f-423c-b936-0f6172b92655.png

我们当前的迭代似乎通过提取一些并非仅与 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()方法将解析后的数据打印到控制台:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/e552b031-1dd2-4f20-b4d4-e7760da8efbf.png

改进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文件以进行解析

  • 用户输入文件的验证

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

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

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

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/a337f4de-28e6-4496-81c2-38c50ec4236b.png

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

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/c36616f5-d81b-4767-be29-f2a1515bbe22.png

我们的最终迭代——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()函数内增加更多控制逻辑。这个函数依赖其他函数来执行任务并返回数据,而不是自己完成工作。这为我们的脚本提供了一种高层次的组织方式,并通过线性执行一个函数接一个函数,帮助保持简洁:

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/5b1debc7-d4f9-4cbd-9fe3-531711b89505.png

扩展 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 数据解析更多的设备信息

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

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

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/4b5156b8-1b52-4e1c-9d20-b362ab37cdf3.png

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

https://github.com/OpenDocCN/freelearn-sec-pt3-zh/raw/master/docs/lrn-py-frsc-2e/img/47a584bc-cd0b-48bf-bb76-c05874f0d5a5.png

挑战

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

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

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

摘要

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

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

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

你可能感兴趣的:(默认分类,默认分类)