Release notes
Changelog
All notable changes to MemDiver are documented here. Format follows Keep a Changelog.
[1.0.1] — 2026-10-24
Fixed
VAS Map: the last map in file order wins (spec 1.1 §5.8), including maps in verified extension and sealed segments (
MslReader.effective_vas_map); maps are no longer merged. Applies to classification,inspect vas/session, heap names; materialize keeps it effective.
[1.0.0] — 2026-10-24
Added
MSL 1.1: EoC carries the exact block count (48-byte payload);
verify_chainchecks FileHash, header/EoC counts and extension segments; files read with version0x0101.Extension segments (
END_OF_EXTENSION0x0FFE): the pointer graph is now a chained, verified segment;add_extension_segment,iter_extension_blocks,collect_end_of_extension; materialize re-emits verified segments.VAS Map entries carry
name_source; the trust rule prefers it over the provenance marker. Thread Context (0x0011) decoded per spec 1.1 Section 5.7.PageState.NOT_RESIDENT(value 3) shown as such in inspect output.Thread Context (0x0011) written:
MslWriter.add_thread_context(sets CapBitmap bit 2); ELF-core (x86_64/aarch64) and minidump (AMD64/ARM64) imports emit one block per thread before the first Memory Region (msl/import_threads.py).Materialize folds every verified sealed extension segment (spec 10.8) into an ordinary segment of the copy (after the in-stream ones; blocks, Producer and WrittenAt kept; re-chained in plaintext copies, inside the new ciphertext in re-encrypted ones); provenance note gains
sealed_segments_folded=N.add_extension_segment(..., written_at_ns=)sets a segment’s WrittenAt.
Changed
add_raw_appendixraises; useadd_extension_segment.Docs:
docs/file_formats/msl_v1_1_0.mdmirrors spec 1.1.
[0.10.0] — 2026-10-23
Added
Heap allocator awareness. A new
heap/layer detects the allocator behind a dump and parses its structures into chunks, free lists, regions and a role for every heap byte (chunk_header,user_data,free_link,free_data,allocator_meta,top,slack). Built-in parsers:glibc ptmalloc2 (L0–L3): tcache with and without safe-linking,
main_arenadiscovery, then fastbins and the unsorted, small and large bins;dlmalloc (L0–L2);
scudo (L0–L2, partial; headers are judged on structure because the checksum cookie is per-process);
musl mallocng (L0–L2);
jemalloc (partial: region attribution plus slot-stride inference);
a generic boundary-tag heuristic.
Works on
.msl, ELF cores, regioned raw dumps and flat raw dumps (with a base VA), strips arm64 TBI pointer tags, and never raises on a parse failure: it falls back to the next candidate and records why. Library:memdiver.analyze_heap,detect_allocator,HeapLayout,HeapRole,register_allocator.heap.*on every surface. The six capabilitiesheap.detect,summary,chunks,chunk_at,free_listsandrolesare available as:CLI
memdiver heap {detect,summary,chunks,at,free-lists,roles}, with--allocator,--base-vaand--json;REST
GET /api/heap/{detect,summary,chunks,chunk-at,free-lists,roles};MCP tools
heap_detect,heap_summary,heap_chunks,heap_chunk_at,heap_free_listsandheap_roles(56 tools in total);memdiver.heap_*_resultproducers.
Allocator plugins. The
memdiver.allocatorsentry-point group lets an installed package replace a built-in allocator parser. Seedocs/contributing/adding_allocator.md.Heap roles in consensus.
build_consensus(..., allocator="auto")projects every dump’s heap roles onto the consensus slab and merges them. Bytes where the dumps disagree becomemixed, and a per-byte agreement count measures layout stability. Raw-only corpora reportno_va_coordinate. The consensus itself is never changed. The pipeline writesheap_roles.npzand a"heap"block inconsensus.json. Choose it with--allocator(CLI) /allocator(web, MCP).heap_policyfor candidate reduction (annotateby default,mask,off):annotateaddsrole_counts, the owning chunk,residual(the region starts in a freed chunk) andagreementto each region without changing candidates or scores;maskdrops allocator-owned bytes (headers, free links, metadata, top) and adjusts scores;offreproduces the previous output byte for byte. Set with--heap-policyonanalyze-candidates/search-reduceandheap_policyon the web candidates request and MCP tools.
locate-keyhits name their chunk:heap: {allocator, chunk_va, chunk_size, state, offset_in_chunk, role}per offset, so a key left in a freed chunk is visible as residual.Heap mode in the web UI. A third app mode next to verification and exploration. It has an allocator overview (confidence, evidence, partial/heuristic badges, regions), a chunk table with jump-to-chunk, a size histogram, a free-list tree, a chunk inspector, and a hex heap layer that colors bytes by role and ticks chunk starts, with a legend and a toolbar toggle. The layer is drawn in all three themes.
Docs:
docs/user_guide/heap.md,docs/architecture/heap.md,docs/contributing/adding_allocator.md, plus a heap mode section in the web UI tour.MEMDIVER_REAL_HEAP_DUMPS(with optional<dump>.expect.jsonsidecars) enables opt-in tests against real captures.capture: one dump of a running process, locally, on a USB/Android device, or on a remote server. CLImemdiver capture <pid|name> <out> --tool --device local|usb|usb:<id>|remote:<host>[:<port>], RESTPOST /api/experiment/capture, MCPcapture_dump, a web-UI form in the Experiment panel andmemdiver.capture_result(). All of them use the same producer, which returns a capture report listing every region that was not captured (base, size, reason). The memslicer backend now callsmemslicer acquire … -owith-U/-D/-R; the oldmemslicer capturecall was not a valid memslicer command.lldb captures large regions. Readable regions are streamed in 8 MiB chunks instead of being dropped silently at 100 MB, and unreadable pages are isolated page by page. Non-writable regions of 100 MiB or more (the macOS shared cache) are skipped by default but reported (use
--include-large-readonlyto capture them). Skips, read failures and timeouts go into the report and into a<dump>.capture.jsonmanifest, whichimportand the import preview turn into warnings.fridump
.mapssidecars carry real names and permissions where they can be found: the target’s/proc/<pid>/maps, read locally or through a short Frida session, else Frida’senumerateRanges. A sidecar without names is marked in the manifest, and the import preview warns about it.classification_basis_mixedalignment warning on every consensus surface. A region-filtered alignment that mixes dumps classified by mapping names (new imports / live captures with a named VAS map) with dumps classified by stored region type only can mis-pairanon:regions. This used to be only logged; it is now a structured diagnostic ({code, message, severity, details}, thecore.service_result.Diagnosticshape) onAlignmentReport.diagnostics/AlignmentCoverage.diagnostics/MslIncrementalBuilder.diagnostics, inalignment.diagnosticsof every aligned payload, hoisted asdiagnosticsonPOST /api/analysis/consensus, the CLIconsensusJSON (plus a stderr warning and hint onconsensus,consensus-window,consensus-regions), the MCPconsensus/aligned_window/consensus_regions/analyze_candidatesoutputs, and a localized web-UI banner suggesting the older dumps be re-imported.Android-aware region classification by mapping name. When a region’s full mapping name is known (
/proc/<pid>/mapspath column, ELFNT_FILEpath, or a trusted.mslVAS map entry),msl/region_names.pyrefines its category: Android native heaps ([anon:scudo:*],jemalloc,GWP-ASan, ART heap spaces such as[anon:dalvik-main space]) →heap, bionic thread stacks ([anon:stack_and_tls:*]) →stack. A heap name on a shared mapping staysshared. The stored region type is unchanged, so alignment keys do not change. The rules and their deliberate exclusions ([anon:.bss], binder,/dev/shm, app files) are specified indocs/file_formats/msl_v1_0_0.md;tests/fixtures/region_name_vectors.jsonholds the normative test vectors, shared byte-identical with memslicer.New
runtimeregion category, excluded by default. It holds regions classified by name as runtime / system memory rather than app data: zygote and boot-image ART spaces, ART GC metadata, JIT caches,linker_alloc,cfi shadow, property areas, boot.art/.oat/.vdex/.odexand ICU data, and kernel / device mappings. It is accepted by--regions/--exclude-regions, MCPregion_types/region_filterand RESTregions, but it is not in the interactive default (heap,stack,unclassified) and is prompt number8. A selection naming the seven older categories means “all butruntime”.Import preview fields
platform(android/linux/windows/ …),platform_source(mapping_names/header/format),runtime_count,runtime_bytesandregions[].name, plus a note on what the default selection drops.--list-regionsprints aplatform:line and aruntimerow.Mapping names persisted in the
.mslVAS map. ELF-core and regioned-raw imports now always write aVAS_MAP(0x1001) with every region’s full mapping name asmapped_pathand mark it with aregion_names=1provenance note segment (ProvenanceNoteFields.region_names). Materialize copies the names and the marker. Readers trust the names only with that marker, or for a live memslicer capture (no provenance,IMPORTEDclear) that has a VAS map, so older files classify exactly as before. ELF-core and regioned-raw imports whose names look Android writeos_type = ANDROIDin the header (previously alwaysLINUX).Alignment classification-basis warning. A filtered alignment that mixes dumps classified by mapping names with dumps classified by stored type only logs a warning, since the same filter can keep a region in one dump and drop it in another. Live memslicer captures with name-based stored types are folded back to the legacy alignment key class, so old and new captures keep identical keys.
Changed
[vdso],[vvar]and device mappings now classify asruntimeon new Linux imports too. The kernel / device name rules are not Android-specific: ELF cores and maps-sidecar raw dumps imported from now on (and live memslicer captures with a VAS map) put[vdso],[vvar],[vsyscall],/dev/*(except binder,/dev/shm,/dev/zero),/dmabuf*andanon_inode:*mappings intoruntime, which the default selection excludes. Pass--regions …,runtimeorallto keep them.Version 0.10.0. Heap allocator awareness is the headline feature. The
heapCLI group brings the subcommand count to 35, and the heap router brings the router count to 19. The docs landing page and architecture overview now show these counts.
Fixed
MCP tool count in the docs. The docs landing page still said 48 MCP tools. It now says 56, matching
mcp_server/server.py(which includes the six newheap_*tools).
[0.9.5] — 2026-10-21
Changed
MCP server migrated to the mcp 2.x SDK. mcp 2.x removed
mcp.server.fastmcp; the server is now built onmcp.server.mcpserver.MCPServer(FastMCP’s successor) and reports the package version. The dependency is nowmcp>=2.2,<3.Frontend dependencies bumped: i18next 26, react-i18next 17, jsdom 30, @types/node 26. jsdom 30 requires a newer Node, so the frontend
enginesrange is now^22.22.2 || ^24.15.0 || >=26and every CI / publish / docs-screenshots workflow runs on Node 22. Two jsdom-30 test adjustments: computedbackgroundshorthands usingvar()are no longer resolved, so the affected tests assert onbackground-color/ inline style instead.Docs toolchain bumped to Sphinx 8.2.3, myst-parser 5.1.0 and sphinx-design 0.7.0 (
docs/requirements.txtand thedocsextra).marimofloor raised to>=0.23.9(pluspymdown-extensions>=11.0.1) to clear pip-audit advisories. The old open floor let pip backtrack to marimo 0.15.1, which failed to import against the websockets version pinned by frida-tools.The pytest
testCI job runs in parallel (pytest-xdist,-n auto).Frontend is eslint-clean (0 errors). All 22
react-hooks/set-state-in-effectfindings were refactored to render-time state adjustment or to the new shareduseFetchJson/useFetchTexthooks (noeslint-disable); theme context and file-browser helpers moved out of component modules for fast refresh;exhaustive-depswarnings fixed via pure store helpers. The entropy tab no longer gets stuck on “loading” when left mid-fetch. The entropy / VAS fetch-failure notifications are now localized (layout.entropyFetchFailed,layout.vasFetchFailed) instead of hardcoded, as are the hex / consensus store fetch errors (hex:fetchErrors.*). A PipelinePanel test whose cold dynamic import straddled vitest’s 5s default timeout no longer flakes.The REST API’s OpenAPI
versionnow reports the package version instead of a hard-coded0.1.0.
Fixed
memdiver mcp --sse --port Ncrashed with aTypeError: the port was passed torun(), which the 1.x SDK did not accept. With the 2.x SDK, host (127.0.0.1) and port are passed torun()explicitly.A broken marimo install was reported as “Marimo not installed”. The
/notebookmount now distinguishes a missing package from one that fails to import and logs the real error.CI test fixtures were never committed: the
*.jsonignore rule swallowedtests/e2e/fixtures/pcap/manifest.json,keylog/expected.json,verify_key/verify-fixture.jsonandtests/fixtures/tshark/secrets.json; they are now un-ignored.The route-protection test failed on FastAPI ≥ 0.141, whose included routers are no longer flattened into
app.routes. The test walks routes viafastapi.routing.iter_route_contextswhen available (falling back to the flat list) and asserts a minimum route count.The favicon test is skipped when no frontend build is present, and the e2e landing-page check targets the “Sessions” heading by role instead of an ambiguous text match.
memdiver locate-keytreated a missing dump path as “searched and absent”. A path that did not exist was dropped with a log record and the remaining dumps were searched, so the command could answerabsent(exit 3) over a dump nobody read. A missing path is nowINVALID_INPUT:memdiver: ERROR — Dump path(s) not found: …, exit 2, no payload. Files directly inside a passed directory that match no dump pattern now get onememdiver: note — skipped N file(s) in DIR with unrecognised extensions …line on stderr (dotfiles and the sidecar suffixes incli/_shared.py:_NON_DUMP_SUFFIXESexcluded), andlocate-key --helpprints the exit-code table. Every other command that resolves dump paths (consensus,scan-yara,export, …) still skips a missing path, but now says so on stderr (memdiver: WARNING — skipping non-existent path: X) instead of in a log record, and prints the same unrecognised-files note.POST /api/analysis/locate-keycould not take apcap_field. The CLI (--pcap-field) and the MCP tool accepted the symbolic{pcap_path, field_id, client_random?}form; the HTTP route did not. It now forwardspcap_fieldto the producer, which validates it (an unknown key is a 400) and existence-checks the capture.DELETE /api/oracles/orphansanswered 500 when no oracle directory was configured.OracleDisabledescaped unhandled; it now maps to the same 503 every other writing oracle endpoint returns.CORS refused
PUT, whichPUT /api/settings/last-dirneeds.allow_methodsis nowGET/POST/PUT/DELETE/OPTIONS;PATCHis still refused (no route uses it).MEMDIVER_PORTnever changed the portmemdiver webbound.--portdefaulted to a literal8080, so the setting was only echoed in the startup log. Precedence is now--port>MEMDIVER_PORT/.env>8080, and the startup log reports the port actually bound.The new-session dialog silently overwrote a saved session of the same name. It now fetches the saved-session list when it opens and warns “A session named … already exists and will be overwritten.” (The warning does not block saving.)
locate-keywas missing from the README and the published CLI reference (#27).sphinx-argparseandautodocimport thememdiverpackage, which the docs workflow never installed, so the CLI reference could not regenerate..github/workflows/docs.ymlnow runspip install -e .,docs/dev/anddocs/issue-drafts/are excluded from the published site, and the README listslocate-key. New pages: a locate-key guide (docs/user_guide/locate_key.md) and a configuration page (docs/user_guide/configuration.md) listing everyMEMDIVER_*variable.The web server could not be shut down, so
kill -9was the only way out — and that leaked semaphores and orphaned processes permanently. Killingmemdiverprintedresource_tracker: There appear to be 5 leaked semaphore objects. The five are the fingerprint of exactly one un-finalizedProcessPoolExecutor(_call_queuecontributes_rlock/_wlock/_sem,_result_queuetwo more) — the app-lifetime pool inTaskManager. They leaked because SIGKILL skips everyatexithandler, and SIGKILL was only ever needed because a graceful shutdown could not finish. Four independent reasons, all now fixed:_gated_submitawaited the pool future vialoop.run_in_executor(None, future.result), parking a thread of asyncio’s default executor for a task’s whole duration, and the progress drain parked a second one permanently in an unboundedq.get(). uvicorn serves underasyncio.Runner, whoseclose()callsloop.shutdown_default_executor()— and on Python 3.11 that joins its threads with no timeout (thetimeoutparameter only arrived in 3.12). One live task was enough to hang Ctrl-C forever. Nowasyncio.wrap_futurebridges the future with no thread at all, and the drain read is bounded byDRAIN_POLL_S.shutdown()calledpool.shutdown(wait=False), which does not stop a worker that is already running — andconcurrent.futures.process._python_exitjoins it at interpreter exit with no timeout of its own, merely relocating the hang. Teardown is now ordered and bounded at every step: cancel events first (they are Manager proxies, so they must be set while the Manager still lives), then the drain, then the pool with its workers force-reaped, then the Manager in afinally. A newaclose()is the async twin the FastAPI lifespan uses;shutdown()stays synchronous forreset_task_managerand the test fixtures.ProgressBus.close_tasksetchannel.closed = Truebut pushed nothing, andsubscribeonly re-checks that flag beforeawait q.get()— so a subscriber already parked there never woke: its iterator leaked for the life of the process, and the WebSocket handler, still suspended inside theasync for, never calledreceive()and so never saw the disconnect.close_tasknow posts aNonesentinel, so a task reaching a terminal event releases its subscribers, andclose_all()does the same for every channel. Known follow-up: this does not by itself unblock a WebSocket parked at shutdown.close_all()runs fromTaskManager.aclose(), i.e. from the lifespan shutdown, and uvicorn’sServer.shutdown()drains open connections before it runs that — so the sentinel necessarily arrives after the moment it would have to act. What bounds that wait today istimeout_graceful_shutdown(see below); the real fix is for the WebSocket handler (api/ws/progress.py) to awaitwebsocket.receive()concurrently with the subscribe loop so it notices the close frame itself.memdiver webran uvicorn with notimeout_graceful_shutdown(it defaults to waiting forever) and nofinally. It now bounds that window — from the newSettings.web_graceful_timeout_s(10s,MEMDIVER_WEB_GRACEFUL_TIMEOUT_S), so every launch path and deployment sees the same knob — and tears the task manager down on the force-exit path, where uvicorn skipslifespan.shutdown()entirely.
A hard kill orphaned the
multiprocessing.Managerprocess forever. CPython’sBaseManagerdoes not start its server as a daemon andServer.serve_forever()has no parent-death check, so everykill -9left a Manager running with its unix socket andpymp-*temp dir — 8 such orphans (up to 18 days old) and 147 stale directories had accumulated. Newcore/process_guard.pyarms a parent-death watchdog inside the Manager server and every pool worker via the documentedstart(initializer=...)/ProcessPoolExecutor(initializer=...)hooks: a daemon thread notices the process has been reparented and exits, running multiprocessing’s finalizers first so the temp dir goes with it. Nothing based onatexitcan cover SIGKILL; the child has to notice on its own.The oracle smoke test could never go green, so it proved nothing. The web UI built its 16 samples client-side as a hard-coded arithmetic ramp (
bytes[j] = (i * 31 + j) & 0xff) and posted them to/dry-run. They were never key material and never touched a dump, so a correct oracle scored 0 pass / 16 fail — and so did an oracle whoseverify()was wired toreturn False. The bar could not distinguish the two, which is the one thing a smoke test exists to do. It also looked identical armed and unarmed, because the input and the code path were byte-identical (dry_runignoresarmedby design, so a user can triage before committing to arming).New
POST /api/oracles/{id}/smoke-testcomposes the samples server-side: one positive control — the master key the run’smeta.jsonrecords, found via the sameRunDiscovery.meta_for_dumpthe config autofill already uses — plus N candidates read at random offsets from the dump itself.The result is a verdict, not a pass count:
discriminates,accepts_noise(the oracle accepts arbitrary memory — a non-detector that would flood a real sweep),never_accepts,no_positive_control(no ground truth was available; reported as its own state, never as a failure — thepositive.okfield is tri-state and staysnull) andinconclusive(the key was accepted but nothing was rejected, so only half the claim holds).Negatives are read in the
vasview. Raw offsets into an.msladdress the container — file and block headers, the hash chain — not process memory, andvapads unmapped pages with synthesized0x00; either would have fed the oracle bytes the dump never captured.A drawn sample equal to the positive control is resampled. The real key genuinely occurs in these dumps, so an unguarded draw yields a bogus “accepts noise” verdict.
The response never contains sample bytes: the positive control is the run’s master key.
dry_runkeeps its exact contract; the grading and loading halves were extracted into shared helpers rather than duplicated.
Per-sample oracle errors were computed and then thrown away. The backend already returned
{"index": i, "ok": false, "error": repr(exc)}, but the UI usederroronly as a boolean to pick a grey dot colour. The message now has a rendering site.The Oracle stage’s Next button was invisible, sitting below the entire pcap block, and the stepper deliberately disables forward jumps — so the way forward could not be found at all. The advance controls are now pinned, the pcap alternative is collapsed by default, and a disabled Next says why.
The oracle registry was memory-only.
configandarmedlived solely in_entries, so a restart dropped every config, orphaned the.pyfiles and made the pipeline’s storedoracleId404 mid-run — with the wizard’s Next button silently going dead because its entry had vanished. Entries now persist to a<uuid>.jsonsidecar (0o600, written viaatomic_write_text, which gained amode=kwarg applied to the tmp file before the replace) and rehydrate at startup.The
.pyis re-hashed on rehydrate and a mismatch is refused: the stored sha exists to givearm()’s display-vs-disk check something truthful to compare against.shapeis read from the sidecar, never re-derived —_detect_shapeimports the user module, which would execute every stored oracle at boot.armedalways rehydrates asFalse. Arming is an authorization act bound to a sha the user saw and to a strict sandbox replay of a filesystem state that may have changed while the server was down; restoring the entry is what fixes the 404, and re-arming costs one click.previously_armedreports the earlier consent so the UI can say so.Orphaned files are logged, never auto-deleted;
GET /api/oracles/statuslists them andDELETE /api/oracles/orphansremoves them on request.
Security
The upload directory is no longer a hardcoded, world-writable
/tmppath.api/config.pydefaultedupload_dirto/tmp/memdiver_uploadsandapi/main.py’s lifespanmkdir’d it on everycreate_app(), so a predictable,drwxrwxrwt-parented directory was created eagerly — holding uploaded packet captures and memory dumps, and doubling as the containment root every server-side write path is checked against (ensure_within). It was the repository’s only MEDIUM bandit finding (B108).upload_diris now configure-on-first-use: unset by default, and the first upload asks the user where to store data rather than silently picking another location for them.Unconfigured fails closed. There is no permissive fallback, because a fallback would widen the write-containment checks instead of failing them.
api/dependencies.upload_dir_or_409()is the single guarded entry point and returns HTTP 409 with a token-prefixed detail (upload_dir_unconfigured: …) — 409 because the request is well-formed and nothing is broken; the user simply has to choose.None, notPath(""), is the unset sentinel:Path("") / "pcaps"is the relative pathpcaps, so an unguarded consumer would have written into the server’s CWD, whereasNone / "pcaps"raises loudly.The chosen directory is validated, not just accepted: absolute only, and rejected if it is or sits inside a system location,
sys.prefix(a write intosite-packagesis a.pthimport-hijack primitive), or any temporary directory (tempfile.gettempdir(),/tmp,/var/tmp) — the last rule being the whole point, since swapping one world-writable temp default for a user-chosen temp path would be theatre. Bare$HOMEis refused in favour of a subdirectory. Writability is proven with a real temp-file write rather than inferred fromos.access(W_OK), which lies under POSIX ACLs, read-only mounts and macOS SIP. The directory is created and pinned0o700.The one-time migration out of the legacy
/tmpdirectory is hardened against a world-writable source: it refuses if that directory is a symlink or is not owned by the current user, skips any entry that is itself a symlink (otherwise “migrate” would relocate an attacker’s link into the user’s new private directory), skips name collisions instead of clobbering, and removes the old directory only once it is genuinely empty.The choice is persisted in the user-local, git-untracked
~/.memdiver/config.json— never the repo-root trackedconfig.jsonthatdataset_rootmerges from, since writing a tracked file would dirty every clone. The write is read-modify-write and atomic (.json.tmpchmod 0o600, thenreplace), so a crash cannot truncate the file and lose the setup wizard’sskip_duckdb_setupflag that shares it.MEMDIVER_UPLOAD_DIR(and.env) still always wins. When it is set, the settings endpoint reportsenv_pinnedand refuses to persist a value from the UI: writing a file that would then be silently shadowed forever is worse than refusing.
Fixed
A vol3 export disagreed with the YARA rule it embeds.
app/export_service.py::_render_contentpassedkey_offset/key_lengthtoYaraExporterbut not toVolatility3Exporter, which takes no such parameters and reads them off the pattern dict – defaulting to0and to the full pattern length. So a padded-window export emitted a plugin whose embedded rule saidkey_offset = 256while its own constants saidKEY_OFFSET = 0/KEY_LENGTH = 560. Run under real Volatility3 it reported the entire window as the key:KeyOffset == PatternOffset,KeyHexthe whole padded window, andKeyEntropydiluted by the padding (2.04 instead of 5.42) – looking successful while finding something useless. Affected every--format volatility3export throughauto_export_pattern/manual_export_pattern/located_export_pattern;engine/vol3_emit.pywas never affected because it enriches the dict itself. Found by actually running the emitted plugin against the reference corpus. Fixed by enriching a copy of the pattern (the original is returned in the producer payload and must not be mutated), and pinned bytest_vol3_plugin_constants_agree_with_its_embedded_yara_rule, which asserts the constants and the embedded rule’s meta agree.The emitted Volatility3 plugin: three real bugs, and its
_versiongoes(1, 1, 0)→(2, 0, 0). All three were found by running the emitted artifact instead ofast.parse-ing it (engine/vol3_verify.pyin-process,engine/vol3_subproc.pythrough a realvol). Everyone who re-emits a plugin gets different bytes, hence the major bump and the regenerated review artifacttests/fixtures/vol3_plugin_pin/default_pad_plugin.py.golden. The YARA golden beside it is unchanged — none of this touches the pattern, only the plugin._required_framework_versionstays(2, 0, 0).(a) The emitted plugin did not import. The template imported
renderersand then usedrenderers.format_hints.Hexat module level in_COLUMNSand_generator.renderersis a package: importing it does not bind itsformat_hintssubmodule, so a bare import raisedAttributeError: module 'volatility3.framework.renderers' has no attribute 'format_hints'. It only ever worked by accident of import order — 111 files undervolatility3/framework/plugins/dofrom volatility3.framework.renderers import format_hints, and once vol3’s ownimport_filesplugin walk has run the submodule is bound on the parent for the rest of the process, which is exactly what happens when a user runsvol. The template now does that import itself.tests/test_vol3_verify.py::test_emitted_plugin_module_level_format_hints_is_a_bugwasxfail(strict=True)and is now green with the marker removed; its control test proves themonkeypatch.delattris what makes it non-vacuous.(b)
--pidwas dead code that silently scanned everything. The emitted requirements carried nokernelModuleRequirement, so_try_pid_scan’sconfig.get("vmlinux")/config.get("nt_symbols")were alwaysNone; and it calledlist_tasks/list_processesas(context, layer_name, symbol_table_string)while 2.27.x wants(context, <module name>, filter_func)— argument 2 raisedKeyError: 'primary'and argument 3 raisedTypeError: 'str' object is not callable. Both were swallowed byexcept Exception: continue, so--pid 1234scanned the whole layer and said nothing. Now: an optionalrequirements.ModuleRequirement(name= "kernel", …)(optional is load-bearing — a mandatory one would failPluginInterface.__init__’s requirement gate on exactly the flat process dumps this plugin exists to scan, while vol3’sKernelModuleautomagic callsrequirement.unsatisfied()directly and so still fills an optional one when a kernel image and symbols exist); a_pid_scan(pid)that passesconfig["kernel"]as the module name and eachPsList’s owncreate_pid_filter([pid])callable asfilter_func(which filters out non-matches, so the old manualproc.pid == pidcomparison is gone); and an OS branch chosen from the kernel symbol table’s metadata class (LinuxMetadata/WindowsMetadata), falling back to trying both in order. When a--pidwas asked for and could not be honoured,run()now emits a loudvollog.warningnaming the PID and saying the results are not restricted to that process, instead of quietly returning a whole-layer scan.PID narrowing itself remains unproven. What is covered: the declared requirement set, that the plugin validates and scans with no kernel module, that the automagic does not skip an optional
ModuleRequirement, the loud warning, and the argument shape (via a stubpslistinjected intosys.modules). What is not:proc.add_process_layer()and the process-layer scan after it, which need a real kernel memory image plus a matching ISF that neither this repository nor the development machine has. Do not read the new tests as end-to-end--pidcoverage.
(c) The plugin scanned the wrong layer — the default scan target is now the physical/file layer. MemDiver’s raw dumps begin with
7f 45 4c 46and havee_type = ET_DYN, so they look like an ELF to vol3’sLayerStackerwhile carrying no PT_LOAD table covering the dump. Measured two independent ways on one 11,223,040-byte ground-truth dump: in-process the stack came out['base_layer', 'primary']withprimaryanElf64Layerexposing ~6 KB (max 0x232b), so the plugin scanned ~6 KB of an 11 MB dump; out-of-process the realvolprintedScan Failure: Sections have no size, nothing to scanand returned[]for the pad-128 plugin that finds the key in 0.02 s in-process. The template now walkslayer.dependenciesdown to the lowest layer — complete, and addressed in file offsets, which is the space MemDiver’s own output is in — with aseenset against cycles and avollog.infonaming the chosen layer and its size. A new--virtual(BooleanRequirement, optional, defaultFalse) opts back into the configured translation layer, which is the right choice for a genuine kernel image. Verified: the realvolCLI now returns exactly one row atKeyOffset 370672,PatternOffset 370544, with key hex identical todata[370672:370720], where the same invocation previously returned[](tests/test_vol3_subproc.py, against both the 2.27.0 console script and the 2.27.1 source checkout); and in-process a CI-runnable model of the truncated stack finds the key only once the walk reaches the lowest layer, while--virtualon the same stack finds nothing (tests/test_vol3_verify.py). Flipping the default is safe because nobody can have been relying on zero hits where the key demonstrably is.Four of five bare
except Exceptionswallows narrowed; the fifth kept. The rule applied was keep the degrade that is legitimate, kill the one that hides an API mismatch. Both per-hitlayer.readguards are nowexcept exceptions.InvalidAddressException(a window straddling the end of a mapped section is expected; anything else is a bug). TheRegExScannerguard now covers only the regex’s construction (except re.error, with a warning) and leaveslayer.scanunguarded — alayer.scanAPI mismatch used to degrade to the weakerBytesScannerand report “no results”, which is indistinguishable from “the key is not there”. The singleimport yara/yara.compileguard is split:except ImportErrorwarns and disables YARA verification (legitimate — the plugin runs in the user’s vol3 environment, which need not carry yara-python), whileexcept yara.SyntaxErrorlogs at error (a rule that will not compile is a defect in MemDiver’s own emitter)._pid_scan’s OS loop catchesImportErrorfor the module import and(SymbolError, InvalidAddressException, KeyError, TypeError)for the listing, each atvollog.debug, because the wrong-OS branch legitimately raises those.
Changed
POST/GET /api/settings/upload-dirand a Settings → Storage panel. Newapi/routers/settings.pyreports where uploads are stored (path, source,env_pinned, quota, and any offerable legacy directory) and accepts a chosen path, returning 400 with the specific validation reason on rejection. The cachedSettingssingleton is mutated in place rather thancache_clear()-ed, because the API-token middleware and the startupArtifactStorehold that exact instance and would otherwise be stranded on a stale object. In the UI, a passive Storage section shows and edits the path; the load-bearing path is the pcap upload itself, which catches the 409, prompts for a directory, and then re-runs the upload with the same file so the user never loses the capture they just dropped. There is deliberately no blocking first-run modal — inspect, analysis, consensus, brute-force, structures and sessions never touchupload_dir.Brute-force
--stridenow defaults to1(was8). The candidate grid is absolute, so the old default handed the oracle only 8-aligned offsets and a secret at, e.g., offset585148was never tested — the run ended successfully with zero hits, indistinguishable from “the key is not in this dump”. The new default sweeps every offset (coverage_fraction == 1.0) on every product surface (CLI, MCP, web API, pipeline runner, engine, frontend); the Phase-A experiment (engine/candidate_stats.py::run_phase_aandscripts/phase_a_experiment.py) stays at8as frozen paper reproduction. Raising the stride is now an opt-in speed tradeoff. The web UI’s Replicate gocryptfs IMF recipe still pinsstride 8to reproduce the published result.Existing browser sessions are migrated. The pipeline wizard’s form is persisted to
localStorage, so a browser that had already stored the oldstride: 8would have gone on running at 15.7 % coverage no matter what the shipped default said — the new default only ever applied to a fresh profile. The pipeline store moves to schemaversion 2with a targeted migration that rewrites only a stalebruteForce.stride === 8to1. A hand-picked stride (2, 4, 16, …) is left alone, and every other saved field — selected dumps, armed oracle/pcap, reduce thresholds, wizard stage, in-flight task id — is carried across untouched. Nobody has to clear site data to get the fix.The candidate pre-count is now closed-form.
count_candidate_slicesran a full discard-iteration over the grid before every sweep purely to produce a progress denominator — ~700k wasted steps per run at the new default, and once per N in an n-sweep. It now delegates to the O(1)count_region_gridthat already backedcount_possible_candidates(~37 s → sub-millisecond on a 4·10⁸-window span). Counts are unchanged; the two functions are the same arithmetic and the equivalence is pinned.
The brute-force stage is cancellable mid-sweep on the web and MCP surfaces. The producer passed only
progress_callbackto the engine and consultedis_cancelledexactly once, before the first candidate, which leftengine.progress.check_canceldead code on those paths — so a cancel was ignored for the whole run. Tolerable over 110k candidates; not over the ~700k the stride-1 default now enumerates. The cancel token is threaded into the sweep and the engine’sCancelledis re-expressed as the app layer’s canonicalcode="cancelled"signal, so it is indistinguishable from one observed at a stage boundary.MSL implementation declares conformance with the official Memory Slice Specification v1.0.0 (https://github.com/MemorySlice/memslice-spec):
Reader rejects unknown MSL major versions per spec §3.4 and rejects any Endianness byte other than
0x01per spec §3.1 (strict consumer rules).Writer now sets the Capability Bitmap accurately per spec §9 (MUST), with bits OR’d in for every emitted data category (MemoryRegions, ModuleList, CryptoHints, RelatedDumps, ProcessIdentity, SystemContext, Process / Network / Handle tables).
Writer validates
PageSizeLog2 ∈ [10, 40]andRegionSize % PageSize == 0per spec §5.1; the decoder rejects out-of-rangePageSizeLog2per spec §14.2 rule 10.Writer adds full Investigation-mode support per spec §6: Process Identity (Block 0), Module List Index + Module Entry children (Block 1), System Context (Block 2), and Process/Connection/Handle table children. Block ordering is enforced at
write()time.Writer emits the spec’s three-state page model (CAPTURED / FAILED / UNMAPPED) when
add_memory_region(..., page_states=...)is supplied; legacy callers withoutpage_statescontinue to get all-CAPTURED maps.msl/importer.pyzero-pads raw.dumpinputs to a page boundary so imported regions satisfy spec §5.1; the original size is preserved inIMPORT_PROVENANCE.orig_file_size.Documentation file
docs/file_formats/msl_v1_1_0.mdrenamed tomsl_v1_0_0.mdto reflect the spec document version (the on-wire binary format byte at file header offset0x0Aremains0x0101, which the spec calls “format 1.1”).
Interface deps moved to extras (breaking):
pip install memdivernow installs only the library + CLI core. The FastAPI web UI + REST API (fastapi/uvicorn/pydantic-settings) live behind[api], the MCP server behind[mcp], and the legacy Marimo UI behind[marimo].[all](=memdiver[api,mcp,marimo]) reproduces the previous all-in-one install.kaitaistructand the crypto stack remain base deps;[experiment]and[dev]are unchanged.memdiver experimentnow surfaces an actionable install hint (pip install memdiver[experiment]) when the experiment extras are missing, instead of raising a bare ImportError.LICENSEfile replaced with the canonical Apache License 2.0 text (was previously MIT). Thepyproject.tomlclassifier was updated toLicense :: OSI Approved :: Apache Software Licenseto match.Install-hint messages in
memdiver web/app/mcp/uinow point at the specific extra (memdiver[api]/[mcp]/[marimo]) when the corresponding dependency is not installed.YARA rule names, tags and meta strings are now unconditionally sanitized — a caller-supplied
rule_namecan change.YaraExporter.exportused to sanitize the name only on the branch whererule_namewasNone(it readrule_name or _sanitize_identifier(pattern["name"])), so a name the caller supplied went into the rule verbatim. YARA’s grammar wants an identifier there, so a name containing a hyphen, a dot, a space, or one that happens to be a reserved word (filesize,condition,ascii, …) produced a rule that does not compile — and nothing in the tree ever compiled the emitted rule, so the breakage only showed up when the operator fed the.yartoyara/yara-pythonthemselves.POST /api/architect/exportpassesrule_namestraight from the request body, so that endpoint could emit an unusable rule for any name a user typed. Now every caller-supplied string is normalised on the way in: the name becomes an identifier (ASCII[A-Za-z0-9_], never leading-digit, never a reserved word, capped at libyara’s 128-character identifier limit), each tag is put through the same filter, anddescription/static_ratioare escaped for a double-quoted meta string (backslashes doubled first, then quotes escaped, then every control or line-breaking character collapsed to a space) so a quote or a newline can no longer terminate the literal early. The visible consequence is that a name likemy-rule.v2is silently rewritten tomy_rule_v2, so a downstream tool matching on the exact rule name must expect the sanitized form. Two deliberate asymmetries are worth knowing: a reserved word in the name position is rescued with anr_prefix (filesize→r_filesize) because a rule must have some name, whereas a reserved-word tag is dropped entirely — a tag is decorative, and silently renaming it tor_asciiwould be more surprising than losing it. Blank and duplicate tags are dropped for the same reason. The keyword list was verified against yara-python 4.5.4 rather than copied from the docs.yara-pythonis now a base dependency, sopip install memdivergrows a compiled C extension (libyara). It is deliberately not an extra: MemDiver has been able to emit YARA rules since the architect layer landed but never to run one, and an emitted detector you cannot scan with is a detector you cannot evaluate — the newengine/yara_scan.pycloses that loop, and it is useless behind an install flag the evaluation path cannot assume. This also keeps the install contract intact:pip install memdiverships everything, withmarimothe only genuinely optional component.YARA rules gained optional
key_offset/key_lengthmetas, locating the key bytes within the matched pattern window. This matters becausePatternGeneratorbuildswildcard_patternas one flat wildcarded byte string spanning a whole region, so libyara reports the start of the window that contains the key, not the key’s own offset — without the metas a detector’s positional claim cannot be scored at all (seeengine/detector_metrics.py). Scope, honestly: the metas are emitted only when a caller actually knows the locator. The three paths that compute it from the hit pass it directly —engine/vol3_emit.py::emit_plugin_for_hit(exact, the hit defined the window),app/export_service.py’s auto export (best.start - offset) and manual export (0, because there the caller’s offset is the key start), andapp/experiment_orchestration.py’s plugin emit (best.start - exp_offset). The two remaining paths —Volatility3Exporter.export’s own YARA fallback andPOST /api/architect/export— hold only a finished pattern dict, so they recover the locator from it via the newkey_locator_from_pattern()and emit nothing when the dict does not carry one. A pattern that came fromPOST /api/architect/generate-patterndoes not carry one, and there the metas are correctly omitted rather than invented; a non-integral value arriving in an HTTP body is coerced toNonefor the same reason.A plain
pytestno longer runsslow-marked tests. The defaultaddoptsmoved from-m 'not e2e'to-m 'not e2e and not slow', because theslowtests either walk the machine-local corpus (~170 GB / 18,917 dumps,tests/test_pcap_oracle_real_dump.py) or run cost benchmarks. They are not orphaned: thetestjob in.github/workflows/ci.ymlnow runspytest -m slowas its own step, andmake test-slowdoes the same locally. This is load-bearing becausetests/test_benchmarks.pyholds the auto-floor cost ratchets — the per-candidate memory and time ceilings that three optimisations were justified by. Before the addopts change those ran in the default sweep only by accident, since-m 'not e2e'never excludedslow; had the marker been deselected without adding the CI step, the ratchets would have stopped gating pushes silently. (The corpus-backed slow tests self-skip in CI — no runner has the dump tree.) Runmake test-slowbefore touchingengine/auto_floor.pyorengine/brute_force.py.DatasetScanner._has_capturenow counts a capture only when it is a non-empty regular file, so the fast scan agrees withRunDiscovery._find_capture’s three-state verdict. A zero-byte capture is"unreadable"there, and counting it as present here inflatedDatasetInfo.runs_with_captureabove the number of runs that can actually be proven against their capture — i.e. it overstated the corpus denominator, which is precisely what the three-state status exists to prevent.stat()costs the same syscall asis_file(), so the agreement is free.Two parity ratchets were tightened over work that was already shipped:
app/capabilities.pyregisters thepcap.inspectcapability, andtests/test_architecture_invariants.pyaddsinspect_pcapto_MCP_TOOL_PRODUCERS. The pcap arm/validate step has been wired on all four surfaces since Phase 1 (libraryservices.inspect_pcap, CLImemdiver inspect-pcap,POST /api/pcaps/validate, MCPinspect_pcap) but was tracked by neither, so its four-surface parity and its MCP signature were both unguarded. Both additions are pure tightenings — neither needs aKNOWN_PARITY_GAPSor_MCP_ALLOWED_OMISSIONSentry.The unsupported-cipher-suite path in
engine/resources/tls_pcap.pywas promoted fromDEBUGtoINFO. Dropping a suite outside MemDiver’s KDF/AEAD table discards an entire TLS session, which at default log levels used to leave no trace whatsoever. The same event is now also reported structurally — seeskipped_sessionsunder Added.ResourceOraclelogs atINFOwhenmax_challengesactually truncates (max_challenges=N truncated M challenges to N), so a capped run’s “0 confirmed” can never be read as full coverage.records_returned/records_truncatednow count coverage through the same gate the challenge emitter uses. They were derived from the raw application-data record totals, so a TLS 1.2 capture whose ChangeCipherSpec is missing — a truncated capture start, a dropped packet, a CCS seen on only one direction — reported full coverage while the oracle verified zero records. TLS 1.2 record keys only take effect after their direction’s CCS, so that application data is not decryptable at all; the raw total was counting ciphertext no challenge could ever reach. That is a falsefalseon the one field whose entire purpose is to stop a corpus number silently understating itself, which makes it worse than no field.TlsPcapResourcenow has exactly one gate per protocol version (_tls12_gated/_tls13_gated), shared by the emitter and bydescribe_capture’s accounting, so the two cannot drift: the report walks the same iterator the challenges come out of and counts what it yields. The coverage number therefore also reflects the record cap and an exhausted challenge budget, which is whyrecords_truncatedis now documented as “some session returned fewer records than it saw, whatever the cause” rather than as a statement aboutmax_records_per_direction. Measured across all 2,598 real captures in the corpus the numbers are unchanged — every one carries a ChangeCipherSpec in both directions — so no published figure was ever wrong. It was a latent hazard, and it now surfaces asrecords_returned: 0plus an explainedskippedentry instead of a confident lie.capsnow reportsmax_challengesas well, and the capture report gainedchallenges_available/challenges_returned/challenges_truncated. Reporting onlymax_records_per_directionmeant the other cap — the one that truncates across sessions and can therefore starve a whole session — was invisible in the arm step’s own output: a run withmax_challenges=1reported full coverage. The key is always present and isnullwhen uncapped, so “uncapped” can never be read as “unreported”. This is a payload SHAPE change onPOST /api/pcaps/validate, the CLIinspect-pcapcommand and the MCPinspect_pcaptool: strictly additive, but a caller pinning the exact key set ofcaps(or of the capture report) will see new keys. The challenge counts are reported separately from the record counts, not derived from them, because the two units do not convert: a TLS 1.3 record is probed over a sequence-number window of up to 9 candidates (_TLS13_SEQ_WINDOW = 8, plus the record’s own index), so one record yields several challenges. Across the corpus that works out at roughly 3.9 challenges per record (48,546 challenges for 12,463 records) — unintuitive enough to be worth a number, because it means apcap_max_challengesof 50 buys only about 13 records of verification, not 50. That ratio is exactly whychallenges_availablehad to be published: without it an operator sizing a cap has no way to know what fraction of the capture the cap actually buys.Documented-reason correction:
inspect_pcap’sskipped_sessionsno longer advertisesno_cipher_suiteandshort_randomas expected outcomes. The bundled dpkt cannot produce either:TLSServerHello.unpackfalls back toget_unknown_ciphersuiterather than leaving the suite unreadable (so the code is always an int), and a ServerHello with a short random is rejected outright during unpacking, surfacing asno_server_hello. Both checks remain in place as defence against a different dpkt build — a caller should still tolerate the two values, and they stay in the frontend’s closedPcapSkipReasonunion — but a UI or a corpus aggregate must not present them as reasons it expects to see, and no test can produce one to pin.RawDumpSourcenow validates itsviewargument onsize_for,read_all,read_range,find_allandfind_first."vas"and"va"are accepted as aliases of"raw", because a flat dump has no region table and its raw and virtual views genuinely coincide; anything else raisesValueError, as it already did on the MSL, gcore and regioned-raw sources. Previously this one source accepted a typo silently and served raw-view results, while the very same typo was a loudValueErroreverywhere else — so a corpus sweep mixing formats got silently format-dependent behaviour out of one argument, andRawDumpSourcebacks every plain.dump/.binin the corpus. A caller that was passing a misspelled view and getting away with it will now see the error it should always have seen.Capture classification gained a readability probe.
stat()succeeds on a file the process cannotopen()— mode000, a restrictive ACL, some read-only mount quirks — so a capture nobody can read reported"present"and inflated the corpus denominator, which is the exact overstatement the three-statecapture_statusexists to prevent. BothRunDiscovery._find_captureandDatasetScanner._has_capturenow add oneos.access(..., os.R_OK)check, so the slow per-run resolution and the stat-only fast scan still agree on every candidate; the fast scan’s budget is preserved (one extra syscall per candidate).The project database is now versioned, and an existing project file is migrated in place (schema v1 -> v3). A
.duckdbwritten by any earlier MemDiver carries no version marker at all, soProjectDB.open()now creates aschema_metakey/value table, readsschema_versionout of it (a file without that row is by definition v1), and brings the file forward to_SCHEMA_VERSION = 3before it hands back a connection. This rewrites the user’s own project file, so it is worth stating exactly what it does. Whole new tables arrive throughCREATE TABLE IF NOT EXISTS:schema_meta;expected_secrets, the denominator ledger holding one row per corpus run and secret type the run’s key log declares;survival, the observation ledger at one row per dump and secret type; andsweeps/sweep_units, the resume watermark for a corpus-scale sweep and the single documented exception to this class’s otherwise append-only rule (a unit row is updated in place as it movespending->running->done). New columns arrive throughALTER TABLE ... ADD COLUMN: the corpus axes (library,protocol_version,library_version,version_axis,scenario,protocol) onprojects;phase,canonical_phase,run_number,libraryandrun_idondumps; those plusprotocol_version,library_version,scenario,run_dir,keylog_pathandpcap_pathonanalysis_runs;secret_type,kind,method,dump_path,phase,canonical_phase,library,run_numberand a deliberately three-valuedverified(NULL= never verified,FALSE= a verifier rejected it,TRUE= confirmed) onfindings; andphase,canonical_phase,dump_id,dump_path,library_version,scenario,run_number,methodandvalue_hexonground_truth. A migrated file ends up with the same column order as a freshly created one, because both are generated from the same table description rather than spelled out twice. Every added column is nullable or defaulted, which is what makes the two compatibility decisions below defensible rather than merely convenient:The migration probes
duckdb_columns()for the columns a table already has and emits anALTER TABLEonly for the ones genuinely missing, instead of leaning onADD COLUMN IF NOT EXISTS. That clause is not available across the whole supported DuckDB range (duckdb>=1.0.0), so the shorter spelling would have made the migration itself silently DuckDB-version dependent — and the probe additionally gives the migration test something to assert against, whichIF NOT EXISTSdoes not.A file written by a newer MemDiver warns and is used anyway, for reads and writes, with the newer columns ignored rather than dropped; the stored version is left alone. Raising would lock an operator out of their own project file over a difference that the nullable/defaulted rule above already makes tolerable, and silently rewriting the version marker downward would lose information the newer build put there.
survivalrecords an absence as a positive fact, and a missing row as “we never looked” — and that distinction must not be optimised away. The table exists becausefindingsstructurally cannot express it:findingsholds what was found, so reading “no row” as “the secret was gone” turns every dump nobody has scanned yet into a confirmed zeroization, which is precisely the claim a survival analysis is supposed to earn. The contract is three-state:present = TRUEmeans we looked and found it;present = FALSEmeans we opened this dump, searched for this secret and it was gone; no row at all means the cell was never attempted.presentis therefore nullable with no default, and a companionstatuscolumn guards it — onlystatus = 'searched'requires a non-NULLpresentand only'searched'counts towardruns_attempted;'unreadable'leavespresentNULL(a dump we could not open was not an observation), and'error'writes no row at all. ANULLpresentis excluded by bothWHERE presentandWHERE NOT present, so it can never quietly land on either side of a published fraction.Both ledgers now have deterministic row identity, so a re-run or a resume corrects a cell instead of duplicating it.
survivalandexpected_secretspreviously took a fresh uuid per row with no unique constraint behind it, which meant a second pass over the same corpus wrote a complete second copy of every observation and every denominator row. That failure is invisible to the obvious sanity check: the invariantruns_found <= runs_attempted <= runs_expectedstill holds with both sides doubled, so the published fractions look untouched while the counts under them are inflated. Row ids are now derived — blake3 over the length-prefixed identity parts, matching the repo-wide hashing convention rather than md5, because a collision here silently overwrites one observation with another — and backed by real unique indexes (survival(sweep_id, dump_path, secret_type)andsweep_units(sweep_id, unit_key)). Writes upsert withON CONFLICT ... DO UPDATErather thanDO NOTHING, which is the behavioural half of the change: a resume that corrects an observation — a cell first writtenpresent = FALSEfrom a truncated read and then found present — now wins, whereDO NOTHINGwould have counted the corrected write and discarded it. Only the observation columns are in the update list; identity and axis columns never are, so an upsert can never move a row to a different cell. If an index cannot be created the failure is recorded rather than swallowed:degraded_indexes()reports it, the survival writer falls back to a primary-key conflict target (naming an index that does not exist as the arbiter is a bind error), and a non-empty result means the counts behind the fractions are not publishable.add_survival_batchnow rejects an emptydump_pathoutright (CapabilityError,INVALID_INPUT), and this one is not theoretical.dump_pathis part of the cell’s identity, so with it blank every row in a batch hashes to the same handful of ids and the unique index collapses them: a real corpus pass of 63,481 cells left 6 rows in the table while the writer cheerfully reported 63,481 written. A missingsweep_idis rejected the same way and for the same reason. Both are new writer-level rejections — a caller that was passing either as""and appearing to succeed will now see an error, which is the point.
methodis a closed vocabulary, validated at every writer. The permitted values arekeylog_substring,consensus_search,brute_forceandoracle, in increasing order of evidential strength, and they apply tofindings.method,ground_truth.methodandsurvival.methodalike. The empty string is the only exemption and means “unclassified”; anything else, including a non-string such asNoneor0, is rejected as a typedCapabilityError.add_findings_batchvalidates the whole batch before writing any row, so a bad method in the tail cannot leave a half-written batch behind. This is a new rejection at the writers, so a caller that was inventing its own method spelling will now be told.A corpus survival pass may never claim decryption-backed provenance. The survival scan proves exactly one thing — that these key-log bytes are or are not present in this dump — and it runs no decryption at all, so labelling its rows
confirmed_by = 'pcap'or'oracle'would pad the proof ledger with claims nothing verified. The rule is enforced structurally rather than by review:survivalhas noconfirmed_bycolumn, andadd_survival_batchrestrictsmethodtokeylog_substringalone. The two decryption-backed labels stay the exclusive property ofrecord_ground_truth_runand the pcap oracle path, which is also why that method deriving its own value fromconfirmed_byis not an oversight.
ProjectDB.persist_reportnow files ground-truth labels by default (persist_ground_truth_labelsflipped fromFalsetoTrue) — a behaviour change for every existing caller. As opt-in, and with nothing in the tree passing it, the W5 proof ledger was permanently empty in production: a table whose entire purpose is to accumulate confirmed recoveries had no way to accumulate anything. The flip is safe because only hits that already carryverifiedorconfirmedare written, so the ordinary run — no verifier, no oracle, no capture — still adds exactly zero rows and the change is invisible to it. What does change is that a verified run now writes toground_truthwithout being asked. The parameter is kept, so a caller that genuinely wants a report persisted without its labels can still say so.
Security
POST /api/analysis/export-keylogcontainsoutput_pathto the upload directory. The handler passed the request string straight tokeylog_result, which doesPath(output_path).write_text(...)— an unauthenticated caller on a token-less localhost instance could write a file of largely chosen content to any path the server user owns (a shell rc,~/.ssh/authorized_keys, a.pthin site-packages). It now resolves throughensure_within(settings.upload_dir, …)and rejects an escape with 400, disclosing no server layout. This API’s read path parameters remain an accepted, documented localhost risk; a write is a different class. The shared producer is deliberately unchanged, so the CLI and MCP surfaces still write wherever the operator’s own shell can.
Fixed
A typed server-side capture can be armed again.
POST /api/pcaps/validatecontainedpcap_pathto the upload directory whilePOST /api/pipeline/run— the same parameter, the same capture — only checked existence. Since the UI’s manual Arm / re-validate control routes through/validate, the “type a server-side path instead of uploading” flow documented indocs/oracle/pcap_oracle.mdwas broken: the capture could be run but never armed. Both endpoints now apply the same existence check.build_oraclerejected nothing and mis-readmax_challenges: 0as “uncapped”. The cap was read asint(raw_cap) if raw_cap else None, a truthiness test, so a config asking for0challenges got the exact opposite of the request — an unlimited run — while a negative value sailed through intochallenges[:max_challenges], i.e.challenges[:-1], silently dropping the tail of the challenge list and logging the nonsensemax_challenges=-1 truncated 37 challenges to -1. The test is nowis not None, and a cap below 1 raisesValueError(matching the unknown-resource_typeraise beside it) rather than being reinterpreted: a cap below 1 verifies nothing, so it could only ever turn a real recovered key into an unexplained “0 confirmed”.app/tools_pipeline.brute_forceadditionally rejects both pcap caps as a typedCapabilityError(INVALID_INPUT) so every surface reports it identically, butbuild_oracleis the oracle-loader entry point and must not depend on that guard. Reachable before only through a builtin-resource-oracle config, which is why it went unnoticed. The rejection now has tests of its own, which it shipped without:tests/test_resource_oracle.pyparametrises0and-1overtest_build_oracle_rejects_a_challenge_cap_below_one(asserting the raise and that the message names the offending value), andtest_build_oracle_distinguishes_an_absent_cap_from_a_cap_of_zeropins the actual behavioural difference against a throwaway registered resource type — three challenges uncapped, one atmax_challenges=1— because that is the distinction the old truthiness test destroyed.find_firstcould reportNonefor a needle that IS present.mmap.find(sub)defaults its start to the mapping’s current file position, not0— unlikebytes.find, which has no position at all — so after anything that advances the mapping (read_all()is the common one) a barebuf.find(needle)searches only the tail and returns-1for a needle sitting before it. Reproduced on a real corpus dump: a TLS 1.3 traffic secret at offset585148was found byfind_alland reported missing byfind_firstonceread_all()had run — the worst possible failure for a presence query whose whole job is to answer “is this secret in this dump at all?” over ~170 GB.find_all_offsetswas immune because it always passed an explicitstart; everyfind_firstsite now does the same (core/dump_io.py::find_first_offsetandcore/dump_sources/gcore.py::_find_first_in_bytes, the latter reading the live mmap deliberately to avoid copying a multi-GB core, plus the explicit0in gcore’s VAS window walk). Every existing test opened a fresh source, so the position was always0and the bug was invisible — the regression tests now read the source first.find_all_offsets(mmap, b"")looped forever.mmap.find(b"", start)clamps an out-of-rangestartto the buffer length instead of returning-1(on a 300-byte mapping,mm.find(b"", 999)is300), so thestart = idx + 1cursor can never escape the buffer and the loop appends the same offset until memory runs out.bytes.findhappens to terminate on the same input, so the hang reproduced only on the mmap-backed sources (DumpReader, and through itRawDumpSource/MslDumpSource) — precisely the ones a corpus sweep uses. An empty needle now returns[], andfind_first_offsetreturnsNonefor the same input so the two can be swapped freely: searching for nothing is a caller error, not a query with a degenerate every-offset answer, and reporting offset0would let an empty secret masquerade as a hit at the start of every dump in the corpus.POST /api/architect/exportreturned 200 with an uncompilable rule. A client-suppliedpatterndict using the wrong key names produced$key = { }— an empty YARA hex string, which libyara rejects — and the endpoint happily served it with HTTP 200, so the breakage only appeared when the analyst fed the.yartoyara. The exporter’s pattern validation now surfaces as 400 (mirroring the 400/architect/generate-patternraises for an unusable pattern), becausereq.patternis free-form client input and a malformed one is a bad request, not a server fault. The endpoint’s tests asserted only HTTP 200 — never that the rule they got back was usable — which is why the empty hex string went unnoticed for so long; the export tests now run every emitted rule throughyara.compile(), and the newtests/test_yara_exporter_compiles.pydoes the same for the exporter itself.Canonical phase labels could be assigned in the wrong chronological order.
PhaseNormalizer.normalize_runsorted a run’s dumps by the raw timestamp string before handing out its generic canonical suffixes, and the suffixes are handed out positionally — first dump gets the first label, and so on. The timestamp’s trailing microsecond field is captured bycore.discovery.DUMP_PATTERNas\d+, i.e. variable width, not zero-padded, so a five-digit field sorts as text against a six-digit one and lands in the wrong place:..._90000_pre_abort(0.090 s) sorts after..._606711_pre_shutdown(0.607 s), because"9" > "6". The consequence would not have been cosmetic —canonical_phaseis what corpus consumers filter and group by, so a mis-ordered run would report one dump’s survival numbers under another dump’s lifecycle phase. The sort key is nowparse_phase_timestamp, which splits the timestamp into a numeric(date, time, microseconds)tuple and returns a sentinel(-1, -1, -1)rather than raising for a shape it cannot parse — placing non-phased dataset dumps (gcore.core,gdb_raw.bin) first, which is where the old string ordering put their empty timestamp anyway. This is a latent-correctness fix, not a corrected result: all 18,917 dumps in the measured corpus carry six microsecond digits, so lexicographic and chronological order agree on every one of them and no shipped figure changes. The parse now lives incore.phase_normalizerand is re-exported byengine.sweep_plan(along with the sentinel) so labelling order and emission order cannot drift apart — the previous copy inenginedocumented a fix it could not deliver, because the normalizer went on sorting by the raw string.
Added
PCAP verification oracle — a first-party trusted oracle that proves a recovered secret by deriving record keys from it and AEAD-decrypting records of a real captured TLS session (TLS 1.3, TLS 1.2 GCM, TLS 1.2 CBC). Because it is MemDiver’s own code and the capture is only ever parsed as data, never executed, it runs outside the untrusted-oracle sandbox that bring-your-own oracle scripts require. Capture parsing lives in
engine/resources/tls_pcap.py(TlsPcapResourceemits oneDecryptionChallengeper encrypted application-data record); derivation and the AEAD/CBC checks stay in the oracle. Hits it confirms are stampedverified: trueandconfirmed_by: "pcap". Parsing needsdpkt, behind the newmemdiver[pcap]extra ([all]now expands toapi,mcp,marimo,pcap); without it the arm/run steps surface anINVALID_INPUTcapability error.brute-force --pcap <capture>on every surface, mutually exclusive with--oracle— a run confirms candidates either through a BYO script or against a capture, never both.--tls-client-random <hex>restricts matching to one session;n-sweepandescalateremain oracle-only.inspect_pcap— the “arm” producer that summarises a capture’s TLS sessions (client_random,server_random, version, cipher suite, per-direction application-record counts,has_app_records), backed byTlsPcapResource.describe_sessions()and exposed on all four surfaces: CLImemdiver inspect-pcap, MCPinspect_pcap, webPOST /api/pcaps/validate, librarymemdiver.services.inspect_pcap.api/routers/pcaps.py—POST /api/pcaps/uploadstreams a.pcap/.pcapng/.capin 1 MiB chunks tosettings.upload_dir/pcaps/, creating the file0o600via anO_CREAT|O_EXCLopener so a secret capture is never briefly group/world-readable, rejects a disallowed suffix with 400 and an over-cap upload with 413 (PCAP_UPLOAD_MAX_BYTES= 512 MiB, partial file removed), then LRU-prunes the persisted dir back undersettings.pcap_quota_bytes(default 5 GiB) oldest-mtime-first, never evicting the just-uploaded capture.POST /api/pcaps/validatearms a persisted capture,ensure_within-confined to the upload dir.POST /api/pipeline/runacceptspcap_path+tls_client_randomas a mutually exclusive alternative tooracle_id— exactly one oracle source is required (400 otherwise), and a pcap run carriesoracle_path: Nonebecause there is no BYO oracle file to hash.Web pcap flow in the pipeline wizard’s oracle stage:
PcapUploaddrag-and-drop uploader with a TLS-session picker (click a session to pin itsclient_random, or “match any session”; sessions without application-data records are marked unusable), driven by the sharedusePcapArmhook (frontend/src/components/pipeline/oracle/use-pcap-arm.ts) reused byStageOraclefor the manual-path re-validate control.Multi-secret NSS key-log composer in
KeyVerificationPanel— add one row per secret (NSS label dropdown,client_random, secret hex), prefill theclient_randomfrom a selected pcap session, and export the whole set as one Wireshark-loadable key log.Provenance reaches the results UI:
frontend/src/components/results/provenance.tsnarrows the backendconfirmed_bylabel topcap/oracle/verifier(anything else falls back to a generic label rather than leaking a raw i18n key), andVerificationBadgerenders it — a pcap-confirmed hit reads “Verified via pcap capture”.--persist-ground-truth(CLI and MCPbrute_force, opt-in) files pcap-confirmed hits into the project’s DuckDB ground-truth ledger viaProjectDB.record_ground_truth_run(..., confirmed_by="pcap"), mapping each recovered key to its byte value for the survival/labelling analytics; a no-op when the DuckDB backend is unavailable.docs/oracle/pcap_oracle.md— walkthrough of the arm/run model, the four-surface table, and the two traps:--key-sizes(the candidate must match the secret — 32 for a TLS 1.3 traffic secret, 48 for a TLS 1.2 master secret) and--stride(the absolute grid, with measured coverage numbers from a real corpus dump).pip install memdiver[experiment]extra, pinningfrida-tools>=12.0andmemslicerfor the dump-collection flow.GitHub Actions workflows:
ci.yml— pytest matrix (Python 3.11 / 3.12) on push / PR.publish.yml— OIDC trusted publishing to PyPI onv*tag push, plus TestPyPI dry-runs viaworkflow_dispatch. Includes anpm ci && npm run buildstep so the React bundle is baked into every wheel.
MANIFEST.in— ensuresLICENSE,README.md,CHANGELOG.md, algorithm patterns, and the fullfrontend/dist/tree ship in the sdist.frontend/dist/**/*added to[tool.setuptools.package-data]so the built React bundle ships in the wheel; it is served once the web UI is installed viapip install memdiver[api].Sphinx documentation site under
docs/(Read the Docs theme, MyST-parser), published to https://memoryslice.github.io/MemDiver/ via GitHub Pages. Covers quickstart, full user guide, ten-subsystem architecture walkthrough, eight algorithm reference pages, nine visualization pages,.mslv1.1.0 file-format spec, Oracle interface + examples, and a 12-module Python API reference generated viaautodoc+napoleon..github/workflows/docs.yml— strict Sphinx build on every push and PR, Pages deploy gated tomain(re-enabled after one-time repo Settings configuration)..github/workflows/docs-screenshots.yml— nightly Playwright refresh of the 11 baseline screenshots underdocs/_static/screenshots/, opening a pull request on visual drift.Logo pipeline:
docs/_static/{logo,logo_simple}.svg(both with<title>,<desc>,role="img"for accessibility),docs/_static/favicon.ico(multi-resolution 16/32/48),docs/_static/logo_readme.png(PyPI-safe 512×512 raster), regenerator atscripts/build_logo.py --check.Playwright screenshot harness under
docs/screenshots/—capture.py(deterministic Chromium driver with 10 flake-reduction techniques),seed_data.py(wraps the sixtests/fixtures/generate_*.pygenerators),captions.json(per-slug alt text + viewport), andgenerate_placeholders.pyfor fresh clones.docsextra (pip install memdiver[docs]) pinning Sphinx 7.4,sphinx-rtd-theme,myst-parser,sphinx-copybutton,sphinx-design,sphinxcontrib-mermaid,sphinx-argparse,cairosvg,pillow.cli.py— publicbuild_parser()alias exposed forsphinx-argparseand external tooling.Repo-root
__init__.py—__version__now resolves dynamically viaimportlib.metadata.version("memdiver")instead of the drifted hard-coded"0.1.0".pyproject.toml— canonical project URLs updated togithub.com/MemorySlice/MemDiver; added aDocumentationURL pointing at the GitHub Pages site.README overhaul — accurate capability counts (8 algorithms, 20 CLI subcommands, 12 FastAPI routers, 15 MCP tools), Apache-2.0 badge, MCP one-line wiring snippet, thumbnail gallery linking into the docs site.
brute_forcegainedpcap_max_records/pcap_max_challenges, wired on all four surfaces — librarytools_pipeline.brute_force(pcap_max_records=…, pcap_max_challenges=…), CLI--pcap-max-records/--pcap-max-challenges,POST /api/pipeline/run(both validated>= 1), and the MCPbrute_forcetool.Noneon every surface preserves today’s behaviour exactly: 16 application-data records per direction, no total challenge cap. These two knobs already existed insideTlsPcapResource(max_records_per_direction) and the builtin resource oracle’s config schema (max_challenges), but no surface could reach them: the pcap branch ofbrute_forcebuilt itsoracle_configdict from scratch with onlyresource_type/pcap/client_random, and the pcap branch ignoresoracle_config_pathentirely, so there was not even a TOML back door. The consequence was a capture that could be silently under-read with no way to widen it — a chatty session contributing only its first 16 records per direction, and averified_count: 0that said nothing about the rest of the capture. Only a cap the caller actually supplied is forwarded, so an unset knob leaves the resource and oracle defaults untouched rather than re-asserting them. A challenge cap truncates across sessions, not per session (TlsPcapResource.challenges()yields session-by-session andResourceOraclekeeps the first N of the flattened sequence), so a small cap on a multi-session capture can be consumed entirely by the first session in parse order — pair it with--tls-client-randomwhen you want a bounded run that still targets a chosen session. Documented indocs/oracle/pcap_oracle.md.inspect_pcap(and thereforePOST /api/pcaps/validate, which returns the producer’s payload verbatim) now also reports what the parse dropped:skipped_sessions,flow_count,capsandrecords_truncated, plusapp_records_seen/records_returnedon each kept session. All are purely additive — every key the producer returned before is unchanged, including a zero-session capture’ssession_count: 0. The motivation is that a TLS session negotiating a cipher suite outside MemDiver’s KDF/AEAD table used to vanish with nothing but aDEBUGlog line, so at corpus scale a “0 confirmed” result was indistinguishable from “we never looked at 40 % of the capture”. Eachskipped_sessionsentry carries a machine-readablereason—no_client_hello,no_server_hello,no_cipher_suite,short_random, orunsupported_cipher_suite, plus the two added below (no_change_cipher_spec,client_random_mismatch); see Changed for whyno_cipher_suiteandshort_randomare defensive guards rather than expected outcomes — the directionalflowit was seen on, and reason-specific context (the IANAcipher_suitecode, or the two random lengths).flow_countgivessession_counta denominator, so “1 session out of 40 flows” is no longer reported the same way as “1 out of 2”.records_truncatedis true whencaps.max_records_per_directionclipped a direction, i.e. some session’srecords_returnedis below itsapp_records_seen— widened before release to fire on every cause of under-coverage, not the record cap alone; see Changed. The accounting lives on the newTlsPcapResource.describe_capture(), built around the frozendescribe_sessions()(which the web router and the React frontend read) rather than by widening it, and it is reset on every_parse_sessions()call so a drop log always describes exactly one pass.Two further
skippedreasons, neither of which drops a whole session.no_change_cipher_specreports a TLS 1.2 direction whose application data no ChangeCipherSpec unlocks, carryingdirectionand that direction’s ownapp_records_seen; the session is kept, but its records are uncoverable, so this is what turns a barerecords_returned: 0into an explained one.client_random_mismatchclosed a real gap rather than adding polish:TlsPcapResource.challenges()skips every session that fails theclient_randomfilter, whiledescribe_capturecounted those same sessions as fully covered — andbuiltin_oraclepasses that filter through today, so the inconsistency was reachable from a real run. The entry carries the hexclient_randomof the session that was passed over, so the report says which one. Both are emitted from the shared session filter (_excluded_by_filter), which is now the single place that decides whether a session is in scope, so the stream and the report cannot disagree about it.DatasetMeta.library_version— an optionallibrary_versionkey in a run’smeta.json, naming the build of the library under test (e.g."3.0.13"). Forward-compatibility only: today’s corpus holds each library at one build, so no run emits it and every axes record resolves to"unknown". It is load-bearing anyway, becausecore/corpus_axes.pyreads exactly this attribute to decide which dimension the corpus varies (version_axis), and the attribute did not exist — so the documented generic-version-axis hook could never fire, and a future multi-build corpus would have silently collapsed every build into"unknown"instead of splitting on it. That is a statistics-merging bug rather than a missing feature, which is why the field is added now rather than when the second build appears.The dataset scan reports a packet-capture inventory:
runs_with_capture(how many runs own a capture) andcaptures(per-"ver/scenario/library"counts) ride the existingscan_datasetpayload viaserialize_dataset_info, so there is no new producer and no new capability. Per-run,RunDirectorygainedcapture_pathand a three-statecapture_status("present"/"absent"/"unreadable"), resolved by the newRunDiscovery._find_captureand surfaced onGET /api/dataset/runsascapture: {path, status}. Three states rather than anOptional[Path]because a zero-byte or un-stat-able capture must not be counted as present: it is a corpus defect, and folding it into “absent” hides it while folding it into “present” overstates the number of runs that can actually be proven against their own capture. Resolution prefers acapturerelative path declared in the run’smeta.json(new, forward-compatible field — no corpus emits it yet) over the hardcodedrun_data/traffic.{pcap, pcapng,cap}probe; a declared path that is absolute or escapes the run directory is logged and ignored, sincemeta.jsonis corpus-authored data and must not be able to point the scanner at an arbitrary file. Likeload_run_meta, the probe never raises — scan paths have to tolerate partial datasets.find_first_offset()(core/dump_io.py),DumpReader.find_first(), afind_first()on every built-in dump source (raw,.msl, gcore, the regioned base) and the tolerant module-levelfind_first_in()(core/dump_source.py) — presence-only byte search with an early exit, for the corpus-scale question “is this secret in this dump at all?” over ~170 GB of dumps, wherefind_all’s scan-to-EOF is pure waste. Design rationale, recorded here and not only in a code comment:find_firstis deliberately NOT a member of theDumpSourceProtocol. That Protocol isruntime_checkable, and such a check verifies method presence, so adding a member would instantly make every duck-typed or third-party source registered viaregister_dump_source()failisinstance(obj, DumpSource)— a silent break in third-party code that never changed. A default body would not help either: Protocol defaults are inherited only by explicit subclasses, and the isinstance check still only looks at attribute presence. Callers therefore go throughfind_first_in(source, needle, view=…), which uses the source’s ownfind_firstwhen present and otherwise falls back to the first element offind_all.viewis forwarded only when the caller supplies one, so each implementation keeps its own default ("raw"for raw/regioned sources,"vas"for.msl). The.mslimplementation mirrorsfind_all’s per-captured-run semantics exactly, including the existing limitation that a needle straddling two captured runs is reported by neither.core/corpus_axes.py— the canonical corpus-axis vocabulary: the frozenCorpusAxesrecord plusaxes_from_run_dir(),axes_from_dump_path()andwith_canonical_phase(). Every corpus-scale consumer needs to agree on what a dump is an instance of (protocol, protocol version, library, library build, scenario, run, lifecycle phase); historically each one re-derived that from the path with its own regex and its own spelling of the field names, so this module derives it exactly once and nothing invents a second spelling. Resolution walks up from the run directory and cross-checks the version encoded in the run directory name against the protocol directory prefix incore.protocols.REGISTRY; a path that does not conform returnsNonerather than raising, which callers read as “not a corpus run”. Two design points are recorded in the module: every record carries both alibrary_versionand aversion_axislabel naming which dimension the corpus varies (today alwaysprotocol_version, since each library is held at one build), so a future multi-build corpus needs no consumer change; andcanonical_phaseis deliberately left empty by both path parsers, because it is not a property of a single path —PhaseNormalizerderives it from the set of sibling dumps in the run, so guessing it from a filename would make it a mutating identity key that silently splits or merges corpus statistics when an unrelated sibling appears. Only a caller that has already run the normalizer over the whole run may set it, viawith_canonical_phase().engine/yara_scan.py— compiles a MemDiver-emitted YARA rule and scans a dump source with it, closing the emit-but-never-run loop (the onlyyara.compilein the tree previously lived inside the generated Volatility3 plugin template and was never executed in-process). Exposescompile_rules()with a content-addressed rule cache,scan_source()/scan_chunked(),rule_names(),max_pattern_length()andRuleMatch/ScanResultrecords that lift thekey_offset/key_lengthmetas onto every hit. Two scan strategies are chosen by dump format: a plain raw dump is scanned by handing libyara the file path (for a raw dump the file offset is the default view offset, so libyara’s own zero-copy mapping is both correct and fastest), while every other format (.msl, gcore, regioned raw) is scanned chunk-by-chunk throughread_rangebecause those bytes must be decrypted and/or VAS-projected first — for a gcore core the file offset is not the VAS offset, and for an encrypted.mslthe on-disk bytes are not the plaintext at all. The module does no scoring of its own.engine/truth_labels.py— the truth-label locators, keeping the two available truth sources explicit and visibly distinct viaTruthInterval.source.locate_keylog_truth()is the authoritative set: every corpus run ships akeylog.csvlisting every secret that existed in that TLS session, so the true byte offsets are computable exactly by substring search and the truth set is complete by construction — a detector that misses a key is genuinely charged a false negative.ledger_truth()reads the DuckDBground_truthtable and is corroboration only: it is opt-in and therefore sparse, and it only ever contains offsets some earlier sweep already visited, so it is stride-dependent and recall measured against it is silently inflated. Both functions are pure compute — reading the keylog and opening the dump stay the caller’s job. Written rather than reusingDumpSearcher.search_secrets()because that helper isDumpReader-based and only ever sees the raw byte view, which would mis-locate truth for the.mslVAS projections scanners actually run against.A ledger truth interval whose extent nothing attests is dropped, and a length the key bytes refute loses to the key. This is not tidiness: a zero-length truth is actively corrupting, because
engine.detector_metrics’s containment test (start <= t_start and t_start + length <= end) is satisfied forlength == 0by any match window covering the start — so one junk row counts as a covered truth and promotes the match to a true positive, inflating recall and precision to 1.0 at once.ledger_truth()therefore drops rows with no offset and rows resolving to a non-positive length, and reports the drop count atWARNINGrather than quietly shrinking the truth set. Where a row carries key bytes, those bytes decide the extent: an absentlengthbecomes the key width, and alengththat disagrees with it is overridden — the row’slengthis bookkeeping, the key it carries is evidence. An explicitlength = 0beside a real key is recovered at key width rather than discarded. Only a row that has neither key bytes nor a usablelengthis dropped. This matters downstream because corroboration keys on(start, length): alength = 48row wrapped around a 32-byte key could never match the key log’s(start, 32)interval, so the corroboration was silently lost and the ledger-only truth count correspondingly inflated.
engine/detector_metrics.py— interval-based precision/recall for an emitted detector, scoring match windows against known-true key intervals under three criteria (containment,key_offsetwith a byte tolerance, andexact= zero tolerance, whose relation is a subset ofkey_offset’s). Deliberately a new metric type rather than a widening ofengine/convergence.py’sDetectionMetrics, which would be wrong three ways: it is an exact set intersection over individual byte offsets, so it scorestp=0on every genuine window match; its byte-set semantics makes precision a property of how much padding the emitted window carries rather than of whether the key was found; and it is frozen into aConvergencePointthe web surface reads. Because the match/truth relation is genuinely many-to-many, precision is match-indexed and recall is truth-indexed — both bounded by 1.0 by construction, and not recombinable into a single confusion matrix — withmax_matches_per_truth(fan-in) andmax_truths_per_match(fan-out) published alongside so a recall of 1.0 reached by one enormous window cannot be laundered into a flattering score. A match carrying nokey_offsetmeta makes no positional claim and is excluded from the positional criteria rather than charged as a false positive.engine/sweep_plan.py— the work units and idempotency digests for a resumable full-corpus sweep (2,600 runs / 18,917 dumps / ~170 GB cannot be one non-restartable process). Provides a lazyenumerate_units(), a cheap separatecount_units()for a progress denominator, and the three digests that make skip-if-already-done correct rather than merely plausible:unit_key(),inputs_digest()(notices the inputs changed) andconfig_digest()(notices the settings changed). The unit is one dump file — the granularity at which a scan result exists is the granularity at which work can be skipped. Two identity decisions are documented at length:canonical_phaseis not part of the unit key, becausePhaseNormalizerderives it positionally from a run’s sibling dumps, so adding one unrelated file would silently re-key every already-scanned dump in that run and leave orphan ledger rows (thedump_filenameused instead already carries the raw phase plus a microsecond timestamp and depends on nothing but that one file); andcorpus_idis a label (Path(root).name) rather than a path or a realpath hash, so copying the corpus to another drive or mounting it elsewhere does not invalidate all 18,917 units for bytes that never changed — the absolute root is still recorded, as informational data.The unit-key format is
u2:library_versionjoined the key, betweenlibraryandrun_number, because a future multi-build corpus varies the build within a library andopenssl_run_13_1compiled against two OpenSSL releases would otherwise collide on one key and one ledger row. Every run in today’s corpus resolves to the unknown-version sentinel, so the collision cannot occur yet — which is exactly why the bump is free now and would be a ledger migration once 18,917 rows exist underu1.normalizeis folded intoconfig_digestalongsideexpand_keys,algorithms,keylog_filename,template_name,min_secret_len, the MemDiver version and the sweep schema version. It has to be: it re-resolves a run’s dumps through the phase normalizer before scanning, so it changes which dump a phase request lands on and therefore what the sweep finds — and it is deliberately not in the unit key, since the key names a concrete dump file rather than a phase request. Absent from both, flipping it would have left a resumed sweep skipping every unit as “already done” against results produced under the other setting. Purely operational knobs (workers,use_processes,max_inflight,fsync_policy,output_dir) are ignored by name rather than by accident, so changing the worker count does not invalidate a half-finished sweep; an unrecognised key is ignored with a warning.count_units()is genuinely cheap now: a directory listing per run plus oneRunDiscovery.dump_file_forcall per filename, and that seam reads no file. Noload_run_directory, no key-log parse, nometa.jsonprobe, no capture probe — the axis resolution it used to do reached the filesystem twice per run, roughly 5,200 syscalls over the measured corpus, to recover alibrary_versionthat is unknown for every run in it and a keylog path a count cannot use. An unrecognised protocol directory drops its whole subtree unlisted. Acanonical_phasesfilter is the one case needing more than a filename, and it runs the normalizer over the names already in hand — pure CPU, still no extra I/O.The count and the enumeration are admitted by the same rule, through the public
RunDiscovery.dump_file_forseam, and share one directory walk whose only parameter is how a run directory becomes its axes. They previously disagreed: the counter kept a local copy of the rule that testedname.endswith(suffix)overDATASET_DUMP_SUFFIXES, whose entries carry a leading dot, while discovery matches without one — so a baregdb_raw.bin/lldb_raw.binwas enumerated but never counted. Unfiltered that merely under-counts; under acanonical_phasesfilter the drift inverts, because the filter re-parses the short name list and the denominator then promises units the enumeration never yields.
frontend/src/api/pipeline.ts— the typed client now carries the fields the backend added, so the operator can actually see an under-read capture in the Arm / re-validate UI, which is the one place it matters:PcapValidateResultgainedskipped_sessions(typed by a closedPcapSkipReasonunion),flow_count,capsandrecords_truncated;PcapSessiongainedapp_records_seen/records_returned; andPipelineRunRequestgainedpcap_max_records/pcap_max_challenges. All new fields are optional, so nothing that already built a request or read a response breaks.Caught up with the accounting changes above:
PcapCapsgainedmax_challenges(number | null),PcapSkipReasongainedno_change_cipher_specandclient_random_mismatch,PcapSkippedSessiongained the reason-specificdirection/app_records_seen/client_randomcontext,PcapSessiongainedchallenges_available/challenges_returned, andPcapValidateResultgained the capture-levelchallenges_available/challenges_returned/challenges_truncated. Runtime was never affected — extra JSON keys are simply ignored — but a field the client has no type for is a field the operator-facing UI cannot render, and a closed union missing two live backend values is worse than that: narrowing onreasonwould silently fail to match the two rows that explain arecords_returned: 0. The doc comments say which reasons are reachable from the arm response at all (client_random_mismatchneeds a pinned resource, which the arm producer does not do) and which two are defensive guards, so the optionality is informative rather than a blanket hedge.tests/frontend/api/pipeline.test.tspins the seven reason literals against the Python call sites and asserts a representative payload for the missing-ChangeCipherSpec and challenge-capped cases.
parse_keylog_with_status()incore/keylog.py— the same parse asKeylogParser.parse, reporting why the result looks the way it does.parseswallows every failure and returns a possibly-empty list, which makes an unreadable key log indistinguishable from a session that genuinely logged nothing — and downstream that difference is load-bearing, because a survival sweep renders “zero secrets available” as not observed, the same cell a real post-KeyUpdate gap produces. A wholly corrupted key log therefore used to report clean. The newKeylogParseResultcarries a status from a closed four-value vocabulary in increasing severity order —ok(read end to end, every secret row well formed),partial(secrets recovered but content was lost: a malformed row, or an error part-way through),unreadable(present but nothing recoverable — no CSV header, nolinecolumn, or a failure before the first secret),missing(no such file) — alongsiderows_read,rows_malformedandsecrets_available. Two details are deliberate. An unknown secret type is malformed, not filtered: the type token is checked against the whole protocol registry, never against the caller’s template, so a type the registry does not know is a row nothing can ever read and is counted as damage, while a type the registry knows but the caller’s template excludes was filtered on purpose and leaves the statusok. And an abort part-way through the file charges the row in flight, sorows_malformedis never a reassuring0beside a truncated file; both counters are documented as lower bounds.KeylogParser.parseis unchanged for its existing callers — it delegates here and returns.secrets— including its exact log surface: it passeslog_structure_warnings=Falseso the new structural diagnostics stay atDEBUGon that path and only the two historicalWARNINGs remain. That matters becauseparseruns once per run over a multi-thousand-run corpus, where a systematically headerless corpus would otherwise emit one new warning per run from a path that was always quiet. The status is still returned either way. On the measured corpus the parse is clean (2,598/2,598 headers exactlyid,line, 7,790/7,790 secret lines exactly three fields, zero malformed rows), so this costs nothing today — it is what keeps that true.core/artifact_util.atomic_write_text()— theunique tmp + os.replaceprimitive lifted out ofapi.services.task_manager.TaskManager._persist, so the task manager and the sweep ledger share one implementation instead of growing a second copy. It lives incore/rather thanapi/specifically becauseapp/— where the sweep ledger writes itsmanifest.json— must not importapi/. Three properties are load-bearing and documented as such: the tmp name is unique per write (a shared<name>.tmplet two writers interleave into torn output, or made the secondos.replaceraiseFileNotFoundErrorbecause the tmp had already been moved),os.replaceonto the final path is atomic on POSIX and Windows so a reader sees the old file or the new one and never a partial one, and cleanup catchesBaseExceptionrather thanExceptionso aKeyboardInterruptor a worker cancellation mid-write leaves no stray tmp behind. It deliberately does notfsync, matching the original: it guards against a torn file, not against power loss, and fsyncing every record write would serialise the hot progress-drain path on disk latency. It is equally deliberately not for large append-only files — it rewrites the whole file every call, so appending to a growingresults.jsonlthis way would be O(n^2) and push roughly 450 GB of writes over a 30,000-line sweep.TaskManager.load_from_disk’s startup sweep now removes both the legacy fixedrecord.json.tmpand the per-writerecord.json.<hex>.tmpspelling.
Deferred follow-ups
user_regexconfidence calibration —algorithms/unknown_key/user_regex.py:48returnsmin(total / 10.0, 1.0), an arbitrary heuristic that inflates confidence on dense matches. Track a calibration task: parameterize the divisor via context, or compute confidence per-pattern from match density × pattern complexity.e2e dataset gate — 24 of 25 Playwright specs in
tests/e2e/specs/skip when no private dataset is mounted (test.skip(!datasetAvailable, ...)). CI cannot exercise the SPA without it. Track a task to either (a) build a minimal synthetic.mslfixture undertests/e2e/fixtures/synthetic_msl/or (b) tag specsrequires-datasetand document the CI strategy.
[0.5.1] — 2026-04-14
TLS polymorphic structures + FTUE tour framework (841 tests).
(packaging/ops) resolved — MemDiver is now PyPI-publishable.