Python 取证学习指南第二版(二)
原文:
annas-archive.org/md5/46c71d4b3d6fceaba506eebc55284aa5译者:飞龙
第四章:处理序列化数据结构
在本章中,我们将通过操作JavaScript 对象表示法(JSON)结构化数据,进一步提高处理嵌套列表和字典的技能。我们的研究对象是原始比特币账户数据,其中包含发送和接收的所有交易列表等信息。我们将使用网页应用程序接口(API)访问此数据集,并以有利于分析的方式进行解析。
API 是为软件产品创建的,允许程序员以定义的方式与软件进行交互。并非所有给定软件的公共 API 都是可用的。当可用时,API 通过提供与软件交互的方法,加速了代码开发,因为 API 将处理低级的实现细节。开发人员实现 API 旨在鼓励他人构建支持程序,并进一步控制其他开发人员的代码如何与他们的软件交互。通过创建 API,开发人员为其他程序员提供了与他们的程序进行受控交互的方式。
在本章中,我们将使用来自www.blockchain.info的网页 API 来查询并接收给定比特币地址的比特币账户信息。该 API 生成的 JSON 数据可以使用标准库中的 JSON 模块转换为 Python 对象。有关其 API 的说明和示例,可以在www.blockchain.info/api/blockchain_api找到。
在本章中,我们将涵盖以下内容:
-
讨论和操作包括可扩展标记语言(XML)和 JSON 数据在内的序列化结构
-
使用 Python 创建日志
-
以 CSV 输出格式报告结果
本章的代码是使用 Python 2.7.15 和 Python 3.7.1 开发和测试的。bitcoin_address_lookup.v1.py 和 bitcoin_address_lookup.v2.py 脚本是为了与 Python 3.7.1 而非 Python 2.7.15 一起使用而开发的。
序列化数据结构
序列化是一个过程,通过该过程,数据对象在计算机系统的存储过程中被保留。序列化数据保留了对象的原始类型。也就是说,我们可以将字典、列表、整数或字符串序列化到一个文件中。稍后,当我们反序列化这个文件时,这些对象将仍然保持它们的原始数据类型。序列化非常好,因为如果我们将脚本对象存储到文本文件中,我们就无法轻松地将这些对象恢复到适当的数据类型。如我们所知,读取文本文件时,读取的数据是字符串。
XML 和 JSON 是两种常见的纯文本编码序列化格式。你可能已经习惯于在法医调查中分析这些文件。熟悉移动设备取证的分析师可能会识别包含帐户或配置详细信息的特定应用程序 XML 文件。让我们看看如何利用 Python 解析 XML 和 JSON 文件。
我们可以使用xml模块解析任何包括 XML 和 HTML 数据的标记语言。以下book.xml文件包含关于这本书的详细信息。如果你之前从未见过 XML 数据,第一件你可能会注意到的是它与 HTML 结构相似,HTML 是另一种标记语言,其中内容被打开和关闭标签包围,如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<root>
<authors>Preston Miller & Chapin Bryce</authors>
<chapters>
<element>
<chapterNumber>1</chapterNumber>
<chapterTitle>Now for Something Completely Different</chapterTitle>
<pageCount>30</pageCount>
</element>
<element>
<chapterNumber>2</chapterNumber>
<chapterTitle>Python Fundamentals</chapterTitle>
<pageCount>25</pageCount>
</element>
</chapters>
<numberOfChapters>13</numberOfChapters>
<pageCount>500</pageCount>
<publisher>Packt Publishing</publisher>
<title>Learning Python for Forensics</title>
</root>
对于分析师来说,XML 和 JSON 文件很容易阅读,因为它们是纯文本格式。然而,当文件包含数千行时,手动审核变得不切实际。幸运的是,这些文件具有高度的结构化,更棒的是,它们是为程序使用而设计的。
要探索 XML,我们需要使用xml模块中的ElementTree类,它将解析数据并允许我们遍历根节点的子节点。为了解析数据,我们必须指定正在解析的文件。在这个例子中,我们的book.xml文件位于与 Python 交互式提示符相同的工作目录中。如果情况不是这样,我们需要在文件名外指定文件路径。如果你使用的是 Python 2,请确保从__future__导入print_function。我们使用getroot()函数访问根级节点,如下所示:
>>> import xml.etree.ElementTree as ET
>>> tree = ET.parse('book.xml')
>>> root = tree.getroot()
通过根元素,让我们使用find()函数在 XML 文件中搜索第一个authors标签实例。每个元素都有不同的属性,如tag、attrib和text。tag元素是一个描述数据的字符串,在这个例子中是authors。如果存在,属性(attrib)会存储在字典中。属性是赋值给标签内的值。例如,我们可以创建一个chapter标签:
<chapter number=2, title="Python Fundamentals", count=20 />
该对象的属性将是一个字典,包含键number、title和count及其各自的值。要访问标签之间的内容(例如chapterNumber),我们需要使用text属性。
我们可以使用findall()函数查找指定子标签的所有出现实例。在以下示例中,我们正在查找数据集中所有出现的chapters/element。一旦找到,我们可以使用列表索引访问element父标签中的特定标签。在这种情况下,我们只想访问element中前两个位置的章节号和标题。请看以下示例:
>>> print(root.find('authors').text)
Preston Miller & Chapin Bryce
>>> for element in root.findall('chapters/element'):
... print('Chapter #{}'.format(element[0].text))
... print('Chapter Title: {}'.format(element[1].text))
...
Chapter #1
Chapter Title: Now for Something Completely Different
Chapter #2
Chapter Title: Python Fundamentals
我们可以使用许多其他方法来处理标记语言文件,利用 xml 模块。完整的文档,请参考 docs.python.org/3/library/xml.etree.elementtree.html.
在介绍完 XML 后,让我们来看一下将相同示例存储为 JSON 数据,并且更重要的是,我们如何使用 Python 来解释这些数据。稍后,我们将创建一个名为 book.json 的 JSON 文件;请注意使用的键,例如 title、authors、publisher,它们的关联值通过冒号分隔。这与 Python 中字典的结构类似。此外,注意 chapters 键使用了方括号,然后如何通过逗号分隔嵌套的类似字典的结构。在 Python 中,加载后,这个 chapters 结构会被解释为一个包含字典的列表,前提是使用 json 模块:
{
"title": "Learning Python Forensics",
"authors": "Preston Miller & Chapin Bryce",
"publisher": "Packt Publishing",
"pageCount": 500,
"numberOfChapters": 13,
"chapters":
[
{
"chapterNumber": 1,
"chapterTitle": "Now for Something Completely Different",
"pageCount": 30
},
{
"chapterNumber": 2,
"chapterTitle": "Python Fundamentals",
"pageCount": 25
}
]
}
为了使用 json 模块解析该数据结构,我们使用 loads() 函数。与 XML 示例不同,我们需要首先打开一个文件对象,然后才能使用 loads() 将数据转换。在下一个代码块中,book.json 文件被打开,它与交互式提示位于同一工作目录,并将其内容读取到 loads() 方法中。顺便说一下,我们可以使用 dump() 函数执行反向操作,将 Python 对象转换为 JSON 格式进行存储。与 XML 代码块类似,如果你使用的是 Python 2,请从 __future__ 导入 print_function:
>>> import json
>>> jsonfile = open('book.json', 'r')
>>> decoded_data = json.loads(jsonfile.read())
>>> print(type(decoded_data))
<class'dict'>
>>> print(decoded_data.keys())
dict_keys(['title', 'authors', 'publisher', 'pageCount', 'numberOfChapters', 'chapters'])
该模块的 loads() 方法读取 JSON 文件的字符串内容,并将数据重构为 Python 对象。如前面的代码所示,整个结构被存储在一个包含键值对的字典中。JSON 能够存储对象的原始数据类型。例如,pageCount 被反序列化为整数,title 被反序列化为字符串对象。
并非所有数据都以字典的形式存储。chapters 键被重构为一个列表。我们可以使用 for 循环遍历 chapters 并打印出任何相关的细节:
>>> for chapter in decoded_data['chapters']:
... number = chapter['chapterNumber']
... title = chapter['chapterTitle']
... pages = chapter['pageCount']
... print('Chapter {}, {}, is {} pages.'.format(number, title, pages))
...
Chapter 1, Now For Something Completely Different, is 30 pages.
Chapter 2, Python Fundamentals, is 25 pages.
为了明确,chapters 键在 JSON 文件中被存储为一个列表,并包含每个 chapter 元素的嵌套字典。当遍历这些字典列表时,我们存储并打印与字典键相关的值。我们将使用这个技术在更大规模的 Bitcoin JSON 数据解析中。有关 json 模块的更多细节,请参阅 docs.python.org/3/library/json.html。本节中使用的 XML 和 JSON 示例文件可以在本章的代码包中找到。还有其他模块,如 pickle 和 shelve,可以用于数据序列化,但它们不在本书范围内。
一个简单的 Bitcoin 网络 API
比特币已经席卷全球,并成为新闻头条;它是最成功、最著名的——或者说是臭名昭著的——去中心化加密货币。比特币被视为一种“匿名”的在线现金替代品。曾经已经关闭的 Tor 网络上的非法市场 SilkRoad 接受比特币作为支付手段,用于非法商品或服务的交易。自从比特币获得广泛关注以来,一些网站和实体商店也开始接受比特币支付。它也因其价值远超预期而获得了广泛的公众关注。
比特币为个人分配地址以存储他们的比特币。这些用户可以通过指定他们想使用的地址来发送或接收比特币。在比特币中,地址由 34 个区分大小写的字母数字字符表示。幸运的是,所有交易都公开存储在区块链上。区块链记录了每笔交易的时间、输入、输出和金额。此外,每笔交易都被分配了一个唯一的交易哈希。
区块链浏览器是允许个人搜索区块链的程序。例如,我们可以搜索特定的地址或交易。一个这样的区块链浏览器位于www.blockchain.com/explorer,我们将使用它来生成我们的数据集。让我们看看一些需要解析的数据。
我们的脚本将处理 JSON 结构的交易数据,处理后将这些信息以分析准备好的状态输出给审查人员。在用户输入感兴趣的地址后,我们将使用blockchain.info API 查询区块链,并拉取相关账户数据,包括所有关联的交易,具体如下:
https://blockchain.info/address/%btc_address%?format=json
我们将通过替换%btc_address%为实际的感兴趣地址来查询前述 URL。在本次练习中,我们将调查125riCXE2MtxHbNZkRtExPGAfbv7LsY3Wa地址。如果你打开浏览器并将%btc_address%替换为感兴趣的地址,我们可以看到我们的脚本负责解析的原始 JSON 数据:
{
"hash160":"0be34924c8147535c5d5a077d6f398e2d3f20e2c",
"address":"125riCXE2MtxHbNZkRtExPGAfbv7LsY3Wa",
"n_tx":25,
"total_received":80000000,
"total_sent":80000000,
"final_balance":0,
"txs":
[
...
]
}
这是我们之前 JSON 示例的一个更复杂版本;然而,适用的规则相同。从hash160开始,包含一般账户信息,如地址、交易数量、余额、已发送和已接收的总额。接下来是交易数组,用方括号表示,包含该地址涉及的每一笔交易。
看一笔单独的交易,有几个键值很突出,比如输入和输出列表中的addr值、时间和哈希。当我们遍历txs列表时,这些键值将被用来重建每笔交易,并向审查员展示这些信息。我们有如下的交易:
"txs":[{
"lock_time":0,
"result":0,
"ver":1,
"size":225,
"inputs":[
{
"sequence":4294967295,
"prev_out":{
"spent":true,
"tx_index":103263818,
"type":0,
"addr":"125riCXE2MtxHbNZkRtExPGAfbv7LsY3Wa",
"value":51498513,
"n":1,
"script":"76a9140be34924c8147535c5d5a077d6f398e2d3f20e2c88ac"
},
"script":"4730440220673b8c6485b263fa15c75adc5de55c902cf80451c3c54f8e49df4357ecd1a3ae022047aff8f9fb960f0f5b0313869b8042c7a81356e4cd23c9934ed1490110911ce9012103e92a19202a543d7da710af28c956807c13f31832a18c1893954f905b339034fb"
}],
"time":1442766495,
"tx_index":103276852,
"vin_sz":1,
"hash":"f00febdc80e67c72d9c4d50ae2aa43eec2684725b566ec2a9fa9e8dbfc449827",
"vout_sz":2,
"relayed_by":"127.0.0.1",
"out":
{
"spent":false,
"tx_index":103276852,
"type":0,
"addr":"12ytXWtNpxaEYW6ZvM564hVnsiFn4QnhAT",
"value":100000,
"n":0,
"script":"76a91415ba6e75f51b0071e33152e5d34c2f6bca7998e888ac"
}
和前一章一样,我们将以模块化的方式进行这项任务,通过迭代构建我们的脚本。除了处理序列化数据结构外,我们还将引入创建日志和将数据写入 CSV 文件的概念。像argparse一样,logging和csv模块将在我们的取证脚本中频繁出现。
我们的第一次迭代 – bitcoin_address_lookup.v1.py
我们脚本的第一次迭代将主要集中在适当地获取和处理数据。在这个脚本中,我们将把账户的交易摘要打印到控制台。在后续的迭代中,我们将添加日志记录并输出数据到 CSV 文件。此脚本已专门为 Python 3.7.1 编写和测试。urllib库(我们用来发起 HTTP 请求的库)在 Python 2 和 3 中结构不同。在此脚本的最终版本中,我们将展示必要的代码,使该脚本兼容 Python 2 和 3。
在脚本的初始版本中,我们将使用五个模块。argparse、json、urllib和sys模块都是标准库的一部分。unix_converter模块是我们在[第二章《Python 基础》中编写的几乎未修改的脚本,用于将 Unix 时间戳转换为比特币交易数据。本章提供的代码中包含了该模块的具体版本。argparse和urllib分别在之前用于用户输入和网页请求。json模块负责将我们的交易数据加载到 Python 对象中,以便我们进行处理:
001 """First iteration of the Bitcoin JSON transaction parser."""
002 import argparse
003 import json
004 import urllib.request
005 import unix_converter as unix
006 import sys
...
036 __authors__ = ["Chapin Bryce", "Preston Miller"]
037 __date__ = 20181027
038 __description__ = """This scripts downloads address transactions
039 using blockchain.info public APIs"""
我们脚本的逻辑由五个函数处理。main()函数定义在第 42 行,作为其他四个函数之间的协调者。首先,我们将用户提供的地址传递给get_address()函数。该函数负责使用urllib调用blockchain.info API,并返回包含该地址交易的 JSON 数据。
接下来,调用print_transactions()遍历嵌套的字典和列表,打印出交易详情。在print_transactions()中,调用了print_header()和get_inputs()。print_header()函数负责打印非交易数据,如交易数量、当前余额、总发送和接收值:
042 def main():
...
053 def get_address():
...
070 def print_transactions():
...
098 def print_header():
...
116 def get_inputs():
如前所见,我们使用argparse创建一个ArgumentParser对象,并添加相应的参数。我们唯一的参数ADDR是一个位置参数,代表感兴趣的比特币地址。我们在第 145 行调用main()函数并传递ADDR参数:
128 if __name__ == '__main__':
129 # Run this code if the script is run from the command line.
130 parser = argparse.ArgumentParser(
131 description=__description__,
132 epilog='Built by {}. Version {}'.format(
133 ", ".join(__authors__), __date__),
134 formatter_class=argparse.ArgumentDefaultsHelpFormatter
135 )
136 parser.add_argument('ADDR', help='Bitcoin Address')
137 args = parser.parse_args()
138
139 # Print Script Information
140 print('{:=²²}'.format(''))
141 print('{}'.format('Bitcoin Address Lookup'))
142 print('{:=²²} \n'.format(''))
143
144 # Run main program
145 main(args.ADDR)
我们脚本的流程图如下所示:
探索 main()函数
main()函数相对简单。首先,在第 48 行,我们调用get_address()函数并将结果存储在一个名为raw_account的变量中。该变量包含我们的 JSON 格式的交易数据。为了操作这些数据,我们使用json.loads()函数将 JSON 数据反序列化并存储在 account 变量中。此时,我们的 account 变量是一个字典和列表的系列,可以开始遍历,正如我们在第 50 行调用的print_transactions()函数所做的那样:
042 def main(address):
043 """
044 The main function handles coordinating logic
045 :param address: The Bitcoin Address to lookup
046 :return: Nothing
047 """
048 raw_account = get_address(address)
049 account = json.loads(raw_account.read())
050 print_transactions(account)
理解get_address()函数
这是我们脚本中的一个重要组成部分,尽管它可能容易出错,因为它依赖于用户正确提供数据。代码本身只是一个简单的数据请求。然而,在处理用户提供的参数时,我们不能假设用户给了脚本正确的数据。考虑到比特币地址的长度和看似随机的序列,用户可能会提供一个不正确的地址。我们将捕获来自urllib.error模块的任何URLError实例,以处理格式错误的输入。URLError不是我们之前提到的内置异常,而是urllib模块定义的自定义异常:
053 def get_address(address):
054 """
055 The get_address function uses the blockchain.info Data API
056 to pull pull down account information and transactions for
057 address of interest
058 :param address: The Bitcoin Address to lookup
059 :return: The response of the url request
060 """
在第 62 行,我们使用字符串format()方法将用户提供的地址插入到blockchain.info API 调用中。然后,我们尝试使用urllib.request.urlopen()函数返回请求的数据。如果用户提供了无效的地址,或者用户没有互联网连接,则会捕获到URLError。一旦捕获到错误,我们会通知用户并退出脚本,在第 67 行调用sys.exit(1):
061 url = 'https://blockchain.info/address/{}?format=json'
062 formatted_url = url.format(address)
063 try:
064 return urllib.request.urlopen(formatted_url)
065 except urllib.error.URLError:
066 print('Received URL Error for {}'.format(formatted_url))
067 sys.exit(1)
使用print_transactions()函数
这个函数处理我们代码中大部分的处理逻辑。这个函数遍历从加载的 JSON 数据中嵌入的字典组成的交易列表,或称txs列表。
对于每个交易,我们将打印出其相对交易编号、交易哈希和交易时间。哈希和时间键容易访问,因为它们的值存储在最外层的字典中。交易的输入和输出细节存储在一个内嵌字典中,该字典映射到输入和输出键。
如常见情况一样,时间值是以 Unix 时间存储的。幸运的是,在第二章,Python 基础知识中,我们编写了一个脚本来处理这种转换,再次我们将通过调用unix_converter()方法重用这个脚本。对这个函数做的唯一更改是移除了 UTC 标签,因为这些时间值存储的是本地时间。
因为我们将unix_converter模块导入为unix,所以我们必须使用unix来引用该模块。
让我们快速看一下我们正在处理的数据结构。想象一下,如果我们能够在执行过程中暂停代码并检查变量的内容,例如我们的账户变量。在本书的这一部分,我们将仅展示执行阶段account变量的内容。稍后在本书中,我们将更正式地讨论如何使用pdb模块进行 Python 调试。
关于 Python 调试器(pdb)的更多信息可以在文档中找到:docs.python.org/3/library/pdb.html。
在下面的示例中,我们可以看到account字典中第一个交易的键映射到txs列表中的内容。hash和time键分别映射到字符串和整数对象,我们可以将它们保留为脚本中的变量:
>>> print(account['txs'][0].keys())
dict_keys(['ver', 'inputs', 'weight', 'block_height', 'relayed_by', 'out', 'lock_time', 'result', 'size', 'time', 'tx_index', 'vin_sz', 'hash', 'vout_sz'])
接下来,我们需要访问交易的输入和输出细节。让我们查看out字典。通过查看键,我们可以立即识别出地址addr和作为重要信息的value。了解了布局和我们希望向用户呈现的数据后,让我们来看一下如何处理txs列表中的每一笔交易:
>>> print(account['txs'][0]['out'][0].keys())
dict_keys(['spent', 'tx_index', 'type', 'addr', 'value', 'n', 'script'])
在打印每个交易的详细信息之前,我们通过第 77 行的print_header()辅助函数调用并打印基本账户信息到控制台。在第 79 行,我们开始遍历txs列表中的每一笔交易。我们使用enumerate()函数来包装列表,以更新我们的计数器,并在for循环中的第一个变量i中跟踪我们正在处理的交易:
070 def print_transactions(account):
071 """
072 The print_transaction function is responsible for presenting
073 transaction details to end user.
074 :param account: The JSON decoded account and transaction data
075 :return:
076 """
077 print_header(account)
078 print('Transactions')
079 for i, tx in enumerate(account['txs']):
对于每个交易,我们打印相对的交易编号、hash和time。正如我们之前看到的,我们可以通过提供适当的键来访问hash或time。记住,我们确实需要转换存储在time键中的 Unix 时间戳。我们通过将该值传递给unix_converter()函数来完成这个任务:
080 print('Transaction #{}'.format(i))
081 print('Transaction Hash:', tx['hash'])
082 print('Transaction Date: {}'.format(
083 unix.unix_converter(tx['time'])))
在第 84 行,我们开始遍历外部字典中的输出列表。这个列表由多个字典组成,每个字典表示一个给定交易的输出。我们感兴趣的键是这些字典中的addr和value键:
084 for outputs in tx['out']:
请注意,value值(这不是错别字)以整数而非浮点数形式存储,因此一个 0.025 BTC 的交易会存储为 2,500,000。我们需要将此值乘以 10^(-8),以准确反映交易的价值。让我们在第 85 行调用我们的辅助函数get_inputs()。此函数将单独解析交易的输入,并以列表形式返回数据:
085 inputs = get_inputs(tx)
在第 86 行,我们检查是否有多个输入地址。这个条件将决定我们打印语句的样子。基本上,如果有多个输入地址,每个地址将通过与号连接,以清楚地表示额外的地址。
第 87 行和 91 行的print语句使用字符串格式化方法,在控制台中适当显示我们处理过的数据。在这些字符串中,我们使用花括号来表示三个不同的变量。我们使用join()函数通过某个分隔符将列表转换为字符串。第二和第三个变量分别是输出的addr和value键:
086 if len(inputs) > 1:
087 print('{} --> {} ({:.8f} BTC)'.format(
088 ' & '.join(inputs), output['addr'],
089 outputs['value'] * 10**-8))
090 else:
091 print('{} --> {} ({:.8f} BTC)'.format(
092 ''.join(inputs), outputs['addr'],
093 outputs['value'] * 10**-8))
094
095 print('{:=²²}\n'.format(''))
注意值对象的表示方式与其他不同。由于我们的值是浮动的,我们可以使用字符串格式化来正确显示数据的精确度。在格式说明符{:.8f}中,8表示我们希望允许的十进制位数。如果超过八位小数,则值会四舍五入到最接近的数字。f让format()方法知道输入的数据类型是浮动类型。这个函数虽然负责将结果打印给用户,但它使用了两个辅助函数来完成其任务。
print_header() 辅助函数
print_header() 辅助函数在打印交易信息之前,将账户信息打印到控制台。具体来说,会显示地址、交易数量、当前余额,以及发送和接收的比特币总量。请查看以下代码:
098 def print_header(account):
099 """
100 The print_header function prints overall header information
101 containing basic address information.
102 :param account: The JSON decoded account and transaction data
103 :return: Nothing
104 """
在第 105 到 113 行之间,我们使用字符串格式化方法打印出我们感兴趣的值。在程序设计过程中,我们选择将其作为一个独立函数,以提高代码的可读性。从功能上讲,这段代码本可以很容易地,且最初确实是在print_transactions()函数中实现。它被分离出来是为了将执行的不同阶段进行模块化。第 113 行的打印语句的目的是创建一行 22 个左对齐的等号,用来在控制台中将账户信息与交易信息分开:
105 print('Address:', account['address'])
106 print('Current Balance: {:.8f} BTC'.format(
107 account['final_balance'] * 10**-8))
108 print('Total Sent: {:.8f} BTC'.format(
109 account['total_sent'] * 10**-8))
110 print('Total Received: {:.8f} BTC'.format(
111 account['total_received'] * 10**-8))
112 print('Number of Transactions:', account['n_tx'])
113 print('{:=²²}\n'.format(''))
get_inputs() 辅助函数
这个辅助函数负责获取发送交易的地址信息。此信息位于多个嵌套字典中。由于可能有多个输入,我们必须遍历输入列表中的一个或多个元素。当我们找到输入地址时,会将其添加到一个在第 123 行实例化的输入列表中,如以下代码所示:
116 def get_inputs(tx):
117 """
118 The get_inputs function is a small helper function that returns
119 input addresses for a given transaction
120 :param tx: A single instance of a Bitcoin transaction
121 :return: inputs, a list of inputs
122 """
123 inputs = []
对于每个输入,都有一个字典键prev_out,它的值是另一个字典。我们需要的信息被映射到该内层字典中的addr键。我们将这些地址附加到输入列表中,并在for循环执行结束后在第 126 行返回该列表:
124 for input_addr in tx['inputs']:
125 inputs.append(input_addr['prev_out']['addr'])
126 return inputs
运行脚本
现在,让我们运行脚本,看看我们的劳动成果。在后面提到的输出中,我们可以看到首先是打印给用户的头部信息,接着是若干交易。数值对象已正确表示,并具有适当的精度。对于这个特定的例子,有四个输入值。使用' & '.join(inputs)语句可以更清晰地将不同的输入值分开:
随着概念验证的完成,我们现在可以遍历并解决当前版本中的一些固有问题。一个问题是我们没有记录任何关于脚本执行的数据。例如,审查员的笔记应包括时间、任何错误或问题以及取证过程的结果。在第二次迭代中,我们将使用日志模块解决这个问题。此模块将存储我们程序执行的日志,以便分析人员记录程序开始、停止的时间,以及与该过程相关的任何其他数据。
我们的第二次迭代 – bitcoin_address_lookup.v2.py
这次迭代通过记录执行的详细信息解决了脚本中的一个问题。实际上,我们使用日志来创建脚本的“证据链”。我们的证据链将告诉其他方我们的脚本在不同时间点做了什么,以及遇到的任何错误。我们提到过,传统的日志记录目的是用于调试吗?然而,我们经过取证的日志在任何情况下都适用。这将通过一个实际示例简要介绍日志模块的基本用法。有关更多示例和参考,请参见docs.python.org/3/library/logging.html文档。
我们已将两个模块添加到导入中:os和logging。如果用户提供了日志文件目录,我们将使用os模块附加该目录并更新日志的路径。为了写入日志,我们将使用logging模块。这两个模块都是标准库的一部分。请参阅以下代码:
001 """Second iteration of the Bitcoin JSON transaction parser."""
002 import argparse
003 import json
004 import logging
005 import sys
006 import os
007 import urllib.request
008 import unix_converter as unix
...
038 __authors__ = ["Chapin Bryce", "Preston Miller"]
039 __date__ = 20181027
040 __description__ = """This scripts downloads address transactions
041 using blockchain.info public APIs"""
由于新增的代码,我们的函数定义在脚本后面。然而,它们的流程和目的与之前相同:
044 def main():
...
059 def get_address():
...
081 def print_transactions():
...
116 def print_header():
...
134 def get_inputs():
我们在第 155 行添加了一个新的可选参数-l。此可选参数可用于指定要将日志写入的目录。如果未提供,则日志将在当前工作目录中创建:
146 if __name__ == '__main__':
147 # Run this code if the script is run from the command line.
148 parser = argparse.ArgumentParser(
149 description=__description__,
150 epilog='Built by {}. Version {}'.format(
151 ", ".join(__authors__), __date__),
152 formatter_class=argparse.ArgumentDefaultsHelpFormatter
153 )
154 parser.add_argument('ADDR', help='Bitcoin Address')
155 parser.add_argument('-l', help="""Specify log directory.
156 Defaults to current working directory.""")
157 args = parser.parse_args()
在第 159 行,我们检查用户是否提供了可选参数-l。如果提供了,我们使用os.path.join()函数将所需的日志文件名附加到提供的目录,并将其存储在名为log_path的变量中。如果没有提供可选参数,我们的log_path变量将只是日志文件的文件名:
159 # Set up Log
160 if args.l:
161 if not os.path.exists(args.l):
162 os.makedirs(args.l)
163 log_path = os.path.join(args.l, 'btc_addr_lookup.log')
164 else:
165 log_path = 'btc_addr_lookup.log'
日志对象是在第 165 行通过logging.basicConfig()方法创建的。该方法接受多种关键字参数。filename关键字参数指定了我们的日志文件的路径和名称,该路径存储在log_path变量中。level关键字设置日志的级别。日志有五个不同的级别,按从最低到最高的紧急程度顺序排列:
-
DEBUG -
INFO -
WARN(默认级别) -
ERROR -
CRITICAL
如果没有提供级别,日志默认使用WARN级别。日志的级别非常重要。只有当消息的级别与日志级别相同或更高时,日志才会记录该条目。通过将日志设置为DEBUG级别,即最低级别,我们可以将任何级别的消息写入日志:
165 logging.basicConfig(
166 filename=log_path, level=logging.DEBUG,
167 format='%(asctime)s | %(levelname)s | %(message)s',
168 filemode='w')
每个级别都有不同的意义,应该根据情况正确使用。DEBUG级别应在记录程序执行的技术细节时使用。INFO级别可用于记录程序的启动、停止以及各个执行阶段的成功情况。其余级别可用于检测潜在的异常执行、生成错误时,或在关键失败时使用。
format 关键字指定我们希望如何构建日志本身。我们的日志将采用以下格式:
time | level | message
例如,这种格式会创建一个日志文件,其中包含添加条目时的本地时间、适当的级别以及任何消息,所有信息之间用管道符号分隔。要在日志中创建条目,我们可以调用日志对象的debug()、info()、warn()、error()或critical()方法,并将消息作为字符串传入。例如,基于以下代码,我们预计会在日志中生成如下条目:
logging.error("Blah Blah function has generated an error from the following input: xyz123.")
以下是日志记录:
2015-11-06 19:51:47,446 | ERROR | Blah Blah function has generated an error from the following input: xyz123.
最后,filemode='w'参数用于在每次脚本执行时覆盖日志中的先前条目。这意味着日志中只会保存最近一次执行的条目。如果我们希望将每次执行周期追加到日志的末尾,可以省略此关键字参数。省略时,默认的文件模式为a,正如你在第一章《完全不同的东西》中学到的那样,它允许我们追加到现有文件的底部。
配置完成后,我们可以开始向日志中写入信息。在第 172 行和 173 行,我们记录了程序执行前用户系统的详细信息。由于内容的技术性较低,我们将此信息写入DEBUG级别的日志:
171 logging.info('Starting Bitcoin Lookup v. {}'.format(__date__))
172 logging.debug('System ' + sys.platform)
173 logging.debug('Version ' + sys.version.replace("\n", " "))
174
175 # Print Script Information
176 print('{:=²²}'.format(''))
177 print('{}'.format('Bitcoin Address Lookup'))
178 print('{:=²²} \n'.format(''))
179
180 # Run main program
181 main(args.ADDR)
我们的脚本版本基本相同,遵循与之前相同的流程示意图。
修改main()函数
在第 44 行定义的main()函数大体未做更改。我们在第 50 行和第 52 行添加了两个INFO级别的日志消息,关于脚本执行的情况。其余部分与第一次迭代中的方法一致:
044 def main(address):
045 """
046 The main function handles coordinating logic
047 :param address: The Bitcoin Address to lookup
048 :return: Nothing
049 """
050 logging.info('Initiated program for {} address'.format(
051 address))
052 logging.info(
053 'Obtaining JSON structured data from blockchain.info')
054 raw_account = get_address(address)
055 account = json.loads(raw_account.read())
056 print_transactions(account)
改进get_address()函数
使用get_address()方法时,我们继续向脚本中添加日志信息。这次,当捕获到URLError时,我们将Exception对象存储为e,以便提取更多调试信息:
059 def get_address(address):
060 """
061 The get_address function uses the blockchain.info Data API
062 to pull pull down account information and transactions for
063 address of interest
064 :param address: The Bitcoin Address to lookup
065 :return: The response of the url request
066 """
对于URLError,我们希望记录code、headers和reason属性。这些属性包含信息,例如 HTML 错误代码——例如,404表示页面未找到——以及错误代码的描述。我们将存储这些数据,以便保留错误发生时的上下文:
067 url = 'https://blockchain.info/address/{}?format=json'
068 formatted_url = url.format(address)
069 try:
070 return urllib.request.urlopen(formatted_url)
071 except urllib.error.URLError as e:
072 logging.error('URL Error for {}'.format(formatted_url))
073 if hasattr(e, 'code') and hasattr(e, 'headers'):
074 logging.debug('{}: {}'.format(e.code, e.reason))
075 logging.debug('{}'.format(e.headers))
076 print('Received URL Error for {}'.format(formatted_url))
077 logging.info('Program exiting...')
078 sys.exit(1)
详细说明 print_transactions()函数
我们在第 81 行定义了print_transaction()函数。我们对该函数进行了些许修改,从第 88 行开始,添加了一条记录当前执行阶段的日志。请看以下函数:
081 def print_transactions(account):
082 """
083 The print_transaction function is responsible for presenting
084 transaction details to end user.
085 :param account: The JSON decoded account and transaction data
086 :return: Nothing
087 """
088 logging.info(
089 'Printing account and transaction data to console.')
090 print_header(account)
091 print('Transactions')
092 for i, tx in enumerate(account['txs']):
093 print('Transaction #{}'.format(i))
094 print('Transaction Hash:', tx['hash'])
095 print('Transaction Date: {}'.format(
096 unix.unix_converter(tx['time'])))
对于第 99 行开始的条件语句,我们使用if、elif和else语句添加了不同的情况来处理输入值大于、等于或其他情况。虽然很少见,但例如第一个比特币交易就没有输入地址。当缺少输入地址时,理想的做法是记录日志,提示没有检测到输入,并将此信息打印给用户,具体如下:
097 for output in tx['out']:
098 inputs = get_inputs(tx)
099 if len(inputs) > 1:
100 print('{} --> {} ({:.8f} BTC)'.format(
101 ' & '.join(inputs), output['addr'],
102 output['value'] * 10**-8))
103 elif len(inputs) == 1:
104 print('{} --> {} ({:.8f} BTC)'.format(
105 ''.join(inputs), output['addr'],
106 output['value'] * 10**-8))
107 else:
108 logging.warn(
109 'Detected 0 inputs for transaction {}').format(
110 tx['hash'])
111 print('Detected 0 inputs for transaction.')
112
113 print('{:=²²}\n'.format(''))
运行脚本
剩余的函数print_header()和get_inputs()与之前的版本没有变化。不同版本之间的整个代码不需要修改。通过构建一个强大的输出模块,我们避免了对报告进行任何调整。
虽然结果仍然显示在控制台中,但我们现在有了程序执行的书面日志。通过指定-l选项运行脚本,可以将日志存储在指定目录中。否则,默认使用当前工作目录。以下是脚本完成后日志的内容:
完成日志记录后,我们又找到了代码可以改进的地方。对于这个特定地址,我们有一定数量的交易会打印到控制台。试想一下,如果一个地址有成百上千的交易,浏览这些输出并找到一个特定的感兴趣交易就不那么简单了。
掌握我们的最终版本 —— bitcoin_address_lookup.py
在最终版本中,我们将把脚本的输出写入 CSV 文件,而不是打印到控制台。这使得检查人员能够快速筛选和排序数据,便于分析。
在第 4 行,我们导入了标准库中的csv模块。与其他输出格式相比,写入 CSV 文件相对简单,而且大多数检查人员非常熟悉操作电子表格。
正如本章前面提到的,在脚本的最终版本中,我们添加了必要的逻辑来检测是使用 Python 2 还是 Python 3 来调用脚本。根据 Python 版本,适当的 urllib 或 urllib2 函数会被导入到脚本中。请注意,我们直接导入了我们计划在脚本中直接调用的函数 urlopen() 和 URLError。这样,我们可以避免后续使用额外的条件语句来判断是否调用 urllib 或 urllib2:
001 """Final iteration of the Bitcoin JSON transaction parser."""
002 from __future__ import print_function
003 import argparse
004 import csv
005 import json
006 import logging
007 import sys
008 import os
009 if sys.version_info[0] == 2:
010 from urllib2 import urlopen
011 from urllib2 import URLError
012 elif sys.version_info[0] == 3:
013 from urllib.request import urlopen
014 from urllib.error import URLError
015 else:
016 print("Unsupported Python version. Exiting..")
017 sys.exit(1)
018 import unix_converter as unix
...
048 __authors__ = ["Chapin Bryce", "Preston Miller"]
049 __date__ = 20181027
050 __description__ = """This scripts downloads address transactions
051 using blockchain.info public APIs"""
本次迭代的重点是新增的 csv_writer() 函数。该函数负责将 parse_transactions() 返回的数据写入 CSV 文件。我们需要修改当前版本的 print_transactions(),使其返回解析后的数据,而不是将其打印到控制台。虽然这不是 csv 模块的深入教程,但我们将讨论在当前上下文中使用该模块的基本内容。在本书的后续章节中,我们将广泛使用 csv 模块,并探索其附加功能。有关 csv 模块的文档可以在docs.python.org/3/library/csv.html找到。
首先,我们打开一个交互式提示符来练习创建和写入 CSV 文件。首先,让我们导入 csv 模块,它将允许我们创建 CSV 文件。接下来,我们创建一个名为 headers 的列表,用于存储 CSV 文件的列头:
>>> import csv
>>> headers = ['Date', 'Name', 'Description']
接下来,我们将使用内置的open()方法以适当的文件模式打开一个文件对象。在 Python 2 中,CSV 文件对象应以 rb 或 wb 模式分别用于读取和写入。在这种情况下,我们将写入 CSV 文件,因此我们将以 wb 模式打开文件。w 代表写入,b 代表二进制模式。
在 Python 3 中,CSV 文件应以 w 模式打开,并指定换行符,如下所示:open('test.csv', 'w', newline='')。
通过我们与文件对象 csvfile 的连接,我们现在需要创建一个写入器或读取器(取决于我们的目标),并将文件对象传入。有两个选项——csv.writer() 或 csv.reader() 方法;它们都需要文件对象作为输入,并接受各种关键字参数。列表对象与 csv 模块非常契合,写入数据到 CSV 文件的代码量非常少。写入字典和其他对象到 CSV 文件并不困难,但超出了本章的范围,将在后续章节中讲解:
>>> with open('test.csv', 'wb') as csvfile:
... writer = csv.writer(csvfile)
writer.writerow() 方法将使用提供的列表写入一行。列表中的每个元素将依次放置在同一行的不同列中。例如,如果再次调用 writerow() 函数并传入另一个列表输入,那么数据将写入到上一行下面:
... writer.writerow(headers)
在实际操作中,我们发现使用嵌套列表是遍历和写入每一行最简单的方法之一。在最后一次迭代中,我们将交易详细信息存储在一个列表中,并将其追加到另一个列表中。然后,我们可以在遍历每个交易时,将其详细信息逐行写入 CSV 文件。
与任何文件对象一样,务必将缓冲区中的数据刷新到文件中,然后再关闭文件。忘记这些步骤并不会导致灾难,因为 Python 通常会自动处理,但我们强烈建议执行这些步骤。执行完这些代码行后,一个名为test.csv的文件将在你的工作目录中创建,文件的第一行将包含Date、Name和Description三个标题。相同的代码也适用于 Python 3 中的csv模块,唯一需要修改的是之前展示的open()函数:
... csvfile.flush()
... csvfile.close()
我们将print_transactions()函数重命名为parse_transactions(),以更准确地反映其功能。此外,在第 159 行,我们添加了一个csv_writer()函数,用于将交易结果写入 CSV 文件。其他所有函数与之前的版本类似:
053 def main():
...
070 def get_address():
...
091 def parse_transactions():
...
123 def print_header():
...
142 def get_inputs():
...
159 def csv_writer():
最后,我们添加了一个名为OUTPUT的新位置参数。这个参数表示 CSV 输出的名称和/或路径。在第 230 行,我们将这个输出参数传递给main()函数:
195 if __name__ == '__main__':
196 # Run this code if the script is run from the command line.
197 parser = argparse.ArgumentParser(
198 description=__description__,
199 epilog='Built by {}. Version {}'.format(
200 ", ".join(__authors__), __date__),
201 formatter_class=argparse.ArgumentDefaultsHelpFormatter
202 )
203
204 parser.add_argument('ADDR', help='Bitcoin Address')
205 parser.add_argument('OUTPUT', help='Output CSV file')
206 parser.add_argument('-l', help="""Specify log directory.
207 Defaults to current working directory.""")
208
209 args = parser.parse_args()
210
211 # Set up Log
212 if args.l:
213 if not os.path.exists(args.l):
214 os.makedirs(args.l) # create log directory path
215 log_path = os.path.join(args.l, 'btc_addr_lookup.log')
216 else:
217 log_path = 'btc_addr_lookup.log'
218 logging.basicConfig(
219 filename=log_path, level=logging.DEBUG,
220 format='%(asctime)s | %(levelname)s | %(message)s',
221 filemode='w')
222
223 logging.info('Starting Bitcoid Lookup v. {}'.format(__date__))
224 logging.debug('System ' + sys.platform)
225 logging.debug('Version ' + sys.version.replace("\n", " "))
226
227 # Print Script Information
228 print('{:=²²}'.format(''))
229 print('{}'.format('Bitcoin Address Lookup'))
230 print('{:=²²} \n'.format(''))
231
232 # Run main program
233 main(args.ADDR, args.OUTPUT)
以下流程图展示了前两次迭代与最终版本之间的区别:
增强parse_transactions()函数
这个之前名为print_transactions()的函数用于处理交易数据,以便能被我们的csv_writer()函数接收。请注意,print_header()函数的调用现在已被移到main()函数中。我们现在也将输出参数传递给parse_transactions():
091 def parse_transactions(account, output_dir):
092 """
093 The parse_transactions function appends transaction data into a
094 nested list structure so it can be successfully used by the
095 csv_writer function.
096 :param account: The JSON decoded account and transaction data
097 :param output_dir: The output directory to write the CSV
098 results
099 :return: Nothing
100 """
正如我们之前所见,我们必须首先遍历transactions列表。在遍历数据时,我们将其追加到一个在第 104 行创建的交易列表中。该列表表示一个给定的交易及其数据。完成追加交易数据后,我们将此列表追加到作为所有交易容器的transactions列表中:
101 msg = 'Parsing transactions...'
102 logging.info(msg)
103 print(msg)
104 transactions = []
105 for i, tx in enumerate(account['txs']):
106 transaction = []
为了将输出地址与其值匹配,我们在第 107 行创建了一个outputs字典。在第 114 行,我们创建了一个表示地址和其接收值的键。请注意,在第 115 行至第 117 行时,我们使用了换行符\n,将多个输出地址及其值合并在一起,以便它们在一个单元格内视觉上分开。我们在get_inputs()函数中也进行了相同的操作,以处理多个输入值。这是我们做出的设计选择,因为我们发现可能存在多个输出地址。与其将这些地址放在各自的列中,我们选择将它们全部放在一列中:
107 outputs = {}
108 inputs = get_inputs(tx)
109 transaction.append(i)
110 transaction.append(unix.unix_converter(tx['time']))
111 transaction.append(tx['hash'])
112 transaction.append(inputs)
113 for output in tx['out']:
114 outputs[output['addr']] = output['value'] * 10**-8
115 transaction.append('\n'.join(outputs.keys()))
116 transaction.append(
117 '\n'.join(str(v) for v in outputs.values()))
在第 118 行,我们使用内置的sum()函数创建了一个新值,将输出值加起来。sum()函数非常方便,接受一个int或float类型的列表作为输入并返回总和:
118 transaction.append('{:.8f}'.format(sum(outputs.values())))
现在,我们在transaction列表中有了所有期望的交易详细信息。我们将交易添加到第 119 行的transactions列表中。当所有交易都添加到transactions列表后,我们调用csv_writer()方法,并传入transactions列表和output目录:
119 transactions.append(transaction)
120 csv_writer(transactions, output_dir)
再次提醒,我们没有对print_header()或get_address()函数做任何修改。
开发csv_writer()函数
在第 159 行,我们定义了csv_writer()函数。在将交易数据写入 CSV 文件之前,我们记录当前的执行阶段并创建一个headers变量。这个headers列表代表了电子表格中的列,将是写入文件的第一行,如下所示:
159 def csv_writer(data, output_dir):
160 """
161 The csv_writer function writes transaction data into a CSV file
162 :param data: The parsed transaction data in nested list
163 :param output_dir: The output directory to write the CSV
164 results
165 :return: Nothing
166 """
167 logging.info('Writing output to {}'.format(output_dir))
168 print('Writing output.')
169 headers = ['Index', 'Date', 'Transaction Hash',
170 'Inputs', 'Outputs', 'Values', 'Total']
与任何用户提供的数据一样,我们必须考虑到提供的数据可能不正确或会引发异常。例如,用户可能会在输出路径参数中指定一个不存在的目录。在第 173 行和第 175 行,我们根据所使用的 Python 版本以适当的方式打开csvfile,并在一个try和except语句块下写入我们的 CSV 数据。如果用户提供的输出存在问题,我们将收到IOError异常。
我们在第 177 行创建了写入对象,并在迭代交易列表之前写入headers。交易列表中的每个交易都会单独写入一行。最后,在第 181 行和第 182 行,我们刷新并关闭了 CSV 文件:
171 try:
172 if sys.version_info[0] == 2:
173 csvfile = open(output_dir, 'wb')
174 else:
175 csvfile = open(output_dir, 'w', newline='')
176 with csvfile:
177 writer = csv.writer(csvfile)
178 writer.writerow(headers)
179 for transaction in data:
180 writer.writerow(transaction)
181 csvfile.flush()
182 csvfile.close()
如果生成了IOError,我们会将错误消息和相关信息写入日志,然后以错误(任何非零退出)退出。如果没有错误,我们会记录脚本完成的情况,并在没有错误的情况下退出(也称为零退出),如第 191 至 193 行所示:
183 except IOError as e:
184 logging.error("""Error writing output to {}.
185 \nGenerated message: {}.""".format(e.filename,
186 e.strerror))
187 print("""Error writing to CSV file.
188 Please check output argument {}""".format(e.filename))
189 logging.info('Program exiting.')
190 sys.exit(1)
191 logging.info('Program exiting.')
192 print('Program exiting.')
193 sys.exit(0)
运行脚本
这一版本最终解决了我们识别出的剩余问题,即如何将数据处理到一个准备好供检查的状态。现在,如果一个地址有数百或数千个交易,检查员可以比在控制台中显示时更高效地分析这些数据。
话虽如此,和大多数事情一样,总有改进的空间。例如,我们处理多个输入和输出值的方式意味着一个特定的单元格中会有多个地址。这在试图筛选特定地址时可能会很麻烦。这里的要点是,脚本从来不会真正完成开发,它总是一个不断发展的过程。
要运行脚本,我们现在必须提供两个参数:比特币地址和期望的输出。以下是运行脚本时使用的示例及输出结果:
transactions.csv 文件将按照指定的路径写入当前工作目录。以下截图展示了该电子表格可能的样子:
挑战
对于额外的挑战,修改脚本,使得每个输出和输入地址都有自己独立的单元格。我们建议通过确定交易列表中输入值或输出地址的最大数量来解决这一问题。了解这些值后,你可以构建一个条件语句来修改表头,使其具有适当的列数。此外,当没有多个输入或输出值时,你还需要编写逻辑跳过这些列,以确保数据的正确间距。
尽管这些例子特定于比特币,但在实际情况中,当两个或多个数据点之间存在动态关系时,可能需要类似的逻辑。解决这一挑战将帮助我们培养一种逻辑且实用的方法论,这种方法可以应用于未来的场景。
总结
在这一章中,我们更加熟悉了常见的序列化结构、比特币和 CSV 文件格式,并学习了如何处理嵌套列表和字典。能够操作列表和字典是一个重要的技能,因为数据通常存储在混合的嵌套结构中。记住,始终使用type()方法来确定你正在处理的数据类型。
对于这个脚本,我们(作者)在编写脚本之前,先在 Python 交互式提示符中尝试了 JSON 数据结构。这让我们在编写任何逻辑之前,能够正确理解如何遍历数据结构以及最佳的实现方式。Python 交互式提示符是一个非常好的沙盒,用于实现新特性或测试新代码。该项目的代码可以从 GitHub 或 Packt 下载,具体信息请参见前言。
在下一章中,我们将讨论存储结构化数据的另一种方法。在学习如何将数据库集成到我们的脚本中时,我们将创建一个活动文件列表脚本,该脚本将所有数据以 SQLite3 格式存储。这样做将帮助我们更熟悉使用两种不同模块从数据库中存储和检索数据。
第五章:Python 中的数据库
在本章中,我们将在脚本中利用数据库,以便在处理大量数据时能够完成有意义的任务。通过一个简单的例子,我们将展示在 Python 脚本中使用数据库后端的功能和优势。我们将把从给定根目录递归索引的文件元数据存储到数据库中,然后查询该数据以生成报告。虽然这看起来是一个简单的任务,但本章的目的是展示我们如何通过创建一个活跃的文件列表与数据库进行交互。
在本章中,我们将深入探讨以下主题:
-
SQLite3 数据库的基本设计与实现
-
使用 Python 的内置模块和第三方模块处理这些数据库
-
理解如何在 Python 中递归遍历目录
-
理解文件系统元数据以及使用 Python 访问它的方法
-
为了方便最终用户的审阅,制作 CSV 和 HTML 格式的报告
本章的代码是在 Python 2.7.15 和 Python 3.7.1 环境下开发和测试的。file_lister.py 脚本是为了支持 Python 3.7.1 开发的。file_lister_peewee.py 脚本则在 Python 2.7.15 和 Python 3.7.1 中都进行了开发和测试。
数据库概述
数据库提供了一种高效的方式,以结构化的方式存储大量数据。数据库有许多种类型,通常分为两大类:SQL 或 NoSQL。SQL(即 结构化查询语言)旨在作为一种简单的语言,使用户能够操作存储在数据库中的大型数据集。这包括常见的数据库,如 MySQL、SQLite 和 PostgreSQL。NoSQL 数据库也非常有用,通常使用 JSON 或 XML 来存储具有不同结构的数据,这两者在上一章中作为常见的序列化数据类型进行了讨论。
使用 SQLite3
SQLite3 是 SQLite 的最新版本,是应用开发中最常见的数据库之一。与其他数据库不同,它被存储为一个单一的文件,不需要运行或安装服务器实例。因此,它因其可移植性而广泛应用于移动设备、桌面应用程序和 Web 服务中。SQLite3 使用稍微修改过的 SQL 语法,虽然 SQL 有许多变种,它仍然是其中实现较为简单的一种。自然,这种轻量级数据库也有一些限制。包括一次只能有一个写操作连接到数据库、存储限制为 140 TB,并且它不是基于客户端-服务器模式的。由于我们的应用不会同时执行多个写操作、使用的存储小于 140 TB,且不需要客户端-服务器的分布式配置,因此我们将在本章中使用 SQLite 来进行示例。
使用 SQL
在开发我们的代码之前,让我们先看看我们将使用的基本 SQL 语句。这将帮助我们了解如何即使不使用 Python 也能与数据库互动。在 SQL 中,命令通常使用大写字母书写,尽管它们对大小写不敏感。为了提高可读性,本练习中我们将使用大写字母。所有 SQL 语句必须以分号结尾才能执行,因为分号表示语句的结束。
如果你想一起操作,可以安装一个 SQLite 管理工具,比如命令行工具 sqlite3。这个工具可以从 www.sqlite.org/download.html 下载。本节展示的输出是通过 sqlite3 命令行工具生成的,尽管给出的语句在大多数其他 sqlite3 图形应用程序中也会生成相同的数据库。如果有疑问,使用官方的 sqlite3 命令行工具。
首先,我们将创建一个表,这是任何数据库的基本组成部分。如果我们将数据库比作 Excel 工作簿,那么表就相当于工作表。表包含命名的列,以及与这些列相对应的数据行。就像 Excel 工作簿可以包含多个工作表一样,数据库也可以包含多个表。要创建一个表,我们将使用 CREATE TABLE 命令,指定表名,然后在括号中列出列名及其数据类型,并以逗号分隔。最后,我们用分号结束 SQL 语句:
>>> CREATE TABLE custodians (id INTEGER PRIMARY KEY, name TEXT);
如我们在 CREATE TABLE 语句中所见,我们在 custodians 表中指定了 id 和 name 列。id 字段是整数且为主键。在 SQLite3 中使用 INTEGER PRIMARY KEY 的指定将自动创建一个索引,该索引会对每个添加的行按顺序递增,从而创建唯一的行标识符索引。name 列的数据类型为 TEXT,允许任何字符作为文本字符串存储。SQLite 支持五种数据类型,其中两种我们已经介绍过:
-
INTEGER -
TEXT -
REAL -
BLOB -
NULL
REAL 数据类型允许浮动小数(例如小数)。BLOB(二进制大对象的缩写)数据类型保持任何输入数据的原样,不会将其转换为特定类型。NULL 数据类型只是存储一个空值。
在创建表格后,我们可以开始向其中添加数据。如以下代码块所示,我们可以使用 INSERT INTO 命令将数据插入到表中。此命令后的语法指定了表名、要插入数据的列,然后是 VALUES 命令,指定要插入的值。列和数据必须用括号括起来,如下面的代码所示。使用 null 语句作为值时,SQLite 的自动增量功能会介入,并用下一个可用的唯一整数填充此值。记住,只有当我们指定该列为 INTEGER PRIMARY KEY 时,这种自动增量才会生效。作为一般规则,表格中只能有一个列被指定为此类型:
>>> INSERT INTO custodians (id, name) VALUES (null, 'Chell');
>>> INSERT INTO custodians (id, name) VALUES (null, 'GLaDOS');
我们已经插入了两个监护人,Chell 和 GLaDOS,并让 SQLite 为它们分配了 ID。数据插入后,我们可以使用 SELECT 命令选择并查看这些信息。基本语法是调用 SELECT 命令,后面跟着要选择的列(或者用星号 * 来表示选择所有列)以及 FROM 语句,后面跟着表名,最后是一个分号。正如我们在下面的代码中看到的,SELECT 将打印出以管道符(|)分隔的存储值列表:
>>> SELECT * FROM custodians;
1|Chell
2|GLaDOS
除了仅显示我们表格中所需的列外,我们还可以基于一个或多个条件来筛选数据。WHERE 语句允许我们筛选结果并仅返回符合条件的项。为了本章脚本的目的,我们将坚持使用简单的 where 语句,并仅使用等号运算符返回符合条件的值。执行时,SELECT-WHERE 语句仅返回 id 值为 1 的监护人信息。此外,注意列的顺序反映了它们被指定的顺序:
>>> SELECT name,id FROM custodians WHERE id = 1;
Chell|1
虽然还有更多操作和语句可以与 SQLite3 数据库进行交互,但前面的操作已经涵盖了我们脚本所需的所有内容。我们邀请你在 SQLite3 文档中探索更多操作,文档可以在 sqlite.org 找到。
设计我们的脚本
我们脚本的第一次迭代专注于以更手动的方式使用标准模块 sqlite3 执行当前任务。这意味着我们需要写出每个 SQL 语句并执行它们,就像你直接与数据库打交道一样。虽然这不是一种非常 Pythonic 的数据库处理方式,但它展示了与 Python 一起操作数据库时所使用的方法。我们的第二次迭代使用了两个第三方库:peewee 和 jinja2。
Peewee 是一个对象关系映射器(ORM),这是一个用于描述使用对象处理数据库操作的软件套件的术语。简而言之,这个 ORM 允许开发者在 Python 中调用函数并定义类,这些函数和类会被解释为数据库命令。这个抽象层帮助标准化数据库调用,并且允许轻松地更换多个数据库后端。Peewee 是一个轻量级的 ORM,因为它只是一个支持 PostgreSQL、MySQL 和 SQLite3 数据库连接的 Python 文件。如果我们需要将第二个脚本从 SQLite3 切换到 PostgreSQL,只需要修改几行代码;而第一个脚本则需要更多的注意来处理这个转换。也就是说,我们的第一个版本除了标准的 Python 安装外,不需要任何额外的依赖项来支持 SQLite3,这对于设计为在现场使用的便携且灵活的工具来说是一个非常有吸引力的特点。
我们的 file_lister.py 脚本是一个按监护人收集元数据和生成报告的脚本。这在事件响应或调查的发现阶段非常重要,因为它存储有关系统或指定目录中由监护人名称标识的活动文件的信息。监护人分配系统允许通过单个监护人名称索引和分类多个机器、目录路径或网络共享,无论该监护人是用户、机器还是设备。为了实现这个系统,我们需要提示用户输入监护人名称、要使用的数据库路径以及输入或输出的信息。
通过允许检查员将多个监护人或路径添加到同一个数据库中,他们可以将找到的文件添加到单个监护人下,或者随意添加多个监护人。这对于收藏来说非常有用,因为调查人员可以根据需要保留任意数量的路径,我们都知道一旦进入现场,不可预见的设备就会出现。此外,我们可以使用相同的脚本来创建文件列表报告,无论收集的文件或监护人数量如何,只要监护人至少有一个收集的文件。
在我们的设计阶段,我们不仅考虑脚本,还考虑将要使用的数据库和关系模型。在我们的案例中,我们处理两个独立的项目:监护人和文件。它们都是很好的表格,因为它们是独立的条目,并且共享一个共同的关系。在我们的场景中,文件有一个监护人,而监护人可能有一个或多个文件;因此,我们希望创建一个外键,将文件与特定的监护人关联。外键是指向另一个表中主键的引用。主键和外键引用通常是一个唯一值或索引,用于将数据连接在一起。
以下图表示了我们数据库的关系模型。我们有两个表:custodians(管理员)和 files(文件),它们之间存在一对多关系。如前所述,这种一对多关系将允许我们将多个文件分配给单个管理员。通过这种关系,我们可以确保脚本以结构化且易于管理的方式正确地分配信息:
在这个关系模型中,例如,我们可能有一个名为 JPriest 的管理员,他拥有位于APB/文件夹中的文件。在这个根文件夹下,有 40,000 个文件分布在 300 个子目录中,我们需要将这 40,000 个文件都分配给 JPriest。由于管理员的名字可能很长或很复杂,我们希望为 JPriest 分配一个标识符,如整数 5,并将其写入存储在Files表中的每一行数据中。这样,我们实现了三件事:
-
我们节省了空间,因为我们在每一行的 40,000 个数据行中只存储了一个字符(
5),而不是七个字符(JPriest) -
我们维护了 JPriest 用户与其文件之间的关联
-
如果我们以后需要重命名 JPriest,我们只需更改
Custodians表中的一行,从而更新所有关联行中的管理员名称
使用 Python 手动操作数据库 – file_lister.py
作为说明,脚本将仅在 Python 3 中工作,并且已经在 Python 3.7.1 版本中进行了测试。如果您在完成本节后希望查看 Python 2 版本的代码,请访问github.com/PacktPublishing/Learning-Python-for-Forensics以查看之前的版本。
在脚本的第一次迭代中,我们使用了几个标准库来完成整个操作所需的所有功能。像之前的脚本一样,我们实现了argparse、csv和logging,用于各自的常规功能,包括参数处理、编写 CSV 报告和记录程序执行。对于日志记录,我们在第 43 行定义了我们的日志处理器logger。我们导入了sqlite3模块来处理所有数据库操作。与我们下一次迭代不同,这个脚本只支持 SQLite 数据库。os模块使我们能够递归地遍历目录及其子目录中的文件。最后,sys模块允许我们收集有关系统的日志信息,datetime模块用于格式化系统中遇到的时间戳。这个脚本不需要任何第三方库。我们有以下代码:
001 """File metadata capture and reporting utility."""
002 import argparse
003 import csv
004 import datetime
005 import logging
006 import os
007 import sqlite3
008 import sys
...
038 __authors__ = ["Chapin Bryce", "Preston Miller"]
039 __date__ = 20181027
040 __description__ = '''This script uses a database to ingest and
041 report meta data information about active entries in
042 directories.'''
043 logger = logging.getLogger(__name__)
在导入语句之后,我们有 main() 函数,它接受以下用户输入:保管人名称、目标输入目录或输出文件,以及要使用的数据库路径。main() 函数处理一些高层操作,如添加和管理保管人、错误处理和日志记录。它首先初始化数据库和表格,然后检查保管人是否在数据库中。如果不在,系统会将该保管人添加到数据库中。该函数允许我们处理两种可能的运行选项:递归地导入基础目录,捕获所有子对象及其元数据,或从数据库中读取捕获的信息并使用我们的写入函数生成报告。
init_db() 函数由 main() 调用,若数据库及默认表格不存在,它将创建这些表格。get_or_add_custodian() 函数以类似的方式检查保管人是否存在。如果存在,它将返回保管人的 ID,否则它会创建保管人表格。为了确保保管人存在于数据库中,在添加新条目后,get_or_add_custodian() 函数会再次运行。
在数据库创建并且保管人表格存在之后,代码会检查源是否为输入目录。如果是,它会调用 ingest_directory() 函数,遍历指定的目录并扫描所有子目录,以收集与文件相关的元数据。捕获到的元数据将存储在数据库的 Files 表中,并通过外键与 Custodians 表关联,从而将每个保管人与其文件绑定。在收集元数据的过程中,我们会调用 format_timestamp() 函数,将收集到的时间戳转换为标准的字符串格式。
如果源是输出文件,则会调用 write_output() 函数,传入打开的数据库游标、输出文件路径和保管人名称作为参数。脚本接着会检查保管人是否在 Files 表中有任何相关结果,并根据输出文件路径的扩展名将其传递给 write_html() 或 write_csv() 函数。如果扩展名为 .html,则调用 write_html() 函数,使用 Bootstrap CSS 创建一个 HTML 表格,显示该保管人的所有响应结果。否则,如果扩展名为 .csv,则调用 write_csv() 函数,将数据写入以逗号分隔的文件。如果输出文件路径中没有提供这两种扩展名,则不会生成报告,并且会抛出错误,提示无法解析文件类型:
046 def main(custodian, target, db):
...
081 def init_db(db_path):
...
111 def get_or_add_custodian(conn, custodian):
...
132 def get_custodian(conn, custodian):
...
148 def ingest_directory(conn, target, custodian_id):
...
207 def format_timestamp(timestamp):
...
219 def write_output(conn, target, custodian):
...
254 def write_csv(conn, target, custodian_id):
...
280 def write_html(conn, target, custodian_id, custodian_name):
现在,让我们来看看这个脚本所需的参数和设置。在第 321 行到第 339 行之间,我们构建了 argparse 命令行接口,其中包括必需的位置参数 CUSTODIAN 和 DB_PATH,以及可选的参数 --input、--output 和 -l:
320 if __name__ == '__main__':
321 parser = argparse.ArgumentParser(
322 description=__description__,
323 epilog='Built by {}. Version {}'.format(
324 ", ".join(__authors__), __date__),
325 formatter_class=argparse.ArgumentDefaultsHelpFormatter
326 )
327 parser.add_argument(
328 'CUSTODIAN', help='Name of custodian collection is of.')
329 parser.add_argument(
330 'DB_PATH', help='File path and name of database to '
331 'create or append metadata to.')
332 parser.add_argument(
333 '--input', help='Base directory to scan.')
334 parser.add_argument(
335 '--output', help='Output file to write to. use `.csv` '
336 'extension for CSV and `.html` for HTML')
337 parser.add_argument(
338 '-l', help='File path and name of log file.')
339 args = parser.parse_args()
在第 341 到 347 行,我们检查用户是否提供了--input或--output参数。我们创建了一个变量arg_source,它是一个元组,包含操作模式和由参数指定的相应路径。如果两个模式参数都没有提供,则会引发ArgumentError并提示用户提供输入或输出。这确保了当存在一个或多个选项时,用户提供了所需的参数:
341 if args.input:
342 arg_source = ('input', args.input)
343 elif args.output:
344 arg_source = ('output', args.output)
345 else:
346 raise argparse.ArgumentError(
347 'Please specify input or output')
在第 349 到 368 行,我们可以看到我们在前几章中使用的日志配置,并检查-l参数,根据需要创建日志路径。我们还在第 366 到 368 行记录了脚本版本和操作系统信息:
349 if args.l:
350 if not os.path.exists(args.l):
351 os.makedirs(args.l) # create log directory path
352 log_path = os.path.join(args.l, 'file_lister.log')
353 else:
354 log_path = 'file_lister.log'
355
356 logger.setLevel(logging.DEBUG)
357 msg_fmt = logging.Formatter("%(asctime)-15s %(funcName)-20s"
358 "%(levelname)-8s %(message)s")
359 strhndl = logging.StreamHandler(sys.stdout)
360 strhndl.setFormatter(fmt=msg_fmt)
361 fhndl = logging.FileHandler(log_path, mode='a')
362 fhndl.setFormatter(fmt=msg_fmt)
363 logger.addHandler(strhndl)
364 logger.addHandler(fhndl)
365
366 logger.info('Starting File Lister v.' + str(__date__))
367 logger.debug('System ' + sys.platform)
368 logger.debug('Version ' + sys.version)
完成日志配置后,我们可以创建一个字典,定义通过 kwargs 传递给main()函数的参数。Kwargs(关键字参数)提供了一种以字典键值对的形式传递参数的方法,其中键与参数名称匹配,并赋予相应的值。为了将字典作为 kwargs 而不是单一值传递给函数或类,我们必须在字典名称前加上两个星号,如第 373 行所示。如果没有使用 kwargs,我们就必须将args.custodian、arg_source和args.db_path参数作为单独的位置参数传递。kwargs 具有更高级的功能,相关示例可以在docs.python.org/3.7/faq/programming.html找到。我们有以下代码:
370 args_dict = {'custodian': args.CUSTODIAN,
371 'target': arg_source, 'db': args.DB_PATH}
372
373 main(**args_dict)
请参考以下流程图,了解每个功能是如何相互连接的:
构建main()函数
main()函数分为两个阶段:数据库初始化和输入/输出(I/O)处理。数据库初始化(包括文档字符串)发生在第 46 到 57 行,在这部分我们定义并记录了函数的输入。请注意,输入变量与作为关键字参数传递给函数的args_dict的键匹配。如果args_dict没有定义这些确切的键,我们在调用函数时会收到TypeError。参见以下代码:
046 def main(custodian, target, db):
047 """
048 The main function creates the database or table, logs
049 execution status, and handles errors
050 :param custodian: The name of the custodian
051 :param target: tuple containing the mode 'input' or 'output'
052 as the first elemnet and a file path as the second
053 :param db: The filepath for the database
054 :return: None
055 """
在第 57 行,我们调用init_db()函数,传递数据库的路径,并将返回的数据库连接赋值给conn变量。数据库连接对象由sqlite3 Python 库处理。我们使用这个对象与数据库进行通信,将所有的 Python 调用转化为 SQL 语句。通过连接对象,我们可以调用游标对象。游标是用于通过连接发送和接收数据的对象;我们将在需要与数据库交互的函数中定义它,因为我们希望将游标的作用范围限制,而可以在不同函数间共享数据库连接:
056 logger.info('Initiating SQLite database: ' + db)
057 conn = init_db(db)
在额外的日志记录后,我们调用get_or_add_custodian(),将连接对象和托管人名称传递给该函数。通过传递打开的连接,我们允许该函数与数据库交互并定义自己的游标。如果找到custodian_id,我们继续执行并跳过第 61 行的while循环;否则,我们重新运行get_or_add_custodian()函数,直到我们添加托管人并获取托管人 ID:
058 logger.info('Initialization Successful')
059 logger.info('Retrieving or adding custodian: ' + custodian)
060 custodian_id = get_or_add_custodian(conn, custodian)
061 while not custodian_id:
062 custodian_id = get_or_add_custodian(conn, custodian)
063 logger.info('Custodian Retrieved')
一旦我们有了一个托管人 ID 需要处理,我们需要确定源是指定为输入还是输出。如果在第 64 行源是input,则我们运行ingest_directory()函数,该函数遍历提供的根目录并收集有关任何子文件的相关元数据。完成后,我们将更改提交(保存)到数据库并记录完成情况:
064 if target[0] == 'input':
065 logger.info('Ingesting base input directory: {}'.format(
066 target[1]))
067 ingest_directory(conn, target[1], custodian_id)
068 conn.commit()
069 logger.info('Ingest Complete')
如果源是output,则调用write_output()函数来处理以指定格式写入输出。如果无法确定源类型,我们将引发argparse.ArgumentError错误,声明无法解释参数。运行所需模式后,我们通过关闭数据库连接并记录脚本完成情况来结束函数,如下所示:
070 elif target[0] == 'output':
071 logger.info('Preparing to write output: ' + target[1])
072 write_output(conn, target[1], custodian)
073 else:
074 raise argparse.ArgumentError(
075 'Could not interpret run time arguments')
076
077 conn.close()
078 logger.info('Script Completed')
使用 init_db()函数初始化数据库
init_db()函数在main()函数的第 87 行被调用,用于执行创建数据库和初始化结构的基本任务。首先,我们需要检查数据库是否已经存在,如果存在,则连接到它并返回连接对象。无论文件是否存在,我们都可以使用sqlite3库的connect()方法打开或创建一个文件作为数据库。这个连接用于允许 Python 对象与数据库之间的通信。我们还专门使用一个游标对象,在第 94 行分配为cur,来跟踪我们在已执行语句中的位置。这个游标是与数据库交互所必需的:
081 def init_db(db_path):
082 """
083 The init_db function opens or creates the database
084 :param db_path: The filepath for the database
085 :return: conn, the sqlite3 database connection
086 """
087 if os.path.exists(db_path):
088 logger.info('Found Existing Database')
089 conn = sqlite3.connect(db_path)
090 else:
091 logger.info('Existing database not found. '
092 'Initializing new database')
093 conn = sqlite3.connect(db_path)
094 cur = conn.cursor()
如果数据库不存在,那么我们必须创建一个新的数据库,连接到它,并初始化表格。如本章 SQL 部分所述,我们必须使用CREATE TABLE语句创建这些表格,并后跟列名及其数据类型。在Custodians表中,我们需要创建一个自动递增的id列,用于为name列提供标识符,该列将存储托管人的名称。
要做到这一点,我们必须首先在第 96 行的sql变量中构建查询。赋值后,我们将这个变量传递给cur.execute()方法,通过游标对象执行我们的 SQL 语句。此时,游标与之前的连接对象进行通信,后者再与数据库进行交流。请查看以下代码:
096 sql = """CREATE TABLE Custodians (
097 cust_id INTEGER PRIMARY KEY, name TEXT);"""
098 cur.execute(sql)
在第 99 行,我们使用PRAGMA创建另一个 SQL 查询,它允许我们修改数据库的配置。默认情况下,在 SQLite3 中,外键是禁用的,这阻止了我们在一个表中引用另一个表中的数据。通过使用PRAGMA语句,我们可以通过将foreign_keys设置为1来启用此功能:
099 cur.execute('PRAGMA foreign_keys = 1;')
我们重复创建Files表的过程,添加更多字段以记录文件的元数据。在第 100 到 105 行,我们列出了字段名称及其关联的数据类型。我们可以通过使用三重引号将该字符串跨越多行,并让 Python 将其解释为一个单一的字符串值。正如我们已经看到的,我们需要列来存储 ID(与Custodians表类似),文件名、文件路径、扩展名、大小、修改时间、创建时间、访问时间、模式和 inode 号。
mode属性指定文件的权限,基于 UNIX 权限标准,而inode属性是 UNIX 系统中唯一标识文件系统对象的编号。这两个元素将在理解 ingest_directory()函数部分中进一步描述,在那里它们从文件中提取。在创建了两个表并定义了它们的结构后,我们在第 106 行执行最终的 SQL 语句并返回连接对象:
100 sql = """CREATE TABLE Files(id INTEGER PRIMARY KEY,
101 custodian INTEGER NOT NULL, file_name TEXT,
102 file_path TEXT, extension TEXT, file_size INTEGER,
103 mtime TEXT, ctime TEXT, atime TEXT, mode TEXT,
104 inode INTEGER, FOREIGN KEY (custodian)
105 REFERENCES Custodians(cust_id));"""
106 cur.execute(sql)
107 conn.commit()
108 return conn
使用 get_or_add_custodian()函数检查保管人
此时,数据库已初始化并准备好进行进一步的交互。调用get_or_add_custodian()函数来检查保管人是否存在,并在找到时传递其 ID。如果保管人不存在,函数将把保管人添加到Custodians表中。在第 120 行,我们调用get_custodian()函数来检查保管人是否存在。在第 122 行,我们使用条件语句检查id是否为空,如果不是,则将保管人的 ID 赋值给cust_id变量。SQLite 库返回的是元组,以确保向后兼容,第一个元素将是我们关心的 ID:
111 def get_or_add_custodian(conn, custodian):
112 """
113 The get_or_add_custodian function checks the database for a
114 custodian and returns the ID if present;
115 Or otherwise creates the custodian
116 :param conn: The sqlite3 database connection object
117 :param custodian: The name of the custodian
118 :return: The custodian ID or None
119 """
120 cust_id = get_custodian(conn, custodian)
121 cur = conn.cursor()
122 if cust_id:
123 cust_id = cust_id[0]
如果没有找到保管人,我们会将其插入表中以供将来使用。在第 125-126 行,我们编写一个 SQL 语句将保管人插入到Custodians表中。注意VALUES部分的null字符串;它被 SQLite 解释为NoneType对象。SQLite 将主键字段中的NoneType对象转换为自增整数。紧随其后的null值是我们的保管人字符串。SQLite 要求字符串值用引号括起来,类似于 Python。
我们必须使用双引号将包含单引号的查询括起来。这可以防止由于引号错误导致字符串断裂的任何问题。如果在代码的这一部分看到语法错误,请务必检查第 125-126 行中使用的引号。
最后,我们执行此语句并返回空的cust_id变量,这样main()函数就必须再次检查数据库中的保管员,并重新运行该函数。下一次执行应该能检测到我们插入的值,并允许main()函数继续执行。我们有以下代码:
124 else:
125 sql = """INSERT INTO Custodians (cust_id, name) VALUES
126 (null, '{}') ;""".format(custodian)
127 cur.execute(sql)
128 conn.commit()
129 return cust_id
尽管我们可以在此处调用get_custodian()函数(或在插入后获取 ID)进行验证,但我们让main()函数再次检查保管员。可以自由实现这些替代解决方案之一,看看它们如何影响代码的性能和稳定性。
使用get_custodian()函数获取保管员
get_custodian()函数被调用以从 SQLite 数据库中检索保管员 ID。使用简单的SELECT语句,我们从Custodian表中选择id列,并根据用户提供的名称与name列进行匹配。我们使用字符串的format()方法将保管员名称插入到 SQL 语句中。请注意,我们仍然需要将插入的字符串用单引号包裹起来,如下所示:
132 def get_custodian(conn, custodian):
133 """
134 The get_custodian function checks the database for a
135 custodian and returns the ID if present
136 :param conn: The sqlite3 database connection object
137 :param custodian: The name of the custodian
138 :return: The custodian ID
139 """
140 cur = conn.cursor()
141 sql = "SELECT cust_id FROM Custodians "\
142 "WHERE name='{}';".format(custodian)
执行此语句后,我们在第 144 行使用fetchone()方法从语句中返回一个结果。这是我们的脚本首次从数据库请求数据。为了获取数据,我们使用fetchone()、fetchmany()或fetchall()中的任何一个方法,从执行的语句中收集数据。这三个方法仅适用于游标对象。在这里,fetchone()方法是更好的选择,因为我们预期该语句返回一个单一的保管员。这个保管员 ID 被捕获并存储在data变量中:
143 cur.execute(sql)
144 data = cur.fetchone()
145 return data
理解ingest_directory()函数
ingest_directory()函数处理我们脚本的输入模式,并递归捕获用户提供的根目录中文件的元数据。在第 158 行,我们在count变量之前设置了数据库游标,该变量将记录存储在Files表中的文件数量:
148 def ingest_directory(conn, target, custodian_id):
149 """
150 The ingest_directory function reads file metadata and stores
151 it in the database
152 :param conn: The sqlite3 database connection object
153 :param target: The path for the root directory to
154 recursively walk
155 :param custodian_id: The custodian ID
156 :return: None
157 """
158 cur = conn.cursor()
159 count = 0
该函数最重要的部分是第 160 行的for循环。此循环使用os.walk()方法将提供的目录路径拆分成一个可迭代数组,供我们逐步遍历。os.walk()方法有三个组成部分,它们通常命名为root、folders和files。root值是一个字符串,表示我们在特定循环迭代过程中当前遍历的基本目录路径。当我们遍历子文件夹时,它们会被附加到root值中。folders和files变量分别提供当前根目录内文件夹和文件名的列表。尽管可以根据需要重命名这些变量,但这是一个良好的命名约定,有助于避免覆盖 Python 语句,例如file或dir,这些在 Python 中已经被使用。不过,在此实例中,我们不需要os.walk()中的folders列表,因此我们将其命名为一个单下划线(_):
160 for root, _, files in os.walk(target):
这是为未在代码中使用的变量赋值的常见做法。因此,仅使用单个下划线表示未使用的数据。尽可能地,尝试重新设计代码,避免返回不需要的值。
在循环中,我们开始遍历files列表,以访问每个文件的信息。在第 162 行,我们创建了一个文件专属的字典meta_data,用来存储收集到的信息,具体如下:
161 for file_name in files:
162 meta_data = dict()
在第 163 行,我们使用 try-except 语句块来捕捉任何异常。我们知道我们曾说过不要这么做,但请先听我们解释。这种通用的异常捕获机制是为了确保在发现的文件中出现的任何错误不会导致脚本崩溃和停止执行。相反,文件名和错误信息将被写入日志,然后跳过该文件并继续执行。这有助于检查人员快速定位和排除特定文件的故障。这一点非常重要,因为某些错误可能在 Windows 系统上由于文件系统标志和命名规则而导致 Python 出现错误。而在 macOS 和 Linux/UNIX 系统上则可能会出现不同的错误,这使得很难预测脚本会在哪些情况下崩溃。这正是日志记录重要性的一个极好例子,因为我们可以回顾脚本生成的错误。
在 try-except 语句块中,我们将文件元数据的不同属性存储到字典的键中。首先,我们在第 163 行和第 164 行记录文件名和完整路径。注意,字典的键与它们在Files表中所属列的名称相同。这个格式将使我们在脚本后续的操作中更加方便。文件路径使用os.path.join()方法进行存储,该方法将不同的路径合并为一个,使用操作系统特定的路径分隔符。
在第 167 行,我们通过使用os.path.splitext()方法来获取文件扩展名,该方法会根据文件名中最后一个.之后的部分进行分割。由于第 167 行的函数会创建一个列表,我们选择最后一个元素以确保我们保存扩展名。在某些情况下,文件可能没有扩展名(例如.DS_Store文件),在这种情况下,返回列表中的最后一个值是一个空字符串。请注意,这个脚本并没有检查文件签名来确认文件类型是否与扩展名匹配;检查文件签名的过程可以自动化:
163 try:
164 meta_data['file_name'] = file_name
165 meta_data['file_path'] = os.path.join(root,
166 file_name)
167 meta_data['extension'] = os.path.splitext(
168 file_name)[-1]
探索os.stat()方法
在第 170 行,我们使用os.stat()来收集文件的元数据。此方法访问系统的stat库以收集提供的文件的信息。默认情况下,该方法返回一个对象,包含有关每个文件的所有可用数据。由于这些信息在平台之间有所不同,我们仅选择了脚本中最具跨平台特性的属性,如os库文档中所定义的;更多信息可以在docs.python.org/3/library/os.html#os.stat_result中找到。此列表包括创建时间、修改时间、访问时间、文件模式、文件大小、inode 号和模式。SQLite 将接受字符串格式的数据类型,尽管我们将以正确的数据类型将其存储在脚本中,以防我们需要修改它们或使用特定类型的特殊特性。
文件模式最好以八进制整数形式显示,因此我们必须使用 Python 的oct()函数将其转换为可读状态,如第 171 行所示:
170 file_stats = os.stat(meta_data['file_path'])
171 meta_data['mode'] = str(oct(file_stats.st_mode))
文件模式是一个三位数的整数,表示文件对象的读、写和执行权限。权限定义在下表中,使用数字 0-7 来确定分配的权限。每一位数字表示文件所有者、文件所属组以及所有其他用户的权限。例如,数字 777 表示任何人都具有完全权限,而 600 意味着只有所有者可以读取和写入文件。除了每个数字,八进制表示法允许我们通过添加数字来为文件分配额外的权限。例如,值 763 授予所有者完全权限(700),授予组读取和写入权限(040 + 020),并授予其他人写和执行权限(002 + 001)。你可能永远不会看到 763 作为权限设置,尽管它在这里是一个有趣的例子:
| 权限 | 描述 |
|---|---|
| 700 | 文件所有者完全权限 |
| 400 | 所有者具有读权限 |
| 200 | 所有者具有写权限 |
| 100 | 所有者具有执行权限 |
| 070 | 完全组权限 |
| 040 | 组具有读权限 |
| 020 | 组具有写权限 |
| 010 | 组具有执行权限 |
| 007 | 其他人(不在该组或所有者之外)具有完全权限 |
| 004 | 其他人具有读权限 |
| 002 | 其他人具有写权限 |
| 001 | 其他人具有执行权限 |
下表显示了由 Python 的os.stat()方法提供的额外文件类型信息。表中的三个井号表示我们刚刚讨论的文件权限在数字中的位置。下表的前两行不言自明,符号链接表示指向文件系统中其他位置的引用。例如,在下表中,值 100777 表示一个常规文件,所有者、组和其他用户都具有完全权限。虽然可能需要一些时间来适应,但这个系统对于识别文件权限及其访问权限非常有用:
| 文件类型 | 描述 |
|---|---|
| 040### | 目录 |
| 100### | 常规文件 |
| 120### | 符号链接 |
inode值是文件系统对象的唯一标识符,这是我们将在第 172 行捕获的下一个值。尽管这是仅在 Linux/UNIX/macOS 系统中找到的特性,但 Python 会将 NTFS 的记录号转换为相同的对象,以保持一致性。在第 173 行,我们为文件大小赋值,文件大小以分配的字节数作为整数表示。在第 174 行到第 179 行,我们按顺序将访问、修改和创建的时间戳赋值给字典。每个时间戳都使用我们的format_timestamps()函数将浮动值转换为字符串。我们现在已经收集了完成Files表中一行所需的数据:
172 meta_data['inode'] = int(file_stats.st_ino)
173 meta_data['file_size'] = int(file_stats.st_size)
174 meta_data['atime'] = format_timestamp(
175 file_stats.st_atime)
176 meta_data['mtime'] = format_timestamp(
177 file_stats.st_mtime)
178 meta_data['ctime'] = format_timestamp(
179 file_stats.st_ctime)
本节前面提到的异常在第 180 行定义,并记录在收集元数据过程中遇到的任何错误:
180 except Exception as e:
181 logger.error(
182 'Error processing file: {} {}'.format(
183 meta_data.get('file_path', None),
184 e.__str__()))
最后,在我们的try-except语句块之外,我们将custodian_id添加到我们的meta_data字典中,这样我们就可以将其与记录一起存储。现在,我们可以构造用于插入新文件元数据记录的 SQL 语句。正如我们之前所看到的,我们将在第 186 行构造一个插入语句,并为列名和值名添加占位符。使用.format()方法,我们将插入我们的meta_data键和值数据。在第 187 行,我们将meta_data的键连接成一个字符串,每个键之间用双引号和逗号分隔。在第 188 行,我们将一个由逗号分隔的列表连接起来,为每个值插入一个问号作为execute()调用的占位符。以下是生成的sql变量中的字符串示例:
INSERT INTO Files
("custodian","ctime","mtime","extension","inode",
"file_size","file_name","mode","atime","file_path")
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
这样,我们就可以在第 189-190 行的try块中看到我们值的列表,并将其传递给 SQLite3 Python 库,以便为数据库生成正确的插入语句。我们需要将字典值转换为元组,以便 SQLite3 支持,如第 190 行的调用所示:
185 meta_data['custodian'] = custodian_id
186 sql = 'INSERT INTO Files ("{}") VALUES ({})'.format(
187 '","'.join(meta_data.keys()),
188 ', '.join('?' for x in meta_data.values()))
189 try:
190 cur.execute(sql, tuple(meta_data.values()))
现在,我们可以关闭我们的except语句块,并为 SQLite3 库的错误提供错误处理和日志记录,错误发生在第 191 行到第 197 行。在错误处理之后,我们将文件处理计数增加 1,并继续处理下一个文件,该文件可以在我们的两个for循环中的任意一个中找到:
191 except (sqlite3.OperationalError,
192 sqlite3.IntegrityError) as e:
193 logger.error(
194 "Could not insert statement {}"
195 " with values: {}".format(
196 sql, meta_data.values()))
197 logger.error("Error message: {}".format(e))
198 count += 1
一旦我们最内层的for循环完成,我们使用commit()方法将新记录保存到数据库中。在外层for循环完成后,我们再次运行commit()方法,然后记录目录处理完成,并向用户提供已处理文件的数量:
199 conn.commit()
200 conn.commit()
201 logger.info('Stored meta data for {} files.'.format(count))
开发format_timestamp()辅助函数
这个相对较小的函数将整数时间戳转换为人类可读的字符串。由于 Python 的os.stat()模块返回的是自纪元(1970 年 1 月 1 日)以来的秒数,我们需要使用datetime库来进行转换。通过使用datetime.datetime.fromtimestamp()函数,我们可以将浮动时间戳解析为datetime对象,本文中我们将其命名为ts_datetime,并在第 211 行进行赋值。将日期作为datetime对象后,我们可以在第 212 行使用strftime()方法按所需格式YYYY-MM-DD HH:MM:SS格式化日期。字符串准备好后,我们将其返回给调用函数,以便插入到数据库中:
204 def format_timestamp(timestamp):
205 """
206 The format_timestamp function formats an integer to a string
207 timestamp
208 :param timestamp: An integer timestamp
209 :return: ts_format, a formatted (YYYY-MM-DD HH:MM:SS) string
210 """
211 ts_datetime = datetime.datetime.fromtimestamp(timestamp)
212 ts_format = ts_datetime.strftime('%Y-%m-%d %H:%M:%S')
213 return ts_format
这样的小型实用函数在较大的脚本中非常有用。一个优势是,如果我们想更新日期格式,只需在一个地方更改,而不必逐一查找所有strftime()的用法。这个小函数还提高了代码的可读性。ingest_directory()函数已经相当庞大,如果将这一逻辑重复三次,可能会使下一个审查代码的人感到困惑。这些函数在字符串格式化或常见转换中非常有用,但在设计自己的脚本时,可以考虑创建哪些实用函数来简化工作。
配置write_output()函数
如果用户指定了输出目标,则会调用write_output()函数。调用后,我们使用get_custodian()函数从数据库中选择看护人 ID,该函数在第 225 行被调用。如果找到了看护人,我们需要构建一个新的查询来确定与看护人关联的文件数量,使用COUNT() SQL 函数。如果未找到看护人,会记录错误,提示用户看护人未响应,具体内容见第 234 到 237 行:
216 def write_output(conn, target, custodian):
217 """
218 The write_output function handles writing either the CSV or
219 HTML reports
220 :param conn: The sqlite3 database connection object
221 :param target: The output filepath
222 :param custodian: Name of the custodian
223 :return: None
224 """
225 custodian_id = get_custodian(conn, custodian)
226 cur = conn.cursor()
227 if custodian_id:
228 custodian_id = custodian_id[0]
229 sql = "SELECT COUNT(id) FROM Files "\
230 "where custodian = {}".format(
231 custodian_id)
232 cur.execute(sql)
233 count = cur.fetchone()
234 else:
235 logger.error(
236 'Could not find custodian in database. Please check '
237 'the input of the custodian name and database path')
如果找到了看护人并且存储的文件数量大于零,我们将检查要生成哪种类型的报告。从第 239 行开始的条件语句检查count的大小和源文件的扩展名。如果count不大于零或没有值,则会在第 240 行记录错误。否则,我们在第 241 行检查 CSV 文件扩展名,在第 243 行检查 HTML 文件扩展名,如果找到匹配项,则调用相应的函数。如果源文件扩展名既不是 CSV 也不是 HTML,则会记录错误,表明无法确定文件类型。最后,如果代码执行到第 247 行的else语句,则会记录一个未知错误发生的事实。我们可以在以下代码中看到这一过程:
239 if not count or not count[0] > 0:
240 logger.error('Files not found for custodian')
241 elif target.endswith('.csv'):
242 write_csv(conn, target, custodian_id)
243 elif target.endswith('.html'):
244 write_html(conn, target, custodian_id, custodian)
245 elif not (target.endswith('.html')or target.endswith('.csv')):
246 logger.error('Could not determine file type')
247 else:
248 logger.error('Unknown Error Occurred')
设计 write_csv() 函数
如果文件扩展名是 CSV,我们可以开始迭代存储在文件表中的条目。第 261 行的 SQL 语句使用 WHERE 语句仅识别与特定保管员相关的文件。返回的 cur.description 值是一个元组的元组,每个嵌套元组中有八个元素,表示我们的列名。每个元组中的第一个值是列名,其余七个是空字符串,作为向后兼容的目的保留。通过在第 265 行使用列表推导式,我们遍历这些元组,选择返回元组中每个项的第一个元素,构建列名列表。这条单行语句使我们能够将一个简单的 for 循环压缩成一条生成所需列表的语句:
251 def write_csv(conn, target, custodian_id):
252 """
253 The write_csv function generates a CSV report from the
254 Files table
255 :param conn: The Sqlite3 database connection object
256 :param target: The output filepath
257 :param custodian_id: The custodian ID
258 :return: None
259 """
260 cur = conn.cursor()
261 sql = "SELECT * FROM Files where custodian = {}".format(
262 custodian_id)
263 cur.execute(sql)
264
265 cols = [description[0] for description in cur.description]
列表推导式是一种简洁的通过单行 for 循环生成列表的方法。通常用于过滤列表内容或进行某种形式的转换。在第 265 行,我们使用它进行结构转换,从 cur.description 列表的每个元素中仅提取第一个项,并将其存储为列名。这是因为 Python 的 SQLite 绑定将列名作为嵌套元组返回,其中每个子元组的第一个元素是列名。
在准备好列名后,我们记录下正在写入 CSV 报告,并在第 267 行以 wb 模式打开输出文件。然后,我们通过在第 268 行调用 csv.writer() 方法并传递文件对象来初始化写入器。打开文件后,我们通过调用 csv_writer 对象的 writerow() 方法写入列行,每次写入一行。
此时,我们将通过迭代游标来循环结果,游标将在每次循环迭代时返回一行,直到没有更多的行响应原始查询为止。对于每一行返回的结果,我们需要再次调用 writerow() 方法,如第 272 行所示。然后,我们在第 273 行将新数据刷新到文件中,以确保数据写入磁盘。最后,我们记录报告已完成并存储在用户指定的位置。我们有以下代码:
266 logger.info('Writing CSV report')
267 with open(target, 'w', newline="") as csv_file:
268 csv_writer = csv.writer(csv_file)
269 csv_writer.writerow(cols)
270
271 for entry in cur:
272 csv_writer.writerow(entry)
273 csv_file.flush()
274 logger.info('CSV report completed: ' + target)
编写 write_html() 函数
如果用户指定了 HTML 报告,则会调用 write_html() 函数从数据库读取数据,生成数据的 HTML 标签,并使用 Bootstrap 样式创建包含文件元数据的表格。由于这是 HTML 格式,我们可以自定义它,创建一个专业外观的报告,该报告可以转换为 PDF 或通过任何具有网页浏览器的用户查看。如果在你的报告版本中额外的 HTML 元素证明是有用的,它们可以轻松地添加到以下字符串中,并通过添加徽标、扩展高亮、响应式表格、图表等进行自定义,只要你使用各种网页样式和脚本,这一切都是可能的。
由于本书专注于 Python 脚本的设计,我们不会详细讲解 HTML、CSS 或其他网页设计语言。在使用这些功能时,我们将描述它们的基本用途和如何实现它们,尽管如果你有兴趣,我们建议你使用相关资源(例如www.w3schools.com)来深入了解这些话题。
这个函数的开始与 write_csv() 类似:我们在 287 行的 SQL 语句中选择属于托管人的文件。执行后,我们再次在 291 行使用列表推导式收集 cols。通过列名,我们使用 join() 函数在 292 行定义 table_header HTML 字符串,并通过 <th></th> 标签将每个值分隔开。除了第一个和最后一个元素外,其他每个元素都会被 <th>{{ element }}</th> 标签包围。现在,我们需要关闭第一个和最后一个元素的标签,以确保它们形成正确的表头。对于字符串的开始部分,我们添加 <tr><th> 标签来定义整个行的表格行 <tr> 和第一个条目的表头 <th>。同样,我们在 293 行字符串的末尾关闭表头和表格行标签,内容如下:
277 def write_html(conn, target, custodian_id, custodian_name):
278 """
279 The write_html function generates an HTML report from the
280 Files table
281 :param conn: The sqlite3 database connection object
282 :param target: The output filepath
283 :param custodian_id: The custodian ID
284 :return: None
285 """
286 cur = conn.cursor()
287 sql = "SELECT * FROM Files where custodian = {}".format(
288 custodian_id)
289 cur.execute(sql)
290
291 cols = [description[0] for description in cur.description]
292 table_header = '</th><th>'.join(cols)
293 table_header = '<tr><th>' + table_header + '</th></tr>'
294
295 logger.info('Writing HTML report')
在 297 行,我们以 w 模式打开 HTML 文件,将其赋值给 html_file 变量。文件打开后,我们开始构建 HTML 代码,从 298 行的 <html><body> 标签开始,这些标签用于初始化 HTML 文档。接着,我们连接到托管在线的自定义样式表,以为表格提供 Bootstrap 样式。我们通过使用 <link> 标签来实现这一点,指定样式表的类型和来源,样式表位于 www.bootstrapcdn.com/。
现在,让我们定义 HTML 报告的头部,以确保它包含托管人 ID 和姓名。我们将使用<h1></h1>或标题 1 标签来实现这一点。对于我们的表格,我们在 302 行使用表格标签,并使用我们想要实现的 Bootstrap 样式(table、table-hover 和 table-striped)。
获取有关 Bootstrap 的更多信息,请访问getbootstrap.com。虽然本脚本使用的是 Bootstrap CSS 版本 3.3.5,但你可以探索 Bootstrap 的最新更新,并查看是否能在你的代码中实现新的功能。
在 HTML 字符串中加入这些头部信息后,我们可以将其写入文件,首先在 304 行写入 HTML 头部和样式表信息,然后在 305 行写入表格的列名,内容如下:
297 with open(target, 'w') as html_file:
298 html_string = """<html><body>\n
299 <link rel="stylesheet"
300 href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
301 <h1>File Listing for Custodian ID: {}, {}</h1>\n
302 <table class='table table-hover table-striped'>\n
303 """.format(custodian_id, custodian_name)
304 html_file.write(html_string)
305 html_file.write(table_header)
现在,让我们遍历数据库中的记录,将它们作为单独的行写入表格。我们首先通过连接表格数据标签(<td></td>)来指定表格单元格的内容,使用列表推导式在 308 行对数据进行连接,并将其转换为 join() 方法所需的字符串值:
307 for entry in cur:
308 row_data = "</td><td>".join(
309 [str(x) for x in entry])
在第 310 行,我们添加了一个换行符(\n),然后是一个<tr>表格行标签和初始的<td>标签,用于打开第一个元素的表格数据。换行符减少了某些 HTML 查看器的加载时间,因为它将数据分割成多行。我们还需要在第 310 行的末尾关闭最后一个表格数据标签和整个表格行。行数据会在第 311 行写入文件。最后,在表格行的循环中,我们使用.flush()方法将内容刷新到文件。随着表格数据的构建,我们可以在第 313 行关闭表格、主体和 HTML 标签。跳出for循环后,我们在第 315 行记录报告的状态和位置:
310 html_string = "\n<tr><td>" + row_data + "</td></tr>"
311 html_file.write(html_string)
312 html_file.flush()
313 html_string = "\n</table>\n</body></html>"
314 html_file.write(html_string)
315 logger.info('HTML Report completed: ' + target)
运行脚本
在这一版本中,我们突出展示了递归读取目录中所有文件元数据、将其存储到数据库、从数据库中提取并基于数据生成报告的过程。本版本使用了基本的库来手动处理必要的 SQL 和 HTML 操作。下一版本将专注于使用 Python 对象执行相同的功能。这两个版本都是脚本的最终版本,并且功能完全可用。不同的版本展示了实现相同任务的不同方法。
要运行我们的脚本,首先需要提供管理员的名称、要创建或读取的数据库位置以及所需的模式。在第一个示例中,我们指定了输入模式并传递了根目录以进行索引。在第二个示例中,我们以输出模式创建了一个 CSV 报告,并提供了适当的文件路径:
前面脚本的输出可以通过以下截图查看。在这里,我们仅仅创建了一个通用的 CSV 报告,包含了本章管理员的索引文件的捕获元数据:
进一步自动化数据库 – file_lister_peewee.py
在这一版本中,我们将使用第三方 Python 模块进一步自动化我们的 SQL 和 HTML 设置。这会引入额外的开销;然而,我们的脚本会更简洁,实施起来更加流畅,这使得我们能够轻松地开发更多功能。面向未来的开发可以帮助我们避免为了每个小的功能请求而重写整个脚本。
我们已经导入了前一版本所需的大部分标准库,并添加了第三方unicodecsv模块(版本 0.14.1)。这个模块是对内置csv模块的封装,自动为 CSV 输出提供 Unicode 支持。为了保持熟悉的使用方式,我们甚至可以通过在第 8 行使用import...as...语句将其命名为csv。正如本章之前提到的,peewee(版本 2.8.0)和jinja2(版本 2.8)是处理 SQLite 和 HTML 操作的两个库。由于这最后三个导入是第三方库,它们需要在用户的机器上安装才能让我们的代码正常运行,安装方法可以使用pip:
001 """File metadata capture and reporting utility."""
002 import argparse
003 import datetime
004 from io import open
005 import logging
006 import os
007 import sys
008 import unicodecsv as csv
009 import peewee
010 import jinja2
在导入语句和许可证之后,我们定义了我们的通用脚本元数据和日志处理器。在第 46 行,我们添加了database_proxy对象,用于为Custodian和Files类表创建 Peewee 基本模型。我们还添加了get_template()函数,该函数使用jinja2构建一个模板 HTML 表格。其他函数在很大程度上与之前版本的对应函数类似,只做了一些小调整。然而,我们已经删除了get_custodian()函数,因为 Peewee 已经内建了该功能:
040 __authors__ = ["Chapin Bryce", "Preston Miller"]
041 __date__ = 20181027
042 __description__ = '''This script uses a database to ingest and
043 report meta data information about active entries in
044 directories.'''
045 logger = logging.getLogger(__name__)
046 database_proxy = peewee.Proxy()
047
048 class BaseModel(peewee.Model):
...
052 class Custodians(BaseModel):
...
055 class Files(BaseModel):
...
069 def get_template():
...
106 def main(custodian, target, db):
...
138 def init_db(db):
...
150 def get_or_add_custodian(custodian):
...
167 def ingest_directory(source, custodian_model):
...
216 def format_timestamp(ts):
...
226 def write_output(source, custodian_model):
...
253 def write_csv(source, custodian_model):
...
282 def write_html(source, custodian_model):
定义命令行参数并设置日志记录的if __name__ == '__main__'条件下的代码块与之前的版本相同。我们在此不再重复这些实现细节,因为我们可以直接从前一版本中复制粘贴该部分,节省了一些纸张。尽管该部分保持不变,但我们脚本的整体流程略有修改,具体变化如以下流程图所示:
Peewee 设置
Peewee 是本章开头提到的对象关系管理库,它在 Python 中的数据库管理非常出色。它使用 Python 类定义数据库的设置,包括表格配置、数据库位置以及如何处理不同的 Python 数据类型。在第 46 行,我们必须首先使用 Peewee 的Proxy()类创建一个匿名的数据库连接,这样我们就可以将信息重定向到之前指定的格式。根据 Peewee 的文档,该变量必须在进行任何 Peewee 操作之前声明(docs.peewee-orm.com/en/3.6.0/)。
在代理初始化之后,我们定义了本书中使用的第一个 Python 类,从而创建了一个BaseModel类,该类定义了要使用的数据库。作为 Peewee 规范的一部分,我们必须将database_proxy链接到BaseModel对象的Meta类中的database变量。
虽然这一配置可能现在看起来不太明了,但请继续阅读本章其余内容,并在完成并运行脚本后回到这一部分,因为这些模块的目的会变得更加清晰。此外,上述文档在展示 Peewee 的功能和使用方面做得非常出色。
我们必须包括在第 48 行至第 50 行定义的基础模型,作为 Peewee 创建数据库的最小设置:
046 database_proxy = peewee.Proxy()
047
048 class BaseModel(peewee.Model):
049 class Meta:
050 database = database_proxy
接下来,我们在第 60 行定义并创建了Custodians表。这个表继承了BaseModel的属性,因此在其括号内包含了BaseModel类。这通常用于定义函数所需的参数,但在类中,它也可以让我们分配一个父类,以便继承数据。在这个脚本中,BaseModel类是peewee.Model的子类,也是Custodians表和(稍后将讨论的)Files表的父类。请记住,Peewee 将表描述为类模型,库会为我们创建一个名为Custodians的表;稍后会详细说明。
初始化后,我们在第 61 行向Custodians表添加了一个文本字段name。unique=True关键字创建了一个自动递增的索引列,除了我们的name列之外。这个表配置将用于稍后创建表、插入数据并从中检索信息:
052 class Custodians(BaseModel):
053 name = peewee.TextField(unique=True)
Files表有更多的字段和几种新的数据类型。正如我们已经知道的,SQLite 只管理文本、整数、空值和 BLOB 数据类型,因此其中一些数据类型可能看起来不太对。以DateTimeField为例,Peewee 可以处理任何 Python date或datetime对象。Peewee 会自动将其存储为数据库中的文本值,甚至可以保留其原始时区。当数据从表中调用时,Peewee 会尝试将这个值转换回datetime对象或格式化的字符串。尽管日期仍然以文本值的形式存储在数据库中,Peewee 在数据传输过程中会进行转换,以提供更好的支持和功能。尽管我们可以像在之前的脚本中那样手动复制这一功能,但这是 Peewee 打包的一些有用功能之一。
在第 56 行到第 66 行之间,我们创建了具有类型的列,这些列反映了主键、外键、文本、时间戳和整数。PrimaryKeyField指定唯一的主键属性,并分配给id列。ForeignKeyField以Custodians类作为参数,因为 Peewee 使用它将其与我们定义的Custodians类中的索引关联起来。紧接着这两个特殊键字段的是一系列我们在本章前面描述的字段:
055 class Files(BaseModel):
056 id = peewee.PrimaryKeyField(unique=True, primary_key=True)
057 custodian = peewee.ForeignKeyField(Custodians)
058 file_name = peewee.TextField()
059 file_path = peewee.TextField()
060 extension = peewee.TextField()
061 file_size = peewee.IntegerField()
062 atime = peewee.DateTimeField()
063 mtime = peewee.DateTimeField()
064 ctime = peewee.DateTimeField()
065 mode = peewee.TextField()
066 inode = peewee.IntegerField()
这完成了我们之前使用 SQL 查询在第一个脚本中创建的数据库的整个设置。虽然与之前相比它更为冗长,但它确实避免了我们必须编写自己的 SQL 查询,并且在处理更大的数据库时,它显得尤为重要。例如,一个包含多个模块的大型脚本,将通过使用 Peewee 来定义和处理数据库连接受益匪浅。它不仅能为模块提供统一性,还能实现与不同数据库后端的跨兼容性。本章稍后将展示如何在 PostgreSQL、MySQL 和 SQLite 之间更改数据库类型。虽然 Peewee 设置较为冗长,但它提供了许多功能,免去了我们自己编写处理数据库事务函数的麻烦。
Jinja2 设置
现在,让我们讨论一下其他新模块的配置。Jinja2 允许我们使用 Python 风格的语法来创建强大的文本模板,以实现文本扩展和逻辑评估。模板还使我们能够开发一个可重用的文本块,而不是在 Python 脚本的 for 循环中逐行构建表格的行和列。尽管前一个脚本通过从字符串形成 HTML 文件采取了简单的方式,但这个模板更为强大、动态,并且最重要的是,更具可持续性。
这个函数定义了一个变量 html_string,它包含我们的 Jinja2 模板。这个字符串捕获了所有的 HTML 标签和数据,将由 Jinja2 处理。尽管我们将这些信息放在一个变量中,但我们也可以将文本放在文件中,以避免在代码中增加额外的行数。在第 76 和 77 行,我们可以看到与之前版本的 write_html() 函数相同的信息:
069 def get_template():
070 """
071 The get_template function returns a basic template for our
072 HTML report
073 :return: Jinja2 Template
074 """
075 html_string = """
076 <html>\n<head>\n<link rel="stylesheet"
077 href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/
css/bootstrap.min.css">
在第 78 至第 80 行,我们打开了<body>和<h1>标题标签,接着是一个包含两个 Python 对象的字符串,且这两个对象被空格包裹的双花括号({{ ... }})包含。Jinja2 会查找与花括号内字符串匹配的字典键或对象名称。在第 79 和 80 行的情况下,custodian 变量是一个具有 id 和 name 属性的对象。使用与 Python 中相同的语法,我们可以调用对象的属性,并在模板执行时将它们插入到 HTML 中:
078 </head>\n<body>\n<h1>
079 File Listing for Custodian {{ custodian.id }},
080 {{ custodian.name }}</h1>\n
第 81 行的 <table> 标签指定了我们用来为表格添加样式的 Bootstrap CSS 类。在第 82 行,我们打开了表格行 <tr> 标签,后面跟着一个换行符 \n 和一个新的模板操作符。围绕百分号的花括号({% ... %})表示 Jinja2 模板包含一个操作,比如循环,需要进行求值。在我们的例子中,在第 83 行我们开始了一个 for 循环,语法类似于 Python 的 for 循环,只是缺少闭合的冒号。跳到第 85 行,我们使用相同的语法将 endfor 语句包围起来,通知 Jinja2 循环已结束。我们必须这样做,因为 HTML 不敏感于制表符或空格,不能像 Python 的缩进代码一样自动确定循环的边界。
在 Jinja2 模板语法和我们希望 Jinja2 插入的值之间加入空格是一个好习惯。例如,{{ Document_Title }} 比 {{Document_Title}} 更易读。
在第 84 行,我们将新定义的 header 变量包裹在表格头 <th> 标签中。循环完成后,在第 86 行关闭表格行 <tr> 标签。通过这个循环,我们生成了一个包含表头列表 <th> 的表格行 <tr>,如下所示:
081 <table class="table table-hover table-striped">\n
082 <tr>\n
083 {% for header in table_headers %}
084 <th>{{ header }}</th>
085 {% endfor %}
086 </tr>\n
接下来,我们打开一个新的循环,遍历每一列数据,为每列创建一个新的表格行 <tr>,并将每个元素包裹在表格数据 <td> 标签中。由于数据库的每一列都是 Peewee 返回的行对象的属性,我们可以使用以下格式来指定列名:entry.column_name。通过这个简单的 for 循环,我们构建了一个易于阅读和扩展的表格格式:
087 {% for entry in file_listing %}
088 <tr>
089 <td>{{ entry.id }}</td>
090 <td>{{ entry.custodian.name }}</td>
091 <td>{{ entry.file_name }}</td></td>
092 <td>{{ entry.file_path }}</td>
093 <td>{{ entry.extension }}</td>
094 <td>{{ entry.file_size }}</td>
095 <td>{{ entry.atime }}</td>
096 <td>{{ entry.mtime }}</td>
097 <td>{{ entry.ctime }}</td>
098 <td>{{ entry.mode }}</td>
099 <td>{{ entry.inode }}</td>
100 </tr>\n
101 {% endfor %}
在 {% endfor %} 语句之后,我们可以通过关闭打开的 HTML 标签并使用三个双引号关闭多行字符串来完成此 HTML 模板。构建好 html_string 后,我们调用 Jinja2 模板引擎来解释构建的字符串。为此,我们在第 103 行调用并返回 jinja2.Template() 函数的输出。这使我们能够在需要生成 HTML 报告时使用该模板。我们也可以使用相同标记语言的 HTML 文件来加载 Jinja2 模板,这在构建更复杂或多页面的 HTML 内容时特别有用:
102 </table>\n</body>\n</html>"""
103 return jinja2.Template(html_string)
更新 main() 函数
这个函数与我们在上一轮迭代中看到的 main() 函数几乎相同,尽管有一些例外。首先,在第 117 行,我们不需要捕获 init_db() 的返回值,因为 peewee 在初始化后会为我们处理这一点。当调用 get_or_add_custodian 时,我们也去除了 while 循环,因为该函数的逻辑已经由 Peewee 补充,使得合理性检查变得不再必要。我们将返回的 custodian 表格赋值给一个名为 custodian_model 的变量,因为 Peewee 将每个表格称为模型。
在我们的例子中,Custodians 和 Files 类是 Peewee 中的模型,分别代表 SQLite 中的 Custodians 和 Files 表。在 Peewee 中,模型返回的数据集被称为模型实例。
第 120 行返回的数据本质上与脚本先前实例中通过 SELECT 语句返回的数据相同,尽管它是由 Peewee 处理的模型实例。
106 def main(custodian, target, db):
107 """
108 The main function creates the database or table, logs
109 execution status, and handles errors
110 :param custodian: The name of the custodian
111 :param target: tuple containing the mode 'input' or 'output'
112 as the first element and its arguments as the second
113 :param db: The file path for the database
114 :return: None
115 """
116 logger.info('Initializing Database')
117 init_db(db)
118 logger.info('Initialization Successful')
119 logger.info('Retrieving or adding custodian: ' + custodian)
120 custodian_model = get_or_add_custodian(custodian)
第三项修改涉及如何处理脚本的不同模式。现在,我们只需要提供 target 和 custodian_model 变量,因为我们可以通过已经构建的 peewee 模型类访问数据库。此行为将在每个函数中展示,以演示如何在表中插入和访问数据。其余函数与之前的版本保持不变:
121 if target[0] == 'input':
122 logger.info('Ingesting base input directory: {}'.format(
123 target[1]))
124 ingest_directory(target[1], custodian_model)
125 logger.info('Ingesting Complete')
126 elif target[0] == 'output':
127 logger.info(
128 'Preparing to write output for custodian: {}'.format(
129 custodian))
130 write_output(target[1], custodian_model)
131 logger.info('Output Complete')
132 else:
133 logger.error('Could not interpret run time arguments')
134
135 logger.info('Script Complete')
调整 init_db() 函数
init_db() 函数是我们定义数据库类型的地方(例如 PostgreSQL、MySQL 或 SQLite)。虽然我们在这个例子中使用 SQLite,但我们可以使用其他数据库类型,在第 144 行调用单独的 peewee 函数,如 PostgresqlDatabase() 或 MySQLDatabase()。在第 144 行,我们必须传递要写入数据库的文件路径。如果我们只希望数据库是临时的,可以传递特殊字符串 :memory:,让 Peewee 在内存中托管 SQLite 数据库。内存选项有两个缺点:一是脚本退出后数据库不会持久化,二是数据库的内容必须能适应内存,这在旧机器或大型数据库中可能不可行。在我们的用例中,我们必须将数据库写入磁盘,因为我们可能希望再次运行脚本以便对同一数据库进行其他保存或报告:
138 def init_db(db):
139 """
140 The init_db function opens or creates the database
141 :param db_path: The file path for the database
142 :return: conn, the sqlite3 database connection
143 """
144 database = peewee.SqliteDatabase(db)
在创建数据库对象后,我们必须初始化第 46 行创建的 database_proxy,并更新它以引用新创建的 SQLite 数据库。这个代理连接告诉 Peewee 如何将数据从模型路由到我们的 SQLite 实例。
我们之前必须创建这个代理,以便在启动数据库连接之前指定模型数据。使用这个代理还允许我们询问用户希望将数据库存储在哪里,通过代理,我们可以创建一个占位符,稍后将其分配给 SQLite(或其他)数据库处理器。
有关代理使用的更多信息,请参考 Peewee 文档中的 docs.peewee-orm.com/en/3.6.0/peewee/database.html?highlight=proxy#dynamically-defining-a-database。
一旦连接到代理,我们可以创建所需的表,因此需要调用我们 Peewee 数据库对象上的 create_tables() 方法。正如你所看到的,我们必须先创建模型的列表,以便在调用 create_tables() 时可以引用这些表(及其模式)进行创建。
safe=True 参数在此处是必需的,因为我们希望在数据库中如果已存在该表时忽略它,以免覆盖或丢失数据。如果我们要扩展工具的功能或需要另一个表,我们需要记住在第 146 行将其添加到列表中,以便该表会被创建。如main()函数中所提到的,我们不需要在此处返回任何连接或游标对象,因为数据通过我们之前定义的peewee模型类流动:
145 database_proxy.initialize(database)
146 table_list = [Custodians, Files] # Update with any new tables
147 database.create_tables(table_list, safe=True)
修改 get_or_add_custodian() 函数
这个函数比之前的版本简单得多。我们只需调用 Custodians 模型上的 get_or_create() 方法,并传入字段标识符 name 及其对应的值 custodian。通过此调用,我们将获得该模型的实例以及一个布尔值,表示该行是新创建的还是已存在。利用这个 created 布尔值,我们可以添加日志语句,提醒用户某个托管人是已添加到数据库中,还是已检索到现有的托管人。在第 164 行,我们将模型实例返回给调用函数,如下所示:
150 def get_or_add_custodian(custodian):
151 """
152 The get_or_add_custodian function gets the custodian by name
153 or adds it to the table
154 :param custodian: The name of the custodian
155 :return: custodian_model, custodian peewee model instance
156 """
157 custodian_model, created = Custodians.get_or_create(
158 name=custodian)
159 if created:
160 logger.info('Custodian added')
161 else:
162 logger.info('Custodian retrieved')
163
164 return custodian_model
改进 ingest_directory() 函数
尽管这是脚本中较为复杂的函数之一,但它与之前的版本几乎完全相同,因为收集这些信息的方法没有变化。此处的新内容包括在第 177 行初始化一个我们将用于收集文件元数据字典的列表,以及将传递的 custodian_model 实例赋值给托管人,而不是使用整数值。我们还生成了 ddate 值,默认设置为时间戳,用于插入到 peewee 中,以防脚本无法获取日期值并需要存储部分记录。默认时间戳值将设置为 Python datetime 库的最小值,以确保日期编码和解码仍然正常工作。
在第 207 行,我们将 meta_data 字典添加到 file_data 列表中。然而,缺少的部分是构建复杂 SQL 插入语句和列名及其值的列表的代码。相反,我们遍历 file_data 列表,并以更高效的方式写入数据,正如稍后所描述的;现在,我们有如下代码:
167 def ingest_directory(source, custodian_model):
168 """
169 The ingest_directory function reads file metadata and stores
170 it in the database
171 :param source: The path for the root directory to
172 recursively walk
173 :param custodian_model: Peewee model instance for the
174 custodian
175 :return: None
176 """
177 file_data = []
178 for root, _, files in os.walk(source):
179 for file_name in files:
180 ddate = datetime.datetime.min
181 meta_data = {
182 'file_name': None, 'file_path': None,
183 'extension': None, 'mode': -1, 'inode': -1,
184 'file_size': -1, 'atime': ddate, 'mtime': ddate,
185 'ctime': ddate, 'custodian': custodian_model.id}
186 try:
187 meta_data['file_name'] = os.path.join(file_name)
188 meta_data['file_path'] = os.path.join(root,
189 file_name)
190 meta_data['extension'] = os.path.splitext(
191 file_name)[-1]
192
193 file_stats = os.stat(meta_data['file_path'])
194 meta_data['mode'] = str(oct(file_stats.st_mode))
195 meta_data['inode'] = str(file_stats.st_ino)
196 meta_data['file_size'] = str(file_stats.st_size)
197 meta_data['atime'] = format_timestamp(
198 file_stats.st_atime)
199 meta_data['mtime'] = format_timestamp(
200 file_stats.st_mtime)
201 meta_data['ctime'] = format_timestamp(
202 file_stats.st_ctime)
203 except Exception as e:
204 logger.error(
205 'Error processing file: {} {}'.format(
206 meta_data['file_path'], e.__str__()))
207 file_data.append(meta_data)
在第 209 行,我们开始将文件元数据插入数据库。由于我们的列表中可能有几千行数据,因此我们需要将插入操作批量处理,以防止资源耗尽的问题。第 209 行的循环使用了range函数,从0开始,按50的增量遍历file_data列表的长度。这意味着x会以50为增量,直到达到最后一个元素时,它会包含所有剩余项。
通过这样做,在第 210 行,我们可以使用.insert_many()方法将数据插入Files。在插入过程中,我们通过x到x+50来访问条目,每次插入50个元素。这种方法与之前逐行插入的做法有很大不同,在这里,我们是通过简化的语句批量插入数据,执行INSERT操作。最后,在第 211 行,我们需要执行每个已执行的任务,将条目提交到数据库。函数结束时,我们记录已插入文件的数量,如下所示:
209 for x in range(0, len(file_data), 50):
210 task = Files.insert_many(file_data[x:x+50])
211 task.execute()
212 logger.info('Stored meta data for {} files.'.format(
213 len(file_data)))
随意调整每次插入时执行的 50 行的单位。根据你的系统调整这个数字可能会提升性能,尽管这个最佳值通常会根据可用资源的不同而有所变化。
你可能还想考虑在file_data列表达到一定长度时插入记录,以帮助内存管理。例如,如果file_data列表超过 500 条记录,可以暂停数据收集,插入整个列表(即每次插入 50 条记录),清空列表,然后继续收集元数据。对于较大的数据集合,你应该会注意到内存使用显著减少。
更详细地查看format_timestamp()函数
这个函数与之前的版本作用相同,但它返回一个datetime对象,因为 Peewee 使用这个对象来写入datetime值。正如我们在之前的迭代中看到的那样,通过使用fromtimestamp()方法,我们可以轻松地将整数日期值转换为datetime对象。我们可以直接返回datetime对象,因为 Peewee 会处理剩余的字符串格式化和转换工作。以下是代码示例:
216 def format_timestamp(ts):
217 """
218 The format_timestamp function converts an integer into a
219 datetime object
220 :param ts: An integer timestamp
221 :return: A datetime object
222 """
223 return datetime.datetime.fromtimestamp(ts)
转换write_output()函数
在这个函数中,我们可以看到如何查询peewee模型实例。在第 235 行,我们需要选择文件计数,其中监护人等于监护人的id。我们首先在模型上调用select()来表示我们希望选择数据,接着使用where()方法指定列名Files.custodian和评估值custodian_model.id。接下来是count()方法,它返回符合条件的结果的数量(一个整数)。注意,count变量是一个整数,而不是像前一次迭代那样的元组:
226 def write_output(source, custodian_model):
227 """
228 The write_output function handles writing either the CSV or
229 HTML reports
230 :param source: The output filepath
231 :param custodian_model: Peewee model instance for the
232 custodian
233 :return: None
234 """
235 count = Files.select().where(
236 Files.custodian == custodian_model.id).count()
237
238 logger.info("{} files found for custodian.".format(count))
在第 240 行,我们沿用之前迭代的相同逻辑,检查哪些行是响应的,然后通过语句验证输出扩展,确保调用正确的写入者或提供用户准确的错误信息。注意,这次我们传递的是监护人模型实例,而不是id或名称,在第 243 行和 247 行使用了此方法,因为 Peewee 在现有模型实例上执行操作效果最佳:
240 if not count:
241 logger.error('Files not found for custodian')
242 elif source.endswith('.csv'):
243 write_csv(source, custodian_model)
244 elif source.endswith('.html'):
245 write_html(source, custodian_model)
246 elif not (source.endswith('.html') or \
247 source.endswith('.csv')):
248 logger.error('Could not determine file type')
249 else:
250 logger.error('Unknown Error Occurred')
简化 write_csv() 函数
write_csv() 函数使用了peewee库中的新方法,允许我们以字典形式从数据库中检索数据。通过使用熟悉的Files.select().where()语句,我们附加了dicts()方法,将结果转换为 Python 字典格式。这个字典格式非常适合作为我们的报告输入,因为内置的 CSV 模块有一个名为DictWriter的类。顾名思义,该类允许我们将字典信息作为一行数据写入 CSV 文件。现在,查询已经准备好,我们可以向用户日志输出,告知我们开始编写 CSV 报告:
253 def write_csv(source, custodian_model):
254 """
255 The write_csv function generates a CSV report from the Files
256 table
257 :param source: The output filepath
258 :param custodian_model: Peewee model instance for the
259 custodian
260 :return: None
261 """
262 query = Files.select().where(
263 Files.custodian == custodian_model.id).dicts()
264 logger.info('Writing CSV report')
接下来,我们定义 CSV 写入器的列名,并使用with...as...语句打开用户指定的输出文件。为了初始化csv.DictWriter类,我们传递打开的文件对象和与表格列名相对应的列头(因此也是字典键名)。初始化后,我们调用writeheader()方法,并在电子表格的顶部写入表头。最后,为了写入行内容,我们在查询对象上打开一个for循环,遍历各行并使用.writerow()方法将其写入文件。通过使用enumerate方法,我们可以每 10,000 行向用户提供一次状态更新,让他们知道在处理较大文件报告时,我们的代码正在努力工作。在写入这些状态更新(当然,还有行内容)之后,我们会添加一些额外的日志消息给用户,并退出该函数。虽然我们调用了csv库,但请记住,实际上是我们导入了unicodecsv。这意味着在生成输出时,我们会遇到比使用标准csv库更少的编码错误:
266 cols = [u'id', u'custodian', u'file_name', u'file_path',
267 u'extension', u'file_size', u'ctime', u'mtime',
268 u'atime', u'mode', u'inode']
269
270 with open(source, 'wb') as csv_file:
271 csv_writer = csv.DictWriter(csv_file, cols)
272 csv_writer.writeheader()
273 for counter, row in enumerate(query):
274 csv_writer.writerow(row)
275 if counter % 10000 == 0:
276 logger.debug('{:,} lines written'.format(counter))
277 logger.debug('{:,} lines written'.format(counter))
278
279 logger.info('CSV Report completed: ' + source)
简化 write_html() 函数
我们将需要之前设计的get_template()函数来生成 HTML 报告。在第 291 行,我们调用了这个预构建的 Jinja2 模板对象,并将其存储在template变量中。在引用模板时,我们需要提供一个包含三个键的字典:table_headers、file_listing和custodian。这三个键是必需的,因为它们是我们在模板中选择的占位符。在第 292 行,我们将表头构建为一个字符串列表,按照希望显示的顺序格式化:
282 def write_html(source, custodian_model):
283 """
284 The write_html function generates an HTML report from the
285 Files table
286 :param source: The output file path
287 :param custodian_model: Peewee model instance for the
288 custodian
289 :return: None
290 """
291 template = get_template()
292 table_headers = [
293 'Id', 'Custodian', 'File Name', 'File Path',
294 'File Extension', 'File Size', 'Created Time',
295 'Modified Time', 'Accessed Time', 'Mode', 'Inode']
随后,我们通过使用与 CSV 函数中类似的 select 语句,创建了第 296 行的 file_data 列表,该列表为 file_listing 键提供数据。这个列表使我们能够在模板中单独访问各个属性,正如之前所指定的那样。我们本可以将这一逻辑也放入模板文件中,但我们认为最好将可变逻辑放在函数中,而不是模板中。看看第 296 和 297 行:
296 file_data = Files.select().where(
297 Files.custodian == custodian_model.id)
收集了这三项元素后,我们创建了一个字典,其键与第 299 行模板中的数据匹配。在日志语句之后,我们使用 with...as... 语句打开源文件。为了写入模板数据,我们在 template 对象上调用 render() 方法,将我们已构建的字典作为 kwarg 传递到第 307 行。render() 方法会评估模板中的语句和逻辑,并将提供的数据放置在正确的位置,以生成 HTML 报告。此方法还会将原始 HTML 作为字符串返回,因此我们将其封装在 write() 调用中,立即将数据写入文件。写入完成后,我们记录源文件的路径以及其成功完成的信息:
299 template_dict = {
300 'custodian': custodian_model,
301 'table_headers': table_headers,
302 'file_listing': file_data}
303
304 logger.info('Writing HTML report')
305
306 with open(source, 'w') as html_file:
307 html_file.write(template.render(**template_dict))
308
309 logger.info('HTML Report completed: ' + source)
运行我们新的改进版脚本
这一迭代突出了使用额外的 Python 第三方库来处理我们之前以更手动方式执行的许多操作。在这个实例中,我们使用了 Peewee 和 Jinja2 来进一步自动化数据库管理和 HTML 报告生成。这两个库是处理此类数据的流行方法,并且已打包到其他 Python 套件中,或者有移植版本,例如 Flask 和 Django。
此外,这一迭代与第一次迭代非常相似,旨在更清楚地展示两种方法之间的差异。本书的目标之一是尽可能多地介绍 Python 中执行任务的方法。本章的目的不是创建一个更好的迭代版本,而是展示不同的方法来完成相同的任务,并为我们的工具箱添加新技能。这是我们将创建多个脚本迭代的最后一章;接下来的章节将专注于更具扩展性的单一脚本,随着我们开始扩展法医编码能力。
请注意,我们执行脚本的方式没有改变。我们仍然需要指定一个管理员、数据库路径和模式类型。你可能会注意到,这个脚本比我们之前的脚本要慢得多。有时,使用自动化解决方案时,我们的代码可能会受到额外开销或模块实现效率低下的影响。在这里,通过放弃更简洁的手动处理过程,我们失去了一些效率。然而,这个脚本更易于维护,并且不需要开发人员深入了解 SQL。
在这一轮中,我们选择生成基于 Bootstrap 的 HTML 报告。尽管该报告在分析能力方面有所欠缺,但它在可移植性和简洁性上有了提升。得益于 Bootstrap,这是一个专业的页面,可以搜索特定的感兴趣文件,或打印出来供喜欢纸笔方法的人使用:
挑战
一如既往,我们鼓励你为这个脚本添加新功能,并利用你掌握的知识和可用资源来扩展它。在本章中,我们首先挑战你使用 MD5 或 SHA1 对索引文件进行哈希处理,并将该信息存储在数据库中。你可以使用内置的hashlib库来处理哈希操作;更多关于哈希处理和其他技术的内容,请参考第七章,模糊哈希。
此外,考虑为集合添加用户指定的过滤器,针对特定文件扩展名进行筛选。这些功能可以在不对代码进行重大修改的情况下实现,尽管你可能会发现从头开始并结合这些新功能来构建脚本对你理解更为容易且更有益。
我们可以向代码中添加的一个扩展是将文件的模式解析为单独的列,以便于在数据库和报告中查询。尽管我们存储的数字是紧凑的,且格式通常是可以理解的,但将值拆分为单独的列可以帮助非技术人员审查这些文件的属性,并在我们想要识别具有特定权限集的所有文件时,方便地对数据库进行查询。我们可以在集合模块中执行此操作,或保持当前的数据库架构,在生成报告时解释这些模式。
总结
本章重点讨论了在脚本开发中使用数据库。我们探索了如何在 Python 中使用和操作 SQLite 数据库,以存储和检索文件列表信息。我们讨论了何时以及如何使用数据库作为存储这些信息的正确解决方案,因为它具有固定的数据结构,并且可能是一个大型数据集。
此外,我们讨论了与数据库交互的多种方法,包括手动过程,展示数据库如何在较低层次上工作,以及一个更加 Pythonic 的示例,其中第三方模块为我们处理这些低级别的交互。我们还探索了一种新的报告类型,使用 HTML 创建一种可以在没有额外软件的情况下查看的不同输出,并根据需要操作它,以添加新的样式和功能。总体而言,本节的内容建立在展示我们如何使用 Python 及其支持库来解决取证挑战的基础目标之上。本项目的代码可以从 GitHub 或 Packt 下载,具体说明请见前言。
在下一章中,我们将学习如何使用第三方库解析二进制数据和注册表蜂窝。学习如何解析二进制数据将成为数字取证开发者的一项基础技能,并且将在本书剩余章节中介绍的许多库中得到应用。
第六章:从二进制文件中提取工件
解析二进制数据是一个不可或缺的技能。我们不可避免地会遇到需要分析的不熟悉或未记录的工件。当感兴趣的文件是二进制文件时,这个问题更加复杂。与分析类文本文件不同,我们通常需要使用我们最喜欢的十六进制编辑器来开始逆向工程文件的内部二进制结构。逆向工程二进制文件的底层逻辑超出了本章的讨论范围。相反,我们将使用一个已经熟知结构的二进制对象。这将使我们能够突出展示如何在理解内部结构后,使用 Python 自动解析这些二进制结构。在本章中,我们将检查来自 NTUSER.DAT 注册表配置单元的 UserAssist 注册表项。
本章展示了如何从二进制数据中提取 Python 对象并生成自动化 Excel 报告。我们将使用三个模块来完成这项任务:struct、yarp 和 xlsxwriter。虽然 struct 模块包含在 Python 的标准安装中,但 yarp 和 xlsxwriter 必须单独安装。我们将在各自的章节中讲解如何安装这些模块。
struct 库用于将二进制对象解析为 Python 对象。一旦我们从二进制对象中解析出数据,就可以将我们的发现写入报告中。在过去的章节中,我们已经将结果报告在 CSV 或 HTML 文件中。在本章中,我们将创建一个包含数据表格和汇总图表的 Excel 报告。
本章将涵盖以下主题:
-
理解
UserAssist工件及其二进制结构 -
ROT-13 编码与解码简介
-
使用
yarp模块安装和操作注册表文件 -
使用
struct从二进制数据中提取 Python 对象 -
使用
xlsxwriter创建工作表、表格和图表
本章的代码是在 Python 2.7.15 和 Python 3.7.1 上开发和测试的
UserAssist
UserAssist 工件标识 图形用户界面(GUI)应用程序在 Windows 机器上的执行。根据 Windows 操作系统的版本,这个工件存储的信息量不同。为了识别特定应用程序的数据,我们必须解码注册表项名称,因为它是作为 ROT13 编码的路径和应用程序名称存储的。例如,Windows XP 和 Vista 的 UserAssist 值数据长度为 16 字节,存储如下信息:
-
最后执行时间的 UTC 信息(以 FILETIME 格式)
-
执行计数
-
会话 ID
最后执行时间信息以 Windows FILETIME 对象的形式存储。这是另一种常见的时间表示方法,区别于我们在之前章节中看到的 UNIX 时间戳。我们将在本章后面展示如何在 Python 中解读这个时间戳并以人类可读的形式显示。执行计数表示应用程序被启动的次数。
Windows 7 及更高版本存储的数据比它们的前身更多。Windows 7 的UserAssist值长度为 72 字节,并且除了前述的三个艺术品外,还存储以下内容:
-
焦点计数
-
焦点时间
焦点计数是将应用程序点击以将其带回焦点的次数。例如,当您打开两个应用程序时,只有一个应用程序在给定时间内处于焦点状态。另一个应用程序在再次点击它之前处于非活动状态。焦点时间是给定应用程序处于焦点状态的总时间,以毫秒表示。
此注册表项不存储基于命令行的程序或 Windows 启动程序的 GUI 应用程序的执行。
UserAssist注册表键位于每个用户主目录的根文件夹中找到的NTUSER.DAT注册表中。在这个注册表中,UserAssist键位于SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\UserAssist。UserAssist键的子键包括已知的 GUID 及其各自的计数子键。在每个 GUID 的计数子键中,可能有与程序执行相关的多个值。该结构如下所示:
SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\UserAssist
.{GUID_1}
..Count
.{GUID_2}
..Count
计数子键内的值存储了我们感兴趣的应用程序执行信息。计数子键下每个值的名称表示了 ROT-13 编码的可执行文件的路径和名称。这使得一眼难以识别可执行文件。让我们修复这个问题。
理解 ROT-13 替换密码 - rot13.py
ROT-13 是一种简单的替换密码,它转换文本并用后面的十三个字符替换每个字符。例如,字母a将被替换为字母n,反之亦然。数字、特殊字符和字符的大小写在密码的影响下不变。虽然 Python 确实提供了一种内置的解码 ROT-13 的方式,但我们假装它不存在,并手动解码 ROT-13 数据。我们将在脚本中使用内置的 ROT-13 解码方法。
在我们假装这个功能不存在之前,让我们快速使用它来说明如何使用 Python 2 对 ROT-13 数据进行编码和解码:
>>> original_data = 'Why, ROT-13?'
>>> encoded_data = original_data.encode('rot-13')
>>> print encoded_data
Jul, EBG-13?
>>> print encoded_data.decode('rot-13')
Why, ROT-13?
在 Python 3 中,使用原生的codecs库对 ROT-13 进行解码或编码需要稍微不同的方法:
>>> import codecs
>>> enc = codecs.getencoder('rot-13')
>>> enc('Why, ROT-13?')
('Jul, EBG-13?', 12)
>>> enc('Why, ROT-13?')[0]
'Jul, EBG-13?'
现在,让我们看看如果没有内置功能,您可能会如何处理这个问题。虽然你不应该重复造轮子,但我们希望借此机会练习列表操作并介绍一个用于审计代码的工具。本章代码包中的rot13.py脚本中的代码如下所示。
第 32 行定义的rot_code()函数接受一个经过 ROT-13 编码或解码的字符串。在第 39 行,我们有rot_chars,一个包含字母表中字符的列表。当我们遍历输入的每个字符时,我们将使用该列表将字符替换为其相隔 13 个元素的对照字符。在执行替换时,我们会将其存储在第 43 行初始化的替换列表中:
032 def rot_code(data):
033 """
034 The rot_code function encodes/decodes data using string
035 indexing
036 :param data: A string
037 :return: The rot-13 encoded/decoded string
038 """
039 rot_chars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
040 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
041 'u', 'v', 'w', 'x', 'y', 'z']
042
043 substitutions = []
在第 46 行,我们开始遍历数据字符串中的每个字符c。在第 49 行,我们使用条件语句来判断字符是大写字母还是小写字母。这样做是为了在处理时保留字符的大小写:
045 # Walk through each individual character
046 for c in data:
047
048 # Walk through each individual character
049 if c.isupper():
在第 54 行,我们尝试识别字符在列表中的索引。如果字符是非字母字符,我们将收到一个ValueError异常。数字或特殊字符等非字母字符将不做任何修改地添加到替换列表中,因为这些类型的值不会被 ROT-13 编码:
051 try:
052 # Find the position of the character in
053 # rot_chars list
054 index = rot_chars.index(c.lower())
055 except ValueError:
056 substitutions.append(c)
057 continue
一旦找到字符的索引,我们就可以通过减去 13 来计算相隔 13 个字符的对应索引。对于小于 13 的值,这将是一个负数。幸运的是,列表索引支持负数,并且在这里表现得非常好。在将对应的字符添加到替换列表之前,我们使用字符串的upper()函数将字符恢复为其原始大小写:
059 # Calculate the relative index that is 13
060 # characters away from the index
061 substitutions.append(
062 (rot_chars[(index-13)]).upper())
条件语句的else部分处理小写字符。以下代码块的功能与我们刚刚讨论的基本相同。不同之处在于,我们从不使用小写或大写字母,因为字符已经处于正确的大小写格式,便于处理:
064 else:
065
066 try:
067 # Find the position of the character in
068 # rot_chars list
069 index = rot_chars.index(c)
070 except ValueError:
071 substitutions.append(c)
072 continue
073
074 substitutions.append(rot_chars[((index-13))])
最后,在第 76 行,我们使用join()方法将替换列表合并为一个字符串。我们在空字符串上进行连接,这样列表中的每个元素都会被附加,而没有任何分隔符。如果从命令行调用此脚本,它将输出处理后的字符串Jul, EBG-13?,我们知道它对应的是ROT-13?。我们有以下代码:
076 return ''.join(substitutions)
077
078 if __name__ == '__main__':
079 print(rot_code('Jul, EBG-13?'))
以下截图展示了我们如何导入rot13模块并调用rot_code()方法来解码或编码字符串:
确保 Python 交互式提示符与rot13.py脚本在同一目录下打开。否则,将会生成ImportError错误。
使用 timeit 评估代码
现在让我们审视一下我们的模块,看看它是否优于内置方法(剧透:并不是!)我们曾提到,除非绝对必要,否则你不应重新发明轮子。这样做是有充分理由的:大多数内置或第三方解决方案已经过性能和安全性优化。我们的rot_code()函数与内置函数相比如何呢?我们可以使用timeit模块来计算一个函数或代码行执行所需的时间。
让我们比较两种解码 ROT-13 值的方法之间的差异。通过向 Python 解释器提供-m选项,如果其父目录在sys.path列表中,则会执行指定的模块。timeit模块可以直接从命令行通过-m选项调用。
我们可以通过导入sys模块并打印sys.path来查看哪些目录在作用范围内。为了扩展sys.path中可用的项目,我们可以使用列表属性(如 append 或 extend)将新项目追加到其中。
timeit模块支持多种开关,并可以用于运行单行代码或整个脚本。-v开关打印更详细的输出,且当提供更多的v开关时,输出会越来越详细。-n开关是执行代码或脚本的次数(例如,每个测量周期内的执行次数)。我们可以使用-r开关来指定重复测量的次数(默认为3)。增加这个值可以让我们计算出更精确的平均执行速度。最后,-s开关是一个在第一次执行时运行的语句,用于让我们导入制作的脚本。有关更多文档,请访问docs.python.org/3/library/timeit.html或运行python -m timeit -h。
当我们在计算两种方法的执行时间时,计算机生成的输出如以下截图所示。性能可能会因机器不同而有所差异。在我们的第一次测试中,我们测量了运行三次一百万次脚本所需的时间。在第一次循环中,我们先导入了我们的模块rot13,然后才调用它。在第二次测试中,我们同样测量了 Python 2 内置decode()函数的三次一百万循环:
事实证明,不必重新发明轮子是有充分理由的。我们的自定义rot_code()函数在运行一千次时明显比内置方法慢。我们可能不会调用这个函数一千次;对于UserAssist键,这个函数可能只会调用几百次。然而,如果我们处理更多数据或脚本特别慢,我们就可以开始对单独的函数或代码行进行计时,找出那些优化不良的代码。
顺便提一句,你还可以在函数调用前后使用time.time()函数,通过减去两个时间点来计算经过的时间。这种替代方法实现起来稍微简单一些,但没有那么健壮。
你现在已经了解了UserAssist工件、ROT-13 编码以及审计我们代码的机制。让我们转移焦点,看看本章中将要使用的其他模块。其中一个模块yarp将用于访问和交互UserAssist键和值。
使用 yarp 库
yarp(即Yet Another Registry Parser)库可以用来从注册表 hive 中获取键和值。Python 提供了一个名为_winreg的内置注册表模块;然而,这个模块仅在 Windows 机器上工作。_winreg模块与运行该模块的系统上的注册表进行交互。它不支持打开外部的注册表 hive。
yarp库允许我们与提供的注册表 hive 进行交互,并且可以在非 Windows 机器上运行。yarp库可以从github.com/msuhanov/yarp下载。在该项目的 GitHub 页面上,点击发布部分,可以看到所有稳定版本的列表,并下载所需的版本。对于本章,我们使用的是 1.0.25 版本。下载并解压缩归档文件后,我们可以运行其中的setup.py文件来安装模块。在命令提示符中,在模块的顶级目录执行以下代码:
python setup.py install
这应该能够成功地在你的机器上安装yarp库。我们可以通过打开 Python 交互式提示符并输入import yarp来确认。如果模块没有成功安装,我们将收到一个错误。安装了yarp后,接下来我们可以开始学习如何利用这个模块满足我们的需求。
首先,我们需要从yarp模块中导入Registry类。然后,使用RegistryHive函数并将其传递给我们想要查询的注册表对象。在这个例子中,我们已将NTUSER.DAT注册表文件复制到当前工作目录,这样我们只需要提供文件名而不需要路径。接下来,我们使用find_key方法来导航到我们感兴趣的键。在这个例子中,我们关注的是RecentDocs注册表键。这个键包含按扩展名分隔的最近活动文件:
>>> from yarp import Registry
>>> reg_file = open('NTUSER.DAT', 'rb')
>>> reg = Registry.RegistryHive(reg_file)
>>> recent_docs = reg.find_key('SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\RecentDocs')
如果我们打印recent_docs变量,我们可以看到它包含 151 个值和 75 个子键,这些子键可能包含附加的值和子键。此外,我们还可以使用last_written_timestamp()方法查看注册表键的最后写入时间:
>>> print(recent_docs)
RegistryKey, name: RecentDocs, subkeys: 75, values: 151
>>> print(recent_docs.last_written_timestamp()) # Last Written Time
datetime.datetime(2018, 11, 20, 3, 14, 40, 286516)
我们可以使用subkeys()函数在recent_docs键的值上进行迭代,使用 for 循环遍历。对于每个值,我们可以访问name()、value()和values_count()方法等。访问值时(与访问子键不同),我们还可以通过使用raw_data()函数访问该值的原始数据。对于我们的目的,当我们想处理底层的二进制数据时,会使用raw_data()函数。我们有如下代码:
>>> for i, value in enumerate(recent_docs.subkeys()):
... print('{}) {}: {}'.format(i, value.name(), value.values_count()))
...
0) .001: 2
1) .1: 2
2) .7z: 2
3) .AAE: 2
...
yarp模块的另一个有用功能是提供了查询特定子键或值的方式。这可以通过subkey()、value()或find_key()函数来实现。如果使用subkey()函数时找不到子键,则会生成None值:
>>> if recent_docs.subkey('.docx'):
... print('Found docx subkey.')
...
Found docx subkey.
>>> if recent_docs.subkey('.1234abcd') is None:
... print('Did not find 1234abcd subkey.')
...
None
find_key() 函数接受一个路径,并能够通过多级递归查找子键。subkey() 和 value() 函数仅搜索子元素。我们可以使用这些函数在尝试导航到键或值之前确认它们是否存在。yarp 还提供了其他一些相关功能,本文未涵盖,包括恢复已删除的注册表键和值、提取注册表键和值以及支持事务日志文件。
使用 yarp 模块,查找键及其值非常简单。然而,当值不是字符串而是二进制数据时,我们必须依赖另一个模块来理清数据。对于所有二进制相关的需求,struct 模块是一个极好的选择。
介绍 struct 模块
struct 模块是 Python 标准库的一部分,非常有用。struct 库用于将 C 结构转换为二进制数据,或将二进制数据转换为 C 结构。该模块的完整文档可以在 docs.python.org/3/library/struct.html 找到。
出于取证目的,struct 模块中最重要的函数是 unpack() 方法。该方法接受一个格式字符串,表示要从二进制数据中提取的对象。格式字符串指定的大小必须与传递给函数的二进制数据的大小相符。
格式字符串告诉 unpack() 函数二进制对象中包含的数据类型及其解释方式。如果我们没有正确识别数据类型或尝试解包的数据比提供的数据多或少,struct 模块将抛出异常。以下是构建格式字符串时最常用字符的表格。标准大小列表示二进制对象的预期大小(以字节为单位):
| 字符 | Python 对象 | 标准大小(字节) |
|---|---|---|
h | 整数 | 2 |
i | 整数 | 4 |
q | 整数 | 8 |
s | 字符串 | 1 |
x | 不适用 | 不适用 |
格式字符串中还可以使用其他字符。例如,其他字符可以将二进制数据解释为浮点数、布尔值以及其他各种 C 结构。x 字符仅仅是一个填充字符,用于忽略我们不关心的字节。
此外,还可以使用可选的起始字符来定义字节顺序、大小和对齐方式。默认情况下是本地字节顺序、大小和对齐方式。由于我们无法预测脚本可能运行的环境,因此通常不建议使用任何本地选项。相反,我们可以使用 < 和 > 符号分别指定小端或大端字节顺序和标准大小。让我们通过几个例子来实践。
首先,打开一个交互式提示符并导入struct模块。接下来,我们将 0x01000000 赋值给一个变量。在 Python 3 中,十六进制表示法通过转义字符和每两个十六进制字符前的x来指定。我们的十六进制数据长度为四个字节,为了将其解释为一个整数,我们使用i字符。将十六进制数据解释为小端整数时,返回的值是1:
>>> import struct
>>> raw_data = b'\x01\x00\x00\x00' # Integer (1)
>>> print(struct.unpack('<i', raw_data)) # Little-Endian
(1,)
<i和>i表示字符串格式。我们告诉unpack()方法将raw_data解释为一个四字节整数,按照小端或大端字节顺序。struct模块将解包的数据作为元组返回。默认情况下,Python 会将一个单一元素的元组打印在括号中,并带有尾随逗号,如以下输出所示:
>>> print(struct.unpack('>i', raw_data)) # Big-Endian
(16777216,)
>>> print(type(struct.unpack('>i', raw_data)))
<class 'tuple'>
让我们看另一个例子。我们可以通过使用三个i字符将rawer_data解释为三个 4 字节整数。或者,我们可以在格式字符前加上数字,以便按顺序解析多个值。在这两种情况下,当按小端方式解释时,我们得到整数1、5和4。如果我们不关心中间的整数,可以使用4x字符跳过它:
>>> rawer_data = b'\x01\x00\x00\x00\x05\x00\x00\x00\x04\x00\x00\x00'
>>> print(struct.unpack('<iii', rawer_data))
(1, 5, 4)
>>> print(struct.unpack('<3i', rawer_data))
(1, 5, 4)
>>> print(struct.unpack('<i4xi', rawer_data)) # "skip" 4 bytes
(1, 4)
我们在本节早些时候提到了使用struct时可能出现的错误。现在,让我们故意制造一些错误,以理解它们的含义。以下两个例子会出现错误,因为我们试图unpack()的数据量比rawer_data变量中实际存在的值多或少。这在尝试解包大量二进制数据时可能会导致一些初步的挫败感。务必检查数学计算、字节顺序以及大小是否为标准或本地格式:
>>> print(struct.unpack('<4i', rawer_data))
struct.error: unpack requires a buffer of 16 bytes
>>> print(struct.unpack('<2i', rawer_data))
struct.error: unpack requires a buffer of 8 bytes
让我们更进一步,使用struct模块解析一个UserAssist值。我们将解析一个 Windows XP 的值,这是最简单的情况,因为它仅为 16 个字节。Windows XP UserAssist 值的字节偏移量记录在以下表中:
| 字节偏移量 | 值 | 对象 |
|---|---|---|
| 0-3 | 会话 ID | 整数 |
| 4-7 | 计数 | 整数 |
| 8-15 | FILETIME | 整数 |
以下十六进制转储保存到文件Neguhe Qrag.bin中。该文件与代码包一起提供,可以从packtpub.com/books/content/support下载:
0000: 0300 0000 4800 0000 |....H...
0010: 01D1 07C4 FA03 EA00 |........
在从文件对象解包数据时,我们需要以rb模式打开文件,而不是默认的r模式,以确保可以将数据作为字节读取。获取原始数据后,我们可以使用特定的字符格式解析它。我们知道前 8 个字节是两个 4 字节整数(2i),然后是一个 8 字节整数(q),表示UserAssist值的 FILETIME。我们可以对返回的元组进行索引,打印出每个提取的整数:
>>> rawest_data = open('Neguhe Qrag.bin', 'rb').read()
>>> parsed_data = struct.unpack('<2iq', rawest_data)
>>> print('Session ID: {}, Count: {}, FILETIME: {}'.format(parsed_data[0], parsed_data[1], parsed_data[2]))
...
Session ID: 3, Count: 72, FILETIME: 6586952011847425
一旦我们在脚本中解析了UserAssist值,我们将以报告准备好的格式呈现结果。过去,我们使用了 CSV 和 HTML 格式的输出报告。报告通常会以电子表格格式进行审查,使用像 Microsoft Excel 这样的软件。为了提供充分利用此软件的报告,我们将学习如何将 XSLX 格式的电子表格作为脚本的输出。
使用 xlsxwriter 模块创建电子表格
xlsxwriter(版本 1.1.2)是一个有用的第三方模块,可以将数据写入 Excel 电子表格。Python 有很多支持 Excel 的模块,但我们选择了这个模块,因为它非常强大且文档齐全。顾名思义,这个模块只能用于写入 Excel 电子表格。xlsxwriter模块支持单元格和条件格式、图表、表格、过滤器和宏等功能。这个模块可以通过pip安装:
pip install xlsxwriter==1.1.2
向电子表格中添加数据
让我们快速创建一个名为simplexlsx.v1.py的脚本来演示这个例子。在第 2 和第 3 行,我们导入了xlsxwriter和datetime模块。我们将要绘制的数据,包括列名,存储在school_data变量中的嵌套列表中。每个列表是一行信息,我们希望将其存储在 Excel 电子表格中,第一项包含列名:
002 import xlsxwriter
003 from datetime import datetime
...
033 school_data = [['Department', 'Students', 'Cumulative GPA',034 'Final Date'],
035 ['Computer Science', 235, 3.44,036 datetime(2015, 7, 23, 18, 0, 0)],
037 ['Chemistry', 201, 3.26,038 datetime(2015, 7, 25, 9, 30, 0)],
039 ['Forensics', 99, 3.8,040 datetime(2015, 7, 23, 9, 30, 0)],
041 ['Astronomy', 115, 3.21,042 datetime(2015, 7, 19, 15, 30, 0)]]
write_xlsx()函数定义在第 45 行,负责将数据写入电子表格。首先,我们必须使用Workbook()函数创建 Excel 电子表格,并提供所需的文件名作为输入。在第 53 行,我们使用add_worksheet()函数创建工作表。此函数可以接受所需的工作表标题,或者使用默认名称Sheet N,其中N表示一个数字:
045 def write_xlsx(data):
046 """
047 The write_xlsx function creates an XLSX spreadsheet from a
048 list of lists
049 :param data: A list of lists to be written in the spreadsheet
050 :return: Nothing
051 """
052 workbook = xlsxwriter.Workbook('MyWorkbook.xlsx')
053 main_sheet = workbo
ok.add_worksheet('MySheet')
date_format变量存储我们将用来显示datetime对象的自定义数字格式,以便以所需的易读格式显示它们。在第 58 行,我们开始遍历数据以进行写入。第 59 行的条件用于处理数据列表中的第一项,即列名。我们使用write()函数并提供数值型的行和列。例如,我们也可以使用 Excel 符号表示法,如A1,表示数据应写入第一列和第一行,而不是使用数值来表示列和行:
055 date_format = workbook.add_format(
056 {'num_format': 'mm/dd/yy hh:mm:ss AM/PM'})
057
058 for i, entry in enumerate(data):
059 if i == 0:
060 main_sheet.write(i, 0, entry[0])
061 main_sheet.write(i, 1, entry[1])
062 main_sheet.write(i, 2, entry[2])
063 main_sheet.write(i, 3, entry[3])
write()方法将尝试在检测到数据类型时写入适当的对象数据类型。然而,我们可以使用不同的写入方法来指定正确的格式。这些专用的写入方法会保留数据类型,以便我们可以使用适当的数据类型特定的 Excel 函数处理对象。由于我们知道条目列表中的数据类型,我们可以手动指定何时使用通用的write()函数,何时使用write_number()函数:
064 else:
065 main_sheet.write(i, 0, entry[0])
066 main_sheet.write_number(i, 1, entry[1])
067 main_sheet.write_number(i, 2, entry[2])
对于列表中的第四个条目,即 datetime 对象,我们向 write_datetime() 函数传入第 55 行定义的 date_format。数据写入工作簿后,我们使用 close() 函数关闭并保存电子表格。在第 73 行,我们调用 write_xlsx() 函数,并将之前构建的 school_data 列表传入,代码如下:
068 main_sheet.write_datetime(i, 3, entry[3], date_format)
069
070 workbook.close()
071
072
073 write_xlsx(school_data)
下表列出了 write 函数及其保留的对象:
| 函数 | 支持的对象 |
|---|---|
write_string | str |
write_number | int、float、long |
write_datetime | datetime 对象 |
write_boolean | bool |
write_url | str |
当脚本在命令行中被调用时,会创建一个名为 MyWorkbook.xlsx 的电子表格。当我们将其转换为表格时,我们可以按任何值进行排序,并使用我们都熟悉的 Excel 函数和功能。如果我们没有保留数据类型,像日期这样的值可能会显示得与预期不同:
构建表格
能够将数据写入 Excel 文件并保留对象类型,已经比 CSV 有所改进,但我们还能做得更好。通常,检查员在处理 Excel 电子表格时首先会做的就是将数据转换为表格,并开始对数据集进行排序和筛选。但我们可以使用 xlsxwriter 将数据范围转换为表格。事实上,用 xlsxwriter 写入表格可以说比逐行写入要容易。本文讨论的代码体现在 simplexlsx.v2.py 文件中。
在这一版本中,我们删除了 school_data 变量中包含列名的初始列表。我们新的 write_xlsx() 函数单独写入表头,稍后我们将看到这一点:
034 school_data = [['Computer Science', 235, 3.44,035 datetime(2015, 7, 23, 18, 0, 0)],
036 ['Chemistry', 201, 3.26,037 datetime(2015, 7, 25, 9, 30, 0)],
038 ['Forensics', 99, 3.8,039 datetime(2015, 7, 23, 9, 30, 0)],
040 ['Astronomy', 115, 3.21,041 datetime(2015, 7, 19, 15, 30, 0)]]
第 44 到 55 行与函数的前一版本相同。我们将表格写入电子表格的操作在第 58 行完成。请查看以下代码:
044 def write_xlsx(data):
045 """
046 The write_xlsx function creates an XLSX spreadsheet from a
047 list of lists
048 :param data: A list of lists to be written in the spreadsheet
049 :return: Nothing
050 """
051 workbook = xlsxwriter.Workbook('MyWorkbook.xlsx')
052 main_sheet = workbook.add_worksheet('MySheet')
053
054 date_format = workbook.add_format(
055 {'num_format': 'mm/dd/yy hh:mm:ss AM/PM'})
add_table() 函数接受多个参数。首先,我们传入一个表示表格左上角和右下角单元格的字符串,采用 Excel 表示法。我们使用在第 56 行定义的length变量来计算表格所需的长度。第二个参数稍微有些复杂;这是一个字典,包含两个键:data 和 columns。data 键对应我们的 data 变量的值,在这个例子中,它可能命名得不好。columns 键定义了每个列头,并且可以选择性地定义其格式,如第 62 行所示:
056 length = str(len(data) + 1)
057
058 main_sheet.add_table(('A1:D' + length),
059 {'data': data,
060 'columns': [{'header': 'Department'}, {'header': 'Students'},
061 {'header': 'Cumulative GPA'},
062 {'header': 'Final Date', 'format': date_format}]})
063
064 workbook.close()
与前一个示例相比,我们用更少的代码行创建了一个更有用的输出,且它是以表格形式构建的。现在,我们的电子表格已经将指定的数据转换成了表格,并准备好进行排序。
在构建表格时,还可以提供更多的键和值。有关高级用法的更多详细信息,请查阅文档(xlsxwriter.readthedocs.org)。
当我们处理表示工作表每一行的嵌套列表时,这个过程非常简单。对于不符合这种格式的数据结构,我们需要结合我们之前迭代中演示的两种方法,以实现相同的效果。例如,我们可以定义一个跨越特定行和列的表格,然后使用 write() 函数写入这些单元格。然而,为了避免不必要的麻烦,我们建议在可能的情况下保持数据为嵌套列表格式。
使用 Python 创建图表
最后,让我们使用 xlsxwriter 创建一个图表。该模块支持多种不同类型的图表,包括线形图、散点图、条形图、柱状图、饼图和区域图。我们使用图表来以有意义的方式总结数据。这在处理大数据集时特别有用,能够让分析人员在深入分析之前先对数据有个初步的了解。
让我们再次修改上一个版本,以显示图表。我们将把这个修改过的文件保存为 simplexlsx.v3.py。在第 65 行,我们将创建一个名为 department_grades 的变量。这个变量将是我们通过 add_chart() 方法创建的图表对象。对于这个方法,我们传入一个字典,指定键和值。在这种情况下,我们指定图表的类型为柱状图:
065 department_grades = workbook.add_chart({'type':'column'})
在第 66 行,我们使用 set_title() 函数,再次传入一个参数字典。我们将名称键设置为我们想要的标题。此时,我们需要告诉图表要绘制哪些数据。我们通过 add_series() 函数来完成这一点。每个类别键都映射到 Excel 表示法,用于指定横轴数据。纵轴由 values 键表示。数据指定完成后,我们使用 insert_chart() 函数在电子表格中绘制数据。我们给这个函数传递一个字符串,表示将作为锚点的单元格,图表的左上角将绘制在此处:
066 department_grades.set_title(
067 {'name':'Department and Grade distribution'})
068 department_grades.add_series(
069 {'categories':'=MySheet!$A$2:$A$5',
070 'values':'=MySheet!$C$2:$C$5'})
071 main_sheet.insert_chart('A8', department_grades)
072 workbook.close()
运行此版本的脚本将把我们的数据转换为表格并生成一个柱状图,按部门比较它们的累计成绩。我们可以清楚地看到,不出意料,物理系在学校项目中拥有最高的 GPA 获得者。对于这样一个小型数据集,这些信息足够直观。但在处理更大规模的数据时,创建总结性图表将特别有助于理解全貌:
请注意,xlsxwriter 模块中有大量额外的功能,我们在脚本中不会使用到。这个模块非常强大,我们推荐在任何需要写入 Excel 电子表格的操作中使用它。
UserAssist 框架
我们的UserAssist框架由三个脚本组成,分别是userassist_parser.py、csv_writer.py和xlsx_writer.py。userassist_parser.py脚本处理大部分的处理逻辑,然后将结果传递给 CSV 或 XLSX 写入器。我们框架的目录结构如下所示。我们的写入器包含在一个名为Writers的目录中。请记住,为了让 Python 能够搜索到一个目录,它需要包含__init__.py文件。该文件可以为空,包含函数和类,或者包含在导入时执行的代码:
|-- userassist_parser.py
|-- Writers
|-- __init__.py
|-- csv_writer.py
|-- xlsx_writer.py
开发我们的 UserAssist 逻辑处理器 – userassist_parser.py
userassist_parser.py脚本负责处理用户输入、创建日志文件,并解析来自NTUSER.DAT文件的UserAssist数据。在第 2 到第 9 行,我们导入了熟悉的和新的模块来促进我们的任务。yarp和struct模块将分别允许我们访问并提取来自UserAssist二进制数据的对象。我们导入了位于Writers目录中的xlsx_writer和csv_writer模块。其他使用的模块在之前的章节中已经介绍过:
001 """UserAssist parser leveraging the YARP library."""
002 from __future__ import print_function
003 import argparse
004 import struct
005 import sys
006 import logging
007 import os
008 from Writers import xlsx_writer, csv_writer
009 from yarp import Registry
在第 45 行定义的KEYS变量作为一个空列表,将存储解析后的UserAssist值。第 48 行定义的main()函数将处理所有协调逻辑。它调用函数解析UserAssist键,并随后写入结果。create_dictionary()函数使用Registry模块查找并将UserAssist值名称和原始数据存储在每个 GUID 的字典中。
在第 134 行,我们定义了parse_values()函数,该函数使用struct处理每个UserAssist值的二进制数据。在此方法中,我们根据数据的长度来确定我们正在处理的是 Windows XP 还是 Windows 7 的UserAssist数据。get_name()函数是一个小函数,用于从完整路径中分离出可执行文件的名称:
045 KEYS = []
...
048 def main():
...
085 def create_dictionary():
...
134 def parse_values():
...
176 def get_name():
在第 202 到 212 行,我们创建了一个参数解析对象,它接受两个位置参数和一个可选参数。我们的REGISTRY输入是感兴趣的NTUSER.DAT文件。OUTPUT参数是所需输出文件的路径和文件名。可选的-l开关是日志文件的路径。如果没有提供此路径,日志文件将创建在当前工作目录中:
202 if __name__ == '__main__':
203 parser = argparse.ArgumentParser(description=__description__,
204 epilog='Developed by ' +
205 __author__ + ' on ' +
206 __date__)
207 parser.add_argument('REGISTRY', help='NTUSER Registry Hive.')
208 parser.add_argument('OUTPUT',
209 help='Output file (.csv or .xlsx)')
210 parser.add_argument('-l', help='File path of log file.')
211
212 args = parser.parse_args()
如果用户提供了日志路径,我们会在第 215 行检查该路径是否存在。如果路径不存在,我们使用os.makedirs()函数创建日志目录。无论哪种情况,我们都会使用提供的目录和日志文件实例化log_path变量。在第 220 行,我们创建日志并像之前的章节一样写入启动细节,然后在第 227 行调用main():
214 if args.l:
215 if not os.path.exists(args.l):
216 os.makedirs(args.l)
217 log_path = os.path.join(args.l, 'userassist_parser.log')
218 else:
219 log_path = 'userassist_parser.log'
220 logging.basicConfig(filename=log_path, level=logging.DEBUG,
221 format=('%(asctime)s | %(levelname)s | '
222 '%(message)s'), filemode='a')
223
224 logging.info('Starting UserAssist_Parser')
225 logging.debug('System ' + sys.platform)
226 logging.debug('Version ' + sys.version)
227 main(args.REGISTRY, args.OUTPUT)
以下流程图描述了我们UserAssist框架内相互关联的函数。在此图中,我们可以看到main()函数如何调用并接收来自create_dictionary()和parse_values()函数的数据。parse_values()函数分别调用了get_name()函数:
评估main()函数
main()函数在调用适当的方法以写入out_file之前,先将注册表文件发送进行处理。在第 61 行,我们调用create_dictionary()函数,创建一个包含UserAssist数据并映射到可执行文件名的字典列表:
048 def main(registry, out_file):
049 """
050 The main function handles main logic of script.
051 :param registry: Registry Hive to process
052 :param out_file: The output path and file
053 :return: Nothing.
054 """
055 if os.path.basename(registry).lower() != 'ntuser.dat':
056 print(('[-] {} filename is incorrect (Should be '
057 'ntuser.dat)').format(registry))
058 logging.error('Incorrect file detected based on name')
059 sys.exit(1)
060 # Create dictionary of ROT-13 decoded UA key and its value
061 apps = create_dictionary(registry)
接下来,这个字典被传递给parse_values()方法,该方法将解析后的数据附加到我们在第 45 行创建的KEYS列表中。此函数返回一个整数,表示解析的UserAssist数据类型。该函数对于 Windows XP 的UserAssist值返回0,对于 Windows 7 的返回1。我们记录此信息以便进行故障排除:
062 ua_type = parse_values(apps)
063
064 if ua_type == 0:
065 logging.info('Detected XP-based Userassist values.')
066
067 else:
068 logging.info(('Detected Win7-based Userassist values. '
069 'Contains Focus values.'))
一旦数据处理完成,它可以被发送到我们的写入程序。我们使用endswith()方法来识别用户提供的输出文件的扩展名。如果输出以.xlsx或.csv结尾,我们分别将数据发送到excel_writer()或csv_writer()函数,具体如下:
071 # Use .endswith string function to determine output type
072 if out_file.lower().endswith('.xlsx'):
073 xlsx_writer.excel_writer(KEYS, out_file)
074 elif out_file.lower().endswith('.csv'):
075 csv_writer.csv_writer(KEYS, out_file)
如果用户没有在输出文件中包括扩展名,我们会向日志写入警告,并将数据写入当前工作目录中的 CSV 文件。我们选择 CSV 输出,因为它代表了我们支持的输出格式中最简单且最具可移植性的选项。此外,如果用户希望在电子表格应用程序中检查数据,他们可以轻松导入并将 CSV 文档转换为 XLSX 格式:
076 else:
077 print(('[-] CSV or XLSX extension not detected in '
078 'output. Writing CSV to current directory.'))
079 logging.warning(('.csv or .xlsx output not detected. '
080 'Writing CSV file to current '
081 'directory.'))
082 csv_writer.csv_writer(KEYS, 'Userassist_parser.csv')
两个写入程序接受相同的参数:KEYS和out_file。KEYS列表(在第 45 行定义)是一个包含UserAssist字典的容器。我们将数据打包成字典列表,以便使用字典键来决定哪些头部是存在的。out_file是期望输出的路径和名称。
定义create_dictionary()函数
create_dictionary()函数为处理准备UserAssist数据。此函数提取每个UserAssist GUID 键中的所有值。它创建一个字典,其中键是经过 ROT-13 解码的可执行文件名,值是相应的二进制数据。现在提取这些二进制数据,以便稍后在其他函数中处理:
085 def create_dictionary(registry):
086 """
087 The create_dictionary function creates a list of dictionaries
088 where keys are the ROT-13 decoded app names and values are
089 the raw hex data of said app.
090 :param registry: Registry Hive to process
091 :return: apps_list, A list containing dictionaries for
092 each app
093 """
在第 97 行,我们尝试打开用户提供的注册表文件。如果访问输入文件时发生错误,我们会捕获该错误,记录日志,并以错误代码2优雅地退出:
094 try:
095 # Open the registry file to be parsed
096 registry_file = open(registry, "rb")
097 reg = Registry.RegistryHive(registry_file)
098 except (IOError, UnicodeDecodeError) as e:
099 msg = 'Invalid NTUSER.DAT path or Registry ID.'
100 print('[-]', msg)
101 logging.error(msg)
102 sys.exit(2)
如果我们能够打开注册表文件,我们接着尝试导航到UserAssist键。我们使用条件语句来捕捉UserAssist键未在提供的注册表文件中找到的情形。注意,对于此错误,我们使用整数3来区分与先前的退出情形:
104 # Navigate to the UserAssist key
105 ua_key = reg.find_key(
106 ('SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer'
107 '\\UserAssist'))
108 if ua_key is None:
109 msg = 'UserAssist Key not found in Registry file.'
110 print('[-]', msg)
111 logging.error(msg)
112 sys.exit(3)
在第 113 行,我们创建了一个名为 apps_list 的列表,用于存储 UserAssist 字典。如果我们能够找到 UserAssist 键,我们就会遍历每个 ua_subkey,一个 GUID,并检查它们的计数子键。这是一个重要的步骤;随着 Windows 的发展,UserAssist 键中添加了更多的 GUID。为了避免硬编码这些值,可能会遗漏未来 Windows 版本中新添加的 GUID,我们选择了一个更动态的过程,该过程将发现并处理跨多个版本的 Windows 中的新 GUID:
113 apps_list = []
114 # Loop through each subkey in the UserAssist key
115 for ua_subkey in ua_key.subkeys():
116 # For each subkey in the UserAssist key, detect a subkey
117 # called Count that has more than 0 values to parse.
该过程涉及检查每个具有名为Count的子键的 GUID,该子键存储实际的 UserAssist 应用程序值。在第 118 行,我们确定该 GUID 是否具有名为 Count 的子键,并且该子键具有一个或多个值。这确保我们找到了系统上所有的 UserAssist 值:
118 if(ua_subkey.subkey('Count') and
119 ua_subkey.subkey('Count').values_count() > 0):
我们在第 120 行创建了一个应用程序字典,并开始循环遍历 Count 子键下的每个值。对于每个值,我们将解码后的 ROT-13 名称作为键,并将其 raw_data 作为值进行关联。一旦 GUID 中的所有值都添加到字典中,它就会被附加到 apps_list 中,循环再次开始。一旦所有 GUID 都被处理完,我们的列表会返回到 main() 函数:
120 apps = {}
121 for v in ua_subkey.subkey('Count').values():
122 if sys.version_info[0] == 2:
123 apps[v.name().encode('utf-8').decode(
124 'rot-13')] = v.data_raw()
125 elif sys.version_info[0] == 3:
126 import codecs
127 enc = codecs.getencoder('rot-13')
128 apps[enc(str(v.name()))[0]] = v.data_raw()
129
130 apps_list.append(apps)
131 return apps_list
使用 parse_values() 函数提取数据
parse_values() 函数将 GUID 字典列表作为输入,并使用 struct 解析二进制数据。正如我们所讨论的,我们将支持两种类型的 UserAssist 键:Windows XP 和 Windows 7。以下两个表格分解了我们将解析的相关数据结构。基于 Windows XP 的键是 16 字节长,包含会话 ID、计数和 FILETIME 时间戳:
| 字节偏移量 | 值 | 对象 |
|---|---|---|
| 0-3 | 会话 ID | 整数 |
| 4-7 | 计数 | 整数 |
| 8-15 | FILETIME | 整数 |
基于 Windows 7 的数据是 72 字节长,包含会话 ID、计数、专注计数/时间和 FILETIME 时间戳:
| 字节偏移量 | 值 | 对象 |
|---|---|---|
| 0-3 | 会话 ID | 整数 |
| 4-7 | 计数 | 整数 |
| 8-11 | 专注计数 | 整数 |
| 12-15 | 专注时间 | 整数 |
| 16-59 | ??? | 不适用 |
| 60-67 | FILETIME | 整数 |
| 68-71 | ??? | 不适用 |
在第 143 行到第 146 行,我们通过实例化 ua_type 变量并记录执行状态来设置我们的函数。这个 ua_type 变量将用于记录我们正在处理的 UserAssist 值的类型。在第 148 行和第 149 行,我们遍历每个字典中的每个值,以识别其类型并进行解析:
134 def parse_values(data):
135 """
136 The parse_values function uses struct to unpack the raw value
137 data from the UA key
138 :param data: A list containing dictionaries of UA
139 application data
140 :return: ua_type, based on the size of the raw data from
141 the dictionary values.
142 """
143 ua_type = -1
144 msg = 'Parsing UserAssist values.'
145 print('[+]', msg)
146 logging.info(msg)
147
148 for dictionary in data:
149 for v in dictionary.keys():
在第 151 行和第 159 行,我们使用 len() 函数来识别 UserAssist 键的类型。对于基于 Windows XP 的数据,我们需要提取两个 4 字节的整数,后跟一个 8 字节的整数。我们还希望使用标准大小以小端字节顺序解释这些数据。在第 152 行,我们通过 <2iq 作为 struct 格式字符串来实现这一点。传递给 unpack 方法的第二个参数是来自 GUID 字典的特定键的原始二进制数据:
150 # WinXP based UA keys are 16 bytes
151 if len(dictionary[v]) == 16:
152 raw = struct.unpack('<2iq', dictionary[v])
153 ua_type = 0
154 KEYS.append({'Name': get_name(v), 'Path': v,
155 'Session ID': raw[0], 'Count': raw[1],
156 'Last Used Date (UTC)': raw[2],
157 'Focus Time (ms)': '', 'Focus Count': ''})
基于 Windows 7 的数据稍微复杂一些。二进制数据的中间和末尾部分包含我们不感兴趣的字节,但由于结构体的性质,我们必须在格式中考虑到它们。我们为此任务使用的格式字符串是 <4i44xq4x,它包含了四个 4 字节整数、44 字节的中间空隙、一个 8 字节整数,以及我们将忽略的剩余 4 字节:
158 # Win7 based UA keys are 72 bytes
159 elif len(dictionary[v]) == 72:
160 raw = struct.unpack('<4i44xq4x', dictionary[v])
161 ua_type = 1
162 KEYS.append({'Name': get_name(v), 'Path': v,
163 'Session ID': raw[0], 'Count': raw[1],
164 'Last Used Date (UTC)': raw[4],
165 'Focus Time (ms)': raw[3],'Focus Count': raw[2]})
在解析 UserAssist 记录时,我们将它们附加到 KEYS 列表中进行存储。当我们附加解析值时,我们用大括号将它们包裹起来,创建我们内部的字典对象。我们还对 UserAssist 值名调用 get_name() 函数,以分离可执行文件和其路径。请注意,无论 UserAssist 键的类型如何,我们都会在字典中创建相同的键。这将确保所有字典具有相同的结构,并有助于简化我们的 CSV 和 XLSX 输出函数。
如果 UserAssist 值不是 16 或 72 字节(这种情况是可能发生的),那么该值将被跳过,并且用户会被通知跳过的名称和大小。根据我们的经验,这些值与法医取证无关,因此我们决定跳过它们。在第 173 行,UserAssist 类型被返回到 main() 函数:
166 else:
167 # If the key is not WinXP or Win7 based -- ignore.
168 msg = 'Ignoring {} value that is {} bytes'.format(
169 str(v), str(len(dictionary[v])))
170 print('[-]', msg)
171 logging.info(msg)
172 continue
173 return ua_type
使用 get_name() 函数处理字符串
get_name() 函数使用字符串操作将可执行文件与路径名分离。从测试中我们发现路径中包含冒号、反斜杠或两者兼有。由于这种模式的存在,我们可以尝试使用这些字符来分割路径信息,从中提取出文件名:
176 def get_name(full_name):
177 """
178 the get_name function splits the name of the application
179 returning the executable name and ignoring the
180 path details.
181 :param full_name: the path and executable name
182 :return: the executable name
183 """
在第 185 行,我们检查 full_name 变量中是否同时包含冒号和反斜杠。如果为真,我们使用 rindex() 函数获取两个元素的最右侧子字符串的索引。在第 187 行,我们检查冒号的最右侧索引是否出现在反斜杠的后面。使用最大索引的元素作为 split() 函数的分隔符。为了获得列表中的最后一个子字符串(即我们的可执行文件名),我们使用 -1 索引:
184 # Determine if '\\' and ':' are within the full_name
185 if ':' in full_name and '\\' in full_name:
186 # Find if ':' comes before '\\'
187 if full_name.rindex(':') > full_name.rindex('\\'):
188 # Split on ':' and return the last element
189 # (the executable)
190 return full_name.split(':')[-1]
191 else:
192 # Otherwise split on '\\'
193 return full_name.split('\\')[-1]
在第 196 行和第 198 行,我们处理了替代场景,并在冒号或反斜杠处进行分割,然后返回子字符串列表中的最后一个元素:
194 # When just ':' or '\\' is in the full_name, split on
195 # that item and return the last element (the executable)
196 elif ':' in full_name:
197 return full_name.split(':')[-1]
198 else:
199 return full_name.split('\\')[-1]
这完成了我们在 userassist_parser.py 脚本中的逻辑。现在,让我们将注意力转向负责以有用格式写入解析数据的两个写入函数。
写入 Excel 电子表格 – xlsx_writer.py
xlsx_writer.py脚本包含了创建包含我们处理过的UserAssist值的 Excel 文档的逻辑。除此之外,这个脚本还创建了一个包含我们数据摘要图表的额外工作表。xlsxwriter在第 1 行导入,是我们用来创建 Excel 文档的第三方模块。第 3 行导入的itemgetter函数将在本节后面的排序函数中使用并解释。我们已经在前面的章节中见过datetime和logging模块:
001 from __future__ import print_function
002 import xlsxwriter
003 from operator import itemgetter
004 from datetime import datetime, timedelta
005 import logging
xlsx_writer.py脚本中有六个函数。协调逻辑由第 36 行定义的excel_writer()函数处理。该函数创建我们的 Excel 工作簿对象,然后将其交给dashboard_writer()和userassist_writer()函数,分别用于创建仪表板和UserAssist工作表。
剩下的三个函数,file_time()、sort_by_count()和sort_by_date(),是仪表板和UserAssist写入器使用的辅助函数。file_time()函数负责将我们从原始UserAssist数据中解析出的 FILETIME 对象转换为datetime对象。排序函数用于根据计数或日期对数据进行排序。我们使用这些排序函数来回答一些关于数据的基本问题。最常用的应用程序是什么?最不常用的应用程序是什么?在计算机上最后使用的 10 个应用程序是什么(根据UserAssist数据)?
036 excel_writer():
...
071 dashboard_writer():
...
156 userassist_writer():
...
201 file_time():
...
214 sort_by_count():
...
227 sort_by_date():
使用excel_writer()函数控制输出
excel_writer()函数是这个脚本的核心。在第 47 行的 headers 列表中,包含了我们所需的列名。这些列名也方便地与我们将要写入的UserAssist字典中的键相对应。在第 49 行,我们创建了将要写入的Workbook对象。在下一行,我们创建了title_format,它控制了电子表头的颜色、字体、大小和其他样式选项。我们有以下代码:
036 def excel_writer(data, out_file):
037 """
038 The excel_writer function handles the main logic of writing
039 the excel output
040 :param data: the list of lists containing parsed UA data
041 :param out_file: the desired output directory and filename
042 for the excel file
043 :return: Nothing
044 """
045 print('[+] Writing XLSX output.')
046 logging.info('Writing XLSX to ' + out_file + '.')
047 headers = ['Name', 'Path', 'Session ID', 'Count',
048 'Last Used Date (UTC)', 'Focus Time (ms)', 'Focus Count']
049 wb = xlsxwriter.Workbook(out_file)
050 title_format = wb.add_format({'bold': True,
051 'font_color': 'white', 'bg_color': 'black', 'font_size': 30,
052 'font_name': 'Calibri', 'align': 'center'})
title_format与我们之前讨论的xlsxwriter模块时创建的date_format相似。这个格式是一个包含关键词和值的字典。具体来说,我们将在创建标题和副标题行时使用这个格式,以使其从电子表格中的其他数据中突出显示。
在第 54 到 59 行,我们将字典转换回列表。看起来这可能会让你觉得我们在存储数据时选择了错误的数据类型,也许你有一定的理由。然而,将数据存储在列表中会极大简化生成 XSLX 输出的过程。一旦我们看到 CSV 写入器如何处理数据,为什么最初使用字典会变得更加清晰。此外,使用字典可以让我们轻松理解存储的数据,而无需查看代码或文档:
054 # A temporary list that will store dictionary values
055 tmp_list = []
056 for dictionary in data:
057 # Adds dictionary values to a list ordered by the headers
058 # Adds an empty string is the key does not exist
059 tmp_list.append([dictionary.get(x, '') for x in headers])
我们使用列表推导将字典中的数据按正确的顺序追加到数据中。让我们逐步分解。在第 59 行,我们迭代每个UserAssist字典。正如我们所知,字典不按索引存储数据,而是按键映射存储数据。但是,我们希望按照我们的标题列表指定的顺序写入数据。标题循环中的x允许我们迭代该列表。对于每个x,我们使用get()方法返回字典中x对应的值,如果在字典中找到的话,否则返回空字符串。
在第 61 和 62 行,我们为仪表板和UserAssist数据调用两个工作表编写器。在这些函数的最后退出后,我们关闭工作簿对象的close()方法。关闭工作簿非常重要。如果不这样做,将会抛出异常,可能会阻止我们将 Excel 文档从内存传输到磁盘。请看下面的代码:
061 dashboard_writer(wb, tmp_list, title_format)
062 userassist_writer(wb, tmp_list, headers, title_format)
063
064 wb.close()
065 msg =('Completed writing XLSX file. '
066 'Program exiting successfully.')
067 print('[*]', msg)
068 logging.info(msg)
使用dashboard_writer()函数总结数据
dashboard_writer()函数的目的是向分析师或审核人员提供一些汇总我们UserAssist数据的图形。我们选择向用户展示前 10、后 10 和最近的 10 个可执行文件。这个函数是我们最长的函数,需要最多的逻辑。
在第 81 行,我们将我们的仪表板工作表对象添加到工作簿中。接下来,我们将从A到Q列合并第一行,并使用我们在excelWriter()函数中创建的标题格式写入我们的公司名称XYZ Corp。类似地,在第 83 行,我们创建一个副标题行,以标识这个工作表作为我们的仪表板。
071 def dashboard_writer(workbook, data, ua_format):
072 """
073 the dashboard_writer function creates the 'Dashboard'
074 worksheet, table, and graphs
075 :param workbook: the excel workbook object
076 :param data: the list of lists containing parsed UA data
077 :param ua_format: the format object for the title and
078 subtitle row
079 :return: Nothing
080 """
081 dashboard = workbook.add_worksheet('Dashboard')
082 dashboard.merge_range('A1:Q1', 'XYZ Corp', ua_format)
083 dashboard.merge_range('A2:Q2', 'Dashboard', ua_format)
在第 87 行,我们创建并向工作簿添加date_format以正确格式化我们的日期。在第 92 和 93 行,我们调用两个排序函数。我们使用列表切片来切割排序后的数据,创建我们的子列表:topten,leastten 和 lastten。对于按计数使用的topten可执行文件,我们从排序列表中获取最后 10 个元素。对于leastten,我们只需执行相反操作。对于lastten,我们获取排序日期列表中的前 10 个结果,如下所示:
085 # The format to use to convert datetime object into a human
086 # readable value
087 date_format = workbook.add_format({
088 'num_format': 'mm/dd/yy h:mm:ss'})
089
090 # Sort our original input by count and date to assist with
091 # creating charts.
092 sorted_count = sort_by_count(data)
093 sorted_date = sort_by_date(data)
094
095 # Use list slicing to obtain the most and least frequently
096 # used UA apps and the most recently used UA apps
097 topten = sorted_count[-10:]
098 leastten = sorted_count[:10]
099 lastten = sorted_date[:10]
在第 103 行,我们迭代处理lastten列表中的元素。我们必须将每个时间戳转换为datetime对象。datetime对象存储在我们创建的UserAssist列表的第一个索引中,并通过file_time()函数进行转换:
101 # For the most recently used UA apps, convert the FILETIME
102 # value to datetime format
103 for element in lastten:
104 element[1] = file_time(element[1])
在第 108 行到 116 行之间,我们为顶部、底部和最新的数据点创建了三个表格。注意这些表格从第 100 行开始。我们选择将它们放置得离电子表格顶部较远,这样用户会先看到我们将要添加的表格,而不是原始数据。正如我们在xlsxwriter部分描述表格时所看到的,add_table()函数的第二个参数是一个字典,包含标题名称和格式的关键字。还可以提供其他关键字来实现更多功能。例如,我们使用format关键字确保我们的datetime对象按照我们指定的date_format变量显示。我们有以下代码:
106 # Create a table for each of the three categories, specifying
107 # the data, column headers, and formats for specific columns
108 dashboard.add_table('A100:B110',
109 {'data': topten, 'columns': [{'header': 'App'},
110 {'header': 'Count'}]})
111 dashboard.add_table('D100:E110',
112 {'data': leastten, 'columns': [{'header': 'App'},
113 {'header': 'Count'}]})
114 dashboard.add_table('G100:H110',
115 {'data': lastten, 'columns': [{'header': 'App'},
116 {'header': 'Date (UTC)', 'format': date_format}]})
在第 118 行到 153 行之间,我们为三个表格创建了图表。在将top_chart实例化为饼图后,我们设置了标题以及X轴和Y轴的刻度。在测试过程中,我们意识到图表的尺寸太小,无法充分展示所有信息,因此我们使用了更大的比例:
118 # Create the most used UA apps chart
119 top_chart = workbook.add_chart({'type': 'pie'})
120 top_chart.set_title({'name': 'Top Ten Apps'})
121 # Set the relative size to fit the labels and pie chart within
122 # chart area
123 top_chart.set_size({'x_scale': 1, 'y_scale': 2})
在第 127 行,我们为饼图添加了系列;识别类别和值是非常直接的。我们需要做的只是定义我们想要绘制的行和列。data_labels键是一个附加选项,可以用来指定绘制数据的值格式。在此情况下,我们选择了'percentage'选项,如第 130 行所示,代码如下:
125 # Add the data as a series by specifying the categories and
126 # values
127 top_chart.add_series(
128 {'categories': '=Dashboard!$A$101:$A$110',
129 'values': '=Dashboard!$B$101:$B$110',
130 'data_labels': {'percentage': True}})
131 # Add the chart to the 'Dashboard' worksheet
132 dashboard.insert_chart('A4', top_chart)
使用此设置,我们的饼图将根据使用次数进行分割,图例将包含可执行文件的名称,百分比将显示与其他九个可执行文件相比的相对执行情况。创建图表后,我们调用insert_chart()将其添加到仪表盘工作表中。least_chart的创建方式与此相同,如下所示:
134 # Create the least used UA apps chart
135 least_chart = workbook.add_chart({'type': 'pie'})
136 least_chart.set_title({'name': 'Least Used Apps'})
137 least_chart.set_size({'x_scale': 1, 'y_scale': 2})
138
139 least_chart.add_series(
140 {'categories': '=Dashboard!$D$101:$D$110',
141 'values': '=Dashboard!$E$101:$E$110',
142 'data_labels': {'percentage': True}})
143 dashboard.insert_chart('J4', least_chart)
最后,我们创建并将last_chart添加到电子表格中。为了节省纸张,这一过程与我们之前讨论的方式相同。不过,这次我们的图表是柱状图,并且我们调整了比例,以便适合该类型的图表:
145 # Create the most recently used UA apps chart
146 last_chart = workbook.add_chart({'type': 'column'})
147 last_chart.set_title({'name': 'Last Used Apps'})
148 last_chart.set_size({'x_scale': 1.5, 'y_scale': 1})
149
150 last_chart.add_series(
151 {'categories': '=Dashboard!$G$101:$G$110',
152 'values': '=Dashboard!$H$101:$H$110'})
153 dashboard.insert_chart('D35', last_chart)
在 userassist_writer()函数中编写的内容
userassist_writer()函数与之前的仪表盘函数类似。不同之处在于,这个函数创建了一个包含原始数据的单一表格,没有任何附加的装饰。在第 167 行到 169 行之间,我们创建了UserAssist工作表对象,并将标题和副标题添加到电子表格中。在第 173 行,我们再次创建了一个date_format,以便正确显示日期,如下所示:
156 def userassist_writer(workbook, data, headers, ua_format):
157 """
158 The userassist_writer function creates the 'UserAssist'
159 worksheet and table
160 :param workbook: the excel workbook object
161 :param data: the list of lists containing parsed UA data
162 :param headers: a list of column names for the spreadsheet
163 :param ua_format: the format object for the title and subtitle
164 row
165 :return: Nothing
166 """
167 userassist = workbook.add_worksheet('UserAssist')
168 userassist.merge_range('A1:H1', 'XYZ Corp', ua_format)
169 userassist.merge_range('A2:H2', 'Case ####', ua_format)
170
171 # The format to use to convert datetime object into a
172 # human readable value
173 date_format = workbook.add_format(
174 {'num_format': 'mm/dd/yy h:mm:ss'})
在第 178 行,我们遍历外部列表,并使用我们预先构建的函数将 FILETIME 对象转换为datetime对象。我们还在列表的开头添加了一个整数,以便检查人员通过查看索引可以快速确定有多少个UserAssist记录:
176 # Convert the FILETIME object to datetime and insert the 'ID'
177 # value as the first element in the list
178 for i, element in enumerate(data):
179 element[4] = file_time(element[4])
180 element.insert(0, i + 1)
在第 188 行,我们开始创建 UserAssist 表。我们使用第 184 行创建的长度变量来确定距离表格右下角的适当位置。注意,长度是列表的长度加三。我们将三加到该长度上,是因为我们需要考虑标题和副标题行,占用了前两列,以及 Python 和 Excel 计数方式的差异。我们有如下代码:
182 # Calculate how big the table should be. Add 3 to account for
183 # the title and header rows.
184 length = len(data) + 3
185
186 # Create the table; depending on the type (WinXP v. Win7) add
187 # additional headers
188 userassist.add_table(('A3:H' + str(length)),
189 {'data': data,
190 'columns': [{'header': 'ID'},
191 {'header': 'Name'},
192 {'header': 'Path'},
193 {'header': 'Session ID'},
194 {'header': 'Count'},
195 {'header': 'Last Run Time (UTC)',
196 'format': date_format},
197 {'header': 'Focus Time (MS)'},
198 {'header': 'Focus Count'}]})
定义 file_time() 函数
这是一个非常小的帮助函数。我们使用 struct 库解析的 FILETIME 对象是一个 8 字节的整数,表示自 1601 年 1 月 1 日以来 100 纳秒单位的计数。这个日期是大多数微软操作系统和应用程序作为公共时间参考点所使用的。
因此,为了获得它所表示的日期,我们需要将 FILETIME 值加到代表 1601 年 1 月 1 日的 datetime 对象上,并使用 timedelta() 函数。timedelta 函数计算一个整数所代表的天数和小时数。然后,我们可以将 timedelta() 函数的输出直接加到 datetime 对象中,得到正确的日期。为了得到正确的量级,我们需要将 FILETIME 值除以 10,如下所示:
201 def file_time(ft):
202 """
203 The file_time function converts the FILETIME objects into
204 datetime objects
205 :param ft: the FILETIME object
206 :return: the datetime object
207 """
208 if ft is not None and ft != 0:
209 return datetime(1601, 1, 1) + timedelta(microseconds=ft / 10)
210 else:
211 return 0
使用 sort_by_count() 函数处理整数
sort_by_count() 函数根据执行计数值对内部列表进行排序。这是一个相对复杂的单行代码,让我们一步一步地拆解。首先,先关注 sorted(data, key=itemgetter(3)) 这一部分。Python 提供了内置的 sorted() 方法,用于根据键(通常是整数)对数据进行排序。我们可以为 sorted() 函数提供一个键,告诉它按照什么排序,并返回一个新的排序后的列表。
和任何新的代码一样,让我们在交互式提示符下看一个简单的例子:
>>> from operator import itemgetter
>>> test = [['a', 2], ['b', 5], ['c', -2], ['d', 213], ['e', 40], ['f', 1]]
>>> print(sorted(test, key=itemgetter(1)))
[['c', -2], ['f', 1], ['a', 2], ['b', 5], ['e', 40], ['d', 213]]
>>> print(sorted(test, key=itemgetter(1), reverse=True))
[['d', 213], ['e', 40], ['b', 5], ['a', 2], ['f', 1], ['c', -2]]
>>> print(sorted(test, key=itemgetter(0)))
[['a', 2], ['b', 5], ['c', -2], ['d', 213], ['e', 40], ['f', 1]]
在前面的例子中,我们创建了一个外部列表,包含了包含两个元素的内部列表:一个字符和一个数字。接下来,我们对这个列表进行排序,并使用内部列表中第一个索引的数字作为排序的键。默认情况下,sorted() 会按升序排序。要按降序排序,你需要提供 reverse=True 参数。如果我们想按字母排序,则可以向 itemgetter() 提供值 0,以指定按该位置的元素排序。
现在,剩下的就是理解 x[0:5:3] 的含义了。我们为什么要这么做?我们使用列表切片仅抓取第一个和第三个元素,即可执行文件的名称和计数,供我们用于表格。
记住,切片表示法支持三个可选的组件:List[x:y:z],其中,x = 起始索引,y = 结束索引,z = 步长。
在这个例子中,我们从索引 0 开始,到索引 5 结束,步长为 3。如果这样做,我们将只获取列表中第零和第三个位置的元素,直到到达末尾。
现在,x[0:5:3]语句会对sorted(data, key=itemgetter(3))中的x进行遍历,并仅保留每个列表中的零和第三位置元素。我们然后将整个语句用一对方括号括起来,以便保留xlsxwriter所偏好的外部和内部列表结构。
列表对象也有一个sort()方法,它在语法上类似于sorted()函数。然而,sort()函数更节省内存,因为它不会创建新列表,而是就地排序当前列表。由于数据集的内存消耗不是大问题,最多可能包含几百条记录,并且由于我们不想修改原始列表,所以选择使用sorted()函数。我们有以下代码:
214 def sort_by_count(data):
215 """
216 The sort_by_count function sorts the lists by their count
217 element
218 :param data: the list of lists containing parsed UA data
219 :return: the sorted count list of lists
220 """
221 # Return only the zero and third indexed item (the name and
222 # count values) in the list after it has been sorted by the
223 # count
224 return [x[0:5:3] for x in sorted(data, key=itemgetter(3))]
使用 sort_by_date()函数处理 datetime 对象
sort_by_date()函数与sort_by_count()函数非常相似,唯一不同的是它使用了不同的索引。由于datetime对象实际上只是一个数字,我们也可以轻松地按此排序。提供reverse=True允许我们按降序排序。
再次,我们首先通过位置 4 的 datetime 来创建一个新的排序列表。然后我们只保留每个列表中的零和第四位置元素,并将所有内容包装在另一个列表中,以保留我们的嵌套列表结构:
227 def sort_by_date(data):
228 """
229 The sort_by_date function sorts the lists by their datetime
230 object
231 :param data: the list of lists containing parsed UA data
232 :return: the sorted date list of lists
233 """
234 # Supply the reverse option to sort by descending order
235 return [x[0:6:4] for x in sorted(data, key=itemgetter(4),
236 reverse=True)]
编写通用电子表格 – csv_writer.py
csv_writer.py脚本相比我们之前编写的两个脚本要简单得多。这个脚本负责输出UserAssist数据的 CSV 格式。csv_writer.py脚本有两个函数:csv_writer()和辅助函数file_time()。我们在xlsx_writer部分已解释过file_time()函数,由于它的实现相同,这里不再重复。
理解 csv_writer()函数
csv_writer()函数定义在第 38 行,与我们在前几章中创建 CSV 输出的方式略有不同。我们通常会首先创建标题列表,创建写入对象,并写入标题列表以及数据变量中的每个子列表。这次,我们不再使用writer(),而是使用DictWriter()方法来帮助我们写入UserAssist字典:
001 from __future__ import print_function
002 import sys
003 if sys.version_info[0] == 2:
004 import unicodecsv as csv
005 elif sys.version_info[0] == 3:
006 import csv
007 from datetime import datetime, timedelta
008 import logging
...
038 def csv_writer(data, out_file):
039 """
040 The csv_writer function writes the parsed UA data to a csv
041 file
042 :param data: the list of lists containing parsed UA data
043 :param out_file: the desired output directory and filename
044 for the csv file
045 :return: Nothing
046 """
047 print('[+] Writing CSV output.')
048 logging.info('Writing CSV to ' + out_file + '.')
在第 49 行,我们仍然像往常一样创建我们的标题列表。然而,这个列表起到了更重要的作用。这个列表包含了每个键的名称,这些键将出现在我们的UserAssist字典中,并且是我们希望显示的顺序。DictWriter()方法将允许我们根据这个列表来排序字典,以确保数据按适当的顺序展示。根据代码是由 Python 2 还是 3 运行,我们会使用合适的方法来打开csvfile。请看下面的代码:
049 headers = ['ID', 'Name', 'Path', 'Session ID', 'Count',
050 'Last Used Date (UTC)', 'Focus Time (ms)', 'Focus Count']
051
052 if sys.version_info[0] == 2:
053 csvfile = open(out_file, "wb")
054 elif sys.version_info[0] == 3:
055 csvfile = open(out_file, "w", newline='',
056 encoding='utf-8')
我们首先创建csvfile对象和写入器。DictWriter()方法需要一个文件对象作为必需的参数,还可以使用可选的关键字参数。fieldnames参数将确保字典的键按照适当的顺序写入。extrasaction关键字设置为忽略字典中包含但不在fieldnames列表中的键。如果没有设置这个选项,如果字典中有额外的未记录键,我们将会遇到异常。在我们的场景中,由于我们已经硬编码了这些键,应该不会遇到这个问题。然而,如果因为某种原因出现了额外的键,我们宁愿让DictWriter()忽略它们,而不是崩溃,代码如下:
058 with csvfile:
059 writer = csv.DictWriter(csvfile, fieldnames=headers,
060 extrasaction='ignore')
使用DictWriter()对象,我们可以调用writeheader()方法自动写入提供的字段名:
061 # Writes the header from list supplied to fieldnames
062 # keyword argument
063 writer.writeheader()
请注意,我们在写入每个字典之前会进行一些额外的处理。首先,我们将 ID 键添加到当前的循环计数中。接下来,在第 71 行,我们调用fileTime()方法将 FILETIME 对象转换为datetime格式。最后,在第 73 行,我们将字典写入 CSV 输出文件:
065 for i, dictionary in enumerate(data):
066 # Insert the 'ID' value to each dictionary in the
067 # list. Add 1 to start ID at 1 instead of 0.
068 dictionary['ID'] = i + 1
069 # Convert the FILETIME object in the fourth index to
070 # human readable value
071 dictionary['Last Used Date (UTC)'] = file_time(
072 dictionary['Last Used Date (UTC)'])
073 writer.writerow(dictionary)
所有字典写入完成后,我们对csvfile对象调用flush()并执行close()操作。至此,我们记录下 CSV 脚本成功完成的日志。此时,剩下的就是实际运行我们新的框架:
075 csvfile.flush()
076 csvfile.close()
077 msg = 'Completed writing CSV file. Program exiting successfully.'
078 print('[*]', msg)
079 logging.info(msg)
运行 UserAssist 框架
我们的脚本能够解析基于 Windows XP 和 Windows 7 的UserAssist注册表项。然而,我们将重点关注 CSV 和 XLSX 输出选项之间的差异。使用xlsxwriter模块并查看输出结果应该能清楚地展示直接写入 Excel 文件相对于 CSV 文件的优势。虽然你失去了 CSV 文档的可移植性,但你获得了更多的功能。以下是运行userassist.py脚本对 Vista 系统的NTUSER.DAT文件进行处理并创建 XLSX 输出的截图:
CSV 输出无法保存 Python 对象或创建可供报告使用的电子表格。CSV 报告的优点,除了其可移植性之外,就是模块的编写非常简单。与超过 100 行代码的 Excel 文档相比,我们能够用仅仅几行代码写出主要逻辑,显然后者开发起来花费了更多时间。
能够编写自定义的 Excel 报告是很棒的,但它需要时间成本。对于法医开发人员来说,这可能并不总是一个可行的附加功能,因为时间限制往往在开发周期中起着重要作用,并决定了你能做什么和不能做什么。然而,如果时间允许,这可以节省检查员手动执行此过程的麻烦,并为分析留出更多时间。
挑战
我们已经详细讨论了 Windows 7 对 UserAssist 工件所做的新增内容。然而,仍有一些我们在当前 UserAssist 框架实现中未考虑到的变化。随着 Windows 7 的发布,一些常见文件夹名称被 GUID 替换。以下是一些文件夹及其对应 GUID 的示例表:
| 文件夹 | GUID |
|---|---|
UserProfiles | {0762D272-C50A-4BB0-A382-697DCD729B80} |
Desktop | {B4BFCC3A-DB2C-424C-B029-7FE99A87C641} |
Documents | {FDD39AD0-238F-46AF-ADB4-6C85480369C7} |
Downloads | {374DE290-123F-4565-9164-39C4925E467B} |
对我们的脚本进行改进,可能涉及到查找这些和其他常见文件夹 GUID,并将其替换为真实路径。微软 MSDN 网站上提供了一些常见 GUID 的列表,网址为 msdn.microsoft.com/en-us/library/bb882665.aspx。
或者,我们选择用来绘制最后 10 个可执行文件的图表,可能不是展示日期的最佳方式。创建一个更侧重时间线的图表可能更能有效地展示这些数据。尝试使用一些其他内置图表及其功能,以更好地熟悉 xlsxwriter 的图表功能。
总结
本章是一个模块为中心的章节,我们向工具包中添加了三个新模块。此外,我们还了解了 UserAssist 工件以及如何解析它。虽然这些概念很重要,但我们对 timeit 的短暂探讨可能在今后的工作中更具价值。
作为开发者,我们有时会遇到脚本执行效率不足,或者在处理大数据集时花费大量时间的情况。在这些情况下,像 timeit 这样的模块可以帮助审计和评估代码,以找出更高效的解决方案。该项目的代码可以从 GitHub 或 Packt 下载,具体说明见 前言。
在下一章,我们将介绍如何在 Python 中进行文件哈希。具体来说,我们将重点讲解如何对数据块进行哈希,以识别相同或相似的文件。这被称为“模糊哈希”。当评估共享相似根源的对象时,这种技术非常有用,例如恶意软件。我们可以获取我们怀疑已在系统中使用的已知恶意软件样本,进行模糊哈希,并在系统中搜索匹配项。我们不再寻找完全匹配,而是在一个不显眼的文件上找到了 90%的匹配,进一步检查后,发现这其实是恶意软件的新变种,否则可能会被忽略。我们将介绍实现这一功能的多种方法和背后的逻辑。