Skip to content

Commit c06e8bd

Browse files
committed
Docs: align repo docs to 3.1.0, drop AI phrasing; harden bounded file reads
1 parent dc03b83 commit c06e8bd

12 files changed

Lines changed: 65 additions & 25 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ The registry propagates a known source SHA-256 from observation metadata into ev
5959

6060
`READ_ONLY < STATEFUL < DESTRUCTIVE`.
6161

62-
The default policy allows only `READ_ONLY` and disallows network traffic. In 2.1:
62+
The default policy allows only `READ_ONLY` and disallows network traffic. In 3.1:
6363

64-
- 65 tools are `READ_ONLY`;
64+
- 67 tools are `READ_ONLY` (68 registered in total);
6565
- `archive.extract_safe` is `STATEFUL` because it writes a derived workspace while leaving the source archive unchanged;
6666
- no registered tool is `DESTRUCTIVE`;
6767
- network-capable tools are separately gated.

‎RELEASE_NOTES_3.1.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# DFTK 3.1.0 release notes
22

3-
DFTK 3.1 keeps the project boundary introduced in 3.0: DFTK is a deterministic forensic capability layer, not an autonomous Agent. The release adds a native Agent transport and strengthens the existing case workspace without changing the public Observation/Evidence contract.
3+
DFTK 3.1 keeps the project boundary introduced in 3.0: DFTK is a deterministic forensic toolkit, not an autonomous Agent. The release adds a native Agent transport and strengthens the existing case workspace without changing the public Observation/Evidence contract.
44

55
## Native MCP
66

‎SECURITY.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ Only the latest published release line receives security fixes.
2828

2929
| Version | Supported |
3030
|---------|-----------|
31-
| 2.1.x | ✅ Yes |
32-
| < 2.1 | ❌ No |
31+
| 3.1.x | ✅ Yes |
32+
| < 3.1 | ❌ No |
3333

3434
## Scope notes
3535

‎skills/dftk/SKILL.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: dftk
33
description: Evidence-preserving digital forensics (DFIR) toolkit for agents. 68 read-only/stateful tools plus a unified-timeline and case-correlation workflow across Android, Linux, Windows, network, email, and crypto, behind a safety-gated Observation/Evidence contract. Use when an agent must collect, hash, parse, analyze, or correlate forensic artifacts without mutating evidence.
4-
version: 3.0.0
4+
version: 3.1.0
55
author: DyNooob @ DigiForensics
66
license: Apache-2.0
77
tags:
@@ -14,7 +14,7 @@ tags:
1414

1515
# dftk — Digital Forensics Toolkit (Agent Skill)
1616

17-
dftk is a **capability layer**, not an autonomous forensic agent: a set of
17+
dftk is a **read-only forensic toolkit**, not an autonomous forensic agent: a set of
1818
evidence-preserving primitives and composable recipes for analysts, automation,
1919
and agents. Every tool returns a structured `Observation` (status + facts +
2020
evidence with source provenance).

‎src/dftk/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ tags:
1313
---
1414
# DFTK — Digital Forensics Toolkit
1515

16-
DFTK is a **forensic capability layer**, not the investigator. You are the investigator.
16+
DFTK is a **read-only forensic toolkit**, not the investigator. You are the investigator.
1717
Use DFTK to obtain deterministic, source-traceable observations; use reasoning to decide what must be proven, which capability can prove it, whether the evidence is sufficient, and when to stop.
1818

1919
The governing rule is:

‎src/dftk/core/helpers.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,41 @@ def bounded_files(root: Path, *, max_files: int = 10000):
5555
n += 1
5656
if n >= max_files:
5757
return
58+
59+
# In-memory read ceiling for tools that must load the whole file. Forensic
60+
# evidence can legitimately be large, but loading multi-GB inputs unconditionally
61+
# risks out-of-memory; per-tool overrides are allowed.
62+
DEFAULT_MAX_READ = 4 * 1024 * 1024 * 1024
63+
64+
def read_file_bounded(path: str | os.PathLike[str], max_bytes: int = DEFAULT_MAX_READ):
65+
"""Read a file fully, refusing inputs above ``max_bytes`` to avoid OOM.
66+
67+
Returns ``(data, None)`` on success, or ``(None, err)`` where ``err`` is a
68+
dict with ``kind`` of ``"too_large"`` or ``"stat"`` and supporting fields.
69+
The caller is responsible for converting ``err`` into an ``Observation``.
70+
"""
71+
p = Path(path)
72+
try:
73+
size = p.stat().st_size
74+
except OSError as e:
75+
return None, {"kind": "stat", "error": str(e)}
76+
if size > max_bytes:
77+
return None, {"kind": "too_large", "size": size, "limit": max_bytes}
78+
return p.read_bytes(), None
79+
80+
def read_file_bounded_observation(name: str, path: str | os.PathLike[str], max_bytes: int = DEFAULT_MAX_READ):
81+
"""Like :func:`read_file_bounded`, but returns ``(data, None)`` or ``(None, Observation)``.
82+
83+
On oversized input the Observation is ``UNSUPPORTED``; on a stat/read failure it is ``ERROR``.
84+
Both carry the source SHA-256 so the caller can return them directly.
85+
"""
86+
from .models import Observation, Status
87+
data, err = read_file_bounded(path, max_bytes)
88+
if err is None:
89+
return data, None
90+
if err["kind"] == "too_large":
91+
return None, Observation(name, Status.UNSUPPORTED, "File exceeds in-memory read limit",
92+
errors=[f"size={err['size']} > limit={err['limit']}"],
93+
meta={"source_sha256": sha256_file(path)})
94+
return None, Observation(name, Status.ERROR, "File read failed", errors=[err["error"]],
95+
meta={"source_sha256": sha256_file(path)})

‎src/dftk/primitives/android.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import struct, zipfile, hashlib, re
1919
from dftk.core.registry import registry
2020
from dftk.core.models import Observation, Evidence, Status, SafetyLevel
21-
from dftk.core.helpers import sha256_file
21+
from dftk.core.helpers import sha256_file, read_file_bounded_observation
2222

2323
class DexFormatError(ValueError): pass
2424

@@ -324,7 +324,8 @@ def _apk_signing_block(data:bytes)->dict:
324324
def apk_signing_inventory(path:str)->Observation:
325325
p=Path(path)
326326
if not p.is_file() or not zipfile.is_zipfile(p): return Observation('android.apk_signing_inventory',Status.UNSUPPORTED,'Input is not an APK/ZIP')
327-
data=p.read_bytes(); block=_apk_signing_block(data)
327+
data,err=read_file_bounded_observation('android.apk_signing_inventory',p,1024*1024*1024); if err: return err
328+
block=_apk_signing_block(data)
328329
with zipfile.ZipFile(p) as z:
329330
names=z.namelist(); v1=[n for n in names if n.upper().startswith('META-INF/') and n.upper().endswith(('.RSA','.DSA','.EC','.SF'))]
330331
schemes=[]

‎src/dftk/primitives/binary.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import struct,re
1818
from dftk.core.registry import registry
1919
from dftk.core.models import Observation,Evidence,Status,SafetyLevel
20-
from dftk.core.helpers import printable_strings,sha256_file
20+
from dftk.core.helpers import printable_strings, sha256_file, read_file_bounded_observation
2121

2222
MACHINES={3:"x86",40:"ARM",62:"x86-64",183:"AArch64",243:"RISC-V"}
2323

@@ -64,7 +64,7 @@ def name_at(off):
6464
def elf_inventory(path:str,strings:bool=True,string_limit:int=2000)->Observation:
6565
p=Path(path)
6666
if not p.is_file(): return Observation("binary.elf_inventory",Status.ERROR,"ELF file not found",errors=[str(p)])
67-
data=p.read_bytes()
67+
data,err=read_file_bounded_observation('binary.elf_inventory',p,512*1024*1024); if err: return err
6868
try: info=parse_elf(data)
6969
except ElfError as e: return Observation("binary.elf_inventory",Status.UNSUPPORTED,"ELF parsing failed",errors=[str(e)],meta={"source_sha256":sha256_file(p)})
7070
rows=printable_strings(data,4)[:string_limit] if strings else []
@@ -80,7 +80,7 @@ def elf_inventory(path:str,strings:bool=True,string_limit:int=2000)->Observation
8080
def pe_inventory(path:str,strings:bool=False,string_limit:int=2000)->Observation:
8181
p=Path(path)
8282
if not p.is_file(): return Observation('binary.pe_inventory',Status.ERROR,'PE file not found',errors=[str(p)])
83-
data=p.read_bytes(); h=sha256_file(p)
83+
data,err=read_file_bounded_observation('binary.pe_inventory',p,512*1024*1024); if err: return err; h=sha256_file(p)
8484
if len(data)<0x40 or data[:2]!=b'MZ': return Observation('binary.pe_inventory',Status.UNSUPPORTED,'Not an MZ/PE file',meta={'source_sha256':h})
8585
peoff=struct.unpack_from('<I',data,0x3c)[0]
8686
if peoff+24>len(data) or data[peoff:peoff+4]!=b'PE\0\0': return Observation('binary.pe_inventory',Status.UNSUPPORTED,'PE signature not found',meta={'source_sha256':h})
@@ -106,7 +106,7 @@ def pe_inventory(path:str,strings:bool=False,string_limit:int=2000)->Observation
106106
def native_indicator_scan(path:str,limit:int=1000)->Observation:
107107
p=Path(path)
108108
if not p.is_file(): return Observation('binary.native_indicator_scan',Status.ERROR,'Binary not found',errors=[str(p)])
109-
rows=printable_strings(p.read_bytes(),4); categories={
109+
data,err=read_file_bounded_observation('binary.native_indicator_scan',p,512*1024*1024); if err: return err; rows=printable_strings(data,4); categories={
110110
'jni':re.compile(r'(?:Java_[A-Za-z0-9_]+|JNI_OnLoad|RegisterNatives)'),
111111
'crypto':re.compile(r'(?i)(?:AES|DES|RC4|ChaCha|EVP_|SHA(?:1|224|256|384|512)|MD5|Cipher|encrypt|decrypt)'),
112112
'url':re.compile(r'(?i)https?://[^\s"\']+'),

‎src/dftk/primitives/database.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def sqlite_inventory(path:str,count_rows:bool=True,table_limit:int=500)->Observa
3232
for typ,name,tbl,sql in rows:
3333
row={"type":typ,"name":name,"table":tbl,"sql":sql}
3434
if count_rows and typ=='table' and not name.startswith('sqlite_'):
35-
try: row['row_count']=con.execute(f'SELECT COUNT(*) FROM "{name.replace(chr(34),chr(34)*2)}"').fetchone()[0]
35+
try: row['row_count']=con.execute(f'SELECT COUNT(*) FROM "{name.replace(chr(34), chr(34)*2)}"').fetchone()[0]
3636
except sqlite3.DatabaseError as e: row['row_count_error']=str(e)
3737
objects.append(row); ev.append(Evidence(str(p),'sqlite_schema',sql or name,locator=f"sqlite_master:{typ}:{name}"))
3838
qc=con.execute('PRAGMA quick_check').fetchone()[0]
@@ -93,7 +93,7 @@ def sql_dump_inventory(path:str,max_bytes:int=256*1024*1024,statement_limit:int=
9393
if not raw: break
9494
scanned+=len(raw)
9595
line=raw.decode('utf-8','replace')
96-
if not buf and line.lstrip().startswith(('--','#')): continue
96+
if line.lstrip().startswith(('--','#')): continue
9797
buf+=line
9898
# Process complete-ish statements at semicolon boundaries. This is intentionally a schema/activity inventory, not a full SQL parser.
9999
while ';' in buf and statements<statement_limit:

‎src/dftk/primitives/email_auth.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import re, hashlib, os
2222
from dftk.core.registry import registry
2323
from dftk.core.models import Observation,Evidence,Status,SafetyLevel
24-
from dftk.core.helpers import sha256_file
24+
from dftk.core.helpers import sha256_file, read_file_bounded_observation
2525

2626

2727
def _decode(v:str)->str:
@@ -58,7 +58,7 @@ def _parse_dkim_domains(headers:list[str]):
5858
def auth_analyze(path:str)->Observation:
5959
p=Path(path)
6060
if not p.is_file(): return Observation("email.auth_analyze",Status.ERROR,"EML file not found",errors=[str(p)])
61-
raw=p.read_bytes()
61+
raw,err=read_file_bounded_observation('email.auth_analyze',p,256*1024*1024); if err: return err
6262
try: msg=BytesParser(policy=policy.default).parsebytes(raw)
6363
except Exception as e: return Observation("email.auth_analyze",Status.ERROR,"Email parse failed",errors=[str(e)],meta={"source_sha256":sha256_file(p)})
6464
from_name,from_addr=parseaddr(_decode(msg.get('From','')))
@@ -96,7 +96,7 @@ def dkim_verify(path:str)->Observation:
9696
if not p.is_file(): return Observation("email.dkim_verify",Status.ERROR,"EML file not found",errors=[str(p)])
9797
try: import dkim
9898
except ImportError: return Observation("email.dkim_verify",Status.UNSUPPORTED,"dkimpy is not installed",errors=["install optional dependency: pip install 'dftk[email]'"],meta={"source_sha256":sha256_file(p)})
99-
raw=p.read_bytes()
99+
raw,err=read_file_bounded_observation('email.dkim_verify',p,256*1024*1024); if err: return err
100100
try:
101101
ok=bool(dkim.verify(raw))
102102
except Exception as e:
@@ -122,7 +122,8 @@ def mime_inventory(path:str,include_body_preview:bool=False,preview_chars:int=10
122122
import hashlib
123123
p=Path(path)
124124
if not p.is_file(): return Observation('email.mime_inventory',Status.ERROR,'Email file not found',errors=[str(p)])
125-
try: msg=BytesParser(policy=policy.default).parsebytes(p.read_bytes())
125+
data,err=read_file_bounded_observation('email.mime_inventory',p,256*1024*1024); if err: return err
126+
try: msg=BytesParser(policy=policy.default).parsebytes(data)
126127
except Exception as e: return Observation('email.mime_inventory',Status.ERROR,'Email parsing failed',errors=[f'{type(e).__name__}: {e}'],meta={'source_sha256':sha256_file(p)})
127128
headers={k:str(v) for k,v in msg.items()}; parts=[]; attachments=[]; ev=[]
128129
iterable=list(msg.walk()) if msg.is_multipart() else [msg]

0 commit comments

Comments
 (0)