richprint-pe 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- richprint/__init__.py +62 -0
- richprint/__main__.py +7 -0
- richprint/cli.py +112 -0
- richprint/constants.py +47 -0
- richprint/data/__init__.py +1 -0
- richprint/data/comp_id.txt +4566 -0
- richprint/database.py +94 -0
- richprint/exceptions.py +41 -0
- richprint/models.py +72 -0
- richprint/parser.py +338 -0
- richprint_pe-1.0.0.dist-info/METADATA +119 -0
- richprint_pe-1.0.0.dist-info/RECORD +15 -0
- richprint_pe-1.0.0.dist-info/WHEEL +4 -0
- richprint_pe-1.0.0.dist-info/entry_points.txt +2 -0
- richprint_pe-1.0.0.dist-info/licenses/LICENSE +22 -0
richprint/database.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Compiler ID database loading and lookup."""
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
# Type alias for the database
|
|
7
|
+
CompilerDatabase = Dict[int, str]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_database(path: Optional[str] = None) -> CompilerDatabase:
|
|
11
|
+
"""
|
|
12
|
+
Load compiler ID database from file.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
path: Path to comp_id.txt file. If None, uses bundled database.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Dictionary mapping comp.id values to descriptions.
|
|
19
|
+
"""
|
|
20
|
+
descriptions: CompilerDatabase = {}
|
|
21
|
+
|
|
22
|
+
if path is None:
|
|
23
|
+
# Use bundled database
|
|
24
|
+
try:
|
|
25
|
+
# Python 3.9+
|
|
26
|
+
files = importlib.resources.files("richprint.data")
|
|
27
|
+
content = (files / "comp_id.txt").read_text(encoding="utf-8")
|
|
28
|
+
except AttributeError:
|
|
29
|
+
# Python 3.8 fallback
|
|
30
|
+
import pkg_resources
|
|
31
|
+
content = pkg_resources.resource_string(
|
|
32
|
+
"richprint.data", "comp_id.txt"
|
|
33
|
+
).decode("utf-8")
|
|
34
|
+
lines = content.splitlines()
|
|
35
|
+
else:
|
|
36
|
+
try:
|
|
37
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
38
|
+
lines = f.readlines()
|
|
39
|
+
except (IOError, OSError):
|
|
40
|
+
return descriptions
|
|
41
|
+
|
|
42
|
+
for line in lines:
|
|
43
|
+
line = line.rstrip("\n\r")
|
|
44
|
+
|
|
45
|
+
# Remove trailing comments
|
|
46
|
+
comment_pos = line.rfind("#")
|
|
47
|
+
if comment_pos != -1:
|
|
48
|
+
# Trim trailing spaces before comment
|
|
49
|
+
while comment_pos > 0 and line[comment_pos - 1] == " ":
|
|
50
|
+
comment_pos -= 1
|
|
51
|
+
line = line[:comment_pos]
|
|
52
|
+
|
|
53
|
+
# Skip empty lines and comment-only lines
|
|
54
|
+
if len(line) <= 8 or line.startswith("#"):
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
# Parse: <hex_id> <description>
|
|
58
|
+
try:
|
|
59
|
+
hex_part = line[:8]
|
|
60
|
+
comp_id = int(hex_part, 16)
|
|
61
|
+
desc = line[9:] if len(line) > 9 else ""
|
|
62
|
+
|
|
63
|
+
# Skip duplicates (keep first)
|
|
64
|
+
if comp_id not in descriptions:
|
|
65
|
+
descriptions[comp_id] = desc
|
|
66
|
+
except ValueError:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
return descriptions
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def lookup_description(
|
|
73
|
+
db: CompilerDatabase, comp_id: int, product_id: int
|
|
74
|
+
) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Look up description for a compiler entry.
|
|
77
|
+
|
|
78
|
+
First tries exact comp_id match, then falls back to product_id only.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
db: Compiler database dictionary.
|
|
82
|
+
comp_id: Full compiler ID (product_id << 16 | build_version).
|
|
83
|
+
product_id: Product ID (high 16 bits of comp_id).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Description string, or empty string if not found.
|
|
87
|
+
"""
|
|
88
|
+
# Try exact match first
|
|
89
|
+
if comp_id in db:
|
|
90
|
+
return db[comp_id]
|
|
91
|
+
# Fall back to product_id only
|
|
92
|
+
if product_id in db:
|
|
93
|
+
return db[product_id]
|
|
94
|
+
return ""
|
richprint/exceptions.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Custom exceptions for richprint."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RichPrintError(Exception):
|
|
5
|
+
"""Base exception for richprint errors."""
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileOpenError(RichPrintError):
|
|
10
|
+
"""Failed to open or read file."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoMZHeaderError(RichPrintError):
|
|
15
|
+
"""File does not have MZ (DOS) header signature."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoPEHeaderError(RichPrintError):
|
|
20
|
+
"""File does not have valid PE header."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidDOSHeaderError(RichPrintError):
|
|
25
|
+
"""DOS header has invalid values."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NoRichHeaderError(RichPrintError):
|
|
30
|
+
"""Rich header not found in file."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NoDanSTokenError(RichPrintError):
|
|
35
|
+
"""Rich header's DanS token not found."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InvalidRichHeaderError(RichPrintError):
|
|
40
|
+
"""Rich header structure is invalid."""
|
|
41
|
+
pass
|
richprint/models.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Data models for richprint."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class CompilerEntry:
|
|
9
|
+
"""A single entry from the Rich header."""
|
|
10
|
+
comp_id: int # Full @comp.id value (product_id << 16 | build_version)
|
|
11
|
+
product_id: int # Product/tool identifier (high 16 bits)
|
|
12
|
+
build_version: int # Build version number (low 16 bits)
|
|
13
|
+
count: int # Number of objects with this comp.id
|
|
14
|
+
description: str = "" # Human-readable description from database
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RichHeader:
|
|
19
|
+
"""Parsed Rich header data."""
|
|
20
|
+
xor_key: int # XOR key used to encode the header
|
|
21
|
+
entries: List[CompilerEntry] = field(default_factory=list)
|
|
22
|
+
dans_offset: int = 0 # File offset of DanS marker
|
|
23
|
+
rich_offset: int = 0 # File offset of Rich marker
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PEInfo:
|
|
28
|
+
"""Basic PE file information."""
|
|
29
|
+
machine_type: int # Machine type value
|
|
30
|
+
machine_name: str # Human-readable machine name
|
|
31
|
+
pe_offset: int # File offset of PE header
|
|
32
|
+
dos_stub_end: int # End of DOS stub (start of search area)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ParseResult:
|
|
37
|
+
"""Complete result of parsing a PE file."""
|
|
38
|
+
filename: str
|
|
39
|
+
success: bool = False
|
|
40
|
+
error: Optional[str] = None
|
|
41
|
+
pe_info: Optional[PEInfo] = None
|
|
42
|
+
rich_header: Optional[RichHeader] = None
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict:
|
|
45
|
+
"""Convert result to dictionary for JSON serialization."""
|
|
46
|
+
result = {
|
|
47
|
+
"filename": self.filename,
|
|
48
|
+
"success": self.success,
|
|
49
|
+
}
|
|
50
|
+
if self.error:
|
|
51
|
+
result["error"] = self.error
|
|
52
|
+
if self.pe_info:
|
|
53
|
+
result["pe_info"] = {
|
|
54
|
+
"machine_type": self.pe_info.machine_type,
|
|
55
|
+
"machine_name": self.pe_info.machine_name,
|
|
56
|
+
"pe_offset": self.pe_info.pe_offset,
|
|
57
|
+
}
|
|
58
|
+
if self.rich_header:
|
|
59
|
+
result["rich_header"] = {
|
|
60
|
+
"xor_key": f"0x{self.rich_header.xor_key:08x}",
|
|
61
|
+
"entries": [
|
|
62
|
+
{
|
|
63
|
+
"comp_id": f"0x{e.comp_id:08x}",
|
|
64
|
+
"product_id": e.product_id,
|
|
65
|
+
"build_version": e.build_version,
|
|
66
|
+
"count": e.count,
|
|
67
|
+
"description": e.description,
|
|
68
|
+
}
|
|
69
|
+
for e in self.rich_header.entries
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
return result
|
richprint/parser.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Core PE/Rich header parsing logic."""
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from typing import BinaryIO, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from .constants import (
|
|
7
|
+
MZ_SIGNATURE,
|
|
8
|
+
PE_SIGNATURE,
|
|
9
|
+
RICH_SIGNATURE,
|
|
10
|
+
DANS_SIGNATURE,
|
|
11
|
+
DOS_NUM_RELOCS_OFFSET,
|
|
12
|
+
DOS_HEADER_PARA_OFFSET,
|
|
13
|
+
DOS_RELOC_OFFSET,
|
|
14
|
+
DOS_PE_OFFSET,
|
|
15
|
+
PE_MACHINE_OFFSET,
|
|
16
|
+
get_machine_type,
|
|
17
|
+
)
|
|
18
|
+
from .database import CompilerDatabase, lookup_description
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
FileOpenError,
|
|
21
|
+
NoMZHeaderError,
|
|
22
|
+
NoPEHeaderError,
|
|
23
|
+
InvalidDOSHeaderError,
|
|
24
|
+
NoRichHeaderError,
|
|
25
|
+
NoDanSTokenError,
|
|
26
|
+
InvalidRichHeaderError,
|
|
27
|
+
)
|
|
28
|
+
from .models import CompilerEntry, RichHeader, PEInfo, ParseResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_word(data: bytes, offset: int) -> int:
|
|
32
|
+
"""Read unsigned 16-bit little-endian value."""
|
|
33
|
+
return struct.unpack_from("<H", data, offset)[0]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_dword(data: bytes, offset: int) -> int:
|
|
37
|
+
"""Read unsigned 32-bit little-endian value."""
|
|
38
|
+
return struct.unpack_from("<I", data, offset)[0]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_pe_info(data: bytes) -> PEInfo:
|
|
42
|
+
"""
|
|
43
|
+
Parse basic PE information from file data.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
data: File contents as bytes.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
PEInfo with machine type, PE offset, and DOS stub end offset.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
NoMZHeaderError: If MZ signature not found.
|
|
53
|
+
InvalidDOSHeaderError: If DOS header values are invalid.
|
|
54
|
+
NoPEHeaderError: If PE signature not found.
|
|
55
|
+
"""
|
|
56
|
+
# Check MZ header
|
|
57
|
+
if len(data) < 2:
|
|
58
|
+
raise NoMZHeaderError("File too small for MZ header")
|
|
59
|
+
|
|
60
|
+
mz = read_word(data, 0)
|
|
61
|
+
if mz != MZ_SIGNATURE:
|
|
62
|
+
raise NoMZHeaderError(f"No MZ header - magic is: 0x{mz:x}")
|
|
63
|
+
|
|
64
|
+
# Read DOS header metrics
|
|
65
|
+
if len(data) < 0x40:
|
|
66
|
+
raise InvalidDOSHeaderError("File too small for DOS header")
|
|
67
|
+
|
|
68
|
+
num_relocs = read_word(data, DOS_NUM_RELOCS_OFFSET)
|
|
69
|
+
header_para = read_word(data, DOS_HEADER_PARA_OFFSET)
|
|
70
|
+
|
|
71
|
+
if header_para < 4:
|
|
72
|
+
raise InvalidDOSHeaderError(
|
|
73
|
+
f"Too few paragraphs in DOS header: {header_para}, not a PE executable"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
reloc_offset = read_word(data, DOS_RELOC_OFFSET)
|
|
77
|
+
pe_offset = read_word(data, DOS_PE_OFFSET)
|
|
78
|
+
|
|
79
|
+
if pe_offset < header_para * 16:
|
|
80
|
+
raise InvalidDOSHeaderError(
|
|
81
|
+
f"PE offset is too small: {pe_offset}, not a PE executable"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Check PE signature
|
|
85
|
+
if len(data) < pe_offset + 6:
|
|
86
|
+
raise NoPEHeaderError("File too small for PE header")
|
|
87
|
+
|
|
88
|
+
pe_sig = read_dword(data, pe_offset)
|
|
89
|
+
if pe_sig != PE_SIGNATURE:
|
|
90
|
+
raise NoPEHeaderError(
|
|
91
|
+
f"No PE header signature: 0x{pe_sig:x}, not a PE executable"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Get machine type
|
|
95
|
+
machine_type = read_word(data, pe_offset + PE_MACHINE_OFFSET)
|
|
96
|
+
machine_name = get_machine_type(machine_type)
|
|
97
|
+
|
|
98
|
+
# Calculate DOS stub end offset
|
|
99
|
+
dos_stub_end = reloc_offset
|
|
100
|
+
if num_relocs > 0:
|
|
101
|
+
dos_stub_end += 4 * num_relocs
|
|
102
|
+
|
|
103
|
+
# Align to 16-byte paragraph boundary
|
|
104
|
+
if dos_stub_end % 16:
|
|
105
|
+
dos_stub_end += 16 - (dos_stub_end % 16)
|
|
106
|
+
|
|
107
|
+
return PEInfo(
|
|
108
|
+
machine_type=machine_type,
|
|
109
|
+
machine_name=machine_name,
|
|
110
|
+
pe_offset=pe_offset,
|
|
111
|
+
dos_stub_end=dos_stub_end,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def find_rich_header(data: bytes, pe_info: PEInfo) -> Tuple[int, int, int]:
|
|
116
|
+
"""
|
|
117
|
+
Find Rich header markers and XOR key.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
data: File contents as bytes.
|
|
121
|
+
pe_info: Parsed PE info with search boundaries.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Tuple of (rich_offset, dans_offset, xor_key).
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
NoRichHeaderError: If Rich signature not found.
|
|
128
|
+
NoDanSTokenError: If DanS token not found.
|
|
129
|
+
InvalidRichHeaderError: If header structure is invalid.
|
|
130
|
+
"""
|
|
131
|
+
start = pe_info.dos_stub_end
|
|
132
|
+
end = pe_info.pe_offset
|
|
133
|
+
|
|
134
|
+
# Search for "Rich" signature
|
|
135
|
+
rich_offset = -1
|
|
136
|
+
for i in range(start, end, 4):
|
|
137
|
+
if i + 4 > len(data):
|
|
138
|
+
break
|
|
139
|
+
val = read_dword(data, i)
|
|
140
|
+
if val == RICH_SIGNATURE:
|
|
141
|
+
rich_offset = i
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
if rich_offset == -1:
|
|
145
|
+
raise NoRichHeaderError("Rich header not found")
|
|
146
|
+
|
|
147
|
+
# XOR key is immediately after "Rich"
|
|
148
|
+
if rich_offset + 8 > len(data):
|
|
149
|
+
raise InvalidRichHeaderError("File truncated after Rich signature")
|
|
150
|
+
|
|
151
|
+
xor_key = read_dword(data, rich_offset + 4)
|
|
152
|
+
|
|
153
|
+
# Search for "DanS" signature (XOR'd with key)
|
|
154
|
+
dans_offset = -1
|
|
155
|
+
target = DANS_SIGNATURE ^ xor_key
|
|
156
|
+
for i in range(start, end, 4):
|
|
157
|
+
if i + 4 > len(data):
|
|
158
|
+
break
|
|
159
|
+
val = read_dword(data, i)
|
|
160
|
+
if val == target:
|
|
161
|
+
dans_offset = i
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
if dans_offset == -1:
|
|
165
|
+
raise NoDanSTokenError("Rich header's DanS token not found")
|
|
166
|
+
|
|
167
|
+
# Validate end offset doesn't run into PE header
|
|
168
|
+
end_offset = rich_offset + 8 # Rich + key
|
|
169
|
+
if end_offset > pe_info.pe_offset:
|
|
170
|
+
raise InvalidRichHeaderError(
|
|
171
|
+
f"Calculated end offset runs into PE header: 0x{end_offset:x}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return rich_offset, dans_offset, xor_key
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def decode_rich_header(
|
|
178
|
+
data: bytes,
|
|
179
|
+
rich_offset: int,
|
|
180
|
+
dans_offset: int,
|
|
181
|
+
xor_key: int,
|
|
182
|
+
db: Optional[CompilerDatabase] = None,
|
|
183
|
+
) -> RichHeader:
|
|
184
|
+
"""
|
|
185
|
+
Decode Rich header entries.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
data: File contents as bytes.
|
|
189
|
+
rich_offset: File offset of Rich marker.
|
|
190
|
+
dans_offset: File offset of DanS marker.
|
|
191
|
+
xor_key: XOR key for decoding.
|
|
192
|
+
db: Optional compiler database for descriptions.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
RichHeader with decoded entries.
|
|
196
|
+
"""
|
|
197
|
+
entries: List[CompilerEntry] = []
|
|
198
|
+
|
|
199
|
+
# Entries start at DanS + 16 (skip DanS + 3 padding DWORDs)
|
|
200
|
+
# Entries end at Rich - 8 (stop before last empty entry)
|
|
201
|
+
start = dans_offset + 16
|
|
202
|
+
end = rich_offset
|
|
203
|
+
|
|
204
|
+
for pos in range(start, end, 8):
|
|
205
|
+
if pos + 8 > len(data):
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
# Read and decode version and count
|
|
209
|
+
ver_raw = read_dword(data, pos)
|
|
210
|
+
count_raw = read_dword(data, pos + 4)
|
|
211
|
+
|
|
212
|
+
ver = ver_raw ^ xor_key
|
|
213
|
+
count = count_raw ^ xor_key
|
|
214
|
+
|
|
215
|
+
# Extract product_id and build_version
|
|
216
|
+
product_id = ver >> 16
|
|
217
|
+
build_version = ver & 0xFFFF
|
|
218
|
+
|
|
219
|
+
# Look up description
|
|
220
|
+
description = ""
|
|
221
|
+
if db is not None:
|
|
222
|
+
description = lookup_description(db, ver, product_id)
|
|
223
|
+
|
|
224
|
+
entries.append(CompilerEntry(
|
|
225
|
+
comp_id=ver,
|
|
226
|
+
product_id=product_id,
|
|
227
|
+
build_version=build_version,
|
|
228
|
+
count=count,
|
|
229
|
+
description=description,
|
|
230
|
+
))
|
|
231
|
+
|
|
232
|
+
return RichHeader(
|
|
233
|
+
xor_key=xor_key,
|
|
234
|
+
entries=entries,
|
|
235
|
+
dans_offset=dans_offset,
|
|
236
|
+
rich_offset=rich_offset,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def parse_file(
|
|
241
|
+
filename: str,
|
|
242
|
+
db: Optional[CompilerDatabase] = None,
|
|
243
|
+
) -> ParseResult:
|
|
244
|
+
"""
|
|
245
|
+
Parse Rich header from a PE file.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
filename: Path to PE file.
|
|
249
|
+
db: Optional compiler database for descriptions.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
ParseResult with parsed data or error information.
|
|
253
|
+
"""
|
|
254
|
+
result = ParseResult(filename=filename)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
with open(filename, "rb") as f:
|
|
258
|
+
data = f.read()
|
|
259
|
+
except (IOError, OSError) as e:
|
|
260
|
+
result.error = f"Failed to open file: {e}"
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
# Parse PE info
|
|
265
|
+
pe_info = parse_pe_info(data)
|
|
266
|
+
result.pe_info = pe_info
|
|
267
|
+
|
|
268
|
+
# Find Rich header
|
|
269
|
+
rich_offset, dans_offset, xor_key = find_rich_header(data, pe_info)
|
|
270
|
+
|
|
271
|
+
# Decode entries
|
|
272
|
+
rich_header = decode_rich_header(
|
|
273
|
+
data, rich_offset, dans_offset, xor_key, db
|
|
274
|
+
)
|
|
275
|
+
result.rich_header = rich_header
|
|
276
|
+
result.success = True
|
|
277
|
+
|
|
278
|
+
except (
|
|
279
|
+
NoMZHeaderError,
|
|
280
|
+
NoPEHeaderError,
|
|
281
|
+
InvalidDOSHeaderError,
|
|
282
|
+
NoRichHeaderError,
|
|
283
|
+
NoDanSTokenError,
|
|
284
|
+
InvalidRichHeaderError,
|
|
285
|
+
) as e:
|
|
286
|
+
result.error = str(e)
|
|
287
|
+
except Exception as e:
|
|
288
|
+
result.error = f"Unexpected error: {e}"
|
|
289
|
+
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def parse_bytes(
|
|
294
|
+
data: bytes,
|
|
295
|
+
db: Optional[CompilerDatabase] = None,
|
|
296
|
+
filename: str = "<bytes>",
|
|
297
|
+
) -> ParseResult:
|
|
298
|
+
"""
|
|
299
|
+
Parse Rich header from raw bytes.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
data: PE file contents as bytes.
|
|
303
|
+
db: Optional compiler database for descriptions.
|
|
304
|
+
filename: Optional filename for result.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
ParseResult with parsed data or error information.
|
|
308
|
+
"""
|
|
309
|
+
result = ParseResult(filename=filename)
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
# Parse PE info
|
|
313
|
+
pe_info = parse_pe_info(data)
|
|
314
|
+
result.pe_info = pe_info
|
|
315
|
+
|
|
316
|
+
# Find Rich header
|
|
317
|
+
rich_offset, dans_offset, xor_key = find_rich_header(data, pe_info)
|
|
318
|
+
|
|
319
|
+
# Decode entries
|
|
320
|
+
rich_header = decode_rich_header(
|
|
321
|
+
data, rich_offset, dans_offset, xor_key, db
|
|
322
|
+
)
|
|
323
|
+
result.rich_header = rich_header
|
|
324
|
+
result.success = True
|
|
325
|
+
|
|
326
|
+
except (
|
|
327
|
+
NoMZHeaderError,
|
|
328
|
+
NoPEHeaderError,
|
|
329
|
+
InvalidDOSHeaderError,
|
|
330
|
+
NoRichHeaderError,
|
|
331
|
+
NoDanSTokenError,
|
|
332
|
+
InvalidRichHeaderError,
|
|
333
|
+
) as e:
|
|
334
|
+
result.error = str(e)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
result.error = f"Unexpected error: {e}"
|
|
337
|
+
|
|
338
|
+
return result
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: richprint-pe
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Decode and print Rich headers from Windows PE executables
|
|
5
|
+
Project-URL: Homepage, https://github.com/dishather/richprint
|
|
6
|
+
Project-URL: Repository, https://github.com/dishather/richprint
|
|
7
|
+
Author-email: dishather <noreply@github.com>
|
|
8
|
+
License-Expression: BSD-2-Clause
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: compiler,executable,forensics,pe,rich-header,windows
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# richprint
|
|
28
|
+
|
|
29
|
+
A Python tool to decode and print compiler information stored in the Rich Header of Windows PE executables.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install richprint-pe
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## What is the Rich Header?
|
|
38
|
+
|
|
39
|
+
The Rich Header is a section of binary data created by Microsoft's linker, located between the DOS stub and PE header in Windows executables. It contains a list of compiler/tool IDs (@comp.id) used to build the executable, allowing identification of exact compiler versions down to build numbers.
|
|
40
|
+
|
|
41
|
+
The data is XOR-encoded, with "Rich" being the only readable marker. Files created by non-Microsoft linkers will not have this header.
|
|
42
|
+
|
|
43
|
+
For technical details, see [Daniel Pistelli's article](http://www.ntcore.com/files/richsign.htm).
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### Command Line
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Analyze one or more files
|
|
51
|
+
richprint notepad.exe
|
|
52
|
+
richprint file1.exe file2.dll file3.sys
|
|
53
|
+
|
|
54
|
+
# JSON output
|
|
55
|
+
richprint --json notepad.exe
|
|
56
|
+
|
|
57
|
+
# Use custom compiler ID database
|
|
58
|
+
richprint --database /path/to/comp_id.txt notepad.exe
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Python API
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from richprint import parse_file, load_database
|
|
65
|
+
|
|
66
|
+
# Load the bundled compiler ID database
|
|
67
|
+
db = load_database()
|
|
68
|
+
|
|
69
|
+
# Parse a PE file
|
|
70
|
+
result = parse_file("notepad.exe", db)
|
|
71
|
+
|
|
72
|
+
if result.success:
|
|
73
|
+
print(f"Machine: {result.pe_info.machine_name}")
|
|
74
|
+
print(f"XOR Key: 0x{result.rich_header.xor_key:08x}")
|
|
75
|
+
for entry in result.rich_header.entries:
|
|
76
|
+
print(f" {entry.comp_id:08x} {entry.description}")
|
|
77
|
+
else:
|
|
78
|
+
print(f"Error: {result.error}")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Output Format
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
Processing notepad.exe
|
|
85
|
+
Target machine: x64
|
|
86
|
+
@comp.id id version count description
|
|
87
|
+
00e1520d e1 21005 10 [C++] VS2013 build 21005
|
|
88
|
+
00df520d df 21005 1 [ASM] VS2013 build 21005
|
|
89
|
+
00de520d de 21005 1 [LNK] VS2013 build 21005
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Compiler ID Database
|
|
93
|
+
|
|
94
|
+
The bundled `comp_id.txt` database maps compiler IDs to human-readable descriptions. The format supports:
|
|
95
|
+
|
|
96
|
+
- `[ C ]` - C compiler
|
|
97
|
+
- `[C++]` - C++ compiler
|
|
98
|
+
- `[ASM]` - Assembler
|
|
99
|
+
- `[LNK]` - Linker
|
|
100
|
+
- `[RES]` - Resource converter
|
|
101
|
+
- `[IMP]` / `[EXP]` - DLL import/export records
|
|
102
|
+
- And many more...
|
|
103
|
+
|
|
104
|
+
## Suppressing Rich Headers
|
|
105
|
+
|
|
106
|
+
To prevent Microsoft tools from emitting this header, use the undocumented linker option:
|
|
107
|
+
```
|
|
108
|
+
/emittoolversioninfo:no
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Available since VS2019 Update 11.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
BSD 2-Clause License. See [LICENSE](LICENSE) for details.
|
|
116
|
+
|
|
117
|
+
## Credits
|
|
118
|
+
|
|
119
|
+
Original C++ implementation and compiler ID database by [dishather](https://github.com/dishather/richprint).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
richprint/__init__.py,sha256=9Z_Z13IE3ib5zzuoeiGDJQ5PMxOfhHlwIejZWeTHWGc,1438
|
|
2
|
+
richprint/__main__.py,sha256=9u51HKotzF8v0FOOjE8xKDe-RgqpLbfVHa19yxZOado,135
|
|
3
|
+
richprint/cli.py,sha256=6nbp8AxonlXZRwPHp_8DEVccASuZ6OSlleGyzAfAuLQ,3017
|
|
4
|
+
richprint/constants.py,sha256=ueg9NjzlRaeymExJTMwa5DJRPyzDulDRUDvsyqt0NrI,1502
|
|
5
|
+
richprint/database.py,sha256=QrW1v84wZaOdJqbK-BSEjH46-1dLkLXHi5fKzbWhqL8,2793
|
|
6
|
+
richprint/exceptions.py,sha256=2_wSOdPszMhmE9syhkw42HlaBfcpGJBgabaiVH0ocGs,848
|
|
7
|
+
richprint/models.py,sha256=agKEUybeg62NMvv-haOel86hCumowPBfCZSK3p122aI,2475
|
|
8
|
+
richprint/parser.py,sha256=RCv-L4u247out-9d1Chv8vfIQZDDAgaXq3zM3dhA9gk,9393
|
|
9
|
+
richprint/data/__init__.py,sha256=rkFDdGUU9barWpkoJjOqqk7VnOQVHgowqMPy-W7FNVE,42
|
|
10
|
+
richprint/data/comp_id.txt,sha256=GGyxqv0yR5ek0UhZ1Wb2jvlh-2f6USD-yi1RNxxf82A,205937
|
|
11
|
+
richprint_pe-1.0.0.dist-info/METADATA,sha256=EIxEVkJPQVCVkQPsC56186c67VZrUXO60Bso5kyCRXc,3574
|
|
12
|
+
richprint_pe-1.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
richprint_pe-1.0.0.dist-info/entry_points.txt,sha256=GaBZUbl51q-6xLefwvWwy5NzRjV2In9p7-WQPPEScW4,49
|
|
14
|
+
richprint_pe-1.0.0.dist-info/licenses/LICENSE,sha256=ej8fVsICMQH4n8rjr8BBLevIsip0bVoyLGID5Jx80SA,1333
|
|
15
|
+
richprint_pe-1.0.0.dist-info/RECORD,,
|