diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..283dbb24 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: Bug report +description: Something in Cortex does not work as documented. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Security problems go through SECURITY.md, not here. + Only report problems seen on software you own or are authorized to analyze. + - type: input + id: version + attributes: + label: Cortex version or commit + placeholder: v2.0.0, or the commit / CI artifact + validations: + required: true + - type: input + id: platform + attributes: + label: Windows version and target architecture + placeholder: Windows 11 24H2, target x86 + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Desktop (scan, addresses, memory viewer) + - Debugger / breakpoints / traces + - Static analysis (inspect, artifacts) + - External engines (Ghidra, ILSpy, Vineflower) + - Live providers (CDP, debugpy, netcoredbg, JDWP) + - MCP server / agents + - Evidence, investigations, projects + - Install / portable bundle + - Other + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Ideally against the repository's test target or fixtures. + placeholder: | + 1. cortex.exe mcp --pid ... + 2. call ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected and actual behaviour + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: Status line, MCP replies, cortex_denied_mutations.jsonl, cortex_debugger_events.jsonl. Remove private data first. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..bedc2261 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/PdB333/cortex/security/advisories/new + about: Report vulnerabilities privately, not in a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..d04ed22d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature request +description: Propose a capability or an improvement. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Requests to bypass anti-cheat, DRM, licence checks or access controls are out of scope and will be closed. + - type: textarea + id: problem + attributes: + label: What are you trying to do? + description: The investigation or workflow, and where Cortex gets in the way. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: What Cortex should do. Say whether it only reads or also changes the target. + validations: + required: true + - type: textarea + id: validation + attributes: + label: How could it be validated? + description: A fixture, an owned program or a reference tool to compare against. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..27d0a203 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + + + +## Validation + + + +- [ ] `ctest` for the affected build(s) +- [ ] End-to-end suites for the touched area (see CONTRIBUTING.md) +- [ ] Windows x64 and x86 targets (CI) + +## Safety + +- [ ] Read-only paths still work without write authority +- [ ] Every change to a target needs the operator's authority and `mutation_permission` +- [ ] No test was skipped, disabled or loosened + +## Limitations + + diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 5526e5e5..da91974c 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -114,39 +114,10 @@ jobs: shell: msys2 {0} run: cmake --build build/p1-${{ matrix.arch }} --parallel 2 - - name: Run existing CTest suite + - name: Run CTest suite (includes the action transaction and semantic plan tests) shell: msys2 {0} run: ctest --test-dir build/p1-${{ matrix.arch }} --output-on-failure - - name: Build action transaction tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 - tests/action_transaction_tests.cpp - core/action/action.cpp - core/events/events.cpp - -Icore - -o build/p1-${{ matrix.arch }}/cortex_action_transaction_tests.exe - -static -static-libgcc -static-libstdc++ - - - name: Run action transaction tests - shell: msys2 {0} - run: build/p1-${{ matrix.arch }}/cortex_action_transaction_tests.exe - - - name: Build semantic plan tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 tests/semantic_tools_tests.cpp - -Ibuild/p1-${{ matrix.arch }}/_deps/json-src/include - -o build/p1-${{ matrix.arch }}/cortex_semantic_tools_tests.exe - -static -static-libgcc -static-libstdc++ - - - name: Run semantic plan tests - shell: msys2 {0} - run: >- - build/p1-${{ matrix.arch }}/cortex_semantic_tools_tests.exe - core/api/routes_status.cpp - # ---- P2 Contracts (was .github/workflows/p2-contracts.yml) p2-contracts: name: p2-${{ matrix.arch }} @@ -202,86 +173,10 @@ jobs: path: build/p2-${{ matrix.arch }}/compile.log if-no-files-found: warn - - name: Run core tests + - name: Run CTest suite (includes the MCP protocol, authority, pipe, schema, scheduler, bridge policy, request id and pagination tests) shell: msys2 {0} run: ctest --test-dir build/p2-${{ matrix.arch }} --output-on-failure - - name: Build and run MCP protocol tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 tests/mcp_protocol_tests.cpp - -Ibuild/p2-${{ matrix.arch }}/_deps/json-src/include - -o build/p2-${{ matrix.arch }}/mcp_protocol_tests.exe - -static -static-libgcc -static-libstdc++ - && build/p2-${{ matrix.arch }}/mcp_protocol_tests.exe - - - name: Build and run MCP write-authority tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 -Icore tests/mcp_authority_tests.cpp - -Ibuild/p2-${{ matrix.arch }}/_deps/json-src/include - -o build/p2-${{ matrix.arch }}/mcp_authority_tests.exe - -static -static-libgcc -static-libstdc++ - && build/p2-${{ matrix.arch }}/mcp_authority_tests.exe - - - name: Build and run native pipe contract tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 tests/mcp_pipe_protocol_tests.cpp - -o build/p2-${{ matrix.arch }}/mcp_pipe_protocol_tests.exe - -static -static-libgcc -static-libstdc++ - && build/p2-${{ matrix.arch }}/mcp_pipe_protocol_tests.exe - - - name: Build and run semantic execution contract tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 tests/semantic_tools_tests.cpp - -Ibuild/p2-${{ matrix.arch }}/_deps/json-src/include - -o build/p2-${{ matrix.arch }}/semantic_tools_tests.exe - -static -static-libgcc -static-libstdc++ - && build/p2-${{ matrix.arch }}/semantic_tools_tests.exe core/api/routes_status.cpp - - - name: Build and run MCP schema tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 tests/mcp_schema_tests.cpp - -Ibuild/p2-${{ matrix.arch }}/_deps/json-src/include - -o build/p2-${{ matrix.arch }}/mcp_schema_tests.exe - -static -static-libgcc -static-libstdc++ - && build/p2-${{ matrix.arch }}/mcp_schema_tests.exe - && c++ -std=c++17 -O2 tests/mcp_query_schema_tests.cpp - -Ibuild/p2-${{ matrix.arch }}/_deps/json-src/include - -o build/p2-${{ matrix.arch }}/mcp_query_schema_tests.exe - -static -static-libgcc -static-libstdc++ - && build/p2-${{ matrix.arch }}/mcp_query_schema_tests.exe - - - name: Build and run game-thread call scheduler tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 tests/call_scheduler_tests.cpp core/call/call.cpp - -o build/p2-${{ matrix.arch }}/call_scheduler_tests.exe - -static -static-libgcc -static-libstdc++ -luser32 - && build/p2-${{ matrix.arch }}/call_scheduler_tests.exe - - name: Build and run MCP bridge policy tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 tests/mcp_bridge_policy_tests.cpp - -o build/p2-${{ matrix.arch }}/mcp_bridge_policy_tests.exe - -static -static-libgcc -static-libstdc++ - && build/p2-${{ matrix.arch }}/mcp_bridge_policy_tests.exe - - - name: Build and run inherited request tests - shell: msys2 {0} - run: >- - c++ -std=c++17 -O2 tests/request_id_tests.cpp - -o build/p2-${{ matrix.arch }}/request_id_tests.exe - -static -static-libgcc -static-libstdc++ - && build/p2-${{ matrix.arch }}/request_id_tests.exe - && c++ -std=c++17 -O2 tests/pagination_tests.cpp - -o build/p2-${{ matrix.arch }}/pagination_tests.exe - -static -static-libgcc -static-libstdc++ - && build/p2-${{ matrix.arch }}/pagination_tests.exe - # ---- P3 Probe Build (was .github/workflows/p3-probe-build.yml) p3-probe-build: name: probe-build-${{ matrix.arch }} @@ -399,6 +294,73 @@ jobs: - name: Run portable target model test run: ./target_model_portable_tests + # The desktop frame loop, its workers and the debugger backend share one + # SessionManager; ThreadSanitizer fails this step on any data race. + - name: Session manager under ThreadSanitizer + run: >- + g++ -std=c++17 -g -O1 -Wall -Wextra -Werror -fsanitize=thread -pthread + -Icore tests/session_manager_tests.cpp core/target/session_manager.cpp + -o session_manager_tsan + && setarch "$(uname -m)" -R env TSAN_OPTIONS=halt_on_error=1 ./session_manager_tsan + + # The shared investigation context is written by pipe workers serving + # the desktop and agents at once. + - name: Investigation context under ThreadSanitizer + run: >- + sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends nlohmann-json3-dev + && g++ -std=c++17 -g -O1 -Wall -Wextra -Werror -fsanitize=thread -pthread + -Icore tests/investigation_context_tests.cpp + core/context/investigation_context.cpp core/context/context_api.cpp + -o investigation_context_tsan + && setarch "$(uname -m)" -R env TSAN_OPTIONS=halt_on_error=1 ./investigation_context_tsan + + # ---- Fuzzing of the file parsers (step 9). Inputs are untrusted: every + # parser must survive malformed files without a crash, an out-of-bounds + # access or undefined behaviour (ASan + UBSan). + fuzz-parsers: + name: Fuzz file parsers + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Install clang and its sanitizer runtimes + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends clang-18 libclang-rt-18-dev ninja-build + # ASan needs fewer random mmap bits than recent runner kernels use. + sudo sysctl -w vm.mmap_rnd_bits=28 + + - name: Build the fuzzers (libFuzzer, ASan, UBSan) + run: | + cmake -S tests/fuzz -B build/fuzz -G Ninja -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 + cmake --build build/fuzz --parallel + + - name: Replay the kept crash inputs and the seeds + run: | + cmake -S tests/fuzz -B build/fuzz-replay -G Ninja -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 -DCORTEX_FUZZ_REPLAY=ON + cmake --build build/fuzz-replay --parallel + ctest --test-dir build/fuzz-replay --output-on-failure + python3 tests/fuzz/make_seeds.py build/fuzz-seeds + for harness in artifact pe_code text protocols stores; do build/fuzz-replay/fuzz_$harness build/fuzz-seeds/$harness; done + + - name: Fuzz each harness for 60 s + run: | + mkdir -p build/fuzz-findings + for harness in artifact pe_code text protocols stores; do + mkdir -p build/fuzz-work/$harness + build/fuzz/fuzz_$harness -max_total_time=60 -max_len=65536 -timeout=10 -rss_limit_mb=2048 \ + -artifact_prefix=build/fuzz-findings/$harness- build/fuzz-work/$harness build/fuzz-seeds/$harness + done + + - name: Upload crashing inputs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-findings + path: build/fuzz-findings + if-no-files-found: ignore + # ---- P4 Target Model (was .github/workflows/p4-target-model.yml) p4-target-model: name: target-model-${{ matrix.arch }} diff --git a/.github/workflows/imgui-gui-tests.yml b/.github/workflows/imgui-gui-tests.yml index d0fe0d1a..1ea92372 100644 --- a/.github/workflows/imgui-gui-tests.yml +++ b/.github/workflows/imgui-gui-tests.yml @@ -15,6 +15,26 @@ on: - "runtime_helper/**" - "tests/gui/**" - "tests/security_no_writes_regression.py" + - "tests/context_isolation_e2e.py" + - "tests/robustness_e2e.py" + - "tests/live_baseline.py" + - "tests/manual_demo_e2e.py" + - "tests/artifact_mcp_e2e.py" + - "tests/dotnet_e2e.py" + - "tests/native_static_e2e.py" + - "tests/evidence_e2e.py" + - "tests/investigation_e2e.py" + - "tests/analysis_e2e.py" + - "tests/analysis_provider_tests.cpp" + - "core/providers/**" + - "core/live/**" + - "tests/live_e2e.py" + - "tests/cdp_tests.cpp" + - "core/investigation/**" + - "core/evidence/**" + - "core/analysis/**" + - "tests/fixtures/**" + - "core/artifacts/**" - "tests/fixtures/mcp_tool_risks.json" - "core/security/**" - "mcp_bridge/**" @@ -32,6 +52,16 @@ concurrency: group: imgui-gui-suite-${{ github.event.pull_request.head.ref || github.ref_name }} cancel-in-progress: true +# ccache for the MinGW builds: restored per job from the latest cache, so a +# pull request recompiles only what changed. It hashes the preprocessed +# source and the compiler, so a stale cache cannot change a build. +env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_BASEDIR: ${{ github.workspace }} + CCACHE_COMPILERCHECK: content + CCACHE_NOHASHDIR: "true" + CCACHE_MAXSIZE: 700M + jobs: source-contracts: runs-on: ubuntu-latest @@ -44,7 +74,7 @@ jobs: run: | set -euo pipefail count="$(grep -c 'workspaces.Add- + git + mingw-w64-x86_64-gcc + mingw-w64-x86_64-cmake + mingw-w64-x86_64-ninja + mingw-w64-x86_64-ccache + + - name: Restore the compiler cache + uses: actions/cache@v4 + with: + path: .ccache + key: ccache-analysis-engines-${{ github.sha }} + restore-keys: ccache-analysis-engines- + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Cache Ghidra + id: ghidra-cache + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/ghidra + key: ghidra-${{ env.GHIDRA_VERSION }}-${{ env.GHIDRA_BUILD }} + + - name: Download Ghidra (Apache-2.0, external process) + if: steps.ghidra-cache.outputs.cache-hit != 'true' + shell: pwsh + run: | + $zip = "$env:RUNNER_TEMP/ghidra.zip" + $url = "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_${env:GHIDRA_VERSION}_build/ghidra_${env:GHIDRA_VERSION}_PUBLIC_${env:GHIDRA_BUILD}.zip" + Invoke-WebRequest -Uri $url -OutFile $zip + Expand-Archive -Path $zip -DestinationPath "$env:RUNNER_TEMP/ghidra" -Force + Remove-Item $zip + + - name: Locate engines + shell: pwsh + run: | + $ghidra = (Get-ChildItem "$env:RUNNER_TEMP/ghidra" -Directory | Select-Object -First 1).FullName + if (-not (Test-Path "$ghidra/support/analyzeHeadless.bat")) { throw "Ghidra not found under $env:RUNNER_TEMP/ghidra" } + "GHIDRA_INSTALL_DIR=$ghidra" | Add-Content $env:GITHUB_ENV + dotnet tool install ilspycmd --version 9.1.0.7988 --tool-path "$env:RUNNER_TEMP/ilspy" + if ($LASTEXITCODE -ne 0) { throw "ilspycmd install failed" } + "CORTEX_ILSPYCMD=$env:RUNNER_TEMP\ilspy\ilspycmd.exe" | Add-Content $env:GITHUB_ENV + # Vineflower (Apache-2.0) from Maven Central, checked against its SHA-1. + $jar = "$env:RUNNER_TEMP\vineflower-$env:VINEFLOWER_VERSION.jar" + Invoke-WebRequest -Uri "https://repo1.maven.org/maven2/org/vineflower/vineflower/$env:VINEFLOWER_VERSION/vineflower-$env:VINEFLOWER_VERSION.jar" -OutFile $jar + if ((Get-FileHash $jar -Algorithm SHA1).Hash.ToLower() -ne $env:VINEFLOWER_SHA1) { throw "vineflower hash mismatch" } + "CORTEX_VINEFLOWER=$jar" | Add-Content $env:GITHUB_ENV + # netcoredbg (MIT), the .NET debug adapter, checked against its SHA-256. + $zip = "$env:RUNNER_TEMP\netcoredbg.zip" + Invoke-WebRequest -Uri "https://github.com/Samsung/netcoredbg/releases/download/$env:NETCOREDBG_RELEASE/netcoredbg-win64.zip" -OutFile $zip + if ((Get-FileHash $zip -Algorithm SHA256).Hash.ToLower() -ne $env:NETCOREDBG_SHA256) { throw "netcoredbg hash mismatch" } + Expand-Archive -Path $zip -DestinationPath "$env:RUNNER_TEMP\netcoredbg" -Force + "CORTEX_NETCOREDBG=$env:RUNNER_TEMP\netcoredbg\netcoredbg\netcoredbg.exe" | Add-Content $env:GITHUB_ENV + # debugpy (MIT) for the Python live fixture. + python -m pip install "debugpy==$env:DEBUGPY_VERSION" + if ($LASTEXITCODE -ne 0) { throw "debugpy install failed" } + + - name: Build provider tests and the x64 target + shell: msys2 {0} + run: | + set -euo pipefail + cmake -S . -B build/analysis -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + cmake --build build/analysis --target cortex_analysis_provider_tests cortex_cdp_tests cortex_dap_tests cortex_test_target --parallel 2 + ccache -s || true + + - name: Download x86 GUI fixture + uses: actions/download-artifact@v4 + with: + name: imgui-gui-x86-fixture + path: build/x86-fixture + + - name: Provider tests with Ghidra and ILSpy (x64 and x86 targets) + shell: pwsh + env: + CORTEX_REQUIRE_ENGINES: "1" + run: | + $tests = "build/analysis/cortex_analysis_provider_tests.exe" + & $tests "build/analysis/cortex_test_target_x64.exe" "tests/fixtures/dotnet/Cortex.Fixture.dll" "tests/fixtures/java/Cortex.Fixture.jar" "tests/fixtures/elf/actor.elf" + if ($LASTEXITCODE -ne 0) { throw "provider tests failed (x64 target)" } + & $tests "build/x86-fixture/e2e/cortex_test_target_x86.exe" + if ($LASTEXITCODE -ne 0) { throw "provider tests failed (x86 target)" } + + - name: CDP tests against a real node (Winsock client) + shell: pwsh + env: + CORTEX_REQUIRE_NODE: "1" + run: | + build/analysis/cortex_cdp_tests.exe tests/fixtures/node-live/app.js + if ($LASTEXITCODE -ne 0) { throw "CDP tests failed" } + + - name: DAP and JDWP tests (Python through debugpy, .NET through netcoredbg, Java through JDWP) + shell: pwsh + env: + CORTEX_REQUIRE_DEBUGPY: "1" + CORTEX_REQUIRE_NETCOREDBG: "1" + CORTEX_REQUIRE_JDWP: "1" + run: | + dotnet build tests/fixtures/dotnet-live/Cortex.LiveFixture.csproj -c Debug -o "$env:RUNNER_TEMP\dotnet-live" + if ($LASTEXITCODE -ne 0) { throw "building the .NET live fixture failed" } + $env:CORTEX_TEST_PYTHON = (Get-Command python).Source + build/analysis/cortex_dap_tests.exe tests/fixtures/python-live/app.py "$env:RUNNER_TEMP\dotnet-live\Cortex.LiveFixture.dll" "$env:CORTEX_NETCOREDBG" tests/fixtures/dotnet-live/Program.cs tests/fixtures/java-live/LiveApp.java + if ($LASTEXITCODE -ne 0) { throw "DAP tests failed" } + + - name: Download tested portable bundle + if: success() || failure() + uses: actions/download-artifact@v4 + with: + name: cortex-imgui-gui-tested-portable + path: stage/Cortex + + - name: Ghidra and ILSpy through MCP + if: success() || failure() + shell: pwsh + run: | + $bundle = (Resolve-Path "stage/Cortex").Path + python tests/analysis_e2e.py --cortex "$bundle/cortex.exe" --ilspycmd "$env:CORTEX_ILSPYCMD" ` + --ghidra-dir "$env:GHIDRA_INSTALL_DIR" --target "$bundle/e2e/cortex_test_target_x64.exe" ` + --vineflower "$env:CORTEX_VINEFLOWER" --compare-out build/engines-comparison.json + if ($LASTEXITCODE -ne 0) { throw "analysis E2E failed" } + + - name: Upload the Cortex vs Ghidra comparison + if: success() || failure() + uses: actions/upload-artifact@v4 + with: + name: cortex-vs-ghidra-functions + path: build/engines-comparison.json + if-no-files-found: warn + x86-fixture: runs-on: windows-2025 timeout-minutes: 35 @@ -97,13 +283,22 @@ jobs: mingw-w64-i686-gcc mingw-w64-i686-cmake mingw-w64-i686-ninja + mingw-w64-i686-ccache + + - name: Restore the compiler cache + uses: actions/cache@v4 + with: + path: .ccache + key: ccache-x86-fixture-${{ github.sha }} + restore-keys: ccache-x86-fixture- - name: Build x86 runtime and target shell: msys2 {0} run: | set -euo pipefail - cmake -S . -B build/gui-x86 -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF + cmake -S . -B build/gui-x86 -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache cmake --build build/gui-x86 --target core cortex_test_target --parallel 2 + ccache -s || true c++ -std=c++17 -O2 -municode runtime_helper/main.cpp -o build/gui-x86/cortex_runtime_helper.exe -static -static-libgcc -static-libstdc++ - name: Stage x86 GUI fixture @@ -141,13 +336,22 @@ jobs: mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake mingw-w64-x86_64-ninja + mingw-w64-x86_64-ccache + + - name: Restore the compiler cache + uses: actions/cache@v4 + with: + path: .ccache + key: ccache-windows-debug-ui-${{ github.sha }} + restore-keys: ccache-windows-debug-ui- - name: Build Debug UI shell: msys2 {0} run: | set -euo pipefail - cmake -S app_imgui -B build/gui-debug -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON + cmake -S app_imgui -B build/gui-debug -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache cmake --build build/gui-debug --parallel 2 + ccache -s || true - name: Run Debug GUI CTest suite shell: msys2 {0} @@ -172,24 +376,47 @@ jobs: mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake mingw-w64-x86_64-ninja + mingw-w64-x86_64-ccache + + - name: Restore the compiler cache + uses: actions/cache@v4 + with: + path: .ccache + key: ccache-windows-release-full-${{ github.sha }} + restore-keys: ccache-windows-release-full- - name: Build Release GUI shell: msys2 {0} run: | set -euo pipefail - cmake -S app_imgui -B build/gui-release -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON + cmake -S app_imgui -B build/gui-release -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache cmake --build build/gui-release --parallel 2 + ccache -s || true - name: Run targetless GUI CTest suite shell: msys2 {0} run: ctest --test-dir build/gui-release --output-on-failure + # Reference numbers for docs/cortex-2-roadmap.md (step 1.3). Shared + # runners are noisy: compare trends, not single runs. + - name: Record baseline benchmarks + shell: msys2 {0} + run: build/gui-release/cortex_baseline_bench.exe --json build/gui-release/cortex-baseline.json + + - name: Upload baseline benchmarks + uses: actions/upload-artifact@v4 + with: + name: cortex-baseline-windows-x64 + path: build/gui-release/cortex-baseline.json + if-no-files-found: error + - name: Build x64 runtime and target shell: msys2 {0} run: | set -euo pipefail - cmake -S . -B build/runtime-x64 -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF + cmake -S . -B build/runtime-x64 -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache cmake --build build/runtime-x64 --target core cortex_test_target --parallel 2 + ccache -s || true - name: Download x86 GUI fixture uses: actions/download-artifact@v4 @@ -207,8 +434,20 @@ jobs: Copy-Item "build/x86-fixture/runtime/x86/cortex_core.dll" "$app/runtime/x86/cortex_core.dll" -Force Copy-Item "build/x86-fixture/runtime/x86/cortex_runtime_helper.exe" "$app/runtime/x86/cortex_runtime_helper.exe" -Force + # ILSpy (MIT) for the Engines tab of the GUI suite and the analysis + # E2E; pinned so results are reproducible. + - name: Install ilspycmd + shell: pwsh + run: | + dotnet tool install ilspycmd --version 9.1.0.7988 --tool-path "$env:RUNNER_TEMP/ilspy" + if ($LASTEXITCODE -ne 0) { throw "ilspycmd install failed" } + "CORTEX_ILSPYCMD=$env:RUNNER_TEMP\ilspy\ilspycmd.exe" | Add-Content $env:GITHUB_ENV + - name: Run full GUI integration suite shell: pwsh + env: + CORTEX_GUI_SUITE_ILSPY: "1" + CORTEX_GUI_SUITE_NODE_FIXTURE: ${{ github.workspace }}\tests\fixtures\node-live\app.js run: | ./tests/gui/run_imgui_gui_suite.ps1 -CortexExe "build/gui-release/cortex.exe" -X64Target "build/runtime-x64/cortex_test_target_x64.exe" -X86Target "build/x86-fixture/e2e/cortex_test_target_x86.exe" -ReportPath "build/gui-report-direct.json" @@ -270,6 +509,147 @@ jobs: if ($LASTEXITCODE -ne 0) { $failed += "x86" } if ($failed.Count -gt 0) { throw "write-authority regression failed on: $($failed -join ', ')" } + - name: Investigation context isolation (two live targets) + shell: pwsh + run: | + # Two injected targets on one cortex.exe mcp: own pid/generation, + # no cross-talk, stale generations refused, reattach, one target + # killed. See tests/context_isolation_e2e.py. + $bundle = (Resolve-Path "stage/Cortex").Path + $failed = @() + foreach ($arch in @("x64", "x86")) { + python tests/context_isolation_e2e.py ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_$arch.exe" + if ($LASTEXITCODE -ne 0) { $failed += $arch } + } + if ($failed.Count -gt 0) { throw "context isolation E2E failed on: $($failed -join ', ')" } + + - name: Robustness (malformed requests, target killed mid-request, engines absent or failing) + shell: pwsh + run: | + # Every tool with wrongly typed arguments and malformed JSON-RPC: + # answered, nothing dies. A target killed during a request, and a + # debuggee killed with a hardware breakpoint: bounded errors, the + # server keeps serving new targets. Engines absent or failing mid-job. + # See tests/robustness_e2e.py. + $bundle = (Resolve-Path "stage/Cortex").Path + $failed = @() + foreach ($arch in @("x64", "x86")) { + python tests/robustness_e2e.py ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_$arch.exe" + if ($LASTEXITCODE -ne 0) { $failed += $arch } + } + if ($failed.Count -gt 0) { throw "robustness E2E failed on: $($failed -join ', ')" } + + - name: Manual workflow demo (scan, watch, find what writes, navigate, save) + shell: pwsh + run: | + # docs/demo-manual.md, scripted. Breakpoints need --allow-writes. + $bundle = (Resolve-Path "stage/Cortex").Path + $failed = @() + foreach ($arch in @("x64", "x86")) { + python tests/manual_demo_e2e.py ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_$arch.exe" + if ($LASTEXITCODE -ne 0) { $failed += $arch } + } + if ($failed.Count -gt 0) { throw "manual workflow demo failed on: $($failed -join ', ')" } + + - name: Static artifact analysis (inspect CLI and MCP artifact tools) + shell: pwsh + run: | + # No target process: PE fixtures, generated ASARs and the Electron + # fixture app, freshly packed by the real @electron/asar, inside a + # temporary --artifact-root. See tests/artifact_mcp_e2e.py. + npm install --prefix "$env:RUNNER_TEMP/asar" --no-audit --no-fund @electron/asar@4.3.1 + if ($LASTEXITCODE -ne 0) { throw "npm install @electron/asar failed" } + node tests/fixtures/make_electron_fixture.mjs "$env:RUNNER_TEMP/asar/node_modules" --asar-only "$env:RUNNER_TEMP/electron-app.asar" + if ($LASTEXITCODE -ne 0) { throw "packing the Electron fixture failed" } + $bundle = (Resolve-Path "stage/Cortex").Path + $failed = @() + foreach ($arch in @("x64", "x86")) { + python tests/artifact_mcp_e2e.py ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_$arch.exe" ` + --electron-asar "$env:RUNNER_TEMP/electron-app.asar" + if ($LASTEXITCODE -ne 0) { $failed += $arch } + } + if ($failed.Count -gt 0) { throw "artifact E2E failed on: $($failed -join ', ')" } + + - name: .NET metadata and CIL against System.Reflection.Metadata + shell: pwsh + run: | + # A fresh C# build and fresh oracle answers from this runner's SDK, + # then the committed fixture. See tests/dotnet_e2e.py. + python tests/fixtures/dotnet/make_dotnet_fixture.py --out "$env:RUNNER_TEMP/dotnet" + if ($LASTEXITCODE -ne 0) { throw "building the .NET fixture failed" } + $bundle = (Resolve-Path "stage/Cortex").Path + python tests/dotnet_e2e.py --cortex "$bundle/cortex.exe" --fixtures "$env:RUNNER_TEMP/dotnet" + if ($LASTEXITCODE -ne 0) { throw ".NET E2E failed on the fresh build" } + python tests/dotnet_e2e.py --cortex "$bundle/cortex.exe" + if ($LASTEXITCODE -ne 0) { throw ".NET E2E failed on the committed fixture" } + + - name: Native code analysis against objdump (catalog, xrefs, CFG, annotations) + shell: pwsh + run: | + # objdump comes from MSYS2's MinGW binutils, an independent + # disassembler. See tests/native_static_e2e.py. + $objdump = "$(msys2 -c 'cygpath -w /mingw64/bin/objdump.exe')".Trim() + if (-not (Test-Path $objdump)) { throw "objdump not found at '$objdump'" } + $bundle = (Resolve-Path "stage/Cortex").Path + $failed = @() + foreach ($arch in @("x64", "x86")) { + python tests/native_static_e2e.py ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_$arch.exe" ` + --objdump $objdump + if ($LASTEXITCODE -ne 0) { $failed += $arch } + } + if ($failed.Count -gt 0) { throw "native static E2E failed on: $($failed -join ', ')" } + + - name: Evidence records (class rules, restart without a process, export, compare) + shell: pwsh + run: | + # See tests/evidence_e2e.py. + $bundle = (Resolve-Path "stage/Cortex").Path + $failed = @() + foreach ($arch in @("x64", "x86")) { + python tests/evidence_e2e.py ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_$arch.exe" + if ($LASTEXITCODE -ne 0) { $failed += $arch } + } + if ($failed.Count -gt 0) { throw "evidence E2E failed on: $($failed -join ', ')" } + + - name: Investigations (plan, server-run static steps, person approval through the CLI) + shell: pwsh + run: | + # See tests/investigation_e2e.py. + $bundle = (Resolve-Path "stage/Cortex").Path + python tests/investigation_e2e.py ` + --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_x64.exe" + if ($LASTEXITCODE -ne 0) { throw "investigation E2E failed" } + + - name: ILSpy through MCP (rebuilt decompiled project compared with the original) + shell: pwsh + run: | + # Ghidra runs in the analysis-engines job. See tests/analysis_e2e.py. + $bundle = (Resolve-Path "stage/Cortex").Path + python tests/analysis_e2e.py --cortex "$bundle/cortex.exe" --ilspycmd "$env:CORTEX_ILSPYCMD" + if ($LASTEXITCODE -ne 0) { throw "analysis E2E (ILSpy) failed" } + + - name: Node.js live through CDP (log breakpoints, console, write authority) + shell: pwsh + run: | + # See tests/live_e2e.py; node is preinstalled on the runner. + node --version + $bundle = (Resolve-Path "stage/Cortex").Path + python tests/live_e2e.py --cortex "$bundle/cortex.exe" --node node + if ($LASTEXITCODE -ne 0) { throw "live E2E (CDP) failed" } + - name: Which tool stalls the target? (diagnostic, only after a failure) if: failure() continue-on-error: true @@ -332,6 +712,27 @@ jobs: Stop-Process -Id $target.Id -Force -ErrorAction SilentlyContinue } + # Step 9: live baselines on this Windows runner (scan time on a real + # process, MCP round-trip latency). Shared runners are noisy: compare + # trends, not single runs. See tests/live_baseline.py. + - name: Record live baselines (x64 and x86 targets) + shell: pwsh + run: | + $bundle = (Resolve-Path "stage/Cortex").Path + New-Item -ItemType Directory -Force -Path build/live-baseline | Out-Null + foreach ($arch in @("x64", "x86")) { + python tests/live_baseline.py --cortex "$bundle/cortex.exe" ` + --target "$bundle/e2e/cortex_test_target_$arch.exe" --out "build/live-baseline/live-$arch.json" + if ($LASTEXITCODE -ne 0) { throw "live baseline failed on $arch" } + } + + - name: Upload live baselines + uses: actions/upload-artifact@v4 + with: + name: cortex-live-baseline-windows + path: build/live-baseline + if-no-files-found: error + - name: Publish GUI test summary if: always() shell: pwsh diff --git a/.github/workflows/imgui-ui.yml b/.github/workflows/imgui-ui.yml index f5444b4c..f058a71a 100644 --- a/.github/workflows/imgui-ui.yml +++ b/.github/workflows/imgui-ui.yml @@ -81,7 +81,7 @@ jobs: } $version = Invoke-CortexCli @("--version") - if ($version -notmatch "1\.0\.1") { throw "Unexpected version output: $version" } + if ($version -notmatch "2\.0\.0") { throw "Unexpected version output: $version" } $help = Invoke-CortexCli @("--help") if ($help -notmatch "cortex mcp") { throw "MCP command missing from help" } diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index f6bff9f2..02e4e92e 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -43,15 +43,15 @@ permissions: contents: write concurrency: - group: publish-v1.0.1-${{ github.ref }} + group: publish-v2.0.0-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - RELEASE_TAG: v1.0.1 + RELEASE_TAG: v2.0.0 jobs: windows-x64-stage: - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'Release v1.0.1') + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'Release v2.0.0') runs-on: windows-2025 timeout-minutes: 50 steps: @@ -130,7 +130,7 @@ jobs: retention-days: 2 windows-x86-stage: - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'Release v1.0.1') + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'Release v2.0.0') runs-on: windows-2025 timeout-minutes: 45 steps: @@ -180,7 +180,7 @@ jobs: retention-days: 2 package: - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'Release v1.0.1') + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'Release v2.0.0') needs: [windows-x64-stage, windows-x86-stage] runs-on: windows-2025 timeout-minutes: 15 @@ -205,8 +205,9 @@ jobs: $bundle = (Resolve-Path "staging/x64/Cortex").Path New-Item -ItemType Directory -Force -Path (Join-Path $bundle "runtime\x86") | Out-Null Copy-Item "staging/x86/runtime/x86/*" (Join-Path $bundle "runtime\x86") -Force - Copy-Item README.md, README_INSTALL.txt, CHANGELOG.md, LICENSE $bundle + Copy-Item README.md, README_INSTALL.txt, CHANGELOG.md, LICENSE, CONTRIBUTING.md, SECURITY.md $bundle Copy-Item -Recurse docs (Join-Path $bundle "docs") + Copy-Item -Recurse agent (Join-Path $bundle "agent") foreach ($required in @( "cortex.exe", @@ -224,6 +225,27 @@ jobs: } if (Test-Path (Join-Path $bundle "cortex_core.dll")) { throw "cortex_core.dll must not be at the bundle root" } + # Cortex 2.0 contents: the documents users need, README links that + # resolve inside the archive, and nothing left over from tests. + foreach ($required in @("docs\limitations.md", "docs\analysis-providers.md", "docs\third-party.md", + "docs\getting-started.md", "docs\mcp.md", "docs\images\brand\cortex-banner-dark.png", + "docs\images\brand\cortex-banner-light.png", "docs\images\readme\manual-find-what-writes.gif")) { + if (-not (Test-Path (Join-Path $bundle $required))) { throw "missing release document: $required" } + } + $readme = Get-Content (Join-Path $bundle "README.md") -Raw + $links = [regex]::Matches($readme, '(?:\]\(|src=")([^)"#\s]+)') | ForEach-Object { $_.Groups[1].Value } | + Where-Object { $_ -notmatch '^(https?:|mailto:)' } | Sort-Object -Unique + $broken = @($links | Where-Object { -not (Test-Path (Join-Path $bundle $_)) }) + if ($broken.Count -gt 0) { throw "README links missing from the archive: $($broken -join ', ')" } + Write-Host ("README links checked: {0}" -f $links.Count) + if ((Get-Content (Join-Path $bundle "README_INSTALL.txt") -Raw) -notmatch "v2\.0\.0") { + throw "README_INSTALL.txt does not name v2.0.0" + } + $leftovers = @(Get-ChildItem -Recurse -Force $bundle | Where-Object { + $_.Name -like "cortex.mcp.*.token" -or $_.Name -eq "cortex_crashes" -or $_.Name -eq "cortex_sessions" -or + $_.Name -like "cortex_test_target*" -or $_.Name -eq "e2e" }) + if ($leftovers.Count -gt 0) { throw "test leftovers in the archive: $($leftovers.FullName -join ', ')" } + $versionStart = [System.Diagnostics.ProcessStartInfo]::new() $versionStart.FileName = Join-Path $bundle "cortex.exe" [void]$versionStart.ArgumentList.Add("--version") @@ -236,9 +258,17 @@ jobs: $versionError = $versionProcess.StandardError.ReadToEnd() $versionProcess.WaitForExit() Write-Host ("release version output: {0}" -f $versionText.Trim()) - if ($versionProcess.ExitCode -ne 0 -or $versionText -notmatch "1\.0\.1") { - throw "release binary does not report v1.0.1 (exit=$($versionProcess.ExitCode), stderr=$versionError)" + if ($versionProcess.ExitCode -ne 0 -or $versionText -notmatch "2\.0\.0") { + throw "release binary does not report v2.0.0 (exit=$($versionProcess.ExitCode), stderr=$versionError)" + } + + $help = & (Join-Path $bundle "cortex.exe") --help 2>&1 | Out-String + foreach ($command in @("mcp", "inspect", "provider", "live", "evidence", "investigation")) { + if ($help -notmatch "cortex $command") { throw "--help does not list '$command'" } } + $providers = & (Join-Path $bundle "cortex.exe") provider list 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "provider list failed without engines installed: $providers" } + Write-Host $providers $oldPath = $env:PATH try { @@ -268,18 +298,18 @@ jobs: } New-Item -ItemType Directory -Force dist | Out-Null - Compress-Archive -Path "$bundle/*" -DestinationPath "dist/cortex-v1.0.1-windows-portable.zip" -CompressionLevel Optimal + Compress-Archive -Path "$bundle/*" -DestinationPath "dist/cortex-v2.0.0-windows-portable.zip" -CompressionLevel Optimal - name: Upload release archive uses: actions/upload-artifact@v4 with: - name: cortex-v1.0.1-windows-portable - path: dist/cortex-v1.0.1-windows-portable.zip + name: cortex-v2.0.0-windows-portable + path: dist/cortex-v2.0.0-windows-portable.zip if-no-files-found: error retention-days: 14 release: - if: github.event_name == 'push' && contains(github.event.head_commit.message, 'Release v1.0.1') + if: github.event_name == 'push' && contains(github.event.head_commit.message, 'Release v2.0.0') needs: package runs-on: ubuntu-latest steps: @@ -289,13 +319,13 @@ jobs: shell: bash run: | set -euo pipefail - awk '/^## \[v1.0.1\]/{found=1; next} /^## \[/{if(found) exit} found' CHANGELOG.md > release-notes.md + awk '/^## \[v2.0.0\]/{found=1; next} /^## \[/{if(found) exit} found' CHANGELOG.md > release-notes.md test -s release-notes.md - name: Download release archive uses: actions/download-artifact@v4 with: - name: cortex-v1.0.1-windows-portable + name: cortex-v2.0.0-windows-portable path: dist - name: Publish GitHub release @@ -312,4 +342,4 @@ jobs: --target "$GITHUB_SHA" \ --title "$RELEASE_TAG" \ --notes-file release-notes.md \ - dist/cortex-v1.0.1-windows-portable.zip + dist/cortex-v2.0.0-windows-portable.zip diff --git a/.gitignore b/.gitignore index b6f05106..58bc3532 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ cortex_host.token .vscode/ *.user Thumbs.db +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d4d4b5..52f12426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,156 @@ All notable changes to Cortex are documented in this file. +## [v2.0.0] - not yet released + +Cortex 2.0. Highlights: + +- **Two ways to work.** The desktop and the command line for a person; `cortex.exe mcp` for an AI agent, which is read-only unless the operator starts it with `--allow-writes` and whose every tool call appears in the desktop's AI Activity panel. +- **Files as well as processes.** Static analysis of PE and native code, .NET metadata and CIL, Electron ASAR, JavaScript/TypeScript with source maps, and ZIP/JAR, without running anything. +- **External engines.** Ghidra (PE x64/x86, ELF), ILSpy (C#) and Vineflower (Java) as separate processes, with results cached by SHA-256 and mapped back to the live module by RVA. +- **Live providers.** Log breakpoints for Node.js (CDP), Python (debugpy), .NET (netcoredbg, stdio mode on Windows) and Java (JDWP), with no evaluation of code. +- **Evidence and investigations.** Records with provenance, class and status; supervised investigations with a plan, journal, report and approval by the person; an AI Investigation workspace. +- **Quality.** Parser and protocol fuzzing, robustness tests against killed targets and failing engines, live baselines and a comparison with Ghidra measured in CI. Fixes include a target crash at thread exit, a scan-session leak in the target and several ways a malformed request could end the MCP server. + +The plan, what each step validated and its limits: `docs/cortex-2-roadmap.md` and `docs/limitations.md`. Steps 1 to 10. + +### Added + +- **Quality (step 9).** + - Contract tests are now in CTest. + - Fuzzing of the file parsers (`tests/fuzz`, CI job `fuzz-parsers`). + - Robustness end-to-end tests (`tests/robustness_e2e.py`). +- **Product documentation (step 10, in progress).** + - The README is reorganized around what Cortex does. It has three GIFs recorded from the real app against the test target and fixtures, an architecture diagram, a table of what can be analyzed with each row's validation, a safety model and an FAQ. + - New: `CONTRIBUTING.md`, `SECURITY.md`, issue and pull request templates, and `docs/limitations.md`. +- **netcoredbg in stdio mode.** + - Cortex starts the operator's netcoredbg (`--netcoredbg`, `CORTEX_NETCOREDBG`) and speaks DAP over its pipes. This works around the Windows 3.1.2 `--server` mode, which reads no request after `initialize`. + - `cortex_live_attach` with `adapter: "netcoredbg"` and no port uses this mode. +- **External analysis engines (step 8).** One `AnalysisProvider` interface for engines that run as separate processes, never linked. + - **Ghidra** headless: a Cortex-owned temporary project and an embedded post-script. It exports functions, imports, calls, types and decompiled C keyed by RVA, and checks that the imported bytes have Cortex's SHA-256. + - **ILSpy** (`ilspycmd`): the C# project or one type, and the type list. + - Jobs can be cancelled (the engine's process tree is killed) and results are cached by SHA-256, engine version and options. + - New: MCP tools `cortex_analysis_*`, `cortex.exe provider list|run`, the Artifacts **Engines** tab, the options `--ghidra-dir`, `--ilspycmd`, `--dotnet` and `--analysis-cache`, and the CI job `analysis-engines` with pinned Ghidra 11.4.2 and ilspycmd 9.1. + - **Vineflower** for Java (.jar/.class), and Ghidra on ELF. + - `cortex_analysis_map` maps analysis RVAs to the live target's module whose file has the same SHA-256. + - **Live providers** behind one interface (`cortex_live_*`, `cortex.exe live`, AI Investigation › Live): + - CDP for Node.js/Electron; + - DAP for Python (debugpy) and .NET (netcoredbg); + - JDWP for Java. + + All three use log breakpoints only: Cortex records the stack and locals, then resumes. There is no evaluation and no method invocation, and a pause Cortex did not set is resumed. + - Ports are allowed with `--inspect-port`. Breakpoints need `mutation_permission` and `--allow-writes`. + - See `docs/analysis-providers.md` and `docs/third-party.md`. + +- **AI Investigation workspace (step 7).** + - Investigations with their plan, journal, evidence and report, plus **Approve/Reject** by the person (actor `desktop`). + - Every evidence record, with **Live** and **Static** navigation. + - The process tree of a multi-process target with Electron/Chromium roles, also available as the read-only MCP tool `cortex_process_tree`. + + module+RVA navigation in both directions: + - the address menu has **Open in Artifacts (static code)**; + - the Code tab has **Show in live disassembly** and **Record as evidence**. + + Live and static are linked only through a loaded module whose file has the same SHA-256. +- **Investigations (step 6).** A host-side journal (`cortex.investigation.v1`) with a testable question, a plan of tool calls, limits, approvals, evidence and a conclusion. It is not a second agent: the AI client runs the tools. + - Cortex enforces the states, runs read-only static steps itself, and checks cited trials and evidence. It refuses to record input or mutating steps until the person approves them with `cortex.exe investigation approve`; there is no MCP approval tool. + - Templates: `function_static`, `memory_value`, `electron_ipc_native`. + - New: 11 MCP tools, `--investigation-store`, `cortex.exe investigation`. See `docs/investigations.md`. +- **Evidence records (step 5).** One host-side record type (`cortex.evidence.v1`) for trials, scans, breakpoints and static results, readable without the target process. + - Subjects are artifact SHA-256 plus a location. Relations are typed and can cross runtimes. + - The class is enforced by Cortex: `verified_observation` only when Cortex reruns the static tool and reproduces the expected values, or when the runner reports the trial completed. Later checks mark records stale; they never upgrade or downgrade them silently. + - Builds can be compared with position-independent code fingerprints (unchanged, moved, not found). + - Export as JSON or Markdown. + - New: MCP tools `cortex_evidence_*`, `cortex.exe evidence export|list`, `--evidence-store`, and the Artifacts **Evidence** tab. See `docs/evidence.md`. +- **Native code analysis of files, offline (step 4d).** For x86 and x64 PEs: + - a function catalog where each start states its provenance (entry, export, pdata, TLS, call target, after-ret heuristic); + - a cross-reference index (calls, jumps, memory and address operands) and strings with their references; + - CFGs with optional coverage from a trace. + + The runtime's CFG algorithm now lives in `core/analysis/cfg_builder.h` and is shared by live and offline analysis. + + New **annotations** (names and comments per artifact SHA-256) with revisions. The desktop can rename functions; agents use `cortex_artifact_annotate`, which needs `--allow-state` and `mutation_permission`. + + New: MCP tools `cortex_artifact_functions`, `_xrefs`, `_strings`, `_cfg`, `_annotations` and `_annotate`; `inspect --functions/--strings/--xrefs/--cfg`; the Artifacts **Code** tab. Checked against objdump and the runtime's unwind reader. +- **.NET assemblies and ZIP containers, static (step 4c).** + - The `dotnet` provider reads the CLR header, the metadata streams and all 45 table layouts. It reports the assembly, references, types, fields, methods with ilasm-style signatures, member references, resources and P/Invoke. Its CIL disassembler covers the full opcode table and exception clauses. + - The `zip` provider (with JAR, APK, NuGet and wheel recognition) uses a bounded DEFLATE decoder and checks CRC-32. + - New: the MCP tool `cortex_artifact_cil`, `cortex.exe inspect --cil`, and the Artifacts **.NET** tab. + - Validated against `System.Reflection.Metadata` and the runtime's `OpCodes` table on a C# fixture. CI rebuilds the fixture with the SDK. +- **JavaScript, TypeScript and Electron, static (step 4b).** New providers: + - `javascript`: a tokenizer that knows strings, templates, regexes and comments. It reports ESM, CommonJS and dynamic dependencies, exports, Electron IPC uses and `BrowserWindow` options, bundler signatures and minification; + - `sourcemap`: VLQ decoding and position lookup. On an esbuild bundle it matches Node's `findEntry` on every column. + + ASAR archives now get: + - a per-file integrity check; + - the header SHA-256; + - a module graph; + - a static Electron view: main, preload and renderer files, IPC channels paired by handler and caller, `contextBridge` keys. + + New: the read-only MCP tool `cortex_artifact_source_position`, `cortex.exe inspect --position`, and the **Electron**, **Modules** and **Source position** tabs in the Artifacts workspace. The validation fixture is a real Electron app, bundled by esbuild and packed by `@electron/asar`. +- **Static artifact analysis (step 4a).** A provider system in `core/artifacts/` identifies a file and reports its structure. It returns the SHA-256 and a separate status for static parsing, decompilation and live debugging. Providers: + - offline PE: sections, imports, exports, relocations, CodeView, TLS, exceptions, CLR header, certificate, overlay; + - Electron ASAR: file tree, `package.json`, nested entries. + + It is reachable three ways: + - `cortex.exe inspect `; + - the read-only MCP tools `cortex_artifact_inspect` / `_children` / `_read`, limited to the operator's `--artifact-root` directories; + - the new **Artifacts** workspace, which can record an inspected artifact in the investigation context. + + Nothing is executed or loaded. See `docs/mcp.md`. +- **Shared investigation context.** The runtime in each target now keeps one context that the desktop and MCP clients share. It holds: + - pid and process generation; + - modules and `module+RVA` locations; + - the address the person selected; + - observations with their provenance; + - recent tool calls. + + MCP tools: `context_get` and `context_events` (observe), and `context_observation_add` (state: `mutation_permission` plus `--allow-state` or `--allow-writes`). In the desktop, right-click an address and choose **Share with investigation context**; only the desktop can make a selection. The Project workspace gains **Knowledge** (the agent's `project_knowledge_put` records) and **Investigation context** tabs, refreshed when an agent writes. Records are caller assertions, never upgraded to facts. See `docs/mcp.md`. +- **Typed events with gap reporting.** New types: + - `runtime.started`; + - `module.loaded` / `module.unloaded`; + - `context.address_selected` / `context.observation_added`; + - `knowledge.updated` and `evidence.created`; + - `agent.action_requested` / `agent.action_completed` / `agent.action_failed`, published for an agent's state-changing tools, with argument names only. + + `/ui/events` and `context_events` report `oldest_id`, `missed` and `gap`, so a consumer knows when events were evicted. +- **Desktop state shared with agents.** Each finished value scan (query, count, up to 256 results) and the external debugger's breakpoints, hit counters and paused threads reach `context_get` under `shared_state`, through a desktop-only route. The memory workspace has a switch to stop sharing scans. +- **`breakpoint.hit` events** for the runtime and desktop debuggers, derived from hit counters when state is refreshed (never from the exception path). +- **Pointer path stability.** The pointer scanner records, per process generation, whether each path led to the value (`Validate here` in the desktop). Paths are `stable` once confirmed in two processes, `invalidated` after one miss, `candidate` otherwise. Unlike Rescan, nothing is dropped. The history is saved (pointer-scan file v2; v1 still loads). +- **Memory viewer capture comparison.** **Capture**, **Compare with capture** and **Next change**: differences from a capture stay highlighted until the next one. +- **Demonstration actor in the test program** and `docs/demo-manual.md`. The scripted version, `tests/manual_demo_e2e.py`, runs in CI for x64 and x86. +- **Common result contract** (`api::response::Operation`): `status`, `error`, `cause`, `target`, `duration_ms`, `limits`, `data`, `evidence`, `permissions_required` and `rollback`. The new context routes use it, and `scan_new`, `scan_next` and `debug_breakpoint_add` gain its fields without changing the ones they already returned. + +### Fixed + +- `cortex.exe mcp` no longer exits when a client sends a wrongly typed argument to one of its own tools (for example `cortex_analysis_get {"job_id": null}`), a `tools/call` whose `params` is not an object, or a notification whose `method` is not a string. The request gets `invalid_arguments` or `internal_error`, and the server keeps serving. +- A killed runtime left its `cortex.mcp..token` behind. When the PID was reused, that old token made clients try a pipe that did not exist. `cortex.exe mcp` now removes, at startup, tokens of processes that are gone or that were written before the process with that PID started; `cortex inject` removes such a token for its target; and a token older than its process is never used. +- Live providers: a debug interface that sent a field of an unexpected type (for example a DAP message with `"type": 5`), or a string that `Clip` cut inside a UTF-8 character, could end `cortex.exe mcp` (the exception left a reader thread, or a record later failed to serialize). Messages are read through typed accessors that never throw (`core/live/json_safe.h`), text is cleaned to valid UTF-8 and cut on a character boundary, and an exception in a session's thread ends that session (`protocol_error`) instead of the process. The framing of WebSocket, DAP and JDWP is in `core/live/protocol_frames` and fuzzed. +- An evidence or investigation file edited or damaged on disk (a step without `status`, a record without `subject`) reached code that indexed its fields. Records are now checked against their structure when read, and a damaged one is skipped as unreadable. Found by fuzzing. +- Scan sessions were never evicted from the target's memory: every scan stayed (an unknown-value scan holds up to 3 million candidates with values and types), so repeated scans grew the target and slowed each next scan. At most the four most recent are kept; an evicted scan answers like a deleted one. +- The runtime could crash the target when one of its threads exited: the per-thread caller identity was a `thread_local` with a destructor in a MinGW DLL, which can run after its storage was freed (`~CallerInfo` reading freed memory, found by `robustness_e2e.py` and Cortex's own crash report). The slot no longer has a destructor. +- A crafted .NET assembly whose `TypeSpec` signature referred to itself made the metadata reader recurse until the stack overflowed. TypeSpec nesting and the work per signature are now bounded. Found by fuzzing. +- **A patch revert could corrupt the target.** `patch::Revert` restored the original bytes without checking that the patched bytes were still in place, that no newer patch covered them, or that the write succeeded. `patch::RevertChecked` refuses with `patch_target_changed`, `patch_overlaps_newer` or `patch_write_failed`, and leaves the target and the registry untouched. `DELETE /patch/{id}` and `batch_run`'s `patch_revert` return these codes; there is no force option. +- **A reused PID kept the previous process's state in the desktop.** The Memory, Project, RE and Trace workspaces compared target ids only, and a reused PID has the same id. They now compare id + generation. A value scan finishing after the target changed is discarded instead of shown for the new process; a pointer scan from a previous process is labelled so it gets rescanned. + +### Security + +- **PID reuse.** `status` reports the runtime's process `generation`. The desktop and `cortex.exe mcp` now refuse a runtime with the right PID but another generation (`payload_target_generation_mismatch`). Context routes refuse a stale `expected_generation` with `stale_target_generation`. + +### Fixed + +- **Session manager races.** The desktop frame loop, its background workers and the debugger backend share one `SessionManager`, which had no lock. Under ThreadSanitizer, the previous code showed data races and heap-use-after-free when sessions were attached or detached while another thread read them. All members are now synchronized. `ActiveTargetId()` returns a copy, and `ActiveTarget()` / `ActiveCapabilities()` return `std::optional` snapshots instead of pointers into a session a detach could free. +- **Settings could be truncated.** `cortex-ui-settings.json` and the runtime `cortex.ini` were rewritten in place; a crash or full disk mid-write left a truncated file. Both are now written to a temporary file and replaced atomically (`services::WriteFileAtomically`). +- **A damaged project file could be lost.** When `.json` did not parse, the runtime fell back to `.bak` or to defaults, and the next saves rotated the damaged bytes away. With a good `.bak`, the first save destroyed that backup. Damaged files are now copied to `.damaged-` first. The damaged primary is no longer rotated over a good backup. If an unreadable file cannot be copied (locked, access denied), saves fail instead of replacing it. `project::LoadStatus()` (logged at start-up) reports where the project was loaded from. + +### Tests and CI + +- `cortex_session_manager_tests` checks that snapshots outlive a detach, and that reads stay consistent while another thread attaches, detaches and prunes. The Ubuntu contracts job also runs it under ThreadSanitizer. +- New `cortex_settings_store_tests` (atomic replace, round trip, `.ini` key preservation) and `cortex_project_store_{backup,damaged,fresh,locked}` (damaged project recovery). +- New `tests/context_isolation_e2e.py`: two injected targets on one `cortex.exe mcp`. It checks identity, no cross-talk, stale generations, detach/reattach, a target killed and agent action events. It runs in the GUI suite workflow for x64 and x86. +- New `cortex_investigation_context_tests` (portable, also under ThreadSanitizer in CI), `cortex_events_tests`, and investigation-context checks in `cortex_app_models_tests` and `tests/native_mcp_stdio_e2e.ps1`. +- New `cortex_baseline_bench` (scanner, pointer scanner, reads, Zydis) with a `--quick` CTest smoke. The GUI release job uploads a Windows run as the `cortex-baseline-windows-x64` artifact. + ## [v1.0.1] - 2026-10-16 Security release. It also publishes the agent test layer that was merged into `main` after v1.0.0 (launch profiles, bounded input trials, a persistent knowledge graph; pull requests #17 to #39). diff --git a/CMakeLists.txt b/CMakeLists.txt index 121a6f9f..e4815f4a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -173,6 +173,7 @@ set(CORTEX_API_ROUTE_SOURCES core/api/routes_batch.cpp core/api/routes_actions.cpp core/api/routes_events.cpp + core/api/routes_context.cpp core/api/routes_pointermap.cpp core/api/routes_trace.cpp core/api/routes_ghidra.cpp @@ -226,6 +227,8 @@ add_library(core SHARED core/dissect/dissect.cpp core/action/action.cpp core/events/events.cpp + core/context/investigation_context.cpp + core/context/context_api.cpp core/api/native_routes.cpp core/api/mcp_tools.cpp core/api/mcp_pipe.cpp @@ -311,6 +314,35 @@ add_executable(cortex_host injector/main.cpp mcp_bridge/main.cpp mcp_bridge/host_mode.cpp + core/artifacts/artifact.cpp + core/artifacts/pe_provider.cpp + core/artifacts/asar_provider.cpp + core/artifacts/registry_default.cpp + core/artifacts/js_lexer.cpp + core/artifacts/js_analysis.cpp + core/artifacts/js_provider.cpp + core/artifacts/source_map.cpp + core/artifacts/inflate.cpp + core/artifacts/zip_provider.cpp + core/artifacts/dotnet_metadata.cpp + core/artifacts/cil.cpp + core/artifacts/dotnet_provider.cpp + core/artifacts/native_code.cpp + core/artifacts/annotations.cpp + core/evidence/evidence.cpp + core/investigation/investigation.cpp + core/target/process_tree.cpp + core/providers/process_runner.cpp + core/providers/analysis_provider.cpp + core/providers/ghidra_provider.cpp + core/providers/ilspy_provider.cpp + core/providers/vineflower_provider.cpp + core/live/websocket.cpp + core/live/protocol_frames.cpp + core/live/cdp.cpp + core/live/dap.cpp + core/live/jdwp.cpp + core/live/live_service.cpp tools/diagnostics_host/main.cpp tools/symbolize_main.cpp host/diagnostics/external_host.cpp @@ -498,11 +530,305 @@ if(BUILD_TESTING) ) target_include_directories(cortex_session_manager_tests PRIVATE core) add_test(NAME cortex_session_manager_tests COMMAND cortex_session_manager_tests) + + # Project file recovery: one process per scenario because project::Init + # runs once. + add_executable(cortex_project_store_tests + tests/project_store_tests.cpp + core/project/project.cpp + core/memory/memory.cpp + core/memory/provenance.cpp + core/process/modules.cpp + core/events/events.cpp + ) + target_include_directories(cortex_project_store_tests PRIVATE core) + target_compile_definitions(cortex_project_store_tests PRIVATE + _WIN32_WINNT=0x0601 + WIN32_LEAN_AND_MEAN + NOMINMAX + ) + target_link_libraries(cortex_project_store_tests PRIVATE nlohmann_json::nlohmann_json) + foreach(scenario backup damaged fresh locked) + add_test(NAME cortex_project_store_${scenario} COMMAND cortex_project_store_tests ${scenario}) + endforeach() + + # Shared investigation context and its route semantics (portable). + add_executable(cortex_investigation_context_tests + tests/investigation_context_tests.cpp + core/context/investigation_context.cpp + core/context/context_api.cpp + ) + target_include_directories(cortex_investigation_context_tests PRIVATE core) + target_link_libraries(cortex_investigation_context_tests PRIVATE nlohmann_json::nlohmann_json) + add_test(NAME cortex_investigation_context_tests COMMAND cortex_investigation_context_tests) + + # Artifact providers: SHA-256, offline PE (on the test target), ASAR, + # malformed and fuzzed inputs; and the MCP artifact tools' root limits. + add_executable(cortex_artifacts_tests + tests/artifacts_tests.cpp + core/artifacts/artifact.cpp + core/artifacts/pe_provider.cpp + core/artifacts/asar_provider.cpp + core/artifacts/registry_default.cpp + core/artifacts/js_lexer.cpp + core/artifacts/js_analysis.cpp + core/artifacts/js_provider.cpp + core/artifacts/source_map.cpp + core/artifacts/inflate.cpp + core/artifacts/zip_provider.cpp + core/artifacts/dotnet_metadata.cpp + core/artifacts/cil.cpp + core/artifacts/dotnet_provider.cpp + ) + target_include_directories(cortex_artifacts_tests PRIVATE core) + target_link_libraries(cortex_artifacts_tests PRIVATE nlohmann_json::nlohmann_json) + add_test(NAME cortex_artifacts_tests COMMAND cortex_artifacts_tests $) + + add_executable(cortex_js_tests + tests/js_tests.cpp + core/artifacts/artifact.cpp + core/artifacts/pe_provider.cpp + core/artifacts/asar_provider.cpp + core/artifacts/registry_default.cpp + core/artifacts/js_lexer.cpp + core/artifacts/js_analysis.cpp + core/artifacts/js_provider.cpp + core/artifacts/source_map.cpp + core/artifacts/inflate.cpp + core/artifacts/zip_provider.cpp + core/artifacts/dotnet_metadata.cpp + core/artifacts/cil.cpp + core/artifacts/dotnet_provider.cpp + ) + target_include_directories(cortex_js_tests PRIVATE core) + target_link_libraries(cortex_js_tests PRIVATE nlohmann_json::nlohmann_json) + add_test(NAME cortex_js_tests COMMAND cortex_js_tests ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures) + + add_executable(cortex_dotnet_tests + tests/dotnet_tests.cpp + core/artifacts/artifact.cpp + core/artifacts/pe_provider.cpp + core/artifacts/asar_provider.cpp + core/artifacts/registry_default.cpp + core/artifacts/js_lexer.cpp + core/artifacts/js_analysis.cpp + core/artifacts/js_provider.cpp + core/artifacts/source_map.cpp + core/artifacts/inflate.cpp + core/artifacts/zip_provider.cpp + core/artifacts/dotnet_metadata.cpp + core/artifacts/cil.cpp + core/artifacts/dotnet_provider.cpp + ) + target_include_directories(cortex_dotnet_tests PRIVATE core) + target_link_libraries(cortex_dotnet_tests PRIVATE nlohmann_json::nlohmann_json) + add_test(NAME cortex_dotnet_tests COMMAND cortex_dotnet_tests ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/dotnet) + + add_executable(cortex_native_code_tests + tests/native_code_tests.cpp + core/artifacts/artifact.cpp + core/artifacts/pe_provider.cpp + core/artifacts/asar_provider.cpp + core/artifacts/registry_default.cpp + core/artifacts/js_lexer.cpp + core/artifacts/js_analysis.cpp + core/artifacts/js_provider.cpp + core/artifacts/source_map.cpp + core/artifacts/inflate.cpp + core/artifacts/zip_provider.cpp + core/artifacts/dotnet_metadata.cpp + core/artifacts/cil.cpp + core/artifacts/dotnet_provider.cpp + core/artifacts/native_code.cpp + core/artifacts/annotations.cpp + ) + target_include_directories(cortex_native_code_tests PRIVATE core) + target_link_libraries(cortex_native_code_tests PRIVATE Zydis nlohmann_json::nlohmann_json) + add_test(NAME cortex_native_code_tests COMMAND cortex_native_code_tests $) + + # Two builds of one program for evidence comparison across versions. + add_executable(cortex_shift_fixture_a tests/fixtures/shift_fixture.c) + add_executable(cortex_shift_fixture_b tests/fixtures/shift_fixture.c) + target_compile_definitions(cortex_shift_fixture_b PRIVATE CORTEX_SHIFT) + foreach(shift cortex_shift_fixture_a cortex_shift_fixture_b) + target_compile_options(${shift} PRIVATE -O2) + target_link_options(${shift} PRIVATE -static -static-libgcc) + endforeach() + + add_executable(cortex_evidence_tests + tests/evidence_tests.cpp + core/artifacts/artifact.cpp + core/artifacts/pe_provider.cpp + core/artifacts/asar_provider.cpp + core/artifacts/registry_default.cpp + core/artifacts/js_lexer.cpp + core/artifacts/js_analysis.cpp + core/artifacts/js_provider.cpp + core/artifacts/source_map.cpp + core/artifacts/inflate.cpp + core/artifacts/zip_provider.cpp + core/artifacts/dotnet_metadata.cpp + core/artifacts/cil.cpp + core/artifacts/dotnet_provider.cpp + core/artifacts/native_code.cpp + core/artifacts/annotations.cpp + core/evidence/evidence.cpp + ) + target_include_directories(cortex_evidence_tests PRIVATE core) + target_link_libraries(cortex_evidence_tests PRIVATE Zydis nlohmann_json::nlohmann_json) + add_test(NAME cortex_evidence_tests COMMAND cortex_evidence_tests $ $) + + add_executable(cortex_investigation_tests + tests/investigation_tests.cpp + core/artifacts/artifact.cpp + core/artifacts/pe_provider.cpp + core/artifacts/asar_provider.cpp + core/artifacts/registry_default.cpp + core/artifacts/js_lexer.cpp + core/artifacts/js_analysis.cpp + core/artifacts/js_provider.cpp + core/artifacts/source_map.cpp + core/artifacts/inflate.cpp + core/artifacts/zip_provider.cpp + core/artifacts/dotnet_metadata.cpp + core/artifacts/cil.cpp + core/artifacts/dotnet_provider.cpp + core/artifacts/native_code.cpp + core/artifacts/annotations.cpp + core/evidence/evidence.cpp + core/investigation/investigation.cpp + ) + target_include_directories(cortex_investigation_tests PRIVATE core) + target_link_libraries(cortex_investigation_tests PRIVATE Zydis nlohmann_json::nlohmann_json) + add_test(NAME cortex_investigation_tests COMMAND cortex_investigation_tests $ ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/electron-app.asar $) + + add_executable(cortex_analysis_provider_tests + tests/analysis_provider_tests.cpp + core/providers/process_runner.cpp + core/providers/analysis_provider.cpp + core/providers/ghidra_provider.cpp + core/providers/ilspy_provider.cpp + core/providers/vineflower_provider.cpp + core/artifacts/artifact.cpp + core/artifacts/pe_provider.cpp + core/artifacts/asar_provider.cpp + core/artifacts/registry_default.cpp + core/artifacts/js_lexer.cpp + core/artifacts/js_analysis.cpp + core/artifacts/js_provider.cpp + core/artifacts/source_map.cpp + core/artifacts/inflate.cpp + core/artifacts/zip_provider.cpp + core/artifacts/dotnet_metadata.cpp + core/artifacts/cil.cpp + core/artifacts/dotnet_provider.cpp + core/artifacts/native_code.cpp + core/artifacts/annotations.cpp) + target_include_directories(cortex_analysis_provider_tests PRIVATE core) + target_link_libraries(cortex_analysis_provider_tests PRIVATE Zydis nlohmann_json::nlohmann_json) + # Real engines run when GHIDRA_INSTALL_DIR / CORTEX_ILSPYCMD are set (CI + # sets them in the analysis-engines job); otherwise those parts skip. + add_test(NAME cortex_analysis_provider_tests COMMAND cortex_analysis_provider_tests + $ ${CMAKE_SOURCE_DIR}/tests/fixtures/dotnet/Cortex.Fixture.dll + ${CMAKE_SOURCE_DIR}/tests/fixtures/java/Cortex.Fixture.jar ${CMAKE_SOURCE_DIR}/tests/fixtures/elf/actor.elf) + set_tests_properties(cortex_analysis_provider_tests PROPERTIES TIMEOUT 3600) + + add_executable(cortex_cdp_tests tests/cdp_tests.cpp core/live/websocket.cpp core/live/protocol_frames.cpp core/live/cdp.cpp core/live/dap.cpp core/live/jdwp.cpp core/live/live_service.cpp core/providers/process_runner.cpp) + target_include_directories(cortex_cdp_tests PRIVATE core) + target_link_libraries(cortex_cdp_tests PRIVATE nlohmann_json::nlohmann_json) + if(WIN32) + target_link_libraries(cortex_cdp_tests PRIVATE ws2_32) + endif() + # Node runs the fixture when it is on PATH (CI); otherwise that part skips. + add_test(NAME cortex_cdp_tests COMMAND cortex_cdp_tests ${CMAKE_SOURCE_DIR}/tests/fixtures/node-live/app.js) + set_tests_properties(cortex_cdp_tests PROPERTIES TIMEOUT 300) + + add_executable(cortex_dap_tests tests/dap_tests.cpp core/live/websocket.cpp core/live/protocol_frames.cpp core/live/cdp.cpp core/live/dap.cpp core/live/jdwp.cpp core/live/live_service.cpp core/providers/process_runner.cpp) + target_include_directories(cortex_dap_tests PRIVATE core) + target_link_libraries(cortex_dap_tests PRIVATE nlohmann_json::nlohmann_json) + if(WIN32) + target_link_libraries(cortex_dap_tests PRIVATE ws2_32) + endif() + # Python with debugpy runs the fixture when present (CI's analysis-engines + # job also passes the .NET fixture and netcoredbg); otherwise it skips. + add_test(NAME cortex_dap_tests COMMAND cortex_dap_tests ${CMAKE_SOURCE_DIR}/tests/fixtures/python-live/app.py) + set_tests_properties(cortex_dap_tests PROPERTIES TIMEOUT 300) + + # Contract tests that contracts.yml used to compile by hand. They are + # self-contained (headers only, or a few core sources) and now run with + # every `ctest`. + foreach(contract request_id pagination mcp_protocol mcp_pipe_protocol mcp_schema + mcp_query_schema mcp_bridge_policy) + add_executable(cortex_${contract}_tests tests/${contract}_tests.cpp) + target_include_directories(cortex_${contract}_tests PRIVATE core) + target_link_libraries(cortex_${contract}_tests PRIVATE nlohmann_json::nlohmann_json) + add_test(NAME cortex_${contract}_tests COMMAND cortex_${contract}_tests) + endforeach() + + add_executable(cortex_mcp_authority_tests tests/mcp_authority_tests.cpp) + target_include_directories(cortex_mcp_authority_tests PRIVATE core) + target_link_libraries(cortex_mcp_authority_tests PRIVATE nlohmann_json::nlohmann_json) + add_test(NAME cortex_mcp_authority_tests + COMMAND cortex_mcp_authority_tests ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/mcp_tool_risks.json) + + add_executable(cortex_semantic_tools_tests tests/semantic_tools_tests.cpp) + target_include_directories(cortex_semantic_tools_tests PRIVATE core) + target_link_libraries(cortex_semantic_tools_tests PRIVATE nlohmann_json::nlohmann_json) + add_test(NAME cortex_semantic_tools_tests + COMMAND cortex_semantic_tools_tests ${CMAKE_CURRENT_SOURCE_DIR}/core/api/routes_status.cpp) + + add_executable(cortex_action_transaction_tests + tests/action_transaction_tests.cpp core/action/action.cpp core/events/events.cpp) + target_include_directories(cortex_action_transaction_tests PRIVATE core) + target_link_libraries(cortex_action_transaction_tests PRIVATE nlohmann_json::nlohmann_json) + add_test(NAME cortex_action_transaction_tests COMMAND cortex_action_transaction_tests) + + if(WIN32) + add_executable(cortex_call_scheduler_tests tests/call_scheduler_tests.cpp core/call/call.cpp) + target_include_directories(cortex_call_scheduler_tests PRIVATE core) + target_link_libraries(cortex_call_scheduler_tests PRIVATE nlohmann_json::nlohmann_json user32) + if(MINGW) + target_link_options(cortex_call_scheduler_tests PRIVATE -static -static-libgcc -static-libstdc++) + endif() + add_test(NAME cortex_call_scheduler_tests COMMAND cortex_call_scheduler_tests) + endif() + + add_executable(cortex_process_tree_tests tests/process_tree_tests.cpp core/target/process_tree.cpp) + target_link_libraries(cortex_process_tree_tests PRIVATE nlohmann_json::nlohmann_json) + add_test(NAME cortex_process_tree_tests COMMAND cortex_process_tree_tests) + + add_executable(cortex_artifact_tools_tests + tests/artifact_tools_tests.cpp + core/artifacts/artifact.cpp + core/artifacts/pe_provider.cpp + core/artifacts/asar_provider.cpp + core/artifacts/registry_default.cpp + core/artifacts/js_lexer.cpp + core/artifacts/js_analysis.cpp + core/artifacts/js_provider.cpp + core/artifacts/source_map.cpp + core/artifacts/inflate.cpp + core/artifacts/zip_provider.cpp + core/artifacts/dotnet_metadata.cpp + core/artifacts/cil.cpp + core/artifacts/dotnet_provider.cpp + core/artifacts/native_code.cpp + core/artifacts/annotations.cpp + ) + target_include_directories(cortex_artifact_tools_tests PRIVATE core) + target_link_libraries(cortex_artifact_tools_tests PRIVATE Zydis nlohmann_json::nlohmann_json) + add_test(NAME cortex_artifact_tools_tests COMMAND cortex_artifact_tools_tests $) + + # Runtime event ring: bounded, contiguous ids, explicit gaps. + add_executable(cortex_events_tests tests/events_tests.cpp core/events/events.cpp) + target_include_directories(cortex_events_tests PRIVATE core) + add_test(NAME cortex_events_tests COMMAND cortex_events_tests) endif() # Standalone test executables must not depend on compiler DLLs being on PATH. if(BUILD_TESTING AND MINGW) - foreach(test_target cortex_knowledge_graph_tests cortex_test_code_candidates_tests cortex_test_generate_tests cortex_test_evidence_graph_tests cortex_config_defaults_tests cortex_session_manager_tests cortex_test_plan_tests cortex_test_knowledge_tests cortex_test_code_context_tests cortex_test_code_knowledge_tests cortex_pe_unwind_tests cortex_launch_config_tests cortex_knowledge_query_tests cortex_knowledge_model_tests) + foreach(test_target cortex_knowledge_graph_tests cortex_test_code_candidates_tests cortex_test_generate_tests cortex_test_evidence_graph_tests cortex_config_defaults_tests cortex_session_manager_tests cortex_project_store_tests cortex_investigation_context_tests cortex_events_tests cortex_artifacts_tests cortex_artifact_tools_tests cortex_js_tests cortex_dotnet_tests cortex_native_code_tests cortex_evidence_tests cortex_investigation_tests cortex_process_tree_tests cortex_analysis_provider_tests cortex_cdp_tests cortex_dap_tests cortex_test_plan_tests cortex_test_knowledge_tests cortex_test_code_context_tests cortex_test_code_knowledge_tests cortex_pe_unwind_tests cortex_launch_config_tests cortex_knowledge_query_tests cortex_knowledge_model_tests + cortex_request_id_tests cortex_pagination_tests cortex_mcp_protocol_tests cortex_mcp_pipe_protocol_tests cortex_mcp_schema_tests cortex_mcp_query_schema_tests cortex_mcp_bridge_policy_tests cortex_mcp_authority_tests cortex_semantic_tools_tests cortex_action_transaction_tests) target_link_options(${test_target} PRIVATE -static -static-libgcc -static-libstdc++) endforeach() endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..c51f51ff --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,98 @@ +# Contributing to Cortex + +Thanks for helping. Cortex attaches to processes and changes them, so the bar +for safety and evidence is higher than for most tools. Please read this page +before opening a pull request. + +## Ground rules + +- **Authorized targets only.** Develop and test against software you own, + the repository's fixtures (`test_target/`, `tests/fixtures/`) or offline + test builds. Do not add features whose purpose is to bypass anti-cheat, + DRM, licence checks or access controls. Such pull requests are closed. +- **Reading stays read-only.** A feature that only observes must work without + write authority. Anything that changes a target (memory, breakpoints, + input, loading the runtime, starting or stopping a process) must require + the operator's authority (`Writes allowed`, `--allow-writes`) *and*, through + MCP, `mutation_permission: true` in the call. +- **No claim without a test.** A feature counts as working only after it has + been checked against a real case or a representative fixture. Say which one + in the pull request, and record anything that is not validated as a + limitation in the docs. +- **Never weaken a test to get green.** Do not skip, disable, loosen or + quarantine a failing test. Fix the cause, or explain in the pull request why + the test is wrong. +- **Reuse what exists.** The scanner, debugger, structure view, input UI, + MCP layer, evidence store and providers already have primitives; extend + them rather than adding a parallel path. + +## Build + +Windows x64 with MSYS2 MinGW-w64, CMake 3.21+ and Ninja (the CI setup): + +```powershell +# Desktop application and its tests +cmake -S app_imgui -B build/gui -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON +cmake --build build/gui --parallel + +# Runtime and test target, x64 (use a MINGW32 shell for x86) +cmake -S . -B build/runtime-x64 -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON +cmake --build build/runtime-x64 --parallel +``` + +Cross-compiling from Linux with `x86_64-w64-mingw32` and +`i686-w64-mingw32` works for development, and most suites run under Wine. +CI on real Windows is still the reference. + +## Test before you open a pull request + +At minimum: + +```powershell +ctest --test-dir build/gui --output-on-failure +ctest --test-dir build/runtime-x64 --output-on-failure +``` + +Then the end-to-end suites that cover what you touched, from a staged bundle +(see `.github/workflows/imgui-gui-tests.yml` for the exact arguments): + +| You changed | Run | +|---|---| +| Anything reachable from MCP | `tests/security_no_writes_regression.py` (no call may change a target without `--allow-writes`) | +| Investigation context, targets | `tests/context_isolation_e2e.py` | +| Scanner, watches, breakpoints, navigation | `tests/manual_demo_e2e.py` | +| Static parsers (PE, ASAR, JS, ZIP) | `tests/artifact_mcp_e2e.py`, `tests/native_static_e2e.py`, and the fuzzers in `tests/fuzz/` (Linux, clang) | +| .NET metadata or CIL | `tests/dotnet_e2e.py` | +| Evidence, investigations | `tests/evidence_e2e.py`, `tests/investigation_e2e.py` | +| External engines | `cortex_analysis_provider_tests`, `tests/analysis_e2e.py` | +| Live providers | `cortex_cdp_tests`, `cortex_dap_tests`, `tests/live_e2e.py` | + +CI runs the same suites on Windows for x64 and x86 targets. Do not merge on a +red or pending run. + +## Pull requests + +- Work on a feature branch. Keep unrelated changes in separate commits or + pull requests. +- Describe what changed, how you tested it (commands, fixture, platform), and + what is still not validated. +- Update the docs that describe the behaviour: `docs/`, `agent/`, and the + `[Unreleased]` section of `CHANGELOG.md`. +- New parsers handle untrusted input: check every bound, cap sizes and counts, + add a fixture with malformed cases, and reach the parser from a harness in + `tests/fuzz/`. +- New MCP tools declare their risk (`tests/fixtures/mcp_tool_risks.json`), keep + a bounded output, and refuse changes without authority. + +## Reporting bugs + +Use the issue templates. Include the Cortex version or commit, Windows version, +target architecture, the exact steps, and logs from beside `cortex.exe` +(`cortex_denied_mutations.jsonl`, `cortex_debugger_events.jsonl`) where +relevant. Remove anything private first. Report security problems as described +in [SECURITY.md](SECURITY.md), not in a public issue. + +## Licence + +By contributing you agree that your contribution is licensed under the +project's [MIT licence](LICENSE). diff --git a/README.md b/README.md index e6824cc7..11074d6a 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,176 @@
-Cortex + + + + Cortex: find what changed it, show the evidence + -# Cortex +**See a value change in a program you own. Find the instruction that changed it. Show the evidence.** -**Runtime inspection, debugging and reverse engineering for Windows software, in one native app — with a first-class MCP server for AI agents.** +Memory scanner, debugger and reverse-engineering workspace for Windows software in one native app. +It reads files as well as live processes, can use Ghidra, ILSpy and live debuggers, and has an MCP server so an AI agent can do the same work under your control. +[![CI](https://github.com/PdB333/cortex/actions/workflows/imgui-gui-tests.yml/badge.svg)](https://github.com/PdB333/cortex/actions/workflows/imgui-gui-tests.yml) [![Release](https://img.shields.io/github/v/release/PdB333/cortex?style=flat-square&color=0f766e)](https://github.com/PdB333/cortex/releases) ![Platform](https://img.shields.io/badge/platform-Windows%20x64%20%C2%B7%20targets%20x86%2Fx64-334155?style=flat-square) ![C++17](https://img.shields.io/badge/C%2B%2B-17-00599C?style=flat-square) -![UI](https://img.shields.io/badge/UI-Dear%20ImGui%20%C2%B7%20D3D11-5C3EE8?style=flat-square) ![MCP](https://img.shields.io/badge/MCP-native%20stdio-2EA44F?style=flat-square) [![License](https://img.shields.io/badge/license-MIT-64748b?style=flat-square)](LICENSE) -[Quick start](#quick-start) · [Features](#features) · [AI & MCP](#ai--mcp) · [Build](#build-from-source) · [Documentation](#documentation) +[Two ways to use it](#two-ways-to-use-cortex) · [Quick start](#quick-start) · [See it work](#see-it-work) · [What you can analyze](#what-you-can-analyze) · [AI & MCP](#ai--mcp) · [Safety](#safety-model) · [FAQ](#faq) · [Docs](#documentation)
-Cortex attached to a target: modules, disassembler following the instruction pointer, debugger with threads, registers and stack +Cortex attaches to the test target, scans for 100, narrows to one address after a hit, finds the instruction that writes it and opens it in the disassembler + +Recorded from the real desktop against the repository's test target: attach, scan, narrow down, find out what writes, disassembler. No speed-up and no edits besides the captions.

> [!WARNING] -> Use Cortex only on software and systems you own or are authorized to inspect. It is built for debugging, software research, accessibility, testing, diagnostics and controlled modding. Anti-cheat bypass, unauthorized access and interference with online services are out of scope. +> Use Cortex only on software and systems you own or are authorized to inspect. It is for debugging, research, interoperability, accessibility, testing and controlled modding. Anti-cheat, DRM and access-control bypass, and interference with online services, are out of scope. ## Why Cortex -- **One app, not a toolbox.** Scanner, memory viewer, disassembler, debugger, patcher, tracer, RE workspace, scripting and diagnostics share one target, one address menu and one project. -- **Safe by default.** Every session starts **Read-only**. In the desktop, writes, freezes, patches, breakpoints and even loading the runtime into the target require the **Writes allowed** switch. An AI client starts read-only too and cannot lift that itself: only the person who launches `cortex.exe mcp --allow-writes` can, and a call that changes the target must also say so (`mutation_permission`). Every refused attempt is logged to `cortex_denied_mutations.jsonl`. -- **Built for agents too.** `cortex.exe mcp` is a native MCP server: attach processes on the fly, drive several targets at once, and let the person at the screen watch every tool call live. -- **Light and portable.** A native Dear ImGui + Direct3D 11 desktop, no Qt or runtime installer. Unzip, run, and keep your settings per user — or beside the executable with a `cortex.portable` file. +- **One tool instead of five.** Scanner, memory viewer, disassembler, debugger, patcher, tracer, RE workspace, scripting and diagnostics all work on the same target and project, with one shared address menu. +- **Read-only until you decide otherwise.** Every session starts **Read-only**. Writes, freezes, patches, breakpoints, and even loading the runtime into the target, need **Writes allowed** in the desktop or `--allow-writes` on the MCP server. An agent cannot grant itself that. +- **Files as well as processes.** Inspect a PE, a .NET assembly, an Electron `.asar` or a JavaScript bundle without running it. Optionally hand it to Ghidra, ILSpy or Vineflower and map the results back to the live process by module and RVA. +- **Built for agents, watched by people.** `cortex.exe mcp` is a native MCP server. Each tool call an agent makes shows up live in the desktop's **AI Activity** panel. +- **Claims come with evidence.** Results record where they came from, their status (`observed`, `validated`…) and their limits. An observation is never presented as an explanation of the code. + +## Two ways to use Cortex + +One `cortex.exe` and one engine, with two ways to drive it. Both share the same targets, projects and evidence, so you can switch between them in the middle of an investigation. + + + + + + + + + + +
🧑‍💻 You drive it: desktop and command line🤖 An AI agent drives it: MCP, under your control
+ +**Desktop (GUI):** run `cortex.exe`. Attach to a process, scan, debug, disassemble and inspect files in dockable workspaces. Everything is clicks and right-click menus. + +**Command line (CLI):** the same engine from a terminal or a script, with nothing to click: +- `cortex.exe inspect app.asar` to analyze a file; +- `cortex.exe provider run ghidra …` to decompile; +- `cortex.exe live watch …` for a live debugger; +- `cortex.exe evidence export` to export the evidence. + +Use it for automation, CI or quick checks. + +**Who decides:** you. A session starts **Read-only**; switch to **Writes allowed** only when you mean to change the target. + + + +**MCP server:** your AI client (Claude Code, Cursor, any MCP client) starts `cortex.exe mcp` in the background and calls Cortex's tools: scan, read memory, breakpoints, inspect, Ghidra and ILSpy, live debuggers. + +**You stay in control:** +- **You** choose what the agent may do, in the client's configuration: read-only by default, `--allow-writes` only if you add it. The agent cannot change this. +- Every change must also be requested explicitly (`mutation_permission: true`). +- Open the desktop to **watch** every call live in **AI Activity**. +- Right-click an address and choose *Share with investigation context* to **give** the agent the address you selected. +- **Approve or reject** each step of an investigation (desktop, or `cortex.exe investigation approve`). + +
+ +A typical session mixes the two: the agent narrows down a value and finds the instruction that writes it, then you open that address in the desktop and check it yourself. ## Quick start -1. Download **`cortex-v1.0.1-windows-portable.zip`** from the [latest release](https://github.com/PdB333/cortex/releases) and extract the whole archive. -2. Run **`cortex.exe`**, click **Select process** and attach to your target. -3. Find a value in **Memory › Value scan** (*First scan*, change it in the target, *Next scan*). -4. Double-click a result to keep it in **Addresses**, then right-click it to open it in the Memory viewer, Disassembler, Debugger or RE. -5. Switch the header to **Writes allowed** only when you mean to change something. +Download **`cortex-v-windows-portable.zip`** from the [latest release](https://github.com/PdB333/cortex/releases) and extract the whole archive (or use the `cortex-imgui-gui-tested-portable` artifact of a green CI run for the current branch). + +**By hand (desktop):** + +1. Run **`cortex.exe`**, click **Select process** and attach to your target. +2. In **Memory › Value scan**, enter the current value and run **First scan**. Change the value in the target, then run **Next scan**. +3. Double-click a result to keep it. Right-click it to open it in the Memory viewer, Disassembler or Debugger, or to *find out what writes to this address*. +4. Switch the header to **Writes allowed** only when you mean to change something. + +**From the command line:** + +```powershell +cortex.exe inspect C:\path\to\app.asar # analyze a file; nothing runs +cortex.exe probe --pid 1234 # read-only health of a running target +``` + +**With an AI agent (MCP):** add Cortex to your client's MCP configuration, then ask the agent for something like *"attach to PID 1234 (my own test program), find the address of the health value and what writes it"*: + +```json +{ + "mcpServers": { + "cortex": { "command": "C:/Cortex/cortex.exe", "args": ["mcp"] } + } +} +``` + +This configuration is read-only. Add `"--allow-writes"` to `args` only if you want the agent to be able to change the target (breakpoints, writes, loading the runtime). See [AI & MCP](#ai--mcp). + +New to Cortex? Follow the [illustrated walkthrough](docs/ui-walkthrough.md) ([français](docs/ui-walkthrough-fr.md)) or the [manual workflow demo](docs/demo-manual.md). + +## See it work + + + + + + +
+ +**An agent finds what writes a value** + +A scripted MCP client scans, narrows to one address, sets a logging hardware write breakpoint and finds the instruction in CortexDemoApplyDamage, while the desktop lists every tool call in AI Activity + +Left: an MCP client. It is a short script standing in for an agent, and every call and reply in it is real. It scans for `100`, narrows to one address after a hit, sets a **logging** hardware write breakpoint (`mutation_permission: true`, server started with `--allow-writes`), and resolves the trap to `cortex_test_target_x64.exe + 0x1504`, just after `mov [rax+0x04], ecx`. Right: the desktop lists each tool call in **AI Activity** as it happens. + + + +**Static analysis, no process needed** + +cortex.exe inspect lists the IPC channels and the contextBridge exposure of an Electron app, the types of a .NET assembly and the CIL of a method -New to Cortex? Follow the [illustrated walkthrough](docs/ui-walkthrough.md) ([français](docs/ui-walkthrough-fr.md)). +`cortex.exe inspect` on the repository's fixtures: +- an Electron `.asar` gives its IPC channels, with sender and handler, and its `contextBridge` exposure, each with file and line; +- a .NET assembly gives its types and the CIL of `Actor::ApplyDamage`. + +Nothing is executed: the files are parsed as untrusted input. + +
+ +The GIFs were recorded on Linux under Wine with Xvfb against the repository's test target and fixtures. The same flows run on Windows in CI: tests/manual_demo_e2e.py, tests/artifact_mcp_e2e.py and tests/dotnet_e2e.py. + +## What you can analyze + +Each row lists how its validation is done. "Validated" means checked in CI against a named fixture, not "works on every program". + +| Target | What Cortex returns | Needs | Validation | +|---|---|---|---| +| **Running Windows processes** (x86, x64) | Value and pointer scans, memory, disassembly, CFG and xrefs, software and hardware breakpoints, *what writes / accesses*, traces, patches, snapshots, Lua | Nothing else | GUI and MCP suites on `cortex_test_target` (x64 and x86). v1.0 was also tested by hand on AssaultCube 1.3 | +| **PE files and native code** | Headers, sections, imports and exports, functions, xrefs, CFG, annotations | Nothing else | Compared with `objdump` (`tests/native_static_e2e.py`) | +| **.NET assemblies** | Metadata (all 45 tables), types, members, P/Invoke, CIL with exception clauses | Nothing else | Compared with `System.Reflection.Metadata` (`tests/dotnet_e2e.py`) | +| **JavaScript / TypeScript, Electron** | Modules and imports, bundles, source maps, `package.json`, preload, `webPreferences`, IPC channels | Nothing else | `tests/fixtures/electron-app.asar` and its expected output | +| **Decompiled native code** | Functions, calls, types and C by RVA, mapped to the live module when the SHA-256 matches | [Ghidra](https://github.com/NationalSecurityAgency/ghidra) 11.x and JDK 21 | PE x64 and x86, ELF x86-64 (`analysis-engines` CI job) | +| **Decompiled C#** | C# project or a single type | [ILSpy](https://github.com/icsharpcode/ILSpy) `ilspycmd` 9.x | The decompiled project is rebuilt and compared with the original | +| **Decompiled Java** | Java sources | [Vineflower](https://github.com/Vineflower/vineflower) 1.11 and a JDK | Sources recompile; `javap -p` signatures match | +| **Live Node.js** | Scripts, sources, log breakpoints with stack and locals, console | `node --inspect` on 127.0.0.1 | Real Node 22 (`tests/live_e2e.py`). Electron itself is not validated | +| **Live Python** | Log breakpoints with stack and locals | [debugpy](https://github.com/microsoft/debugpy) | Real debugpy 1.8 against `tests/fixtures/python-live` | +| **Live .NET** | Log breakpoints with stack and locals | [netcoredbg](https://github.com/Samsung/netcoredbg) 3.1, started by Cortex in stdio mode | Real netcoredbg 3.1.2 against `tests/fixtures/dotnet-live`, on Windows (stdio mode) and Linux | +| **Live Java** | Log breakpoints with stack and locals | A JVM started with JDWP | Real JVM against `tests/fixtures/java-live` | + +Live providers never evaluate code. At a log breakpoint they record the stack and locals and resume at once, and a pause Cortex did not set is resumed too. Python bytecode, Unity/IL2CPP and WebAssembly are planned; see the [roadmap](docs/cortex-2-roadmap.md). + +## How it fits together + +Desktop, MCP server and command line share one engine, which reads running processes through the in-target runtime, files through built-in parsers, and runs optional engines and debuggers as separate processes + +The desktop runs in its own process and draws nothing inside the target. The runtime (`cortex_core.dll`) is loaded only when a feature needs to run in-process: from the desktop, only with writes allowed. External engines and debug adapters run as separate processes started from paths the operator configures (`--ghidra-dir`, `--ilspycmd`, `--netcoredbg`…), never linked into Cortex. See [architecture](docs/unified-app-architecture.md) and [analysis providers](docs/analysis-providers.md). ## Features @@ -50,13 +179,24 @@ New to Cortex? Follow the [illustrated walkthrough](docs/ui-walkthrough.md) ([fr **Memory** -Cheat Engine level scanning — every type (byte through double, string, array of bytes with wildcards, all numeric), exact / comparative / unknown-initial scans and *by* deltas, region-type and protection filters, fast scan and threads. An address list with freezes, `module+offset` and pointer entries, full address expressions, groups, colours, dropdowns and per-entry global hotkeys, that opens and saves Cheat Engine `.CT` tables. A 64 KB hex viewer with change highlighting and a data inspector, pointer maps, a pointer scanner and a **pointer spider** (list or node graph), **structure dissect** across several instances, grouped scans, user-defined value types, memory dumps to file, structures with inference, snapshots with diff and rewind. +Scanning on par with Cheat Engine: +- every value type (byte through double, string, array of bytes with wildcards); +- exact, comparative and unknown-initial scans; +- region and protection filters. + +An address list with freezes, `module+offset` and pointer entries, groups and hotkeys, which opens and saves Cheat Engine `.CT` tables. Also: a hex viewer with a data inspector, a pointer scanner and pointer spider, structure dissect, structures with inference, and snapshots with diff and rewind. **Code** -Zydis-backed x86/x64 disassembly that can follow the instruction pointer, CFG, structured CFG and cross-references, symbols (PDB and DWARF), an x86/x64 **assembler** and Auto-Assembler style **code injection** (allocate a cave, relocate the replaced instructions, jump and return, restore), AOB signatures, and reversible patches: bytes, NOP, assembly, detours, trampolines and code caves. **Auto Assembler** scripts (`[ENABLE]` / `[DISABLE]`, `alloc`, `aobscan`, `registersymbol`, …) live in the address list and in `.CT` tables, and a **speedhack** scales the target's clock. +- Zydis-backed x86/x64 disassembly that can follow the instruction pointer. +- CFG, structured CFG and cross-references. +- PDB and DWARF symbols. +- An assembler and Auto Assembler style code injection: cave allocation, relocated instructions, jump and return, restore. +- AOB signatures. +- Reversible patches: bytes, NOP, assembly, detours, trampolines, caves. +- A speedhack for the target's clock. @@ -64,13 +204,21 @@ Zydis-backed x86/x64 disassembly that can follow the instruction pointer, CFG, s **Debugging** -Threads, registers and stack at a glance; software and hardware breakpoints with hit counters, thread coverage and hit logs; pause, continue, step into and step over; per-thread traces with register snapshots. External Windows debugger or in-process VEH backend. +- Threads, registers and stack. +- Software and hardware breakpoints with hit counters, thread coverage and hit logs. +- Pause, continue, step into and step over. +- Per-thread traces with register snapshots. +- Two backends: an external Windows debugger or an in-process VEH backend. -**Reverse engineering** -Tracked objects with liveness and field-change events, last-writer and C++ subobject detection, transition tracing, experiments with automatic rollback, persistent facts, checkpoints, run export and diff, Ghidra interop. +**Reverse engineering & evidence** +- Tracked objects, last-writer detection, transition tracing. +- Experiments with automatic rollback. +- Evidence records with provenance and status, and investigations with a plan, journal and report. +- An **AI Investigation** workspace with Approve/Reject for each step. +- Project knowledge with revisions. @@ -78,47 +226,38 @@ Tracked objects with liveness and field-change events, last-writer and C++ subob **Observe & automate** -A Cheat Engine compatible **Lua engine** that reads and writes the target from outside (AOB scans, symbols, module walks, byte tables) with no runtime injected; "what accesses / writes an address" and instruction watches through hardware breakpoints; page-access and allocation instrumentation, network events, screenshots, input sequences with record and replay, and a reversible action journal for everything that changed. +- A **Lua engine**, compatible with Cheat Engine's, that works from outside the process. +- *What accesses / writes* and instruction watches. +- Page-access and allocation instrumentation, network events and screenshots. +- Input sequences with record and replay. +- A reversible journal of every action. **Diagnostics** -Crash and hang capture from outside the process, minidumps, symbolized stacks, hook verification, breadcrumbs and an evidence-based analyzer — plus a C++ SDK mods can use to add context. +- Crash and hang capture from outside the process. +- Minidumps and symbolized stacks. +- Hook verification and breadcrumbs. +- An evidence-based analyzer. +- A C++ SDK that mods can use to add context. -## A workspace for every task - -Every panel is a dockable window. Six presets arrange them for the job at hand, and the layout is remembered. - -| Preset | What you get | -|---|---| -| **Memory** | Value scan, Addresses, Memory viewer, Memory tools, Modules, Watches | -| **Debug** | Disassembler, Debugger, What accesses, Patches, Memory viewer, Modules, Watches | -| **RE** | Reverse Engineering, Project, Symbols, Structures, Pointer Maps, Snapshots, Memory tools, Instrumentation | -| **Trace** | Trace, Disassembler, Debugger, Memory viewer, Watches | -| **Automation** | Lua engine, Scripts, Input, Actions, Events | -| **Runtime** | Advanced tools, Diagnostics, Network, Screenshots, Sessions, Settings | +Every panel is a dockable window. Six presets (**Memory**, **Debug**, **RE**, **Trace**, **Automation** and **Runtime**) arrange them for the task, and the layout is remembered. Keyboard: `Ctrl+G` go to · `Ctrl+Shift+P` command palette · `Alt+←/→` history · `Space` freeze · `F2` edit · `Ctrl+B` breakpoint. - - - - - - + +
Value scan results
Value scan — first and next scans with typed results
Addresses table with a live value
Addresses — the persistent working table, with live watches
Reverse engineering workspace
RE preset — tracked objects, project knowledge and runtime tools
Shared address context menu
One address menu — open, monitor, debug, analyze or copy from anywhere
Debugger with threads, registers and stack
Debug preset: disassembler following the IP, threads, registers, stack
Reverse engineering workspace
RE preset: tracked objects, project knowledge and runtime tools
-**Keyboard:** `Ctrl+G` go to an address, `module+offset` or symbol · `Ctrl+Shift+P` / `Ctrl+K` command palette · `Alt+←` / `Alt+→` navigation history · `Space` freeze · `F2` edit · `Ctrl+B` breakpoint. - ## AI & MCP -MCP is built into `cortex.exe`. Configure your client once; the server starts targetless and attaches processes when asked. +MCP is built into `cortex.exe`. Configure your client once; the server starts without a target and attaches to processes on request. ```json { @@ -128,56 +267,48 @@ MCP is built into `cortex.exe`. Configure your client once; the server starts ta } ``` -That server is **read-only**: it attaches only to a process whose runtime is already loaded (`cortex.exe inject `, or the desktop), and every call that would change the target is refused. To let the agent load the runtime and change the target (memory writes, freezes, patches, breakpoints, input, prompts), add `"--allow-writes"` to `args`. That is your decision, not the agent's, and the agent cannot set it: +That server is **read-only**: +- it attaches only to a process whose runtime is already loaded (`cortex.exe inject `, or the desktop); +- every call that would change the target is refused. -```json -{ - "mcpServers": { - "cortex": { "command": "C:/Cortex/cortex.exe", "args": ["mcp", "--allow-writes"] } - } -} -``` +To let the agent load the runtime and change the target, add `"--allow-writes"` to `args`. That is your decision; the agent cannot make it. `"--allow-state"` lets it save only its own records (notes, knowledge, journals). -An MCP client written for v1.0.0 that changed a target must now be started with `--allow-writes`. To let the agent only **save its own records** (notes, knowledge, facts, test journals) without letting it touch the target, use `"--allow-state"` instead; `--allow-writes` includes it. +- **Dynamic targets:** `cortex_processes`, `cortex_attach`, `cortex_targets` and `cortex_detach`, with `tools/list_changed` notifications. Several targets at once, each with its own runtime connection. +- **Compact by default:** semantic tools run bounded steps and return the raw evidence. `--tools all` exposes every primitive. +- **Two keys for every change:** `mutation_permission=true` in the call (the agent saying it means to) *and* write authority from outside the model. Supported changes run in rollback-aware transactions. +- **Engines and live debuggers:** the `cortex_analysis_*` and `cortex_live_*` tools, plus `cortex_analysis_map` to map an engine's RVA onto the running module. +- **Visible:** the desktop's **AI Activity** panel shows each agent session and tool call. -- **Dynamic targets** — `cortex_processes`, `cortex_attach`, `cortex_targets`, `cortex_detach`, with `tools/list_changed` notifications. -- **Many targets, no races** — each attached process has its own runtime connection; tools take a `_cortex_target` selector. -- **Compact by default** — 30 semantic tools that run the bounded steps the agent writes and return the raw evidence; `--tools all` exposes every primitive. Cortex does not score or interpret results for the agent. -- **Guarded** — a state-changing call needs two things: `mutation_permission=true` in the call (the agent saying it means to) and write authority from outside the model (`--allow-writes` on the command line). Supported changes run inside rollback-aware transactions. Human test prompts can only be answered by the person, in the desktop. Known limit: the pipe token is a file the same Windows user can read, so an agent that also has a shell or file access is not held back by any of this. -- **Visible** — the desktop's **AI Activity** panel shows each agent session and tool call as it happens. +Known limit: the pipe token is a file the same Windows user can read. An agent that also has a shell or file access is not held back by any of this. -```text -AI client ──stdio──▶ cortex.exe mcp ──authenticated named pipe──▶ cortex_core.dll (in target) -``` +Details: [MCP internals](docs/mcp.md) · [Agent guide](agent/agents.md) · [Semantic tools](agent/semantic-tools.md) · [Investigations](docs/investigations.md) · [Evidence](docs/evidence.md) -Details: [MCP internals](docs/mcp.md) · [Agent guide](agent/agents.md) · [Semantic tools](agent/semantic-tools.md) +## Safety model -## Command line +| | Without authority | With authority | +|---|---|---| +| Read memory, scan, disassemble, inspect files, run engines on files | ✅ | ✅ | +| Write memory, freeze, patch, breakpoints, input, load the runtime | ❌ refused and logged to `cortex_denied_mutations.jsonl` | ✅ only if the call also says `mutation_permission: true` | +| Live debuggers | Status, scripts, sources | Log breakpoints only. No evaluation, no method calls | +| Network | Debug interfaces on `127.0.0.1` only, ports allowed with `--inspect-port` | Same | -```powershell -cortex.exe # desktop application -cortex.exe mcp [--pid N | --process name] [--tools compact|all] [--allow-state | --allow-writes] -cortex.exe probe --pid 1234 # read-only target and runtime health -cortex.exe diagnose --pid 1234 # crash and hang watcher -cortex.exe analyze # offline crash analysis -cortex.exe symbolize [options] # PDB / DWARF lookup -cortex.exe inject # load the runtime -``` +Data read from a target (memory, strings, files, logs) is treated as untrusted input, never as instructions. Cortex does not circumvent protections such as anti-cheat, DRM, obfuscation or an Electron app's disabled inspector. See [SECURITY.md](SECURITY.md). -## How it fits together +## Command line -```mermaid -flowchart LR - UI["Desktop UI
Dear ImGui · D3D11"] --> S - MCP["MCP server
stdio"] --> S - CLI["CLI"] --> S - S["Application services
sessions · targets · capabilities"] -- "authenticated
named pipe" --> R64["cortex_core.dll
in an x64 target"] - S -- "authenticated
named pipe" --> R86["cortex_core.dll
in an x86 target"] - S -. "loads via" .-> H["x86 helper"] -.-> R86 +```powershell +cortex.exe # desktop application +cortex.exe mcp [--pid N] [--tools all] [--allow-state | --allow-writes] +cortex.exe inspect [--cil METHOD] # PE, .NET, ASAR, JS/TS, ZIP; nothing runs +cortex.exe provider list|run ... # Ghidra, ILSpy, Vineflower +cortex.exe live targets|scripts|watch ... # CDP, DAP (debugpy, netcoredbg), JDWP +cortex.exe evidence export|list # evidence records (JSON or Markdown) +cortex.exe investigation list|show|approve # investigations and their steps +cortex.exe probe --pid 1234 # read-only target and runtime health +cortex.exe diagnose --pid 1234 # crash and hang watcher +cortex.exe inject # load the runtime (a change to the target) ``` -The desktop runs in its own process and draws nothing inside the target. The runtime is loaded only when a feature needs to run in-process; from the desktop, only with writes allowed. See [architecture](docs/unified-app-architecture.md). - ## Portable bundle ```text @@ -189,49 +320,92 @@ Cortex/ x86/cortex_runtime_helper.exe ``` -There is one runtime per architecture because Windows cannot load a 64-bit DLL into a 32-bit process, or the reverse. You never pick one: the desktop, `cortex.exe mcp` and `cortex.exe inject ` read the target's architecture and use the matching runtime, going through the private x86 helper for 32-bit targets. - -Settings (`cortex-ui-settings.json`) and the dock layout (`cortex-ui.ini`) live in `%LOCALAPPDATA%\Cortex`. Create an empty `cortex.portable` beside `cortex.exe` to keep them next to the executable; files from an earlier version are copied over on first start. +Windows cannot load a 64-bit DLL into a 32-bit process, so there is one runtime per architecture. You never pick one: Cortex reads the target's architecture. Settings live in `%LOCALAPPDATA%\Cortex`; an empty `cortex.portable` file beside `cortex.exe` keeps them next to the executable. External engines are not bundled: install them yourself and point Cortex at them ([analysis providers](docs/analysis-providers.md), [third-party notices](docs/third-party.md)). ## Build from source Requirements: Windows, CMake 3.21+, Ninja and a C++17 compiler (CI uses MSYS2 MinGW-w64). Dear ImGui, cpp-httplib, nlohmann/json and Zydis are fetched at pinned revisions. ```powershell -# Desktop application -cmake -S app_imgui -B build/ui -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake -S app_imgui -B build/ui -G Ninja -DCMAKE_BUILD_TYPE=Release # desktop cmake --build build/ui --parallel - -# In-target runtime (per architecture) cmake -S . -B build/runtime-x64 -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -cmake --build build/runtime-x64 --target core +cmake --build build/runtime-x64 --target core # runtime ``` -`-DBUILD_TESTING=ON` adds the GUI smoke modes and the application-model unit tests to `ctest`; `-DCORTEX_IMGUI_TEST_MODES=OFF` builds `cortex.exe` without the GUI test modes. For hands-on testing, prefer the CI portable artifact, which bundles matching x64/x86 runtimes. +See [CONTRIBUTING.md](CONTRIBUTING.md) for the tests to run before a pull request. ## Quality gates -Version 1.0 was also exercised by hand on a real game (AssaultCube 1.3.0.2): scans, freezes, code injection, what-accesses, pointer scans, dissect and spider, `.CT` round trips, hotkeys and Lua, with screenshots. +CI checks every change on Windows: +- Desktop builds and GUI suites: headless, native window, workspaces, attached target, multi-session, cross-bitness. +- x64 and x86 runtimes. +- MCP end-to-end tests, including a regression check that no call changes a target without `--allow-writes`. +- The manual workflow demo. +- Static analysis compared with `objdump` and `System.Reflection.Metadata`. +- Evidence and investigations. +- The `analysis-engines` job, with pinned Ghidra, ILSpy, Vineflower, netcoredbg, debugpy and a JDK. + +Automated validation does not replace testing against real, authorized targets. Version 1.0 was also exercised by hand on AssaultCube 1.3.0.2. -Every change is checked on Windows by CI: the desktop build with headless, native-window, workspace, attached-target, multi-session and cross-bitness GUI suites; x64 and x86 runtimes; integrated MCP end-to-end tests that also call every read-only tool; private prompt and event channels; project, patch, snapshot, script, input, instrumentation, symbol and structure contracts; portable dependency closure; and the MCP schema, protocol and semantic contracts grouped in [`contracts.yml`](.github/workflows/contracts.yml). +## FAQ -Automated validation does not replace testing against real, authorized targets. +
+Do I have to attach to a process? + +No. `cortex.exe inspect`, the Artifacts workspace and the `cortex_artifact_*` / `cortex_analysis_*` MCP tools work on files alone. Attach only when you want live memory, the debugger or a live provider. +
+ +
+Do I need Ghidra or ILSpy? + +No. Built-in parsers cover PE, native code, .NET metadata and CIL, ASAR, JS/TS and ZIP. Ghidra, ILSpy, Vineflower and the debug adapters are optional. You install them, and Cortex runs them as separate processes from the paths you give it. +
+ +
+Does anything leave my machine? + +No. Cortex has no telemetry or update check and uploads nothing. External engines run locally, and debug interfaces are reached only on `127.0.0.1`. Your MCP client decides what it sends to its own model. +
+ +
+Can an AI agent change my process on its own? + +Only if you started the server with `--allow-writes`, and then only for calls that also say `mutation_permission: true`. Refused attempts are logged. `mutation_permission` is the agent's intent, not your consent: the authority is the flag you set. +
+ +
+Will it work against games with anti-cheat, or with DRM? + +It is not designed to, and it will not try to get around them. Use offline software you own, test builds, or the repository's fixtures. +
+ +
+Is Cortex an autonomous game bot? + +No. It provides bounded, supervised experiments (a launcher limited to profiles the operator configures, a test runner and reports), not universal autonomous gameplay. +
## Documentation | | | |---|---| -| [Getting started](docs/getting-started.md) | First run, attach, scan, Addresses, write permission, manual checklist | -| [Illustrated walkthrough](docs/ui-walkthrough.md) · [FR](docs/ui-walkthrough-fr.md) | The main workflow, screen by screen | +| [Getting started](docs/getting-started.md) · [Walkthrough](docs/ui-walkthrough.md) ([FR](docs/ui-walkthrough-fr.md)) · [Manual demo](docs/demo-manual.md) | First run and the main workflow, screen by screen | | [UI guide](docs/ui-guide.md) | Every workspace, menu, shortcut and setting | -| [MCP internals](docs/mcp.md) | Transport, routing, security, semantic execution | -| [Agent guide](agent/agents.md) | How an AI agent should drive Cortex | -| [Architecture](docs/unified-app-architecture.md) | Product and runtime layering | +| [Analysis providers](docs/analysis-providers.md) | Ghidra, ILSpy, Vineflower and the live providers: setup, guarantees, validation, limits | +| [MCP internals](docs/mcp.md) · [Agent guide](agent/agents.md) | Transport, routing, security; how an agent should drive Cortex | +| [Evidence](docs/evidence.md) · [Investigations](docs/investigations.md) | Records with provenance; supervised multi-step investigations | +| [Architecture](docs/unified-app-architecture.md) · [Roadmap](docs/cortex-2-roadmap.md) | Layering; the Cortex 2.0 plan and what each step validated | | [Diagnostics](docs/external-diagnostics.md) · [Symbols](docs/symbols.md) · [Hooks](docs/hooks.md) · [Mod SDK](docs/mod-sdk.md) | Crash and hang tooling | -| [Changelog](CHANGELOG.md) | Release history | +| [Known limitations](docs/limitations.md) | What Cortex does not do, or has not validated, in one page | +| [Changelog](CHANGELOG.md) · [Third-party](docs/third-party.md) | Release history and licences | All documents are indexed in [`docs/README.md`](docs/README.md). +## Contributing + +Bug reports, fixtures and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) first; report vulnerabilities as described in [SECURITY.md](SECURITY.md), not in public issues. + ## License -[MIT](LICENSE) © 2026 PdB333 +[MIT](LICENSE) © 2026 PdB333. External engines and debug adapters keep their own licences; see [third-party notices](docs/third-party.md). diff --git a/README_INSTALL.txt b/README_INSTALL.txt index 41252cc5..3d2410b6 100644 --- a/README_INSTALL.txt +++ b/README_INSTALL.txt @@ -1,17 +1,21 @@ -CORTEX v1.0.1 - INSTALLATION AND QUICK START +CORTEX v2.0.0 - INSTALLATION AND QUICK START ============================================ -Cortex v1.0.1 ships as a single portable Windows application: cortex.exe. -The x64 and x86 instrumentation runtimes live in the runtime folder and are -selected automatically for each target. Nothing else has to be installed -(no Qt, no Visual C++ runtime). +Cortex ships as a single portable Windows application: cortex.exe. The x64 +and x86 runtimes live in the runtime folder and are selected automatically +for each target. Nothing else has to be installed (no Qt, no Visual C++ +runtime). External analysis engines are optional (see below). + +Use Cortex only on software and systems you own or are authorized to +inspect. Anti-cheat, DRM and access-control bypass, and interference with +online services, are outside the scope of this project. INSTALLATION ------------ 1. Download the archive: - cortex-v1.0.1-windows-portable.zip + cortex-v2.0.0-windows-portable.zip 2. Extract the whole archive into a normal, writable folder, for example: @@ -24,74 +28,105 @@ INSTALLATION .\cortex.exe -FIRST SESSION -------------- +TWO WAYS TO USE CORTEX +---------------------- + +You drive it: the desktop (cortex.exe) and the command line. +An AI agent drives it: your MCP client starts "cortex.exe mcp". The server +is read-only unless YOU add --allow-writes to its arguments; every change +must also be asked for explicitly by the agent (mutation_permission), and +the desktop's AI Activity panel shows each call. Both share the same +targets, projects and evidence. + +FIRST SESSION (DESKTOP) +----------------------- 1. Click "Select process" in the header (or File > Select process...). 2. Select the target and click Attach (or double-click the row). 3. Use the "Value scan" panel of the Memory workspace to scan a value (First scan, then Next scan). -4. Double-click a useful result to prepare an Addresses entry. -5. Use an address's context menu to jump to the Memory viewer, the - Disassembler, RE, Pointer Maps, Structures or debugger actions. +4. Double-click a useful result to keep it in the address list. +5. Right-click an address to open it in the Memory viewer, the Disassembler + or the Debugger, or to find out what writes to it. 6. The Memory, Debug, RE, Trace, Automation and Runtime buttons in the header rearrange the workspaces for each task. 7. Switch the header toggle from "Read-only" to "Writes allowed" only when you intend an operation that changes the target or persistent state. -COMMAND-LINE MODES ------------------- - -Help: +COMMAND LINE +------------ .\cortex.exe --help + .\cortex.exe --version -Version: +Files, without running them (PE, .NET, Electron .asar, JavaScript, ZIP): - .\cortex.exe --version + .\cortex.exe inspect C:\path\to\app.asar + .\cortex.exe inspect C:\path\to\Library.dll --cil Namespace.Type::Method -Persistent MCP server over stdio (targetless; attach from the AI client): +Running processes: - .\cortex.exe mcp + .\cortex.exe probe --pid 1234 (read-only health) + .\cortex.exe diagnose --pid 1234 (crash and hang watcher) + .\cortex.exe inject (loads the runtime: a change) -Attach MCP to a PID or a process at startup: +Engines, live debuggers, evidence and investigations: - .\cortex.exe mcp --pid 1234 - .\cortex.exe mcp --process game.exe + .\cortex.exe provider list|run ... + .\cortex.exe live targets|scripts|watch ... + .\cortex.exe evidence export|list + .\cortex.exe investigation list|show|approve|reject ... -Also expose the low-level primitive tools: +MCP SERVER (FOR AN AI CLIENT) +----------------------------- - .\cortex.exe mcp --tools all +Add to your client's MCP configuration (read-only): -Other built-in commands: + { "mcpServers": { "cortex": { "command": "C:/Cortex/cortex.exe", + "args": ["mcp"] } } } - .\cortex.exe probe --pid 1234 - .\cortex.exe diagnose --pid 1234 - .\cortex.exe analyze - .\cortex.exe symbolize [options] - .\cortex.exe inject [dll] +Useful arguments, all chosen by you, never by the agent: + + --allow-writes let the agent change targets (each call must + still say mutation_permission: true) + --allow-state let it save only its own records + --tools all expose every primitive tool + --pid 1234 attach at startup + --artifact-root folders whose files the agent may inspect + --launch-config programs the agent may start (allowlist) + +In MCP mode, stdout is reserved for JSON-RPC; diagnostics go to stderr. + +OPTIONAL ENGINES +---------------- + +Cortex does not bundle these. Install them yourself and point Cortex at them: + + Ghidra 11.x + JDK 21 --ghidra-dir (or GHIDRA_INSTALL_DIR) + ILSpy ilspycmd 9.x --ilspycmd (or CORTEX_ILSPYCMD) + Vineflower 1.11 + a JDK --vineflower (or CORTEX_VINEFLOWER) + netcoredbg 3.1 --netcoredbg (or CORTEX_NETCOREDBG) + Node --inspect, debugpy, a JVM with JDWP: allow their port with + --inspect-port + +Live debuggers only set log breakpoints: they record the stack and locals +and resume; they never evaluate code. See docs/analysis-providers.md. WHAT IS IN THE ARCHIVE ---------------------- cortex.exe - The Cortex application (desktop UI and CLI/MCP modes). + The Cortex application (desktop, command line and MCP server). -runtime\x64\cortex_core.dll - Instrumentation runtime for 64-bit targets. - -runtime\x86\cortex_core.dll - Instrumentation runtime for 32-bit targets. +runtime\x64\cortex_core.dll, runtime\x86\cortex_core.dll + Runtimes for 64-bit and 32-bit targets. runtime\x86\cortex_runtime_helper.exe Private helper used automatically for 32-bit targets. - You never choose between x64 and x86: Cortex reads the target's - architecture and uses the matching runtime, including for - "cortex.exe inject ". - -README.md, CHANGELOG.md, LICENSE, docs - Documentation, history, license and technical guides. +README.md, README_INSTALL.txt, CHANGELOG.md, LICENSE, docs + Documentation, history, licence and guides (docs/limitations.md lists + what Cortex does not do or has not validated). TROUBLESHOOTING --------------- @@ -101,18 +136,10 @@ TROUBLESHOOTING - If a target cannot be opened, check its architecture and the process's Windows rights. Only use elevated privileges when the target requires them. - If a write, breakpoint or control operation is refused, switch the header - to "Writes allowed" first. + to "Writes allowed" (desktop) or start the MCP server with --allow-writes. - Settings and the window layout are saved in %LOCALAPPDATA%\Cortex. To keep - them next to cortex.exe instead (USB stick, sandbox), create an empty file - named cortex.portable next to the executable. -- In MCP mode, stdout is reserved for JSON-RPC for the whole session; - diagnostics go to stderr. -- See docs/getting-started.md, docs/ui-guide.md and docs/ui-walkthrough.md - for the complete workflow. - -AUTHORIZATION -------------- - -Use Cortex only on software and systems you own or are authorized to inspect. -Anti-cheat bypass, unauthorized access and interference with online services -are outside the scope of this project. + them next to cortex.exe instead, create an empty file named cortex.portable + next to the executable. +- Engines show as unavailable with the reason: "cortex.exe provider list". +- Guides: docs/getting-started.md, docs/ui-guide.md, docs/ui-walkthrough.md, + docs/mcp.md, docs/analysis-providers.md. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..1ecb8598 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,65 @@ +# Security policy + +Cortex reads and, with permission, changes other processes. A flaw in it can +let a client do more than the operator allowed, so these reports matter. + +## Reporting a vulnerability + +Please **do not open a public issue** with the details. + +1. Use GitHub's private vulnerability reporting for this repository + (**Security › Report a vulnerability**). +2. If that is not available to you, open an issue that only asks for a + private contact, without technical details. + +Include the version or commit, Windows version, target architecture, the steps +or a proof of concept against the repository's test target, and what you +expected to happen. You will get an acknowledgement, and a fix or an +explanation, as soon as possible. Releases are published only after the +maintainer's explicit approval, so a fix may first land on a branch. + +## What counts + +In scope, for example: + +- an MCP client, or a page talking to a local debug port, that changes a + target **without** `--allow-writes`, or without `mutation_permission: true` + in the call; +- a tool call that reads or writes files outside the roots the operator gave + (`--artifact-root`, `--test-results`, the project folder); +- an MCP client that starts an executable or arguments not in the operator's + launch profile; +- a live provider that evaluates code, runs a getter or method, or connects + somewhere other than `127.0.0.1`; +- a crash, hang, or out-of-bounds read or write in Cortex caused by a crafted + file (PE, .NET, ASAR, JS, ZIP, class files) or by data read from a target; +- data from a target (strings, memory, logs, file contents) being treated as + instructions by Cortex itself. + +Out of scope: + +- anything that needs the operator to grant the authority in question first + (for example writes after `--allow-writes`); +- the documented limit that the pipe token is a file the same Windows user can + read: an agent that also has a shell or file access is not contained by + Cortex; +- vulnerabilities in Ghidra, ILSpy, Vineflower, netcoredbg, debugpy, Node.js + or the JVM themselves (report those upstream), unless Cortex makes them + reachable when they otherwise would not be; +- requests to add anti-cheat, DRM or access-control bypasses. These are not + vulnerabilities and will not be implemented. + +## Design guarantees + +See [docs/mcp.md](docs/mcp.md) (write authority, routing, the private +desktop channel), [docs/analysis-providers.md](docs/analysis-providers.md) +(external engines and live providers) and +[docs/automated-tests.md](docs/automated-tests.md) (launch profiles). Their +regression tests include `tests/security_no_writes_regression.py`, +`tests/mcp_authority_tests.cpp` and the live provider suites. + +## Responsible use + +Use Cortex only on software you own or are authorized to analyze. The +maintainers do not support using it to cheat in online games, defeat DRM or +licence checks, or access systems without permission. diff --git a/app_imgui/CMakeLists.txt b/app_imgui/CMakeLists.txt index f17a1e66..b3c43d2a 100644 --- a/app_imgui/CMakeLists.txt +++ b/app_imgui/CMakeLists.txt @@ -124,6 +124,7 @@ add_executable(cortex_imgui WIN32 application/input_model.cpp application/screenshot_model.cpp application/runtime_events_model.cpp + application/context_model.cpp ui/bottom_panel_workspace.cpp ui/addresses_workspace.cpp ui/memory_workspace.cpp @@ -133,6 +134,8 @@ add_executable(cortex_imgui WIN32 ui/overview_workspace.cpp ui/patches_workspace.cpp ui/project_workspace.cpp + ui/artifacts_workspace.cpp + ui/ai_investigation_workspace.cpp ui/symbols_workspace.cpp ui/structures_workspace.cpp ui/pointer_maps_workspace.cpp @@ -166,6 +169,40 @@ add_executable(cortex_imgui WIN32 ../mcp_bridge/host_mode.cpp ../tools/diagnostics_host/main.cpp ../tools/symbolize_main.cpp + ../tools/inspect_main.cpp + ../tools/evidence_main.cpp + ../tools/investigation_main.cpp + ../tools/provider_main.cpp + ../tools/live_main.cpp + ../core/artifacts/artifact.cpp + ../core/artifacts/pe_provider.cpp + ../core/artifacts/asar_provider.cpp + ../core/artifacts/registry_default.cpp + ../core/artifacts/js_lexer.cpp + ../core/artifacts/js_analysis.cpp + ../core/artifacts/js_provider.cpp + ../core/artifacts/source_map.cpp + ../core/artifacts/inflate.cpp + ../core/artifacts/zip_provider.cpp + ../core/artifacts/dotnet_metadata.cpp + ../core/artifacts/cil.cpp + ../core/artifacts/dotnet_provider.cpp + ../core/artifacts/native_code.cpp + ../core/artifacts/annotations.cpp + ../core/evidence/evidence.cpp + ../core/investigation/investigation.cpp + ../core/target/process_tree.cpp + ../core/providers/process_runner.cpp + ../core/providers/analysis_provider.cpp + ../core/providers/ghidra_provider.cpp + ../core/providers/ilspy_provider.cpp + ../core/providers/vineflower_provider.cpp + ../core/live/websocket.cpp + ../core/live/protocol_frames.cpp + ../core/live/cdp.cpp + ../core/live/dap.cpp + ../core/live/jdwp.cpp + ../core/live/live_service.cpp ../host/diagnostics/external_host.cpp ../host/diagnostics/analyzer.cpp ../core/symbols/symbols.cpp @@ -284,10 +321,15 @@ if(BUILD_TESTING) application/snapshots_model.cpp application/symbols_model.cpp application/watches_model.cpp + application/context_model.cpp + ../core/context/investigation_context.cpp + ../core/context/context_api.cpp + ../core/services/value_scanner.cpp ) target_include_directories(cortex_app_models_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/../core + ${CMAKE_CURRENT_SOURCE_DIR}/../host/debugger ) target_link_libraries(cortex_app_models_tests PRIVATE nlohmann_json::nlohmann_json) if(MINGW) @@ -296,6 +338,38 @@ if(BUILD_TESTING) add_test(NAME cortex_app_models_tests COMMAND cortex_app_models_tests) set_tests_properties(cortex_app_models_tests PROPERTIES TIMEOUT 30) + # Desktop settings and cortex.ini persistence (atomic replace, round trip). + add_executable(cortex_settings_store_tests + ../tests/settings_store_tests.cpp + application/settings.cpp + ) + target_include_directories(cortex_settings_store_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../core + ) + target_link_libraries(cortex_settings_store_tests PRIVATE nlohmann_json::nlohmann_json) + if(MINGW) + target_link_options(cortex_settings_store_tests PRIVATE -static -static-libgcc -static-libstdc++) + endif() + add_test(NAME cortex_settings_store_tests COMMAND cortex_settings_store_tests) + set_tests_properties(cortex_settings_store_tests PROPERTIES TIMEOUT 30) + + # Baseline benchmarks (docs/cortex-2-roadmap.md, step 1.3). The CTest + # entry is the --quick correctness smoke; run the executable without + # arguments for the reference measurement. + add_executable(cortex_baseline_bench + ../tests/bench/cortex_baseline_bench.cpp + ../core/services/value_scanner.cpp + ../core/services/pointer_scanner.cpp + ) + target_include_directories(cortex_baseline_bench PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../core) + target_link_libraries(cortex_baseline_bench PRIVATE Zydis nlohmann_json::nlohmann_json) + if(MINGW) + target_link_options(cortex_baseline_bench PRIVATE -static -static-libgcc -static-libstdc++) + endif() + add_test(NAME cortex_baseline_bench_smoke COMMAND cortex_baseline_bench --quick) + set_tests_properties(cortex_baseline_bench_smoke PROPERTIES TIMEOUT 120) + # Value scanner against an in-memory fake process. add_executable(cortex_value_scanner_tests ../tests/value_scanner_tests.cpp diff --git a/app_imgui/application/context_model.cpp b/app_imgui/application/context_model.cpp new file mode 100644 index 00000000..6c21ff58 --- /dev/null +++ b/app_imgui/application/context_model.cpp @@ -0,0 +1,214 @@ +#include "context_model.h" + +#include + +#include +#include +#include + +namespace cortex::application { +namespace { + +using json = nlohmann::json; + +std::string Hex(uint64_t value) { + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), "0x%llx", static_cast(value)); + return buffer; +} + +} // namespace + +std::string HexBytes(const uint8_t* data, size_t size) { + static const char* digits = "0123456789abcdef"; + std::string text; + text.reserve(size * 2); + for (size_t i = 0; i < size; ++i) { + text.push_back(digits[data[i] >> 4]); + text.push_back(digits[data[i] & 0xF]); + } + return text; +} + +namespace { + +std::string Text(const json& object, const char* key) { + const auto it = object.find(key); + return it != object.end() && it->is_string() ? it->get() : std::string(); +} + +} // namespace + +void InvestigationContextModel::Reset() { + entries_.clear(); + history_.clear(); + pid_ = generation_ = droppedEntries_ = 0; + lastSelected_.clear(); + lastShared_.clear(); +} + +bool InvestigationContextModel::Route(const std::string& method, const std::string& path, const json& body, + json& result, std::string* error) { + if (error) error->clear(); + if (!payload_.Ready()) { + if (error) *error = "runtime_not_connected"; + return false; + } + json output; + if (!payload_.CallRouteExisting(method, path, body, output, error)) { + if (error && error->empty()) *error = "context_unavailable"; + return false; + } + // Private routes answer {"status": http, "result": body}. + result = output.is_object() && output.contains("result") ? output["result"] : output; + if (!result.is_object()) { + if (error) *error = "context_response_invalid"; + return false; + } + if (!result.value("ok", false)) { + if (error) *error = result.value("error", std::string("context_call_failed")); + return false; + } + return true; +} + +bool InvestigationContextModel::SelectAddress(uint64_t address, const std::string& label, const std::string& note, + uint64_t expectedGeneration, std::string* error) { + if (address == 0) { + if (error) *error = "invalid_address"; + return false; + } + json body = {{"address", Hex(address)}, {"label", label}, {"note", note}}; + if (expectedGeneration) body["expected_generation"] = expectedGeneration; + json result; + if (!Route("POST", "/context/select", body, result, error)) return false; + lastSelected_ = result.value("data", json::object()).value("entry", json::object()).value("id", std::string()); + return true; +} + +bool InvestigationContextModel::Observe(const std::string& statement, const json& data, const json& evidence, + uint64_t expectedGeneration, std::string* error) { + json body = {{"statement", statement}, {"kind", "observation"}, {"data", data}, {"evidence", evidence}}; + if (expectedGeneration) body["expected_generation"] = expectedGeneration; + json result; + return Route("POST", "/context/observation", body, result, error); +} + +std::string HexBytes(const uint8_t* data, size_t size); + +bool InvestigationContextModel::Share(const std::string& kind, const json& data, uint64_t expectedGeneration, + std::string* error) { + // Unchanged state is not sent again (the frame loop calls this often). + const std::string serialized = data.dump(); + const auto previous = lastShared_.find(kind); + if (previous != lastShared_.end() && previous->second == serialized) { + if (error) error->clear(); + return true; + } + json body = {{"kind", kind}, {"data", data}}; + if (expectedGeneration) body["expected_generation"] = expectedGeneration; + json result; + if (!Route("POST", "/context/share", body, result, error)) return false; + lastShared_[kind] = serialized; + return true; +} + +json InvestigationContextModel::ScanSummary(const services::ScanState& state, const services::ScanQuery& query, + size_t maxRows) { + using services::ValueScanner; + json results = json::array(); + if (state.Listed()) { + const size_t rows = std::min(maxRows, state.Size()); + for (size_t i = 0; i < rows; ++i) { + const size_t size = std::min(state.HitSize(i), state.valueSize); + json row = {{"address", Hex(state.addresses[i])}, {"value", HexBytes(state.Value(i), size)}}; + if (!state.firstValues.empty()) row["first_value"] = HexBytes(state.FirstValue(i), size); + if (!state.hitTypes.empty()) row["type"] = ValueScanner::TypeName(state.HitType(i)); + results.push_back(std::move(row)); + } + } + return {{"scan_number", state.scanNumber}, + {"value_type", ValueScanner::TypeName(state.type)}, + {"compare", ValueScanner::CompareName(query.compare)}, + {"value", query.value}, + {"result_count", state.count}, + {"listed", state.Listed()}, + {"results", std::move(results)}, + {"results_truncated", state.Listed() && state.Size() > maxRows}, + {"limit_reached", state.limitReached}, + {"bytes_scanned", state.bytesScanned}, + {"milliseconds", state.milliseconds}, + {"values_encoding", "little-endian hex bytes as read when the scan ran"}}; +} + +json InvestigationContextModel::DebuggerSummary(const std::string& backend, + const std::vector& breakpoints, + const std::vector& paused, size_t maxRows) { + json rows = json::array(); + for (const auto& breakpoint : breakpoints) { + if (rows.size() >= maxRows) break; + rows.push_back({{"id", breakpoint.id}, + {"kind", breakpoint.kind}, + {"address", Hex(breakpoint.address)}, + {"size", breakpoint.size}, + {"pause_on_hit", breakpoint.pauseOnHit}, + {"hit_count", breakpoint.hitCount}, + {"process_global", breakpoint.processGlobal}, + {"applied_threads", breakpoint.appliedThreads}, + {"total_threads", breakpoint.totalThreads}}); + } + json threads = json::array(); + for (const auto& thread : paused) + threads.push_back({{"thread_id", thread.threadId}, {"breakpoint", thread.breakpointId}}); + return {{"backend", backend}, + {"breakpoints", std::move(rows)}, + {"breakpoints_truncated", breakpoints.size() > maxRows}, + {"paused_threads", std::move(threads)}}; +} + +bool InvestigationContextModel::Refresh(uint64_t expectedGeneration, std::string* error) { + std::string path = "/context?limit=200"; + if (expectedGeneration) path += "&expected_generation=" + std::to_string(expectedGeneration); + json result; + if (!Route("GET", path, json::object(), result, error)) return false; + const json data = result.value("data", json::object()); + const json target = data.value("target", json::object()); + pid_ = target.value("pid", uint64_t{0}); + generation_ = target.value("generation", uint64_t{0}); + droppedEntries_ = data.value("dropped_entries", uint64_t{0}); + + entries_.clear(); + for (const auto& row : data.value("entries", json::array())) { + if (!row.is_object()) continue; + ContextEntry entry; + entry.id = Text(row, "id"); + entry.sequence = row.value("sequence", uint64_t{0}); + entry.kind = Text(row, "kind"); + entry.address = Text(row, "address"); + const json location = row.value("location", json()); + if (location.is_object()) entry.location = Text(location, "expression"); + entry.text = entry.kind == "selection" ? Text(row, "label") : Text(row, "statement"); + entry.status = Text(row, "status"); + const json provenance = row.value("provenance", json::object()); + entry.source = Text(provenance, "source"); + entry.session = Text(provenance, "session"); + entry.recordedMs = provenance.value("recorded_ms", uint64_t{0}); + entries_.push_back(std::move(entry)); + } + + history_.clear(); + for (const auto& row : data.value("history", json::array())) { + if (!row.is_object()) continue; + ContextOperation operation; + operation.sequence = row.value("sequence", uint64_t{0}); + operation.tool = Text(row, "tool"); + operation.risk = Text(row, "risk"); + operation.status = Text(row, "status"); + operation.durationMs = row.value("duration_ms", uint64_t{0}); + operation.source = Text(row.value("provenance", json::object()), "source"); + history_.push_back(std::move(operation)); + } + return true; +} + +} // namespace cortex::application diff --git a/app_imgui/application/context_model.h b/app_imgui/application/context_model.h new file mode 100644 index 00000000..cd069201 --- /dev/null +++ b/app_imgui/application/context_model.h @@ -0,0 +1,101 @@ +#pragma once + +#include "debug_provider.h" +#include "services/runtime_transport.h" +#include "services/value_scanner.h" + +#include +#include +#include +#include + +namespace cortex::application { + +// One record of the runtime's shared investigation context (core/context): +// a selection the person made in the desktop, or an observation the person +// or an agent recorded. +struct ContextEntry { + std::string id; + uint64_t sequence = 0; + std::string kind; // selection | observation | correlation | hypothesis | note + std::string source; // desktop | agent | unattributed + std::string session; + std::string address; // "0x..." or empty + std::string location; // "module+0xRVA" or empty + std::string text; // label for a selection, statement otherwise + std::string status; // pointer_only | client_assertion + uint64_t recordedMs = 0; +}; + +struct ContextOperation { + uint64_t sequence = 0; + std::string tool; + std::string risk; + std::string status; + std::string source; + uint64_t durationMs = 0; +}; + +// The desktop's view of the investigation context it shares with MCP +// clients. It talks to an already-connected runtime only: selecting an +// address never loads the runtime into the target. +class InvestigationContextModel { +public: + explicit InvestigationContextModel(services::RuntimeTransport& payload) : payload_(payload) {} + + void Reset(); + + // Publishes the person's selection. `expectedGeneration` is the active + // target's process generation (0 = unchecked); the runtime refuses the + // call with stale_target_generation if it belongs to another process. + bool SelectAddress(uint64_t address, const std::string& label, const std::string& note, + uint64_t expectedGeneration, std::string* error = nullptr); + + // Records an observation the person makes from the desktop (for example + // an artifact they inspected). `evidence` holds {source, reference} + // objects. The record says what the person saw, never that it is true. + bool Observe(const std::string& statement, const nlohmann::json& data, const nlohmann::json& evidence, + uint64_t expectedGeneration, std::string* error = nullptr); + + // Shares desktop-side state that the runtime cannot see by itself: the + // value scanner's results or the external debugger's breakpoints. + // `expectedGeneration` is the generation of the target the state was + // produced on; a share for another process is refused by the runtime. + bool Share(const std::string& kind, const nlohmann::json& data, uint64_t expectedGeneration, + std::string* error = nullptr); + + // What Share("scan") sends: the query, totals and at most `maxRows` + // listed results with their current and first-scan values (hex bytes). + static nlohmann::json ScanSummary(const services::ScanState& state, const services::ScanQuery& query, + size_t maxRows = 256); + // What Share("debugger") sends: breakpoints with hit counters and the + // paused threads. + static nlohmann::json DebuggerSummary(const std::string& backend, + const std::vector& breakpoints, + const std::vector& paused, size_t maxRows = 128); + + // Reloads the context. Entries are replaced, not appended. + bool Refresh(uint64_t expectedGeneration, std::string* error = nullptr); + + const std::vector& Entries() const { return entries_; } + const std::vector& History() const { return history_; } + uint64_t Pid() const { return pid_; } + uint64_t Generation() const { return generation_; } + uint64_t DroppedEntries() const { return droppedEntries_; } + const std::string& LastSelectedEntry() const { return lastSelected_; } + +private: + bool Route(const std::string& method, const std::string& path, const nlohmann::json& body, + nlohmann::json& result, std::string* error); + + services::RuntimeTransport& payload_; + std::vector entries_; + std::vector history_; + uint64_t pid_ = 0; + uint64_t generation_ = 0; + uint64_t droppedEntries_ = 0; + std::string lastSelected_; + std::map lastShared_; // kind -> last payload sent +}; + +} // namespace cortex::application diff --git a/app_imgui/application/debugger_model.h b/app_imgui/application/debugger_model.h index 3b2d601b..22a06730 100644 --- a/app_imgui/application/debugger_model.h +++ b/app_imgui/application/debugger_model.h @@ -28,6 +28,8 @@ class DebuggerModel { bool EnsureAttached(std::string* error = nullptr); bool Ready(); const std::string& Backend(); + // The backend of an existing provider, without creating one. + const std::string& ProviderBackend() const { return providerBackend_; } bool RefreshThreads(std::string* error = nullptr); bool SelectThread(uint64_t threadId, std::string* error = nullptr); diff --git a/app_imgui/application/project_model.cpp b/app_imgui/application/project_model.cpp index e2521f07..edcaaade 100644 --- a/app_imgui/application/project_model.cpp +++ b/app_imgui/application/project_model.cpp @@ -30,6 +30,7 @@ void ProjectModel::Reset() { addresses_.clear(); pointerPaths_.clear(); notes_.clear(); + knowledge_.clear(); } @@ -88,6 +89,29 @@ bool ProjectModel::Refresh(std::string* error) { } } + // Revisioned claims written by the person or an agent + // (project_knowledge_put). Shown as stored; nothing is upgraded here. + knowledge_.clear(); + const json knowledge = result.value("knowledge", json::object()); + if (knowledge.is_object()) { + for (auto it = knowledge.begin(); it != knowledge.end(); ++it) { + if (!it.value().is_object()) continue; + ProjectKnowledge row; + row.id = it.key(); + row.kind = it.value().value("kind", std::string()); + row.status = it.value().value("status", std::string()); + row.statement = it.value().value("statement", std::string()); + row.origin = it.value().value("origin", std::string()); + row.revision = it.value().value("revision", 0); + const json evidence = it.value().value("evidence", json::array()); + row.evidenceCount = evidence.is_array() ? static_cast(evidence.size()) : 0; + const json verification = it.value().value("last_verification", json::object()); + row.verification = verification.is_object() ? verification.value("status", std::string()) : std::string(); + knowledge_.push_back(std::move(row)); + } + } + std::sort(knowledge_.begin(), knowledge_.end(), [](const auto& a, const auto& b) { return a.id < b.id; }); + std::sort(addresses_.begin(), addresses_.end(), [](const auto& a, const auto& b) { return a.name < b.name; }); std::sort(pointerPaths_.begin(), pointerPaths_.end(), diff --git a/app_imgui/application/project_model.h b/app_imgui/application/project_model.h index 61f6a171..9d3bbad9 100644 --- a/app_imgui/application/project_model.h +++ b/app_imgui/application/project_model.h @@ -30,6 +30,17 @@ struct ProjectNote { std::string tagsJson = "[]"; }; +struct ProjectKnowledge { + std::string id; + std::string kind; + std::string status; // hypothesis | observed | refuted | stale + std::string statement; + std::string origin; // client_assertion + std::string verification; + int revision = 0; + int evidenceCount = 0; +}; + class ProjectModel : public RuntimeModelBase { public: explicit ProjectModel(services::RuntimeTransport& payload) : RuntimeModelBase(payload) {} @@ -40,6 +51,7 @@ class ProjectModel : public RuntimeModelBase { const std::vector& Addresses() const { return addresses_; } const std::vector& PointerPaths() const { return pointerPaths_; } const std::vector& Notes() const { return notes_; } + const std::vector& Knowledge() const { return knowledge_; } bool SetAddress(const std::string& name, const std::string& address, const std::string& type, const std::string& notes, @@ -67,6 +79,7 @@ class ProjectModel : public RuntimeModelBase { std::vector addresses_; std::vector pointerPaths_; std::vector notes_; + std::vector knowledge_; }; } // namespace cortex::application diff --git a/app_imgui/application/settings.cpp b/app_imgui/application/settings.cpp index 5a8fbed6..8485e09b 100644 --- a/app_imgui/application/settings.cpp +++ b/app_imgui/application/settings.cpp @@ -107,12 +107,12 @@ bool UpdateIni(const std::filesystem::path& path, for (const auto& item : managed) if (!written[item.first]) lines.push_back(item.first + "=" + item.second); - std::ofstream output(path, std::ios::trunc); - if (!output) { + std::string text; + for (const auto& line : lines) text += line + '\n'; + if (!services::WriteFileAtomically(path, text)) { if (error) *error = "unable_to_write_runtime_config:" + path.u8string(); return false; } - for (const auto& line : lines) output << line << '\n'; return true; } @@ -259,12 +259,10 @@ bool SettingsStore::Save(std::string* error) const { {"autoAttachEnabled", values_.autoAttachEnabled}, {"autoAttachProcesses", values_.autoAttachProcesses} }; - std::ofstream output(path_, std::ios::trunc); - if (!output) { + if (!services::WriteFileAtomically(path_, document.dump(2) + '\n')) { if (error) *error = "unable_to_write_settings"; return false; } - output << document.dump(2) << '\n'; return true; } catch (const std::exception& ex) { if (error) *error = std::string("settings_save_failed:") + ex.what(); diff --git a/app_imgui/cli.cpp b/app_imgui/cli.cpp index 7b2be92c..9c04b3ec 100644 --- a/app_imgui/cli.cpp +++ b/app_imgui/cli.cpp @@ -3,6 +3,7 @@ #include "desktop_app.h" #include "../host/cli_entry_points.h" +#include "../core/version.h" namespace cortex::desktop { @@ -47,10 +48,10 @@ static int ForwardCli(CliEntryPoint entry, const char* programName, static void PrintCliUsage(FILE* out = stdout) { std::fputs( - "Cortex lightweight migration preview\n\n" + "Cortex: memory scanner, debugger, reverse engineering and MCP server\n\n" "Usage:\n" " cortex Launch the ImGui desktop UI\n" - " cortex --version Print preview version\n" + " cortex --version Print the Cortex version\n" " cortex --smoke-test Run deterministic headless ImGui smoke\n" " cortex --gui-workspace-suite Render all workspaces/presets and validate docking\n" " cortex --gui-attached-suite Exercise GUI against a live --pid target\n" @@ -65,7 +66,12 @@ static void PrintCliUsage(FILE* out = stdout) { " cortex probe --pid Inspect target/runtime health\n" " cortex diagnose --pid Watch crash/hang diagnostics\n" " cortex analyze Analyze a crash directory\n" - " cortex symbolize [options] Resolve PE symbols offline\n", + " cortex symbolize [options] Resolve PE symbols offline\n" + " cortex inspect [...] Identify a file and print its structure (PE, ASAR, JS, .NET, ZIP)\n" + " cortex evidence export|list Export or list recorded evidence (JSON or Markdown)\n" + " cortex investigation ... List, show, report, approve or reject investigation steps\n" + " cortex provider list|run ... External engines: Ghidra (native), ILSpy (C#), Vineflower (Java)\n" + " cortex live targets|scripts|watch Live debuggers (CDP, debugpy, netcoredbg, JDWP): log breakpoints\n", out); } @@ -78,7 +84,7 @@ static std::optional RunCliMode(std::vector& args) { return 0; } if (command == "--version" || command == "version") { - std::puts("cortex 1.0.1"); + std::printf("cortex %s\n", cortex::kVersion); return 0; } #if CORTEX_WITH_TEST_MODES @@ -109,6 +115,11 @@ static std::optional RunCliMode(std::vector& args) { if (command == "mcp") return ForwardCli(CortexMcpMain, "cortex mcp", args, 2); if (command == "inject") return ForwardCli(CortexInjectMain, "cortex inject", args, 2); if (command == "probe") return ForwardCli(CortexProbeMain, "cortex probe", args, 2); + if (command == "inspect") return ForwardCli(CortexInspectMain, "cortex inspect", args, 2); + if (command == "evidence") return ForwardCli(CortexEvidenceMain, "cortex evidence", args, 2); + if (command == "investigation") return ForwardCli(CortexInvestigationMain, "cortex investigation", args, 2); + if (command == "provider") return ForwardCli(CortexProviderMain, "cortex provider", args, 2); + if (command == "live") return ForwardCli(CortexLiveMain, "cortex live", args, 2); if (command == "diagnose" || command == "diagnostics" || command == "watch") return ForwardCli(CortexDiagnoseMain, "cortex diagnose", args, 2); if (command == "analyze" || command == "analyse") { diff --git a/app_imgui/desktop_app.h b/app_imgui/desktop_app.h index ef941008..f41c0b50 100644 --- a/app_imgui/desktop_app.h +++ b/app_imgui/desktop_app.h @@ -56,6 +56,8 @@ #include "ui/tools_workspace.h" #include "ui/access_finder_workspace.h" #include "ui/lua_workspace.h" +#include "ui/artifacts_workspace.h" +#include "ui/ai_investigation_workspace.h" #include "ui/watches_workspace.h" #include "ui/scripts_workspace.h" #include "ui/screenshot_workspace.h" @@ -167,6 +169,7 @@ struct AppState { cortex::application::InputModel inputModel; cortex::application::ScreenshotModel screenshotModel; cortex::application::RuntimeEventsModel runtimeEventsModel; + cortex::application::InvestigationContextModel contextModel; cortex::application::PatchesModel patchesModel; cortex::application::PromptModel promptModel; cortex::ui::UiContext ui; @@ -221,9 +224,12 @@ struct AppState { inputModel(payload), screenshotModel(payload), runtimeEventsModel(payload), + contextModel(payload), patchesModel(payload), promptModel(payload) { catalog.AddBackend(std::make_shared()); + // Records this desktop creates in the runtime are the person's. + payload.SetDesktopChannel(true); ui.sessions = &sessions; ui.memory = &memory; @@ -249,6 +255,7 @@ struct AppState { ui.inputModel = &inputModel; ui.screenshotModel = &screenshotModel; ui.runtimeEventsModel = &runtimeEventsModel; + ui.contextModel = &contextModel; ui.patchesModel = &patchesModel; ui.nativeRenderDevice = NativeRenderDevice(); @@ -283,6 +290,8 @@ struct AppState { workspaces.Add(); workspaces.Add(); workspaces.Add(); + workspaces.Add(); + workspaces.Add(); workspaces.ApplyPreset(cortex::ui::WorkspacePreset::Memory, false); } @@ -309,6 +318,7 @@ struct AppState { inputModel.Reset(); screenshotModel.Reset(); runtimeEventsModel.Reset(); + contextModel.Reset(); patchesModel.Reset(); promptModel.Reset(); ui.mutationAllowed = false; diff --git a/app_imgui/desktop_ui.cpp b/app_imgui/desktop_ui.cpp index b1c934cf..4dbc7f69 100644 --- a/app_imgui/desktop_ui.cpp +++ b/app_imgui/desktop_ui.cpp @@ -4,6 +4,7 @@ #include "desktop_app.h" #include "ui/tools_tabs.h" #include "ui/address_resolver.h" +#include "../core/version.h" #include @@ -664,6 +665,28 @@ bool AttachProcessId(AppState& app, uint64_t pid, const std::string& verb) { return false; } +// The external debugger lives in this process, so agents see its +// breakpoints, hit counters and paused threads only through the shared +// investigation context. At most once a second, only with a connected +// runtime, and only what changed (InvestigationContextModel::Share). +void ShareDebuggerState(AppState& app) { + static std::chrono::steady_clock::time_point last; + const auto now = std::chrono::steady_clock::now(); + if (now - last < std::chrono::seconds(1)) return; + last = now; + if (!app.payload.Ready()) return; + const auto target = app.sessions.ActiveTarget(); + if (!target) return; + const auto& breakpoints = app.debuggerModel.Breakpoints(); + const auto& paused = app.debuggerModel.PausedThreads(); + if (breakpoints.empty() && paused.empty() && app.debuggerModel.ProviderBackend().empty()) return; + std::string error; + app.contextModel.Share("debugger", + cortex::application::InvestigationContextModel::DebuggerSummary( + app.debuggerModel.ProviderBackend(), breakpoints, paused), + target->generation, &error); +} + void TogglePauseTarget(AppState& app) { const auto session = app.sessions.Active(); if (!session) { @@ -1045,6 +1068,7 @@ void DrawApp(AppState& app) { // on; the desktop is the authority for its own calls. app.payload.SetWritesAuthority(app.ui.mutationAllowed); app.debuggerModel.SetTargetPaused(app.ui.targetPaused); + ShareDebuggerState(app); } const ImGuiViewport* viewport = ImGui::GetMainViewport(); @@ -1188,7 +1212,7 @@ void DrawApp(AppState& app) { ImGui::EndMenu(); } if (ImGui::BeginMenu("Help")) { - ImGui::TextDisabled("Cortex v1.0.1"); + ImGui::TextDisabled("Cortex v%s", cortex::kVersion); ImGui::Separator(); if (ImGui::MenuItem("Diagnostics")) app.workspaces.Select("diagnostics"); diff --git a/app_imgui/smoke_modes.cpp b/app_imgui/smoke_modes.cpp index 1b7a30c3..c891d642 100644 --- a/app_imgui/smoke_modes.cpp +++ b/app_imgui/smoke_modes.cpp @@ -3,6 +3,12 @@ #include "desktop_app.h" #include "ui/address_resolver.h" +#include "../core/artifacts/pe_provider.h" +#include "ui/address_context_menu.h" +#include "ui/code_navigation.h" +#include "ui/desktop_stores.h" +#include "../core/providers/process_runner.h" +#include "../mcp_bridge/evidence_tools.h" #include "services/assembler.h" #include "services/auto_assembler.h" @@ -13,7 +19,12 @@ #include "ui/auto_assembler_host.h" #include "process/remote_memory.h" +#include +#include #include +#include +#include +#include namespace cortex::desktop { @@ -277,7 +288,7 @@ constexpr const char* kGuiRequiredWorkspaces[] = { "symbols", "structures", "pointermaps", "snapshots", "re", "instrumentation", "watches", "actions", "network", "diagnostics", "scripts", "input", "screenshots", "patches", "events", "trace", "tools", - "access-finder", "lua" + "access-finder", "lua", "artifacts", "ai-investigation" }; @@ -390,6 +401,304 @@ bool ExerciseEveryWorkspace(AppState& app, std::string& error) { return true; } +// A real inspection through the Artifacts workspace: the suite's own +// executable, which is a PE the providers must identify. +bool ExerciseArtifactsWorkspace(AppState& app, std::string& error) { + std::wstring executable(32768, L'\0'); + const DWORD length = GetModuleFileNameW(nullptr, executable.data(), static_cast(executable.size())); + if (!length || length >= executable.size()) { + error = "module_path_failed"; + return false; + } + executable.resize(length); + app.workspaces.CloseAll(); + app.workspaces.Select("artifacts"); + app.ui.commands.push_back("inspect_artifact " + std::filesystem::path(executable).u8string()); + // Headless: the background runner is absent, so the inspection is inline. + if (!RenderGuiStable(app, error, true)) { + error = "artifacts_render:" + error; + return false; + } + if (app.ui.status.rfind("Inspected ", 0) != 0 || app.ui.status.find("(pe)") == std::string::npos) { + error = "artifacts_inspect:" + app.ui.status; + return false; + } + if (!RenderGuiStable(app, error, true)) return false; + + // Code tab: the catalog of the system's kernel32.dll (small enough for a + // Debug build to analyse quickly, unlike cortex.exe itself), its entry + // point's CFG, and a name recorded then removed. + { + wchar_t system[MAX_PATH]; + const UINT systemLength = GetSystemDirectoryW(system, MAX_PATH); + const std::filesystem::path library = + std::filesystem::path(std::wstring(system, systemLength < MAX_PATH ? systemLength : 0)) / L"kernel32.dll"; + std::string peError; + cortex::artifacts::PeFile pe; + const auto self = cortex::artifacts::OpenFile(library.u8string(), &peError); + if (!self || !cortex::artifacts::ParsePe(*self, pe, peError) || !pe.entryRva) { + error = "artifacts_kernel32:" + peError; + return false; + } + app.ui.commands.push_back("inspect_artifact " + library.u8string()); + if (!RenderGuiStable(app, error, true)) return false; + char entry[24]; + std::snprintf(entry, sizeof(entry), "0x%x", pe.entryRva); + const std::vector> steps = { + {"artifact_tab Code", ""}, + {"artifact_functions", "Code analysed: "}, + {std::string("artifact_function ") + entry, "Function "}, + {std::string("artifact_rename ") + entry + " suite_entry", std::string("Renamed ") + entry + " to suite_entry"}, + {std::string("artifact_rename ") + entry, "Name removed"}, + {"artifact_record_function", "Evidence recorded: "}, + {"artifact_tab Evidence", ""}, + {"artifact_evidence", "Evidence: "}, + }; + for (const auto& [command, expected] : steps) { + app.ui.commands.push_back(command); + if (!RenderGuiStable(app, error, true)) return false; + if (!expected.empty() && app.ui.status.rfind(expected, 0) != 0) { + error = "artifacts_code:" + command + ":" + app.ui.status; + return false; + } + } + } + + // A generated script with an inline source map and an IPC call: the + // Electron and Source position tabs are drawn, and the position maps. + const auto script = std::filesystem::temp_directory_path() / + ("cortex-gui-suite-" + std::to_string(GetCurrentProcessId()) + ".js"); + { + // {"version":3,"sources":["suite.ts"],"names":[],"mappings":"AAAA;AACA","sourcesContent":["first\nsecond"]} + std::ofstream out(script, std::ios::binary); + out << "const { ipcRenderer } = require('electron');\nipcRenderer.invoke('suite:ping');\n" + "//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInN1aXRlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO0FBQ0EiLCJzb3VyY2VzQ29udGVudCI6WyJmaXJzdFxuc2Vjb25kIl19\n"; + } + app.ui.commands.push_back("inspect_artifact " + script.u8string()); + bool ok = RenderGuiStable(app, error, true) && app.ui.status.find("(javascript)") != std::string::npos; + if (!ok && error.empty()) error = "artifacts_script:" + app.ui.status; + for (const char* tab : {"Electron", "Source position", "Structure"}) { + if (!ok) break; + app.ui.commands.push_back(std::string("artifact_tab ") + tab); + ok = RenderGuiStable(app, error, true); + } + if (ok) { + app.ui.commands.push_back("artifact_position 2:5"); + ok = RenderGuiStable(app, error, true) && app.ui.status == "Mapped to suite.ts:2:1"; + if (!ok && error.empty()) error = "artifacts_position:" + app.ui.status; + } + std::error_code ignored; + std::filesystem::remove(script, ignored); + // A .NET assembly when one is given (CI passes the C# fixture): the + // .NET tab is drawn and a method's CIL is disassembled. + const char* assembly = std::getenv("CORTEX_GUI_SUITE_DOTNET"); + if (ok && assembly && *assembly) { + app.ui.commands.push_back(std::string("inspect_artifact ") + assembly); + ok = RenderGuiStable(app, error, true) && app.ui.status.find("(dotnet)") != std::string::npos; + if (!ok && error.empty()) error = "artifacts_dotnet:" + app.ui.status; + if (ok) { + app.ui.commands.push_back("artifact_tab .NET"); + app.ui.commands.push_back("artifact_cil Cortex.Fixture.Actor::Heal"); + ok = RenderGuiStable(app, error, true) && RenderGuiStable(app, error, true) && + app.ui.status == "Disassembled 1 method(s): Cortex.Fixture.Actor::Heal"; + if (!ok && error.empty()) error = "artifacts_cil:" + app.ui.status; + } + // The Engines tab with a real ILSpy (CI sets CORTEX_ILSPYCMD and + // CORTEX_GUI_SUITE_ILSPY): decompile the assembly, open one file. + const char* ilspy = std::getenv("CORTEX_GUI_SUITE_ILSPY"); + if (ok && ilspy && *ilspy) { + app.ui.commands.push_back("artifact_tab Engines"); + app.ui.commands.push_back("artifact_engine_start ilspy"); + ok = RenderGuiStable(app, error, true); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::minutes(3); + while (ok && app.ui.status.find(": completed (") == std::string::npos && + app.ui.status.find(": failed") == std::string::npos && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ok = RenderGuiFrame(app, error, false); + } + ok = ok && app.ui.status.find(": completed (") != std::string::npos; + if (!ok && error.empty()) error = "artifacts_ilspy:" + app.ui.status; + if (ok) { + app.ui.commands.push_back("artifact_engine_open Cortex.Fixture/Actor.cs"); + ok = RenderGuiStable(app, error, true) && app.ui.status == "Engine: opened Cortex.Fixture/Actor.cs"; + if (!ok && error.empty()) error = "artifacts_ilspy_open:" + app.ui.status; + } + } + } + return ok; +} + +// The AI Investigation workspace against the real stores: an investigation +// an agent created waits for approval; the person approves from the +// workspace (actor "desktop"); evidence and the process tree load. +bool ExerciseAiInvestigationWorkspace(AppState& app, std::string& error) { + using json = nlohmann::json; + auto& investigations = cortex::ui::DesktopInvestigations(); + const json created = investigations.Create({{"title", "GUI suite"}, {"question", "Which instruction writes the value?"}, + {"workflow", "memory_value"}, {"parameters", {{"value", "100"}}}}, "agent"); + const std::string id = created.value("investigation", json::object()).value("id", std::string()); + if (id.empty() || !investigations.Start(id, 1, "agent").value("ok", false) || + !investigations.StepResult({{"id", id}, {"index", 0}, {"expected_revision", 2}, {"status", "done"}, {"summary", "scan"}}, + "agent", {}).value("ok", false)) { + error = "ai_setup:" + created.dump(); + return false; + } + app.workspaces.CloseAll(); + app.workspaces.Select("ai-investigation"); + const std::vector> steps = { + {"ai_tab Investigations", ""}, + {"ai_investigations_refresh", "Investigations: "}, + {"ai_investigation_open " + id, "Investigation GUI suite: awaiting_approval"}, + {"ai_investigation_approve " + id + " 1", "Approved step 1 of " + id}, + {"ai_tab Evidence", ""}, + {"ai_evidence_refresh", "Evidence: "}, + {"ai_tab Process tree", ""}, + {"ai_process_tree " + std::to_string(GetCurrentProcessId()), "Process tree: "}, + }; + for (const auto& [command, expected] : steps) { + app.ui.commands.push_back(command); + if (!RenderGuiStable(app, error, true)) return false; + if (!expected.empty() && app.ui.status.rfind(expected, 0) != 0) { + error = "ai_workspace:" + command + ":" + app.ui.status; + return false; + } + } + const json after = investigations.Get(id)["investigation"]; + if (after["plan"][1].value("status", std::string()) != "approved" || after["plan"][1]["approval"].value("by", std::string()) != "desktop") { + error = "ai_approval_not_recorded:" + after["plan"][1].dump(); + return false; + } + investigations.Cancel(id, after["revision"].get(), "GUI suite", "desktop"); + // The Live (CDP) tab against a real node (CI sets CORTEX_GUI_SUITE_NODE_FIXTURE + // to tests/fixtures/node-live/app.js): attach, open the script, a log + // breakpoint on the health write, hits, detach. + const char* fixture = std::getenv("CORTEX_GUI_SUITE_NODE_FIXTURE"); + if (fixture && *fixture) { + // CORTEX_GUI_SUITE_INSPECT_PORT: a fixture already started elsewhere. + const char* external = std::getenv("CORTEX_GUI_SUITE_INSPECT_PORT"); + const std::string node = external ? std::string() : cortex::providers::FindOnPath("node"); + if (!external && node.empty()) { + error = "ai_live:node_not_on_path"; + return false; + } + const int port = external ? std::atoi(external) : 9700 + static_cast(GetCurrentProcessId() % 200); + std::atomic stop{false}; + std::thread runner([&] { + if (external) return; + cortex::providers::ProcessSpec spec; + spec.argv = {node, "--inspect=127.0.0.1:" + std::to_string(port), fixture}; + spec.timeout = std::chrono::minutes(3); + cortex::providers::RunProcess(spec, &stop); + }); + auto finish = [&](bool result) { + stop = true; + runner.join(); + return result; + }; + // node needs a moment to open its inspector (longer on a busy + // runner): retry the attach for up to 20 s. + app.ui.commands.push_back("ai_tab Live (CDP)"); + bool attachedOk = false; + const auto attachDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(20); + while (!attachedOk && std::chrono::steady_clock::now() < attachDeadline) { + app.ui.commands.push_back("ai_live_attach " + std::to_string(port)); + if (!RenderGuiStable(app, error, true)) return finish(false); + attachedOk = app.ui.status.rfind("Live: attached", 0) == 0; + if (!attachedOk) std::this_thread::sleep_for(std::chrono::milliseconds(250)); + } + if (!attachedOk) { + if (error.empty()) error = "ai_live_attach:" + app.ui.status; + return finish(false); + } + std::string url; + int line = 0; + for (int attempt = 0; attempt < 50 && url.empty(); ++attempt) { + const json sessions = cortex::ui::DesktopLive().Sessions(); + for (const auto& row : sessions["sessions"]) { + auto session = cortex::ui::DesktopLive().Get(row.value("session_id", std::string())); + const json scripts = session ? session->Scripts("app.js", 0, 5) : json(); + if (session && scripts.value("total", 0) == 1) url = scripts["scripts"][0].value("url", std::string()); + } + if (url.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + { + std::ifstream source(fixture); + std::string text; + for (int n = 1; std::getline(source, text); ++n) + if (text.find("CORTEX_HEALTH_WRITE") != std::string::npos) line = n; + } + const bool writes = app.ui.mutationAllowed; + app.ui.mutationAllowed = true; + app.ui.commands.push_back("ai_live_script " + url); + app.ui.commands.push_back("ai_live_breakpoint " + std::to_string(line)); + bool ok = RenderGuiStable(app, error, true) && app.ui.status.rfind("Live: breakpoint 1 at ", 0) == 0; + if (!ok && error.empty()) error = "ai_live_breakpoint:" + app.ui.status + " url=" + url; + for (int attempt = 0; ok && attempt < 100; ++attempt) { + app.ui.commands.push_back("ai_live_hits"); + ok = RenderGuiFrame(app, error, false); + if (app.ui.status.rfind("Live: ", 0) == 0 && std::atoi(app.ui.status.c_str() + 6) >= 3) break; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + if (ok && std::atoi(app.ui.status.c_str() + 6) < 3) { + ok = false; + error = "ai_live_hits:" + app.ui.status; + } + app.ui.commands.push_back("ai_live_detach"); + ok = ok && RenderGuiStable(app, error, true) && app.ui.status == "Live: detached"; + if (!ok && error.empty()) error = "ai_live_detach:" + app.ui.status; + app.ui.mutationAllowed = writes; + return finish(ok); + } + return true; +} + +// Live <-> static by module+RVA on an attached target: the address menu's +// static view of the module that holds `address`, back to the same live +// address, and evidence recorded on that location shown live. +bool ExerciseLiveStaticNavigation(AppState& app, uint64_t address, std::string& error) { + using json = nlohmann::json; + const auto module = cortex::ui::ModuleAt(app.ui, address); + if (!module) { + error = "navigation_no_module_for_address"; + return false; + } + cortex::ui::OpenStaticCode(app.ui, *module, address); + if (!RenderGuiStable(app, error, true) || !RenderGuiStable(app, error, true)) return false; + if (app.ui.status.rfind("Opened ", 0) != 0) { + error = "navigation_static:" + app.ui.status; + return false; + } + app.ui.commands.push_back("artifact_show_live"); + if (!RenderGuiStable(app, error, true)) return false; + // The disassembly view takes over the status line once it draws; the + // navigation itself is what is checked. + if (app.ui.navigationCurrent.workspace != "disassembly" || app.ui.navigationCurrent.address != address) { + error = "navigation_live:" + app.ui.status; + return false; + } + // Evidence on that module+RVA, then Live from the AI workspace. + char rva[24]; + std::snprintf(rva, sizeof(rva), "0x%llx", static_cast(address - module->base)); + const json recorded = mcp_bridge::evidence::Handle( + "cortex_evidence_record", + {{"statement", "GUI suite: the target was executing here"}, {"class", "hypothesis"}, {"origin", "observed"}, + {"subject", {{"path", module->path}, {"location", std::string("rva:") + rva}}}, {"mutation_permission", true}}, + &cortex::ui::DesktopEvidence(), {std::filesystem::u8path(module->path).parent_path()}, nullptr, "desktop"); + const std::string id = recorded.value("record", json::object()).value("id", std::string()); + if (id.empty()) { + error = "navigation_evidence:" + recorded.dump(); + return false; + } + app.ui.NavigateTo("memory-browser", 0x1000); + app.ui.commands.push_back("ai_evidence_live " + id); + if (!RenderGuiStable(app, error, true)) return false; + if (app.ui.navigationCurrent.workspace != "disassembly" || app.ui.navigationCurrent.address != address) { + error = "navigation_evidence_live:" + app.ui.status; + return false; + } + return true; +} + bool ExerciseNavigationContract(AppState& app, std::string& error) { app.ui.ResetNavigation(); app.ui.NavigateTo("memory-browser", 0x1000); @@ -520,20 +829,55 @@ bool ExerciseMemoryTools(AppState& app, uint64_t codeAddress, std::string& summa app.ui.mutationAllowed = true; app.ui.accessFinderRequests.push_back({frame, 4, true}); app.workspaces.Select("access-finder"); + // Two phases with time budgets rather than a fixed number of frames: + // on a loaded runner the debugger can take seconds to apply the + // breakpoint, which used to use up the whole wait. The test still + // requires at least one recorded write once it is applied. size_t hits = 0; - for (int attempt = 0; attempt < 80 && hits == 0; ++attempt) { + const DebugBreakpointInfo* installed = nullptr; + auto findBreakpoint = [&]() -> const DebugBreakpointInfo* { + for (const auto& breakpoint : app.debuggerModel.Breakpoints()) + if (breakpoint.address == frame && breakpoint.kind == "hw_write") return &breakpoint; + return nullptr; + }; + const auto installStart = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - installStart < std::chrono::seconds(30)) { if (!RenderGuiStable(app, error, true)) return false; - Sleep(150); + installed = findBreakpoint(); + if (installed && installed->appliedThreads > 0) break; + Sleep(100); + } + const auto installMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - installStart).count(); + if (!installed || installed->appliedThreads == 0) { + error = "access_finder_breakpoint_not_applied: present=" + std::string(installed ? "yes" : "no") + + " applied_threads=" + std::to_string(installed ? installed->appliedThreads : 0) + "/" + + std::to_string(installed ? installed->totalThreads : 0) + " waited_ms=" + std::to_string(installMs); for (const auto& breakpoint : app.debuggerModel.Breakpoints()) - if (breakpoint.address == frame && breakpoint.kind == "hw_write") hits = breakpoint.hitCount; + if (breakpoint.address == frame) app.debuggerModel.RemoveBreakpoint(breakpoint.id, nullptr); + app.ui.mutationAllowed = false; + return false; + } + size_t logEntries = 0; + uint64_t hitCount = 0; + const auto hitStart = std::chrono::steady_clock::now(); + while (hits == 0 && std::chrono::steady_clock::now() - hitStart < std::chrono::seconds(20)) { + if (!RenderGuiStable(app, error, true)) return false; + Sleep(150); + if (const auto* breakpoint = findBreakpoint()) hitCount = breakpoint->hitCount; + hits = static_cast(hitCount); std::vector entries; for (const auto& breakpoint : app.debuggerModel.Breakpoints()) { if (breakpoint.address != frame) continue; std::string logError; - if (app.debuggerModel.LoadBreakpointLog(breakpoint.id, 0, 16, entries, &logError)) + if (app.debuggerModel.LoadBreakpointLog(breakpoint.id, 0, 16, entries, &logError)) { + logEntries = entries.size(); hits = std::max(hits, entries.size()); + } } } + const auto hitMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - hitStart).count(); for (const auto& breakpoint : app.debuggerModel.Breakpoints()) if (breakpoint.address == frame) app.debuggerModel.RemoveBreakpoint(breakpoint.id, nullptr); // Hits still queued for the removed breakpoint must not reach the @@ -545,7 +889,9 @@ bool ExerciseMemoryTools(AppState& app, uint64_t codeAddress, std::string& summa return false; } if (hits == 0) { - error = "access_finder_recorded_no_write"; + error = "access_finder_recorded_no_write: hit_count=" + std::to_string(hitCount) + + " log_entries=" + std::to_string(logEntries) + " applied_after_ms=" + std::to_string(installMs) + + " waited_ms=" + std::to_string(hitMs); return false; } summary += " frame_writes=" + std::to_string(hits); @@ -1035,6 +1381,14 @@ int RunGuiWorkspaceSuite() { std::fprintf(stderr, "gui workspace suite: %s\n", error.c_str()); return 5; } + if (!ExerciseArtifactsWorkspace(app, error)) { + std::fprintf(stderr, "gui workspace suite: %s\n", error.c_str()); + return 6; + } + if (!ExerciseAiInvestigationWorkspace(app, error)) { + std::fprintf(stderr, "gui workspace suite: %s\n", error.c_str()); + return 7; + } std::printf( "PASS: GUI workspace suite %zu workspaces / %zu presets / docking / navigation\n", @@ -1104,6 +1458,10 @@ int RunGuiAttachedSuite(const std::vector& args) { std::fprintf(stderr, "gui attached suite: workspace render failed: %s\n", error.c_str()); return 9; } + if (!ExerciseLiveStaticNavigation(app, address, error)) { + std::fprintf(stderr, "gui attached suite: live/static navigation failed: %s\n", error.c_str()); + return 13; + } // Give the panel-local live refresh loops enough time to execute at least // once while Debug/Mem/Disassembly are visible. diff --git a/app_imgui/ui/address_context_menu.h b/app_imgui/ui/address_context_menu.h index 87877db2..2fad10d9 100644 --- a/app_imgui/ui/address_context_menu.h +++ b/app_imgui/ui/address_context_menu.h @@ -1,5 +1,6 @@ #pragma once +#include "code_navigation.h" #include "ui_context.h" #include @@ -65,6 +66,14 @@ inline void RuntimeDisabledHint(bool disabled, const std::string& reason) { } } +// Opens the module's file in the Artifacts workspace at address - base. +inline void OpenStaticCode(UiContext& context, const LiveModule& module, uint64_t address) { + char rva[24]; + std::snprintf(rva, sizeof(rva), "0x%llx", static_cast(address - module.base)); + context.commands.push_back("artifact_open " + module.path + "|" + rva); + context.requestWorkspace = "artifacts"; +} + inline void DrawAddressContextActions( UiContext& context, uint64_t address, const AddressContextOptions& options = {}) { @@ -90,6 +99,14 @@ inline void DrawAddressContextActions( context.NavigateTo("pointermaps", address); if (ImGui::MenuItem("Structures")) context.NavigateTo("structures", address); + // module+RVA in the module's file on disk: offline catalog, CFG, + // references, annotations and evidence. + const auto module = ModuleAt(context, address); + ImGui::BeginDisabled(!module); + if (ImGui::MenuItem("Open in Artifacts (static code)") && module) OpenStaticCode(context, *module, address); + ImGui::EndDisabled(); + if (!module && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("The address is not inside a loaded module with a file on disk."); ImGui::Separator(); if (ImGui::MenuItem("Pointer scan for this address")) { context.toolsTabRequest = "pointers"; @@ -102,6 +119,26 @@ inline void DrawAddressContextActions( ImGui::EndMenu(); } + // The person points an agent at this address: it becomes the desktop's + // current selection in the investigation context MCP clients read + // (context_get). It records Cortex's own state only, so it needs a + // connected runtime but not Writes allowed, and never loads the runtime. + { + const bool shareDisabled = !context.contextModel || !runtimeConnected; + ImGui::BeginDisabled(shareDisabled); + if (ImGui::MenuItem("Share with investigation context")) { + const auto target = context.sessions ? context.sessions->ActiveTarget() : std::nullopt; + std::string error; + if (context.contextModel->SelectAddress(address, label, {}, target ? target->generation : 0, &error)) + context.status = "Shared " + addressText + " with the investigation context"; + else + context.status = "Share failed: " + error; + } + ImGui::EndDisabled(); + if (shareDisabled && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("Connect the Cortex runtime first (Project > Connect existing runtime)."); + } + if (ImGui::BeginMenu("Monitor")) { const bool watchDisabled = !context.watchesModel || !runtimeMutation; diff --git a/app_imgui/ui/ai_investigation_workspace.cpp b/app_imgui/ui/ai_investigation_workspace.cpp new file mode 100644 index 00000000..863c3d45 --- /dev/null +++ b/app_imgui/ui/ai_investigation_workspace.cpp @@ -0,0 +1,551 @@ +#include "ai_investigation_workspace.h" + +#include "address_context_menu.h" +#include "code_navigation.h" +#include "desktop_stores.h" +#include "widgets.h" + +#include "../../core/target/process_tree.h" + +#include + +#include +#include +#include +#include + +namespace cortex::ui { +namespace { + +using json = nlohmann::json; + +ImVec4 StateColor(const std::string& state) { + if (state == "completed") return ImVec4(0.35f, 0.78f, 0.45f, 1.0f); + if (state == "awaiting_approval") return ImVec4(0.95f, 0.75f, 0.3f, 1.0f); + if (state == "failed" || state == "cancelled") return ImVec4(0.9f, 0.4f, 0.35f, 1.0f); + return ImGui::GetStyleColorVec4(ImGuiCol_Text); +} + +uint64_t ParseRva(const std::string& location) { + if (location.rfind("rva:0x", 0) != 0) return 0; + try { + return std::stoull(location.substr(6), nullptr, 16); + } catch (...) { + return 0; + } +} + +} // namespace + +int AiInvestigationWorkspace::TabFlags(const char* name) const { + return selectTab_ == name ? ImGuiTabItemFlags_SetSelected : ImGuiTabItemFlags_None; +} + +void AiInvestigationWorkspace::HandleCommand(UiContext& context, const std::string& command) { + auto argument = [&](const char* prefix) { return command.substr(std::string(prefix).size()); }; + if (command == "ai_investigations_refresh") Refresh(context); + else if (command.rfind("ai_investigation_open ", 0) == 0) Open(context, argument("ai_investigation_open ")); + else if (command.rfind("ai_investigation_approve ", 0) == 0 || command.rfind("ai_investigation_reject ", 0) == 0) { + const bool approve = command.rfind("ai_investigation_approve ", 0) == 0; + const std::string rest = argument(approve ? "ai_investigation_approve " : "ai_investigation_reject "); + const size_t space = rest.find(' '); + if (space != std::string::npos) Decide(context, rest.substr(0, space), std::strtoul(rest.c_str() + space + 1, nullptr, 10), approve); + } else if (command == "ai_evidence_refresh") RefreshEvidence(context); + else if (command.rfind("ai_evidence_live ", 0) == 0 || command.rfind("ai_evidence_static ", 0) == 0) { + const bool live = command.rfind("ai_evidence_live ", 0) == 0; + const json found = DesktopEvidence().Get(argument(live ? "ai_evidence_live " : "ai_evidence_static ")); + if (!found.value("ok", false)) context.status = "Evidence not found"; + else if (live) EvidenceLive(context, found["record"]); + else EvidenceStatic(context, found["record"]); + } else if (command.rfind("ai_process_tree ", 0) == 0) LoadProcessTree(context, std::strtoull(argument("ai_process_tree ").c_str(), nullptr, 10)); + else if (command.rfind("ai_tab ", 0) == 0) selectTab_ = argument("ai_tab "); + else if (command.rfind("ai_live_attach ", 0) == 0) LiveAttach(context, std::atoi(argument("ai_live_attach ").c_str())); + else if (command.rfind("ai_live_attach_dap ", 0) == 0) { + std::istringstream in(argument("ai_live_attach_dap ")); + int port = 0, pid = 0; + std::string adapter; + in >> port >> adapter >> pid; + LiveAttachDap(context, port, adapter, pid); + } else if (command.rfind("ai_live_script ", 0) == 0) LiveOpenScript(context, argument("ai_live_script ")); + else if (command.rfind("ai_live_breakpoint ", 0) == 0) LiveBreakpoint(context, std::atoi(argument("ai_live_breakpoint ").c_str())); + else if (command == "ai_live_hits") { + auto session = DesktopLive().Get(liveSession_); + if (session && liveBreakpoint_) { + liveHits_ = session->Hits(liveBreakpoint_, 0, 200); + context.status = "Live: " + std::to_string(liveHits_.value("hits", 0)) + " hit(s)"; + } + } else if (command == "ai_live_detach") LiveDetach(context); +} + +void AiInvestigationWorkspace::Draw(UiContext& context) { + HintText("Agents work through MCP; this is where you follow them, approve or reject what touches the target, " + "and check what they recorded. No AI runs inside Cortex."); + if (ImGui::BeginTabBar("AiTabs")) { + if (ImGui::BeginTabItem("Investigations", nullptr, TabFlags("Investigations"))) { + DrawInvestigations(context); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Evidence", nullptr, TabFlags("Evidence"))) { + DrawEvidence(context); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Process tree", nullptr, TabFlags("Process tree"))) { + DrawProcessTree(context); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Live (CDP)", nullptr, TabFlags("Live (CDP)"))) { + DrawLive(context); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + selectTab_.clear(); + } +} + +void AiInvestigationWorkspace::Refresh(UiContext& context) { + investigations_ = DesktopInvestigations().List({}, 200); + size_t waiting = 0; + for (const auto& row : investigations_.value("investigations", json::array())) + if (row.value("state", std::string()) == "awaiting_approval") ++waiting; + context.status = "Investigations: " + std::to_string(investigations_.value("investigations", json::array()).size()) + + ", " + std::to_string(waiting) + " awaiting approval"; +} + +void AiInvestigationWorkspace::Open(UiContext& context, const std::string& id) { + const json found = DesktopInvestigations().Get(id); + selected_ = found.value("ok", false) ? found["investigation"] : json(nullptr); + context.status = selected_.is_object() ? "Investigation " + selected_.value("title", std::string()) + ": " + + selected_.value("state", std::string()) + : "Investigation not found"; +} + +void AiInvestigationWorkspace::Decide(UiContext& context, const std::string& id, size_t step, bool approve) { + // The person's decision: recorded with the actor "desktop". + const json result = DesktopInvestigations().Approve(id, step, approve, note_.data(), "desktop"); + if (result.value("ok", false)) { + selected_ = result["investigation"]; + note_.fill(0); + context.status = std::string(approve ? "Approved" : "Rejected") + " step " + std::to_string(step) + " of " + id; + Refresh(context); + context.status = std::string(approve ? "Approved" : "Rejected") + " step " + std::to_string(step) + " of " + id; + } else { + context.status = "Decision refused: " + result.value("error", std::string()) + " " + result.value("reason", std::string()); + } +} + +void AiInvestigationWorkspace::DrawInvestigations(UiContext& context) { + if (ImGui::Button("Refresh")) Refresh(context); + if (!investigations_.is_object()) { + ImGui::SameLine(); + ImGui::TextDisabled("Refresh to read the investigations agents created."); + return; + } + const float listWidth = std::max(Px(320), ImGui::GetContentRegionAvail().x * 0.38f); + ImGui::BeginChild("InvestigationList", ImVec2(listWidth, 0), ImGuiChildFlags_Borders); + for (const auto& row : investigations_.value("investigations", json::array())) { + const std::string id = row.value("id", std::string()); + ImGui::PushID(id.c_str()); + const bool open = selected_.is_object() && selected_.value("id", std::string()) == id; + if (ImGui::Selectable(row.value("title", std::string()).c_str(), open)) Open(context, id); + ImGui::TextColored(StateColor(row.value("state", std::string())), " %s", row.value("state", std::string()).c_str()); + ImGui::SameLine(); + ImGui::TextDisabled("%zu/%zu steps %s", row.value("steps_finished", size_t{0}), row.value("steps", size_t{0}), + row.value("workflow", std::string()).c_str()); + ImGui::PopID(); + } + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("InvestigationDetail", ImVec2(0, 0), ImGuiChildFlags_Borders); + if (!selected_.is_object()) { + ImGui::TextDisabled("Select an investigation."); + ImGui::EndChild(); + return; + } + const std::string id = selected_.value("id", std::string()); + ImGui::TextWrapped("%s", selected_.value("question", std::string()).c_str()); + ImGui::TextColored(StateColor(selected_.value("state", std::string())), "%s", selected_.value("state", std::string()).c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("Copy report")) + ImGui::SetClipboardText(DesktopInvestigations().Report(id).value("document", std::string()).c_str()); + ImGui::SetNextItemWidth(Px(320)); + ImGui::InputTextWithHint("##DecisionNote", "note for your decision (optional)", note_.data(), note_.size()); + if (BeginDataTable("InvestigationPlan", 6, 0, ImVec2(0, 0), 200.0f)) { + ImGui::TableSetupColumn("#", ImGuiTableColumnFlags_WidthFixed, Px(24)); + ImGui::TableSetupColumn("Step", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Tool", ImGuiTableColumnFlags_WidthFixed, Px(170)); + ImGui::TableSetupColumn("Capability / effect", ImGuiTableColumnFlags_WidthFixed, Px(150)); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, Px(130)); + ImGui::TableSetupColumn("Decision", ImGuiTableColumnFlags_WidthFixed, Px(140)); + ImGui::TableHeadersRow(); + for (const auto& step : selected_.value("plan", json::array())) { + const size_t index = step.value("index", size_t{0}); + ImGui::PushID(static_cast(index)); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::Text("%zu", index); + ImGui::TableSetColumnIndex(1); + ImGui::TextWrapped("%s", step.value("title", std::string()).c_str()); + if (step.contains("hop")) ImGui::TextDisabled("%s", step.value("hop", std::string()).c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(step.value("tool", std::string()).c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::Text("%s / %s", step.value("capability", std::string()).c_str(), step.value("effect", std::string()).c_str()); + ImGui::TableSetColumnIndex(4); + ImGui::TextUnformatted(step.value("status", std::string()).c_str()); + ImGui::TableSetColumnIndex(5); + if (step.value("status", std::string()) == "awaiting_approval") { + if (ImGui::SmallButton("Approve")) Decide(context, id, index, true); + ImGui::SameLine(); + if (ImGui::SmallButton("Reject")) Decide(context, id, index, false); + } else if (step.contains("approval")) { + ImGui::TextDisabled("%s by %s", step["approval"].value("approved", false) ? "approved" : "rejected", + step["approval"].value("by", std::string()).c_str()); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + HintText("Approving lets the agent record that step's result. The tool's own gates still apply " + "(--allow-writes, launch profiles)."); + if (ImGui::CollapsingHeader("Journal")) { + for (const auto& entry : selected_.value("journal", json::array())) + ImGui::BulletText("%s by %s", entry.value("event", std::string()).c_str(), entry.value("actor", std::string()).c_str()); + } + if (ImGui::CollapsingHeader("Evidence cited")) { + for (const auto& evidenceId : selected_.value("evidence", json::array())) { + const json record = DesktopEvidence().Get(evidenceId.get()); + ImGui::BulletText("%s %s", evidenceId.get().c_str(), + record.value("ok", false) ? record["record"].value("statement", std::string()).c_str() : "(missing)"); + } + } + ImGui::EndChild(); +} + +void AiInvestigationWorkspace::RefreshEvidence(UiContext& context) { + evidence_ = DesktopEvidence().List(json::object(), 1000); + context.status = "Evidence: " + std::to_string(evidence_.value("matched", size_t{0})) + " record(s)"; +} + +void AiInvestigationWorkspace::EvidenceLive(UiContext& context, const json& record) { + const json subject = record.value("subject", json::object()); + const uint64_t rva = ParseRva(subject.value("location", std::string())); + const auto module = ModuleForSha256(context, subject.value("sha256", std::string())); + if (!rva || !module) { + context.status = "Live: no loaded module has this evidence's file (SHA-256), or no RVA"; + return; + } + context.NavigateTo("disassembly", module->base + rva); + char text[96]; + std::snprintf(text, sizeof(text), "Live %s+0x%llx = 0x%llx", module->name.c_str(), static_cast(rva), + static_cast(module->base + rva)); + context.status = text; +} + +void AiInvestigationWorkspace::EvidenceStatic(UiContext& context, const json& record) { + const json subject = record.value("subject", json::object()); + const uint64_t rva = ParseRva(subject.value("location", std::string())); + // The file on disk comes from a loaded module with the same SHA-256. + const auto module = ModuleForSha256(context, subject.value("sha256", std::string())); + if (!rva || !module) { + context.status = "Static: the file is not known on this machine (no loaded module with its SHA-256), or no RVA"; + return; + } + OpenStaticCode(context, *module, module->base + rva); + context.status = "Opening " + module->path + " at " + subject.value("location", std::string()); +} + +void AiInvestigationWorkspace::DrawEvidence(UiContext& context) { + if (ImGui::Button("Refresh")) RefreshEvidence(context); + ImGui::SameLine(); + ImGui::SetNextItemWidth(Px(260)); + ImGui::InputTextWithHint("##EvidenceFilter", "filter statements", evidenceFilter_.data(), evidenceFilter_.size()); + if (!evidence_.is_object()) return; + HintText("Live and Static are enabled only through a loaded module whose file has the record's SHA-256."); + const std::string filter = evidenceFilter_.data(); + if (!BeginDataTable("AiEvidence", 5, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) return; + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, Px(150)); + ImGui::TableSetupColumn("Statement", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Location", ImGuiTableColumnFlags_WidthFixed, Px(160)); + ImGui::TableSetupColumn("Check", ImGuiTableColumnFlags_WidthFixed, Px(120)); + ImGui::TableSetupColumn("Go", ImGuiTableColumnFlags_WidthFixed, Px(110)); + ImGui::TableHeadersRow(); + const bool live = context.sessions && context.sessions->ActiveTarget(); + for (const auto& record : evidence_.value("records", json::array())) { + const std::string statement = record.value("statement", std::string()); + if (!filter.empty() && statement.find(filter) == std::string::npos) continue; + ImGui::PushID(record.value("id", std::string()).c_str()); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(record.value("class", std::string()).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextWrapped("%s", statement.c_str()); + ImGui::TableSetColumnIndex(2); + const std::string location = record["subject"].value("location", std::string()); + ImGui::TextUnformatted(location.empty() ? "(artifact)" : location.c_str()); + ImGui::TableSetColumnIndex(3); + const std::string status = record.value("verification", json::object()).value("status", std::string("not_checked")); + if (record.value("stale", false)) ImGui::TextColored(ImVec4(0.9f, 0.6f, 0.3f, 1.0f), "STALE"); + else ImGui::TextDisabled("%s", status.c_str()); + ImGui::TableSetColumnIndex(4); + ImGui::BeginDisabled(!live || !ParseRva(location)); + if (ImGui::SmallButton("Live")) EvidenceLive(context, record); + ImGui::SameLine(); + if (ImGui::SmallButton("Static")) EvidenceStatic(context, record); + ImGui::EndDisabled(); + ImGui::PopID(); + } + ImGui::EndTable(); +} + +void AiInvestigationWorkspace::LoadProcessTree(UiContext& context, uint64_t pid) { + tree_ = cortex::target::ProcessTreeJson(cortex::target::SnapshotProcesses(), pid); + const size_t count = tree_.value("processes", json::array()).size(); + context.status = tree_.value("ok", false) + ? "Process tree: " + std::to_string(count) + " process(es)" + (tree_.value("electron", false) ? ", Electron/Chromium" : "") + : "Process tree: " + tree_.value("error", std::string()); +} + +void AiInvestigationWorkspace::DrawProcessTree(UiContext& context) { + if (treePid_ == 0 && context.sessions) + if (const auto target = context.sessions->ActiveTarget()) treePid_ = static_cast(target->processId); + ImGui::SetNextItemWidth(Px(120)); + ImGui::InputInt("PID", &treePid_); + ImGui::SameLine(); + if (ImGui::Button("Show tree") && treePid_ > 0) LoadProcessTree(context, static_cast(treePid_)); + HintText("Roles come from each process's --type= switch (Electron, Chromium). Attach to the process that runs " + "the code you want: main for ipcMain handlers, a renderer for page code, the main or a utility for " + "native addons."); + if (!tree_.is_object() || !tree_.value("ok", false)) return; + if (!BeginDataTable("ProcessTree", 4, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) return; + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Process", ImGuiTableColumnFlags_WidthFixed, Px(240)); + ImGui::TableSetupColumn("PID", ImGuiTableColumnFlags_WidthFixed, Px(70)); + ImGui::TableSetupColumn("Role", ImGuiTableColumnFlags_WidthFixed, Px(220)); + ImGui::TableSetupColumn("Command line", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (const auto& process : tree_["processes"]) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::Text("%*s%s", static_cast(process.value("depth", size_t{0}) * 2), "", process.value("name", std::string()).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%llu", static_cast(process.value("pid", uint64_t{0}))); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(process.value("role", std::string()).c_str()); + ImGui::TableSetColumnIndex(3); + const json line = process.value("command_line", json()); + ImGui::TextDisabled("%s", line.is_string() ? line.get().c_str() : "(not readable)"); + } + ImGui::EndTable(); +} + +} // namespace cortex::ui + +namespace cortex::ui { + +void AiInvestigationWorkspace::LiveAttach(UiContext& context, int port) { + if (port <= 0 || port > 65535) { + context.status = "Live: invalid port"; + return; + } + if (!liveSession_.empty()) DesktopLive().Detach(liveSession_); + liveSession_.clear(); + liveScripts_ = liveSource_ = liveHits_ = nullptr; + liveBreakpoint_ = 0; + const json attached = DesktopLive().Attach(static_cast(port), ""); + if (!attached.value("ok", false)) { + context.status = "Live: attach failed: " + attached.value("error", std::string()); + return; + } + livePort_ = port; + liveSession_ = attached.value("session_id", std::string()); + liveStatus_ = attached; + const json target = attached.contains("target") && attached["target"].is_object() ? attached["target"] : json::object(); + context.status = "Live: attached " + liveSession_ + " (" + target.value("type", std::string()) + " " + + target.value("title", std::string()) + ")"; +} + +void AiInvestigationWorkspace::LiveAttachDap(UiContext& context, int port, const std::string& adapter, int pid) { + // netcoredbg with port 0: Cortex starts it in stdio mode (CORTEX_NETCOREDBG). + const bool stdio = adapter == "netcoredbg" && port == 0; + if (!stdio && (port <= 0 || port > 65535)) { + context.status = "Live: invalid port"; + return; + } + // Attaching a managed debugger to a process follows the write permission. + if (adapter == "netcoredbg" && !context.mutationAllowed) { + context.status = "Live: attaching netcoredbg to a process needs write permission"; + return; + } + if (!liveSession_.empty()) DesktopLive().Detach(liveSession_); + liveSession_.clear(); + liveScripts_ = liveSource_ = liveHits_ = nullptr; + liveBreakpoint_ = 0; + if (stdio && DesktopLive().Netcoredbg().empty()) + if (const char* path = std::getenv("CORTEX_NETCOREDBG")) DesktopLive().SetNetcoredbg(path); + const json attached = stdio ? DesktopLive().AttachNetcoredbgStdio(static_cast(std::max(0, pid))) + : adapter == "jdwp" ? DesktopLive().AttachJdwp(static_cast(port)) + : DesktopLive().AttachDap(static_cast(port), adapter, static_cast(std::max(0, pid))); + if (!attached.value("ok", false)) { + context.status = "Live: attach failed: " + attached.value("error", std::string()); + return; + } + livePort_ = port; + liveSession_ = attached.value("session_id", std::string()); + liveStatus_ = attached; + context.status = "Live: attached " + liveSession_ + " (" + adapter + ")"; +} + +void AiInvestigationWorkspace::LiveOpenScript(UiContext& context, const std::string& url) { + auto session = DesktopLive().Get(liveSession_); + if (!session) { + context.status = "Live: not attached"; + return; + } + liveSource_ = session->Source(url, 1, 20000); + // DAP: a file is readable once the debugger reported it in a stack; + // before that the path is still usable for a breakpoint. + if ((session->Protocol() == "dap" || session->Protocol() == "jdwp") && !liveSource_.value("ok", false)) + liveSource_ = {{"ok", true}, {"url", url}, {"text", "(the source is shown once the debugger reports this file in a stack)"}}; + context.status = liveSource_.value("ok", false) ? "Live: source of " + url : "Live: no script " + url; +} + +void AiInvestigationWorkspace::LiveBreakpoint(UiContext& context, int line) { + auto session = DesktopLive().Get(liveSession_); + if (!session || !liveSource_.is_object() || !liveSource_.value("ok", false)) { + context.status = "Live: open a script first"; + return; + } + // A breakpoint pauses the program briefly at each hit: it follows the + // desktop's write permission like the native debugger. + if (!context.mutationAllowed) { + context.status = "Live: breakpoints need write permission (enable writes for this target)"; + return; + } + const json bp = session->SetLogBreakpoint(liveSource_.value("url", std::string()), static_cast(std::max(1, line)), 0, 50, 8); + if (!bp.value("ok", false)) { + context.status = "Live: breakpoint failed: " + bp.value("error", std::string()); + return; + } + liveBreakpoint_ = bp.value("breakpoint", 0); + liveHits_ = nullptr; + context.status = "Live: breakpoint " + std::to_string(liveBreakpoint_) + " at " + liveSource_.value("url", std::string()) + ":" + + std::to_string(line); +} + +void AiInvestigationWorkspace::LiveDetach(UiContext& context) { + if (liveSession_.empty()) return; + DesktopLive().Detach(liveSession_); + liveSession_.clear(); + liveBreakpoint_ = 0; + context.status = "Live: detached"; +} + +void AiInvestigationWorkspace::DrawLive(UiContext& context) { + HintText("A Node.js or Electron inspector on 127.0.0.1 (node --inspect=127.0.0.1:PORT, Electron --inspect or " + "--remote-debugging-port). Cortex lists scripts and console output and sets LOG breakpoints: each hit records " + "the stack and locals, then the program resumes at once. Nothing is evaluated in the program."); + auto session = DesktopLive().Get(liveSession_); + static const char* protocols[] = {"CDP (Node.js, Electron)", "DAP: debugpy (Python)", "DAP: netcoredbg (.NET)", "JDWP (Java)"}; + ImGui::SetNextItemWidth(Px(220)); + ImGui::Combo("##LiveProtocol", &liveProtocol_, protocols, IM_ARRAYSIZE(protocols)); + ImGui::SameLine(); + ImGui::SetNextItemWidth(Px(120)); + ImGui::InputInt("Port", &livePort_); + if (liveProtocol_ == 2) { + ImGui::SameLine(); + ImGui::SetNextItemWidth(Px(120)); + ImGui::InputInt("Process id", &livePid_); + } + ImGui::SameLine(); + if (ImGui::Button(session ? "Reattach" : "Attach")) { + if (liveProtocol_ == 0) LiveAttach(context, livePort_); + else if (liveProtocol_ == 3) LiveAttachDap(context, livePort_, "jdwp", 0); + else LiveAttachDap(context, livePort_, liveProtocol_ == 1 ? "debugpy" : "netcoredbg", livePid_); + } + if (session) { + ImGui::SameLine(); + if (ImGui::Button("Detach")) { + LiveDetach(context); + return; + } + } + if (!session) return; + const double now = ImGui::GetTime(); + if (now - livePolled_ > 0.5) { + livePolled_ = now; + liveStatus_ = session->Status(); + liveScripts_ = session->Scripts(liveFilter_.data(), 0, 500); + if (liveBreakpoint_) liveHits_ = session->Hits(liveBreakpoint_, 0, 200); + } + if (!liveStatus_.is_object()) liveStatus_ = json::object(); + const json target = liveStatus_.contains("target") && liveStatus_["target"].is_object() ? liveStatus_["target"] : json::object(); + ImGui::Text("%s %s: %zu scripts, %llu console messages, %llu foreign pauses resumed%s", + target.value("type", std::string()).c_str(), target.value("title", std::string()).c_str(), + liveStatus_.value("scripts", size_t{0}), static_cast(liveStatus_.value("console_messages", uint64_t{0})), + static_cast(liveStatus_.value("foreign_pauses", uint64_t{0})), + liveStatus_.value("connected", false) ? "" : " (disconnected)"); + const float listWidth = std::max(Px(260), ImGui::GetContentRegionAvail().x * 0.3f); + ImGui::BeginChild("LiveScripts", ImVec2(listWidth, 0), ImGuiChildFlags_Borders); + if (session->Protocol() == "dap" || session->Protocol() == "jdwp") { + ImGui::TextWrapped(session->Protocol() == "dap" ? "DAP: give the source file path for a breakpoint." + : "JDWP: give a loaded class name (pkg.Class) for a breakpoint."); + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##LivePath", "source file path", livePath_.data(), livePath_.size()); + if (ImGui::Button("Use this file")) LiveOpenScript(context, livePath_.data()); + } + ImGui::SetNextItemWidth(-1); + if (session->Protocol() == "cdp") + ImGui::InputTextWithHint("##LiveFilter", "filter scripts (URL)", liveFilter_.data(), liveFilter_.size()); + const json scripts = liveScripts_.is_object() ? liveScripts_.value("scripts", json::array()) : json::array(); + for (const auto& script : scripts) { + const std::string url = script.value("url", std::string()); + if (url.empty()) continue; + const bool selected = liveSource_.is_object() && liveSource_.value("url", std::string()) == url; + if (ImGui::Selectable(url.c_str(), selected)) LiveOpenScript(context, url); + } + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("LiveDetail", ImVec2(0, 0), ImGuiChildFlags_Borders); + if (liveSource_.is_object() && liveSource_.value("ok", false)) { + ImGui::TextUnformatted(liveSource_.value("url", std::string()).c_str()); + ImGui::SetNextItemWidth(Px(100)); + ImGui::InputInt("Line", &liveLine_); + ImGui::SameLine(); + ImGui::BeginDisabled(!context.mutationAllowed); + if (ImGui::Button("Log breakpoint")) LiveBreakpoint(context, liveLine_); + ImGui::EndDisabled(); + if (!context.mutationAllowed && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("Needs write permission: a hit pauses the program briefly."); + const float sourceHeight = ImGui::GetContentRegionAvail().y * (liveBreakpoint_ ? 0.5f : 1.0f); + std::string text = liveSource_.value("text", std::string()); + ImGui::InputTextMultiline("##LiveSource", text.data(), text.size() + 1, ImVec2(-1, sourceHeight), ImGuiInputTextFlags_ReadOnly); + if (liveBreakpoint_ && liveHits_.is_object()) { + ImGui::Text("Breakpoint %d: %d hit(s)%s", liveBreakpoint_, liveHits_.value("hits", 0), + liveHits_.value("active", false) ? "" : " (removed after its hit limit)"); + ImGui::BeginChild("LiveHits", ImVec2(0, 0), ImGuiChildFlags_None); + const json records = liveHits_.value("records", json::array()); + for (const auto& record : records) { + if (!record.is_object()) continue; + const json frames = record.value("frames", json::array()); + if (frames.empty() || !frames[0].is_object()) continue; + const json& top = frames[0]; + std::string locals; + const json frameLocals = top.value("locals", json::object()); + for (const auto& [name, value] : frameLocals.items()) { + if (!value.is_object()) continue; + locals += (locals.empty() ? "" : ", ") + name + "=" + + (value.contains("value") ? value["value"].dump() : value.value("description", value.value("type", std::string()))); + } + ImGui::BulletText("#%d %s %s:%d %s", record.value("hit", 0), top.value("function", std::string()).c_str(), + top.value("url", top.value("path", std::string())).c_str(), top.value("line", 0), locals.c_str()); + } + ImGui::EndChild(); + } + } else { + ImGui::TextDisabled("Select a script."); + } + ImGui::EndChild(); +} + +} // namespace cortex::ui diff --git a/app_imgui/ui/ai_investigation_workspace.h b/app_imgui/ui/ai_investigation_workspace.h new file mode 100644 index 00000000..167028e1 --- /dev/null +++ b/app_imgui/ui/ai_investigation_workspace.h @@ -0,0 +1,74 @@ +#pragma once + +#include "workspace.h" + +#include + +#include +#include + +namespace cortex::ui { + +// The person's view of what agents do (roadmap step 7): investigations with +// their plan, approvals and journal; evidence records with navigation to +// the live process or the static code; the process tree of a +// multi-process target (Electron main/renderer/GPU/utility). The AI side +// is any MCP client; this workspace holds no API key and runs no agent. +class AiInvestigationWorkspace final : public IWorkspace { +public: + const char* Id() const override { return "ai-investigation"; } + const char* Title() const override { return "AI Investigation"; } + void Draw(UiContext& context) override; + // "ai_investigations_refresh", "ai_investigation_open ", + // "ai_investigation_approve ", "ai_investigation_reject + // ", "ai_evidence_refresh", "ai_evidence_live ", + // "ai_evidence_static ", "ai_process_tree ", "ai_tab ", + // "ai_live_attach " (CDP), "ai_live_attach_dap [pid]", + // "ai_live_script ", "ai_live_breakpoint + // ", "ai_live_hits", "ai_live_detach". + void HandleCommand(UiContext& context, const std::string& command) override; + +private: + void DrawInvestigations(UiContext& context); + void DrawEvidence(UiContext& context); + void DrawProcessTree(UiContext& context); + void Refresh(UiContext& context); + void Open(UiContext& context, const std::string& id); + void Decide(UiContext& context, const std::string& id, size_t step, bool approve); + void RefreshEvidence(UiContext& context); + void EvidenceLive(UiContext& context, const nlohmann::json& record); + void EvidenceStatic(UiContext& context, const nlohmann::json& record); + void LoadProcessTree(UiContext& context, uint64_t pid); + // Live (CDP): a Node.js/Electron inspector on 127.0.0.1. + void DrawLive(UiContext& context); + void LiveAttach(UiContext& context, int port); + void LiveAttachDap(UiContext& context, int port, const std::string& adapter, int pid); + void LiveOpenScript(UiContext& context, const std::string& url); + void LiveBreakpoint(UiContext& context, int line); + void LiveDetach(UiContext& context); + int TabFlags(const char* name) const; + + nlohmann::json investigations_; + nlohmann::json selected_; + nlohmann::json evidence_; + nlohmann::json tree_; + std::array note_ = {}; + std::array evidenceFilter_ = {}; + int treePid_ = 0; + std::string selectTab_; + std::string liveSession_; + int livePort_ = 9229; + int liveProtocol_ = 0; // 0 CDP, 1 DAP debugpy, 2 DAP netcoredbg + int livePid_ = 0; + std::array livePath_ = {}; + int liveLine_ = 1; + int liveBreakpoint_ = 0; + double livePolled_ = 0; + nlohmann::json liveStatus_; + nlohmann::json liveScripts_; + nlohmann::json liveSource_; + nlohmann::json liveHits_; + std::array liveFilter_ = {}; +}; + +} // namespace cortex::ui diff --git a/app_imgui/ui/artifacts_workspace.cpp b/app_imgui/ui/artifacts_workspace.cpp new file mode 100644 index 00000000..bd488c8d --- /dev/null +++ b/app_imgui/ui/artifacts_workspace.cpp @@ -0,0 +1,1351 @@ +#include "artifacts_workspace.h" + +#include "file_dialog.h" +#include "widgets.h" + +#include "../../mcp_bridge/analysis_tools.h" +#include "../../mcp_bridge/artifact_tools.h" +#include "../../mcp_bridge/evidence_tools.h" +#include "code_navigation.h" +#include "desktop_stores.h" +#include "../../core/artifacts/dotnet_provider.h" +#include "../../core/services/user_data.h" +#include "../../core/artifacts/js_provider.h" + +#include + +#include +#include +#include + +namespace cortex::ui { +namespace { + +using json = nlohmann::json; + +void Copy(char* destination, size_t size, const std::string& text) { + std::snprintf(destination, size, "%s", text.c_str()); +} + +std::string Scalar(const json& value) { + if (value.is_string()) return value.get(); + return value.dump(); +} + +// A bounded tree view of provider output: large lists show their first rows. +void JsonTree(const json& value, const char* label, int depth) { + constexpr size_t kRows = 200; + if (!value.is_object() && !value.is_array()) { + ImGui::TextUnformatted(label); + ImGui::SameLine(); + ImGui::TextDisabled("%s", Scalar(value).c_str()); + return; + } + const std::string title = std::string(label) + (value.is_array() ? " [" + std::to_string(value.size()) + "]" : ""); + const ImGuiTreeNodeFlags flags = depth == 0 ? ImGuiTreeNodeFlags_DefaultOpen : 0; + if (!ImGui::TreeNodeEx(title.c_str(), flags)) return; + size_t shown = 0; + if (value.is_object()) { + for (auto it = value.begin(); it != value.end() && shown < kRows; ++it, ++shown) { + ImGui::PushID(it.key().c_str()); + JsonTree(it.value(), it.key().c_str(), depth + 1); + ImGui::PopID(); + } + } else { + for (size_t i = 0; i < value.size() && shown < kRows; ++i, ++shown) { + ImGui::PushID(static_cast(i)); + const json& row = value[i]; + std::string rowLabel = "#" + std::to_string(i); + if (row.is_object()) + for (const char* key : {"name", "type", "rva"}) + if (row.contains(key) && row[key].is_string()) { rowLabel += " " + row[key].get(); break; } + JsonTree(row, rowLabel.c_str(), depth + 1); + ImGui::PopID(); + } + } + if (value.size() > kRows) ImGui::TextDisabled("... %zu more (use cortex.exe inspect for the full JSON)", value.size() - kRows); + ImGui::TreePop(); +} + +} // namespace + +namespace { + +// The inspection of a file (or a container entry) and its entries. +json InspectNow(const std::string& path, const std::string& child, json& children, std::string& name) { + std::string error; + auto source = mcp_bridge::artifacts::OpenChain(path, child, error); + if (!source) return {{"ok", false}, {"error", error}}; + name = source->Name(); + const auto& registry = cortex::artifacts::DefaultRegistry(); + json result = registry.Inspect(*source); + const auto provider = registry.Detect(*source); + if (provider && !provider->Children(*source, {}, error).empty()) children = registry.ListChildren(*source); + return result; +} + +// CFG and references of one function, with the desktop's annotations. +json FunctionDetail(const std::string& path, const std::string& chain, const std::string& location, std::string& error) { + auto source = mcp_bridge::artifacts::OpenChain(path, chain, error); + if (!source) return nullptr; + auto* store = &DesktopAnnotations(); + json detail; + detail["cfg"] = mcp_bridge::artifacts::HandleCode("cortex_artifact_cfg", {{"function", location}}, *source, store, "desktop"); + detail["xrefs"] = mcp_bridge::artifacts::HandleCode("cortex_artifact_xrefs", {{"target", location}, {"max_entries", 200}}, + *source, store, "desktop"); + detail["rva"] = detail["cfg"].value("function", location); + detail["sha256"] = cortex::artifacts::Sha256Of(*source); + detail["file_name"] = std::filesystem::u8path(path).filename().u8string(); + return detail; +} + +} // namespace + +void ArtifactsWorkspace::OpenAt(UiContext& context, const std::string& path, const std::string& location) { + Copy(path_.data(), path_.size(), path); + child_.fill(0); + context.RunInBackground("Opening " + location + " in " + path, [this, &context, path, location]() { + json children; + std::string name, error; + json result = InspectNow(path, "", children, name); + json detail = result.value("ok", false) ? FunctionDetail(path, "", location, error) : json(nullptr); + const bool ok = detail.is_object() && detail["cfg"].value("ok", false); + context.status = ok ? "Opened " + detail["cfg"].value("name", location) + " in " + name + : "Open failed: " + (result.value("ok", false) ? (detail.is_object() ? detail["cfg"].value("error", std::string()) : error) + : result.value("error", std::string())); + std::lock_guard lock(mutex_); + result_ = std::move(result); + children_ = std::move(children); + engineJob_.clear(); + engineProvider_.clear(); + engineState_ = nullptr; + engineItems_ = nullptr; + engineDetail_ = nullptr; + inspectedPath_ = path; + inspectedChild_.clear(); + position_ = nullptr; + cil_ = nullptr; + functions_ = nullptr; + evidence_ = nullptr; + codeDetail_ = ok ? std::move(detail) : json(nullptr); + selectTab_ = "Code"; + }); +} + +void ArtifactsWorkspace::Inspect(UiContext& context, const std::string& path, const std::string& child) { + if (path.empty()) { + context.status = "Enter a file path"; + return; + } + context.RunInBackground("Inspecting " + path, [this, &context, path, child]() { + json children; + std::string name; + json result = InspectNow(path, child, children, name); + std::lock_guard lock(mutex_); + result_ = std::move(result); + position_ = nullptr; + cil_ = nullptr; + functions_ = nullptr; + codeDetail_ = nullptr; + evidence_ = nullptr; + children_ = std::move(children); + engineJob_.clear(); + engineProvider_.clear(); + engineState_ = nullptr; + engineItems_ = nullptr; + engineDetail_ = nullptr; + inspectedPath_ = path; + inspectedChild_ = child; + context.status = result_.value("ok", false) + ? "Inspected " + name + " (" + result_.value("format", std::string("?")) + ")" + : "Inspect failed: " + result_.value("error", std::string("unknown")); + }); +} + +void ArtifactsWorkspace::HandleCommand(UiContext& context, const std::string& command) { + static const std::string prefix = "inspect_artifact "; + static const std::string tab = "artifact_tab "; + static const std::string position = "artifact_position "; + if (command.rfind(tab, 0) == 0) { + selectTab_ = command.substr(tab.size()); + return; + } + static const std::string cil = "artifact_cil "; + if (command == "artifact_evidence") { + LoadEvidence(context); + return; + } + if (command.rfind("artifact_engine_start ", 0) == 0) { + StartEngine(context, command.substr(22)); + return; + } + if (command == "artifact_engine_cancel") { + std::string job; + { + std::lock_guard lock(mutex_); + job = engineJob_; + } + if (!job.empty()) { + const json answer = DesktopAnalysis()->Cancel(job); + context.status = "Engine job " + job + ": " + answer.value("state", answer.value("error", std::string())); + } + return; + } + if (command.rfind("artifact_engine_open ", 0) == 0) { + OpenEngineItem(context, command.substr(21)); + return; + } + if (command == "artifact_engines_refresh") { + RefreshEngines(context); + return; + } + if (command == "artifact_functions") { + LoadFunctions(context); + return; + } + if (command.rfind("artifact_open ", 0) == 0) { + // "artifact_open |" (from the address menu) + const std::string rest = command.substr(14); + const size_t bar = rest.rfind('|'); + if (bar != std::string::npos) OpenAt(context, rest.substr(0, bar), rest.substr(bar + 1)); + return; + } + if (command == "artifact_show_live") { + json detail; + { + std::lock_guard lock(mutex_); + detail = codeDetail_; + } + if (detail.is_object()) + ShowLive(context, detail.value("sha256", std::string()), detail.value("file_name", std::string()), detail.value("rva", std::string())); + return; + } + if (command == "artifact_record_function") { + std::string rva; + { + std::lock_guard lock(mutex_); + if (codeDetail_.is_object()) rva = codeDetail_.value("rva", std::string()); + } + if (!rva.empty()) RecordFunctionEvidence(context, rva); + return; + } + if (command.rfind("artifact_function ", 0) == 0) { + OpenFunction(context, command.substr(18)); + return; + } + if (command.rfind("artifact_rename ", 0) == 0) { + const std::string rest = command.substr(16); + const size_t space = rest.find(' '); + Rename(context, rest.substr(0, space), space == std::string::npos ? std::string() : rest.substr(space + 1)); + return; + } + if (command.rfind(cil, 0) == 0) { + Disassemble(context, command.substr(cil.size())); + return; + } + if (command.rfind(position, 0) == 0) { + int line = 0, column = 0; + if (std::sscanf(command.c_str() + position.size(), "%d:%d", &line, &column) == 2 && line > 0 && column > 0) { + positionLine_ = line; + positionColumn_ = column; + LookupPosition(context); + } + return; + } + if (command.rfind(prefix, 0) != 0) return; + const std::string path = command.substr(prefix.size()); + Copy(path_.data(), path_.size(), path); + child_.fill(0); + Inspect(context, path, {}); +} + +int ArtifactsWorkspace::TabFlags(const char* name) const { + return selectTab_ == name ? ImGuiTabItemFlags_SetSelected : ImGuiTabItemFlags_None; +} + +void ArtifactsWorkspace::Draw(UiContext& context) { + ImGui::SetNextItemWidth(std::max(Px(240), ImGui::GetContentRegionAvail().x - ButtonWidth("Browse...") - + ButtonWidth("Inspect") - Px(16))); + const bool enter = ImGui::InputTextWithHint("##ArtifactPath", "file: .exe, .dll, .asar ...", path_.data(), path_.size(), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + std::string chosen; + if (ShowOpenFileDialog(L"All files\0*.*\0Executables and libraries\0*.exe;*.dll\0Electron archives\0*.asar\0", + chosen)) { + Copy(path_.data(), path_.size(), chosen); + child_.fill(0); + Inspect(context, chosen, {}); + } + } + ImGui::SameLine(); + if (ImGui::Button("Inspect") || enter) Inspect(context, path_.data(), child_.data()); + ImGui::SetNextItemWidth(std::min(Px(420), ImGui::GetContentRegionAvail().x)); + ImGui::InputTextWithHint("##ArtifactChild", "entry inside a container (nested: a.asar!b.js)", child_.data(), child_.size()); + HintText("Static analysis of files: nothing is executed or loaded. The same providers answer cortex.exe inspect " + "and the cortex_artifact_* MCP tools."); + ImGui::Separator(); + DrawResult(context); +} + +void ArtifactsWorkspace::DrawResult(UiContext& context) { + json result; + { + std::lock_guard lock(mutex_); + result = result_; + } + if (result.is_null()) { + ImGui::TextDisabled("No artifact inspected yet."); + return; + } + if (!result.value("ok", false)) { + ImGui::TextColored(ImVec4(0.9f, 0.4f, 0.35f, 1.0f), "%s", result.value("error", std::string("failed")).c_str()); + if (result.contains("identity")) JsonTree(result["identity"], "identity", 0); + return; + } + const json identity = result.value("identity", json::object()); + ImGui::Text("%s", identity.value("name", std::string()).c_str()); + ImGui::TextDisabled("%s - %llu bytes - SHA-256 %s", result.value("format", std::string()).c_str(), + static_cast(identity.value("size", uint64_t{0})), + identity.value("sha256", std::string()).c_str()); + if (ImGui::SmallButton("Copy SHA-256")) ImGui::SetClipboardText(identity.value("sha256", std::string()).c_str()); + ImGui::SameLine(); + // Hands the artifact to agents through the shared investigation context. + const bool canRecord = context.contextModel && context.payload && context.payload->Ready(); + ImGui::BeginDisabled(!canRecord); + if (ImGui::SmallButton("Record in investigation context")) { + std::string error; + const auto target = context.sessions ? context.sessions->ActiveTarget() : std::nullopt; + const json data = {{"artifact", {{"name", identity.value("name", std::string())}, + {"sha256", identity.value("sha256", std::string())}, + {"format", result.value("format", std::string())}, + {"provider", result.value("provider", json::object()).value("name", std::string())}}}}; + if (context.contextModel->Observe("Inspected artifact " + identity.value("name", std::string()), data, + {{{"source", "artifact"}, {"reference", "sha256:" + identity.value("sha256", std::string())}}}, + target ? target->generation : 0, &error)) + context.status = "Artifact recorded in the investigation context"; + else + context.status = "Record failed: " + error; + } + ImGui::EndDisabled(); + if (!canRecord && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("Needs a connected Cortex runtime in a target (the context lives there)."); + + // Static, decompile and live are separate capabilities, each validated + // on its own; the provider says which ones it has. + if (BeginDataTable("ArtifactCapabilities", 3, 0, ImVec2(0, 0), 90.0f)) { + ImGui::TableSetupColumn("Capability", ImGuiTableColumnFlags_WidthFixed, Px(100)); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, Px(110)); + ImGui::TableSetupColumn("Note", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + const json capabilities = result.value("capabilities", json::object()); + for (const char* name : {"static", "decompile", "live"}) { + const json row = capabilities.value(name, json::object()); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(name); + ImGui::TableSetColumnIndex(1); + const std::string status = row.value("status", std::string("unavailable")); + if (status == "validated") ImGui::TextColored(ImVec4(0.35f, 0.78f, 0.45f, 1.0f), "%s", status.c_str()); + else ImGui::TextDisabled("%s", status.c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextWrapped("%s", row.value("note", std::string()).c_str()); + } + ImGui::EndTable(); + } + + if (ImGui::BeginTabBar("ArtifactTabs")) { + if (ImGui::BeginTabItem("Structure", nullptr, TabFlags("Structure"))) { + ImGui::BeginChild("ArtifactTree", ImVec2(0, 0), ImGuiChildFlags_None); + JsonTree(result.value("data", json::object()), result.value("format", std::string("data")).c_str(), 0); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + bool hasChildren = false; + { + std::lock_guard lock(mutex_); + hasChildren = children_.is_object() && children_.contains("children"); + } + if (hasChildren && ImGui::BeginTabItem("Entries")) { + DrawChildren(context); + ImGui::EndTabItem(); + } + const json data = result.value("data", json::object()); + const json electron = data.value("electron", json()); + bool electronUsed = false; + if (electron.is_object()) { + const json ipc = electron.value("ipc", json()); + const bool channels = ipc.is_object() ? !ipc.value("channels", json::array()).empty() : ipc.is_array() && !ipc.empty(); + electronUsed = channels || !electron.value("windows", json::array()).empty() || + !electron.value("bridge", json::array()).empty(); + } + if (electronUsed && ImGui::BeginTabItem("Electron", nullptr, TabFlags("Electron"))) { + DrawElectron(data); + ImGui::EndTabItem(); + } + if (data.contains("modules") && ImGui::BeginTabItem("Modules", nullptr, TabFlags("Modules"))) { + DrawModules(data["modules"]); + ImGui::EndTabItem(); + } + const std::string machine = data.value("machine", std::string()); + if (data.contains("sections") && (machine.find("x64") != std::string::npos || machine.find("x86") != std::string::npos || + machine.find("amd64") != std::string::npos || machine.find("i386") != std::string::npos) && + ImGui::BeginTabItem("Code", nullptr, TabFlags("Code"))) { + DrawCode(context); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Evidence", nullptr, TabFlags("Evidence"))) { + DrawEvidence(context); + ImGui::EndTabItem(); + } + const bool pe = data.contains("sections"); + std::string inspected; + { + std::lock_guard lock(mutex_); + inspected = inspectedChild_.empty() ? inspectedPath_ : inspectedChild_; + } + std::string extension = inspected.size() >= 4 ? inspected.substr(inspected.size() - 4) : ""; + for (auto& c : extension) c = static_cast(std::tolower(static_cast(c))); + if ((pe || extension == ".jar") && ImGui::BeginTabItem("Engines", nullptr, TabFlags("Engines"))) { + DrawEngines(context); + ImGui::EndTabItem(); + } + if (data.contains("dotnet") && ImGui::BeginTabItem(".NET", nullptr, TabFlags(".NET"))) { + DrawDotNet(context, data["dotnet"]); + ImGui::EndTabItem(); + } + const std::string format = result.value("format", std::string()); + if ((format == "sourcemap" || (format == "javascript" && data.contains("source_map"))) && + ImGui::BeginTabItem("Source position", nullptr, TabFlags("Source position"))) { + DrawSourcePosition(context); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + selectTab_.clear(); + } +} + +namespace { + +std::string Where(const json& row) { + if (row.contains("at")) return std::to_string(row["at"].value("line", 0)) + ":" + std::to_string(row["at"].value("column", 0)); + return row.value("file", std::string()) + ":" + std::to_string(row.value("line", 0)); +} + +std::string Preference(const json& preferences, const char* key) { + if (!preferences.is_object() || !preferences.contains(key)) return "(default)"; + const json& value = preferences[key]; + if (value.is_boolean()) return value.get() ? "true" : "false"; + if (value.is_string()) return value.get(); + return value.value("expression", value.dump()); +} + +} // namespace + +// Windows, bridge and IPC channels: what the source names, never observed +// traffic. One script lists its own uses; an archive aggregates them. +void ArtifactsWorkspace::DrawElectron(const json& data) { + const json electron = data.value("electron", json::object()); + HintText("Static view: names found in the source, not observed traffic. Channels built at run time are only counted."); + const json windows = electron.value("windows", json::array()); + if (!windows.empty() && BeginDataTable("ElectronWindows", 6, 0, ImVec2(0, 0), 140.0f)) { + ImGui::TableSetupColumn("Where", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Constructor", ImGuiTableColumnFlags_WidthFixed, Px(120)); + ImGui::TableSetupColumn("contextIsolation", ImGuiTableColumnFlags_WidthFixed, Px(120)); + ImGui::TableSetupColumn("nodeIntegration", ImGuiTableColumnFlags_WidthFixed, Px(120)); + ImGui::TableSetupColumn("sandbox", ImGuiTableColumnFlags_WidthFixed, Px(90)); + ImGui::TableSetupColumn("Preload", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (const auto& window : windows) { + const json preferences = window.value("web_preferences", json()); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(Where(window).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(window.value("constructor", std::string()).c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(Preference(preferences, "contextIsolation").c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::TextUnformatted(Preference(preferences, "nodeIntegration").c_str()); + ImGui::TableSetColumnIndex(4); + ImGui::TextUnformatted(Preference(preferences, "sandbox").c_str()); + ImGui::TableSetColumnIndex(5); + const json preload = window.value("preload", json()); + if (preload.is_object()) { + const json resolved = preload.value("resolved", json()); + ImGui::TextUnformatted(resolved.is_string() ? resolved.get().c_str() + : preload.value("expression", std::string()).c_str()); + } + } + ImGui::EndTable(); + } + for (const auto& exposure : electron.value("bridge", json::array())) + ImGui::BulletText("contextBridge exposes window.%s (%s)", exposure.value("key", std::string()).c_str(), + Where(exposure).c_str()); + + // An archive groups uses by channel; a single script lists them. + const json ipc = electron.value("ipc", json()); + if (ipc.is_object()) { + const json channels = ipc.value("channels", json::array()); + ImGui::Text("%zu IPC channel(s), %d computed", channels.size(), ipc.value("computed_channels", 0)); + for (const char* list : {"called_without_handler", "handled_without_caller"}) + for (const auto& channel : ipc.value(list, json::array())) + ImGui::TextColored(ImVec4(0.9f, 0.7f, 0.3f, 1.0f), "%s: %s", list, channel.get().c_str()); + if (BeginDataTable("ElectronChannels", 5, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Channel", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Main handles", ImGuiTableColumnFlags_WidthFixed, Px(100)); + ImGui::TableSetupColumn("Renderer calls", ImGuiTableColumnFlags_WidthFixed, Px(110)); + ImGui::TableSetupColumn("To renderer", ImGuiTableColumnFlags_WidthFixed, Px(100)); + ImGui::TableSetupColumn("Uses", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (const auto& channel : channels) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(channel.value("channel", std::string()).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(channel.value("handled_in_main", false) ? "yes" : "-"); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(channel.value("called_from_renderer", false) ? "yes" : "-"); + ImGui::TableSetColumnIndex(3); + ImGui::TextUnformatted(channel.value("sent_to_renderer", false) ? "yes" : "-"); + ImGui::TableSetColumnIndex(4); + std::string uses; + for (const auto& use : channel.value("uses", json::array())) + uses += (uses.empty() ? "" : ", ") + use.value("object", std::string()) + "." + + use.value("method", std::string()) + " @" + Where(use); + ImGui::TextWrapped("%s", uses.c_str()); + } + ImGui::EndTable(); + } + } else if (ipc.is_array() && BeginDataTable("ElectronUses", 4, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Channel", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Call", ImGuiTableColumnFlags_WidthFixed, Px(220)); + ImGui::TableSetupColumn("Role", ImGuiTableColumnFlags_WidthFixed, Px(150)); + ImGui::TableSetupColumn("Line:col", ImGuiTableColumnFlags_WidthFixed, Px(90)); + ImGui::TableHeadersRow(); + for (const auto& use : ipc) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(use.value("channel", std::string()).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%s.%s", use.value("object", std::string()).c_str(), use.value("method", std::string()).c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(use.value("role", std::string()).c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::TextUnformatted(Where(use).c_str()); + } + ImGui::EndTable(); + } +} + +void ArtifactsWorkspace::DrawModules(const json& modules) { + const json edges = modules.value("edges", json::array()); + const json unresolved = modules.value("unresolved", json::array()); + ImGui::Text("%zu file(s), %zu edge(s), %zu unresolved, %zu package(s)", modules.value("nodes", json::array()).size(), + edges.size(), unresolved.size(), modules.value("packages", json::object()).size()); + HintText("Every import/require with a constant specifier, whether or not it runs."); + for (const auto& row : unresolved) + ImGui::TextColored(ImVec4(0.9f, 0.7f, 0.3f, 1.0f), "%s:%d %s (%s)", row.value("from", std::string()).c_str(), + row.value("line", 0), row.value("specifier", std::string()).c_str(), + row.value("reason", std::string()).c_str()); + if (!BeginDataTable("ModuleEdges", 4, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) return; + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("From", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("To", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Kind", ImGuiTableColumnFlags_WidthFixed, Px(120)); + ImGui::TableSetupColumn("Line", ImGuiTableColumnFlags_WidthFixed, Px(60)); + ImGui::TableHeadersRow(); + for (const auto& edge : edges) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(edge.value("from", std::string()).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(edge.value("to", std::string()).c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(edge.value("kind", std::string()).c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::Text("%d", edge.value("line", 0)); + } + ImGui::EndTable(); +} + +void ArtifactsWorkspace::DrawSourcePosition(UiContext& context) { + HintText("A 1-based line and column of this generated script, mapped through its source map."); + ImGui::SetNextItemWidth(Px(100)); + ImGui::InputInt("Line", &positionLine_); + ImGui::SameLine(); + ImGui::SetNextItemWidth(Px(100)); + ImGui::InputInt("Column", &positionColumn_); + positionLine_ = std::max(1, positionLine_); + positionColumn_ = std::max(1, positionColumn_); + ImGui::SameLine(); + if (ImGui::Button("Map to original")) LookupPosition(context); + json answer; + { + std::lock_guard lock(mutex_); + answer = position_; + } + if (answer.is_null()) return; + if (!answer.value("ok", false)) { + ImGui::TextColored(ImVec4(0.9f, 0.4f, 0.35f, 1.0f), "%s", answer.value("error", std::string("failed")).c_str()); + return; + } + const json original = answer.value("original", json()); + if (original.is_null()) { + ImGui::TextDisabled("No mapping at or before this position."); + return; + } + ImGui::Text("%s:%d:%d", original.value("source", std::string()).c_str(), original.value("line", 0), + original.value("column", 0)); + if (original.contains("name") && original["name"].is_string()) + ImGui::Text("name: %s", original["name"].get().c_str()); + if (original.contains("line_text")) ImGui::TextWrapped("%s", original["line_text"].get().c_str()); + ImGui::TextDisabled("map: %s", answer.value("map", json::object()).value("name", std::string()).c_str()); +} + +void ArtifactsWorkspace::LookupPosition(UiContext& context) { + std::string path, chain; + { + std::lock_guard lock(mutex_); + path = inspectedPath_; + chain = inspectedChild_; + } + const uint32_t line = static_cast(positionLine_), column = static_cast(positionColumn_); + context.RunInBackground("Mapping source position", [this, &context, path, chain, line, column]() { + std::string error; + json answer; + auto source = mcp_bridge::artifacts::OpenChain(path, chain, error); + if (source) answer = cortex::artifacts::SourcePosition(*source, line, column, + mcp_bridge::artifacts::MapOpenerFor(path, chain, nullptr), error); + if (answer.is_null()) answer = {{"ok", false}, {"error", error}}; + const json original = answer.value("original", json()); + context.status = original.is_object() + ? "Mapped to " + original.value("source", std::string()) + ":" + std::to_string(original.value("line", 0)) + + ":" + std::to_string(original.value("column", 0)) + : "No original position: " + answer.value("error", std::string("no mapping")); + std::lock_guard lock(mutex_); + position_ = std::move(answer); + }); +} + +// Assembly identity, types and members; a method's CIL on demand. +void ArtifactsWorkspace::DrawDotNet(UiContext& context, const json& dotnet) { + if (dotnet.contains("error")) { + ImGui::TextColored(ImVec4(0.9f, 0.4f, 0.35f, 1.0f), "metadata: %s", dotnet.value("error", std::string()).c_str()); + return; + } + const json assembly = dotnet.value("assembly", json::object()); + ImGui::Text("%s %s - metadata %s", assembly.value("name", std::string("(no assembly)")).c_str(), + assembly.value("version", std::string()).c_str(), dotnet.value("metadata_version", std::string()).c_str()); + std::string references; + for (const auto& reference : dotnet.value("assembly_refs", json::array())) + references += (references.empty() ? "" : ", ") + reference.value("name", std::string()) + " " + + reference.value("version", std::string()); + if (!references.empty()) ImGui::TextWrapped("References: %s", references.c_str()); + for (const auto& import : dotnet.value("pinvoke", json::array())) + ImGui::BulletText("P/Invoke %s -> %s!%s", import.value("method", std::string()).c_str(), + import.value("module", std::string()).c_str(), import.value("entry", std::string()).c_str()); + HintText("Double-click a method to disassemble its CIL (static; not decompiled)."); + + const float listWidth = std::max(Px(320), ImGui::GetContentRegionAvail().x * 0.45f); + ImGui::BeginChild("DotNetTypes", ImVec2(listWidth, 0), ImGuiChildFlags_Borders); + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##TypeFilter", "filter types and members", typeFilter_.data(), typeFilter_.size()); + const std::string filter = typeFilter_.data(); + for (const auto& type : dotnet.value("types", json::array())) { + const std::string typeName = type.value("name", std::string()); + bool typeMatches = filter.empty() || typeName.find(filter) != std::string::npos; + if (!typeMatches) + for (const auto& method : type.value("methods", json::array())) + typeMatches = typeMatches || method.value("name", std::string()).find(filter) != std::string::npos; + if (!typeMatches) continue; + ImGui::PushID(typeName.c_str()); + if (ImGui::TreeNode(typeName.c_str())) { + for (const auto& field : type.value("fields", json::array())) + ImGui::TextDisabled("field %s %s", field.value("signature", std::string()).c_str(), + field.value("name", std::string()).c_str()); + for (const auto& method : type.value("methods", json::array())) { + const std::string label = method.value("name", std::string()) + " " + method.value("signature", std::string()); + const std::string token = method.value("token", std::string()); + ImGui::PushID(token.c_str()); + if (ImGui::Selectable(label.c_str(), cil_.is_object() && cil_.value("query", std::string()) == token, + ImGuiSelectableFlags_AllowDoubleClick) && + ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + Disassemble(context, token); + ImGui::PopID(); + } + ImGui::TreePop(); + } + ImGui::PopID(); + } + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("DotNetCil", ImVec2(0, 0), ImGuiChildFlags_Borders); + json cil; + { + std::lock_guard lock(mutex_); + cil = cil_; + } + if (!cil.is_object()) { + ImGui::TextDisabled("No method disassembled."); + } else if (!cil.value("ok", false)) { + ImGui::TextColored(ImVec4(0.9f, 0.4f, 0.35f, 1.0f), "%s", cil.value("error", std::string()).c_str()); + } else { + for (const auto& method : cil.value("methods", json::array())) { + ImGui::Text("%s %s", method.value("method", std::string()).c_str(), method.value("signature", std::string()).c_str()); + if (!method.contains("instructions")) { + ImGui::TextDisabled("no body: %s", method.value("reason", std::string()).c_str()); + continue; + } + ImGui::TextDisabled("%s header, max stack %d, %d bytes", method.value("header", std::string()).c_str(), + method.value("max_stack", 0), method.value("code_size", 0)); + for (const auto& clause : method.value("exception_clauses", json::array())) + ImGui::TextDisabled(".try IL_%04x..IL_%04x %s IL_%04x %s", clause.value("try_offset", 0), + clause.value("try_offset", 0) + clause.value("try_length", 0), + clause.value("kind", std::string()).c_str(), clause.value("handler_offset", 0), + clause.value("catch_type", json()).is_string() ? clause["catch_type"].get().c_str() : ""); + for (const auto& instruction : method.value("instructions", json::array())) + ImGui::Text("IL_%04x %-12s %s", instruction.value("offset", 0), instruction.value("opcode", std::string()).c_str(), + instruction.value("operand", std::string()).c_str()); + if (method.contains("warning")) ImGui::TextDisabled("%s", method["warning"].get().c_str()); + ImGui::Separator(); + } + } + ImGui::EndChild(); +} + +void ArtifactsWorkspace::Disassemble(UiContext& context, const std::string& method) { + std::string path, chain; + { + std::lock_guard lock(mutex_); + path = inspectedPath_; + chain = inspectedChild_; + } + context.RunInBackground("Disassembling " + method, [this, &context, path, chain, method]() { + std::string error; + json answer; + auto source = mcp_bridge::artifacts::OpenChain(path, chain, error); + answer = source ? cortex::artifacts::DisassembleMethods(*source, method, 20000) : json{{"ok", false}, {"error", error}}; + answer["query"] = method; + const size_t count = answer.value("methods", json::array()).size(); + context.status = answer.value("ok", false) ? "Disassembled " + std::to_string(count) + " method(s): " + method + : "CIL failed: " + answer.value("error", std::string()); + std::lock_guard lock(mutex_); + cil_ = std::move(answer); + }); +} + +// Native code of a PE: the function catalog (with provenance), a function's +// CFG and the references to it, and names kept as annotations. +void ArtifactsWorkspace::DrawCode(UiContext& context) { + json functions, detail; + { + std::lock_guard lock(mutex_); + functions = functions_; + detail = codeDetail_; + } + if (!functions.is_object()) { + if (ImGui::Button("Analyse code")) LoadFunctions(context); + ImGui::SameLine(); + HintText("Builds the function catalog and cross-references from the file (offline, nothing runs)."); + return; + } + if (!functions.value("ok", false)) { + ImGui::TextColored(ImVec4(0.9f, 0.4f, 0.35f, 1.0f), "%s", functions.value("error", std::string()).c_str()); + return; + } + std::string sources; + const json bySource = functions.value("by_source", json::object()); + for (const auto& [source, count] : bySource.items()) + sources += (sources.empty() ? "" : ", ") + source + " " + std::to_string(count.get()); + ImGui::Text("%s: %zu functions (%s), %zu xrefs, %zu strings", functions.value("architecture", std::string()).c_str(), + functions.value("total", size_t{0}), sources.c_str(), functions.value("xrefs", size_t{0}), + functions.value("strings", size_t{0})); + const float listWidth = std::max(Px(300), ImGui::GetContentRegionAvail().x * 0.38f); + ImGui::BeginChild("CodeFunctions", ImVec2(listWidth, 0), ImGuiChildFlags_Borders); + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##CodeFilter", "filter functions", codeFilter_.data(), codeFilter_.size()); + const std::string filter = codeFilter_.data(); + if (BeginDataTable("CodeFunctionTable", 3, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("RVA", ImGuiTableColumnFlags_WidthFixed, Px(80)); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Provenance", ImGuiTableColumnFlags_WidthFixed, Px(150)); + ImGui::TableHeadersRow(); + int shown = 0; + for (const auto& function : functions.value("functions", json::array())) { + const std::string name = function.value("name", std::string()); + if (!filter.empty() && name.find(filter) == std::string::npos) continue; + if (++shown > 5000) break; + const std::string rva = function.value("rva", std::string()); + ImGui::PushID(rva.c_str()); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + const bool open = detail.is_object() && detail.value("rva", std::string()) == rva; + if (ImGui::Selectable(rva.c_str(), open, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick) && + ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + OpenFunction(context, rva); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(name.c_str()); + ImGui::TableSetColumnIndex(2); + std::string provenance; + for (const auto& source : function.value("sources", json::array())) + provenance += (provenance.empty() ? "" : ",") + source.get(); + ImGui::TextDisabled("%s", provenance.c_str()); + ImGui::PopID(); + } + ImGui::EndTable(); + } + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("CodeDetail", ImVec2(0, 0), ImGuiChildFlags_Borders); + if (!detail.is_object()) { + ImGui::TextDisabled("Double-click a function for its CFG and references."); + } else { + const json cfg = detail.value("cfg", json::object()); + const std::string rva = detail.value("rva", std::string()); + ImGui::Text("%s %s", rva.c_str(), cfg.value("name", std::string()).c_str()); + ImGui::SetNextItemWidth(Px(220)); + ImGui::InputTextWithHint("##Rename", "new name", rename_.data(), rename_.size()); + ImGui::SameLine(); + if (ImGui::Button("Rename")) Rename(context, rva, rename_.data()); + HintText("A name is an annotation keyed by the file's SHA-256; agents see it too."); + // Live: only when a loaded module is this very file (same SHA-256). + const std::string sha = detail.value("sha256", std::string()); + if (ImGui::Button("Show in live disassembly")) ShowLive(context, sha, detail.value("file_name", std::string()), rva); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Needs a target that has loaded this exact file (same SHA-256)."); + ImGui::SameLine(); + bool ghidraDone = false; + { + std::lock_guard lock(mutex_); + ghidraDone = engineProvider_ == "ghidra" && engineState_.value("state", std::string()) == "completed"; + } + if (ghidraDone && ImGui::Button("Ghidra decompilation")) { + OpenEngineItem(context, rva); + selectTab_ = "Engines"; + } + if (ghidraDone) ImGui::SameLine(); + if (ImGui::Button("Record as evidence")) RecordFunctionEvidence(context, rva); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Records that this function is in the catalog at this RVA; Cortex reruns the catalog to verify it."); + const json xrefs = detail.value("xrefs", json::object()); + ImGui::Text("%zu reference(s)", xrefs.value("total", size_t{0})); + for (const auto& ref : xrefs.value("references", json::array())) { + const json function = ref.value("function", json::object()); + ImGui::BulletText("%s %s in %s", ref.value("type", std::string()).c_str(), ref.value("from", std::string()).c_str(), + function.value("name", std::string("?")).c_str()); + } + ImGui::Separator(); + for (const auto& block : cfg.value("blocks", json::array())) { + ImGui::TextDisabled("block %s", block.value("start", std::string()).c_str()); + for (const auto& instruction : block.value("instructions", json::array())) { + std::string text = instruction.value("text", std::string()); + if (instruction.contains("call")) text += " ; " + instruction["call"].get(); + ImGui::Text(" %s %s", instruction.value("rva", std::string()).c_str(), text.c_str()); + } + } + for (const auto& edge : cfg.value("edges", json::array())) + ImGui::TextDisabled("%s -> %s (%s)", edge.value("from", std::string()).c_str(), + edge["to"].is_string() ? edge["to"].get().c_str() : "?", + edge.value("type", std::string()).c_str()); + } + ImGui::EndChild(); +} + +void ArtifactsWorkspace::LoadFunctions(UiContext& context, bool announce) { + std::string path, chain; + { + std::lock_guard lock(mutex_); + path = inspectedPath_; + chain = inspectedChild_; + } + context.RunInBackground("Analysing code", [this, &context, path, chain, announce]() { + std::string error; + auto source = mcp_bridge::artifacts::OpenChain(path, chain, error); + json answer = source ? mcp_bridge::artifacts::HandleCode("cortex_artifact_functions", {{"max_entries", 65536}}, *source, + &DesktopAnnotations(), "desktop") + : json{{"ok", false}, {"error", error}}; + if (announce || !answer.value("ok", false)) + context.status = answer.value("ok", false) + ? "Code analysed: " + std::to_string(answer.value("total", size_t{0})) + " functions" + : "Code analysis failed: " + answer.value("error", std::string()); + std::lock_guard lock(mutex_); + functions_ = std::move(answer); + }); +} + +void ArtifactsWorkspace::OpenFunction(UiContext& context, const std::string& location, bool announce) { + std::string path, chain; + { + std::lock_guard lock(mutex_); + path = inspectedPath_; + chain = inspectedChild_; + } + context.RunInBackground("Opening " + location, [this, &context, path, chain, location, announce]() { + std::string error; + json detail = FunctionDetail(path, chain, location, error); + const bool ok = detail.is_object() && detail["cfg"].value("ok", false); + if (announce || !ok) context.status = ok ? "Function " + detail["cfg"].value("name", location) + ": " + + std::to_string(detail["cfg"].value("blocks", json::array()).size()) + " blocks" + : "Function failed: " + (detail.is_object() ? detail["cfg"].value("error", std::string()) : error); + std::lock_guard lock(mutex_); + codeDetail_ = ok ? std::move(detail) : json(nullptr); + }); +} + +void ArtifactsWorkspace::ShowLive(UiContext& context, const std::string& sha256, const std::string& fileName, + const std::string& rva) { + const auto module = ModuleForSha256(context, sha256, fileName); + uint64_t offset = 0; + try { + offset = std::stoull(rva, nullptr, 16); + } catch (...) { + context.status = "Show live failed: invalid RVA"; + return; + } + if (!module) { + context.status = "Show live: no loaded module has this file's SHA-256"; + return; + } + context.NavigateTo("disassembly", module->base + offset); + char text[96]; + std::snprintf(text, sizeof(text), "Live %s+%s = 0x%llx", module->name.c_str(), rva.c_str(), + static_cast(module->base + offset)); + context.status = text; +} + +// The person records what the catalog shows; Cortex reruns it to verify. +void ArtifactsWorkspace::RecordFunctionEvidence(UiContext& context, const std::string& rva) { + std::string path, chain, name; + { + std::lock_guard lock(mutex_); + path = inspectedPath_; + chain = inspectedChild_; + if (codeDetail_.is_object()) name = codeDetail_["cfg"].value("name", std::string()); + } + if (!chain.empty()) { + context.status = "Record evidence: open the file itself, not a container entry"; + return; + } + // Annotated names are the person's; the catalog proves the export name or sub_. + const json catalog = mcp_bridge::artifacts::Handle( + "cortex_artifact_functions", {{"path", path}, {"filter", ""}, {"max_entries", 65536}}, + {std::filesystem::u8path(path).parent_path()}, nullptr); + size_t index = 0; + bool found = false; + for (const auto& function : catalog.value("functions", json::array())) { + if (function.value("rva", std::string()) == rva) { + found = true; + break; + } + ++index; + } + if (!found) { + context.status = "Record evidence: " + rva + " is not a function start in the catalog"; + return; + } + const std::vector roots = {std::filesystem::u8path(path).parent_path()}; + const json result = mcp_bridge::evidence::Handle( + "cortex_evidence_record", + {{"statement", "A function starts at " + rva + (name.empty() ? "" : " (" + name + ")") + " in " + + std::filesystem::u8path(path).filename().u8string()}, + {"class", "verified_observation"}, + {"origin", "static"}, + {"subject", {{"path", path}, {"location", "rva:" + rva}}}, + {"produced_by", {{"tool", "cortex_artifact_functions"}, + {"arguments", {{"path", path}, {"filter", ""}, {"max_entries", 65536}}}, + {"expect", json::array({{{"pointer", "/functions/" + std::to_string(index) + "/rva"}, {"value", rva}}})}}}, + {"mutation_permission", true}}, + &DesktopEvidence(), roots, nullptr, "desktop"); + context.status = result.value("ok", false) + ? "Evidence recorded: " + result["record"].value("id", std::string()) + " (verified_observation)" + : "Record evidence failed: " + result.value("error", std::string()) + " " + result.value("reason", std::string()); +} + +void ArtifactsWorkspace::Rename(UiContext& context, const std::string& rva, const std::string& name) { + std::string path, chain; + { + std::lock_guard lock(mutex_); + path = inspectedPath_; + chain = inspectedChild_; + } + std::string error; + auto source = mcp_bridge::artifacts::OpenChain(path, chain, error); + if (!source) { + context.status = "Rename failed: " + error; + return; + } + // The desktop writes the person's own annotation: no mutation_permission, + // which is the agent-side declaration. + const json result = DesktopAnnotations().Put(cortex::artifacts::Sha256Of(*source), "rva:" + rva, "name", name, "desktop", -1); + context.status = result.value("ok", false) ? (name.empty() ? "Name removed" : "Renamed " + rva + " to " + name) + : "Rename failed: " + result.value("error", std::string()); + if (result.value("ok", false)) { + LoadFunctions(context, false); + OpenFunction(context, rva, false); + } +} + +// Evidence about this artifact (by SHA-256), as recorded and checked. +void ArtifactsWorkspace::DrawEvidence(UiContext& context) { + json evidence; + { + std::lock_guard lock(mutex_); + evidence = evidence_; + } + if (ImGui::Button(evidence.is_object() ? "Refresh" : "Load evidence")) LoadEvidence(context); + ImGui::SameLine(); + const std::string sha = [&] { + std::lock_guard lock(mutex_); + return result_.value("identity", json::object()).value("sha256", std::string()); + }(); + if (ImGui::Button("Copy Markdown report")) + ImGui::SetClipboardText(DesktopEvidence().Export({{"sha256", sha}}, "markdown").value("document", std::string()).c_str()); + HintText("Records agents made with cortex_evidence_record. The class is enforced by Cortex: verified_observation " + "means Cortex reproduced the static result or found the trial completed."); + if (!evidence.is_object()) return; + if (BeginDataTable("EvidenceRecords", 5, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, Px(150)); + ImGui::TableSetupColumn("Origin", ImGuiTableColumnFlags_WidthFixed, Px(80)); + ImGui::TableSetupColumn("Location", ImGuiTableColumnFlags_WidthFixed, Px(150)); + ImGui::TableSetupColumn("Statement", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Check", ImGuiTableColumnFlags_WidthFixed, Px(140)); + ImGui::TableHeadersRow(); + for (const auto& record : evidence.value("records", json::array())) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + const std::string cls = record.value("class", std::string()); + if (cls == "verified_observation") ImGui::TextColored(ImVec4(0.35f, 0.78f, 0.45f, 1.0f), "%s", cls.c_str()); + else ImGui::TextUnformatted(cls.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(record.value("origin", std::string()).c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(record.value("subject", json::object()).value("location", std::string("(artifact)")).c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::TextWrapped("%s", record.value("statement", std::string()).c_str()); + ImGui::TableSetColumnIndex(4); + const std::string status = record.value("verification", json::object()).value("status", std::string("not_checked")); + if (record.value("stale", false)) ImGui::TextColored(ImVec4(0.9f, 0.6f, 0.3f, 1.0f), "STALE (%s)", status.c_str()); + else ImGui::TextDisabled("%s", status.c_str()); + } + ImGui::EndTable(); + } +} + +void ArtifactsWorkspace::LoadEvidence(UiContext& context) { + std::string sha; + { + std::lock_guard lock(mutex_); + sha = result_.value("identity", json::object()).value("sha256", std::string()); + } + if (sha.empty()) return; + json listed = DesktopEvidence().List({{"sha256", sha}}, 1000); + context.status = "Evidence: " + std::to_string(listed.value("matched", size_t{0})) + " record(s)"; + std::lock_guard lock(mutex_); + evidence_ = std::move(listed); +} + +void ArtifactsWorkspace::DrawChildren(UiContext& context) { + json children; + std::string path, chain; + { + std::lock_guard lock(mutex_); + children = children_.value("children", json::array()); + path = inspectedPath_; + chain = inspectedChild_; + } + ImGui::SetNextItemWidth(Px(260)); + ImGui::InputTextWithHint("##ArtifactChildFilter", "filter entries", childFilter_.data(), childFilter_.size()); + ImGui::SameLine(); + ImGui::TextDisabled("%zu entr%s - double-click to inspect one", children.size(), children.size() == 1 ? "y" : "ies"); + if (!BeginDataTable("ArtifactEntries", 3, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) return; + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Entry", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Size", ImGuiTableColumnFlags_WidthFixed, Px(100)); + ImGui::TableSetupColumn("Note", ImGuiTableColumnFlags_WidthFixed, Px(240)); + ImGui::TableHeadersRow(); + const std::string filter = childFilter_.data(); + int row = 0; + for (const auto& entry : children) { + const std::string name = entry.value("name", std::string()); + if (!filter.empty() && name.find(filter) == std::string::npos) continue; + ImGui::PushID(row++); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + const bool readable = entry.value("readable", false); + if (!readable) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + if (ImGui::Selectable(name.c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick) && + readable && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + const std::string next = chain.empty() ? name : chain + "!" + name; + Copy(child_.data(), child_.size(), next); + Inspect(context, path, next); + } + if (!readable) ImGui::PopStyleColor(); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%llu", static_cast(entry.value("size", uint64_t{0}))); + ImGui::TableSetColumnIndex(2); + ImGui::TextDisabled("%s", entry.value("note", std::string()).c_str()); + ImGui::PopID(); + } + ImGui::EndTable(); +} + +} // namespace cortex::ui + +namespace cortex::ui { + +void ArtifactsWorkspace::RefreshEngines(UiContext& context) { + context.RunInBackground("Detecting analysis engines", [this, &context]() { + json providers = DesktopAnalysis()->Providers(true); + size_t available = 0; + for (const auto& row : providers.value("providers", json::array())) available += row.value("available", false) ? 1 : 0; + context.status = "Engines: " + std::to_string(available) + " available"; + std::lock_guard lock(mutex_); + engineProviders_ = std::move(providers); + }); +} + +void ArtifactsWorkspace::StartEngine(UiContext& context, const std::string& provider) { + std::string path, chain; + { + std::lock_guard lock(mutex_); + path = inspectedPath_; + chain = inspectedChild_; + } + if (path.empty()) { + context.status = "Inspect a file first"; + return; + } + context.RunInBackground("Starting " + provider, [this, &context, path, chain, provider]() { + auto service = DesktopAnalysis(); + std::string file = path, error; + if (!chain.empty() && + !mcp_bridge::analysis::ExtractChild(path, chain, DesktopEngineSettings().cacheDir / "extracted", file, error)) { + context.status = "Engine failed: " + error; + return; + } + // Ghidra: every function decompiled (bounded), so any RVA opens + // from the same cached run. ILSpy: the whole assembly as a project. + const json options = provider == "ghidra" ? json{{"decompile_limit", 5000}} : json::object(); + const json started = service->Start(provider, {file, provider == "ghidra" ? "analyze" : "decompile", options}); + if (!started.value("ok", false)) { + context.status = "Engine failed: " + started.value("error", std::string()) + " " + started.value("message", std::string()); + return; + } + { + std::lock_guard lock(mutex_); + engineJob_ = started["job_id"]; + engineProvider_ = provider; + engineState_ = started; + engineItems_ = nullptr; + engineDetail_ = nullptr; + } + context.status = "Engine job " + started["job_id"].get() + ": " + started["state"].get() + + (started.value("cache_hit", false) ? " (cache)" : ""); + PollEngine(context, true); + }); +} + +void ArtifactsWorkspace::PollEngine(UiContext& context, bool force) { + std::string job, provider, state; + { + std::lock_guard lock(mutex_); + job = engineJob_; + provider = engineProvider_; + state = engineState_.is_object() ? engineState_.value("state", std::string()) : ""; + if (job.empty() || (!force && (engineItems_.is_object() || state == "failed" || state == "cancelled"))) return; + if (!force) { + const double now = ImGui::GetTime(); + if (now - enginePolled_ < 0.5) return; + enginePolled_ = now; + } + } + auto service = DesktopAnalysis(); + json current = service->Get(job); + const std::string next = current.value("state", std::string()); + json items; + if (next == "completed") { + // The list for the left pane, with long texts cut. + const std::string part = provider == "ghidra" ? "functions" : "files"; + items = json::array(); + for (size_t offset = 0;;) { + const json page = service->Get(job, {{"part", part}, {"offset", offset}, {"limit", 500}, {"max_chars", 256}}); + const json result = page.value("result", json::object()); + for (const auto& item : result.value("items", json::array())) + items.push_back({{"key", item.value(provider == "ghidra" ? "rva" : "path", std::string())}, + {"name", item.value("name", item.value("path", std::string()))}, + {"detail", item.value("signature", std::string())}}); + if (!result.value("next_offset", json()).is_number()) break; + offset = result["next_offset"].get(); + } + } + if (next != state) + context.status = "Engine job " + job + ": " + next + + (next == "completed" + ? " (" + std::to_string(items.size()) + (provider == "ghidra" ? " functions)" : " files)") + : next == "failed" ? " " + current.value("error", std::string()) : std::string()); + std::lock_guard lock(mutex_); + if (engineJob_ != job) return; + engineState_ = std::move(current); + if (items.is_array()) engineItems_ = {{"items", std::move(items)}}; +} + +void ArtifactsWorkspace::OpenEngineItem(UiContext& context, const std::string& key) { + std::string job, provider; + { + std::lock_guard lock(mutex_); + job = engineJob_; + provider = engineProvider_; + } + if (job.empty()) { + context.status = "No engine result"; + return; + } + auto service = DesktopAnalysis(); + json found; + if (provider == "ghidra") { + const json decompiled = service->Get(job, {{"part", "decompiled"}, {"rva", key}, {"max_chars", 1 << 20}}); + const json function = service->Get(job, {{"part", "functions"}, {"rva", key}, {"max_chars", 4096}}); + const json items = decompiled.value("result", json::object()).value("items", json::array()); + const json functions = function.value("result", json::object()).value("items", json::array()); + if (!functions.empty()) { + found = functions[0]; + found["text"] = items.empty() ? std::string("(not decompiled: beyond the decompile limit, or a thunk)") + : items[0].value("c", "(Ghidra could not decompile it: " + items[0].value("error", std::string()) + ")"); + } + } else { + const json files = service->Get(job, {{"part", "files"}, {"query", key}, {"limit", 500}, {"max_chars", 1 << 20}}); + for (const auto& item : files.value("result", json::object()).value("items", json::array())) + if (item.value("path", std::string()) == key) found = item; + } + context.status = found.is_object() ? "Engine: opened " + key : "Engine: nothing at " + key; + std::lock_guard lock(mutex_); + engineDetail_ = found.is_object() ? found : json(); +} + +void ArtifactsWorkspace::DrawEngines(UiContext& context) { + PollEngine(context); + json providers, state, items, detail, result; + std::string provider; + bool detect = false; + { + std::lock_guard lock(mutex_); + if (engineProviders_.is_null()) { + engineProviders_ = json::object(); // first draw: detect once + detect = true; + } + providers = engineProviders_; + state = engineState_; + items = engineItems_; + detail = engineDetail_; + provider = engineProvider_; + result = result_; + } + if (detect) { + const auto settings = DesktopEngineSettings(); + Copy(ghidraDir_.data(), ghidraDir_.size(), settings.ghidraDir); + Copy(ilspycmd_.data(), ilspycmd_.size(), settings.ilspycmd); + Copy(dotnetPath_.data(), dotnetPath_.size(), settings.dotnet); + Copy(vineflowerJar_.data(), vineflowerJar_.size(), settings.vineflowerJar); + RefreshEngines(context); + } + if (!providers.contains("providers")) { + ImGui::TextDisabled("Detecting engines..."); + return; + } + HintText("External engines run as separate processes on this file; nothing is linked or loaded into Cortex. Results " + "are cached by SHA-256. Decompiled code is the engine's reconstruction, not the original source."); + if (BeginDataTable("EngineProviders", 4, 0, ImVec2(0, 0), 80.0f)) { + ImGui::TableSetupColumn("Engine", ImGuiTableColumnFlags_WidthFixed, Px(170)); + ImGui::TableSetupColumn("Version", ImGuiTableColumnFlags_WidthFixed, Px(110)); + ImGui::TableSetupColumn("Licence", ImGuiTableColumnFlags_WidthFixed, Px(90)); + ImGui::TableSetupColumn("Availability", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (const auto& row : providers["providers"]) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(row.value("engine", std::string()).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(row.value("version", std::string()).c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(row.value("license", std::string()).c_str()); + ImGui::TableSetColumnIndex(3); + const std::string availability = row.value("availability", std::string()); + if (row.value("available", false)) ImGui::TextColored(ImVec4(0.35f, 0.78f, 0.45f, 1.0f), "%s", availability.c_str()); + else ImGui::TextDisabled("%s", availability.c_str()); + } + ImGui::EndTable(); + } + if (ImGui::CollapsingHeader("Engine locations")) { + ImGui::SetNextItemWidth(Px(480)); + ImGui::InputTextWithHint("Ghidra folder", "GHIDRA_INSTALL_DIR", ghidraDir_.data(), ghidraDir_.size()); + ImGui::SetNextItemWidth(Px(480)); + ImGui::InputTextWithHint("ilspycmd", "CORTEX_ILSPYCMD, or ilspycmd on PATH", ilspycmd_.data(), ilspycmd_.size()); + ImGui::SetNextItemWidth(Px(480)); + ImGui::InputTextWithHint("dotnet (for ilspycmd.dll)", "PATH", dotnetPath_.data(), dotnetPath_.size()); + ImGui::SetNextItemWidth(Px(480)); + ImGui::InputTextWithHint("vineflower.jar", "CORTEX_VINEFLOWER (java from JAVA_HOME or PATH)", vineflowerJar_.data(), vineflowerJar_.size()); + if (ImGui::Button("Save and detect")) { + std::string error; + if (ConfigureDesktopAnalysis(ghidraDir_.data(), ilspycmd_.data(), dotnetPath_.data(), vineflowerJar_.data(), error)) RefreshEngines(context); + else context.status = "Saving engine locations failed: " + error; + } + } + const bool dotnet = result.value("data", json::object()).contains("dotnet"); + const bool java = !dotnet && !result.value("data", json::object()).contains("sections"); // a .jar (ZIP) + const char* engine = dotnet ? "ilspy" : java ? "vineflower" : "ghidra"; + if (ImGui::Button(dotnet ? "Decompile C# (ILSpy)" : java ? "Decompile Java (Vineflower)" : "Analyse and decompile (Ghidra)")) + StartEngine(context, engine); + ImGui::SameLine(); + HintText(dotnet || java ? "Seconds for a small archive." : "Ghidra's auto-analysis takes minutes on a large binary; it can be cancelled."); + if (!state.is_object()) return; + const std::string current = state.value("state", std::string()); + ImGui::Text("Job %s (%s): %s%s", state.value("job_id", std::string()).c_str(), provider.c_str(), current.c_str(), + state.value("cache_hit", false) ? ", from cache" : ""); + if (current == "running" || current == "queued") { + ImGui::SameLine(); + if (ImGui::Button("Cancel")) HandleCommand(context, "artifact_engine_cancel"); + } + if (current == "failed" || current == "cancelled") { + ImGui::TextColored(ImVec4(0.9f, 0.4f, 0.35f, 1.0f), "%s %s", state.value("error", std::string()).c_str(), + state.value("message", std::string()).substr(0, 600).c_str()); + return; + } + if (!items.is_object()) return; + const json provenance = state.value("provenance", json::object()); + ImGui::TextDisabled("%s %s, SHA-256 %s", provenance.value("engine", std::string()).c_str(), + state.value("engine_version", std::string()).c_str(), state.value("sha256", std::string()).substr(0, 16).c_str()); + const float listWidth = std::max(Px(300), ImGui::GetContentRegionAvail().x * 0.35f); + ImGui::BeginChild("EngineItems", ImVec2(listWidth, 0), ImGuiChildFlags_Borders); + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##EngineFilter", "filter", engineFilter_.data(), engineFilter_.size()); + const std::string filter = engineFilter_.data(); + int shown = 0; + for (const auto& item : items["items"]) { + const std::string key = item.value("key", std::string()), name = item.value("name", std::string()); + if (!filter.empty() && name.find(filter) == std::string::npos && key.find(filter) == std::string::npos) continue; + if (++shown > 5000) break; + const bool selected = detail.is_object() && (detail.value("rva", std::string()) == key || detail.value("path", std::string()) == key); + const std::string label = (provider == "ghidra" ? key + " " + name : name) + "##" + key; + if (ImGui::Selectable(label.c_str(), selected)) OpenEngineItem(context, key); + } + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("EngineDetail", ImVec2(0, 0), ImGuiChildFlags_Borders); + if (!detail.is_object()) { + ImGui::TextDisabled(provider == "ghidra" ? "Select a function." : "Select a file."); + } else { + if (provider == "ghidra") + ImGui::TextWrapped("%s %s", detail.value("rva", std::string()).c_str(), detail.value("signature", std::string()).c_str()); + else + ImGui::TextUnformatted(detail.value("path", std::string()).c_str()); + std::string text = detail.value("text", std::string()); + ImGui::InputTextMultiline("##EngineText", text.data(), text.size() + 1, ImVec2(-1, -1), ImGuiInputTextFlags_ReadOnly); + } + ImGui::EndChild(); +} + +} // namespace cortex::ui diff --git a/app_imgui/ui/artifacts_workspace.h b/app_imgui/ui/artifacts_workspace.h new file mode 100644 index 00000000..3c7d78a8 --- /dev/null +++ b/app_imgui/ui/artifacts_workspace.h @@ -0,0 +1,102 @@ +#pragma once + +#include "workspace.h" + +#include + +#include +#include +#include +#include + +namespace cortex::ui { + +// Static analysis of files through the artifact providers (core/artifacts): +// PE, ASAR, and the runtimes added in later steps. Nothing needs a target +// process; the same providers back `cortex.exe inspect` and the +// cortex_artifact_* MCP tools. +class ArtifactsWorkspace final : public IWorkspace { +public: + const char* Id() const override { return "artifacts"; } + const char* Title() const override { return "Artifacts"; } + void Draw(UiContext& context) override; + // "inspect_artifact ": inspects a file without the dialog; + // "artifact_tab " selects a result tab; "artifact_position L:C" + // maps a generated position through the script's source map; + // "artifact_cil " disassembles .NET methods; + // "artifact_functions", "artifact_function " and + // "artifact_rename " drive the Code tab; + // "artifact_engine_start ", "artifact_engine_cancel", + // "artifact_engine_open " and "artifact_engines_refresh" drive + // the Engines tab; "artifact_evidence" reloads the Evidence tab; "artifact_open + // |", "artifact_show_live" and "artifact_record_function" + // navigate between live and static code and record evidence. + void HandleCommand(UiContext& context, const std::string& command) override; + +private: + void Inspect(UiContext& context, const std::string& path, const std::string& child); + void DrawResult(UiContext& context); + void DrawChildren(UiContext& context); + void DrawElectron(const nlohmann::json& data); + void DrawModules(const nlohmann::json& modules); + void DrawSourcePosition(UiContext& context); + void LookupPosition(UiContext& context); + void DrawDotNet(UiContext& context, const nlohmann::json& dotnet); + void Disassemble(UiContext& context, const std::string& method); + void DrawCode(UiContext& context); + // `announce` false keeps the status line (a reload after a rename). + void LoadFunctions(UiContext& context, bool announce = true); + void OpenFunction(UiContext& context, const std::string& location, bool announce = true); + void Rename(UiContext& context, const std::string& rva, const std::string& name); + int TabFlags(const char* name) const; // ImGuiTabItemFlags + void DrawEvidence(UiContext& context); + void LoadEvidence(UiContext& context); + // Inspect `path` and open the function at `location` (module+RVA from + // the live address menu), in one background task. + void OpenAt(UiContext& context, const std::string& path, const std::string& location); + // Disassemble base+RVA in the target when a loaded module is this file. + void ShowLive(UiContext& context, const std::string& sha256, const std::string& fileName, const std::string& rva); + void RecordFunctionEvidence(UiContext& context, const std::string& rva); + // External engines (core/providers): Ghidra for PE, ILSpy for .NET. + void DrawEngines(UiContext& context); + void StartEngine(UiContext& context, const std::string& provider); + void PollEngine(UiContext& context, bool force = false); + void OpenEngineItem(UiContext& context, const std::string& key); // an RVA (Ghidra) or a file path (ILSpy) + void RefreshEngines(UiContext& context); + + std::array path_ = {}; + std::array child_ = {}; + // Written by the background inspection, read by the frame loop. + std::mutex mutex_; + nlohmann::json result_; + nlohmann::json children_; + std::string inspectedPath_; + std::string inspectedChild_; + std::array childFilter_ = {}; + int positionLine_ = 1; + int positionColumn_ = 1; + nlohmann::json position_; // last source position answer + nlohmann::json cil_; // last CIL disassembly + std::array typeFilter_ = {}; + nlohmann::json functions_; // native function catalog of the inspected PE + nlohmann::json codeDetail_; // {"cfg", "xrefs"} of the opened function + std::array codeFilter_ = {}; + std::array rename_ = {}; + nlohmann::json evidence_; // evidence records about the inspected artifact + std::string selectTab_; // "artifact_tab ": selected on the next frame + // Engine job of the inspected file; guarded by mutex_. + std::string engineJob_; + std::string engineProvider_; + nlohmann::json engineState_; // last cortex::providers::Service::Get (summary) + nlohmann::json engineItems_; // Ghidra functions or ILSpy files, texts clipped + nlohmann::json engineDetail_; // the opened decompiled function or file + nlohmann::json engineProviders_; // detected engines + double enginePolled_ = 0; + std::array ghidraDir_ = {}; + std::array ilspycmd_ = {}; + std::array dotnetPath_ = {}; + std::array vineflowerJar_ = {}; + std::array engineFilter_ = {}; +}; + +} // namespace cortex::ui diff --git a/app_imgui/ui/code_navigation.h b/app_imgui/ui/code_navigation.h new file mode 100644 index 00000000..fd4b66dc --- /dev/null +++ b/app_imgui/ui/code_navigation.h @@ -0,0 +1,76 @@ +#pragma once + +// module+RVA between the live process and static analysis (step 7): which +// loaded module contains an address, and which loaded module is the file an +// artifact SHA-256 names. A module matches an artifact only when its file on +// disk has the same SHA-256; a name alone is never enough. + +#include "ui_context.h" + +#include "../../core/artifacts/artifact.h" +#include "../../core/services/module_service.h" + +#include +#include +#include +#include + +namespace cortex::ui { + +struct LiveModule { + std::string name; + std::string path; + uint64_t base = 0; + uint64_t size = 0; +}; + +// SHA-256 of a module file, cached by path, size and modification time. +inline std::string ModuleFileSha256(const std::string& path) { + struct Entry { + uintmax_t size = 0; + std::filesystem::file_time_type time{}; + std::string sha256; + }; + static std::map cache; + std::error_code error; + const auto file = std::filesystem::u8path(path); + const uintmax_t size = std::filesystem::file_size(file, error); + if (error) return {}; + const auto time = std::filesystem::last_write_time(file, error); + if (error) return {}; + auto& entry = cache[path]; + if (entry.sha256.empty() || entry.size != size || entry.time != time) { + const auto source = cortex::artifacts::OpenFile(path); + entry = {size, time, source ? cortex::artifacts::Sha256Of(*source) : std::string()}; + } + return entry.sha256; +} + +inline std::vector LiveModules(UiContext& context) { + std::vector modules; + if (!context.modules || !context.sessions || !context.sessions->ActiveTarget()) return modules; + for (const auto& module : context.modules->List()) modules.push_back({module.name, module.path, module.base, module.size}); + return modules; +} + +// The loaded module containing `address`, with a file on disk. +inline std::optional ModuleAt(UiContext& context, uint64_t address) { + for (const auto& module : LiveModules(context)) + if (address >= module.base && address - module.base < module.size && !module.path.empty()) return module; + return std::nullopt; +} + +// The loaded module whose file has this SHA-256. `nameHint` (a file name) +// narrows which files are hashed first. +inline std::optional ModuleForSha256(UiContext& context, const std::string& sha256, const std::string& nameHint = {}) { + if (sha256.size() != 64) return std::nullopt; + auto modules = LiveModules(context); + std::stable_partition(modules.begin(), modules.end(), [&](const LiveModule& module) { + return !nameHint.empty() && std::filesystem::u8path(module.path).filename().u8string() == nameHint; + }); + for (const auto& module : modules) + if (!module.path.empty() && ModuleFileSha256(module.path) == sha256) return module; + return std::nullopt; +} + +} // namespace cortex::ui diff --git a/app_imgui/ui/desktop_stores.h b/app_imgui/ui/desktop_stores.h new file mode 100644 index 00000000..f9b0a5f6 --- /dev/null +++ b/app_imgui/ui/desktop_stores.h @@ -0,0 +1,110 @@ +#pragma once + +// The per-user stores the desktop shares with `cortex.exe mcp` (whose +// defaults are the same folders): annotations, evidence and investigations. +// In the desktop the actor is the person ("desktop"). + +#include "../../core/artifacts/annotations.h" +#include "../../core/evidence/evidence.h" +#include "../../core/investigation/investigation.h" +#include "../../core/providers/analysis_provider.h" +#include "../../core/live/live_service.h" +#include "../../core/services/user_data.h" + +#include +#include +#include +#include +#include + +namespace cortex::desktop { +std::string ExecutableDirectory(); +} + +namespace cortex::ui { + +inline std::filesystem::path DesktopDataDirectory() { + return cortex::services::UserDataDirectory(std::filesystem::u8path(cortex::desktop::ExecutableDirectory())); +} + +inline cortex::artifacts::AnnotationStore& DesktopAnnotations() { + static cortex::artifacts::AnnotationStore store(DesktopDataDirectory() / "annotations"); + return store; +} + +inline cortex::evidence::Store& DesktopEvidence() { + static cortex::evidence::Store store(DesktopDataDirectory() / "evidence"); + return store; +} + +inline cortex::investigation::Store& DesktopInvestigations() { + static cortex::investigation::Store store(DesktopDataDirectory() / "investigations"); + return store; +} + +// Live CDP sessions (Node.js/Electron inspectors). In the desktop the person +// is the operator: any port on 127.0.0.1. Breakpoints still need the +// desktop's write permission. +inline cortex::live::LiveService& DesktopLive() { + static cortex::live::LiveService service({}, true); + return service; +} + +// External engines (Ghidra, ILSpy). The person is the operator here: the +// engine paths they set are kept in analysis-engines.json; empty fields fall +// back to GHIDRA_INSTALL_DIR / CORTEX_ILSPYCMD / PATH. The result cache is +// the folder `cortex.exe mcp` uses by default. +inline cortex::providers::Settings DesktopEngineSettings() { + cortex::providers::Settings settings; + settings.cacheDir = DesktopDataDirectory() / "analysis-cache"; + settings.tempDir = std::filesystem::temp_directory_path(); + try { + std::ifstream file(DesktopDataDirectory() / "analysis-engines.json"); + if (file) { + const auto saved = nlohmann::json::parse(file); + settings.ghidraDir = saved.value("ghidra_dir", std::string()); + settings.ilspycmd = saved.value("ilspycmd", std::string()); + settings.dotnet = saved.value("dotnet", std::string()); + settings.vineflowerJar = saved.value("vineflower", std::string()); + } + } catch (...) { + } + return settings; +} + +struct DesktopAnalysisSlot { + std::mutex mutex; + std::shared_ptr service; +}; + +inline DesktopAnalysisSlot& DesktopAnalysisState() { + static DesktopAnalysisSlot slot; + return slot; +} + +inline std::shared_ptr DesktopAnalysis() { + auto& slot = DesktopAnalysisState(); + std::lock_guard lock(slot.mutex); + if (!slot.service) slot.service = std::make_shared(DesktopEngineSettings()); + return slot.service; +} + +// Saves the engine paths and starts a new service (running jobs of the old +// one are cancelled when it is released). +inline bool ConfigureDesktopAnalysis(const std::string& ghidraDir, const std::string& ilspycmd, const std::string& dotnet, + const std::string& vineflower, std::string& error) { + const nlohmann::json saved{{"ghidra_dir", ghidraDir}, {"ilspycmd", ilspycmd}, {"dotnet", dotnet}, {"vineflower", vineflower}}; + std::error_code ec; + std::filesystem::create_directories(DesktopDataDirectory(), ec); + if (!cortex::services::WriteFileAtomically(DesktopDataDirectory() / "analysis-engines.json", saved.dump(2), &error)) return false; + auto& slot = DesktopAnalysisState(); + std::shared_ptr old; + { + std::lock_guard lock(slot.mutex); + old = std::move(slot.service); + slot.service = std::make_shared(DesktopEngineSettings()); + } + return true; +} + +} // namespace cortex::ui diff --git a/app_imgui/ui/memory_address_list.cpp b/app_imgui/ui/memory_address_list.cpp index efad7f66..76bf6ae0 100644 --- a/app_imgui/ui/memory_address_list.cpp +++ b/app_imgui/ui/memory_address_list.cpp @@ -627,7 +627,7 @@ void MemoryWorkspace::PublishHotkeys(UiContext& context) { void MemoryWorkspace::Tick(UiContext& context) { PollScan(context); const auto session = context.sessions ? context.sessions->Active() : nullptr; - const std::string targetId = session ? session->Target().id : std::string(); + const std::string targetId = session ? TargetKey(session->Target()) : std::string(); if (targetId != activeTargetId_) ResetForTarget(context, targetId); for (auto& entry : addresses_) if (!entry.uid) entry.uid = nextUid_++; diff --git a/app_imgui/ui/memory_browser_workspace.cpp b/app_imgui/ui/memory_browser_workspace.cpp index 55ab93f0..002cdfd5 100644 --- a/app_imgui/ui/memory_browser_workspace.cpp +++ b/app_imgui/ui/memory_browser_workspace.cpp @@ -140,6 +140,29 @@ void MemoryBrowserWorkspace::Refresh(UiContext& context, bool force) { } bytes_ = std::move(fresh); pageValid_ = std::move(valid); + UpdateCaptureDiff(); +} + +void MemoryBrowserWorkspace::Capture(UiContext& context) { + Refresh(context, true); + capture_ = bytes_; + captureValid_ = pageValid_; + captureBase_ = base_; + compareCapture_ = true; + UpdateCaptureDiff(); + context.status = "Captured " + Hex(base_) + " - " + Hex(base_ + kWindow - 1) + + ": changes are highlighted until the next capture"; +} + +void MemoryBrowserWorkspace::UpdateCaptureDiff() { + captureRuns_.clear(); + captureChanged_.clear(); + captureTruncated_ = false; + if (!compareCapture_ || capture_.empty() || captureBase_ != base_ || bytes_.size() != capture_.size()) return; + captureRuns_ = services::DiffRuns(capture_, bytes_, captureValid_, pageValid_, kPage, 4096, &captureTruncated_); + captureChanged_.assign(kWindow, 0); + for (const auto& run : captureRuns_) + for (uint64_t i = 0; i < run.length; ++i) captureChanged_[run.offset + i] = 1; } bool MemoryBrowserWorkspace::Readable(uint64_t address, size_t size) const { @@ -318,6 +341,38 @@ void MemoryBrowserWorkspace::DrawToolbar(UiContext& context) { pendingNibble_ = -1; if (hasSelection_) selected_ -= selected_ % CellSize(); } + FlowSameLine(ButtonWidth("Capture")); + if (ImGui::Button("Capture")) Capture(context); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Keep a copy of this 64 KB window; every byte that differs from it stays highlighted."); + if (!capture_.empty()) { + FlowSameLine(CheckboxWidth("Compare with capture")); + if (ImGui::Checkbox("Compare with capture", &compareCapture_)) UpdateCaptureDiff(); + if (compareCapture_) { + if (captureBase_ != base_) { + FlowSameLine(ButtonWidth("Back to capture")); + if (ImGui::Button("Back to capture")) GoTo(captureBase_ + kWindow / 2, false); + } else { + FlowSameLine(Px(240)); + ImGui::TextDisabled("%llu byte(s) in %zu run(s) changed%s", + static_cast(services::ChangedBytes(captureRuns_)), + captureRuns_.size(), captureTruncated_ ? " (first 4096 runs)" : ""); + FlowSameLine(ButtonWidth("Next change")); + ImGui::BeginDisabled(captureRuns_.empty()); + if (ImGui::Button("Next change")) { + const uint64_t from = hasSelection_ && selected_ >= base_ ? selected_ - base_ : 0; + const size_t next = services::NextRun(captureRuns_, from); + if (next < captureRuns_.size()) { + selected_ = base_ + captureRuns_[next].offset; + hasSelection_ = true; + scrollToSelection_ = true; + std::snprintf(address_, sizeof(address_), "%s", Hex(selected_).c_str()); + } + } + ImGui::EndDisabled(); + } + } + } FlowSameLine(CheckboxWidth("Live") + Px(90)); ImGui::Checkbox("Live", &liveRefresh_); ImGui::SameLine(); @@ -424,11 +479,16 @@ void MemoryBrowserWorkspace::DrawHex(UiContext& context, float width, float heig if (changedAt_.size() == kWindow) for (size_t i = 0; i < cell; ++i) changed |= now - changedAt_[address - base_ + i] < std::chrono::milliseconds(1200); + bool sinceCapture = false; + if (captureChanged_.size() == kWindow) + for (size_t i = 0; i < cell && address - base_ + i < kWindow; ++i) + sinceCapture |= captureChanged_[address - base_ + i] != 0; const bool readable = Readable(address, cell); const bool selected = hasSelection_ && selected_ >= address && selected_ < address + cell; ImGui::PushID(static_cast(c)); if (!readable) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); else if (changed) ImGui::PushStyleColor(ImGuiCol_Text, ChangedValueColor()); + else if (sinceCapture) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.95f, 0.62f, 0.25f, 1.0f)); const std::string shown = selected && pendingNibble_ >= 0 ? std::string(1, "0123456789ABCDEF"[pendingNibble_]) + "_" : text; if (ImGui::Selectable(shown.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick, @@ -446,7 +506,7 @@ void MemoryBrowserWorkspace::DrawHex(UiContext& context, float width, float heig openEdit_ = true; } } - if (!readable || changed) ImGui::PopStyleColor(); + if (!readable || changed || sinceCapture) ImGui::PopStyleColor(); if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { selected_ = address; hasSelection_ = true; @@ -638,8 +698,13 @@ void MemoryBrowserWorkspace::Draw(UiContext& context) { HintText("Select a process to browse memory."); return; } - if (session->Target().id != targetId_) { - targetId_ = session->Target().id; + if (TargetKey(session->Target()) != targetId_) { + targetId_ = TargetKey(session->Target()); + capture_.clear(); + captureValid_.clear(); + captureRuns_.clear(); + captureChanged_.clear(); + compareCapture_ = false; hasSelection_ = false; regions_.clear(); modules_.clear(); diff --git a/app_imgui/ui/memory_browser_workspace.h b/app_imgui/ui/memory_browser_workspace.h index c19e4fe5..a78c831a 100644 --- a/app_imgui/ui/memory_browser_workspace.h +++ b/app_imgui/ui/memory_browser_workspace.h @@ -1,6 +1,7 @@ #pragma once #include "workspace.h" +#include "services/byte_diff.h" #include "services/value_scanner.h" #include "target/module_provider.h" #include "target/session.h" @@ -80,6 +81,19 @@ class MemoryBrowserWorkspace final : public IWorkspace { uint64_t editAddress_ = 0; char editValue_[256] = ""; std::string findInfo_; + + // A capture of the window to compare later states with: unlike the + // 1.2 s change flash, its differences stay highlighted until a new + // capture, so a before/after an action can be read at leisure. + void Capture(UiContext& context); + void UpdateCaptureDiff(); + std::vector capture_; + std::vector captureValid_; + uint64_t captureBase_ = 0; + bool compareCapture_ = false; + std::vector captureRuns_; + std::vector captureChanged_; // one flag per window byte + bool captureTruncated_ = false; }; } // namespace cortex::ui diff --git a/app_imgui/ui/memory_workspace.cpp b/app_imgui/ui/memory_workspace.cpp index c4dfa02a..13c20161 100644 --- a/app_imgui/ui/memory_workspace.cpp +++ b/app_imgui/ui/memory_workspace.cpp @@ -177,6 +177,12 @@ void MemoryWorkspace::PollScan(UiContext& context) { scanCancel_.reset(); scanProgress_.reset(); + const auto active = context.sessions ? context.sessions->ActiveTarget() : std::nullopt; + if (!active || TargetKey(*active) != scanTargetKey_) { + context.status = "Scan result discarded: the target changed while it ran"; + return; + } + if (!task.state) { lastScanError_ = task.error; context.status = task.error == "scan_cancelled" @@ -193,6 +199,17 @@ void MemoryWorkspace::PollScan(UiContext& context) { if (!scan_->Listed()) context.status += " - narrow them down with a next scan"; if (scan_->limitReached) context.status += " - limit reached, other matches were not kept"; if (!task.pauseError.empty()) context.status += " (the target could not be paused: " + task.pauseError + ")"; + ShareScan(context); +} + +void MemoryWorkspace::ShareScan(UiContext& context) { + // Agents read the person's scan through context_get. Only with a runtime + // already connected: sharing never loads it into the target. + if (!shareScans_ || !scan_ || !context.contextModel || !context.payload || !context.payload->Ready()) return; + std::string error; + if (!context.contextModel->Share("scan", application::InvestigationContextModel::ScanSummary(*scan_, scanQuery_), + scanTargetGeneration_, &error)) + context.status += " (not shared with the investigation context: " + error + ")"; } void MemoryWorkspace::StartScan(UiContext& context) { @@ -219,6 +236,9 @@ void MemoryWorkspace::StartScan(UiContext& context) { const bool pause = pauseWhileScanning_; const uint64_t pid = session->Target().processId; + scanTargetKey_ = TargetKey(session->Target()); + scanTargetGeneration_ = session->Target().generation; + scanQuery_ = query; auto previous = scan_; scanCancel_ = std::make_shared(false); scanProgress_ = std::make_shared(); @@ -703,6 +723,9 @@ void MemoryWorkspace::DrawScanOptions(UiContext& context) { ImGui::EndDisabled(); ImGui::Checkbox("Pause the target while scanning", &pauseWhileScanning_); + ImGui::Checkbox("Share results with the investigation context", &shareScans_); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("When the Cortex runtime is connected, MCP agents can read the latest scan with context_get."); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Suspends every thread of the target for the duration of each scan,\n" "so values cannot change while they are read."); diff --git a/app_imgui/ui/memory_workspace.h b/app_imgui/ui/memory_workspace.h index ce0a2a8d..31451d5e 100644 --- a/app_imgui/ui/memory_workspace.h +++ b/app_imgui/ui/memory_workspace.h @@ -115,6 +115,7 @@ class MemoryWorkspace final : public IWorkspace { // Scanning void ApplyDefaults(UiContext& context); void ResetForTarget(UiContext& context, const std::string& targetId); + void ShareScan(UiContext& context); void PollScan(UiContext& context); void StartScan(UiContext& context); void NewScan(UiContext& context); @@ -213,7 +214,14 @@ class MemoryWorkspace final : public IWorkspace { std::future scanFuture_; std::shared_ptr scanCancel_; std::shared_ptr scanProgress_; - std::string activeTargetId_; + std::string activeTargetId_; // TargetKey of the target the workspace shows + // The target a running scan started on, and its query: a result that + // finishes after the target changed is discarded, never shown for the + // new process. + std::string scanTargetKey_; + uint64_t scanTargetGeneration_ = 0; + services::ScanQuery scanQuery_; + bool shareScans_ = true; std::string lastScanError_; // Results view diff --git a/app_imgui/ui/project_workspace.cpp b/app_imgui/ui/project_workspace.cpp index 6640048c..c6caa348 100644 --- a/app_imgui/ui/project_workspace.cpp +++ b/app_imgui/ui/project_workspace.cpp @@ -122,14 +122,20 @@ void ProjectWorkspace::Draw(UiContext& context) { return; } - if (targetId_ != session->Target().id) { - targetId_ = session->Target().id; + if (targetId_ != TargetKey(session->Target())) { + targetId_ = TargetKey(session->Target()); context.projectModel->Reset(); ClearAddressForm(); ClearPointerForm(); ClearNoteForm(); - if (context.payload && context.payload->Ready()) Refresh(context); + lastEventSeen_ = context.runtimeEventsModel ? context.runtimeEventsModel->LastEventId() : 0; + if (context.contextModel) context.contextModel->Reset(); + if (context.payload && context.payload->Ready()) { + Refresh(context); + RefreshContext(context); + } } + FollowRuntimeEvents(context); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Persistent target knowledge"); @@ -410,8 +416,154 @@ void ProjectWorkspace::Draw(UiContext& context) { } ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Knowledge")) { + DrawKnowledgeTab(context); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Investigation context")) { + DrawContextTab(context); + ImGui::EndTabItem(); + } ImGui::EndTabBar(); } } +void ProjectWorkspace::FollowRuntimeEvents(UiContext& context) { + if (!context.runtimeEventsModel || !context.payload || !context.payload->Ready()) return; + bool knowledgeChanged = false, contextChanged = false; + uint64_t newest = lastEventSeen_; + for (const auto& event : context.runtimeEventsModel->Events()) { + if (event.id <= lastEventSeen_) continue; + newest = std::max(newest, event.id); + if (event.type == "knowledge.updated" || event.type == "evidence.created") knowledgeChanged = true; + if (event.type.rfind("context.", 0) == 0 || event.type.rfind("agent.action_", 0) == 0) contextChanged = true; + } + lastEventSeen_ = newest; + if (knowledgeChanged) Refresh(context); + if (contextChanged) RefreshContext(context); +} + +void ProjectWorkspace::RefreshContext(UiContext& context) { + if (!context.contextModel || !context.sessions) return; + const auto target = context.sessions->ActiveTarget(); + std::string error; + if (!context.contextModel->Refresh(target ? target->generation : 0, &error)) + context.status = "Investigation context refresh failed: " + error; +} + +void ProjectWorkspace::DrawKnowledgeTab(UiContext& context) { + HintText("Revisioned claims saved by you or an agent (project_knowledge_put). A status is what its author " + "asserted; Cortex never upgrades a claim on its own."); + const auto& rows = context.projectModel->Knowledge(); + if (rows.empty()) { + ImGui::TextDisabled("No knowledge records in this project yet."); + return; + } + if (!BeginDataTable("ProjectKnowledgeTable", 7, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) return; + ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, Px(150)); + ImGui::TableSetupColumn("Kind", ImGuiTableColumnFlags_WidthFixed, Px(90)); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, Px(90)); + ImGui::TableSetupColumn("Statement", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Rev", ImGuiTableColumnFlags_WidthFixed, Px(45)); + ImGui::TableSetupColumn("Evidence", ImGuiTableColumnFlags_WidthFixed, Px(70)); + ImGui::TableSetupColumn("Byte check", ImGuiTableColumnFlags_WidthFixed, Px(90)); + ImGui::TableHeadersRow(); + for (const auto& row : rows) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(row.id.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(row.kind.c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(row.status.c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::TextWrapped("%s", row.statement.c_str()); + ImGui::TableSetColumnIndex(4); + ImGui::Text("%d", row.revision); + ImGui::TableSetColumnIndex(5); + ImGui::Text("%d", row.evidenceCount); + ImGui::TableSetColumnIndex(6); + ImGui::TextUnformatted(row.verification.c_str()); + } + ImGui::EndTable(); +} + +void ProjectWorkspace::DrawContextTab(UiContext& context) { + if (!context.contextModel) return; + auto& model = *context.contextModel; + if (ImGui::Button("Refresh context")) RefreshContext(context); + ImGui::SameLine(); + if (model.Pid()) + ImGui::TextDisabled("PID %llu, generation %llu%s", static_cast(model.Pid()), + static_cast(model.Generation()), + model.DroppedEntries() ? " (older entries dropped)" : ""); + HintText("Shared with MCP clients (context_get). Right-click any address > Share with investigation context. " + "Agent entries are assertions with their session as provenance, not verified facts."); + + if (BeginDataTable("ProjectContextTable", 6, ImGuiTableFlags_ScrollY, + ImVec2(0.0f, ImGui::GetContentRegionAvail().y * 0.62f))) { + ImGui::TableSetupColumn("#", ImGuiTableColumnFlags_WidthFixed, Px(45)); + ImGui::TableSetupColumn("Kind", ImGuiTableColumnFlags_WidthFixed, Px(90)); + ImGui::TableSetupColumn("Source", ImGuiTableColumnFlags_WidthFixed, Px(80)); + ImGui::TableSetupColumn("Address", ImGuiTableColumnFlags_WidthFixed, Px(220)); + ImGui::TableSetupColumn("Text", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, Px(110)); + ImGui::TableHeadersRow(); + const auto& entries = model.Entries(); + for (auto it = entries.rbegin(); it != entries.rend(); ++it) { + ImGui::PushID(it->id.c_str()); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::Text("%llu", static_cast(it->sequence)); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(it->kind.c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(it->source.c_str()); + ImGui::TableSetColumnIndex(3); + const std::string where = it->location.empty() ? it->address : it->location; + if (!it->address.empty()) { + if (ImGui::Selectable(where.c_str())) { + try { + context.NavigateTo("memory-browser", std::stoull(it->address, nullptr, 0)); + } catch (...) { + context.status = "Invalid context address " + it->address; + } + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s - click to open in the memory viewer", it->address.c_str()); + } + ImGui::TableSetColumnIndex(4); + ImGui::TextWrapped("%s", it->text.c_str()); + ImGui::TableSetColumnIndex(5); + ImGui::TextUnformatted(it->status.c_str()); + ImGui::PopID(); + } + ImGui::EndTable(); + } + + ImGui::TextDisabled("Recent tool calls"); + if (BeginDataTable("ProjectContextHistory", 5, ImGuiTableFlags_ScrollY, ImGui::GetContentRegionAvail())) { + ImGui::TableSetupColumn("#", ImGuiTableColumnFlags_WidthFixed, Px(45)); + ImGui::TableSetupColumn("Tool", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Risk", ImGuiTableColumnFlags_WidthFixed, Px(90)); + ImGui::TableSetupColumn("Source", ImGuiTableColumnFlags_WidthFixed, Px(80)); + ImGui::TableSetupColumn("Result", ImGuiTableColumnFlags_WidthFixed, Px(110)); + ImGui::TableHeadersRow(); + const auto& history = model.History(); + for (auto it = history.rbegin(); it != history.rend(); ++it) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::Text("%llu", static_cast(it->sequence)); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(it->tool.c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(it->risk.c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::TextUnformatted(it->source.c_str()); + ImGui::TableSetColumnIndex(4); + ImGui::Text("%s %llums", it->status.c_str(), static_cast(it->durationMs)); + } + ImGui::EndTable(); + } +} + } // namespace cortex::ui diff --git a/app_imgui/ui/project_workspace.h b/app_imgui/ui/project_workspace.h index 65071002..4cb5aeb3 100644 --- a/app_imgui/ui/project_workspace.h +++ b/app_imgui/ui/project_workspace.h @@ -20,6 +20,10 @@ class ProjectWorkspace final : public IWorkspace { void ClearAddressForm(); void ClearPointerForm(); void ClearNoteForm(); + void FollowRuntimeEvents(UiContext& context); + void RefreshContext(UiContext& context); + void DrawKnowledgeTab(UiContext& context); + void DrawContextTab(UiContext& context); std::string targetId_; std::array addressName_ = {}; @@ -36,6 +40,10 @@ class ProjectWorkspace final : public IWorkspace { std::array noteText_ = {}; std::array noteTags_ = {}; + + // Last runtime event id acted on, so an agent's knowledge.updated or + // context.* event refreshes the view once. + uint64_t lastEventSeen_ = 0; }; } // namespace cortex::ui diff --git a/app_imgui/ui/re_workspace.cpp b/app_imgui/ui/re_workspace.cpp index 9c4dcb45..4301d141 100644 --- a/app_imgui/ui/re_workspace.cpp +++ b/app_imgui/ui/re_workspace.cpp @@ -80,8 +80,8 @@ void ReWorkspace::Draw(UiContext& context) { return; } - if (targetId_ != session->Target().id) - ResetForTarget(context, session->Target().id); + if (targetId_ != TargetKey(session->Target())) + ResetForTarget(context, TargetKey(session->Target())); uint64_t navigationAddress = 0; if (context.ConsumeNavigation("re", navigationAddress)) { diff --git a/app_imgui/ui/tools_workspace.cpp b/app_imgui/ui/tools_workspace.cpp index 99998b84..1fd1c1f6 100644 --- a/app_imgui/ui/tools_workspace.cpp +++ b/app_imgui/ui/tools_workspace.cpp @@ -801,6 +801,7 @@ void ToolsWorkspace::PollPointerScan(UiContext& context) { return; } pointers_ = std::move(*pointerPending_); + pointers_.scanGeneration = pointerTargetGeneration_; pointerPending_.reset(); pointersLoaded_ = true; pointerLive_.clear(); @@ -809,6 +810,11 @@ void ToolsWorkspace::PollPointerScan(UiContext& context) { static_cast(pointers_.pointersIndexed), pointers_.milliseconds / 1000.0, pointers_.truncated ? " - limit reached, shorter paths first" : ""); pointerInfo_ = text; + // Paths are module-relative, so they stay usable on another process (the + // point of pointer maps), but they were found in the old one. + const auto active = context.sessions ? context.sessions->ActiveTarget() : std::nullopt; + if (!active || TargetKey(*active) != pointerTargetKey_) + pointerInfo_ += " - found in a previous process: Rescan before trusting them here"; context.status = pointerInfo_; } @@ -869,6 +875,8 @@ void ToolsWorkspace::DrawPointers(UiContext& context) { auto cancel = pointerCancel_; auto progress = pointerProgress_; pointerRunning_ = true; + pointerTargetKey_ = TargetKey(session->Target()); + pointerTargetGeneration_ = session->Target().generation; pointerInfo_ = "Indexing pointers..."; pointerFuture_ = std::async(std::launch::async, [session, options, pending, cancel, progress]() { std::string error; @@ -902,6 +910,29 @@ void ToolsWorkspace::DrawPointers(UiContext& context) { if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("After the game restarts, scan the value again and enter its new\n" "address: only the paths that still reach it are kept."); + ImGui::SameLine(); + ImGui::BeginDisabled(pointers_.paths.empty()); + if (ImGui::Button("Validate here")) { + uint64_t target = 0; + lastModules_ = {}; + RefreshModules(context); + if (!Resolve(context, pointerRescan_, target)) { + pointerInfo_ = "Enter the value's address in this process"; + } else { + const auto summary = services::PointerScanner::Validate( + session, pointers_, target, CurrentPointerModules(), session->Target().generation); + pointerInfo_ = std::to_string(summary.stable) + " stable, " + std::to_string(summary.candidate) + + " candidate, " + std::to_string(summary.invalidated) + " invalidated (checked against " + + Hex(target) + ")"; + pointerLive_.clear(); + } + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("Records, for this process, which paths lead to the address entered at left.\n" + "A path is stable once it led to the value in two different processes,\n" + "invalidated as soon as it led elsewhere once. Nothing is removed; Save keeps\n" + "the history for the next session."); } ImGui::SetNextItemWidth(std::max(Px(200), ImGui::GetContentRegionAvail().x - ButtonWidth("Save") - @@ -945,9 +976,10 @@ void ToolsWorkspace::DrawPointers(UiContext& context) { if (refresh) lastPointerRefresh_ = now; const auto currentModules = CurrentPointerModules(); - if (!ToolTable("PointerTable", 3)) return; + if (!ToolTable("PointerTable", 4)) return; ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Path", ImGuiTableColumnFlags_WidthStretch, 0.6f); + ImGui::TableSetupColumn("Path", ImGuiTableColumnFlags_WidthStretch, 0.5f); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 0.1f); ImGui::TableSetupColumn("Points to", ImGuiTableColumnFlags_WidthStretch, 0.22f); ImGui::TableSetupColumn("Value (4 bytes)", ImGuiTableColumnFlags_WidthStretch, 0.18f); ImGui::TableHeadersRow(); @@ -1003,6 +1035,20 @@ void ToolsWorkspace::DrawPointers(UiContext& context) { } ImGui::PopID(); ImGui::TableSetColumnIndex(1); + { + const auto status = services::PointerScanner::Status(pointers_, path); + const char* name = services::PointerScanner::StatusName(status); + if (status == services::PointerPathStatus::Stable) + ImGui::TextColored(ImVec4(0.35f, 0.78f, 0.45f, 1.0f), "%s", name); + else if (status == services::PointerPathStatus::Invalidated) + ImGui::TextColored(ImVec4(0.90f, 0.40f, 0.35f, 1.0f), "%s", name); + else + ImGui::TextDisabled("%s", name); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%zu check(s) in other processes. Validate here records one for this process.", + path.checks.size()); + } + ImGui::TableSetColumnIndex(2); if (resolved == 0) { ImGui::TextDisabled("??"); } else { @@ -1010,7 +1056,7 @@ void ToolsWorkspace::DrawPointers(UiContext& context) { MonoText("%s", Hex(resolved).c_str()); if (!same && ImGui::IsItemHovered()) ImGui::SetTooltip("No longer the scanned address"); } - ImGui::TableSetColumnIndex(2); + ImGui::TableSetColumnIndex(3); int32_t value = 0; if (resolved && session->ReadMemory(resolved, &value, sizeof(value), nullptr)) ImGui::Text("%d", value); else ImGui::TextDisabled("??"); diff --git a/app_imgui/ui/tools_workspace.h b/app_imgui/ui/tools_workspace.h index d26b8112..38368919 100644 --- a/app_imgui/ui/tools_workspace.h +++ b/app_imgui/ui/tools_workspace.h @@ -112,6 +112,8 @@ class ToolsWorkspace final : public IWorkspace { int pointerMaxResults_ = 100000; bool pointerMapped_ = false; bool pointerRunning_ = false; + std::string pointerTargetKey_; // TargetKey of the process the running pointer scan reads + uint64_t pointerTargetGeneration_ = 0; std::future> pointerFuture_; std::shared_ptr pointerPending_; std::shared_ptr pointerCancel_; diff --git a/app_imgui/ui/trace_workspace.cpp b/app_imgui/ui/trace_workspace.cpp index a3dd12fe..89d13d29 100644 --- a/app_imgui/ui/trace_workspace.cpp +++ b/app_imgui/ui/trace_workspace.cpp @@ -193,8 +193,8 @@ void TraceWorkspace::Draw(UiContext& context) { return; } - if (targetId_ != session->Target().id) - ResetForTarget(context, session->Target().id); + if (targetId_ != TargetKey(session->Target())) + ResetForTarget(context, TargetKey(session->Target())); if (threadId_ == 0 && context.debuggerModel) threadId_ = context.debuggerModel->CurrentThread(); diff --git a/app_imgui/ui/ui_context.h b/app_imgui/ui/ui_context.h index 56e7ba80..992e9c58 100644 --- a/app_imgui/ui/ui_context.h +++ b/app_imgui/ui/ui_context.h @@ -5,6 +5,7 @@ #include "application/actions_model.h" #include "application/address_expression.h" +#include "application/context_model.h" #include "application/ai_activity_model.h" #include "application/diagnostics_model.h" #include "application/input_model.h" @@ -69,6 +70,13 @@ struct AccessFinderRequest { bool instruction = false; }; +// Identifies one process lifetime: the target id plus its generation. A +// workspace that keeps per-target state compares keys, not ids, so a reused +// PID (same id, new generation) resets it like any other new process. +inline std::string TargetKey(const target::TargetDescriptor& target) { + return target.id + "@" + std::to_string(target.generation); +} + struct UiContext { target::SessionManager* sessions = nullptr; services::MemoryService* memory = nullptr; @@ -94,6 +102,7 @@ struct UiContext { application::InputModel* inputModel = nullptr; application::ScreenshotModel* screenshotModel = nullptr; application::RuntimeEventsModel* runtimeEventsModel = nullptr; + application::InvestigationContextModel* contextModel = nullptr; application::PatchesModel* patchesModel = nullptr; void* nativeRenderDevice = nullptr; diff --git a/app_imgui/ui/workspace_registry.h b/app_imgui/ui/workspace_registry.h index 3c0073a3..5866545a 100644 --- a/app_imgui/ui/workspace_registry.h +++ b/app_imgui/ui/workspace_registry.h @@ -140,13 +140,13 @@ class WorkspaceRegistry { break; case WorkspacePreset::ReverseEngineering: OpenMany({"re", "disassembly", "patches", "memory-browser", "tools", "modules", "project", "symbols", - "structures", "pointermaps", "snapshots", "instrumentation", "runtime"}); + "structures", "pointermaps", "snapshots", "instrumentation", "runtime", "artifacts"}); break; case WorkspacePreset::Trace: OpenMany({"disassembly", "debugger", "trace", "memory-browser", "watches"}); break; case WorkspacePreset::Automation: - OpenMany({"lua", "scripts", "input", "actions", "events", "watches", "runtime"}); + OpenMany({"lua", "scripts", "input", "actions", "events", "watches", "runtime", "ai-investigation"}); break; case WorkspacePreset::Runtime: OpenMany({"runtime", "diagnostics", "network", "screenshots", "instrumentation", @@ -282,7 +282,7 @@ class WorkspaceRegistry { DockMany(leftBottom, {"pointermaps", "snapshots", "modules"}, docked); DockMany(center, {"re", "disassembly", "memory-browser", "tools"}, docked); DockMany(rightTop, {"patches", "instrumentation"}, docked); - DockMany(rightBottom, {"runtime"}, docked); + DockMany(rightBottom, {"runtime", "artifacts"}, docked); break; } @@ -294,7 +294,7 @@ class WorkspaceRegistry { case WorkspacePreset::Automation: DockMany(left, {"scripts", "input"}, docked); - DockMany(center, {"lua", "actions", "events"}, docked); + DockMany(center, {"lua", "actions", "events", "ai-investigation"}, docked); DockMany(right, {"watches", "runtime"}, docked); break; diff --git a/core/analysis/analysis.cpp b/core/analysis/analysis.cpp index 90f375cd..92705183 100644 --- a/core/analysis/analysis.cpp +++ b/core/analysis/analysis.cpp @@ -1,4 +1,5 @@ #include "analysis.h" +#include "cfg_builder.h" #include "../memory/memory.h" #include "../process/modules.h" @@ -278,90 +279,21 @@ CfgResult BuildCFG(uintptr_t address, bool& ok) { if (VirtualQuery(reinterpret_cast(address), &probe, sizeof(probe)) != sizeof(probe)) return result; if (probe.State != MEM_COMMIT || (probe.Protect & PAGE_NOACCESS) || (probe.Protect & PAGE_GUARD)) return result; - ZydisDecoder decoder = MakeDecoder(); - std::set leaders{address}; - std::set visited; - std::vector worklist{address}; - - while (!worklist.empty()) { - if (result.blocks.size() >= kMaxCfgBlocks) { - result.truncated = true; - break; - } - uintptr_t blockStart = worklist.back(); - worklist.pop_back(); - if (visited.count(blockStart)) continue; - visited.insert(blockStart); - - MEMORY_BASIC_INFORMATION mbi; - if (VirtualQuery(reinterpret_cast(blockStart), &mbi, sizeof(mbi)) != sizeof(mbi)) continue; - if (mbi.State != MEM_COMMIT || (mbi.Protect & PAGE_NOACCESS) || (mbi.Protect & PAGE_GUARD)) continue; - uintptr_t regionEnd = reinterpret_cast(mbi.BaseAddress) + mbi.RegionSize; - if (blockStart >= regionEnd) continue; - - size_t wantSize = (std::min)(static_cast(kMaxCfgInstructionsPerBlock) * 16, - static_cast(regionEnd - blockStart)); + // The algorithm is shared with offline analysis of files (cfg_builder.h); + // here the bytes come from this process's committed memory. + const ZydisDecoder decoder = MakeDecoder(); + auto fetch = [](uintptr_t blockStart, size_t maxBytes) { std::vector buf; - if (!memory::ReadBytes(blockStart, wantSize, buf)) continue; - - size_t offset = 0; - uintptr_t curAddr = blockStart; - uintptr_t blockEnd = blockStart; - size_t instrCount = 0; - - while (offset < buf.size() && instrCount < kMaxCfgInstructionsPerBlock) { - if (curAddr != blockStart && leaders.count(curAddr)) { - result.edges.push_back({blockStart, curAddr, "fallthrough"}); - if (!visited.count(curAddr)) worklist.push_back(curAddr); - break; - } - - ZydisDecodedInstruction instr; - ZydisDecodedOperand operands[ZYDIS_MAX_OPERAND_COUNT]; - ZyanStatus status = ZydisDecoderDecodeFull(&decoder, buf.data() + offset, buf.size() - offset, - &instr, operands); - if (!ZYAN_SUCCESS(status)) break; // undecodable -- end block here, no successors - - blockEnd = curAddr + instr.length; - instrCount++; - auto category = instr.meta.category; - - if (category == ZYDIS_CATEGORY_RET) { - break; - } - if (category == ZYDIS_CATEGORY_COND_BR) { - uintptr_t fallAddr = curAddr + instr.length; - leaders.insert(fallAddr); - if (!visited.count(fallAddr)) worklist.push_back(fallAddr); - result.edges.push_back({blockStart, fallAddr, "cond_false"}); - - uintptr_t target = 0; - if (ResolveBranchTarget(instr, operands, curAddr, target)) { - leaders.insert(target); - if (!visited.count(target)) worklist.push_back(target); - result.edges.push_back({blockStart, target, "cond_true"}); - } - break; - } - if (category == ZYDIS_CATEGORY_UNCOND_BR) { - uintptr_t target = 0; - if (ResolveBranchTarget(instr, operands, curAddr, target)) { - leaders.insert(target); - if (!visited.count(target)) worklist.push_back(target); - result.edges.push_back({blockStart, target, "jump"}); - } else { - result.edges.push_back({blockStart, 0, "indirect"}); - } - break; - } - - offset += instr.length; - curAddr += instr.length; - } - - result.blocks.push_back({blockStart, blockEnd}); - } - + MEMORY_BASIC_INFORMATION mbi; + if (VirtualQuery(reinterpret_cast(blockStart), &mbi, sizeof(mbi)) != sizeof(mbi)) return buf; + if (mbi.State != MEM_COMMIT || (mbi.Protect & PAGE_NOACCESS) || (mbi.Protect & PAGE_GUARD)) return buf; + const uintptr_t regionEnd = reinterpret_cast(mbi.BaseAddress) + mbi.RegionSize; + if (blockStart >= regionEnd) return buf; + const size_t wantSize = (std::min)(maxBytes, static_cast(regionEnd - blockStart)); + if (!memory::ReadBytes(blockStart, wantSize, buf)) buf.clear(); + return buf; + }; + result = BuildCfgFrom(address, decoder, fetch, kMaxCfgBlocks, kMaxCfgInstructionsPerBlock); ok = true; return result; } diff --git a/core/analysis/cfg_builder.h b/core/analysis/cfg_builder.h new file mode 100644 index 00000000..e1003a15 --- /dev/null +++ b/core/analysis/cfg_builder.h @@ -0,0 +1,113 @@ +#pragma once + +// The control-flow graph algorithm shared by the in-process runtime +// (analysis::BuildCFG, over the target's memory) and offline analysis of +// files (core/artifacts/native_code, over a PE's bytes). Only the byte +// source differs: `fetch(address, maxBytes)` returns the readable bytes at +// `address` (fewer near the end of a region, none if unreadable). +// +// Recursive descent: a block is decoded until a ret, a jump, a conditional +// branch or an undecodable byte. Calls do not end a block. Branch targets +// and fall-throughs after conditional branches become new leaders, explored +// with a visited set so loops terminate. An indirect jump yields an edge +// with `to = 0`, type "indirect". + +#include "analysis.h" + +#include + +#include +#include + +namespace analysis { + +// The absolute target of a direct (immediate) call/jmp/jcc; false for +// indirect (register or memory) branches. +inline bool ResolveDirectBranch(const ZydisDecodedInstruction& instr, const ZydisDecodedOperand* operands, + uint64_t address, uint64_t& target) { + if (instr.operand_count_visible < 1 || operands[0].type != ZYDIS_OPERAND_TYPE_IMMEDIATE) return false; + ZyanU64 absolute = 0; + if (!ZYAN_SUCCESS(ZydisCalcAbsoluteAddress(&instr, &operands[0], address, &absolute))) return false; + target = absolute; + return true; +} + +template +CfgResult BuildCfgFrom(uintptr_t address, const ZydisDecoder& decoder, Fetch&& fetch, size_t maxBlocks, + size_t maxInstructionsPerBlock) { + CfgResult result; + std::set leaders{address}; + std::set visited; + std::vector worklist{address}; + + while (!worklist.empty()) { + if (result.blocks.size() >= maxBlocks) { + result.truncated = true; + break; + } + const uintptr_t blockStart = worklist.back(); + worklist.pop_back(); + if (visited.count(blockStart)) continue; + visited.insert(blockStart); + + const std::vector buf = fetch(blockStart, maxInstructionsPerBlock * 16); + if (buf.empty()) continue; + + size_t offset = 0; + uintptr_t curAddr = blockStart; + uintptr_t blockEnd = blockStart; + size_t instrCount = 0; + + while (offset < buf.size() && instrCount < maxInstructionsPerBlock) { + if (curAddr != blockStart && leaders.count(curAddr)) { + result.edges.push_back({blockStart, curAddr, "fallthrough"}); + if (!visited.count(curAddr)) worklist.push_back(curAddr); + break; + } + + ZydisDecodedInstruction instr; + ZydisDecodedOperand operands[ZYDIS_MAX_OPERAND_COUNT]; + const ZyanStatus status = + ZydisDecoderDecodeFull(&decoder, buf.data() + offset, buf.size() - offset, &instr, operands); + if (!ZYAN_SUCCESS(status)) break; // undecodable: end the block here, no successors + + blockEnd = curAddr + instr.length; + instrCount++; + const auto category = instr.meta.category; + + if (category == ZYDIS_CATEGORY_RET) break; + if (category == ZYDIS_CATEGORY_COND_BR) { + const uintptr_t fallAddr = curAddr + instr.length; + leaders.insert(fallAddr); + if (!visited.count(fallAddr)) worklist.push_back(fallAddr); + result.edges.push_back({blockStart, fallAddr, "cond_false"}); + uint64_t target = 0; + if (ResolveDirectBranch(instr, operands, curAddr, target)) { + leaders.insert(static_cast(target)); + if (!visited.count(static_cast(target))) worklist.push_back(static_cast(target)); + result.edges.push_back({blockStart, static_cast(target), "cond_true"}); + } + break; + } + if (category == ZYDIS_CATEGORY_UNCOND_BR) { + uint64_t target = 0; + if (ResolveDirectBranch(instr, operands, curAddr, target)) { + leaders.insert(static_cast(target)); + if (!visited.count(static_cast(target))) worklist.push_back(static_cast(target)); + result.edges.push_back({blockStart, static_cast(target), "jump"}); + } else { + result.edges.push_back({blockStart, 0, "indirect"}); + } + break; + } + + offset += instr.length; + curAddr += instr.length; + } + + result.blocks.push_back({blockStart, blockEnd}); + } + return result; +} + +} // namespace analysis diff --git a/core/api/mcp_contract.h b/core/api/mcp_contract.h index fccbf352..a266a645 100644 --- a/core/api/mcp_contract.h +++ b/core/api/mcp_contract.h @@ -54,7 +54,7 @@ inline const std::set& StateTools() { static const std::set names = { "project_knowledge_put", "project_knowledge_verify", "project_address_set", "project_address_delete", "project_pointer_path_set", "project_pointer_path_delete", "project_note_add", "project_note_delete", - "re_session_fact_set", "re_session_fact_delete", "re_object_delete"}; + "re_session_fact_set", "re_session_fact_delete", "re_object_delete", "context_observation_add"}; return names; } diff --git a/core/api/mcp_pipe.cpp b/core/api/mcp_pipe.cpp index 8f36bf98..a8382a95 100644 --- a/core/api/mcp_pipe.cpp +++ b/core/api/mcp_pipe.cpp @@ -4,6 +4,7 @@ #include "mcp_protocol.h" #include "mcp_tools.h" #include "native_routes.h" +#include "../context/caller.h" #include #include @@ -197,6 +198,7 @@ void ServeClient(const std::shared_ptr& worker, const std::string& expec if (envelope.value("channel", std::string()) != "desktop") { responsePayload = RpcErrorForEnvelope(envelope, -32601, "private_route_not_available").dump(); } else { + context::ScopedCaller caller(context::Caller::Desktop, "desktop"); responsePayload = HandlePrivateRouteRequest(envelope["message"]).dump(); } } else { @@ -210,6 +212,13 @@ void ServeClient(const std::shared_ptr& worker, const std::string& expec authority.writesAllowed = envelope["writes_allowed"].get(); if (envelope.contains("state_allowed") && envelope["state_allowed"].is_boolean()) authority.stateAllowed = envelope["state_allowed"].get(); + // Provenance for the records this call creates. The desktop's + // adapter marks its envelopes; everything else is an agent. + // A label, not an authority: `authority` above decides. + const bool fromDesktop = envelope.value("channel", std::string()) == "desktop"; + context::ScopedCaller caller(fromDesktop ? context::Caller::Desktop : context::Caller::Agent, + fromDesktop ? std::string("desktop") + : envelope.value("session", std::string())); const auto result = mcp_tools::Handle( envelope["message"], profile, diff --git a/core/api/mcp_protocol.h b/core/api/mcp_protocol.h index 7aa8e486..ef2539ee 100644 --- a/core/api/mcp_protocol.h +++ b/core/api/mcp_protocol.h @@ -2,6 +2,8 @@ #include +#include "../version.h" + #include #include #include @@ -14,7 +16,7 @@ using json = nlohmann::json; inline constexpr const char* kModernProtocolVersion = "2026-07-28"; inline constexpr const char* kLatestLegacyProtocolVersion = "2025-11-25"; inline constexpr const char* kServerName = "cortex"; -inline constexpr const char* kServerVersion = "1.0.1"; +inline constexpr const char* kServerVersion = cortex::kVersion; inline constexpr long long kCatalogTtlMs = 30000; enum class ToolProfile { diff --git a/core/api/mcp_tools.cpp b/core/api/mcp_tools.cpp index af96bb31..17b70677 100644 --- a/core/api/mcp_tools.cpp +++ b/core/api/mcp_tools.cpp @@ -6,6 +6,8 @@ #include "mcp_contract.h" #include "../action/action.h" #include "../security/denial_log.h" +#include "../context/investigation_context.h" +#include "../events/events.h" #include #include @@ -340,6 +342,65 @@ json DispatchPrimitive(const json& entry, const json& arguments) { return Dispatch(method, rendered.path, BodyPayload(arguments)); } +bool ChangesState(mcp_contract::ToolRisk risk) { + return risk == mcp_contract::ToolRisk::State || risk == mcp_contract::ToolRisk::Control || + risk == mcp_contract::ToolRisk::Mutate || risk == mcp_contract::ToolRisk::NativeCall; +} + +// agent.action_* event data: who asked for what, never the argument values +// (they can be large or sensitive), only their names. +json ActionEventData(const std::string& tool, mcp_contract::ToolRisk risk, const json& arguments, + const context::CallerInfo& caller) { + json keys = json::array(); + if (arguments.is_object()) + for (auto it = arguments.begin(); it != arguments.end() && keys.size() < 32; ++it) keys.push_back(it.key()); + json data = context::Global().TargetJson(); + data["tool"] = tool; + data["risk"] = mcp_contract::RiskName(risk); + data["source"] = context::CallerName(caller.caller); + data["session"] = caller.session; + data["argument_names"] = std::move(keys); + return data; +} + +// Every primitive call goes through here: it lands in the investigation +// context's operation history, and a state-changing call by an agent is +// announced as agent.action_requested, then agent.action_completed or +// agent.action_failed, so the person sees it in the desktop as it happens. +json RecordedDispatch(const std::string& tool, mcp_contract::ToolRisk risk, const json& manifest, + const json& arguments, bool okFalseIsError) { + const context::CallerInfo caller = context::CurrentCaller(); + const bool announce = caller.caller == context::Caller::Agent && ChangesState(risk); + if (announce) events::Publish(events::types::AgentActionRequested, ActionEventData(tool, risk, arguments, caller).dump()); + const ULONGLONG started = GetTickCount64(); + json output = DispatchPrimitive(manifest, arguments); + const uint64_t duration = GetTickCount64() - started; + const bool failed = IsToolError(output, okFalseIsError); + if (announce) { + json data = ActionEventData(tool, risk, arguments, caller); + data["duration_ms"] = duration; + if (failed) { + const json& nested = output.contains("result") && output["result"].is_object() ? output["result"] : output; + data["error"] = nested.value("error", std::string("tool_failed")); + } + events::Publish(failed ? events::types::AgentActionFailed : events::types::AgentActionCompleted, data.dump()); + } + context::Global().RecordOperation(caller, tool, mcp_contract::RiskName(risk), failed ? "failed" : "completed", + duration); + return output; +} + +// A call refused before dispatch (authority, hidden tool) is history too. +void RecordRefusal(const std::string& tool, mcp_contract::ToolRisk risk, const json& arguments, const json& error) { + const context::CallerInfo caller = context::CurrentCaller(); + if (caller.caller == context::Caller::Agent && ChangesState(risk)) { + json data = ActionEventData(tool, risk, arguments, caller); + data["error"] = error.value("error", std::string("refused")); + events::Publish(events::types::AgentActionFailed, data.dump()); + } + context::Global().RecordOperation(caller, tool, mcp_contract::RiskName(risk), "refused", 0); +} + bool ResolveReferences(json& value, const json& evidence, std::string& error) { if (value.is_object() && value.contains("$from_step")) { if (!value["$from_step"].is_number_integer()) { @@ -542,7 +603,8 @@ json ExecuteSemantic(const std::string& wanted, } } - json output = DispatchPrimitive(steps[index].manifest, resolvedArguments); + json output = RecordedDispatch(steps[index].canonicalName, steps[index].risk, steps[index].manifest, + resolvedArguments, steps[index].manifest.value("ok_false_is_error", true)); evidence.push_back({ {"step", index}, {"tool", steps[index].canonicalName}, @@ -617,10 +679,17 @@ json CallToolScoped(const std::string& wanted, manifest.value("name", wanted), manifest.value("method", std::string("GET")), manifest.value("path", std::string())); - const Gate gate = CheckAuthority(manifest.value("name", wanted), risk, arguments, + const std::string canonical = manifest.value("name", wanted); + const Gate gate = CheckAuthority(canonical, risk, arguments, arguments.is_object() && arguments.value("mutation_permission", false), authority); - if (!gate.allowed) return ToolCallPayload(gate.error); - return ToolCallPayload(DispatchPrimitive(manifest, arguments), manifest.value("ok_false_is_error", true)); + if (!gate.allowed) { + RecordRefusal(canonical, EffectiveRiskForCall(canonical, risk, arguments), arguments, gate.error); + return ToolCallPayload(gate.error); + } + const bool okFalseIsError = manifest.value("ok_false_is_error", true); + return ToolCallPayload(RecordedDispatch(canonical, EffectiveRiskForCall(canonical, risk, arguments), manifest, + arguments, okFalseIsError), + okFalseIsError); } } // namespace diff --git a/core/api/response_contract.h b/core/api/response_contract.h index 24036552..866a9d15 100644 --- a/core/api/response_contract.h +++ b/core/api/response_contract.h @@ -31,4 +31,74 @@ inline json Error(const std::string& code, return result; } +// The common operation result (roadmap step 2.5). New services return it; +// existing routes keep their shape until they are migrated one by one. +// +// ok status == "completed" (kept for clients and +// mcp_tools::IsToolError that read `ok`) +// status completed | failed | refused | cancelled | timed_out +// error, cause stable code and human detail, only when not completed +// target {pid, generation} of the process that ran it +// duration_ms wall time spent in the operation +// limits the bounds applied (counts, sizes, timeouts) +// data what the operation produced +// evidence references other records can cite +// permissions_required authorities the call needed (empty for reads) +// rollback how to undo it, or null when nothing changed +struct Operation { + std::string status = "completed"; + std::string error; + std::string cause; + json target = json::object(); + uint64_t durationMs = 0; + json limits = json::object(); + json data = json::object(); + json evidence = json::array(); + json permissionsRequired = json::array(); + json rollback = nullptr; +}; + +inline json OperationResult(const Operation& operation) { + json result = {{"ok", operation.status == "completed"}, + {"status", operation.status}, + {"target", operation.target}, + {"duration_ms", operation.durationMs}, + {"limits", operation.limits}, + {"data", operation.data}, + {"evidence", operation.evidence}, + {"permissions_required", operation.permissionsRequired}, + {"rollback", operation.rollback}}; + if (operation.status != "completed") { + result["error"] = IsStableErrorCode(operation.error) ? operation.error : std::string("operation_failed"); + if (!operation.cause.empty()) result["cause"] = operation.cause; + } + return result; +} + +// Migrates a legacy route result to the contract without breaking clients: +// fields the route already sets (ok, error, its own data keys) are kept as +// they are; only the missing contract fields are added. status is derived +// from ok when the route did not set one. +inline void Annotate(json& body, const Operation& meta) { + if (!body.is_object()) return; + if (!body.contains("status")) + body["status"] = body.value("ok", false) ? "completed" : (meta.status == "completed" ? "failed" : meta.status); + if (!body.contains("target")) body["target"] = meta.target; + if (!body.contains("duration_ms")) body["duration_ms"] = meta.durationMs; + if (!body.contains("limits")) body["limits"] = meta.limits; + if (!body.contains("evidence")) body["evidence"] = meta.evidence; + if (!body.contains("permissions_required")) body["permissions_required"] = meta.permissionsRequired; + if (!body.contains("rollback")) body["rollback"] = meta.rollback; + if (!body.value("ok", false) && !meta.cause.empty() && !body.contains("cause")) body["cause"] = meta.cause; +} + +inline Operation Refused(const std::string& code, const std::string& cause, json target = json::object()) { + Operation operation; + operation.status = "refused"; + operation.error = code; + operation.cause = cause; + operation.target = std::move(target); + return operation; +} + } // namespace api::response diff --git a/core/api/routes.h b/core/api/routes.h index 42a8fee6..9e90c2ed 100644 --- a/core/api/routes.h +++ b/core/api/routes.h @@ -40,6 +40,7 @@ void RegisterDissectRoutes(RouteRegistrar& svr); void RegisterBatchRoutes(RouteRegistrar& svr); void RegisterActionRoutes(RouteRegistrar& svr); void RegisterEventRoutes(RouteRegistrar& svr); +void RegisterContextRoutes(RouteRegistrar& svr); void RegisterPointerMapRoutes(RouteRegistrar& svr); void RegisterTraceRoutes(RouteRegistrar& svr); void RegisterGhidraRoutes(RouteRegistrar& svr); diff --git a/core/api/routes_batch.cpp b/core/api/routes_batch.cpp index 3b44b7e8..2ecb731a 100644 --- a/core/api/routes_batch.cpp +++ b/core/api/routes_batch.cpp @@ -295,7 +295,10 @@ json RunOp(const json& step) { if (op == "patch_revert") { int id = step.at("id").get(); - if (!patch::Revert(id)) return {{"ok", false}, {"error", "unknown_patch"}}; + std::string detail; + const auto result = patch::RevertChecked(id, &detail); + if (result != patch::RevertResult::Reverted) + return {{"ok", false}, {"error", patch::RevertResultName(result)}, {"cause", detail}}; return {{"ok", true}}; } diff --git a/core/api/routes_context.cpp b/core/api/routes_context.cpp new file mode 100644 index 00000000..2216ba98 --- /dev/null +++ b/core/api/routes_context.cpp @@ -0,0 +1,164 @@ +#include "routes.h" +#include "response_contract.h" +#include "../context/caller.h" +#include "../context/context_api.h" +#include "../context/investigation_context.h" +#include "../events/events.h" +#include "../debugger/debugger.h" +#include "../process/modules.h" + +#include + +#include + +#include +#include +#include + +using json = nlohmann::json; + +namespace api { +namespace { + +uint64_t OwnProcessGeneration() { + FILETIME creation{}, exitTime{}, kernel{}, user{}; + if (!GetProcessTimes(GetCurrentProcess(), &creation, &exitTime, &kernel, &user)) return 0; + ULARGE_INTEGER value{}; + value.LowPart = creation.dwLowDateTime; + value.HighPart = creation.dwHighDateTime; + return value.QuadPart; +} + +context::InvestigationContext& Context() { + static std::once_flag once; + std::call_once(once, [] { + auto& shared = context::Global(); + context::Identity identity; + identity.pid = GetCurrentProcessId(); + identity.generation = OwnProcessGeneration(); +#ifdef _WIN64 + identity.architecture = "x64"; +#else + identity.architecture = "x86"; +#endif + char path[MAX_PATH] = {}; + GetModuleFileNameA(nullptr, path, MAX_PATH); + identity.executablePath = path; + const std::string full(path); + const size_t slash = full.find_last_of("\\/"); + identity.processName = slash == std::string::npos ? full : full.substr(slash + 1); + shared.SetIdentity(identity); + shared.SetPublisher([](const std::string& type, const json& data) { events::Publish(type, data.dump()); }); + events::Publish(events::types::RuntimeStarted, + json{{"pid", identity.pid}, {"generation", identity.generation}, + {"architecture", identity.architecture}, {"process_name", identity.processName}} + .dump()); + }); + return context::Global(); +} + +// Module changes and breakpoint hits are published when they are looked at +// (each context route), never from the loader notification or the +// debugger's exception path, which must not allocate or take locks. +void RefreshModules(context::InvestigationContext& shared) { + std::vector modules; + for (const auto& module : process::ListModules()) + modules.push_back({module.name, static_cast(module.base), static_cast(module.size)}); + shared.UpdateModules(std::move(modules)); + + std::vector counts; + for (const auto& breakpoint : dbg::ListBreakpoints()) + counts.push_back({std::to_string(breakpoint.id), static_cast(breakpoint.address), breakpoint.hitCount}); + shared.UpdateBreakpointHits("runtime", counts); +} + +void Send(httplib::Response& res, const context::api::Reply& reply) { + res.status = reply.status; + res.set_content(reply.body.dump(), "application/json"); +} + +bool QueryUnsigned(const httplib::Request& req, const char* name, uint64_t& value) { + if (!req.has_param(name)) return true; + try { + size_t used = 0; + const std::string text = req.get_param_value(name); + value = std::stoull(text, &used, 0); + return used == text.size(); + } catch (...) { + return false; + } +} + +} // namespace + +void RegisterContextRoutes(httplib::Server& svr) { + // Identity, publisher and the module baseline exist from runtime start, + // so the first module.* events describe changes after attach. + RefreshModules(Context()); + + // Read-only snapshot of the shared investigation context. + svr.Get("/context", [](const httplib::Request& req, httplib::Response& res) { + auto& shared = Context(); + uint64_t limit = 100, since = 0, expected = 0; + if (!QueryUnsigned(req, "limit", limit) || !QueryUnsigned(req, "since", since) || + !QueryUnsigned(req, "expected_generation", expected)) + return Send(res, context::api::InvalidQuery(shared, "invalid_context_query")); + RefreshModules(shared); + Send(res, context::api::GetContext(shared, limit, since, expected, events::LatestId())); + }); + + // Typed events after `since`, with explicit gap reporting. + svr.Get("/context/events", [](const httplib::Request& req, httplib::Response& res) { + auto& shared = Context(); + uint64_t since = 0, limit = 128, expected = 0; + if (!QueryUnsigned(req, "since", since) || !QueryUnsigned(req, "limit", limit) || + !QueryUnsigned(req, "expected_generation", expected)) + return Send(res, context::api::InvalidQuery(shared, "invalid_event_query")); + if (!shared.GenerationMatches(expected)) return Send(res, context::api::StaleGeneration(shared)); + limit = std::clamp(limit, 1, 256); + RefreshModules(shared); + const auto page = events::Read(since, static_cast(limit)); + json rows = json::array(); + for (const auto& event : page.events) { + json data; + try { data = json::parse(event.dataJson); } catch (...) { data = event.dataJson; } + rows.push_back({{"id", event.id}, {"timestamp_ms", event.timestampMs}, + {"type", event.type}, {"data", std::move(data)}}); + } + response::Operation operation; + operation.target = shared.TargetJson(); + operation.limits = {{"limit", limit}, {"ring_size", events::kMaxEvents}}; + operation.data = {{"events", std::move(rows)}, + {"latest_id", page.latestId}, + {"oldest_id", page.oldestId}, + {"missed", page.missed}, + {"gap", page.gap}}; + Send(res, {200, response::OperationResult(operation)}); + }); + + // An observation, correlation or hypothesis stated by the caller. State + // tier over MCP: it writes Cortex's own records, never the target. + svr.Post("/context/observation", [](const httplib::Request& req, httplib::Response& res) { + auto& shared = Context(); + RefreshModules(shared); + Send(res, context::api::AddObservation(shared, context::CurrentCaller(), req.body)); + }); + + // The desktop's own scan results or debugger state (desktop only). + svr.Post("/context/share", [](const httplib::Request& req, httplib::Response& res) { + auto& shared = Context(); + RefreshModules(shared); + Send(res, context::api::ShareState(shared, context::CurrentCaller(), req.body)); + }); + + // The person selected an address in the desktop. Refused unless the + // request came through the desktop's private channel; deliberately + // absent from the MCP manifest. + svr.Post("/context/select", [](const httplib::Request& req, httplib::Response& res) { + auto& shared = Context(); + RefreshModules(shared); + Send(res, context::api::SelectAddress(shared, context::CurrentCaller(), req.body)); + }); +} + +} // namespace api diff --git a/core/api/routes_debug.cpp b/core/api/routes_debug.cpp index 6997d761..2ae371b3 100644 --- a/core/api/routes_debug.cpp +++ b/core/api/routes_debug.cpp @@ -3,6 +3,8 @@ #include "../debugger/debugger.h" #include "../overlay/overlay.h" #include "../action/action.h" +#include "../context/investigation_context.h" +#include "response_contract.h" #include #include @@ -143,6 +145,16 @@ void RegisterDebugRoutes(httplib::Server& svr) { json out; out["ok"] = id >= 0; if (id >= 0) out["id"] = id; else out["error"] = "add_breakpoint_failed"; + response::Operation meta; + meta.target = context::Global().TargetJson(); + meta.limits = {{"size", size}, {"process_global", processGlobal}}; + meta.permissionsRequired = json::array({"control"}); + if (id >= 0) { + meta.evidence = json::array({"breakpoint:" + std::to_string(id)}); + meta.rollback = {{"tool", "debug_breakpoint_delete"}, {"_path", {{"id", id}}}, + {"journal", "actions_rollback"}}; + } + response::Annotate(out, meta); res.set_content(out.dump(), "application/json"); overlay::LogApiCall("POST /debug/breakpoint " + kindStr + " @ " + HexAddr(address)); } catch (const std::exception& e) { diff --git a/core/api/routes_events.cpp b/core/api/routes_events.cpp index c4c6572e..6825dfe5 100644 --- a/core/api/routes_events.cpp +++ b/core/api/routes_events.cpp @@ -30,7 +30,8 @@ void RegisterEventRoutes(httplib::Server& svr) { if (limit > 256) limit = 256; json rows = json::array(); - for (const auto& event : events::Since(since, limit)) { + const auto page = events::Read(since, limit); + for (const auto& event : page.events) { json data; try { data = json::parse(event.dataJson); } catch (...) { data = event.dataJson; } @@ -39,7 +40,8 @@ void RegisterEventRoutes(httplib::Server& svr) { {"type", event.type}, {"data", std::move(data)}}); } - res.set_content(json{{"ok", true}, {"events", std::move(rows)}, {"latest_id", events::LatestId()}}.dump(), + res.set_content(json{{"ok", true}, {"events", std::move(rows)}, {"latest_id", page.latestId}, + {"oldest_id", page.oldestId}, {"missed", page.missed}, {"gap", page.gap}}.dump(), "application/json"); }); svr.Get("/events", [](const httplib::Request& req, httplib::Response& res) { diff --git a/core/api/routes_patch.cpp b/core/api/routes_patch.cpp index b9a813b8..269da81a 100644 --- a/core/api/routes_patch.cpp +++ b/core/api/routes_patch.cpp @@ -206,9 +206,18 @@ void RegisterPatchRoutes(httplib::Server& svr) { svr.Delete(R"(/patch/(\d+))", [](const httplib::Request& req, httplib::Response& res) { auto mutation = action::LockMutations(); int id = std::stoi(req.matches[1]); - bool ok = patch::Revert(id); - res.status = ok ? 200 : 404; - res.set_content(json{{"ok", ok}}.dump(), "application/json"); + std::string detail; + const auto result = patch::RevertChecked(id, &detail); + const bool ok = result == patch::RevertResult::Reverted; + json out{{"ok", ok}}; + if (!ok) { + out["error"] = patch::RevertResultName(result); + if (!detail.empty()) out["cause"] = detail; + } + res.status = ok ? 200 + : result == patch::RevertResult::UnknownPatch ? 404 + : result == patch::RevertResult::WriteFailed ? 500 : 409; + res.set_content(out.dump(), "application/json"); overlay::LogApiCall("DELETE /patch/" + std::to_string(id)); }); diff --git a/core/api/routes_scan.cpp b/core/api/routes_scan.cpp index c5e59d95..f57855f2 100644 --- a/core/api/routes_scan.cpp +++ b/core/api/routes_scan.cpp @@ -1,6 +1,10 @@ #include "routes.h" #include "../memory/scan.h" #include "../overlay/overlay.h" +#include "../context/investigation_context.h" +#include "response_contract.h" + +#include #include #include @@ -29,6 +33,16 @@ json ScanResultToJson(const memscan::ScanResult& r) { return out; } +uint64_t ElapsedMs(std::chrono::steady_clock::time_point started) { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now() - started).count()); +} + +// Scans read the target; pausing it is a control operation (mcp_contract). +json ScanPermissions(bool pauseProcess) { + return pauseProcess ? json::array({"control"}) : json::array(); +} + std::optional OptScalar(const json& body, const char* key) { if (!body.contains(key) || body.at(key).is_null()) return std::nullopt; const json& value = body.at(key); @@ -46,6 +60,7 @@ std::optional OptAddress(const json& body, const char* key) { void RegisterScanRoutes(httplib::Server& svr) { svr.Post("/scan/new", [](const httplib::Request& req, httplib::Response& res) { + const auto started = std::chrono::steady_clock::now(); try { json body = json::parse(req.body); std::string type = body.at("type").get(); @@ -74,6 +89,13 @@ void RegisterScanRoutes(httplib::Server& svr) { } else { out["error"] = (type == "all" && !value.has_value()) ? "all_requires_value" : "unknown_type"; } + response::Operation meta; + meta.target = context::Global().TargetJson(); + meta.durationMs = ElapsedMs(started); + meta.limits = {{"results_truncated", truncated}}; + meta.permissionsRequired = ScanPermissions(options.pauseProcess); + if (id >= 0) meta.evidence = json::array({"scan:" + std::to_string(id)}); + response::Annotate(out, meta); res.set_content(out.dump(), "application/json"); overlay::LogApiCall("POST /scan/new " + type + (value.has_value() ? " =" + *value : " (unknown)")); } catch (const std::exception& e) { @@ -83,6 +105,7 @@ void RegisterScanRoutes(httplib::Server& svr) { }); svr.Post("/scan/next", [](const httplib::Request& req, httplib::Response& res) { + const auto started = std::chrono::steady_clock::now(); try { json body = json::parse(req.body); int id = body.at("scan_id").get(); @@ -103,6 +126,12 @@ void RegisterScanRoutes(httplib::Server& svr) { json out; out["ok"] = ok; if (ok) out["count"] = count; else out["error"] = "scan_not_found"; + response::Operation meta; + meta.target = context::Global().TargetJson(); + meta.durationMs = ElapsedMs(started); + meta.permissionsRequired = ScanPermissions(pauseProcess); + if (ok) meta.evidence = json::array({"scan:" + std::to_string(id)}); + response::Annotate(out, meta); res.set_content(out.dump(), "application/json"); overlay::LogApiCall("POST /scan/next #" + std::to_string(id) + " " + filterStr); } catch (const std::exception& e) { diff --git a/core/api/routes_status.cpp b/core/api/routes_status.cpp index 1c9dbb50..60f95a88 100644 --- a/core/api/routes_status.cpp +++ b/core/api/routes_status.cpp @@ -181,7 +181,7 @@ json BuildToolsManifest() { {"body", {{"min_size", "optional, default 16: minimum size in bytes"}, {"module", "optional: restricts to this module, otherwise all executable memory"}}}}); j.push_back({{"name", "disasm"}, {"method", "GET"}, {"path", "/disasm"}, - {"description", "Disassembles x86-32 code starting at an address (Intel syntax)."}, + {"description", "Disassembles code of the runtime's bitness (x86 or x64) starting at an address (Intel syntax)."}, {"query", {{"address", "required: start address"}, {"count", "optional, default 20, max 500: number of instructions"}}}}); j.push_back({{"name", "debug_breakpoint_add"}, {"method", "POST"}, {"path", "/debug/breakpoint"}, @@ -292,6 +292,30 @@ json BuildToolsManifest() { j.push_back({{"name", "project_knowledge_verify"}, {"method", "POST"}, {"path", "/project/knowledge/{id}/verify"}, {"description", "Rerun bounded read-only byte invariants against the live process. A match validates bytes, not semantic claims."}}); + j.push_back({{"name", "context_get"}, {"method", "GET"}, {"path", "/context"}, + {"description", "Shared investigation context of this process: pid and generation, modules, the address the person " + "selected in the Cortex desktop, observations recorded by the person or an agent (with provenance), " + "and recent tool calls. Records are caller assertions, not verified facts."}, + {"query", {{"limit", "optional, entries returned, 1-512, default 100"}, + {"since", "optional, only entries with a larger sequence"}, + {"expected_generation", "optional, refuse with stale_target_generation if the process changed"}}}}); + j.push_back({{"name", "context_events"}, {"method", "GET"}, {"path", "/context/events"}, + {"description", "Typed runtime events after an id (module.loaded/unloaded, context.*, knowledge.updated, " + "evidence.created, agent.action_*). gap=true means events were evicted: resynchronize with context_get."}, + {"query", {{"since", "optional, last event id seen, default 0"}, + {"limit", "optional, 1-256, default 128"}, + {"expected_generation", "optional, refuse with stale_target_generation if the process changed"}}}}); + j.push_back({{"name", "context_observation_add"}, {"method", "POST"}, {"path", "/context/observation"}, + {"description", "Record an observation, correlation, hypothesis or note in the shared investigation context so the " + "person sees it in Cortex. It is stored as a client assertion with your session as provenance; " + "it never upgrades to a verified fact. Writes only Cortex's own records."}, + {"body", {{"statement", "required, at most 2048 bytes"}, + {"kind", "optional: observation (default), correlation, hypothesis or note"}, + {"address", "optional, integer or hex string"}, + {"data", {{"type", "object"}, {"description", "optional structured data, at most 4096 serialized bytes"}}}, + {"evidence", {{"type", "array"}, {"description", "optional, up to 16 {source, reference} objects"}}}, + {"expected_generation", {{"type", "integer"}, {"minimum", 0}}}}}}); + j.push_back({{"name", "project_address_set"}, {"method", "POST"}, {"path", "/project/address"}, {"description", "Saves/overwrites a named address."}, {"body", {{"name", "required"}, {"address", "required"}, {"type", "optional"}, {"notes", "optional"}}}}); @@ -846,8 +870,17 @@ json BuildOpenApiDocument() { void RegisterStatusRoutes(httplib::Server& svr) { svr.Get("/status", [](const httplib::Request&, httplib::Response& res) { - json j{{"status", "ok"}, {"pid", GetCurrentProcessId()}, {"process_name", ProcessName()}, - {"port", GetPort()}, {"uptime_ms", GetUptimeMs()}}; + // generation is the process creation time, the value the desktop's + // target descriptor carries: a client verifies both pid and + // generation so a recycled PID cannot pass for its old target. + uint64_t generation = 0; + { + FILETIME creation{}, exitTime{}, kernel{}, user{}; + if (GetProcessTimes(GetCurrentProcess(), &creation, &exitTime, &kernel, &user)) + generation = (static_cast(creation.dwHighDateTime) << 32) | creation.dwLowDateTime; + } + json j{{"status", "ok"}, {"pid", GetCurrentProcessId()}, {"generation", generation}, + {"process_name", ProcessName()}, {"port", GetPort()}, {"uptime_ms", GetUptimeMs()}}; res.set_content(j.dump(), "application/json"); overlay::LogApiCall("GET /status"); }); diff --git a/core/api/semantic_tools.h b/core/api/semantic_tools.h index adcc5a6c..21af6511 100644 --- a/core/api/semantic_tools.h +++ b/core/api/semantic_tools.h @@ -50,7 +50,7 @@ inline json Tool(const char* name, const char* description, json primitives) { inline json Catalog() { json tools = json::array(); - tools.push_back(Tool("capture_runtime_state", "Plan a complete runtime inventory and checkpoint.", {"health","modules","memory_regions","debug_threads","project_get","window_get","actions_list","patch_list","freeze_list","session_export"})); + tools.push_back(Tool("capture_runtime_state", "Plan a complete runtime inventory and checkpoint.", {"health","modules","memory_regions","debug_threads","project_get","window_get","actions_list","patch_list","freeze_list","session_export","context_get","context_events"})); tools.push_back(Tool("observe_visual_state", "Plan screenshot and OCR observation without assuming any HUD concept.", {"screenshot","ocr","window_get"})); tools.push_back(Tool("record_interaction_window", "Plan a bounded observation window combining input, snapshots, watches, screenshots, and session export.", {"input_record_start","input_record_stop","snapshot_create","snapshot_diff","watch_events","screenshot","session_export"})); tools.push_back(Tool("discover_changing_values", "Plan unknown-value scans around labelled actions.", {"scan_new","scan_next","scan_results"})); diff --git a/core/api/server.cpp b/core/api/server.cpp index d1949202..a56f4710 100644 --- a/core/api/server.cpp +++ b/core/api/server.cpp @@ -259,6 +259,7 @@ bool Start(int port, const std::string& configuredToken, bool httpEnabled) { RegisterBatchRoutes(routes); RegisterActionRoutes(routes); RegisterEventRoutes(routes); + RegisterContextRoutes(routes); RegisterPointerMapRoutes(routes); RegisterTraceRoutes(routes); RegisterGhidraRoutes(routes); diff --git a/core/artifacts/annotations.cpp b/core/artifacts/annotations.cpp new file mode 100644 index 00000000..98f285d1 --- /dev/null +++ b/core/artifacts/annotations.cpp @@ -0,0 +1,113 @@ +#include "annotations.h" + +#include "../services/user_data.h" + +#include +#include +#include + +namespace cortex::artifacts { +namespace { + +using json = nlohmann::json; + +constexpr size_t kMaxText = 4096; +constexpr size_t kMaxAnnotations = 100000; + +json Load(const std::filesystem::path& path) { + std::ifstream in(path, std::ios::binary); + if (!in) return nullptr; + json data = json::parse(in, nullptr, false); + return data.is_object() ? data : json(nullptr); +} + +bool HexDigits(const std::string& text) { + return !text.empty() && text.find_first_not_of("0123456789abcdefABCDEF") == std::string::npos; +} + +} // namespace + +bool ValidSha256(const std::string& text) { + return text.size() == 64 && text.find_first_not_of("0123456789abcdef") == std::string::npos; +} + +bool ValidAnnotationLocation(const std::string& location) { + if (location.size() > 512 || location.find_first_of("\r\n") != std::string::npos) return false; + if (location.rfind("rva:0x", 0) == 0) return location.size() <= 16 && HexDigits(location.substr(6)); + if (location.rfind("token:0x", 0) == 0) return location.size() == 16 && HexDigits(location.substr(8)); + if (location.rfind("js:", 0) == 0) return location.size() > 3; + return false; +} + +std::filesystem::path AnnotationStore::FileFor(const std::string& sha256) const { + return directory_ / (sha256 + ".json"); +} + +json AnnotationStore::Get(const std::string& sha256) const { + if (!ValidSha256(sha256)) return {{"ok", false}, {"error", "invalid_sha256"}}; + std::lock_guard lock(mutex_); + json data = Load(FileFor(sha256)); + if (data.is_null()) data = {{"revision", 0}, {"annotations", json::array()}}; + return {{"ok", true}, + {"sha256", sha256}, + {"revision", data.value("revision", 0)}, + {"annotations", data.value("annotations", json::array())}, + {"note", "names and comments written by people or agents; not derived or verified by Cortex"}}; +} + +json AnnotationStore::Put(const std::string& sha256, const std::string& location, const std::string& kind, + const std::string& text, const std::string& source, int64_t expectedRevision) { + if (!ValidSha256(sha256)) return {{"ok", false}, {"error", "invalid_sha256"}}; + if (!ValidAnnotationLocation(location)) return {{"ok", false}, {"error", "invalid_location"}}; + if (kind != "name" && kind != "comment") return {{"ok", false}, {"error", "invalid_kind"}}; + if (text.size() > kMaxText || (kind == "name" && text.find_first_of("\r\n") != std::string::npos)) + return {{"ok", false}, {"error", "invalid_text"}}; + std::lock_guard lock(mutex_); + json data = Load(FileFor(sha256)); + if (data.is_null()) data = {{"revision", 0}, {"annotations", json::array()}}; + const int64_t revision = data.value("revision", int64_t{0}); + if (expectedRevision >= 0 && expectedRevision != revision) + return {{"ok", false}, {"error", "revision_conflict"}, {"revision", revision}}; + json annotations = json::array(); + bool replaced = false; + for (const auto& entry : data.value("annotations", json::array())) { + if (entry.value("location", std::string()) == location && entry.value("kind", std::string()) == kind) { + replaced = true; + continue; // replaced below, or removed when text is empty + } + annotations.push_back(entry); + } + if (!text.empty()) { + if (annotations.size() >= kMaxAnnotations) return {{"ok", false}, {"error", "too_many_annotations"}}; + const auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + annotations.push_back({{"location", location}, {"kind", kind}, {"text", text}, {"source", source}, + {"updated_ms", now}}); + } else if (!replaced) { + return {{"ok", false}, {"error", "annotation_not_found"}}; + } + data["revision"] = revision + 1; + data["annotations"] = annotations; + std::error_code ignored; + std::filesystem::create_directories(directory_, ignored); + std::string error; + if (!services::WriteFileAtomically(FileFor(sha256), data.dump(1), &error)) return {{"ok", false}, {"error", error}}; + return {{"ok", true}, {"sha256", sha256}, {"revision", revision + 1}, {"location", location}, {"kind", kind}, + {"removed", text.empty()}}; +} + +std::map AnnotationStore::NativeNames(const std::string& sha256) const { + std::map names; + const json data = Get(sha256); + for (const auto& entry : data.value("annotations", json::array())) { + const std::string location = entry.value("location", std::string()); + if (entry.value("kind", std::string()) != "name" || location.rfind("rva:0x", 0) != 0) continue; + try { + names[static_cast(std::stoul(location.substr(6), nullptr, 16))] = entry.value("text", std::string()); + } catch (...) { + } + } + return names; +} + +} // namespace cortex::artifacts diff --git a/core/artifacts/annotations.h b/core/artifacts/annotations.h new file mode 100644 index 00000000..63047065 --- /dev/null +++ b/core/artifacts/annotations.h @@ -0,0 +1,50 @@ +#pragma once + +// Annotations on artifacts (step 4d): names and comments a person or an +// agent attaches to a location, stored per artifact SHA-256 so they follow +// the file's content, not its path. Locations: +// rva:0x1234 native code or data (PE) +// token:0x06000004 .NET metadata token +// js:path:line:col JavaScript source position +// Records are what someone wrote, never facts derived by Cortex. Each +// change bumps the artifact's revision; a stale expected revision is +// refused so concurrent writers do not overwrite each other. + +#include + +#include +#include +#include +#include + +namespace cortex::artifacts { + +class AnnotationStore { +public: + explicit AnnotationStore(std::filesystem::path directory) : directory_(std::move(directory)) {} + + // {"ok", "sha256", "revision", "annotations": [...]}; an artifact with + // no file yet has revision 0 and no entries. + nlohmann::json Get(const std::string& sha256) const; + + // Sets (or with empty `text`, removes) the `kind` ("name" | "comment") + // annotation at `location`. `source` is who wrote it ("desktop", + // "agent", "cli"). `expectedRevision` < 0 skips the check. + nlohmann::json Put(const std::string& sha256, const std::string& location, const std::string& kind, + const std::string& text, const std::string& source, int64_t expectedRevision); + + // RVA -> name for rva: locations of an artifact. + std::map NativeNames(const std::string& sha256) const; + + const std::filesystem::path& Directory() const { return directory_; } + +private: + std::filesystem::path FileFor(const std::string& sha256) const; + std::filesystem::path directory_; + mutable std::mutex mutex_; +}; + +bool ValidSha256(const std::string& text); +bool ValidAnnotationLocation(const std::string& location); + +} // namespace cortex::artifacts diff --git a/core/artifacts/artifact.cpp b/core/artifacts/artifact.cpp new file mode 100644 index 00000000..524093fb --- /dev/null +++ b/core/artifacts/artifact.cpp @@ -0,0 +1,318 @@ +#include "artifact.h" + +#include "sha256.h" + +#include +#include +#include +#include +#include +#include + +namespace cortex::artifacts { +namespace { + +class FileSource final : public ByteSource { +public: + FileSource(std::string path, uint64_t size, std::ifstream stream) + : path_(std::move(path)), size_(size), stream_(std::move(stream)) {} + + uint64_t Size() const override { return size_; } + std::string Name() const override { return path_; } + + bool Read(uint64_t offset, void* buffer, size_t size) const override { + if (offset > size_ || size > size_ - offset) return false; + if (size == 0) return true; + std::lock_guard lock(mutex_); + stream_.clear(); + stream_.seekg(static_cast(offset)); + stream_.read(static_cast(buffer), static_cast(size)); + return static_cast(stream_.gcount()) == size; + } + +private: + std::string path_; + uint64_t size_; + mutable std::mutex mutex_; + mutable std::ifstream stream_; +}; + +class MemorySource final : public ByteSource { +public: + MemorySource(std::vector bytes, std::string name) : bytes_(std::move(bytes)), name_(std::move(name)) {} + uint64_t Size() const override { return bytes_.size(); } + std::string Name() const override { return name_; } + bool Read(uint64_t offset, void* buffer, size_t size) const override { + if (offset > bytes_.size() || size > bytes_.size() - offset) return false; + if (size) std::memcpy(buffer, bytes_.data() + offset, size); + return true; + } + +private: + std::vector bytes_; + std::string name_; +}; + +class SliceSource final : public ByteSource { +public: + SliceSource(ByteSourcePtr parent, uint64_t offset, uint64_t size, std::string name) + : parent_(std::move(parent)), offset_(offset), size_(size), name_(std::move(name)) {} + uint64_t Size() const override { return size_; } + std::string Name() const override { return name_; } + bool Read(uint64_t offset, void* buffer, size_t size) const override { + if (offset > size_ || size > size_ - offset) return false; + return parent_->Read(offset_ + offset, buffer, size); + } + +private: + ByteSourcePtr parent_; + uint64_t offset_; + uint64_t size_; + std::string name_; +}; + +} // namespace + +std::vector ByteSource::ReadSome(uint64_t offset, size_t size) const { + if (offset >= Size()) return {}; + const uint64_t available = Size() - offset; + std::vector bytes(static_cast(std::min(available, size))); + if (!bytes.empty() && !Read(offset, bytes.data(), bytes.size())) bytes.clear(); + return bytes; +} + +bool ByteSource::ReadCString(uint64_t offset, size_t max, std::string& text) const { + text.clear(); + const auto bytes = ReadSome(offset, max); + for (uint8_t byte : bytes) { + if (byte == 0) return true; + text.push_back(static_cast(byte)); + } + return false; // no terminator within `max` (or out of range) +} + +ByteSourcePtr OpenFile(const std::string& path, std::string* error) { + std::error_code fsError; + const auto native = std::filesystem::u8path(path); + if (!std::filesystem::is_regular_file(native, fsError)) { + if (error) *error = "not_a_regular_file"; + return {}; + } + const uint64_t size = std::filesystem::file_size(native, fsError); + if (fsError) { + if (error) *error = "file_size_unavailable"; + return {}; + } + std::ifstream stream(native, std::ios::binary); + if (!stream) { + if (error) *error = "file_not_readable"; + return {}; + } + return std::make_shared(path, size, std::move(stream)); +} + +ByteSourcePtr FromBytes(std::vector bytes, std::string name) { + return std::make_shared(std::move(bytes), std::move(name)); +} + +ByteSourcePtr Slice(ByteSourcePtr parent, uint64_t offset, uint64_t size, std::string name) { + if (!parent || offset > parent->Size() || size > parent->Size() - offset) return {}; + return std::make_shared(std::move(parent), offset, size, std::move(name)); +} + +std::string Sha256Of(const ByteSource& source) { + Sha256 hash; + std::vector buffer(1 << 20); + for (uint64_t offset = 0; offset < source.Size();) { + const size_t take = static_cast(std::min(buffer.size(), source.Size() - offset)); + if (!source.Read(offset, buffer.data(), take)) return {}; + hash.Update(buffer.data(), take); + offset += take; + } + return Sha256::Hex(hash.Final()); +} + +const char* CapabilityStatusName(CapabilityStatus status) { + switch (status) { + case CapabilityStatus::Validated: return "validated"; + case CapabilityStatus::Experimental: return "experimental"; + case CapabilityStatus::Unavailable: break; + } + return "unavailable"; +} + +json CapabilitiesJson(const std::vector& capabilities) { + json rows = json::object(); + for (const auto& capability : capabilities) { + json row = {{"status", CapabilityStatusName(capability.status)}}; + if (!capability.fixture.empty()) row["fixture"] = capability.fixture; + if (!capability.note.empty()) row["note"] = capability.note; + rows[capability.name] = std::move(row); + } + for (const char* name : {"static", "decompile", "live"}) + if (!rows.contains(name)) rows[name] = {{"status", "unavailable"}}; + return rows; +} + +bool ReadU16(const ByteSource& source, uint64_t offset, uint16_t& value) { + uint8_t b[2]; + if (!source.Read(offset, b, 2)) return false; + value = static_cast(b[0] | (b[1] << 8)); + return true; +} + +bool ReadU32(const ByteSource& source, uint64_t offset, uint32_t& value) { + uint8_t b[4]; + if (!source.Read(offset, b, 4)) return false; + value = uint32_t(b[0]) | (uint32_t(b[1]) << 8) | (uint32_t(b[2]) << 16) | (uint32_t(b[3]) << 24); + return true; +} + +bool ReadU64(const ByteSource& source, uint64_t offset, uint64_t& value) { + uint32_t low = 0, high = 0; + if (!ReadU32(source, offset, low) || !ReadU32(source, offset + 4, high)) return false; + value = (uint64_t(high) << 32) | low; + return true; +} + +std::string HexU64(uint64_t value) { + char buffer[24]; + std::snprintf(buffer, sizeof(buffer), "0x%llx", static_cast(value)); + return buffer; +} + +void Registry::Add(ProviderPtr provider) { + if (provider) providers_.push_back(std::move(provider)); +} + +ProviderPtr Registry::Detect(const ByteSource& source) const { + ProviderPtr best; + int bestScore = 0; + for (const auto& provider : providers_) { + const int score = provider->Detect(source); + if (score > bestScore) { + bestScore = score; + best = provider; + } + } + return best; +} + +ProviderPtr Registry::Find(const std::string& name) const { + for (const auto& provider : providers_) + if (name == provider->Name()) return provider; + return {}; +} + +namespace { + +json IdentityJson(const ByteSource& source) { + return {{"name", source.Name()}, {"size", source.Size()}, {"sha256", Sha256Of(source)}}; +} + +} // namespace + +json Registry::Inspect(const ByteSource& source, const InspectOptions& options) const { + json result = {{"schema", "cortex.artifact.v1"}, {"identity", IdentityJson(source)}}; + const auto provider = Detect(source); + if (!provider) { + result["ok"] = false; + result["error"] = "unknown_format"; + result["providers_tried"] = providers_.size(); + return result; + } + result["provider"] = {{"name", provider->Name()}, {"version", provider->Version()}}; + result["capabilities"] = CapabilitiesJson(provider->Capabilities()); + std::string error; + json data; + try { + data = provider->Inspect(source, options, error); + } catch (const std::exception& ex) { + // A provider bug must not take the host down with it. + error = std::string("provider_exception:") + ex.what(); + data = nullptr; + } + if (data.is_null()) { + result["ok"] = false; + result["error"] = error.empty() ? "inspect_failed" : error; + return result; + } + result["ok"] = true; + result["format"] = data.value("format", std::string(provider->Formats().empty() ? "" : provider->Formats()[0])); + result["data"] = std::move(data); + return result; +} + +json Registry::ListChildren(const ByteSource& source, const InspectOptions& options) const { + json result = {{"schema", "cortex.artifact.children.v1"}, {"identity", IdentityJson(source)}}; + const auto provider = Detect(source); + if (!provider) { + result["ok"] = false; + result["error"] = "unknown_format"; + return result; + } + result["provider"] = {{"name", provider->Name()}, {"version", provider->Version()}}; + std::string error; + std::vector children; + try { + children = provider->Children(source, options, error); + } catch (const std::exception& ex) { + error = std::string("provider_exception:") + ex.what(); + } + if (!error.empty()) { + result["ok"] = false; + result["error"] = error; + return result; + } + json rows = json::array(); + for (const auto& child : children) { + json row = {{"name", child.name}, {"size", child.size}, {"readable", child.readable}}; + if (!child.note.empty()) row["note"] = child.note; + if (!child.compression.empty()) { + row["compression"] = child.compression; + row["stored_size"] = child.storedSize; + row["crc32"] = HexU64(child.crc32); + } + rows.push_back(std::move(row)); + } + result["ok"] = true; + result["children"] = std::move(rows); + result["truncated"] = children.size() >= options.maxEntries; + return result; +} + +ByteSourcePtr Provider::OpenChild(ByteSourcePtr container, const Child& child, std::string& error) const { + auto slice = Slice(container, child.offset, child.size, container->Name() + "!" + child.name); + if (!slice) error = "child_out_of_range"; + return slice; +} + +ByteSourcePtr Registry::OpenChild(ByteSourcePtr container, const std::string& childName, std::string& error, + const InspectOptions& options) const { + error.clear(); + if (!container) { + error = "no_container"; + return {}; + } + const auto provider = Detect(*container); + if (!provider) { + error = "unknown_format"; + return {}; + } + InspectOptions all = options; + all.maxEntries = std::max(all.maxEntries, 1u << 20); + const auto children = provider->Children(*container, all, error); + if (!error.empty()) return {}; + for (const auto& child : children) { + if (child.name != childName) continue; + if (!child.readable) { + error = "child_not_readable"; + return {}; + } + return provider->OpenChild(container, child, error); + } + error = "child_not_found"; + return {}; +} + +} // namespace cortex::artifacts diff --git a/core/artifacts/artifact.h b/core/artifacts/artifact.h new file mode 100644 index 00000000..861dedad --- /dev/null +++ b/core/artifacts/artifact.h @@ -0,0 +1,132 @@ +#pragma once + +// Artifact providers (roadmap step 4a, §8): static analysis of files of any +// supported format through one interface, so native PE, .NET, JavaScript, +// Electron and later runtimes share detection, identity, the GUI, the CLI +// and MCP. Everything read from an artifact is untrusted data: providers +// bound every read and every count, and report what they could not parse +// instead of guessing. + +#include + +#include +#include +#include +#include + +namespace cortex::artifacts { + +using json = nlohmann::json; + +// Random-access bytes of one artifact (a file, or an entry of a container). +class ByteSource { +public: + virtual ~ByteSource() = default; + virtual uint64_t Size() const = 0; + // Reads exactly `size` bytes at `offset`; false if any byte is outside. + virtual bool Read(uint64_t offset, void* buffer, size_t size) const = 0; + // Display name: a path, or "container!entry". + virtual std::string Name() const = 0; + + // Reads `size` bytes or fewer if the source ends first. + std::vector ReadSome(uint64_t offset, size_t size) const; + // Reads a NUL-terminated string of at most `max` bytes. + bool ReadCString(uint64_t offset, size_t max, std::string& text) const; +}; + +using ByteSourcePtr = std::shared_ptr; + +ByteSourcePtr OpenFile(const std::string& path, std::string* error = nullptr); +ByteSourcePtr FromBytes(std::vector bytes, std::string name); +// A window of another source (a container entry), without copying it. +ByteSourcePtr Slice(ByteSourcePtr parent, uint64_t offset, uint64_t size, std::string name); + +// Streams the source through SHA-256; empty string if a read fails. +std::string Sha256Of(const ByteSource& source); + +// Static parsing, decompilation and live debugging are separate +// capabilities, each validated on its own against a named fixture. +enum class CapabilityStatus { Validated, Experimental, Unavailable }; +const char* CapabilityStatusName(CapabilityStatus status); + +struct Capability { + std::string name; // "static" | "decompile" | "live" + CapabilityStatus status = CapabilityStatus::Unavailable; + std::string fixture; // test that validates it, when Validated + std::string note; +}; + +struct Child { + std::string name; // path inside the container + uint64_t offset = 0; // in the container's bytes + uint64_t size = 0; + bool readable = true; // false: stored outside (ASAR unpacked) or compressed without support + std::string note; + // Compressed entries (ZIP): `offset`/`storedSize` locate the stored + // bytes, `size` is the expanded size and `crc32` its checksum. + std::string compression; // "", "stored" or "deflate" + uint64_t storedSize = 0; + uint32_t crc32 = 0; +}; + +struct InspectOptions { + size_t maxEntries = 4096; // per list (imports, exports, files, ...) +}; + +class Provider { +public: + virtual ~Provider() = default; + virtual const char* Name() const = 0; // stable id, e.g. "pe" + virtual const char* Version() const = 0; + virtual std::vector Formats() const = 0; + virtual std::vector Capabilities() const = 0; + // 0 = not this format; higher = more specific (a .NET PE beats a PE). + virtual int Detect(const ByteSource& source) const = 0; + // Structured, bounded description of the artifact. Sets `error` and + // returns null only when nothing can be said; partial results carry + // "warnings". + virtual json Inspect(const ByteSource& source, const InspectOptions& options, std::string& error) const = 0; + // Containers list their entries; other providers return none. + virtual std::vector Children(const ByteSource&, const InspectOptions&, std::string&) const { return {}; } + // A readable child as its own source. The default is a window of the + // container; compressed formats expand the entry (bounded). + virtual ByteSourcePtr OpenChild(ByteSourcePtr container, const Child& child, std::string& error) const; +}; + +using ProviderPtr = std::shared_ptr; + +class Registry { +public: + void Add(ProviderPtr provider); + const std::vector& Providers() const { return providers_; } + // The most specific provider for the bytes, or null. + ProviderPtr Detect(const ByteSource& source) const; + ProviderPtr Find(const std::string& name) const; + + // {"schema": "cortex.artifact.v1", "identity": {...}, "provider", + // "format", "capabilities", "data" | "error"}. + json Inspect(const ByteSource& source, const InspectOptions& options = {}) const; + // Entries of a container artifact, with the same identity block. + json ListChildren(const ByteSource& source, const InspectOptions& options = {}) const; + // A child as its own source, for a nested inspect; null if absent or + // not readable. + ByteSourcePtr OpenChild(ByteSourcePtr container, const std::string& childName, std::string& error, + const InspectOptions& options = {}) const; + +private: + std::vector providers_; +}; + +// The registry with every built-in provider. +const Registry& DefaultRegistry(); + +json CapabilitiesJson(const std::vector& capabilities); + +// Little-endian readers over a source; false when out of range. +bool ReadU16(const ByteSource& source, uint64_t offset, uint16_t& value); +bool ReadU32(const ByteSource& source, uint64_t offset, uint32_t& value); +bool ReadU64(const ByteSource& source, uint64_t offset, uint64_t& value); + +std::string HexU64(uint64_t value); + +} // namespace cortex::artifacts diff --git a/core/artifacts/asar_provider.cpp b/core/artifacts/asar_provider.cpp new file mode 100644 index 00000000..998179b2 --- /dev/null +++ b/core/artifacts/asar_provider.cpp @@ -0,0 +1,306 @@ +#include "asar_provider.h" + +#include "js_provider.h" +#include "sha256.h" + +#include +#include +#include + +namespace cortex::artifacts { +namespace { + +constexpr uint32_t kMaxHeader = 64u << 20; // a header above 64 MiB is not a real app +constexpr int kMaxDepth = 64; +constexpr uint64_t kIntegrityBudget = 256ull << 20; // bytes hashed to check integrity + +std::string Lower(std::string text) { + for (auto& c : text) c = static_cast(std::tolower(static_cast(c))); + return text; +} + +// Electron's per-file integrity: SHA-256 of the whole file and of each +// blockSize block. "mismatch" means the bytes are not what the header says. +std::string CheckIntegrity(const ByteSource& source, const Child& child, const json& integrity) { + if (Lower(integrity.value("algorithm", std::string())) != "sha256") return "unsupported_algorithm"; + const uint64_t blockSize = integrity.value("blockSize", uint64_t{0}); + const json blocks = integrity.value("blocks", json::array()); + if (!blocks.is_array()) return "malformed"; + Sha256 whole, block; + std::vector blockHashes; + std::vector buffer(64 * 1024); + uint64_t done = 0, inBlock = 0; + while (done < child.size) { + size_t chunk = static_cast(std::min(buffer.size(), child.size - done)); + if (blockSize) chunk = static_cast(std::min(chunk, blockSize - inBlock)); + if (!source.Read(child.offset + done, buffer.data(), chunk)) return "read_failed"; + whole.Update(buffer.data(), chunk); + block.Update(buffer.data(), chunk); + done += chunk; + inBlock += chunk; + if (blockSize && inBlock == blockSize) { + blockHashes.push_back(Sha256::Hex(block.Final())); + block = Sha256(); + inBlock = 0; + } + } + if (blockSize && (inBlock || blockHashes.empty())) blockHashes.push_back(Sha256::Hex(block.Final())); + if (Sha256::Hex(whole.Final()) != Lower(integrity.value("hash", std::string()))) return "hash_mismatch"; + if (blockSize) { + if (blocks.size() != blockHashes.size()) return "block_count_mismatch"; + for (size_t i = 0; i < blockHashes.size(); ++i) + if (!blocks[i].is_string() || Lower(blocks[i].get()) != blockHashes[i]) return "block_mismatch"; + } + return "ok"; +} + +bool ParseOffset(const json& value, uint64_t& offset) { + // ASAR stores offsets as decimal strings (they can exceed 2^53). + try { + if (value.is_string()) { + const std::string text = value.get(); + if (text.empty() || text.size() > 20 || text.find_first_not_of("0123456789") != std::string::npos) + return false; + offset = std::stoull(text); + return true; + } + if (value.is_number_unsigned()) { + offset = value.get(); + return true; + } + } catch (...) { + } + return false; +} + +// Walks the file tree depth-first in name order. `visit` returns false to stop. +void Walk(const json& node, const std::string& prefix, int depth, + const std::function& visit, bool& stopped, bool& tooDeep) { + if (stopped) return; + if (depth > kMaxDepth) { + tooDeep = true; + return; + } + const auto files = node.find("files"); + if (files == node.end() || !files->is_object()) return; + for (auto it = files->begin(); it != files->end() && !stopped; ++it) { + const std::string& name = it.key(); + // A name with a separator or a dot segment would escape its folder + // once extracted; it is listed as-is but never joined into a path + // that could climb out. + const std::string path = prefix.empty() ? name : prefix + "/" + name; + if (!it.value().is_object()) continue; + if (!visit(path, it.value())) { + stopped = true; + return; + } + if (it.value().contains("files")) Walk(it.value(), path, depth + 1, visit, stopped, tooDeep); + } +} + +bool UnsafeName(const std::string& path) { + if (path.find('\\') != std::string::npos || path.find(':') != std::string::npos) return true; + size_t start = 0; + while (start <= path.size()) { + const size_t end = path.find('/', start); + const std::string segment = path.substr(start, end == std::string::npos ? std::string::npos : end - start); + if (segment.empty() || segment == "." || segment == "..") return true; + if (end == std::string::npos) break; + start = end + 1; + } + return false; +} + +} // namespace + +bool ReadAsarHeader(const ByteSource& source, json& header, uint64_t& dataOffset, std::string& error) { + uint32_t sizePayload = 0, headerSize = 0, headerPayload = 0, jsonLength = 0; + if (!ReadU32(source, 0, sizePayload) || sizePayload != 4 || !ReadU32(source, 4, headerSize)) { + error = "not_asar"; + return false; + } + if (headerSize < 8 || headerSize > kMaxHeader || 8ull + headerSize > source.Size()) { + error = "asar_header_size_invalid"; + return false; + } + if (!ReadU32(source, 8, headerPayload) || !ReadU32(source, 12, jsonLength) || jsonLength == 0 || + jsonLength > headerSize - 8 || headerPayload > headerSize - 4) { + error = "asar_header_pickle_invalid"; + return false; + } + std::string text(jsonLength, '\0'); + if (!source.Read(16, text.data(), jsonLength)) { + error = "asar_header_truncated"; + return false; + } + header = json::parse(text, nullptr, false); + if (header.is_discarded() || !header.is_object() || !header.contains("files") || !header["files"].is_object()) { + error = "asar_header_not_a_file_tree"; + return false; + } + dataOffset = 8ull + headerSize; + return true; +} + +std::vector AsarProvider::Capabilities() const { + return {{"static", CapabilityStatus::Validated, + "cortex_artifacts_tests (generated ASARs), cortex_js_tests (archive packed by @electron/asar)", + "file tree, sizes, unpacked and link entries, per-file integrity check, header SHA-256, package.json, " + "module graph and static Electron view (windows, preload, IPC channels, bridge)"}, + {"decompile", CapabilityStatus::Unavailable, "", "scripts inside are source; see the javascript provider"}, + {"live", CapabilityStatus::Unavailable, "", "Electron processes through CDP (step 8)"}}; +} + +int AsarProvider::Detect(const ByteSource& source) const { + json header; + uint64_t dataOffset = 0; + std::string error; + return ReadAsarHeader(source, header, dataOffset, error) ? 60 : 0; +} + +std::vector AsarProvider::Children(const ByteSource& source, const InspectOptions& options, + std::string& error) const { + std::vector children; + json header; + uint64_t dataOffset = 0; + if (!ReadAsarHeader(source, header, dataOffset, error)) return children; + bool stopped = false, tooDeep = false; + Walk(header, "", 0, [&](const std::string& path, const json& node) { + if (node.contains("files")) return true; // folders are implied by their entries + if (children.size() >= options.maxEntries) return false; + Child child; + child.name = path; + if (node.contains("link")) { + child.readable = false; + child.note = "link -> " + node.value("link", std::string()); + } else if (node.value("unpacked", false)) { + child.readable = false; + child.note = "unpacked: stored in app.asar.unpacked/"; + child.size = node.value("size", uint64_t{0}); + } else { + uint64_t offset = 0; + child.size = node.value("size", uint64_t{0}); + if (!ParseOffset(node.value("offset", json()), offset) || offset > source.Size() || + dataOffset > source.Size() - offset || child.size > source.Size() - dataOffset - offset) { + child.readable = false; + child.note = "offset outside the archive"; + } else { + child.offset = dataOffset + offset; + } + } + if (UnsafeName(path)) { + child.readable = false; + child.note = "unsafe entry name (separator, dot segment or drive)"; + } + children.push_back(std::move(child)); + return true; + }, stopped, tooDeep); + if (tooDeep) error = "asar_tree_too_deep"; + return children; +} + +json AsarProvider::Inspect(const ByteSource& source, const InspectOptions& options, std::string& error) const { + json header; + uint64_t dataOffset = 0; + if (!ReadAsarHeader(source, header, dataOffset, error)) return nullptr; + std::string childError; + const auto children = Children(source, options, childError); + + uint64_t files = 0, unpacked = 0, links = 0, bytes = 0, withIntegrity = 0, unsafe = 0; + json extensions = json::object(); + std::map integrities; + bool stopped = false, tooDeep = false; + Walk(header, "", 0, [&](const std::string& path, const json& node) { + if (node.contains("files")) return true; + if (node.contains("link")) { ++links; return true; } + ++files; + if (node.value("unpacked", false)) ++unpacked; + if (node.contains("integrity")) { + ++withIntegrity; + if (node["integrity"].is_object()) integrities[path] = node["integrity"]; + } + if (UnsafeName(path)) ++unsafe; + bytes += node.value("size", uint64_t{0}); + const auto dot = path.find_last_of('.'); + const auto slash = path.find_last_of('/'); + const std::string extension = dot == std::string::npos || (slash != std::string::npos && dot < slash) + ? std::string("(none)") : path.substr(dot); + extensions[extension] = extensions.value(extension, 0) + 1; + return true; + }, stopped, tooDeep); + + json data = {{"format", "asar"}, + {"header_size", dataOffset - 8}, + {"data_offset", dataOffset}, + {"files", files}, + {"unpacked_files", unpacked}, + {"links", links}, + {"total_file_bytes", bytes}, + {"files_with_integrity", withIntegrity}, + {"extensions", extensions}}; + if (unsafe) data["unsafe_entry_names"] = unsafe; + if (tooDeep || !childError.empty()) data["warnings"] = json::array({childError.empty() ? "asar_tree_too_deep" : childError}); + + // The header hash is what Electron's ElectronAsarIntegrity records. + uint32_t jsonLength = 0; + if (ReadU32(source, 12, jsonLength)) { + std::string text(jsonLength, '\0'); + if (source.Read(16, text.data(), text.size())) { + Sha256 digest; + digest.Update(text.data(), text.size()); + data["header_sha256"] = Sha256::Hex(digest.Final()); + } + } + + // Each file's bytes against its integrity record, within a budget. + if (!integrities.empty()) { + uint64_t hashed = 0, matched = 0, skipped = 0; + json mismatches = json::array(); + for (const auto& child : children) { + const auto integrity = integrities.find(child.name); + if (integrity == integrities.end() || !child.readable) continue; + if (hashed + child.size > kIntegrityBudget) { + ++skipped; + continue; + } + hashed += child.size; + const std::string verdict = CheckIntegrity(source, child, integrity->second); + if (verdict == "ok") ++matched; + else if (mismatches.size() < options.maxEntries) mismatches.push_back({{"file", child.name}, {"result", verdict}}); + } + data["integrity"] = {{"checked", matched + mismatches.size()}, + {"matched", matched}, + {"mismatches", mismatches}, + {"skipped_budget", skipped}, + {"note", "file bytes against the header's SHA-256 records; the header itself is " + "only trusted if its hash matches the one the application embeds"}}; + } + + // Electron reads package.json at the archive root for its entry point. + json packageJson; + for (const auto& child : children) { + if (child.name != "package.json" || !child.readable || child.size > (1u << 20)) continue; + std::string text(static_cast(child.size), '\0'); + if (!source.Read(child.offset, text.data(), text.size())) break; + const json package = json::parse(text, nullptr, false); + if (!package.is_object()) { + data["package_json_error"] = "not_json"; + break; + } + packageJson = package; + json summary = json::object(); + for (const char* key : {"name", "version", "main", "productName", "description", "type"}) + if (package.contains(key) && package[key].is_string()) summary[key] = package[key]; + if (package.contains("dependencies") && package["dependencies"].is_object()) + summary["dependencies"] = package["dependencies"].size(); + data["package_json"] = std::move(summary); + break; + } + + // Scripts: module graph, and the Electron view when the app uses it. + json scripts = AnalyzeScriptTree(source, children, packageJson, options); + for (auto it = scripts.begin(); it != scripts.end(); ++it) data[it.key()] = it.value(); + return data; +} + +} // namespace cortex::artifacts diff --git a/core/artifacts/asar_provider.h b/core/artifacts/asar_provider.h new file mode 100644 index 00000000..21bc465d --- /dev/null +++ b/core/artifacts/asar_provider.h @@ -0,0 +1,32 @@ +#pragma once + +// Electron ASAR archives (app.asar): a Chromium pickle holding the header +// size, then a pickle holding a JSON file tree, then the file bytes. +// +// [u32 4][u32 header_size] +// [u32 payload][i32 json_length][json, padded to 4] (header_size bytes) +// file data, offsets relative to 8 + header_size +// +// Entries marked "unpacked" live in app.asar.unpacked/ beside the archive +// and are listed but not readable from it. Links are listed, never followed. + +#include "artifact.h" + +namespace cortex::artifacts { + +class AsarProvider final : public Provider { +public: + const char* Name() const override { return "asar"; } + const char* Version() const override { return "2"; } + std::vector Formats() const override { return {"asar"}; } + std::vector Capabilities() const override; + int Detect(const ByteSource& source) const override; + json Inspect(const ByteSource& source, const InspectOptions& options, std::string& error) const override; + std::vector Children(const ByteSource& source, const InspectOptions& options, + std::string& error) const override; +}; + +// The JSON header and the offset file data starts at; false if not ASAR. +bool ReadAsarHeader(const ByteSource& source, json& header, uint64_t& dataOffset, std::string& error); + +} // namespace cortex::artifacts diff --git a/core/artifacts/cil.cpp b/core/artifacts/cil.cpp new file mode 100644 index 00000000..ca39d161 --- /dev/null +++ b/core/artifacts/cil.cpp @@ -0,0 +1,302 @@ +#include "cil.h" + +#include +#include + +namespace cortex::artifacts::dotnet { +namespace { + +using json = nlohmann::json; +using O = Operand; + +std::string Hex32(uint32_t value) { + char text[16]; + std::snprintf(text, sizeof(text), "0x%08x", value); + return text; +} + +std::string Label(int64_t offset) { + char text[24]; + std::snprintf(text, sizeof(text), "IL_%04llx", static_cast(offset & 0xFFFFFFFF)); + return text; +} + +template +T Read(const uint8_t* p) { + T value; + std::memcpy(&value, p, sizeof(T)); + return value; +} + +} // namespace + +const char* OperandName(Operand operand) { + static const char* const names[] = {"InlineNone", "ShortInlineBrTarget", "InlineBrTarget", "ShortInlineI", + "InlineI", "InlineI8", "ShortInlineR", "InlineR", + "InlineMethod", "InlineField", "InlineType", "InlineString", + "InlineSig", "InlineTok", "InlineSwitch", "InlineVar", + "ShortInlineVar"}; + return names[static_cast(operand)]; +} + +const std::vector& OpCodeTable() { + static const std::vector table = { + {0x00, "nop", O::InlineNone}, {0x01, "break", O::InlineNone}, + {0x02, "ldarg.0", O::InlineNone}, {0x03, "ldarg.1", O::InlineNone}, {0x04, "ldarg.2", O::InlineNone}, + {0x05, "ldarg.3", O::InlineNone}, {0x06, "ldloc.0", O::InlineNone}, {0x07, "ldloc.1", O::InlineNone}, + {0x08, "ldloc.2", O::InlineNone}, {0x09, "ldloc.3", O::InlineNone}, {0x0A, "stloc.0", O::InlineNone}, + {0x0B, "stloc.1", O::InlineNone}, {0x0C, "stloc.2", O::InlineNone}, {0x0D, "stloc.3", O::InlineNone}, + {0x0E, "ldarg.s", O::ShortInlineVar}, {0x0F, "ldarga.s", O::ShortInlineVar}, + {0x10, "starg.s", O::ShortInlineVar}, {0x11, "ldloc.s", O::ShortInlineVar}, + {0x12, "ldloca.s", O::ShortInlineVar}, {0x13, "stloc.s", O::ShortInlineVar}, + {0x14, "ldnull", O::InlineNone}, {0x15, "ldc.i4.m1", O::InlineNone}, {0x16, "ldc.i4.0", O::InlineNone}, + {0x17, "ldc.i4.1", O::InlineNone}, {0x18, "ldc.i4.2", O::InlineNone}, {0x19, "ldc.i4.3", O::InlineNone}, + {0x1A, "ldc.i4.4", O::InlineNone}, {0x1B, "ldc.i4.5", O::InlineNone}, {0x1C, "ldc.i4.6", O::InlineNone}, + {0x1D, "ldc.i4.7", O::InlineNone}, {0x1E, "ldc.i4.8", O::InlineNone}, {0x1F, "ldc.i4.s", O::ShortInlineI}, + {0x20, "ldc.i4", O::InlineI}, {0x21, "ldc.i8", O::InlineI8}, {0x22, "ldc.r4", O::ShortInlineR}, + {0x23, "ldc.r8", O::InlineR}, {0x25, "dup", O::InlineNone}, {0x26, "pop", O::InlineNone}, + {0x27, "jmp", O::InlineMethod}, {0x28, "call", O::InlineMethod}, {0x29, "calli", O::InlineSig}, + {0x2A, "ret", O::InlineNone}, {0x2B, "br.s", O::ShortInlineBrTarget}, + {0x2C, "brfalse.s", O::ShortInlineBrTarget}, {0x2D, "brtrue.s", O::ShortInlineBrTarget}, + {0x2E, "beq.s", O::ShortInlineBrTarget}, {0x2F, "bge.s", O::ShortInlineBrTarget}, + {0x30, "bgt.s", O::ShortInlineBrTarget}, {0x31, "ble.s", O::ShortInlineBrTarget}, + {0x32, "blt.s", O::ShortInlineBrTarget}, {0x33, "bne.un.s", O::ShortInlineBrTarget}, + {0x34, "bge.un.s", O::ShortInlineBrTarget}, {0x35, "bgt.un.s", O::ShortInlineBrTarget}, + {0x36, "ble.un.s", O::ShortInlineBrTarget}, {0x37, "blt.un.s", O::ShortInlineBrTarget}, + {0x38, "br", O::InlineBrTarget}, {0x39, "brfalse", O::InlineBrTarget}, {0x3A, "brtrue", O::InlineBrTarget}, + {0x3B, "beq", O::InlineBrTarget}, {0x3C, "bge", O::InlineBrTarget}, {0x3D, "bgt", O::InlineBrTarget}, + {0x3E, "ble", O::InlineBrTarget}, {0x3F, "blt", O::InlineBrTarget}, {0x40, "bne.un", O::InlineBrTarget}, + {0x41, "bge.un", O::InlineBrTarget}, {0x42, "bgt.un", O::InlineBrTarget}, {0x43, "ble.un", O::InlineBrTarget}, + {0x44, "blt.un", O::InlineBrTarget}, {0x45, "switch", O::InlineSwitch}, {0x46, "ldind.i1", O::InlineNone}, + {0x47, "ldind.u1", O::InlineNone}, {0x48, "ldind.i2", O::InlineNone}, {0x49, "ldind.u2", O::InlineNone}, + {0x4A, "ldind.i4", O::InlineNone}, {0x4B, "ldind.u4", O::InlineNone}, {0x4C, "ldind.i8", O::InlineNone}, + {0x4D, "ldind.i", O::InlineNone}, {0x4E, "ldind.r4", O::InlineNone}, {0x4F, "ldind.r8", O::InlineNone}, + {0x50, "ldind.ref", O::InlineNone}, {0x51, "stind.ref", O::InlineNone}, {0x52, "stind.i1", O::InlineNone}, + {0x53, "stind.i2", O::InlineNone}, {0x54, "stind.i4", O::InlineNone}, {0x55, "stind.i8", O::InlineNone}, + {0x56, "stind.r4", O::InlineNone}, {0x57, "stind.r8", O::InlineNone}, {0x58, "add", O::InlineNone}, + {0x59, "sub", O::InlineNone}, {0x5A, "mul", O::InlineNone}, {0x5B, "div", O::InlineNone}, + {0x5C, "div.un", O::InlineNone}, {0x5D, "rem", O::InlineNone}, {0x5E, "rem.un", O::InlineNone}, + {0x5F, "and", O::InlineNone}, {0x60, "or", O::InlineNone}, {0x61, "xor", O::InlineNone}, + {0x62, "shl", O::InlineNone}, {0x63, "shr", O::InlineNone}, {0x64, "shr.un", O::InlineNone}, + {0x65, "neg", O::InlineNone}, {0x66, "not", O::InlineNone}, {0x67, "conv.i1", O::InlineNone}, + {0x68, "conv.i2", O::InlineNone}, {0x69, "conv.i4", O::InlineNone}, {0x6A, "conv.i8", O::InlineNone}, + {0x6B, "conv.r4", O::InlineNone}, {0x6C, "conv.r8", O::InlineNone}, {0x6D, "conv.u4", O::InlineNone}, + {0x6E, "conv.u8", O::InlineNone}, {0x6F, "callvirt", O::InlineMethod}, {0x70, "cpobj", O::InlineType}, + {0x71, "ldobj", O::InlineType}, {0x72, "ldstr", O::InlineString}, {0x73, "newobj", O::InlineMethod}, + {0x74, "castclass", O::InlineType}, {0x75, "isinst", O::InlineType}, {0x76, "conv.r.un", O::InlineNone}, + {0x79, "unbox", O::InlineType}, {0x7A, "throw", O::InlineNone}, {0x7B, "ldfld", O::InlineField}, + {0x7C, "ldflda", O::InlineField}, {0x7D, "stfld", O::InlineField}, {0x7E, "ldsfld", O::InlineField}, + {0x7F, "ldsflda", O::InlineField}, {0x80, "stsfld", O::InlineField}, {0x81, "stobj", O::InlineType}, + {0x82, "conv.ovf.i1.un", O::InlineNone}, {0x83, "conv.ovf.i2.un", O::InlineNone}, + {0x84, "conv.ovf.i4.un", O::InlineNone}, {0x85, "conv.ovf.i8.un", O::InlineNone}, + {0x86, "conv.ovf.u1.un", O::InlineNone}, {0x87, "conv.ovf.u2.un", O::InlineNone}, + {0x88, "conv.ovf.u4.un", O::InlineNone}, {0x89, "conv.ovf.u8.un", O::InlineNone}, + {0x8A, "conv.ovf.i.un", O::InlineNone}, {0x8B, "conv.ovf.u.un", O::InlineNone}, {0x8C, "box", O::InlineType}, + {0x8D, "newarr", O::InlineType}, {0x8E, "ldlen", O::InlineNone}, {0x8F, "ldelema", O::InlineType}, + {0x90, "ldelem.i1", O::InlineNone}, {0x91, "ldelem.u1", O::InlineNone}, {0x92, "ldelem.i2", O::InlineNone}, + {0x93, "ldelem.u2", O::InlineNone}, {0x94, "ldelem.i4", O::InlineNone}, {0x95, "ldelem.u4", O::InlineNone}, + {0x96, "ldelem.i8", O::InlineNone}, {0x97, "ldelem.i", O::InlineNone}, {0x98, "ldelem.r4", O::InlineNone}, + {0x99, "ldelem.r8", O::InlineNone}, {0x9A, "ldelem.ref", O::InlineNone}, {0x9B, "stelem.i", O::InlineNone}, + {0x9C, "stelem.i1", O::InlineNone}, {0x9D, "stelem.i2", O::InlineNone}, {0x9E, "stelem.i4", O::InlineNone}, + {0x9F, "stelem.i8", O::InlineNone}, {0xA0, "stelem.r4", O::InlineNone}, {0xA1, "stelem.r8", O::InlineNone}, + {0xA2, "stelem.ref", O::InlineNone}, {0xA3, "ldelem", O::InlineType}, {0xA4, "stelem", O::InlineType}, + {0xA5, "unbox.any", O::InlineType}, {0xB3, "conv.ovf.i1", O::InlineNone}, {0xB4, "conv.ovf.u1", O::InlineNone}, + {0xB5, "conv.ovf.i2", O::InlineNone}, {0xB6, "conv.ovf.u2", O::InlineNone}, {0xB7, "conv.ovf.i4", O::InlineNone}, + {0xB8, "conv.ovf.u4", O::InlineNone}, {0xB9, "conv.ovf.i8", O::InlineNone}, {0xBA, "conv.ovf.u8", O::InlineNone}, + {0xC2, "refanyval", O::InlineType}, {0xC3, "ckfinite", O::InlineNone}, {0xC6, "mkrefany", O::InlineType}, + {0xD0, "ldtoken", O::InlineTok}, {0xD1, "conv.u2", O::InlineNone}, {0xD2, "conv.u1", O::InlineNone}, + {0xD3, "conv.i", O::InlineNone}, {0xD4, "conv.ovf.i", O::InlineNone}, {0xD5, "conv.ovf.u", O::InlineNone}, + {0xD6, "add.ovf", O::InlineNone}, {0xD7, "add.ovf.un", O::InlineNone}, {0xD8, "mul.ovf", O::InlineNone}, + {0xD9, "mul.ovf.un", O::InlineNone}, {0xDA, "sub.ovf", O::InlineNone}, {0xDB, "sub.ovf.un", O::InlineNone}, + {0xDC, "endfinally", O::InlineNone}, {0xDD, "leave", O::InlineBrTarget}, {0xDE, "leave.s", O::ShortInlineBrTarget}, + {0xDF, "stind.i", O::InlineNone}, {0xE0, "conv.u", O::InlineNone}, + // Reserved prefixes (never decoded as instructions except 0xFE). + {0xF8, "prefix7", O::InlineNone}, {0xF9, "prefix6", O::InlineNone}, {0xFA, "prefix5", O::InlineNone}, + {0xFB, "prefix4", O::InlineNone}, {0xFC, "prefix3", O::InlineNone}, {0xFD, "prefix2", O::InlineNone}, + {0xFE, "prefix1", O::InlineNone}, {0xFF, "prefixref", O::InlineNone}, + {0xFE00, "arglist", O::InlineNone}, {0xFE01, "ceq", O::InlineNone}, {0xFE02, "cgt", O::InlineNone}, + {0xFE03, "cgt.un", O::InlineNone}, {0xFE04, "clt", O::InlineNone}, {0xFE05, "clt.un", O::InlineNone}, + {0xFE06, "ldftn", O::InlineMethod}, {0xFE07, "ldvirtftn", O::InlineMethod}, {0xFE09, "ldarg", O::InlineVar}, + {0xFE0A, "ldarga", O::InlineVar}, {0xFE0B, "starg", O::InlineVar}, {0xFE0C, "ldloc", O::InlineVar}, + {0xFE0D, "ldloca", O::InlineVar}, {0xFE0E, "stloc", O::InlineVar}, {0xFE0F, "localloc", O::InlineNone}, + {0xFE11, "endfilter", O::InlineNone}, {0xFE12, "unaligned.", O::ShortInlineI}, + {0xFE13, "volatile.", O::InlineNone}, {0xFE14, "tail.", O::InlineNone}, {0xFE15, "initobj", O::InlineType}, + {0xFE16, "constrained.", O::InlineType}, {0xFE17, "cpblk", O::InlineNone}, {0xFE18, "initblk", O::InlineNone}, + {0xFE19, "no.", O::ShortInlineI}, {0xFE1A, "rethrow", O::InlineNone}, {0xFE1C, "sizeof", O::InlineType}, + {0xFE1D, "refanytype", O::InlineNone}, {0xFE1E, "readonly.", O::InlineNone}, + }; + return table; +} + +namespace { + +const OpCodeInfo* Lookup(uint16_t value) { + static const auto index = [] { + std::vector one(256, nullptr), two(256, nullptr); + for (const auto& info : OpCodeTable()) { + if (info.value >= 0xF8 && info.value <= 0xFF) continue; // prefixes are not instructions + if (info.value < 0x100) one[info.value] = &info; + else two[info.value & 0xFF] = &info; + } + return std::make_pair(one, two); + }(); + return value < 0x100 ? index.first[value] : index.second[value & 0xFF]; +} + +std::string FormatFloat(double value, int digits) { + char text[64]; + std::snprintf(text, sizeof(text), "%.*g", digits, value); + std::string out = text; + // .NET writes the exponent as E+XX. + const size_t e = out.find('e'); + if (e != std::string::npos) { + out[e] = 'E'; + size_t digit = e + 2; + while (digit + 1 < out.size() && out[digit] == '0') out.erase(digit, 1); + } + if (out == "inf") return "∞"; + if (out == "-inf") return "-∞"; + if (out == "nan" || out == "-nan") return "NaN"; + return out; +} + +} // namespace + +json Disassemble(const Metadata& metadata, const std::vector& code, size_t maxInstructions, std::string& error) { + json rows = json::array(); + const uint8_t* il = code.data(); + const size_t size = code.size(); + size_t i = 0; + while (i < size) { + if (rows.size() >= maxInstructions) { + error = "instructions_truncated"; + break; + } + const size_t start = i; + uint16_t value = il[i++]; + if (value == 0xFE) { + if (i >= size) { + error = "truncated_opcode at " + Label(static_cast(start)); + break; + } + value = static_cast(0xFE00 | il[i++]); + } + const OpCodeInfo* op = Lookup(value); + if (!op) { + char text[48]; + std::snprintf(text, sizeof(text), "unknown_opcode 0x%x at ", value); + error = text + Label(static_cast(start)); + break; + } + json row = {{"offset", start}, {"opcode", op->name}}; + static const uint8_t sizes[] = {0, 1, 4, 1, 4, 8, 4, 8, 4, 4, 4, 4, 4, 4, 4, 2, 1}; + const size_t operandSize = sizes[static_cast(op->operand)]; + if (size - i < operandSize) { + error = "truncated_operand at " + Label(static_cast(start)); + break; + } + const uint8_t* p = il + i; + i += operandSize; + switch (op->operand) { + case O::InlineNone: break; + case O::ShortInlineBrTarget: + row["operand"] = Label(static_cast(i) + static_cast(*p)); + break; + case O::InlineBrTarget: row["operand"] = Label(static_cast(i) + Read(p)); break; + case O::InlineSwitch: { + const uint32_t count = Read(p); + if (count > (size - i) / 4) { + error = "truncated_switch at " + Label(static_cast(start)); + return rows; + } + const size_t end = i + 4ull * count; + std::string targets = "("; + for (uint32_t k = 0; k < count; ++k) + targets += (k ? ", " : "") + Label(static_cast(end) + Read(il + i + 4 * k)); + row["operand"] = targets + ")"; + i = end; + break; + } + case O::ShortInlineI: + row["operand"] = value == 0xFE12 ? std::to_string(*p) : std::to_string(static_cast(*p)); + break; + case O::ShortInlineVar: row["operand"] = std::to_string(*p); break; + case O::InlineVar: row["operand"] = std::to_string(Read(p)); break; + case O::InlineI: row["operand"] = std::to_string(Read(p)); break; + case O::InlineI8: row["operand"] = std::to_string(Read(p)); break; + case O::ShortInlineR: row["operand"] = FormatFloat(Read(p), 9); break; + case O::InlineR: row["operand"] = FormatFloat(Read(p), 17); break; + case O::InlineString: { + const uint32_t token = Read(p); + row["operand"] = "\"" + metadata.UserString(token & 0x00FFFFFF) + "\""; + row["token"] = Hex32(token); + break; + } + default: { + const uint32_t token = Read(p); + row["operand"] = metadata.MemberName(token); + row["token"] = Hex32(token); + break; + } + } + rows.push_back(std::move(row)); + } + return rows; +} + +json MethodJson(const ByteSource& source, const PeFile& pe, const Metadata& metadata, uint32_t methodRow, + size_t maxInstructions, std::string& error) { + const uint32_t token = (static_cast(MethodDef) << 24) | methodRow; + json result = {{"token", Hex32(token)}, + {"method", metadata.MemberName(token)}, + {"signature", metadata.MethodSignature(metadata.Cell(MethodDef, methodRow, 4))}, + {"rva", metadata.Cell(MethodDef, methodRow, 0)}}; + MethodBody body; + std::string bodyError; + if (!metadata.ReadBody(source, pe, methodRow, body, bodyError)) { + result["body"] = nullptr; + result["reason"] = bodyError; // abstract, extern (P/Invoke), runtime-implemented, or damaged + return result; + } + result["header"] = body.tiny ? "tiny" : "fat"; + result["max_stack"] = body.maxStack; + result["code_size"] = body.code.size(); + result["init_locals"] = body.initLocals; + result["local_signature"] = body.localSignature ? json(Hex32(body.localSignature)) : json(nullptr); + json clauses = json::array(); + for (const auto& clause : body.clauses) { + clauses.push_back({{"kind", clause.kind}, + {"try_offset", clause.tryOffset}, + {"try_length", clause.tryLength}, + {"handler_offset", clause.handlerOffset}, + {"handler_length", clause.handlerLength}, + {"catch_type", clause.kind == "catch" ? json(metadata.TypeName(clause.classTokenOrFilter)) : json(nullptr)}}); + } + result["exception_clauses"] = clauses; + std::string disassemblyError; + result["instructions"] = Disassemble(metadata, body.code, maxInstructions, disassemblyError); + if (!disassemblyError.empty()) { + result["warning"] = disassemblyError; + if (error.empty()) error = disassemblyError; + } + return result; +} + +std::vector FindMethods(const Metadata& metadata, const std::string& query) { + std::vector rows; + unsigned long token = 0; + if (query.rfind("0x", 0) == 0 && std::sscanf(query.c_str(), "%lx", &token) == 1) { + if ((token >> 24) == MethodDef && (token & 0xFFFFFF) && (token & 0xFFFFFF) <= metadata.Rows(MethodDef)) + rows.push_back(static_cast(token & 0xFFFFFF)); + return rows; + } + const bool qualified = query.find("::") != std::string::npos; + for (uint32_t row = 1; row <= metadata.Rows(MethodDef) && rows.size() < 256; ++row) { + const std::string name = qualified ? metadata.MemberName((static_cast(MethodDef) << 24) | row) + : metadata.String(metadata.Cell(MethodDef, row, 3)); + if (name == query) rows.push_back(row); + } + return rows; +} + +} // namespace cortex::artifacts::dotnet diff --git a/core/artifacts/cil.h b/core/artifacts/cil.h new file mode 100644 index 00000000..c9e55e01 --- /dev/null +++ b/core/artifacts/cil.h @@ -0,0 +1,50 @@ +#pragma once + +// CIL disassembly (ECMA-335 partition III) of .NET method bodies. Operands +// are decoded by their operand type and metadata tokens resolved to names +// (dotnet_metadata.h). The opcode table is checked in tests against the +// runtime's own System.Reflection.Emit.OpCodes. + +#include "dotnet_metadata.h" + +#include + +#include +#include +#include + +namespace cortex::artifacts::dotnet { + +enum class Operand : uint8_t { + InlineNone, ShortInlineBrTarget, InlineBrTarget, ShortInlineI, InlineI, InlineI8, ShortInlineR, InlineR, + InlineMethod, InlineField, InlineType, InlineString, InlineSig, InlineTok, InlineSwitch, InlineVar, + ShortInlineVar, +}; + +const char* OperandName(Operand operand); + +struct OpCodeInfo { + uint16_t value; // 0x00-0xFF, or 0xFE00 | second byte + const char* name; + Operand operand; +}; + +// Every opcode, in value order (including the prefix1..prefixref entries). +const std::vector& OpCodeTable(); + +// [{offset, opcode, operand?, token?}], at most `maxInstructions`; a +// truncated or unknown instruction ends the listing with a warning in +// `error` (the rows before it are kept). +nlohmann::json Disassemble(const Metadata& metadata, const std::vector& code, size_t maxInstructions, + std::string& error); + +// {"token", "method", "signature", "max_stack", "code_size", "init_locals", +// "local_signature", "exception_clauses", "instructions"} for a MethodDef row. +nlohmann::json MethodJson(const ByteSource& source, const PeFile& pe, const Metadata& metadata, uint32_t methodRow, + size_t maxInstructions, std::string& error); + +// MethodDef rows matching "0x06000004", "Namespace.Type::Method" (every +// overload) or just "Method". +std::vector FindMethods(const Metadata& metadata, const std::string& query); + +} // namespace cortex::artifacts::dotnet diff --git a/core/artifacts/dotnet_metadata.cpp b/core/artifacts/dotnet_metadata.cpp new file mode 100644 index 00000000..6d8c4a9f --- /dev/null +++ b/core/artifacts/dotnet_metadata.cpp @@ -0,0 +1,872 @@ +#include "dotnet_metadata.h" + +#include +#include +#include + +namespace cortex::artifacts::dotnet { +namespace { + +constexpr uint32_t kMaxMetadata = 128u << 20; +constexpr int kMaxSignatureDepth = 64; +// A TypeSpec signature can name another TypeSpec (generic instances, +// pointers, arrays), and a crafted file can make that a cycle. Depth inside +// one signature is bounded by kMaxSignatureDepth; these bound the nesting of +// TypeSpecs and the total work for one top-level signature. +constexpr int kMaxTypeSpecNesting = 8; +constexpr int kSignatureWorkBudget = 4096; +thread_local int g_typeSpecNesting = 0; +thread_local int g_signatureWork = 0; +constexpr uint8_t kNone = 0xFF; + +enum Coded : uint8_t { + TypeDefOrRef, HasConstant, HasCustomAttribute, HasFieldMarshal, HasDeclSecurity, MemberRefParent, + HasSemantics, MethodDefOrRef, MemberForwarded, Implementation, CustomAttributeType, ResolutionScope, + TypeOrMethodDef, CodedCount, +}; + +struct CodedKind { + uint8_t bits; + std::vector tables; +}; + +const CodedKind& CodedInfo(uint8_t kind) { + static const CodedKind kinds[CodedCount] = { + {2, {TypeDef, TypeRef, TypeSpec}}, + {2, {Field, Param, Property}}, + {5, {MethodDef, Field, TypeRef, TypeDef, Param, InterfaceImpl, MemberRef, Module, DeclSecurity, Property, Event, + StandAloneSig, ModuleRef, TypeSpec, Assembly, AssemblyRef, File, ExportedType, ManifestResource, + GenericParam, GenericParamConstraint, MethodSpec}}, + {1, {Field, Param}}, + {2, {TypeDef, MethodDef, Assembly}}, + {3, {TypeDef, TypeRef, ModuleRef, MethodDef, TypeSpec}}, + {1, {Event, Property}}, + {1, {MethodDef, MemberRef}}, + {1, {Field, MethodDef}}, + {2, {File, AssemblyRef, ExportedType}}, + {3, {kNone, kNone, MethodDef, MemberRef, kNone}}, + {2, {Module, ModuleRef, AssemblyRef, TypeRef}}, + {1, {TypeDef, MethodDef}}, + }; + return kinds[kind]; +} + +// Column kinds: fixed sizes, heap indexes, simple and coded table indexes. +enum class Col : uint8_t { U16, U32, Str, Guid, Blob, Index, Coded }; +struct Column { + Col kind; + uint8_t arg = 0; // table for Index, kind for Coded +}; + +using Schema = std::vector; + +const Schema& TableSchema(uint8_t table) { + using C = Column; + const C u16{Col::U16}, u32{Col::U32}, str{Col::Str}, guid{Col::Guid}, blob{Col::Blob}; + auto idx = [](uint8_t t) { return C{Col::Index, t}; }; + auto cod = [](uint8_t k) { return C{Col::Coded, k}; }; + static const Schema schemas[TableCount] = { + /*Module*/ {u16, str, guid, guid, guid}, + /*TypeRef*/ {cod(ResolutionScope), str, str}, + /*TypeDef*/ {u32, str, str, cod(TypeDefOrRef), idx(Field), idx(MethodDef)}, + /*FieldPtr*/ {idx(Field)}, + /*Field*/ {u16, str, blob}, + /*MethodPtr*/ {idx(MethodDef)}, + /*MethodDef*/ {u32, u16, u16, str, blob, idx(Param)}, + /*ParamPtr*/ {idx(Param)}, + /*Param*/ {u16, u16, str}, + /*InterfaceImpl*/ {idx(TypeDef), cod(TypeDefOrRef)}, + /*MemberRef*/ {cod(MemberRefParent), str, blob}, + /*Constant*/ {u16, cod(HasConstant), blob}, + /*CustomAttribute*/ {cod(HasCustomAttribute), cod(CustomAttributeType), blob}, + /*FieldMarshal*/ {cod(HasFieldMarshal), blob}, + /*DeclSecurity*/ {u16, cod(HasDeclSecurity), blob}, + /*ClassLayout*/ {u16, u32, idx(TypeDef)}, + /*FieldLayout*/ {u32, idx(Field)}, + /*StandAloneSig*/ {blob}, + /*EventMap*/ {idx(TypeDef), idx(Event)}, + /*EventPtr*/ {idx(Event)}, + /*Event*/ {u16, str, cod(TypeDefOrRef)}, + /*PropertyMap*/ {idx(TypeDef), idx(Property)}, + /*PropertyPtr*/ {idx(Property)}, + /*Property*/ {u16, str, blob}, + /*MethodSemantics*/ {u16, idx(MethodDef), cod(HasSemantics)}, + /*MethodImpl*/ {idx(TypeDef), cod(MethodDefOrRef), cod(MethodDefOrRef)}, + /*ModuleRef*/ {str}, + /*TypeSpec*/ {blob}, + /*ImplMap*/ {u16, cod(MemberForwarded), str, idx(ModuleRef)}, + /*FieldRVA*/ {u32, idx(Field)}, + /*EncLog*/ {u32, u32}, + /*EncMap*/ {u32}, + /*Assembly*/ {u32, u16, u16, u16, u16, u32, blob, str, str}, + /*AssemblyProcessor*/ {u32}, + /*AssemblyOS*/ {u32, u32, u32}, + /*AssemblyRef*/ {u16, u16, u16, u16, u32, blob, str, str, blob}, + /*AssemblyRefProcessor*/ {u32, idx(AssemblyRef)}, + /*AssemblyRefOS*/ {u32, u32, u32, idx(AssemblyRef)}, + /*File*/ {u32, str, blob}, + /*ExportedType*/ {u32, u32, str, str, cod(Implementation)}, + /*ManifestResource*/ {u32, u32, str, cod(Implementation)}, + /*NestedClass*/ {idx(TypeDef), idx(TypeDef)}, + /*GenericParam*/ {u16, u16, cod(TypeOrMethodDef), str}, + /*MethodSpec*/ {cod(MethodDefOrRef), blob}, + /*GenericParamConstraint*/ {idx(GenericParam), cod(TypeDefOrRef)}, + }; + return schemas[table]; +} + +uint32_t Le32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (static_cast(p[3]) << 24); } +uint16_t Le16(const uint8_t* p) { return static_cast(p[0] | (p[1] << 8)); } + +std::string Hex32(uint32_t value) { + char text[16]; + std::snprintf(text, sizeof(text), "0x%08x", value); + return text; +} + +std::string Join(const std::vector& parts, const char* separator) { + std::string out; + for (size_t i = 0; i < parts.size(); ++i) out += (i ? separator : "") + parts[i]; + return out; +} + +void AppendUtf8(std::string& out, uint32_t code) { + if (code < 0x80) out.push_back(static_cast(code)); + else if (code < 0x800) { + out.push_back(static_cast(0xC0 | (code >> 6))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } else if (code < 0x10000) { + out.push_back(static_cast(0xE0 | (code >> 12))); + out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } else { + out.push_back(static_cast(0xF0 | (code >> 18))); + out.push_back(static_cast(0x80 | ((code >> 12) & 0x3F))); + out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } +} + +} // namespace + +const char* TableName(uint8_t table) { + static const char* const names[TableCount] = { + "Module", "TypeRef", "TypeDef", "FieldPtr", "Field", "MethodPtr", "MethodDef", "ParamPtr", "Param", + "InterfaceImpl", "MemberRef", "Constant", "CustomAttribute", "FieldMarshal", "DeclSecurity", "ClassLayout", + "FieldLayout", "StandAloneSig", "EventMap", "EventPtr", "Event", "PropertyMap", "PropertyPtr", "Property", + "MethodSemantics", "MethodImpl", "ModuleRef", "TypeSpec", "ImplMap", "FieldRVA", "EncLog", "EncMap", + "Assembly", "AssemblyProcessor", "AssemblyOS", "AssemblyRef", "AssemblyRefProcessor", "AssemblyRefOS", + "File", "ExportedType", "ManifestResource", "NestedClass", "GenericParam", "MethodSpec", + "GenericParamConstraint"}; + return table < TableCount ? names[table] : "?"; +} + +bool ReadCompressed(const uint8_t*& p, const uint8_t* end, uint32_t& value) { + if (p >= end) return false; + const uint8_t first = *p; + if ((first & 0x80) == 0) { + value = first; + p += 1; + return true; + } + if ((first & 0xC0) == 0x80) { + if (end - p < 2) return false; + value = (static_cast(first & 0x3F) << 8) | p[1]; + p += 2; + return true; + } + if ((first & 0xE0) == 0xC0) { + if (end - p < 4) return false; + value = (static_cast(first & 0x1F) << 24) | (p[1] << 16) | (p[2] << 8) | p[3]; + p += 4; + return true; + } + return false; +} + +bool Metadata::Parse(const ByteSource& source, const PeFile& pe, std::string& error) { + if (!pe.HasDirectory(kPeClr)) { + error = "no_clr_header"; + return false; + } + uint64_t offset = 0; + uint32_t available = 0; + if (!pe.RvaToOffset(pe.directories[kPeClr].rva, offset, &available) || available < 72) { + error = "clr_header_outside_file"; + return false; + } + const auto header = source.ReadSome(offset, 72); + if (header.size() < 72) { + error = "clr_header_truncated"; + return false; + } + runtimeMajor = Le16(&header[4]); + runtimeMinor = Le16(&header[6]); + const PeDirectory metadataDirectory{Le32(&header[8]), Le32(&header[12])}; + clrFlags = Le32(&header[16]); + entryPointToken = Le32(&header[20]); + resources = {Le32(&header[24]), Le32(&header[28])}; + strongNameSignature = {Le32(&header[32]), Le32(&header[36])}; + if (metadataDirectory.size < 32 || metadataDirectory.size > kMaxMetadata || + !pe.RvaToOffset(metadataDirectory.rva, offset, &available) || available < metadataDirectory.size) { + error = "metadata_outside_file"; + return false; + } + metadata_ = source.ReadSome(offset, metadataDirectory.size); + if (metadata_.size() != metadataDirectory.size) { + error = "metadata_truncated"; + return false; + } + const uint8_t* m = metadata_.data(); + if (Le32(m) != 0x424A5342) { + error = "metadata_signature_invalid"; + return false; + } + const uint32_t versionLength = Le32(m + 12); + if (versionLength > 255 || 16ull + versionLength + 4 > metadata_.size()) { + error = "metadata_version_invalid"; + return false; + } + version.assign(reinterpret_cast(m + 16), strnlen(reinterpret_cast(m + 16), versionLength)); + size_t at = 16 + versionLength; + const uint16_t streamCount = Le16(m + at + 2); + at += 4; + bool hasTables = false; + for (uint16_t i = 0; i < streamCount; ++i) { + if (at + 8 > metadata_.size()) { + error = "stream_headers_truncated"; + return false; + } + const uint32_t streamOffset = Le32(m + at), streamSize = Le32(m + at + 4); + at += 8; + std::string name; + while (at < metadata_.size() && m[at] && name.size() < 32) name.push_back(static_cast(m[at++])); + at = (at + 4) & ~size_t{3}; // the NUL, then padding to 4 + if (static_cast(streamOffset) + streamSize > metadata_.size()) { + error = "stream_outside_metadata:" + name; + return false; + } + streams.emplace_back(name, streamSize); + const Heap heap{streamOffset, streamSize}; + if (name == "#Strings") strings_ = heap; + else if (name == "#US") userStrings_ = heap; + else if (name == "#GUID") guids_ = heap; + else if (name == "#Blob") blobs_ = heap; + else if (name == "#~" || name == "#-") { + tables_ = heap; + hasTables = true; + } + } + if (!hasTables) { + error = "no_table_stream"; + return false; + } + if (!LayoutTables(error)) return false; + // Nesting, for names like Outer/Inner. + nestedEnclosing_.assign(Rows(TypeDef) + 1, 0); + for (uint32_t row = 1; row <= Rows(NestedClass); ++row) { + const uint32_t nested = Cell(NestedClass, row, 0), enclosing = Cell(NestedClass, row, 1); + if (nested && nested <= Rows(TypeDef) && enclosing <= Rows(TypeDef) && nested != enclosing) + nestedEnclosing_[nested] = enclosing; + } + // Declaring type of every method and field, from the TypeDef ranges. + methodOwner_.assign(Rows(MethodDef) + 1, 0); + fieldOwner_.assign(Rows(Field) + 1, 0); + for (uint32_t type = 1; type <= Rows(TypeDef); ++type) { + uint32_t first = 0, end = 0; + Methods(type, first, end); + for (uint32_t i = first; i < end; ++i) + if (const uint32_t method = MethodAt(i); method < methodOwner_.size() && !methodOwner_[method]) methodOwner_[method] = type; + Fields(type, first, end); + for (uint32_t i = first; i < end; ++i) + if (const uint32_t field = FieldAt(i); field < fieldOwner_.size() && !fieldOwner_[field]) fieldOwner_[field] = type; + } + return true; +} + +bool Metadata::LayoutTables(std::string& error) { + if (tables_.size < 24) { + error = "table_stream_truncated"; + return false; + } + const uint8_t* t = metadata_.data() + tables_.offset; + const uint8_t heapSizes = t[6]; + wideStrings_ = heapSizes & 0x01; + wideGuids_ = heapSizes & 0x02; + wideBlobs_ = heapSizes & 0x04; + uint64_t valid = 0; + std::memcpy(&valid, t + 8, 8); + size_t at = 24; + for (uint8_t table = 0; table < 64; ++table) { + if (!(valid & (1ull << table))) continue; + if (table >= TableCount) { + error = std::string("unsupported_table:") + std::to_string(table); + return false; + } + if (at + 4 > tables_.size) { + error = "table_row_counts_truncated"; + return false; + } + rows_[table] = Le32(t + at); + if (rows_[table] > 0x00FFFFFF) { + error = "table_too_large"; + return false; + } + at += 4; + } + if (heapSizes & 0x40) at += 4; // extra data (#- streams) + auto indexWidth = [&](uint8_t table) -> uint8_t { return rows_[table] < 0x10000 ? 2 : 4; }; + auto codedWidth = [&](uint8_t kind) -> uint8_t { + const auto& info = CodedInfo(kind); + uint32_t most = 0; + for (uint8_t table : info.tables) + if (table != kNone) most = std::max(most, rows_[table]); + return most < (1u << (16 - info.bits)) ? 2 : 4; + }; + uint64_t offset = tables_.offset + at; + for (uint8_t table = 0; table < TableCount; ++table) { + auto& widths = columnWidths_[table]; + widths.clear(); + uint32_t size = 0; + for (const auto& column : TableSchema(table)) { + uint8_t width = 0; + switch (column.kind) { + case Col::U16: width = 2; break; + case Col::U32: width = 4; break; + case Col::Str: width = wideStrings_ ? 4 : 2; break; + case Col::Guid: width = wideGuids_ ? 4 : 2; break; + case Col::Blob: width = wideBlobs_ ? 4 : 2; break; + case Col::Index: width = indexWidth(column.arg); break; + case Col::Coded: width = codedWidth(column.arg); break; + } + widths.push_back(width); + size += width; + } + rowSize_[table] = size; + tableOffset_[table] = offset; + offset += static_cast(size) * rows_[table]; + } + if (offset > static_cast(tables_.offset) + tables_.size) { + error = "tables_exceed_stream"; + return false; + } + return true; +} + +uint32_t Metadata::ReadIndex(uint64_t at, uint8_t width) const { + if (at + width > metadata_.size()) return 0; + return width == 2 ? Le16(&metadata_[at]) : Le32(&metadata_[at]); +} + +uint32_t Metadata::Cell(uint8_t table, uint32_t row, size_t column) const { + if (table >= TableCount || row == 0 || row > rows_[table] || column >= columnWidths_[table].size()) return 0; + uint64_t at = tableOffset_[table] + static_cast(row - 1) * rowSize_[table]; + for (size_t c = 0; c < column; ++c) at += columnWidths_[table][c]; + return ReadIndex(at, columnWidths_[table][column]); +} + +std::string Metadata::String(uint32_t index) const { + if (index >= strings_.size) return {}; + const char* start = reinterpret_cast(metadata_.data() + strings_.offset + index); + return std::string(start, strnlen(start, strings_.size - index)); +} + +std::string Metadata::UserString(uint32_t index) const { + if (index == 0 || index >= userStrings_.size) return {}; + const uint8_t* p = metadata_.data() + userStrings_.offset + index; + const uint8_t* end = metadata_.data() + userStrings_.offset + userStrings_.size; + uint32_t length = 0; + if (!ReadCompressed(p, end, length) || length > static_cast(end - p)) return {}; + // UTF-16LE plus one trailing flag byte. + std::string out; + for (uint32_t i = 0; i + 1 < length; i += 2) { + uint32_t unit = Le16(p + i); + if (unit >= 0xD800 && unit < 0xDC00 && i + 3 < length) { + const uint32_t low = Le16(p + i + 2); + if (low >= 0xDC00 && low < 0xE000) { + unit = 0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00); + i += 2; + } + } + AppendUtf8(out, unit); + } + return out; +} + +std::string Metadata::Guid(uint32_t index) const { + if (index == 0 || static_cast(index) * 16 > guids_.size) return {}; + const uint8_t* g = metadata_.data() + guids_.offset + (index - 1) * 16; + char text[40]; + std::snprintf(text, sizeof(text), "%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x", Le32(g), Le16(g + 4), + Le16(g + 6), g[8], g[9], g[10], g[11], g[12], g[13], g[14], g[15]); + return text; +} + +bool Metadata::Blob(uint32_t index, const uint8_t*& data, uint32_t& size) const { + if (index >= blobs_.size) return false; + const uint8_t* p = metadata_.data() + blobs_.offset + index; + const uint8_t* end = metadata_.data() + blobs_.offset + blobs_.size; + if (!ReadCompressed(p, end, size) || size > static_cast(end - p)) return false; + data = p; + return true; +} + +std::string Metadata::TypeDefName(uint32_t row) const { + std::string name = String(Cell(TypeDef, row, 1)); + // Walk out through enclosing types (bounded against cycles). + for (int depth = 0; depth < 64 && row < nestedEnclosing_.size() && nestedEnclosing_[row]; ++depth) { + row = nestedEnclosing_[row]; + name = String(Cell(TypeDef, row, 1)) + "/" + name; + } + const std::string ns = String(Cell(TypeDef, row, 2)); + return ns.empty() ? name : ns + "." + name; +} + +std::string Metadata::TypeRefName(uint32_t row) const { + std::string name = String(Cell(TypeRef, row, 1)); + for (int depth = 0; depth < 64; ++depth) { + const uint32_t scope = Cell(TypeRef, row, 0); + const auto& info = CodedInfo(ResolutionScope); + const uint32_t tag = scope & ((1u << info.bits) - 1), target = scope >> info.bits; + if (tag >= info.tables.size() || info.tables[tag] != TypeRef || !target || target == row) break; + row = target; + name = String(Cell(TypeRef, row, 1)) + "/" + name; + } + const std::string ns = String(Cell(TypeRef, row, 2)); + return ns.empty() ? name : ns + "." + name; +} + +std::string Metadata::TypeName(uint32_t token) const { + const uint8_t table = static_cast(token >> 24); + const uint32_t row = token & 0x00FFFFFF; + if (table == TypeDef) return TypeDefName(row); + if (table == TypeRef) return TypeRefName(row); + if (table == TypeSpec) return TypeSpecSignature(Cell(TypeSpec, row, 0)); + return "?"; +} + +std::string Metadata::TypeDefOrRefEncoded(uint32_t value) const { + static const uint8_t tables[3] = {TypeDef, TypeRef, TypeSpec}; + const uint32_t tag = value & 3, row = value >> 2; + if (tag > 2) return "?"; + return TypeName((static_cast(tables[tag]) << 24) | row); +} + +std::string Metadata::TypeFromSignature(const uint8_t*& p, const uint8_t* end, int depth) const { + if (depth == 0 && g_typeSpecNesting == 0) g_signatureWork = 0; // a top-level signature + if (depth > kMaxSignatureDepth || p >= end || ++g_signatureWork > kSignatureWorkBudget) return "?"; + const uint8_t element = *p++; + static const char* const primitives[] = {nullptr, "void", "bool", "char", "int8", "uint8", + "int16", "uint16", "int32", "uint32", "int64", "uint64", + "float32", "float64", "string"}; + if (element >= 0x01 && element <= 0x0E) return primitives[element]; + uint32_t value = 0; + switch (element) { + case 0x0F: return TypeFromSignature(p, end, depth + 1) + "*"; + case 0x10: return TypeFromSignature(p, end, depth + 1) + "&"; + case 0x11: + case 0x12: return ReadCompressed(p, end, value) ? TypeDefOrRefEncoded(value) : "?"; + case 0x13: return ReadCompressed(p, end, value) ? "!" + std::to_string(value) : "?"; + case 0x1E: return ReadCompressed(p, end, value) ? "!!" + std::to_string(value) : "?"; + case 0x14: { + const std::string element_ = TypeFromSignature(p, end, depth + 1); + uint32_t rank = 0, sizes = 0, bounds = 0, ignored = 0; + if (!ReadCompressed(p, end, rank) || rank == 0 || rank > 32 || !ReadCompressed(p, end, sizes) || sizes > rank) + return "?"; + for (uint32_t i = 0; i < sizes; ++i) + if (!ReadCompressed(p, end, ignored)) return "?"; + if (!ReadCompressed(p, end, bounds) || bounds > rank) return "?"; + for (uint32_t i = 0; i < bounds; ++i) + if (!ReadCompressed(p, end, ignored)) return "?"; // signed, same encoding width + return element_ + "[" + std::string(rank - 1, ',') + "]"; + } + case 0x15: { + if (p >= end) return "?"; + ++p; // class or valuetype + uint32_t generic = 0, count = 0; + if (!ReadCompressed(p, end, generic) || !ReadCompressed(p, end, count) || count > 64) return "?"; + std::vector args; + for (uint32_t i = 0; i < count; ++i) args.push_back(TypeFromSignature(p, end, depth + 1)); + return TypeDefOrRefEncoded(generic) + "<" + Join(args, ",") + ">"; + } + case 0x16: return "typedref"; + case 0x18: return "native int"; + case 0x19: return "native uint"; + case 0x1B: { + // Function pointer: its method signature is skipped. + if (p >= end) return "?"; + const uint8_t convention = *p++; + uint32_t generic = 0; + if ((convention & 0x10) && !ReadCompressed(p, end, generic)) return "?"; + uint32_t count = 0; + if (!ReadCompressed(p, end, count) || count > 1024) return "?"; + TypeFromSignature(p, end, depth + 1); + for (uint32_t i = 0; i < count; ++i) TypeFromSignature(p, end, depth + 1); + return "method"; + } + case 0x1C: return "object"; + case 0x1D: return TypeFromSignature(p, end, depth + 1) + "[]"; + case 0x1F: + case 0x20: // custom modifiers: skip the modifier, keep the type + if (!ReadCompressed(p, end, value)) return "?"; + return TypeFromSignature(p, end, depth + 1); + case 0x45: return TypeFromSignature(p, end, depth + 1); // pinned + case 0x41: return TypeFromSignature(p, end, depth + 1); // sentinel (varargs) + } + return "?"; +} + +std::string Metadata::MethodSignature(uint32_t blob) const { + const uint8_t* p = nullptr; + uint32_t size = 0; + if (!blob || !Blob(blob, p, size) || size == 0) return "?"; + const uint8_t* end = p + size; + const uint8_t convention = *p++; + uint32_t generic = 0, count = 0; + if ((convention & 0x10) && !ReadCompressed(p, end, generic)) return "?"; + if (!ReadCompressed(p, end, count) || count > 4096) return "?"; + const std::string returns = TypeFromSignature(p, end, 0); + std::vector parameters; + for (uint32_t i = 0; i < count && p < end; ++i) parameters.push_back(TypeFromSignature(p, end, 0)); + return returns + " (" + Join(parameters, ", ") + ")"; +} + +std::string Metadata::FieldSignature(uint32_t blob) const { + const uint8_t* p = nullptr; + uint32_t size = 0; + if (!Blob(blob, p, size) || size < 2 || *p != 0x06) return "?"; + ++p; + return TypeFromSignature(p, p + size - 1, 0); +} + +std::string Metadata::TypeSpecSignature(uint32_t blob) const { + const uint8_t* p = nullptr; + uint32_t size = 0; + if (!Blob(blob, p, size) || size == 0 || g_typeSpecNesting >= kMaxTypeSpecNesting) return "?"; + struct Nesting { + Nesting() { ++g_typeSpecNesting; } + ~Nesting() { --g_typeSpecNesting; } + } nesting; + return TypeFromSignature(p, p + size, 0); +} + +std::string Metadata::ParentName(uint32_t coded) const { + const auto& info = CodedInfo(MemberRefParent); + const uint32_t tag = coded & ((1u << info.bits) - 1), row = coded >> info.bits; + if (tag >= info.tables.size()) return "?"; + const uint8_t table = info.tables[tag]; + if (table == ModuleRef) return "[" + String(Cell(ModuleRef, row, 0)) + "]"; + if (table == MethodDef) return MemberName((static_cast(MethodDef) << 24) | row); + return TypeName((static_cast(table) << 24) | row); +} + +uint32_t Metadata::MethodAt(uint32_t index) const { return Rows(MethodPtr) ? Cell(MethodPtr, index, 0) : index; } +uint32_t Metadata::FieldAt(uint32_t index) const { return Rows(FieldPtr) ? Cell(FieldPtr, index, 0) : index; } +uint32_t Metadata::ParamAt(uint32_t index) const { return Rows(ParamPtr) ? Cell(ParamPtr, index, 0) : index; } + +void Metadata::Methods(uint32_t typeRow, uint32_t& first, uint32_t& end) const { + const uint32_t count = Rows(MethodPtr) ? Rows(MethodPtr) : Rows(MethodDef); + first = std::max(1, Cell(TypeDef, typeRow, 5)); + end = typeRow < Rows(TypeDef) ? Cell(TypeDef, typeRow + 1, 5) : count + 1; + end = std::min(end, count + 1); + if (end < first) end = first; +} + +void Metadata::Fields(uint32_t typeRow, uint32_t& first, uint32_t& end) const { + const uint32_t count = Rows(FieldPtr) ? Rows(FieldPtr) : Rows(Field); + first = std::max(1, Cell(TypeDef, typeRow, 4)); + end = typeRow < Rows(TypeDef) ? Cell(TypeDef, typeRow + 1, 4) : count + 1; + end = std::min(end, count + 1); + if (end < first) end = first; +} + +void Metadata::Params(uint32_t methodRow, uint32_t& first, uint32_t& end) const { + const uint32_t count = Rows(ParamPtr) ? Rows(ParamPtr) : Rows(Param); + first = std::max(1, Cell(MethodDef, methodRow, 5)); + end = methodRow < Rows(MethodDef) ? Cell(MethodDef, methodRow + 1, 5) : count + 1; + end = std::min(end, count + 1); + if (end < first) end = first; +} + +uint32_t Metadata::MethodOwner(uint32_t methodRow) const { + return methodRow < methodOwner_.size() ? methodOwner_[methodRow] : 0; +} + +uint32_t Metadata::FieldOwner(uint32_t fieldRow) const { + return fieldRow < fieldOwner_.size() ? fieldOwner_[fieldRow] : 0; +} + +std::string Metadata::MemberName(uint32_t token) const { + const uint8_t table = static_cast(token >> 24); + const uint32_t row = token & 0x00FFFFFF; + switch (table) { + case MethodDef: return TypeDefName(MethodOwner(row)) + "::" + String(Cell(MethodDef, row, 3)); + case Field: return TypeDefName(FieldOwner(row)) + "::" + String(Cell(Field, row, 1)); + case MemberRef: return ParentName(Cell(MemberRef, row, 0)) + "::" + String(Cell(MemberRef, row, 1)); + case MethodSpec: { + const uint32_t method = Cell(MethodSpec, row, 0); + const auto& info = CodedInfo(MethodDefOrRef); + const uint32_t tag = method & 1, target = method >> 1; + const std::string name = MemberName((static_cast(info.tables[tag]) << 24) | target); + const uint8_t* p = nullptr; + uint32_t size = 0, count = 0; + if (!Blob(Cell(MethodSpec, row, 1), p, size) || size < 2 || *p != 0x0A) return name + ""; + const uint8_t* end = p + size; + ++p; + if (!ReadCompressed(p, end, count) || count > 64) return name + ""; + std::vector args; + for (uint32_t i = 0; i < count; ++i) args.push_back(TypeFromSignature(p, end, 0)); + return name + "<" + Join(args, ",") + ">"; + } + case TypeDef: + case TypeRef: + case TypeSpec: return TypeName(token); + case StandAloneSig: return "sig"; + } + return "?"; +} + +bool Metadata::ReadBody(const ByteSource& source, const PeFile& pe, uint32_t methodRow, MethodBody& body, + std::string& error) const { + body = MethodBody(); + const uint32_t rva = Cell(MethodDef, methodRow, 0); + uint64_t offset = 0; + uint32_t available = 0; + if (!rva || !pe.RvaToOffset(rva, offset, &available) || available == 0) { + error = rva ? "method_body_outside_file" : "method_has_no_body"; + return false; + } + uint8_t first = 0; + if (!source.Read(offset, &first, 1)) { + error = "method_body_truncated"; + return false; + } + uint64_t codeOffset = 0; + uint32_t codeSize = 0; + bool moreSections = false; + if ((first & 3) == 2) { + body.tiny = true; + body.maxStack = 8; + codeSize = first >> 2; + codeOffset = offset + 1; + } else if ((first & 3) == 3) { + uint8_t header[12]; + if (!source.Read(offset, header, sizeof(header))) { + error = "method_header_truncated"; + return false; + } + const uint16_t flags = Le16(header); + const uint32_t headerSize = (flags >> 12) * 4u; + if (headerSize < 12) { + error = "method_header_size_invalid"; + return false; + } + moreSections = flags & 0x08; + body.initLocals = flags & 0x10; + body.maxStack = Le16(header + 2); + codeSize = Le32(header + 4); + body.localSignature = Le32(header + 8); + codeOffset = offset + headerSize; + } else { + error = "method_header_format_invalid"; + return false; + } + if (codeSize > (16u << 20)) { + error = "method_body_too_large"; + return false; + } + body.code = source.ReadSome(codeOffset, codeSize); + if (body.code.size() != codeSize) { + error = "method_body_truncated"; + return false; + } + uint64_t section = (codeOffset + codeSize + 3) & ~uint64_t{3}; + for (int guard = 0; moreSections && guard < 16; ++guard) { + uint8_t kind = 0; + if (!source.Read(section, &kind, 1)) { + error = "method_sections_truncated"; + return false; + } + const bool fat = kind & 0x40; + uint32_t dataSize = 0; + if (fat) { + uint8_t size[3]; + if (!source.Read(section + 1, size, 3)) return error = "method_sections_truncated", false; + dataSize = size[0] | (size[1] << 8) | (size[2] << 16); + } else { + uint8_t size = 0; + if (!source.Read(section + 1, &size, 1)) return error = "method_sections_truncated", false; + dataSize = size; + } + if ((kind & 0x3F) == 0x01) { // exception handling table + const uint32_t clauseSize = fat ? 24 : 12; + const uint32_t count = dataSize >= 4 ? (dataSize - 4) / clauseSize : 0; + const auto table = source.ReadSome(section + 4, static_cast(count) * clauseSize); + if (table.size() != static_cast(count) * clauseSize) return error = "exception_table_truncated", false; + for (uint32_t i = 0; i < count && i < 4096; ++i) { + const uint8_t* c = table.data() + i * clauseSize; + ExceptionClause clause; + uint32_t flags = 0; + if (fat) { + flags = Le32(c); + clause.tryOffset = Le32(c + 4); + clause.tryLength = Le32(c + 8); + clause.handlerOffset = Le32(c + 12); + clause.handlerLength = Le32(c + 16); + clause.classTokenOrFilter = Le32(c + 20); + } else { + flags = Le16(c); + clause.tryOffset = Le16(c + 2); + clause.tryLength = c[4]; + clause.handlerOffset = Le16(c + 5); + clause.handlerLength = c[7]; + clause.classTokenOrFilter = Le32(c + 8); + } + clause.kind = flags == 0 ? "catch" : flags == 1 ? "filter" : flags == 2 ? "finally" : flags == 4 ? "fault" : "unknown"; + body.clauses.push_back(clause); + } + } + moreSections = kind & 0x80; + if (dataSize < 4) break; + section = (section + dataSize + 3) & ~uint64_t{3}; + } + return true; +} + +json Metadata::Summary(const InspectOptions& options) const { + json data = {{"metadata_version", version}, + {"runtime_header", std::to_string(runtimeMajor) + "." + std::to_string(runtimeMinor)}, + {"clr_flags", Hex32(clrFlags)}, + {"il_only", (clrFlags & 1) != 0}, + {"strong_name_signed", (clrFlags & 8) != 0}}; + json streamRows = json::array(); + for (const auto& [name, size] : streams) streamRows.push_back({{"name", name}, {"size", size}}); + data["streams"] = streamRows; + json tables = json::object(); + for (uint8_t table = 0; table < TableCount; ++table) + if (rows_[table]) tables[TableName(table)] = rows_[table]; + data["tables"] = tables; + + if (Rows(Assembly)) { + const uint32_t row = 1; + const std::string version_ = std::to_string(Cell(Assembly, row, 1)) + "." + std::to_string(Cell(Assembly, row, 2)) + + "." + std::to_string(Cell(Assembly, row, 3)) + "." + std::to_string(Cell(Assembly, row, 4)); + const uint8_t* key = nullptr; + uint32_t keySize = 0; + Blob(Cell(Assembly, row, 6), key, keySize); + data["assembly"] = {{"name", String(Cell(Assembly, row, 7))}, + {"version", version_}, + {"culture", String(Cell(Assembly, row, 8))}, + {"flags", Hex32(Cell(Assembly, row, 5))}, + {"public_key_bytes", keySize}}; + } + if (Rows(Module)) + data["module"] = {{"name", String(Cell(Module, 1, 1))}, {"mvid", Guid(Cell(Module, 1, 2))}}; + if (entryPointToken) data["entry_point"] = {{"token", Hex32(entryPointToken)}, {"name", MemberName(entryPointToken)}}; + + json references = json::array(); + for (uint32_t row = 1; row <= Rows(AssemblyRef) && references.size() < options.maxEntries; ++row) { + const std::string version_ = std::to_string(Cell(AssemblyRef, row, 0)) + "." + std::to_string(Cell(AssemblyRef, row, 1)) + + "." + std::to_string(Cell(AssemblyRef, row, 2)) + "." + std::to_string(Cell(AssemblyRef, row, 3)); + const uint8_t* token = nullptr; + uint32_t tokenSize = 0; + std::string hex; + if (Blob(Cell(AssemblyRef, row, 5), token, tokenSize) && tokenSize <= 8) + for (uint32_t i = 0; i < tokenSize; ++i) { + char byte[3]; + std::snprintf(byte, sizeof(byte), "%02x", token[i]); + hex += byte; + } + references.push_back({{"name", String(Cell(AssemblyRef, row, 6))}, {"version", version_}, {"public_key_token", hex}}); + } + data["assembly_refs"] = references; + + json types = json::array(); + size_t members = 0; + bool truncated = false; + for (uint32_t row = 1; row <= Rows(TypeDef); ++row) { + if (types.size() >= options.maxEntries) { + truncated = true; + break; + } + const uint32_t extends = Cell(TypeDef, row, 3); + json type = {{"token", Hex32((static_cast(TypeDef) << 24) | row)}, + {"name", TypeDefName(row)}, + {"flags", Cell(TypeDef, row, 0)}, + {"extends", extends >> 2 ? json(TypeDefOrRefEncoded(extends)) : json(nullptr)}}; + json fields = json::array(), methods = json::array(); + uint32_t first = 0, end = 0; + Fields(row, first, end); + for (uint32_t i = first; i < end; ++i, ++members) { + if (members >= options.maxEntries * 8) { truncated = true; break; } + const uint32_t field = FieldAt(i); + fields.push_back({{"token", Hex32((static_cast(Field) << 24) | field)}, + {"name", String(Cell(Field, field, 1))}, + {"flags", Cell(Field, field, 0)}, + {"signature", FieldSignature(Cell(Field, field, 2))}}); + } + Methods(row, first, end); + for (uint32_t i = first; i < end; ++i, ++members) { + if (members >= options.maxEntries * 8) { truncated = true; break; } + const uint32_t method = MethodAt(i); + json parameters = json::array(); + uint32_t pFirst = 0, pEnd = 0; + Params(method, pFirst, pEnd); + for (uint32_t p = pFirst; p < pEnd && parameters.size() < 256; ++p) { + const uint32_t param = ParamAt(p); + if (Cell(Param, param, 1) > 0) parameters.push_back(String(Cell(Param, param, 2))); + } + methods.push_back({{"token", Hex32((static_cast(MethodDef) << 24) | method)}, + {"name", String(Cell(MethodDef, method, 3))}, + {"flags", Cell(MethodDef, method, 2)}, + {"impl_flags", Cell(MethodDef, method, 1)}, + {"rva", Cell(MethodDef, method, 0)}, + {"signature", MethodSignature(Cell(MethodDef, method, 4))}, + {"parameters", parameters}}); + } + type["fields"] = fields; + type["methods"] = methods; + types.push_back(std::move(type)); + } + data["types"] = types; + + json typeRefs = json::array(); + for (uint32_t row = 1; row <= Rows(TypeRef) && typeRefs.size() < options.maxEntries; ++row) + typeRefs.push_back({{"token", Hex32((static_cast(TypeRef) << 24) | row)}, {"name", TypeRefName(row)}}); + data["type_refs"] = typeRefs; + + json memberRefs = json::array(); + for (uint32_t row = 1; row <= Rows(MemberRef) && memberRefs.size() < options.maxEntries; ++row) { + const uint32_t token = (static_cast(MemberRef) << 24) | row; + const uint32_t blob = Cell(MemberRef, row, 2); + const uint8_t* p = nullptr; + uint32_t size = 0; + const bool field = Blob(blob, p, size) && size && *p == 0x06; + memberRefs.push_back({{"token", Hex32(token)}, {"name", MemberName(token)}, + {"signature", field ? FieldSignature(blob) : MethodSignature(blob)}}); + } + data["member_refs"] = memberRefs; + + json resourcesJson = json::array(); + for (uint32_t row = 1; row <= Rows(ManifestResource) && resourcesJson.size() < options.maxEntries; ++row) + resourcesJson.push_back({{"name", String(Cell(ManifestResource, row, 2))}, + {"offset", Cell(ManifestResource, row, 0)}, + {"embedded", (Cell(ManifestResource, row, 3) >> 2) == 0}}); + data["manifest_resources"] = resourcesJson; + + json pinvoke = json::array(); + for (uint32_t row = 1; row <= Rows(ImplMap) && pinvoke.size() < options.maxEntries; ++row) { + const uint32_t forwarded = Cell(ImplMap, row, 1); + const uint32_t tag = forwarded & 1, target = forwarded >> 1; + const uint32_t token = ((tag ? static_cast(MethodDef) : static_cast(Field)) << 24) | target; + pinvoke.push_back({{"method", MemberName(token)}, + {"entry", String(Cell(ImplMap, row, 2))}, + {"module", String(Cell(ModuleRef, Cell(ImplMap, row, 3), 0))}}); + } + data["pinvoke"] = pinvoke; + data["truncated"] = truncated; + if (!warnings.empty()) data["warnings"] = warnings; + return data; +} + +} // namespace cortex::artifacts::dotnet diff --git a/core/artifacts/dotnet_metadata.h b/core/artifacts/dotnet_metadata.h new file mode 100644 index 00000000..531fa4bf --- /dev/null +++ b/core/artifacts/dotnet_metadata.h @@ -0,0 +1,136 @@ +#pragma once + +// ECMA-335 metadata of a .NET assembly, read from the PE file (step 4c): +// the CLR header, the metadata root and streams, the #~ table schema (all +// 45 tables, so any assembly is laid out correctly even when only some +// tables are decoded), heaps, signatures and method bodies. The bytes are +// untrusted: every index is checked against its table or heap, signature +// decoding is depth-limited, and failures name what was wrong. +// +// Names follow ilasm: Namespace.Name, nested types as Outer/Inner, method +// signatures "ret (p1, p2)", generic parameters !n / !!n, Name`n. + +#include "artifact.h" +#include "pe_provider.h" + +#include +#include +#include +#include + +namespace cortex::artifacts::dotnet { + +enum Table : uint8_t { + Module = 0x00, TypeRef = 0x01, TypeDef = 0x02, FieldPtr = 0x03, Field = 0x04, MethodPtr = 0x05, + MethodDef = 0x06, ParamPtr = 0x07, Param = 0x08, InterfaceImpl = 0x09, MemberRef = 0x0A, Constant = 0x0B, + CustomAttribute = 0x0C, FieldMarshal = 0x0D, DeclSecurity = 0x0E, ClassLayout = 0x0F, FieldLayout = 0x10, + StandAloneSig = 0x11, EventMap = 0x12, EventPtr = 0x13, Event = 0x14, PropertyMap = 0x15, PropertyPtr = 0x16, + Property = 0x17, MethodSemantics = 0x18, MethodImpl = 0x19, ModuleRef = 0x1A, TypeSpec = 0x1B, ImplMap = 0x1C, + FieldRva = 0x1D, EncLog = 0x1E, EncMap = 0x1F, Assembly = 0x20, AssemblyProcessor = 0x21, AssemblyOs = 0x22, + AssemblyRef = 0x23, AssemblyRefProcessor = 0x24, AssemblyRefOs = 0x25, File = 0x26, ExportedType = 0x27, + ManifestResource = 0x28, NestedClass = 0x29, GenericParam = 0x2A, MethodSpec = 0x2B, + GenericParamConstraint = 0x2C, TableCount = 0x2D, +}; + +const char* TableName(uint8_t table); + +struct ExceptionClause { + std::string kind; // catch | filter | finally | fault + uint32_t tryOffset = 0, tryLength = 0, handlerOffset = 0, handlerLength = 0; + uint32_t classTokenOrFilter = 0; +}; + +struct MethodBody { + bool tiny = false; + uint16_t maxStack = 0; + uint32_t localSignature = 0; // StandAloneSig token, 0 if none + bool initLocals = false; + std::vector code; + std::vector clauses; +}; + +class Metadata { +public: + // Reads the CLR header and metadata of `pe`. False with `error` set if + // the PE has no usable metadata. + bool Parse(const ByteSource& source, const PeFile& pe, std::string& error); + + uint32_t Rows(uint8_t table) const { return table < TableCount ? rows_[table] : 0; } + // Column value of a 1-based row; 0 when out of range. + uint32_t Cell(uint8_t table, uint32_t row, size_t column) const; + + std::string String(uint32_t index) const; + std::string UserString(uint32_t index) const; + std::string Guid(uint32_t index) const; + // Blob bytes; false if the index or length is outside the heap. + bool Blob(uint32_t index, const uint8_t*& data, uint32_t& size) const; + + // Names. + std::string TypeDefName(uint32_t row) const; + std::string TypeRefName(uint32_t row) const; + // A TypeDefOrRef coded index, or a token of TypeDef/TypeRef/TypeSpec. + std::string TypeName(uint32_t token) const; + // Parent::Name for MethodDef, Field, MemberRef, MethodSpec tokens; the + // type for type tokens; "sig" for StandAloneSig. + std::string MemberName(uint32_t token) const; + + // Signatures as text. + std::string MethodSignature(uint32_t blob) const; + std::string FieldSignature(uint32_t blob) const; + std::string TypeSpecSignature(uint32_t blob) const; + + // Declaring type of a MethodDef / Field row (0 if none). + uint32_t MethodOwner(uint32_t methodRow) const; + uint32_t FieldOwner(uint32_t fieldRow) const; + // Member ranges of a TypeDef row: [first, end) in MethodDef / Field / Param. + void Methods(uint32_t typeRow, uint32_t& first, uint32_t& end) const; + void Fields(uint32_t typeRow, uint32_t& first, uint32_t& end) const; + void Params(uint32_t methodRow, uint32_t& first, uint32_t& end) const; + // Through MethodPtr/FieldPtr/ParamPtr when the tables are unsorted (#-). + uint32_t MethodAt(uint32_t index) const; + uint32_t FieldAt(uint32_t index) const; + uint32_t ParamAt(uint32_t index) const; + + // Body of a MethodDef row (RVA != 0). + bool ReadBody(const ByteSource& source, const PeFile& pe, uint32_t methodRow, MethodBody& body, + std::string& error) const; + + // The summary the provider returns: identity, references, types and + // members (bounded), resources, P/Invoke, table row counts. + json Summary(const InspectOptions& options) const; + + std::string version; // metadata root version string ("v4.0.30319") + uint16_t runtimeMajor = 0, runtimeMinor = 0; + uint32_t clrFlags = 0; + uint32_t entryPointToken = 0; + PeDirectory resources{}; + PeDirectory strongNameSignature{}; + std::vector> streams; // name, size + std::vector warnings; + +private: + struct Heap { + uint32_t offset = 0, size = 0; // inside metadata_ + }; + bool LayoutTables(std::string& error); + uint32_t ReadIndex(uint64_t at, uint8_t width) const; + std::string TypeFromSignature(const uint8_t*& p, const uint8_t* end, int depth) const; + std::string TypeDefOrRefEncoded(uint32_t value) const; + std::string ParentName(uint32_t codedMemberRefParent) const; + + std::vector metadata_; + Heap strings_, userStrings_, guids_, blobs_, tables_; + std::array rows_{}; + std::array rowSize_{}; + std::array tableOffset_{}; + std::array, TableCount> columnWidths_{}; + bool wideStrings_ = false, wideGuids_ = false, wideBlobs_ = false; + std::vector nestedEnclosing_; // TypeDef row -> enclosing row (0 if not nested) + std::vector methodOwner_; // MethodDef row -> TypeDef row + std::vector fieldOwner_; // Field row -> TypeDef row +}; + +// Compressed unsigned integer (ECMA-335 II.23.2); false if truncated. +bool ReadCompressed(const uint8_t*& p, const uint8_t* end, uint32_t& value); + +} // namespace cortex::artifacts::dotnet diff --git a/core/artifacts/dotnet_provider.cpp b/core/artifacts/dotnet_provider.cpp new file mode 100644 index 00000000..6f73c238 --- /dev/null +++ b/core/artifacts/dotnet_provider.cpp @@ -0,0 +1,67 @@ +#include "dotnet_provider.h" + +#include "cil.h" +#include "dotnet_metadata.h" +#include "pe_provider.h" + +namespace cortex::artifacts { + +std::vector DotNetProvider::Capabilities() const { + return {{"static", CapabilityStatus::Validated, + "cortex_dotnet_tests (C# fixture compared with System.Reflection.Metadata and the runtime OpCodes table)", + "CLR header, metadata streams and all 45 table layouts; assembly, references, types, fields, methods, " + "signatures, member references, resources, P/Invoke; CIL disassembly with exception clauses"}, + {"decompile", CapabilityStatus::Unavailable, "", + "no built-in C# decompiler (CIL is disassembled). External: the ILSpy analysis provider " + "(cortex_analysis_start provider=ilspy, `cortex provider run ilspy`) when the operator configures ilspycmd"}, + {"live", CapabilityStatus::Unavailable, "", + "no managed debugging in this file provider; the native engine still attaches to the process. External: " + "the netcoredbg live provider (cortex_live_attach protocol=dap adapter=netcoredbg, log breakpoints only) " + "when the operator configures --netcoredbg"}}; +} + +int DotNetProvider::Detect(const ByteSource& source) const { + PeFile pe; + std::string error; + return ParsePe(source, pe, error) && pe.HasDirectory(kPeClr) ? 58 : 0; +} + +json DotNetProvider::Inspect(const ByteSource& source, const InspectOptions& options, std::string& error) const { + // The PE view first: a .NET assembly is still a PE file. + json data = PeProvider().Inspect(source, options, error); + if (data.is_null()) return nullptr; + PeFile pe; + std::string peError; + if (!ParsePe(source, pe, peError)) { + error = peError; + return nullptr; + } + dotnet::Metadata metadata; + std::string metadataError; + if (!metadata.Parse(source, pe, metadataError)) { + data["dotnet"] = {{"error", metadataError}}; + } else { + data["dotnet"] = metadata.Summary(options); + } + data["format"] = "dotnet"; + return data; +} + +json DisassembleMethods(const ByteSource& source, const std::string& query, size_t maxInstructions) { + PeFile pe; + std::string error; + if (!ParsePe(source, pe, error)) return {{"ok", false}, {"error", error}}; + dotnet::Metadata metadata; + if (!metadata.Parse(source, pe, error)) return {{"ok", false}, {"error", error}}; + const auto rows = dotnet::FindMethods(metadata, query); + if (rows.empty()) return {{"ok", false}, {"error", "method_not_found"}}; + json methods = json::array(); + for (uint32_t row : rows) { + std::string methodError; + methods.push_back(dotnet::MethodJson(source, pe, metadata, row, maxInstructions, methodError)); + } + return {{"ok", true}, {"name", source.Name()}, {"methods", methods}, + {"note", "CIL disassembly (static); names resolved from metadata"}}; +} + +} // namespace cortex::artifacts diff --git a/core/artifacts/dotnet_provider.h b/core/artifacts/dotnet_provider.h new file mode 100644 index 00000000..0025e773 --- /dev/null +++ b/core/artifacts/dotnet_provider.h @@ -0,0 +1,24 @@ +#pragma once + +// .NET assemblies (step 4c): a PE with a CLR header, read as metadata and +// CIL. The PE view stays available under the same result. + +#include "artifact.h" + +namespace cortex::artifacts { + +class DotNetProvider final : public Provider { +public: + const char* Name() const override { return "dotnet"; } + const char* Version() const override { return "1"; } + std::vector Formats() const override { return {"dotnet"}; } + std::vector Capabilities() const override; + int Detect(const ByteSource& source) const override; + json Inspect(const ByteSource& source, const InspectOptions& options, std::string& error) const override; +}; + +// CIL of the methods matching `query` (a 0x06xxxxxx token, Type::Method or +// a method name) in a .NET assembly: {"ok", "methods": [...]} or an error. +json DisassembleMethods(const ByteSource& source, const std::string& query, size_t maxInstructions); + +} // namespace cortex::artifacts diff --git a/core/artifacts/inflate.cpp b/core/artifacts/inflate.cpp new file mode 100644 index 00000000..f2920a3f --- /dev/null +++ b/core/artifacts/inflate.cpp @@ -0,0 +1,303 @@ +#include "inflate.h" + +#include + +namespace cortex::artifacts { +namespace { + +constexpr int kMaxBits = 15; +constexpr int kMaxLengthCodes = 286; +constexpr int kMaxDistanceCodes = 30; +constexpr int kFixedLengthCodes = 288; + +// Canonical Huffman table: how many codes of each length, then the symbols +// in code order. +struct Huffman { + std::array count{}; + std::array symbol{}; +}; + +class Decoder { +public: + Decoder(const uint8_t* input, size_t size, std::vector& output, size_t maxOutput) + : in_(input), size_(size), out_(output), max_(maxOutput) {} + + bool Run(std::string& error) { + bool last = false; + while (!last) { + int value = 0; + if (!Bits(1, value)) return Fail(error); + last = value != 0; + int type = 0; + if (!Bits(2, type)) return Fail(error); + bool ok = false; + if (type == 0) ok = Stored(); + else if (type == 1) ok = Fixed(); + else if (type == 2) ok = Dynamic(); + else reason_ = "inflate_invalid_block_type"; + if (!ok) return Fail(error); + } + return true; + } + +private: + bool Fail(std::string& error) { + error = reason_.empty() ? "inflate_truncated" : reason_; + return false; + } + + bool Bits(int need, int& value) { + long bits = bitBuffer_; + while (bitCount_ < need) { + if (pos_ >= size_) { + reason_ = "inflate_truncated"; + return false; + } + bits |= static_cast(in_[pos_++]) << bitCount_; + bitCount_ += 8; + } + bitBuffer_ = static_cast(bits >> need); + bitCount_ -= need; + value = static_cast(bits & ((1L << need) - 1)); + return true; + } + + bool Emit(uint8_t byte) { + if (out_.size() >= max_) { + reason_ = "inflate_output_limit"; + return false; + } + out_.push_back(byte); + return true; + } + + bool Stored() { + bitBuffer_ = 0; // discard the rest of the current byte + bitCount_ = 0; + if (pos_ + 4 > size_) { + reason_ = "inflate_truncated"; + return false; + } + const unsigned length = in_[pos_] | (in_[pos_ + 1] << 8); + const unsigned complement = in_[pos_ + 2] | (in_[pos_ + 3] << 8); + pos_ += 4; + if (length != (~complement & 0xFFFFu)) { + reason_ = "inflate_stored_length_mismatch"; + return false; + } + if (pos_ + length > size_) { + reason_ = "inflate_truncated"; + return false; + } + for (unsigned i = 0; i < length; ++i) + if (!Emit(in_[pos_++])) return false; + return true; + } + + // Next symbol, reading the code one bit at a time (codes are stored + // bit-reversed in the stream). + bool Decode(const Huffman& h, int& symbol) { + int code = 0, first = 0, index = 0; + for (int length = 1; length <= kMaxBits; ++length) { + int bit = 0; + if (!Bits(1, bit)) return false; + code |= bit; + const int count = h.count[length]; + if (code - count < first) { + symbol = h.symbol[index + (code - first)]; + return true; + } + index += count; + first += count; + first <<= 1; + code <<= 1; + } + reason_ = "inflate_invalid_code"; + return false; + } + + // Builds a table from code lengths. Returns <0 for an over-subscribed + // set, 0 for complete, >0 for incomplete. + static int Build(Huffman& h, const uint16_t* lengths, int n) { + h.count.fill(0); + for (int s = 0; s < n; ++s) h.count[lengths[s]]++; + if (h.count[0] == n) return 0; // no codes: complete, decodes nothing + int left = 1; + for (int length = 1; length <= kMaxBits; ++length) { + left <<= 1; + left -= h.count[length]; + if (left < 0) return left; + } + std::array offsets{}; + for (int length = 1; length < kMaxBits; ++length) offsets[length + 1] = offsets[length] + h.count[length]; + for (int s = 0; s < n; ++s) + if (lengths[s] != 0) h.symbol[offsets[lengths[s]]++] = static_cast(s); + return left; + } + + bool Codes(const Huffman& lengthCodes, const Huffman& distanceCodes) { + static const uint16_t base[29] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, + 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258}; + static const uint8_t extra[29] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, + 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0}; + static const uint16_t distanceBase[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, + 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, + 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577}; + static const uint8_t distanceExtra[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, + 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13}; + while (true) { + int symbol = 0; + if (!Decode(lengthCodes, symbol)) return false; + if (symbol < 256) { + if (!Emit(static_cast(symbol))) return false; + continue; + } + if (symbol == 256) return true; + symbol -= 257; + if (symbol >= 29) { + reason_ = "inflate_invalid_length_symbol"; + return false; + } + int add = 0; + if (!Bits(extra[symbol], add)) return false; + const int length = base[symbol] + add; + int distanceSymbol = 0; + if (!Decode(distanceCodes, distanceSymbol)) return false; + if (distanceSymbol >= 30) { + reason_ = "inflate_invalid_distance_symbol"; + return false; + } + if (!Bits(distanceExtra[distanceSymbol], add)) return false; + const size_t distance = static_cast(distanceBase[distanceSymbol] + add); + if (distance > out_.size()) { + reason_ = "inflate_distance_too_far"; + return false; + } + for (int i = 0; i < length; ++i) + if (!Emit(out_[out_.size() - distance])) return false; + } + } + + bool Fixed() { + static Huffman lengthCodes, distanceCodes; + static const bool built = [] { + uint16_t lengths[kFixedLengthCodes]; + int s = 0; + for (; s < 144; ++s) lengths[s] = 8; + for (; s < 256; ++s) lengths[s] = 9; + for (; s < 280; ++s) lengths[s] = 7; + for (; s < kFixedLengthCodes; ++s) lengths[s] = 8; + Build(lengthCodes, lengths, kFixedLengthCodes); + for (s = 0; s < kMaxDistanceCodes; ++s) lengths[s] = 5; + Build(distanceCodes, lengths, kMaxDistanceCodes); + return true; + }(); + (void)built; + return Codes(lengthCodes, distanceCodes); + } + + bool Dynamic() { + static const uint8_t order[19] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15}; + int literalCount = 0, distanceCount = 0, codeCount = 0; + if (!Bits(5, literalCount) || !Bits(5, distanceCount) || !Bits(4, codeCount)) return false; + literalCount += 257; + distanceCount += 1; + codeCount += 4; + if (literalCount > kMaxLengthCodes || distanceCount > kMaxDistanceCodes) { + reason_ = "inflate_bad_counts"; + return false; + } + uint16_t lengths[kMaxLengthCodes + kMaxDistanceCodes] = {}; + for (int i = 0; i < codeCount; ++i) { + int value = 0; + if (!Bits(3, value)) return false; + lengths[order[i]] = static_cast(value); + } + Huffman lengthCodes, distanceCodes; + if (Build(lengthCodes, lengths, 19) != 0) { + reason_ = "inflate_incomplete_code_lengths"; + return false; + } + int index = 0; + while (index < literalCount + distanceCount) { + int symbol = 0; + if (!Decode(lengthCodes, symbol)) return false; + if (symbol < 16) { + lengths[index++] = static_cast(symbol); + continue; + } + uint16_t repeated = 0; + int repeat = 0; + if (symbol == 16) { + if (index == 0) { + reason_ = "inflate_repeat_without_previous"; + return false; + } + repeated = lengths[index - 1]; + if (!Bits(2, repeat)) return false; + repeat += 3; + } else if (symbol == 17) { + if (!Bits(3, repeat)) return false; + repeat += 3; + } else { + if (!Bits(7, repeat)) return false; + repeat += 11; + } + if (index + repeat > literalCount + distanceCount) { + reason_ = "inflate_too_many_lengths"; + return false; + } + while (repeat--) lengths[index++] = repeated; + } + if (lengths[256] == 0) { + reason_ = "inflate_no_end_of_block"; + return false; + } + // Incomplete codes are allowed only for a single length code. + const int literalResult = Build(lengthCodes, lengths, literalCount); + if (literalResult < 0 || (literalResult > 0 && literalCount - lengthCodes.count[0] != 1)) { + reason_ = "inflate_bad_literal_lengths"; + return false; + } + const int distanceResult = Build(distanceCodes, lengths + literalCount, distanceCount); + if (distanceResult < 0 || (distanceResult > 0 && distanceCount - distanceCodes.count[0] != 1)) { + reason_ = "inflate_bad_distance_lengths"; + return false; + } + return Codes(lengthCodes, distanceCodes); + } + + const uint8_t* in_; + size_t size_; + size_t pos_ = 0; + int bitBuffer_ = 0; + int bitCount_ = 0; + std::vector& out_; + size_t max_; + std::string reason_; +}; + +} // namespace + +bool Inflate(const uint8_t* input, size_t size, std::vector& output, size_t maxOutput, std::string& error) { + output.clear(); + error.clear(); + return Decoder(input, size, output, maxOutput).Run(error); +} + +uint32_t Crc32(const uint8_t* data, size_t size, uint32_t crc) { + static const auto table = [] { + std::array values{}; + for (uint32_t i = 0; i < 256; ++i) { + uint32_t c = i; + for (int k = 0; k < 8; ++k) c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1; + values[i] = c; + } + return values; + }(); + crc = ~crc; + for (size_t i = 0; i < size; ++i) crc = table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8); + return ~crc; +} + +} // namespace cortex::artifacts diff --git a/core/artifacts/inflate.h b/core/artifacts/inflate.h new file mode 100644 index 00000000..315c1f6f --- /dev/null +++ b/core/artifacts/inflate.h @@ -0,0 +1,23 @@ +#pragma once + +// Raw DEFLATE (RFC 1951) decoding and CRC-32, for ZIP containers (JAR, APK, +// NuGet, wheels). The input is untrusted: every read is bounds-checked, the +// output is capped, and invalid streams fail with a reason instead of +// producing partial data. Written after zlib's reference decoder "puff". + +#include +#include +#include +#include + +namespace cortex::artifacts { + +// Decodes a raw DEFLATE stream (no zlib or gzip header). Fails with +// `error` set if the stream is invalid, truncated, or would expand past +// `maxOutput` bytes ("inflate_output_limit"). +bool Inflate(const uint8_t* input, size_t size, std::vector& output, size_t maxOutput, std::string& error); + +// CRC-32 (IEEE 802.3), as ZIP stores it. Chain calls with the previous value. +uint32_t Crc32(const uint8_t* data, size_t size, uint32_t crc = 0); + +} // namespace cortex::artifacts diff --git a/core/artifacts/js_analysis.cpp b/core/artifacts/js_analysis.cpp new file mode 100644 index 00000000..76af54a5 --- /dev/null +++ b/core/artifacts/js_analysis.cpp @@ -0,0 +1,559 @@ +#include "js_analysis.h" + +#include +#include + +namespace cortex::artifacts::js { +namespace { + +using json = nlohmann::json; + +// Electron exports this analysis follows through aliases. +const std::set& ElectronNames() { + static const std::set names = {"ipcMain", "ipcRenderer", "contextBridge", "BrowserWindow", + "BrowserView", "WebContentsView", "webContents"}; + return names; +} + +struct IpcMethod { + const char* object; + const char* method; + const char* role; +}; + +const IpcMethod kIpcMethods[] = { + {"ipcMain", "handle", "main_handler"}, {"ipcMain", "handleOnce", "main_handler"}, + {"ipcMain", "on", "main_listener"}, {"ipcMain", "once", "main_listener"}, + {"ipcMain", "addListener", "main_listener"}, {"ipcRenderer", "invoke", "renderer_invoke"}, + {"ipcRenderer", "send", "renderer_send"}, {"ipcRenderer", "sendSync", "renderer_send"}, + {"ipcRenderer", "sendToHost", "renderer_send"}, {"ipcRenderer", "postMessage", "renderer_send"}, + {"ipcRenderer", "on", "renderer_listener"}, {"ipcRenderer", "once", "renderer_listener"}, + {"ipcRenderer", "addListener", "renderer_listener"}, + // webContents.on(...) is a page event, not IPC: only send is listed. + {"webContents", "send", "main_to_renderer"}, {"webContents", "postMessage", "main_to_renderer"}, + {"sender", "send", "main_to_renderer"}, +}; + +const char* const kBundlerSignatures[] = { + "__webpack_require__", "__webpack_modules__", "__webpack_exports__", "webpackJsonp", "__commonJS", + "__toESM", "__toCommonJS", "__esm", "__defProp", "parcelRequire", + "__vitePreload", "__vite__mapDeps", "commonjsGlobal", "getDefaultExportFromCjs", + "__awaiter", "__importDefault", "__createBinding", +}; + +class Walker { +public: + Walker(std::string_view source, Analysis& out, size_t maxEntries) + : s_(source), t_(out.lex.tokens), out_(out), max_(maxEntries) {} + + void Run() { + CollectElectronAliases(); + for (size_t i = 0; i < t_.size(); ++i) { + const Token& token = t_[i]; + if (token.kind == TokenKind::String && Text(i).find("webpackChunk") != std::string_view::npos) + ++out_.bundlerSignatures["webpackChunk"]; + if (token.kind != TokenKind::Identifier) continue; + const std::string_view word = Text(i); + for (const char* signature : kBundlerSignatures) + if (word == signature) ++out_.bundlerSignatures[signature]; + if (word.substr(0, 12) == "webpackChunk") ++out_.bundlerSignatures["webpackChunk"]; + if (Member(i)) { + Ipc(i); + continue; + } + if (word == "import") Import(i); + else if (word == "export") Export(i); + else if (word == "require") Require(i); + else if (word == "module" || word == "exports") CommonJsExport(i); + else if (word == "new") New(i); + else if (word == "Object") DefineProperty(i); + } + Bundlers(); + if (esm_ && cjs_) out_.moduleKind = "mixed"; + else if (esm_) out_.moduleKind = "esm"; + else if (cjs_) out_.moduleKind = "cjs"; + else out_.moduleKind = "none"; + } + +private: + std::string_view Text(size_t i) const { return i < t_.size() ? TokenText(s_, t_[i]) : std::string_view(); } + bool Is(size_t i, std::string_view text) const { + return i < t_.size() && t_[i].kind != TokenKind::String && t_[i].kind != TokenKind::Template && Text(i) == text; + } + bool Ident(size_t i) const { return i < t_.size() && t_[i].kind == TokenKind::Identifier; } + // A constant string: '...', "..." or a template without ${}. + bool Literal(size_t i) const { + return i < t_.size() && (t_[i].kind == TokenKind::String || + (t_[i].kind == TokenKind::Template && !t_[i].substitutions)); + } + bool Member(size_t i) const { return i > 0 && (Is(i - 1, ".") || Is(i - 1, "?.")); } + Location At(size_t i) const { return {t_[i].line, t_[i].column}; } + bool Room(size_t count) { + if (count < max_) return true; + out_.truncated = true; + return false; + } + + void AddDependency(size_t literal, const char* kind, bool typeOnly = false) { + if (!Room(out_.dependencies.size())) return; + out_.dependencies.push_back({t_[literal].value, kind, typeOnly, At(literal)}); + } + void AddExport(const std::string& name, const char* kind, size_t at) { + if (!Room(out_.exports.size())) return; + out_.exports.push_back({name, kind, At(at)}); + } + + // Index of the token closing the bracket opened at `open`, or t_.size(). + size_t Close(size_t open) const { + int depth = 0; + for (size_t j = open; j < t_.size(); ++j) { + if (t_[j].kind != TokenKind::Punctuator) continue; + const std::string_view p = Text(j); + if (p == "{" || p == "(" || p == "[") ++depth; + else if (p == "}" || p == ")" || p == "]") { + if (--depth == 0) return j; + } + } + return t_.size(); + } + + std::string Canonical(std::string_view name) const { + const auto alias = aliases_.find(std::string(name)); + return alias == aliases_.end() ? std::string(name) : alias->second; + } + + // `const { ipcMain, BrowserWindow: W } = require('electron')` and + // `import { ipcMain as m } from 'electron'`: m and W are followed. + void CollectElectronAliases() { + for (size_t i = 0; i + 3 < t_.size(); ++i) { + if (!Literal(i) || t_[i].value != "electron") continue; + if (Is(i - 1, "(") && i >= 2 && Is(i - 2, "require") && i >= 3 && Is(i - 3, "=") && i >= 4 && Is(i - 4, "}")) { + size_t open = i - 4; + int depth = 0; + while (open > 0) { + if (Is(open, "}")) ++depth; + else if (Is(open, "{") && --depth == 0) break; + --open; + } + Bindings(open + 1, i - 4, ":"); + } else if (i >= 2 && Is(i - 1, "from")) { + size_t close = i - 2; + if (!Is(close, "}")) continue; + size_t open = close; + while (open > 0 && !Is(open, "{")) --open; + Bindings(open + 1, close, "as"); + } + } + } + void Bindings(size_t begin, size_t end, std::string_view separator) { + for (size_t j = begin; j < end; ++j) { + if (!Ident(j)) continue; + const std::string name(Text(j)); + if (!ElectronNames().count(name)) continue; + if (Is(j + 1, separator) && Ident(j + 2)) aliases_[std::string(Text(j + 2))] = name; + } + } + + void Import(size_t i) { + if (Member(i) || Is(i + 1, ":")) return; + const size_t n = i + 1; + if (Is(n, "(")) { + if (Literal(n + 1) && (Is(n + 2, ")") || Is(n + 2, ","))) AddDependency(n + 1, "dynamic_import"); + else ++out_.computedDynamicImports; + return; + } + if (Is(n, ".")) return; // import.meta + esm_ = true; + if (Literal(n)) { + AddDependency(n, "import"); + return; + } + const bool typeOnly = Is(n, "type") && !Is(n + 1, "from") && !Is(n + 1, ","); + for (size_t j = n; j < t_.size() && j < n + 4096; ++j) { + if (Is(j, "from") && Literal(j + 1)) { + AddDependency(j + 1, "import", typeOnly); + return; + } + if (Is(j, ";") || Is(j, "import") || Is(j, "export")) return; + if (Is(j, "=")) { + // TypeScript: import fs = require('fs') + esm_ = false; + if (Is(j + 1, "require") && Is(j + 2, "(") && Literal(j + 3)) { + AddDependency(j + 3, "import_equals", typeOnly); + importEquals_.insert(j + 1); + cjs_ = true; + } + return; + } + } + } + + void ExportList(size_t open, bool typeOnly) { + const size_t close = Close(open); + std::vector> names; + for (size_t j = open + 1; j < close; ++j) { + if (!Ident(j)) continue; + bool memberType = typeOnly; + if (Is(j, "type") && Ident(j + 1) && !Is(j + 1, "as")) { + memberType = true; + ++j; + } + std::string name(Text(j)); + size_t at = j; + if (Is(j + 1, "as") && j + 2 < close) { + name = Literal(j + 2) ? t_[j + 2].value : std::string(Text(j + 2)); + at = j + 2; + j += 2; + } + names.emplace_back(memberType ? "type:" + name : name, at); + while (j + 1 < close && !Is(j + 1, ",")) ++j; + } + const bool reexport = Is(close + 1, "from") && Literal(close + 2); + if (reexport) AddDependency(close + 2, "export_from", typeOnly); + for (const auto& [name, at] : names) { + const bool isType = name.rfind("type:", 0) == 0; + AddExport(isType ? name.substr(5) : name, isType ? "ts_type" : reexport ? "esm_reexport" : "esm", at); + } + } + + void Export(size_t i) { + if (Member(i) || Is(i + 1, ":")) return; + size_t n = i + 1; + esm_ = true; + if (Is(n, "*")) { + std::string name = "*"; + size_t j = n + 1; + if (Is(j, "as") && j + 1 < t_.size()) { + name = Literal(j + 1) ? t_[j + 1].value : std::string(Text(j + 1)); + j += 2; + } + if (Is(j, "from") && Literal(j + 1)) AddDependency(j + 1, "export_from"); + AddExport(name, "esm_reexport", n); + return; + } + if (Is(n, "{")) { + ExportList(n, false); + return; + } + if (Is(n, "type") && Is(n + 1, "{")) { + ExportList(n + 1, true); + return; + } + if (Is(n, "=")) { + esm_ = false; + cjs_ = true; + AddExport("export=", "cjs", n); + return; + } + if (Is(n, "default")) { + AddExport("default", "esm", n); + return; + } + if (Is(n, "import") || Is(n, "as")) return; + bool typeDeclaration = false; + while (Is(n, "declare") || Is(n, "abstract") || Is(n, "async") || (Is(n, "const") && Is(n + 1, "enum"))) ++n; + if (Is(n, "type") || Is(n, "interface")) typeDeclaration = true; + if (Is(n, "function") || Is(n, "class") || Is(n, "const") || Is(n, "let") || Is(n, "var") || Is(n, "enum") || + Is(n, "namespace") || Is(n, "module") || typeDeclaration) { + size_t j = n + 1; + if (Is(j, "*")) ++j; // generator + if (Ident(j)) AddExport(std::string(Text(j)), typeDeclaration ? "ts_type" : "esm", j); + } + } + + void Require(size_t i) { + if (Member(i) || (i > 0 && Is(i - 1, "function")) || !Is(i + 1, "(")) return; + if (importEquals_.count(i)) return; + if (Literal(i + 2) && Is(i + 3, ")")) { + AddDependency(i + 2, "require"); + cjs_ = true; + } else if (!Is(i + 2, ")")) { + ++out_.computedRequires; + } + } + + void CommonJsExport(size_t i) { + if (Member(i)) return; + size_t j = i; + if (Is(i, "module")) { + if (!Is(i + 1, ".") || !Is(i + 2, "exports")) return; + j = i + 2; + if (Is(j + 1, "=")) { + cjs_ = true; + AddExport("module.exports", "cjs", j); + return; + } + } + if (Is(j + 1, ".") && Ident(j + 2) && Is(j + 3, "=")) { + cjs_ = true; + AddExport(std::string(Text(j + 2)), "cjs", j + 2); + } else if (Is(j + 1, "[") && Literal(j + 2) && Is(j + 3, "]") && Is(j + 4, "=")) { + cjs_ = true; + AddExport(t_[j + 2].value, "cjs", j + 2); + } + } + + // Object.defineProperty(exports, "name", ...) + void DefineProperty(size_t i) { + if (Member(i) || !Is(i + 1, ".") || !Is(i + 2, "defineProperty") || !Is(i + 3, "(") || !Is(i + 4, "exports") || + !Is(i + 5, ",") || !Literal(i + 6)) + return; + cjs_ = true; + if (t_[i + 6].value != "__esModule") AddExport(t_[i + 6].value, "cjs", i + 6); + } + + void Ipc(size_t methodIndex) { + // object . method ( "channel" + if (methodIndex < 2 || !Ident(methodIndex - 2) || !Is(methodIndex + 1, "(")) return; + const std::string object = Canonical(Text(methodIndex - 2)); + const std::string_view method = Text(methodIndex); + if (object == "contextBridge" && (method == "exposeInMainWorld" || method == "exposeInIsolatedWorld")) { + size_t key = methodIndex + 2; + if (method == "exposeInIsolatedWorld") { + // exposeInIsolatedWorld(worldId, key, api): skip one argument + while (key < t_.size() && !Is(key, ",") && !Is(key, ")")) ++key; + ++key; + } + if (Literal(key)) AddIpc(object, method, t_[key].value, "bridge_exposure", key); + else ++out_.computedIpcChannels; + return; + } + for (const auto& known : kIpcMethods) { + if (object != known.object || method != known.method) continue; + if (Literal(methodIndex + 2)) AddIpc(object, method, t_[methodIndex + 2].value, known.role, methodIndex + 2); + else ++out_.computedIpcChannels; + return; + } + } + void AddIpc(const std::string& object, std::string_view method, const std::string& channel, const char* role, + size_t at) { + if (!Room(out_.ipc.size())) return; + out_.ipc.push_back({object, std::string(method), channel, role, At(at)}); + } + + // Value tokens of each key of the object literal opened at `open`. + std::vector>> ObjectKeys(size_t open) const { + std::vector>> keys; + const size_t close = Close(open); + size_t j = open + 1; + while (j < close) { + if (Is(j, "...")) { // spread: skip to the next property + while (j < close && !Is(j, ",")) j = Is(j, "{") || Is(j, "(") || Is(j, "[") ? Close(j) + 1 : j + 1; + ++j; + continue; + } + std::string key = Literal(j) ? t_[j].value : std::string(Text(j)); + size_t valueBegin = j, valueEnd = j + 1; // shorthand `key,` + if (Is(j + 1, ":")) { + valueBegin = j + 2; + valueEnd = valueBegin; + while (valueEnd < close && !Is(valueEnd, ",")) + valueEnd = Is(valueEnd, "{") || Is(valueEnd, "(") || Is(valueEnd, "[") ? Close(valueEnd) + 1 : valueEnd + 1; + } else if (Is(j + 1, "(")) { // method shorthand + valueEnd = j + 1; + while (valueEnd < close && !Is(valueEnd, ",")) + valueEnd = Is(valueEnd, "{") || Is(valueEnd, "(") || Is(valueEnd, "[") ? Close(valueEnd) + 1 : valueEnd + 1; + } + keys.push_back({key, {valueBegin, std::min(valueEnd, close)}}); + j = std::min(valueEnd, close) + 1; + } + return keys; + } + + std::string Source(size_t begin, size_t end, size_t limit = 200) const { + if (begin >= end || end > t_.size()) return {}; + std::string text(s_.substr(t_[begin].begin, t_[end - 1].end - t_[begin].begin)); + if (text.size() > limit) text = text.substr(0, limit) + "..."; + return text; + } + + json Value(size_t begin, size_t end) const { + if (end == begin + 1) { + if (Is(begin, "true")) return true; + if (Is(begin, "false")) return false; + if (Literal(begin)) return t_[begin].value; + if (t_[begin].kind == TokenKind::Number) return json{{"number", std::string(Text(begin))}}; + } + // Minifiers write true as !0 and false as !1. + if (end == begin + 2 && Is(begin, "!") && Is(begin + 1, "0")) return true; + if (end == begin + 2 && Is(begin, "!") && Is(begin + 1, "1")) return false; + return json{{"expression", Source(begin, end)}}; + } + + void New(size_t i) { + size_t j = i + 1; + while (Ident(j) && Is(j + 1, ".") && Ident(j + 2)) j += 2; + if (!Ident(j)) return; + const std::string name = Canonical(Text(j)); + if (name != "BrowserWindow" && name != "BrowserView" && name != "WebContentsView") return; + if (!Is(j + 1, "(") || !Room(out_.windows.size())) return; + WindowConfig window; + window.constructorName = name; + window.at = At(j); + if (Is(j + 2, "{")) { + for (const auto& [key, range] : ObjectKeys(j + 2)) { + if (key != "webPreferences") continue; + window.hasWebPreferences = true; + if (!Is(range.first, "{")) { + window.webPreferences = json{{"expression", Source(range.first, range.second)}}; + break; + } + for (const auto& [preference, value] : ObjectKeys(range.first)) { + window.webPreferences[preference] = Value(value.first, value.second); + if (preference != "preload") continue; + window.preloadExpression = Source(value.first, value.second, 300); + for (size_t k = value.first; k < value.second; ++k) { + if (Literal(k)) window.preloadLiterals.push_back(t_[k].value); + if (Is(k, "__dirname")) window.preloadFromDirname = true; + } + } + } + } + out_.windows.push_back(std::move(window)); + } + + void Bundlers() { + const auto has = [&](const char* name) { return out_.bundlerSignatures.count(name) > 0; }; + if (has("__webpack_require__") || has("webpackChunk") || has("webpackJsonp") || has("__webpack_modules__")) + out_.bundlers.push_back("webpack"); + if ((has("__commonJS") || has("__toESM") || has("__toCommonJS") || has("__esm")) && has("__defProp")) + out_.bundlers.push_back("esbuild"); + if (has("parcelRequire")) out_.bundlers.push_back("parcel"); + if (has("commonjsGlobal") || has("getDefaultExportFromCjs")) out_.bundlers.push_back("rollup"); + if (has("__vitePreload") || has("__vite__mapDeps")) out_.bundlers.push_back("vite"); + if (has("__awaiter") || has("__importDefault") || has("__createBinding")) + out_.bundlers.push_back("typescript_helpers"); + } + + std::string_view s_; + const std::vector& t_; + Analysis& out_; + size_t max_; + std::map aliases_; + std::set importEquals_; + bool esm_ = false; + bool cjs_ = false; +}; + +std::string Extension(const std::string& name) { + const size_t slash = name.find_last_of("/\\!"); + const size_t dot = name.find_last_of('.'); + if (dot == std::string::npos || (slash != std::string::npos && dot < slash)) return {}; + std::string extension = name.substr(dot); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return extension; +} + +json LocationJson(const Location& at) { return {{"line", at.line}, {"column", at.column}}; } + +} // namespace + +bool IsScriptName(const std::string& name) { + static const std::set extensions = {".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts"}; + return extensions.count(Extension(name)) > 0; +} + +bool IsNodeBuiltin(const std::string& specifier) { + static const std::set builtins = { + "assert", "assert/strict", "async_hooks", "buffer", "child_process", "cluster", "console", "constants", + "crypto", "dgram", "diagnostics_channel", "dns", "dns/promises", "domain", "events", "fs", "fs/promises", + "http", "http2", "https", "inspector", "module", "net", "os", "path", "path/posix", "path/win32", + "perf_hooks", "process", "punycode", "querystring", "readline", "readline/promises", "repl", "stream", + "stream/promises", "stream/web", "string_decoder", "sys", "timers", "timers/promises", "tls", + "trace_events", "tty", "url", "util", "util/types", "v8", "vm", "wasi", "worker_threads", "zlib"}; + if (specifier.rfind("node:", 0) == 0) return true; + return builtins.count(specifier) > 0; +} + +Analysis Analyze(std::string_view source, const std::string& name, size_t maxEntries) { + Analysis analysis; + analysis.bytes = source.size(); + const std::string extension = Extension(name); + analysis.language = extension == ".ts" || extension == ".tsx" || extension == ".mts" || extension == ".cts" + ? "typescript" : "javascript"; + analysis.lex = Lex(source); + analysis.sourceMappingUrl = analysis.lex.sourceMappingUrl; + Walker(source, analysis, maxEntries).Run(); + // Minified: long lines and almost no whitespace. A heuristic, labelled + // as such in the output. + if (analysis.bytes >= 200 && analysis.lex.lines) { + const double average = static_cast(analysis.bytes) / analysis.lex.lines; + const double whitespace = static_cast(analysis.lex.whitespaceBytes) / static_cast(analysis.bytes); + // One line holding most of the file counts too (a bundle plus its + // sourceMappingURL comment is two lines). + const bool oneLine = analysis.lex.longestLine >= 200 && analysis.lex.longestLine * 10 >= analysis.bytes * 8; + analysis.minified = whitespace < 0.12 && (average > 250 || oneLine || analysis.lex.longestLine > 10000); + } + return analysis; +} + +json ToJson(const Analysis& analysis, size_t maxEntries) { + json dependencies = json::array(); + for (const auto& dependency : analysis.dependencies) { + json row = {{"specifier", dependency.specifier}, {"kind", dependency.kind}, {"at", LocationJson(dependency.at)}}; + if (dependency.typeOnly) row["type_only"] = true; + if (!IsNodeBuiltin(dependency.specifier) && dependency.specifier.rfind("./", 0) != 0 && + dependency.specifier.rfind("../", 0) != 0 && dependency.specifier.rfind('/', 0) != 0) + row["external"] = true; + if (IsNodeBuiltin(dependency.specifier)) row["builtin"] = true; + dependencies.push_back(std::move(row)); + } + json exports = json::array(); + for (const auto& name : analysis.exports) + exports.push_back({{"name", name.name}, {"kind", name.kind}, {"at", LocationJson(name.at)}}); + json ipc = json::array(); + for (const auto& use : analysis.ipc) + ipc.push_back({{"object", use.object}, {"method", use.method}, {"channel", use.channel}, {"role", use.role}, + {"at", LocationJson(use.at)}}); + json windows = json::array(); + for (const auto& window : analysis.windows) { + json row = {{"constructor", window.constructorName}, + {"at", LocationJson(window.at)}, + {"web_preferences", window.hasWebPreferences ? window.webPreferences : json(nullptr)}}; + if (!window.preloadExpression.empty()) + row["preload"] = {{"expression", window.preloadExpression}, + {"literals", window.preloadLiterals}, + {"from_dirname", window.preloadFromDirname}}; + windows.push_back(std::move(row)); + } + json signatures = json::object(); + for (const auto& [name, count] : analysis.bundlerSignatures) signatures[name] = count; + const auto& lex = analysis.lex; + json data = { + {"format", "javascript"}, + {"language", analysis.language}, + {"module_kind", analysis.moduleKind}, + {"dependencies", dependencies}, + {"computed_dynamic_imports", analysis.computedDynamicImports}, + {"computed_requires", analysis.computedRequires}, + {"exports", exports}, + {"electron", {{"ipc", ipc}, + {"computed_channels", analysis.computedIpcChannels}, + {"windows", windows}, + {"note", "static names found in the source, not observed traffic"}}}, + {"bundle", {{"bundlers", analysis.bundlers}, + {"signatures", signatures}, + {"minified", analysis.minified}, + {"note", "heuristics: identifier signatures, line length and whitespace"}}}, + {"stats", {{"bytes", analysis.bytes}, + {"lines", lex.lines}, + {"longest_line", lex.longestLine}, + {"tokens", lex.tokens.size()}, + {"comment_bytes", lex.commentBytes}, + {"whitespace_ratio", analysis.bytes ? static_cast(lex.whitespaceBytes) / analysis.bytes : 0.0}}}, + {"truncated", analysis.truncated || lex.truncated}, + {"max_entries", maxEntries}, + }; + if (!analysis.sourceMappingUrl.empty()) { + const bool inline_ = analysis.sourceMappingUrl.rfind("data:", 0) == 0; + std::string shown = analysis.sourceMappingUrl; + if (shown.size() > 200) shown = shown.substr(0, 200) + "..."; + data["source_map"] = {{"url", shown}, {"inline", inline_}, {"line", lex.sourceMappingLine}, + {"url_bytes", analysis.sourceMappingUrl.size()}}; + } + if (!lex.warnings.empty()) data["warnings"] = lex.warnings; + return data; +} + +} // namespace cortex::artifacts::js diff --git a/core/artifacts/js_analysis.h b/core/artifacts/js_analysis.h new file mode 100644 index 00000000..03e4f7b0 --- /dev/null +++ b/core/artifacts/js_analysis.h @@ -0,0 +1,92 @@ +#pragma once + +// Static facts about one JavaScript/TypeScript file, read from its tokens +// (js_lexer.h). Everything here is what the text says, not what runs: a +// `require` behind a condition is still listed, a computed specifier is +// only counted, and an IPC channel found in the source is a static name, +// never observed traffic. + +#include "js_lexer.h" + +#include + +#include +#include +#include + +namespace cortex::artifacts::js { + +struct Location { + uint32_t line = 0; + uint32_t column = 0; +}; + +struct Dependency { + std::string specifier; + // import | export_from | dynamic_import | require | import_equals + std::string kind; + bool typeOnly = false; // TypeScript `import type` / `export type` + Location at; +}; + +struct ExportName { + std::string name; // "default", "*", or the exported name + std::string kind; // esm | esm_reexport | cjs | ts_type + Location at; +}; + +// One use of an Electron IPC or bridge API with a constant channel. +struct IpcUse { + std::string object; // ipcMain | ipcRenderer | webContents | contextBridge + std::string method; // handle, invoke, send, exposeInMainWorld, ... + std::string channel; // the channel (or the exposed key) + std::string role; // main_handler | main_listener | renderer_invoke | renderer_send | + // renderer_listener | main_to_renderer | bridge_exposure + Location at; +}; + +// `new BrowserWindow({...})` (or BrowserView / WebContentsView). +struct WindowConfig { + std::string constructorName; + Location at; + bool hasWebPreferences = false; + nlohmann::json webPreferences = nlohmann::json::object(); // key -> literal or {"expression": text} + // The preload expression: its string literals in order, and whether it + // starts from __dirname (path.join(__dirname, 'preload.js')). + std::string preloadExpression; + std::vector preloadLiterals; + bool preloadFromDirname = false; +}; + +struct Analysis { + std::string language; // javascript | typescript + std::string moduleKind; // esm | cjs | mixed | none + std::vector dependencies; + size_t computedDynamicImports = 0; // import(expr) without a constant + size_t computedRequires = 0; + std::vector exports; + std::vector ipc; + size_t computedIpcChannels = 0; + std::vector windows; + std::map bundlerSignatures; // identifier -> occurrences + std::vector bundlers; // webpack, esbuild, ... (heuristic) + bool minified = false; + std::string sourceMappingUrl; + LexResult lex; // tokens and statistics + size_t bytes = 0; + bool truncated = false; // entries dropped at the limit +}; + +// `name` decides the language (.ts/.tsx/.mts/.cts are TypeScript). +Analysis Analyze(std::string_view source, const std::string& name, size_t maxEntries = 4096); + +nlohmann::json ToJson(const Analysis& analysis, size_t maxEntries); + +// True for names this analysis reads: .js .mjs .cjs .jsx .ts .tsx .mts .cts +// (a .d.ts declaration file too). +bool IsScriptName(const std::string& name); + +// Node.js built-in module names (without "node:"). +bool IsNodeBuiltin(const std::string& specifier); + +} // namespace cortex::artifacts::js diff --git a/core/artifacts/js_lexer.cpp b/core/artifacts/js_lexer.cpp new file mode 100644 index 00000000..9c9c51b6 --- /dev/null +++ b/core/artifacts/js_lexer.cpp @@ -0,0 +1,465 @@ +#include "js_lexer.h" + +#include +#include + +namespace cortex::artifacts::js { +namespace { + +constexpr size_t kMaxWarnings = 32; + +bool IdentStart(unsigned char c) { return std::isalpha(c) || c == '_' || c == '$' || c >= 0x80; } +bool IdentPart(unsigned char c) { return IdentStart(c) || std::isdigit(c); } + +// Keywords after which a `/` starts a regular expression, not a division. +bool RegexAfterKeyword(std::string_view word) { + static const char* const words[] = {"return", "typeof", "instanceof", "in", "of", "new", + "delete", "void", "throw", "case", "do", "else", + "yield", "await", "extends", "default"}; + return std::any_of(std::begin(words), std::end(words), [&](const char* w) { return word == w; }); +} + +// Longest first, so ">>>=" wins over ">>". +const char* const kPunctuators[] = { + ">>>=", "...", "===", "!==", "**=", "<<=", ">>=", ">>>", "&&=", "||=", "?\?=", "=>", "==", "!=", + "<=", ">=", "&&", "||", "??", "?.", "++", "--", "+=", "-=", "*=", "%=", "&=", "|=", + "^=", "**", "<<", ">>", "{", "}", "(", ")", "[", "]", ";", ",", "<", ">", + "+", "-", "*", "%", "&", "|", "^", "!", "~", "?", ":", "=", ".", "@", +}; + +void AppendUtf8(std::string& out, uint32_t code) { + if (code > 0x10FFFF) code = 0xFFFD; + if (code < 0x80) { + out.push_back(static_cast(code)); + } else if (code < 0x800) { + out.push_back(static_cast(0xC0 | (code >> 6))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } else if (code < 0x10000) { + out.push_back(static_cast(0xE0 | (code >> 12))); + out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } else { + out.push_back(static_cast(0xF0 | (code >> 18))); + out.push_back(static_cast(0x80 | ((code >> 12) & 0x3F))); + out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } +} + +int HexDigit(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +// The text between the quotes (or backticks) with escapes resolved. +std::string Cook(std::string_view raw) { + std::string out; + out.reserve(raw.size()); + for (size_t i = 0; i < raw.size(); ++i) { + const char c = raw[i]; + if (c != '\\' || i + 1 >= raw.size()) { + out.push_back(c); + continue; + } + const char e = raw[++i]; + switch (e) { + case 'n': out.push_back('\n'); break; + case 't': out.push_back('\t'); break; + case 'r': out.push_back('\r'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'v': out.push_back('\v'); break; + case '0': out.push_back('\0'); break; + case '\r': + if (i + 1 < raw.size() && raw[i + 1] == '\n') ++i; + break; // line continuation + case '\n': break; + case 'x': + if (i + 2 < raw.size() && HexDigit(raw[i + 1]) >= 0 && HexDigit(raw[i + 2]) >= 0) { + AppendUtf8(out, static_cast(HexDigit(raw[i + 1]) * 16 + HexDigit(raw[i + 2]))); + i += 2; + } else { + out.push_back(e); + } + break; + case 'u': { + uint32_t code = 0; + if (i + 1 < raw.size() && raw[i + 1] == '{') { + size_t j = i + 2; + int digits = 0; + while (j < raw.size() && raw[j] != '}' && HexDigit(raw[j]) >= 0 && digits < 8) { + code = code * 16 + static_cast(HexDigit(raw[j])); + ++j; + ++digits; + } + if (j < raw.size() && raw[j] == '}' && digits) { + AppendUtf8(out, code); + i = j; + break; + } + } else if (i + 4 < raw.size()) { + bool ok = true; + for (size_t k = 1; k <= 4; ++k) { + const int d = HexDigit(raw[i + k]); + if (d < 0) ok = false; + else code = code * 16 + static_cast(d); + } + if (ok) { + AppendUtf8(out, code); + i += 4; + break; + } + } + out.push_back(e); + break; + } + default: out.push_back(e); + } + } + return out; +} + +// Line/column of increasing byte offsets, in one pass over the source. +class Positions { +public: + explicit Positions(std::string_view source) : source_(source) {} + void At(size_t target, uint32_t& line, uint32_t& column) { + while (pos_ < target && pos_ < source_.size()) { + const auto c = static_cast(source_[pos_]); + if (c == '\n') { + ++line_; + column_ = 1; + } else if (c == '\r') { + if (pos_ + 1 >= source_.size() || source_[pos_ + 1] != '\n') { + ++line_; + column_ = 1; + } + } else if ((c & 0xC0) == 0x80) { + // UTF-8 continuation byte: same UTF-16 unit + } else if ((c & 0xF8) == 0xF0) { + column_ += 2; // outside the BMP: a surrogate pair + } else { + ++column_; + } + ++pos_; + } + line = line_; + column = column_; + } + +private: + std::string_view source_; + size_t pos_ = 0; + uint32_t line_ = 1; + uint32_t column_ = 1; +}; + +class Lexer { +public: + Lexer(std::string_view source, size_t maxTokens) : s_(source), positions_(source), maxTokens_(maxTokens) {} + + LexResult Run() { + Stats(); + size_t i = 0; + if (s_.size() >= 3 && static_cast(s_[0]) == 0xEF && static_cast(s_[1]) == 0xBB && + static_cast(s_[2]) == 0xBF) + i = 3; + if (s_.compare(i, 2, "#!") == 0) i = LineEnd(i); + while (i < s_.size()) { + if (result_.tokens.size() >= maxTokens_) { + result_.truncated = true; + break; + } + const auto c = static_cast(s_[i]); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f') { + ++i; + continue; + } + if (c == 0xC2 && i + 1 < s_.size() && static_cast(s_[i + 1]) == 0xA0) { + i += 2; // no-break space + continue; + } + if (c == 0xE2 && i + 2 < s_.size() && static_cast(s_[i + 1]) == 0x80 && + (static_cast(s_[i + 2]) == 0xA8 || static_cast(s_[i + 2]) == 0xA9)) { + i += 3; // line/paragraph separator + continue; + } + if (c == '/' && i + 1 < s_.size() && s_[i + 1] == '/') { + const size_t end = LineEnd(i); + Comment(i, end, i + 2); + i = end; + continue; + } + if (c == '/' && i + 1 < s_.size() && s_[i + 1] == '*') { + const size_t close = s_.find("*/", i + 2); + const size_t end = close == std::string_view::npos ? s_.size() : close + 2; + if (close == std::string_view::npos) Warn("unterminated_comment", i); + Comment(i, end, i + 2); + i = end; + continue; + } + if (c == '\'' || c == '"') { + i = String(i); + continue; + } + if (c == '`') { + i = Template(i, i + 1); + continue; + } + if (c == '}' && !braces_.empty() && braces_.back() == 'T') { + braces_.pop_back(); + i = Template(i, i + 1); + continue; + } + if (std::isdigit(c) || (c == '.' && i + 1 < s_.size() && std::isdigit(static_cast(s_[i + 1])))) { + i = Number(i); + continue; + } + if (IdentStart(c) || c == '#' || (c == '\\' && i + 1 < s_.size() && s_[i + 1] == 'u')) { + i = Identifier(i); + continue; + } + if (c == '/') { + const size_t end = RegexAllowed() ? Regex(i) : 0; + if (end) { + Emit(TokenKind::Regex, i, end); + i = end; + continue; + } + const size_t length = i + 1 < s_.size() && s_[i + 1] == '=' ? 2 : 1; + Emit(TokenKind::Punctuator, i, i + length); + i += length; + continue; + } + i = Punctuator(i); + } + if (!braces_.empty() && braces_.back() == 'T') Warn("unterminated_template", s_.size()); + if (suppressedWarnings_) + result_.warnings.push_back("... " + std::to_string(suppressedWarnings_) + " more warning(s)"); + return std::move(result_); + } + +private: + void Stats() { + uint32_t lines = s_.empty() ? 0 : 1, current = 0, longest = 0; + uint64_t whitespace = 0; + for (size_t i = 0; i < s_.size(); ++i) { + const char c = s_[i]; + if (c == '\n' || (c == '\r' && (i + 1 >= s_.size() || s_[i + 1] != '\n'))) { + longest = std::max(longest, current); + current = 0; + if (i + 1 < s_.size()) ++lines; + ++whitespace; + continue; + } + if (c != '\r') ++current; + if (c == ' ' || c == '\t' || c == '\r') ++whitespace; + } + result_.lines = lines; + result_.longestLine = std::max(longest, current); + result_.whitespaceBytes = whitespace; + } + + size_t LineEnd(size_t i) const { + while (i < s_.size() && s_[i] != '\n' && s_[i] != '\r') ++i; + return i; + } + + void Warn(const char* what, size_t at) { + if (result_.warnings.size() >= kMaxWarnings) { + ++suppressedWarnings_; + return; + } + uint32_t line = 0, column = 0; + Positions probe(s_); + probe.At(at, line, column); + result_.warnings.push_back(std::string(what) + " at " + std::to_string(line) + ":" + std::to_string(column)); + } + + void Comment(size_t begin, size_t end, size_t textStart) { + result_.commentBytes += end - begin; + std::string_view text = s_.substr(textStart, end - textStart); + if (text.size() >= 2 && text.substr(text.size() - 2) == "*/") text.remove_suffix(2); + while (!text.empty() && (text.front() == ' ' || text.front() == '\t')) text.remove_prefix(1); + if (text.size() < 20 || (text[0] != '#' && text[0] != '@')) return; + text.remove_prefix(1); + while (!text.empty() && (text.front() == ' ' || text.front() == '\t')) text.remove_prefix(1); + constexpr std::string_view key = "sourceMappingURL="; + if (text.substr(0, key.size()) != key) return; + text.remove_prefix(key.size()); + size_t length = 0; + while (length < text.size() && !std::isspace(static_cast(text[length]))) ++length; + result_.sourceMappingUrl = std::string(text.substr(0, length)); + uint32_t column = 0; + positions_.At(begin, result_.sourceMappingLine, column); + } + + void Emit(TokenKind kind, size_t begin, size_t end, std::string value = {}, bool substitutions = false) { + Token token; + token.kind = kind; + token.begin = static_cast(begin); + token.end = static_cast(end); + positions_.At(begin, token.line, token.column); + token.value = std::move(value); + token.substitutions = substitutions; + result_.tokens.push_back(std::move(token)); + } + + size_t String(size_t i) { + const char quote = s_[i]; + size_t j = i + 1; + bool closed = false; + while (j < s_.size()) { + const char c = s_[j]; + if (c == '\\') { + j += 2; + continue; + } + if (c == quote) { + closed = true; + break; + } + if (c == '\n' || c == '\r') break; + ++j; + } + j = std::min(j, s_.size()); + if (!closed) Warn("unterminated_string", i); + Emit(TokenKind::String, i, closed ? j + 1 : j, Cook(s_.substr(i + 1, j - i - 1))); + return closed ? j + 1 : j; + } + + // From `begin` (a backtick, or the `}` closing a substitution) to the + // closing backtick or the next `${`. + size_t Template(size_t begin, size_t j) { + const bool head = s_[begin] == '`'; + while (j < s_.size()) { + const char c = s_[j]; + if (c == '\\') { + j += 2; + continue; + } + if (c == '`') { + // A whole template without substitutions is a constant. + if (head) Emit(TokenKind::Template, begin, j + 1, Cook(s_.substr(begin + 1, j - begin - 1)), false); + else Emit(TokenKind::Template, begin, j + 1, {}, true); + return j + 1; + } + if (c == '$' && j + 1 < s_.size() && s_[j + 1] == '{') { + Emit(TokenKind::Template, begin, j + 2, {}, true); + braces_.push_back('T'); + return j + 2; + } + ++j; + } + Warn("unterminated_template", begin); + Emit(TokenKind::Template, begin, s_.size(), {}, true); + return s_.size(); + } + + size_t Number(size_t i) { + const bool hex = s_[i] == '0' && i + 1 < s_.size() && (s_[i + 1] == 'x' || s_[i + 1] == 'X'); + size_t j = i; + while (j < s_.size()) { + const auto c = static_cast(s_[j]); + if (std::isalnum(c) || c == '_' || c == '.') { + ++j; + } else if ((c == '+' || c == '-') && !hex && (s_[j - 1] == 'e' || s_[j - 1] == 'E')) { + ++j; + } else { + break; + } + } + Emit(TokenKind::Number, i, j); + return j; + } + + size_t Identifier(size_t i) { + size_t j = i + 1; + while (j < s_.size()) { + const auto c = static_cast(s_[j]); + if (IdentPart(c)) { + ++j; + } else if (c == '\\' && j + 1 < s_.size() && s_[j + 1] == 'u') { + j += 2; + } else { + break; + } + } + Emit(TokenKind::Identifier, i, j); + return j; + } + + bool RegexAllowed() const { + if (result_.tokens.empty()) return true; + const Token& last = result_.tokens.back(); + const std::string_view text = TokenText(s_, last); + switch (last.kind) { + case TokenKind::Identifier: return RegexAfterKeyword(text); + case TokenKind::Number: + case TokenKind::String: + case TokenKind::Regex: return false; + case TokenKind::Template: return text.size() >= 2 && text.substr(text.size() - 2) == "${"; + case TokenKind::Punctuator: + return !(text == ")" || text == "]" || text == "}" || text == "++" || text == "--"); + } + return true; + } + + // End of a regular expression literal starting at `i`, or 0 if the + // line ends first (then the `/` is a division after all). + size_t Regex(size_t i) const { + size_t j = i + 1; + bool inClass = false; + while (j < s_.size()) { + const char c = s_[j]; + if (c == '\n' || c == '\r') return 0; + if (c == '\\') { + j += 2; + continue; + } + if (c == '[') inClass = true; + else if (c == ']') inClass = false; + else if (c == '/' && !inClass) break; + ++j; + } + if (j >= s_.size()) return 0; + ++j; + while (j < s_.size() && IdentPart(static_cast(s_[j]))) ++j; + return j; + } + + size_t Punctuator(size_t i) { + for (const char* p : kPunctuators) { + const size_t length = std::strlen(p); + if (s_.compare(i, length, p) != 0) continue; + // `a?.5:b` is a conditional, not optional chaining. + if (length == 2 && p[0] == '?' && p[1] == '.' && i + 2 < s_.size() && + std::isdigit(static_cast(s_[i + 2]))) + continue; + if (length == 1 && p[0] == '{') braces_.push_back('B'); + if (length == 1 && p[0] == '}' && !braces_.empty()) braces_.pop_back(); + Emit(TokenKind::Punctuator, i, i + length); + return i + length; + } + // Anything else (a stray backslash, a control byte) is skipped. + ++i; + return i; + } + + std::string_view s_; + Positions positions_; + size_t maxTokens_; + LexResult result_; + std::vector braces_; + size_t suppressedWarnings_ = 0; +}; + +} // namespace + +LexResult Lex(std::string_view source, size_t maxTokens) { + return Lexer(source, maxTokens).Run(); +} + +} // namespace cortex::artifacts::js diff --git a/core/artifacts/js_lexer.h b/core/artifacts/js_lexer.h new file mode 100644 index 00000000..30d56dda --- /dev/null +++ b/core/artifacts/js_lexer.h @@ -0,0 +1,63 @@ +#pragma once + +// A JavaScript/TypeScript tokenizer for static analysis (roadmap step 4b). +// It is not a parser: it only has to know where strings, template literals, +// regular expressions and comments start and end, so that text inside them +// is never mistaken for code (an `import` inside a string is not a +// dependency). The source is untrusted: every scan is bounded by the input, +// and errors (an unterminated string, comment or template) are reported as +// warnings while lexing continues. +// +// Positions are 1-based lines and 1-based columns counted in UTF-16 code +// units, the unit source maps and browsers use. + +#include +#include +#include +#include + +namespace cortex::artifacts::js { + +enum class TokenKind { + Identifier, // names and keywords; `#name` private names too + Punctuator, + String, // '...' or "..."; `value` holds the cooked text + Template, // a whole template without substitutions, or one piece of one + Regex, + Number, +}; + +struct Token { + TokenKind kind = TokenKind::Punctuator; + uint32_t begin = 0; // byte offsets in the source + uint32_t end = 0; + uint32_t line = 0; + uint32_t column = 0; + // Cooked text of a String, or of a Template without substitutions. + std::string value; + // Template pieces: true when this template has ${...} substitutions, + // so its text is not a constant. + bool substitutions = false; +}; + +struct LexResult { + std::vector tokens; + std::vector warnings; + // The last `//# sourceMappingURL=` (or `//@`) comment, as written. + std::string sourceMappingUrl; + uint32_t sourceMappingLine = 0; + uint32_t lines = 0; + uint32_t longestLine = 0; // bytes + uint64_t whitespaceBytes = 0; + uint64_t commentBytes = 0; + bool truncated = false; // maxTokens reached +}; + +LexResult Lex(std::string_view source, size_t maxTokens = 4u << 20); + +// Text of a token in the source. +inline std::string_view TokenText(std::string_view source, const Token& token) { + return source.substr(token.begin, token.end - token.begin); +} + +} // namespace cortex::artifacts::js diff --git a/core/artifacts/js_provider.cpp b/core/artifacts/js_provider.cpp new file mode 100644 index 00000000..e1c03f4c --- /dev/null +++ b/core/artifacts/js_provider.cpp @@ -0,0 +1,568 @@ +#include "js_provider.h" + +#include "js_analysis.h" +#include "sha256.h" +#include "source_map.h" + +#include +#include +#include +#include + +namespace cortex::artifacts { +namespace { + +constexpr uint64_t kMaxScriptBytes = 32ull << 20; // one script +constexpr uint64_t kMaxMapBytes = 64ull << 20; // one source map +constexpr uint64_t kTreeScriptBytes = 16ull << 20; // one script inside a tree +constexpr uint64_t kTreeTotalBytes = 96ull << 20; // all scripts of a tree +constexpr size_t kTreeMaxScripts = 2000; + +std::string ReadText(const ByteSource& source, uint64_t limit, bool& truncated) { + const uint64_t size = std::min(source.Size(), limit); + truncated = source.Size() > limit; + std::string text(static_cast(size), '\0'); + if (size && !source.Read(0, text.data(), text.size())) return {}; + return text; +} + +bool LooksBinary(const std::string& text) { + const size_t probe = std::min(text.size(), 4096); + return std::find(text.begin(), text.begin() + static_cast(probe), '\0') != + text.begin() + static_cast(probe); +} + +std::string BaseName(const std::string& name) { + const size_t cut = name.find_last_of("/\\!"); + return cut == std::string::npos ? name : name.substr(cut + 1); +} + +std::string DirName(const std::string& path) { + const size_t slash = path.find_last_of('/'); + return slash == std::string::npos ? std::string() : path.substr(0, slash); +} + +bool EndsWith(const std::string& text, const std::string& suffix) { + return text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +json Located(const std::string& file, const js::Location& at) { + return {{"file", file}, {"line", at.line}, {"column", at.column}}; +} + +} // namespace + +bool JoinArchivePath(const std::string& base, const std::string& relative, std::string& out) { + std::vector parts; + auto push = [&](const std::string& text) { + size_t start = 0; + while (start <= text.size()) { + size_t end = text.find_first_of("/\\", start); + if (end == std::string::npos) end = text.size(); + const std::string segment = text.substr(start, end - start); + if (segment == "..") { + if (parts.empty()) return false; + parts.pop_back(); + } else if (!segment.empty() && segment != ".") { + parts.push_back(segment); + } + start = end + 1; + } + return true; + }; + if (!(relative.rfind('/', 0) == 0 ? true : push(base)) || !push(relative)) return false; + out.clear(); + for (const auto& part : parts) out += (out.empty() ? "" : "/") + part; + return true; +} + +std::vector JavaScriptProvider::Capabilities() const { + return {{"static", CapabilityStatus::Validated, + "cortex_js_tests (electron-app fixture, esbuild bundle, lexer traps, fuzzing)", + "tokens (strings, templates, regexes, comments), ESM/CommonJS/dynamic imports, exports, Electron IPC " + "and BrowserWindow options, bundler signatures, source map URL"}, + {"decompile", CapabilityStatus::Unavailable, "", + "the file is already source; no beautifier or deobfuscator is provided"}, + {"live", CapabilityStatus::Unavailable, "", "Node/Electron inspector (CDP) is planned for step 8"}}; +} + +int JavaScriptProvider::Detect(const ByteSource& source) const { + if (js::IsScriptName(BaseName(source.Name()))) return 30; + // A script without extension: a Node shebang. + const auto head = source.ReadSome(0, 64); + const std::string text(head.begin(), head.end()); + if (text.rfind("#!", 0) == 0 && text.find("node") != std::string::npos && text.find('\n') != std::string::npos) + return 20; + return 0; +} + +json JavaScriptProvider::Inspect(const ByteSource& source, const InspectOptions& options, std::string& error) const { + bool truncated = false; + const std::string text = ReadText(source, kMaxScriptBytes, truncated); + if (source.Size() && text.empty()) { + error = "read_failed"; + return nullptr; + } + if (LooksBinary(text)) { + error = "not_text"; + return nullptr; + } + const auto analysis = js::Analyze(text, BaseName(source.Name()), options.maxEntries); + json data = js::ToJson(analysis, options.maxEntries); + if (truncated) data["warnings"].push_back("only the first " + std::to_string(kMaxScriptBytes) + " bytes were analysed"); + if (analysis.sourceMappingUrl.rfind("data:", 0) == 0) { + std::string mapText, mapError; + SourceMap map; + if (!DecodeDataUrl(analysis.sourceMappingUrl, mapText)) data["source_map"]["error"] = "data_url_not_decoded"; + else if (!ParseSourceMap(mapText, map, mapError)) data["source_map"]["error"] = mapError; + else data["source_map"]["summary"] = SourceMapSummary(map, options.maxEntries); + } + return data; +} + +std::vector SourceMapProvider::Capabilities() const { + return {{"static", CapabilityStatus::Validated, + "cortex_js_tests (esbuild map: every column checked against node:module SourceMap)", + "revision 3 maps: sources, embedded content, VLQ mappings, position lookup; index maps are refused"}, + {"decompile", CapabilityStatus::Unavailable, "", "not applicable"}, + {"live", CapabilityStatus::Unavailable, "", "not applicable"}}; +} + +int SourceMapProvider::Detect(const ByteSource& source) const { + const auto head = source.ReadSome(0, 4096); + const std::string text(head.begin(), head.end()); + const size_t start = text.find_first_not_of(" \t\r\n"); + const bool object = start != std::string::npos && (text[start] == '{' || text.compare(start, 4, ")]}'") == 0); + if (!object) return 0; + if (EndsWith(BaseName(source.Name()), ".map")) return 45; + return text.find("\"mappings\"") != std::string::npos && text.find("\"version\"") != std::string::npos ? 25 : 0; +} + +json SourceMapProvider::Inspect(const ByteSource& source, const InspectOptions& options, std::string& error) const { + bool truncated = false; + const std::string text = ReadText(source, kMaxMapBytes, truncated); + if (truncated) { + error = "sourcemap_too_large"; + return nullptr; + } + SourceMap map; + if (!ParseSourceMap(text, map, error)) return nullptr; + return SourceMapSummary(map, options.maxEntries); +} + +json SourcePosition(const ByteSource& artifact, uint32_t line, uint32_t column, const MapOpener& open, + std::string& error) { + if (line == 0 || column == 0) { + error = "position_is_1_based"; + return nullptr; + } + bool truncated = false; + std::string text = ReadText(artifact, kMaxMapBytes, truncated); + if (truncated) { + error = "artifact_too_large"; + return nullptr; + } + json mapInfo; + SourceMap map; + // A source map itself, or a script naming one. + if (SourceMapProvider().Detect(artifact) > 0) { + if (!ParseSourceMap(text, map, error)) return nullptr; + mapInfo = {{"name", artifact.Name()}, {"inline", false}}; + } else { + const auto lex = js::Lex(text); + const std::string url = lex.sourceMappingUrl; + if (url.empty()) { + error = "no_source_map"; + return nullptr; + } + std::string mapText; + if (url.rfind("data:", 0) == 0) { + if (!DecodeDataUrl(url, mapText)) { + error = "data_url_not_decoded"; + return nullptr; + } + mapInfo = {{"name", artifact.Name()}, {"inline", true}}; + } else { + if (!open) { + error = "source_map_not_reachable"; + return nullptr; + } + const auto mapSource = open(url, error); + if (!mapSource) return nullptr; + mapText = ReadText(*mapSource, kMaxMapBytes, truncated); + if (truncated) { + error = "sourcemap_too_large"; + return nullptr; + } + mapInfo = {{"name", mapSource->Name()}, {"inline", false}, {"url", url}}; + } + if (!ParseSourceMap(mapText, map, error)) return nullptr; + text.swap(mapText); + } + Sha256 digest; + digest.Update(text.data(), text.size()); + mapInfo["sha256"] = Sha256::Hex(digest.Final()); + json result = LookupJson(map, line, column); + result["map"] = mapInfo; + result["ok"] = true; + return result; +} + +namespace { + +struct Script { + std::string file; + js::Analysis analysis; +}; + +struct Resolved { + std::string target; // archive entry, when found + std::string reason; // why not, otherwise +}; + +class Tree { +public: + Tree(const ByteSource& container, const std::vector& children) : container_(container) { + for (const auto& child : children) entries_[child.name] = &child; + } + + bool Read(const Child& child, uint64_t limit, std::string& text) const { + if (!child.readable || child.size > limit) return false; + text.assign(static_cast(child.size), '\0'); + return child.size == 0 || container_.Read(child.offset, text.data(), text.size()); + } + + bool Has(const std::string& name) const { return entries_.count(name) > 0; } + const Child* Find(const std::string& name) const { + const auto it = entries_.find(name); + return it == entries_.end() ? nullptr : it->second; + } + const std::map& Entries() const { return entries_; } + + // Node's resolution, restricted to what the archive holds. + Resolved ResolveFile(const std::string& path) const { + static const char* const extensions[] = {".js", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".jsx", ".json", ".node"}; + if (Has(path)) return {path, {}}; + for (const char* extension : extensions) + if (Has(path + extension)) return {path + extension, {}}; + // TypeScript sources import "./x.js" for "./x.ts". + for (const char* from : {".js", ".mjs", ".cjs"}) { + if (!EndsWith(path, from)) continue; + const std::string stem = path.substr(0, path.size() - std::string(from).size()); + for (const char* to : {".ts", ".tsx", ".mts", ".cts"}) + if (Has(stem + to)) return {stem + to, {}}; + } + if (const Child* package = Find(path + "/package.json")) { + std::string text; + if (Read(*package, 1u << 20, text)) { + const json manifest = json::parse(text, nullptr, false); + if (manifest.is_object() && manifest.contains("main") && manifest["main"].is_string()) { + std::string main; + if (JoinArchivePath(path, manifest["main"].get(), main)) { + const auto resolved = ResolveFile(main); + if (!resolved.target.empty()) return resolved; + } + } + } + } + for (const char* extension : extensions) + if (Has(path + "/index" + extension)) return {path + "/index" + extension, {}}; + return {{}, "not_in_archive"}; + } + + Resolved Resolve(const std::string& from, const std::string& specifier) const { + if (specifier.rfind("./", 0) == 0 || specifier.rfind("../", 0) == 0 || specifier == "." || specifier == "..") { + std::string path; + if (!JoinArchivePath(DirName(from), specifier, path)) return {{}, "outside_archive"}; + return ResolveFile(path); + } + if (specifier.rfind('/', 0) == 0) return {{}, "absolute_path"}; + return {{}, "package"}; + } + +private: + const ByteSource& container_; + std::map entries_; +}; + +std::string PackageName(const std::string& specifier) { + const size_t first = specifier.find('/'); + if (specifier.rfind('@', 0) == 0 && first != std::string::npos) { + const size_t second = specifier.find('/', first + 1); + return specifier.substr(0, second); + } + return specifier.substr(0, first); +} + +// diff --git a/tests/fixtures/electron-app/src/greet.ts b/tests/fixtures/electron-app/src/greet.ts new file mode 100644 index 00000000..cd591d67 --- /dev/null +++ b/tests/fixtures/electron-app/src/greet.ts @@ -0,0 +1,9 @@ +export interface Greeting { + text: string; + level: number; +} + +export function greet(theme: string, level: number): Greeting { + const text = `Hello from the ${theme} theme`; + return { text, level }; +} diff --git a/tests/fixtures/electron-app/src/math.ts b/tests/fixtures/electron-app/src/math.ts new file mode 100644 index 00000000..66300afb --- /dev/null +++ b/tests/fixtures/electron-app/src/math.ts @@ -0,0 +1,5 @@ +export function clamp(value: number, min: number, max: number): number { + if (value < min) return min; + if (value > max) return max; + return value; +} diff --git a/tests/fixtures/electron-app/src/renderer.ts b/tests/fixtures/electron-app/src/renderer.ts new file mode 100644 index 00000000..bd81035d --- /dev/null +++ b/tests/fixtures/electron-app/src/renderer.ts @@ -0,0 +1,21 @@ +// Renderer code in TypeScript, bundled by esbuild into renderer/bundle.js. +import { greet, type Greeting } from './greet'; +import { clamp } from './math'; + +interface Bridge { + getSetting(key: string): Promise; + log(message: string): void; +} + +declare global { + interface Window { cortexDemo: Bridge } +} + +export async function start(): Promise { + const theme = await window.cortexDemo.getSetting('theme'); + const greeting = greet(String(theme), clamp(42, 0, 10)); + window.cortexDemo.log(greeting.text); + return greeting; +} + +start(); diff --git a/tests/fixtures/elf/actor-expected.json b/tests/fixtures/elf/actor-expected.json new file mode 100644 index 00000000..11b94976 --- /dev/null +++ b/tests/fixtures/elf/actor-expected.json @@ -0,0 +1,16 @@ +{ + "format": "elf", + "functions": { + "CortexDemoApplyDamage": "0x1169", + "CortexNativeAdd": "0x11b7", + "__do_global_dtors_aux": "0x1120", + "_fini": "0x1278", + "_init": "0x1000", + "_start": "0x1080", + "deregister_tm_clones": "0x10b0", + "frame_dummy": "0x1160", + "main": "0x11c3", + "register_tm_clones": "0x10e0" + }, + "machine": "x86-64" +} \ No newline at end of file diff --git a/tests/fixtures/elf/actor.c b/tests/fixtures/elf/actor.c new file mode 100644 index 00000000..22f57b06 --- /dev/null +++ b/tests/fixtures/elf/actor.c @@ -0,0 +1,30 @@ +/* ELF fixture for the Ghidra provider (tests/analysis_provider_tests.cpp): + * the same armor/health rule as test_target's CortexDemoApplyDamage. Built + * by make_elf_fixture.sh (gcc -O1, PIE, not stripped); committed so CI on + * Windows needs no Linux compiler. */ +#include +#include + +struct CortexDemoActor { + uint32_t magic; + int32_t health; + int32_t armor; + uint32_t hits; +}; + +__attribute__((noinline)) void CortexDemoApplyDamage(struct CortexDemoActor* actor, int32_t amount) { + volatile struct CortexDemoActor* target = actor; + const int32_t absorbed = target->armor > 0 ? (amount / 3 < target->armor ? amount / 3 : target->armor) : 0; + target->armor = target->armor - absorbed; + target->health = target->health - (amount - absorbed); + target->hits = target->hits + 1; +} + +__attribute__((noinline)) int64_t CortexNativeAdd(int64_t a, int64_t b, int64_t c) { return a + b + c; } + +int main(void) { + struct CortexDemoActor actor = {0x52544341u, 100, 25, 0}; + for (int i = 0; i < 3; ++i) CortexDemoApplyDamage(&actor, 9); + printf("%d %d %u %lld\n", actor.health, actor.armor, actor.hits, (long long)CortexNativeAdd(1, 2, 3)); + return 0; +} diff --git a/tests/fixtures/elf/actor.elf b/tests/fixtures/elf/actor.elf new file mode 100755 index 00000000..c39e62e8 Binary files /dev/null and b/tests/fixtures/elf/actor.elf differ diff --git a/tests/fixtures/elf/make_elf_fixture.sh b/tests/fixtures/elf/make_elf_fixture.sh new file mode 100755 index 00000000..e5f8b125 --- /dev/null +++ b/tests/fixtures/elf/make_elf_fixture.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Rebuilds actor.elf and actor-expected.json (function symbols from nm). +set -eu +cd "$(dirname "$0")" +gcc -O1 -fPIE -pie -fno-omit-frame-pointer -o actor.elf actor.c +python3 - <<'PY' +import json, subprocess +out = subprocess.run(["nm", "--defined-only", "actor.elf"], capture_output=True, text=True, check=True).stdout +functions = {} +for line in out.splitlines(): + parts = line.split() + if len(parts) == 3 and parts[1] in ("T", "t"): + functions[parts[2]] = "0x%x" % int(parts[0], 16) +json.dump({"format": "elf", "machine": "x86-64", "functions": functions}, open("actor-expected.json", "w"), indent=2, sort_keys=True) +PY diff --git a/tests/fixtures/java-live/LiveApp.java b/tests/fixtures/java-live/LiveApp.java new file mode 100644 index 00000000..58479ffc --- /dev/null +++ b/tests/fixtures/java-live/LiveApp.java @@ -0,0 +1,34 @@ +// Live fixture for the JDWP provider (tests/dap_tests.cpp): the same rule as +// test_target's CortexDemoApplyDamage. Compiled with javac -g (local +// variable tables) and started with +// java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:PORT -cp DIR LiveApp +public final class LiveApp { + static final class Actor { + String name = "demo"; + int health = 100; + int armor = 25; + int hits; + } + + static int applyDamage(Actor target, int amount) { + int absorbed = target.armor > 0 ? Math.min(amount / 3, target.armor) : 0; + target.armor = target.armor - absorbed; + target.health = target.health - (amount - absorbed); // CORTEX_HEALTH_WRITE + target.hits = target.hits + 1; + return target.health; + } + + public static void main(String[] args) throws Exception { + Actor actor = new Actor(); + String label = "tick"; + for (long ticks = 1; ; ++ticks) { + int health = applyDamage(actor, 9); + if (health <= 0) { + actor.health = 100; + actor.armor = 25; + } + System.out.println(label + " " + ticks + " health " + actor.health); + Thread.sleep(20); + } + } +} diff --git a/tests/fixtures/java/Cortex.Fixture.jar b/tests/fixtures/java/Cortex.Fixture.jar new file mode 100644 index 00000000..c53d5eff Binary files /dev/null and b/tests/fixtures/java/Cortex.Fixture.jar differ diff --git a/tests/fixtures/java/make_java_fixture.py b/tests/fixtures/java/make_java_fixture.py new file mode 100644 index 00000000..1e9dd743 --- /dev/null +++ b/tests/fixtures/java/make_java_fixture.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Builds Cortex.Fixture.jar from src/ with javac --release 17 (committed so +the tests run without a JDK); --out DIR builds a fresh copy elsewhere.""" +import argparse, os, shutil, subprocess, tempfile, zipfile + +HERE = os.path.dirname(os.path.abspath(__file__)) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--out", default=HERE) + args = parser.parse_args() + classes = tempfile.mkdtemp(prefix="cortex-java-fixture-") + try: + sources = [os.path.join(root, name) for root, _, names in os.walk(os.path.join(HERE, "src")) for name in names if name.endswith(".java")] + subprocess.run(["javac", "--release", "17", "-d", classes] + sorted(sources), check=True) + os.makedirs(args.out, exist_ok=True) + jar = os.path.join(args.out, "Cortex.Fixture.jar") + # Fixed timestamps and order: the jar's bytes depend only on javac's output. + with zipfile.ZipFile(jar, "w", zipfile.ZIP_DEFLATED) as out: + info = zipfile.ZipInfo("META-INF/MANIFEST.MF", (2026, 1, 1, 0, 0, 0)) + out.writestr(info, "Manifest-Version: 1.0\r\nCreated-By: make_java_fixture.py\r\n\r\n") + for root, _, names in sorted(os.walk(classes)): + for name in sorted(names): + path = os.path.join(root, name) + info = zipfile.ZipInfo(os.path.relpath(path, classes).replace(os.sep, "/"), (2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + out.writestr(info, open(path, "rb").read()) + print(jar) + finally: + shutil.rmtree(classes, ignore_errors=True) + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/java/src/cortex/fixture/Actor.java b/tests/fixtures/java/src/cortex/fixture/Actor.java new file mode 100644 index 00000000..e16b4d13 --- /dev/null +++ b/tests/fixtures/java/src/cortex/fixture/Actor.java @@ -0,0 +1,71 @@ +package cortex.fixture; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntUnaryOperator; + +// Java fixture for the Vineflower provider: the same rules as the C# and +// native fixtures (armor absorbs a third of the damage). +public final class Actor { + public static final int MAX_HEALTH = 100; + public static int created; + + public static final class Snapshot { + public int health; + public int[] recent = new int[4]; + } + + private final String name; + private int health = MAX_HEALTH; + private int armor = 25; + private final List log = new ArrayList<>(); + + public Actor(String name) { + this.name = name; + created++; + } + + public String getName() { return name; } + public int getHealth() { return health; } + public int getArmor() { return armor; } + + public void applyDamage(int amount, DamageKind kind) { + if (amount <= 0) throw new IllegalArgumentException("damage must be positive"); + int dealt; + switch (kind) { + case FIRE: dealt = amount * 2; break; + case POISON: dealt = amount / 2; break; + default: dealt = Math.max(1, amount - armor / 5); break; + } + int absorbed = armor > 0 ? Math.min(dealt / 3, armor) : 0; + armor -= absorbed; + health = Math.max(0, health - (dealt - absorbed)); + log.add(name + " took " + dealt); + } + + public int heal(int amount) { + try { + health = Math.min(MAX_HEALTH, Math.addExact(health, amount)); + } catch (ArithmeticException overflow) { + health = MAX_HEALTH; + } finally { + log.add("healed"); + } + return health; + } + + public static T pick(List items, int index) { + return index < 0 || index >= items.size() ? null : items.get(index); + } + + public int scaled(int factor) { + IntUnaryOperator scale = value -> value * factor; + return scale.applyAsInt(health); + } + + public Snapshot snapshot() { + Snapshot s = new Snapshot(); + s.health = health; + return s; + } +} diff --git a/tests/fixtures/java/src/cortex/fixture/DamageKind.java b/tests/fixtures/java/src/cortex/fixture/DamageKind.java new file mode 100644 index 00000000..98fc5745 --- /dev/null +++ b/tests/fixtures/java/src/cortex/fixture/DamageKind.java @@ -0,0 +1,5 @@ +package cortex.fixture; + +public enum DamageKind { + PHYSICAL, FIRE, POISON +} diff --git a/tests/fixtures/make_electron_fixture.mjs b/tests/fixtures/make_electron_fixture.mjs new file mode 100644 index 00000000..3ffd0c9c --- /dev/null +++ b/tests/fixtures/make_electron_fixture.mjs @@ -0,0 +1,80 @@ +// Builds the generated parts of the Electron fixture with the real tools: +// - electron-app/renderer/bundle.js(.map): esbuild bundle of src/*.ts, +// minified, with a linked source map (sourceMappingURL comment); +// - electron-app.asar: the app packed by @electron/asar (with integrity); +// - electron-app-expected.json: source-map lookups computed by Node's own +// node:module SourceMap, which the Cortex decoder must reproduce. +// +// npm install --prefix esbuild@0.25.12 @electron/asar@4.3.1 +// node tests/fixtures/make_electron_fixture.mjs /node_modules [--asar-only ] +// +// CI runs it with --asar-only to pack a fresh archive for the E2E test. +import { createRequire } from 'node:module'; +import { SourceMap } from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const app = path.join(here, 'electron-app'); +const modules = path.resolve(process.argv[2] ?? 'node_modules'); +const require = createRequire(path.join(modules, 'noop.js')); +const asar = require('@electron/asar'); + +const asarOnly = process.argv.indexOf('--asar-only'); +if (asarOnly !== -1) { + await asar.createPackage(app, path.resolve(process.argv[asarOnly + 1])); + process.exit(0); +} + +const esbuild = require('esbuild'); +await esbuild.build({ + entryPoints: [path.join(app, 'src', 'renderer.ts')], + bundle: true, + minify: true, + format: 'iife', + target: 'es2020', + sourcemap: 'linked', + sourcesContent: true, + outfile: path.join(app, 'renderer', 'bundle.js'), + logLevel: 'warning', +}); + +// Positions in the bundle of a few distinctive literals, and where Node's +// SourceMap says they come from. Lines and columns are 1-based here. +const bundle = fs.readFileSync(path.join(app, 'renderer', 'bundle.js'), 'utf8'); +const map = new SourceMap(JSON.parse(fs.readFileSync(path.join(app, 'renderer', 'bundle.js.map'), 'utf8'))); +const lookups = []; +for (const needle of ['"theme"', 'Hello from the ', 'e { + for (let column = 0; column <= text.length; ++column) { + const entry = map.findEntry(index, column); + columns.push(entry.originalSource === undefined + ? [index + 1, column + 1, null] + : [index + 1, column + 1, entry.originalSource, entry.originalLine + 1, entry.originalColumn + 1, entry.name ?? null]); + } +}); +fs.writeFileSync(path.join(here, 'electron-app-expected.json'), + JSON.stringify({ lookups, columns: columns.map((row) => JSON.stringify(row)) }, null, 1) + '\n'); +await asar.createPackage(app, path.join(here, 'electron-app.asar')); +console.log('fixture built:', lookups.length, 'lookups'); diff --git a/tests/fixtures/mcp_tool_risks.json b/tests/fixtures/mcp_tool_risks.json index ca00fc03..1ddda865 100644 --- a/tests/fixtures/mcp_tool_risks.json +++ b/tests/fixtures/mcp_tool_risks.json @@ -91,6 +91,24 @@ "path": "/call/thread", "risk": "native_call" }, + { + "name": "context_events", + "method": "GET", + "path": "/context/events", + "risk": "observe" + }, + { + "name": "context_get", + "method": "GET", + "path": "/context", + "risk": "observe" + }, + { + "name": "context_observation_add", + "method": "POST", + "path": "/context/observation", + "risk": "state" + }, { "name": "debug_breakpoint_add", "method": "POST", diff --git a/tests/fixtures/node-live/app.js b/tests/fixtures/node-live/app.js new file mode 100644 index 00000000..69d3e546 --- /dev/null +++ b/tests/fixtures/node-live/app.js @@ -0,0 +1,27 @@ +// Live fixture for the CDP provider (tests/cdp_tests.cpp, tests/live_e2e.py). +// Same rule as test_target's CortexDemoApplyDamage: armor absorbs a third of +// the damage until it is gone. Started with node --inspect=127.0.0.1:. +'use strict'; + +const actor = { name: 'demo', health: 100, armor: 25, hits: 0 }; +let ticks = 0; + +function applyDamage(target, amount) { + const absorbed = target.armor > 0 ? Math.min(Math.floor(amount / 3), target.armor) : 0; + target.armor = target.armor - absorbed; + target.health = target.health - (amount - absorbed); // CORTEX_HEALTH_WRITE + target.hits = target.hits + 1; + return target.health; +} + +setInterval(() => { + ticks += 1; + applyDamage(actor, 9); + if (actor.health <= 0) { + actor.health = 100; + actor.armor = 25; + } + // A pause Cortex did not ask for: it must be recorded and resumed. + if (ticks % 50 === 0) debugger; + console.log(`tick ${ticks} health ${actor.health} armor ${actor.armor}`); +}, 20); diff --git a/tests/fixtures/python-live/app.py b/tests/fixtures/python-live/app.py new file mode 100644 index 00000000..50fb96e8 --- /dev/null +++ b/tests/fixtures/python-live/app.py @@ -0,0 +1,28 @@ +# Live fixture for the DAP provider (tests/dap_tests.cpp): the same rule as +# test_target's CortexDemoApplyDamage. Started with +# python -m debugpy --listen 127.0.0.1: app.py +import time + +actor = {"name": "demo", "health": 100, "armor": 25, "hits": 0} + + +def apply_damage(target, amount): + absorbed = min(amount // 3, target["armor"]) if target["armor"] > 0 else 0 + target["armor"] = target["armor"] - absorbed + target["health"] = target["health"] - (amount - absorbed) # CORTEX_HEALTH_WRITE + target["hits"] = target["hits"] + 1 + return target["health"] + + +ticks = 0 +while True: + ticks += 1 + apply_damage(actor, 9) + if actor["health"] <= 0: + actor["health"] = 100 + actor["armor"] = 25 + # A stop Cortex did not ask for: it must be recorded and continued. + if ticks % 50 == 0: + breakpoint() + print("tick %d health %d armor %d" % (ticks, actor["health"], actor["armor"]), flush=True) + time.sleep(0.02) diff --git a/tests/fixtures/shift_fixture.c b/tests/fixtures/shift_fixture.c new file mode 100644 index 00000000..8c118e58 --- /dev/null +++ b/tests/fixtures/shift_fixture.c @@ -0,0 +1,42 @@ +/* Two builds of this file stand for two versions of one program + * (cortex_evidence_tests): build B (CORTEX_SHIFT) inserts a function + * before cortex_shift_target, so the target moves to another RVA with the + * same code, and changes cortex_shift_changed. Never run by the tests. */ +#include + +static int g_counter; + +#ifdef CORTEX_SHIFT +__declspec(dllexport) __attribute__((noinline)) int cortex_shift_padding(int x) { + int sum = 0; + for (int i = 0; i < x; ++i) sum += (i * i) ^ x; + return sum; +} +#endif + +__declspec(dllexport) __attribute__((noinline)) int cortex_shift_target(int amount) { + g_counter += amount; + if (g_counter > 100) g_counter = 100; + return g_counter * 3 + amount; +} + +__declspec(dllexport) __attribute__((noinline)) int cortex_shift_changed(int value) { +#ifdef CORTEX_SHIFT + return value * 7 - 3; +#else + return value * 5 + 1; +#endif +} + +int main(int argc, char** argv) { + (void)argv; + int result = 0; + for (int i = 0; i < argc; ++i) { + result += cortex_shift_target(i) + cortex_shift_changed(i); +#ifdef CORTEX_SHIFT + result += cortex_shift_padding(i); +#endif + } + printf("%d\n", result); + return 0; +} diff --git a/tests/fuzz/CMakeLists.txt b/tests/fuzz/CMakeLists.txt new file mode 100644 index 00000000..ff7077ab --- /dev/null +++ b/tests/fuzz/CMakeLists.txt @@ -0,0 +1,75 @@ +# libFuzzer harnesses for Cortex's file parsers. A separate project, built on +# Linux with clang (the parsers are portable C++17): +# cmake -S tests/fuzz -B build/fuzz -G Ninja -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_COMPILER=clang +# cmake --build build/fuzz +# build/fuzz/fuzz_artifact -max_total_time=60 build/fuzz/corpus/artifact +# CORTEX_FUZZ_REPLAY=ON builds the same harnesses as plain executables that +# replay a corpus directory (no libFuzzer), for ctest. +cmake_minimum_required(VERSION 3.21) +project(cortex_fuzz CXX C) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +option(CORTEX_FUZZ_REPLAY "Build replay executables instead of libFuzzer targets" OFF) + +include(FetchContent) +# Same revisions as the main build (CMakeLists.txt at the repository root). +FetchContent_Declare(json GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG 9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03) +set(JSON_BuildTests OFF CACHE INTERNAL "") +FetchContent_MakeAvailable(json) +FetchContent_Declare(zycore GIT_REPOSITORY https://github.com/zyantific/zycore-c.git + GIT_TAG 74620eefd233bec20daeb66e78e744ff06e273b7) +set(CMAKE_DISABLE_FIND_PACKAGE_Doxygen TRUE CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(zycore) +FetchContent_Declare(zydis GIT_REPOSITORY https://github.com/zyantific/zydis.git + GIT_TAG 569320ad3c4856da13b9dbf1f0d9e20bda63870e) +set(ZYDIS_BUILD_TOOLS OFF CACHE INTERNAL "") +set(ZYDIS_BUILD_EXAMPLES OFF CACHE INTERNAL "") +set(ZYDIS_BUILD_TESTS OFF CACHE INTERNAL "") +set(ZYDIS_BUILD_DOXYGEN OFF CACHE INTERNAL "") +set(ZYDIS_BUILD_MAN OFF CACHE INTERNAL "") +FetchContent_MakeAvailable(zydis) + +set(ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../..) +set(ARTIFACT_SOURCES + ${ROOT}/core/artifacts/artifact.cpp ${ROOT}/core/artifacts/pe_provider.cpp + ${ROOT}/core/artifacts/asar_provider.cpp ${ROOT}/core/artifacts/registry_default.cpp + ${ROOT}/core/artifacts/js_lexer.cpp ${ROOT}/core/artifacts/js_analysis.cpp + ${ROOT}/core/artifacts/js_provider.cpp ${ROOT}/core/artifacts/source_map.cpp + ${ROOT}/core/artifacts/inflate.cpp ${ROOT}/core/artifacts/zip_provider.cpp + ${ROOT}/core/artifacts/dotnet_metadata.cpp ${ROOT}/core/artifacts/cil.cpp + ${ROOT}/core/artifacts/dotnet_provider.cpp ${ROOT}/core/artifacts/native_code.cpp + ${ROOT}/core/artifacts/annotations.cpp) +add_library(cortex_fuzz_parsers STATIC ${ARTIFACT_SOURCES} ${ROOT}/core/live/protocol_frames.cpp + ${ROOT}/core/evidence/evidence.cpp ${ROOT}/core/investigation/investigation.cpp) +target_include_directories(cortex_fuzz_parsers PUBLIC ${ROOT}/core) +target_link_libraries(cortex_fuzz_parsers PUBLIC nlohmann_json::nlohmann_json Zydis) + +if(CORTEX_FUZZ_REPLAY) + set(SAN -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer) +else() + set(SAN -fsanitize=fuzzer-no-link,address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer) +endif() +target_compile_options(cortex_fuzz_parsers PRIVATE ${SAN} -g -O1) + +foreach(harness fuzz_artifact fuzz_pe_code fuzz_text fuzz_protocols fuzz_stores) + if(CORTEX_FUZZ_REPLAY) + add_executable(${harness} ${harness}.cpp replay_main.cpp) + target_compile_options(${harness} PRIVATE ${SAN} -g -O1) + target_link_options(${harness} PRIVATE -fsanitize=address,undefined) + else() + add_executable(${harness} ${harness}.cpp) + target_compile_options(${harness} PRIVATE -fsanitize=fuzzer,address,undefined -fno-sanitize-recover=undefined -g -O1) + target_link_options(${harness} PRIVATE -fsanitize=fuzzer,address,undefined) + endif() + target_link_libraries(${harness} PRIVATE cortex_fuzz_parsers) +endforeach() +target_link_options(cortex_fuzz_parsers INTERFACE $,-fsanitize=address$undefined,-fsanitize=fuzzer-no-link$address$undefined>) + +if(CORTEX_FUZZ_REPLAY) + enable_testing() + foreach(harness fuzz_artifact fuzz_pe_code fuzz_text fuzz_protocols fuzz_stores) + add_test(NAME ${harness}_regressions COMMAND ${harness} ${CMAKE_CURRENT_SOURCE_DIR}/regressions/${harness}) + endforeach() +endif() diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 00000000..ca236580 --- /dev/null +++ b/tests/fuzz/README.md @@ -0,0 +1,42 @@ +# Parser fuzzing + +Files Cortex inspects are untrusted input. These libFuzzer harnesses run the +file parsers under AddressSanitizer and UndefinedBehaviorSanitizer: + +| Harness | What it drives | +|---|---| +| `fuzz_artifact` | The provider registry as `cortex.exe inspect` uses it: detection, `Inspect`, the list of children and up to six children two levels deep (PE, .NET, ZIP/JAR with DEFLATE, ASAR, JavaScript/TypeScript, source maps). The first byte picks the file name, since some providers also look at the extension. | +| `fuzz_pe_code` | `ParsePe`, CLR metadata and the CIL of up to 16 methods, and the native-code index (Zydis): functions, strings, xrefs, CFG and fingerprints of the first functions. Inputs are capped at 1 MiB. | +| `fuzz_text` | The JavaScript lexer and analysis, source maps, base64 data URLs and DEFLATE. The first byte picks the decoder. | +| `fuzz_protocols` | The live debug protocols as the sessions decode them (`core/live/protocol_frames`): WebSocket messages for CDP, DAP's `Content-Length` framing, JDWP packets, and the typed accessors (`core/live/json_safe.h`) applied at every node of each message. Also checks properties: cleaned text always serializes, `ValidUtf8` is idempotent, `Clip` respects its bound. | +| `fuzz_stores` | Evidence and investigation records read from disk (list, get, export, start, approve), and knowledge records a client sends (`Validate`, `Prepare` with a fuzzed previous revision). No exception may leave a store. | + +## Running + +Linux with clang and its sanitizer runtimes (`libclang-rt-18-dev` on Ubuntu): + +```bash +cmake -S tests/fuzz -B build/fuzz -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ +cmake --build build/fuzz +python3 tests/fuzz/make_seeds.py build/fuzz-seeds +build/fuzz/fuzz_artifact -max_total_time=300 -max_len=65536 build/fuzz-work build/fuzz-seeds/artifact +``` + +CI (`contracts.yml`, job `fuzz-parsers`) runs each harness for 60 seconds on +every pull request, and uploads any crashing input as an artifact. + +## Keeping a crash + +When a harness finds a crash, fix the parser, then add the input to +`regressions//`. `-DCORTEX_FUZZ_REPLAY=ON` builds the harnesses as +plain executables, and `ctest` replays every file there on each run. + +## Not covered + +- The project store (`core/project/project.cpp`) is Windows-only; its loader + is covered by unit tests, not by a fuzzer. +- The capture code that runs at a breakpoint hit (stack and locals requests) + sends requests to the debugger, so it is exercised by the live tests, not + by `fuzz_protocols`. It reads replies through the same typed accessors. +- Python bytecode, Unity `global-metadata.dat`, class files and WebAssembly + have no parser yet. diff --git a/tests/fuzz/fuzz_artifact.cpp b/tests/fuzz/fuzz_artifact.cpp new file mode 100644 index 00000000..0deb7249 --- /dev/null +++ b/tests/fuzz/fuzz_artifact.cpp @@ -0,0 +1,41 @@ +// Whole-registry fuzzer: detection, inspection and container children (ZIP, +// ASAR, nested entries) exactly as `cortex.exe inspect` and the +// cortex_artifact_* tools run them. The first byte picks the file name, since +// some providers (JavaScript, source maps) also look at the extension. +#include "artifacts/artifact.h" + +#include +#include +#include + +using namespace cortex::artifacts; + +namespace { +const char* const kNames[] = {"input.bin", "input.dll", "input.exe", "app.asar", "bundle.js", + "bundle.js.map", "module.mjs", "archive.zip", "package.jar", "code.ts"}; + +void Walk(const Registry& registry, ByteSourcePtr source, int depth) { + InspectOptions options; + options.maxEntries = 64; + registry.Inspect(*source, options); + if (depth >= 2) return; + json children = registry.ListChildren(*source, options); + if (!children.is_object() || !children.contains("children") || !children["children"].is_array()) return; + size_t opened = 0; + for (const auto& child : children["children"]) { + if (opened++ >= 6) break; + if (!child.is_object() || !child.contains("name") || !child["name"].is_string()) continue; + std::string error; + ByteSourcePtr inner = registry.OpenChild(source, child["name"].get(), error, options); + if (inner) Walk(registry, inner, depth + 1); + } +} +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size == 0) return 0; + const std::string name = kNames[data[0] % (sizeof(kNames) / sizeof(kNames[0]))]; + std::vector bytes(data + 1, data + size); + Walk(DefaultRegistry(), FromBytes(std::move(bytes), name), 0); + return 0; +} diff --git a/tests/fuzz/fuzz_pe_code.cpp b/tests/fuzz/fuzz_pe_code.cpp new file mode 100644 index 00000000..523a161e --- /dev/null +++ b/tests/fuzz/fuzz_pe_code.cpp @@ -0,0 +1,50 @@ +// PE, CLR metadata, CIL and native code: the parts behind cortex_artifact_cil, +// _functions, _xrefs, _strings and _cfg. Every byte is attacker-controlled. +#include "artifacts/artifact.h" +#include "artifacts/cil.h" +#include "artifacts/dotnet_metadata.h" +#include "artifacts/native_code.h" +#include "artifacts/pe_provider.h" + +#include +#include +#include +#include +#include + +using namespace cortex::artifacts; + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + // Keep native-code indexing bounded: the decoder is linear in the size. + if (size > (1u << 20)) return 0; + auto source = FromBytes(std::vector(data, data + size), "input.dll"); + PeFile pe; + std::string error; + if (!ParsePe(*source, pe, error)) return 0; + + if (pe.HasDirectory(kPeClr)) { + dotnet::Metadata metadata; + if (metadata.Parse(*source, pe, error)) { + const uint32_t methods = metadata.Rows(dotnet::MethodDef); + for (uint32_t row = 1; row <= methods && row <= 16; ++row) { + std::string methodError; + dotnet::MethodJson(*source, pe, metadata, row, 256, methodError); + } + dotnet::FindMethods(metadata, "Cortex.Fixture.Actor::ApplyDamage"); + } + } + + native::CodeIndex index; + if (native::BuildIndex(*source, pe, index, error)) { + const std::map annotations; + native::FunctionsJson(index, "", 32, annotations); + native::StringsJson(index, "", 32); + for (size_t i = 0; i < index.functions.size() && i < 4; ++i) { + const uint32_t rva = index.functions[i].rva; + native::XrefsJson(index, rva, 16, annotations); + native::CfgJson(*source, pe, index, rva, {}, 64, annotations); + native::Fingerprint(*source, pe, index, rva); + } + } + return 0; +} diff --git a/tests/fuzz/fuzz_protocols.cpp b/tests/fuzz/fuzz_protocols.cpp new file mode 100644 index 00000000..68233e0c --- /dev/null +++ b/tests/fuzz/fuzz_protocols.cpp @@ -0,0 +1,126 @@ +// Live debug protocols: WebSocket messages (CDP), DAP framing, JDWP packets, +// and the typed JSON accessors and UTF-8 cleaning applied to what a debug +// interface sends. Besides "no crash", it checks properties: every string +// that goes into a record serializes, ValidUtf8 is idempotent, Clip respects +// its bound, and the accessors never throw. +#include "live/json_safe.h" +#include "live/protocol_frames.h" + +#include +#include +#include +#include +#include + +using namespace cortex::live; +using json = nlohmann::json; + +namespace { + +[[noreturn]] void Fail(const char* what) { + std::fprintf(stderr, "property violated: %s\n", what); + std::abort(); +} + +void CheckText(const std::string& raw) { + const std::string clean = jsafe::ValidUtf8(raw); + if (jsafe::ValidUtf8(clean) != clean) Fail("ValidUtf8 is not idempotent"); + try { + (void)json(clean).dump(); + } catch (...) { + Fail("a ValidUtf8 string does not serialize"); + } + for (size_t size : {0u, 1u, 3u, 16u, 512u}) { + const std::string clipped = jsafe::Clip(raw, size); + if (clipped.size() > size + 3 && raw.size() > size) Fail("Clip exceeds its bound"); + try { + (void)json(clipped).dump(); + } catch (...) { + Fail("a clipped string does not serialize"); + } + } +} + +// Reads fields the way the sessions do at every node of the message (its +// real structure, so the walk is linear in the message size). +void Walk(const json& value, int depth) { + if (depth > 16) return; + static const char* const keys[] = {"type", "event", "request_seq", "body", "seq", "success", "message", + "stackFrames", "source", "path", "line", "column", "name", "id", + "method", "params", "result", "callFrames", "location", "scriptId", + "lineNumber", "value", "variables", "hitBreakpointIds", "reason"}; + for (const char* key : keys) { + CheckText(jsafe::Str(value, key)); + (void)jsafe::Int(value, key, -1); + (void)jsafe::Bool(value, key, false); + (void)jsafe::Obj(value, key); + (void)jsafe::Arr(value, key); + (void)jsafe::Any(value, key); + } + (void)jsafe::At(value, 0); + if (value.is_object()) + for (const auto& item : value.items()) Walk(item.value(), depth + 1); + else if (value.is_array()) + for (const auto& element : value) Walk(element, depth + 1); +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size == 0) return 0; + const uint8_t mode = data[0] % 4; + const uint8_t* body = data + 1; + const size_t length = size - 1; + switch (mode) { + case 0: { // WebSocket frames from a CDP endpoint + size_t at = 0; + size_t sent = 0; + frames::FrameIo io; + io.read = [&](void* out, size_t want, int, bool& timedOut) { + timedOut = false; + if (length - at < want) { + timedOut = at == length; + return false; + } + std::memcpy(out, body + at, want); + at += want; + return true; + }; + io.send = [&](uint8_t, const std::string& payload) { sent += payload.size(); }; + std::string text; + bool timedOut = false; + for (int i = 0; i < 64 && frames::ReceiveMessage(io, text, 0, timedOut); ++i) { + const json message = json::parse(text, nullptr, false); + Walk(message, 0); + } + break; + } + case 1: { // DAP stream, delivered in uneven chunks + std::string buffer; + size_t at = 0; + while (at < length) { + const size_t chunk = 1 + (body[at] % 97); + const size_t take = std::min(chunk, length - at); + buffer.append(reinterpret_cast(body + at), take); + at += take; + json message; + frames::Take taken; + while ((taken = frames::TakeDapMessage(buffer, message)) == frames::Take::Message) Walk(message, 0); + if (taken == frames::Take::Fatal) return 0; + } + break; + } + case 2: { // JDWP packets + std::vector buffer(body, body + length); + frames::JdwpPacket packet; + while (frames::TakeJdwpPacket(buffer, packet) == frames::Take::Message) { + if (packet.data.size() > frames::kMaxJdwpPacket) Fail("JDWP packet over its bound"); + } + break; + } + case 3: // raw text that a debug interface reports (names, values, output) + CheckText(std::string(reinterpret_cast(body), length)); + break; + } + return 0; +} diff --git a/tests/fuzz/fuzz_stores.cpp b/tests/fuzz/fuzz_stores.cpp new file mode 100644 index 00000000..8d4e747e --- /dev/null +++ b/tests/fuzz/fuzz_stores.cpp @@ -0,0 +1,86 @@ +// The JSON records Cortex keeps on disk (evidence, investigations) and the +// knowledge records clients send: a file can be edited or replaced, and a +// client sends whatever it wants. The fuzzed bytes become a record file in a +// store, then the store's read paths run on it (list, get, export, start, +// approve); for knowledge, Validate and Prepare (with a fuzzed previous +// revision too). Nothing may crash or throw out of the store. +#include "evidence/evidence.h" +#include "investigation/investigation.h" +#include "project/knowledge.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using json = nlohmann::json; + +namespace { + +const fs::path& Directory() { + static const fs::path directory = [] { + fs::path base = fs::temp_directory_path() / ("cortex-fuzz-stores-" + std::to_string(::getpid())); + fs::create_directories(base / "evidence"); + fs::create_directories(base / "investigations"); + return base; + }(); + return directory; +} + +void Write(const fs::path& path, const uint8_t* data, size_t size) { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(reinterpret_cast(data), static_cast(size)); +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size == 0) return 0; + const uint8_t mode = data[0] % 3; + const uint8_t* body = data + 1; + const size_t length = size - 1; + try { + if (mode == 0) { + const std::string id = "ev_00000000000000000000"; + const fs::path file = Directory() / "evidence" / (id + ".json"); + Write(file, body, length); + cortex::evidence::Store store(Directory() / "evidence"); + (void)store.Get(id).dump(); + (void)store.List(json::object(), 20).dump(); + (void)store.Export(json::object(), "json").dump(); + (void)store.Export(json::object(), "markdown").dump(); + fs::remove(file); + } else if (mode == 1) { + const std::string id = "inv_00000000000000000000"; + const fs::path file = Directory() / "investigations" / (id + ".json"); + Write(file, body, length); + cortex::investigation::Store store(Directory() / "investigations"); + (void)store.Get(id).dump(); + (void)store.List("", 20).dump(); + (void)store.List("running", 20).dump(); + (void)store.Start(id, 1, "desktop").dump(); + (void)store.Approve(id, 0, true, "", "desktop").dump(); + fs::remove(file); + } else { + // "\n": both are untrusted. + const std::string text(reinterpret_cast(body), length); + const size_t split = text.find('\n'); + const json previous = json::parse(text.substr(0, split), nullptr, false); + const json input = json::parse(split == std::string::npos ? std::string() : text.substr(split + 1), + nullptr, false); + std::string error; + json out; + (void)project::knowledge::Validate(input, error); + (void)project::knowledge::Prepare(input, nullptr, out, error); + if (previous.is_object()) (void)project::knowledge::Prepare(input, &previous, out, error); + } + } catch (const std::exception& e) { + std::fprintf(stderr, "exception escaped a store (mode %d): %s\n", mode, e.what()); + std::abort(); + } + return 0; +} diff --git a/tests/fuzz/fuzz_text.cpp b/tests/fuzz/fuzz_text.cpp new file mode 100644 index 00000000..520e1138 --- /dev/null +++ b/tests/fuzz/fuzz_text.cpp @@ -0,0 +1,49 @@ +// Text and stream decoders: JavaScript/TypeScript lexer and analysis, source +// maps (including base64 data URLs) and DEFLATE. +#include "artifacts/inflate.h" +#include "artifacts/js_analysis.h" +#include "artifacts/js_lexer.h" +#include "artifacts/source_map.h" + +#include +#include +#include +#include + +using namespace cortex::artifacts; + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size == 0) return 0; + const uint8_t mode = data[0]; + std::string_view text(reinterpret_cast(data + 1), size - 1); + switch (mode % 4) { + case 0: { + js::Lex(text, 1u << 16); + js::Analyze(text, (mode & 4) ? "input.ts" : "input.js", 256); + break; + } + case 1: { + SourceMap map; + std::string error; + if (ParseSourceMap(text, map, error, 1u << 16)) { + LookupMapping(map, 0, 0); + LookupMapping(map, 3, 17); + LookupMapping(map, 0xFFFFFFFFu, 0xFFFFFFFFu); + } + break; + } + case 2: { + std::string out; + DecodeBase64(text, out); + DecodeDataUrl(text, out); + break; + } + case 3: { + std::vector output; + std::string error; + Inflate(data + 1, size - 1, output, 1u << 22, error); + break; + } + } + return 0; +} diff --git a/tests/fuzz/make_seeds.py b/tests/fuzz/make_seeds.py new file mode 100644 index 00000000..cc741897 --- /dev/null +++ b/tests/fuzz/make_seeds.py @@ -0,0 +1,106 @@ +"""Build the seed corpora of the fuzzers from the repository's fixtures. + +usage: python make_seeds.py OUT_DIR + +fuzz_artifact inputs start with one byte that picks the file name (see +fuzz_artifact.cpp); fuzz_text inputs start with one byte that picks the +decoder (see fuzz_text.cpp). +""" +import base64 +import glob +import json +import os +import sys +import zlib + +ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) +NAMES = {"bin": 0, "dll": 1, "exe": 2, "asar": 3, "js": 4, "map": 5, "zip": 7, "jar": 8, "ts": 9} + + +def put(out, corpus, name, data): + os.makedirs(os.path.join(out, corpus), exist_ok=True) + with open(os.path.join(out, corpus, name), "wb") as handle: + handle.write(data) + + +def main(): + out = sys.argv[1] + fixtures = os.path.join(ROOT, "tests", "fixtures") + files = [ + (os.path.join(fixtures, "electron-app.asar"), "asar"), + (os.path.join(fixtures, "dotnet", "Cortex.Fixture.dll"), "dll"), + (os.path.join(fixtures, "java", "Cortex.Fixture.jar"), "jar"), + (os.path.join(fixtures, "elf", "actor.elf"), "bin"), + ] + files += [(p, "exe") for p in glob.glob(os.path.join(ROOT, "tests", "fuzz", "seeds", "pe", "*.exe"))] + files += [(p, "js") for p in sorted(glob.glob(os.path.join(fixtures, "**", "*.js"), recursive=True))[:20]] + files += [(p, "ts") for p in sorted(glob.glob(os.path.join(fixtures, "**", "*.ts"), recursive=True))[:5]] + files += [(p, "map") for p in sorted(glob.glob(os.path.join(fixtures, "**", "*.map"), recursive=True))[:5]] + for index, (path, kind) in enumerate(files): + if not os.path.exists(path): + continue + data = open(path, "rb").read() + put(out, "artifact", "seed%d" % index, bytes([NAMES[kind]]) + data) + if kind in ("dll", "exe") and len(data) <= 65536: + put(out, "pe_code", "seed%d" % index, data) + if kind in ("js", "ts"): + put(out, "text", "js%d" % index, bytes([0 if kind == "js" else 4]) + data[:200000]) + if kind == "map": + put(out, "text", "map%d" % index, bytes([1]) + data) + put(out, "text", "inflate", bytes([3]) + zlib.compress(b"hello cortex " * 64)[2:-4]) + put(out, "text", "dataurl", bytes([2]) + b"data:application/json;base64," + + base64.b64encode(b'{"version":3,"sources":["a.ts"],"mappings":"AAAA"}')) + put(out, "text", "map", bytes([1]) + json.dumps( + {"version": 3, "sources": ["a.ts"], "names": [], "mappings": "AAAA;AACA,CAAC"}).encode()) + # Live protocols (fuzz_protocols): mode byte, then the stream. + def ws_frame(payload, opcode=1, fin=True): + head = bytes([(0x80 if fin else 0) | opcode]) + n = len(payload) + if n < 126: + head += bytes([n]) + elif n < 65536: + head += bytes([126, n >> 8, n & 255]) + else: + head += bytes([127]) + n.to_bytes(8, "big") + return head + payload + paused = json.dumps({"method": "Debugger.paused", "params": {"reason": "other", "hitBreakpoints": ["1:11:0"], + "callFrames": [{"functionName": "applyDamage", "location": {"scriptId": "42", "lineNumber": 11}, + "scopeChain": [{"type": "local", "object": {"objectId": "1"}}]}]}}).encode() + put(out, "protocols", "ws", bytes([0]) + ws_frame(paused[:40], fin=False) + ws_frame(paused[40:], opcode=0) + + ws_frame(b"ping", opcode=9) + ws_frame(json.dumps({"id": 3, "result": {"result": []}}).encode())) + def dap(message): + body = json.dumps(message).encode() + return b"Content-Length: %d\r\n\r\n" % len(body) + body + put(out, "protocols", "dap", bytes([1]) + dap({"seq": 1, "type": "response", "request_seq": 1, "success": True, + "body": {"supportsConfigurationDoneRequest": True}}) + dap({"seq": 2, "type": "event", "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 1, "hitBreakpointIds": [1]}}) + dap({"type": "event", + "event": "output", "body": {"category": "stdout", "output": "h\u00e9llo \u4e16\u754c\n"}})) + def jdwp(packet_id, flags, a, b, data): + return (11 + len(data)).to_bytes(4, "big") + packet_id.to_bytes(4, "big") + bytes([flags, a, b]) + data + put(out, "protocols", "jdwp", bytes([2]) + jdwp(1, 0x80, 0, 0, b"\x00\x00\x00\x08" * 5) + + jdwp(7, 0, 64, 100, b"\x02\x00\x00\x00\x01\x02\x00\x00\x00\x05" + b"\x00" * 33)) + put(out, "protocols", "text", bytes([3]) + "Actor h\u00e9 \u4e16 \U0001F600".encode() + b"\xc0\x80\xed\xa0\x80\xff") + + # Records on disk and knowledge input (fuzz_stores): mode byte, then the file. + evidence = {"schema": "cortex.evidence.v1", "id": "ev_00000000000000000000", "revision": 1, "created_ms": 1, + "class": "hypothesis", "origin": "static", "statement": "CortexDemoApplyDamage writes health", + "subject": {"sha256": "0" * 64, "identity": "client_asserted"}, "references": [], "relations": [], + "stale": False, "history": [], "verification": {"status": "not_checked", "trials": []}} + put(out, "stores", "evidence", bytes([0]) + json.dumps(evidence).encode()) + step = {"title": "Inspect", "tool": "cortex_artifact_inspect", "capability": "static", "effect": "read", + "status": "pending", "runner": "agent", "arguments": {}, "index": 0} + investigation = {"schema": "cortex.investigation.v1", "id": "inv_00000000000000000000", "revision": 1, + "state": "planned", "title": "t", "question": "q", "workflow": "custom", "plan": [step], + "limits": {"max_steps": 32, "max_live_steps": 0, "deadline_minutes": 60}, "deadline_ms": 1, + "live_steps_approved": 0, "evidence": [], "created_ms": 1, "journal": []} + put(out, "stores", "investigation", bytes([1]) + json.dumps(investigation).encode()) + put(out, "stores", "knowledge", bytes([2]) + json.dumps({"id": "demo.health", "revision": 1}).encode() + b"\n" + + json.dumps({"id": "demo.health", "kind": "fact", "statement": "s", "status": "observed", "evidence": [], + "expected_revision": 1}).encode()) + + for corpus in sorted(os.listdir(out)): + print(corpus, len(os.listdir(os.path.join(out, corpus)))) + + +if __name__ == "__main__": + main() diff --git a/tests/fuzz/regressions/fuzz_artifact/.keep b/tests/fuzz/regressions/fuzz_artifact/.keep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fuzz/regressions/fuzz_artifact/typespec-cycle-stack-overflow b/tests/fuzz/regressions/fuzz_artifact/typespec-cycle-stack-overflow new file mode 100644 index 00000000..b2adb8f4 Binary files /dev/null and b/tests/fuzz/regressions/fuzz_artifact/typespec-cycle-stack-overflow differ diff --git a/tests/fuzz/regressions/fuzz_pe_code/.keep b/tests/fuzz/regressions/fuzz_pe_code/.keep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fuzz/regressions/fuzz_pe_code/typespec-cycle-stack-overflow b/tests/fuzz/regressions/fuzz_pe_code/typespec-cycle-stack-overflow new file mode 100644 index 00000000..5f070edd Binary files /dev/null and b/tests/fuzz/regressions/fuzz_pe_code/typespec-cycle-stack-overflow differ diff --git a/tests/fuzz/regressions/fuzz_protocols/.keep b/tests/fuzz/regressions/fuzz_protocols/.keep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fuzz/regressions/fuzz_protocols/clip-bound-after-utf8-repair b/tests/fuzz/regressions/fuzz_protocols/clip-bound-after-utf8-repair new file mode 100644 index 00000000..ae90ebae --- /dev/null +++ b/tests/fuzz/regressions/fuzz_protocols/clip-bound-after-utf8-repair @@ -0,0 +1 @@ +AchtÃr o© 世 Actor hé \ No newline at end of file diff --git a/tests/fuzz/regressions/fuzz_stores/.keep b/tests/fuzz/regressions/fuzz_stores/.keep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fuzz/regressions/fuzz_stores/evidence-record-without-subject b/tests/fuzz/regressions/fuzz_stores/evidence-record-without-subject new file mode 100644 index 00000000..f6c56c64 Binary files /dev/null and b/tests/fuzz/regressions/fuzz_stores/evidence-record-without-subject differ diff --git a/tests/fuzz/regressions/fuzz_stores/investigation-step-without-status b/tests/fuzz/regressions/fuzz_stores/investigation-step-without-status new file mode 100644 index 00000000..63701f33 --- /dev/null +++ b/tests/fuzz/regressions/fuzz_stores/investigation-step-without-status @@ -0,0 +1 @@ +{"id": "inv_00000000000000000000", "state": "planned", "revision": 1, "title": "x", "steps": [{"kind": "static", "tool": "cortex_artifact_inspect", "state": "pending", "needs_approval": true}], "journal": []} \ No newline at end of file diff --git a/tests/fuzz/regressions/fuzz_text/.keep b/tests/fuzz/regressions/fuzz_text/.keep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fuzz/replay_main.cpp b/tests/fuzz/replay_main.cpp new file mode 100644 index 00000000..d7c2802d --- /dev/null +++ b/tests/fuzz/replay_main.cpp @@ -0,0 +1,33 @@ +// Replays every file of the directories (or files) given on the command +// line through LLVMFuzzerTestOneInput, without libFuzzer. Used by ctest to +// keep inputs that once crashed a parser as regression tests. +#include +#include +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size); + +static void Run(const std::filesystem::path& path, size_t& count) { + std::ifstream in(path, std::ios::binary); + std::vector bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + LLVMFuzzerTestOneInput(bytes.data(), bytes.size()); + ++count; +} + +int main(int argc, char** argv) { + size_t count = 0; + for (int i = 1; i < argc; ++i) { + std::filesystem::path root(argv[i]); + if (std::filesystem::is_directory(root)) { + for (const auto& entry : std::filesystem::recursive_directory_iterator(root)) + if (entry.is_regular_file()) Run(entry.path(), count); + } else if (std::filesystem::exists(root)) { + Run(root, count); + } + } + std::printf("replayed %zu inputs\n", count); + return 0; +} diff --git a/tests/fuzz/seeds/pe/README.md b/tests/fuzz/seeds/pe/README.md new file mode 100644 index 00000000..7291a4ea --- /dev/null +++ b/tests/fuzz/seeds/pe/README.md @@ -0,0 +1,8 @@ +# PE seeds + +`tiny64.exe` and `tiny86.exe` are built from `tiny.c` with no C runtime, so +they stay a few kilobytes and the PE/native-code fuzzer spends its time on +the parser rather than on decoding a large binary: + + x86_64-w64-mingw32-gcc -O1 -nostdlib -e entry -s tiny.c -o tiny64.exe + i686-w64-mingw32-gcc -O1 -nostdlib -e _entry -s tiny.c -o tiny86.exe diff --git a/tests/fuzz/seeds/pe/tiny.c b/tests/fuzz/seeds/pe/tiny.c new file mode 100644 index 00000000..c85c14d2 --- /dev/null +++ b/tests/fuzz/seeds/pe/tiny.c @@ -0,0 +1,4 @@ +__declspec(dllexport) int Add(int a, int b) { return a + b; } +__declspec(dllexport) int Loop(int n) { int s = 0; for (int i = 0; i < n; ++i) { if (i & 1) s += i; else s ^= i; } return s; } +static const char msg[] = "cortex tiny fixture"; +int entry(void) { return Loop(Add(3, 4)) + (int)sizeof(msg); } diff --git a/tests/fuzz/seeds/pe/tiny64.exe b/tests/fuzz/seeds/pe/tiny64.exe new file mode 100755 index 00000000..c64bb4cb Binary files /dev/null and b/tests/fuzz/seeds/pe/tiny64.exe differ diff --git a/tests/fuzz/seeds/pe/tiny86.exe b/tests/fuzz/seeds/pe/tiny86.exe new file mode 100755 index 00000000..277656ea Binary files /dev/null and b/tests/fuzz/seeds/pe/tiny86.exe differ diff --git a/tests/gui/run_imgui_gui_suite.ps1 b/tests/gui/run_imgui_gui_suite.ps1 index 730f60fe..4e93a238 100644 --- a/tests/gui/run_imgui_gui_suite.ps1 +++ b/tests/gui/run_imgui_gui_suite.ps1 @@ -115,7 +115,10 @@ $x86 = $null $mcp = $null try { + # The Artifacts workspace also opens the C# fixture's .NET tab and CIL. + $env:CORTEX_GUI_SUITE_DOTNET = (Resolve-Path (Join-Path $PSScriptRoot "../fixtures/dotnet/Cortex.Fixture.dll")).Path Invoke-CortexProcess "workspace-layout" @("--gui-workspace-suite") 30000 | Out-Null + Remove-Item Env:CORTEX_GUI_SUITE_DOTNET Invoke-CortexProcess "native-window-d3d11" @("--window-smoke-test") 30000 | Out-Null Invoke-CortexProcess "ai-activity" @("--ai-activity-smoke") 30000 | Out-Null diff --git a/tests/investigation_context_tests.cpp b/tests/investigation_context_tests.cpp new file mode 100644 index 00000000..0b23a521 --- /dev/null +++ b/tests/investigation_context_tests.cpp @@ -0,0 +1,399 @@ +// The shared investigation context (core/context) and its route semantics +// (context_api), without a target process: identity, module+RVA location, +// caller provenance, validation and bounds, typed events, process-generation +// isolation and the common result contract. + +#include "context/caller.h" +#include "context/context_api.h" +#include "context/investigation_context.h" +#include "api/response_contract.h" + +#include +#include +#include +#include +#include + +namespace { + +using context::Caller; +using context::CallerInfo; +using context::InvestigationContext; +using json = nlohmann::json; + +int failures = 0; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + ++failures; \ + std::cerr << __FILE__ << ":" << __LINE__ << ": CHECK failed: " #condition \ + << std::endl; \ + } \ + } while (false) + +struct Published { + std::string type; + json data; +}; + +constexpr uint64_t kGeneration = 133700000000000001ull; + +void Prepare(InvestigationContext& shared, std::vector& events, uint64_t& clock) { + shared.SetIdentity({4242, kGeneration, "x64", "game.exe", "C:/Games/game.exe"}); + shared.SetPublisher([&events](const std::string& type, const json& data) { events.push_back({type, data}); }); + shared.SetClock([&clock] { return clock; }); + shared.UpdateModules({{"game.exe", 0x140000000ull, 0x200000}, {"engine.dll", 0x7ff800000000ull, 0x100000}}); +} + +const CallerInfo kDesktop{Caller::Desktop, "desktop"}; +const CallerInfo kAgent{Caller::Agent, "mcp-session-7"}; + +void TestCallerScoping() { + CHECK(context::CurrentCaller().caller == Caller::Unattributed); + { + context::ScopedCaller outer(Caller::Agent, "session-a"); + CHECK(context::CurrentCaller().caller == Caller::Agent); + CHECK(context::CurrentCaller().session == "session-a"); + { + context::ScopedCaller inner(Caller::Desktop, "desktop"); + CHECK(context::CurrentCaller().caller == Caller::Desktop); + } + CHECK(context::CurrentCaller().caller == Caller::Agent); + // Another thread (another pipe worker) never inherits this caller. + Caller seen = Caller::Desktop; + std::thread([&seen] { seen = context::CurrentCaller().caller; }).join(); + CHECK(seen == Caller::Unattributed); + } + CHECK(context::CurrentCaller().caller == Caller::Unattributed); + CHECK(std::string(context::CallerName(Caller::Agent)) == "agent"); +} + +void TestLocateAndModuleEvents() { + InvestigationContext shared; + std::vector events; + uint64_t clock = 1000; + Prepare(shared, events, clock); + CHECK(events.empty()); // the first module list is the baseline + + const json inside = shared.Locate(0x140001234ull); + CHECK(inside.is_object() && inside["expression"] == "game.exe+0x1234" && inside["rva"] == "0x1234"); + CHECK(shared.Locate(0x140200000ull).is_null()); // one past the end + CHECK(shared.Locate(0x10).is_null()); + + const json diff = shared.UpdateModules({{"game.exe", 0x140000000ull, 0x200000}, {"plugin.dll", 0x180000000ull, 0x4000}}); + CHECK(diff["loaded"].size() == 1 && diff["loaded"][0]["name"] == "plugin.dll"); + CHECK(diff["unloaded"].size() == 1 && diff["unloaded"][0]["name"] == "engine.dll"); + CHECK(events.size() == 2); + bool sawLoaded = false, sawUnloaded = false; + for (const auto& event : events) { + CHECK(event.data["pid"] == 4242 && event.data["generation"] == kGeneration); + sawLoaded = sawLoaded || (event.type == "module.loaded" && event.data["name"] == "plugin.dll"); + sawUnloaded = sawUnloaded || (event.type == "module.unloaded" && event.data["name"] == "engine.dll"); + } + CHECK(sawLoaded && sawUnloaded); +} + +void TestSelectionAndObservation() { + InvestigationContext shared; + std::vector events; + uint64_t clock = 5000; + Prepare(shared, events, clock); + + // The person selects an address through the desktop channel. + auto reply = context::api::SelectAddress(shared, kDesktop, + R"({"address":"0x140001234","label":"health","expected_generation":133700000000000001})"); + CHECK(reply.status == 200); + CHECK(reply.body["ok"] == true && reply.body["status"] == "completed"); + CHECK(reply.body["target"]["pid"] == 4242 && reply.body["target"]["generation"] == kGeneration); + const json selection = reply.body["data"]["entry"]; + CHECK(selection["kind"] == "selection" && selection["address"] == "0x140001234"); + CHECK(selection["location"]["expression"] == "game.exe+0x1234"); + CHECK(selection["provenance"]["source"] == "desktop" && selection["provenance"]["recorded_ms"] == 5000); + CHECK(selection["status"] == "pointer_only"); + CHECK(reply.body["evidence"].size() == 1 && reply.body["evidence"][0] == selection["id"]); + CHECK(!events.empty() && events.back().type == "context.address_selected" && + events.back().data["source"] == "desktop"); + + // An agent cannot impersonate the person's selection. + reply = context::api::SelectAddress(shared, kAgent, R"({"address":"0x140001234"})"); + CHECK(reply.status == 403 && reply.body["status"] == "refused" && reply.body["error"] == "desktop_channel_required"); + reply = context::api::SelectAddress(shared, CallerInfo{}, R"({"address":"0x140001234"})"); + CHECK(reply.status == 403); + + // The agent reads it and records an observation; it stays an assertion. + clock = 6000; + reply = context::api::AddObservation( + shared, kAgent, + R"({"statement":"Value drops by 10 when hit","kind":"correlation","address":"0x140001234", + "data":{"before":100,"after":90},"evidence":[{"source":"cortex_test","reference":"test-0003"}]})"); + CHECK(reply.status == 200); + const json observation = reply.body["data"]["entry"]; + CHECK(observation["kind"] == "correlation" && observation["status"] == "client_assertion"); + CHECK(observation["provenance"]["source"] == "agent" && observation["provenance"]["session"] == "mcp-session-7"); + CHECK(observation["location"]["expression"] == "game.exe+0x1234"); + CHECK(reply.body["permissions_required"] == json::array({"state"})); + CHECK(events.back().type == "context.observation_added" && events.back().data["kind"] == "correlation"); + + // Both are in one snapshot that either side reads. + reply = context::api::GetContext(shared, 100, 0, kGeneration, 77); + CHECK(reply.status == 200); + const json data = reply.body["data"]; + CHECK(data["schema"] == "cortex.context.v1"); + CHECK(data["entries"].size() == 2); + CHECK(data["selections"]["desktop"]["id"] == selection["id"]); + CHECK(!data["selections"].contains("agent")); + CHECK(data["latest_event_id"] == 77); + CHECK(data["target"]["process_name"] == "game.exe"); + + // `since` returns only newer entries. + reply = context::api::GetContext(shared, 100, selection["sequence"].get(), 0, 0); + CHECK(reply.body["data"]["entries"].size() == 1 && reply.body["data"]["entries"][0]["id"] == observation["id"]); +} + +void TestValidation() { + InvestigationContext shared; + std::vector events; + uint64_t clock = 1; + Prepare(shared, events, clock); + + auto expectFailure = [&](const std::string& body, const char* error) { + const auto reply = context::api::AddObservation(shared, kAgent, body); + CHECK(reply.status == 400); + CHECK(reply.body["ok"] == false && reply.body["error"] == error); + }; + expectFailure("not json", "invalid_json_body"); + expectFailure("[1,2]", "invalid_json_body"); + expectFailure(R"({})", "invalid_statement"); + expectFailure(R"({"statement":""})", "invalid_statement"); + expectFailure(R"({"statement":"x","kind":"fact"})", "invalid_kind"); // no self-promotion + expectFailure(R"({"statement":"x","status":"observed"})", "unknown_field"); // status is server-owned + expectFailure(R"({"statement":"x","address":"-1"})", "invalid_address"); + expectFailure(R"({"statement":"x","address":0})", "invalid_address"); + expectFailure(R"({"statement":"x","evidence":[{"source":"a"}]})", "invalid_evidence"); + expectFailure(R"({"statement":"x","expected_generation":"soon"})", "invalid_expected_generation"); + expectFailure(json{{"statement", std::string(2049, 'a')}}.dump(), "invalid_statement"); + expectFailure(json{{"statement", "x"}, {"data", {{"blob", std::string(5000, 'b')}}}}.dump(), "data_too_large"); + json tooMany = {{"statement", "x"}, {"evidence", json::array()}}; + for (int i = 0; i < 17; ++i) tooMany["evidence"].push_back({{"source", "s"}, {"reference", "r"}}); + expectFailure(tooMany.dump(), "invalid_evidence"); + + auto reply = context::api::SelectAddress(shared, kDesktop, R"({"address":"nowhere"})"); + CHECK(reply.status == 400 && reply.body["error"] == "invalid_address"); + reply = context::api::SelectAddress(shared, kDesktop, R"({"address":0})"); + CHECK(reply.status == 400 && reply.body["error"] == "invalid_address"); + reply = context::api::SelectAddress(shared, kDesktop, R"({"address":"0x10","label":7})"); + CHECK(reply.status == 400 && reply.body["error"] == "invalid_text"); + reply = context::api::SelectAddress(shared, kDesktop, json{{"address", "0x10"}, {"label", std::string(200, 'l')}}.dump()); + CHECK(reply.status == 400 && reply.body["error"] == "text_too_long"); + + // Nothing was recorded by the refused calls. + CHECK(shared.Snapshot()["entries"].empty()); + CHECK(events.empty()); +} + +void TestGenerationIsolation() { + InvestigationContext shared; + std::vector events; + uint64_t clock = 1; + Prepare(shared, events, clock); + + // A client still holding the generation of an exited process (PID + // reused) is refused before anything is recorded. + const uint64_t stale = kGeneration - 1; + auto reply = context::api::GetContext(shared, 10, 0, stale, 0); + CHECK(reply.status == 409 && reply.body["error"] == "stale_target_generation"); + CHECK(reply.body["target"]["generation"] == kGeneration); + reply = context::api::AddObservation(shared, kAgent, json{{"statement", "x"}, {"expected_generation", stale}}.dump()); + CHECK(reply.status == 409); + reply = context::api::SelectAddress(shared, kDesktop, json{{"address", "0x10"}, {"expected_generation", stale}}.dump()); + CHECK(reply.status == 409); + CHECK(shared.Snapshot()["entries"].empty()); + + // 0 means "not pinned" and is accepted. + reply = context::api::GetContext(shared, 10, 0, 0, 0); + CHECK(reply.status == 200); +} + +void TestBoundsAndHistory() { + context::Limits limits; + limits.maxEntries = 3; + limits.maxHistory = 2; + InvestigationContext shared(limits); + std::vector events; + uint64_t clock = 1; + Prepare(shared, events, clock); + + for (int i = 0; i < 5; ++i) + CHECK(shared.AddObservation(kAgent, {{"statement", "note " + std::to_string(i)}, {"kind", "note"}})["ok"] == true); + json snapshot = shared.Snapshot(10); + CHECK(snapshot["entries"].size() == 3); + CHECK(snapshot["entries"][0]["statement"] == "note 2"); + CHECK(snapshot["dropped_entries"] == 2); + CHECK(snapshot["latest_entry"] == 5); + CHECK(shared.Snapshot(2)["entries"].size() == 2); + CHECK(shared.Snapshot(2)["entries_truncated"] == true); + + shared.RecordOperation(kAgent, "memory_read", "analyze", "completed", 3); + shared.RecordOperation(kDesktop, "patch_write", "mutate", "refused", 0); + shared.RecordOperation(kAgent, "context_get", "observe", "completed", 1); + snapshot = shared.Snapshot(); + CHECK(snapshot["history"].size() == 2); + CHECK(snapshot["history"][0]["tool"] == "patch_write" && snapshot["history"][0]["provenance"]["source"] == "desktop"); + CHECK(snapshot["history"][1]["sequence"] == 3); + CHECK(snapshot["dropped_history"] == 1); + + shared.Clear(); + CHECK(shared.Snapshot()["entries"].empty() && shared.Snapshot()["history"].empty()); +} + +void TestConcurrentWriters() { + InvestigationContext shared; + std::vector events; + uint64_t clock = 1; + shared.SetIdentity({1, 2, "x64", "a.exe", ""}); + std::vector threads; + for (int t = 0; t < 4; ++t) { + threads.emplace_back([&shared, t] { + const CallerInfo caller{t % 2 ? Caller::Agent : Caller::Desktop, "s" + std::to_string(t)}; + for (int i = 0; i < 200; ++i) { + if (t % 2) shared.AddObservation(caller, {{"statement", "x"}}); + else shared.SelectAddress(caller, 0x1000 + i, "l", ""); + shared.RecordOperation(caller, "tool", "observe", "completed", 0); + (void)shared.Snapshot(5); + } + }); + } + for (auto& thread : threads) thread.join(); + const json snapshot = shared.Snapshot(512); + CHECK(snapshot["latest_entry"] == 800); + CHECK(snapshot["entries"].size() == 512); + CHECK(snapshot["dropped_entries"] == 288); + (void)clock; +} + +void TestSharedDesktopState() { + InvestigationContext shared; + std::vector events; + uint64_t clock = 10; + Prepare(shared, events, clock); + + const json scan = {{"kind", "scan"}, + {"expected_generation", kGeneration}, + {"data", {{"value_type", "i32"}, {"compare", "exact"}, {"value", "100"}, {"result_count", 2}, + {"results", {{{"address", "0x140001000"}, {"value", "64000000"}}, + {{"address", 5368717312ull}, {"value", "64000000"}}}}}}}; + auto reply = context::api::ShareState(shared, kDesktop, scan.dump()); + CHECK(reply.status == 200); + CHECK(events.back().type == "context.state_shared" && events.back().data["kind"] == "scan" && + events.back().data["rows"] == 2); + json state = shared.Snapshot()["shared_state"]; + CHECK(state["scan"]["data"]["results"][0]["location"]["expression"] == "game.exe+0x1000"); + CHECK(state["scan"]["data"]["results"][1]["address"] == "0x140002000"); // normalized + CHECK(state["scan"]["provenance"]["source"] == "desktop" && state["scan"]["status"] == "desktop_state"); + + // The newest share of a kind replaces the previous one. + json next = scan; + next["data"]["results"] = json::array({{{"address", "0x140001000"}, {"value", "5a000000"}}}); + CHECK(context::api::ShareState(shared, kDesktop, next.dump()).status == 200); + state = shared.Snapshot()["shared_state"]; + CHECK(state["scan"]["data"]["results"].size() == 1 && state["scan"]["sequence"] == 2); + + // Only the desktop shares its own state; bounded; validated. + CHECK(context::api::ShareState(shared, kAgent, scan.dump()).status == 403); + CHECK(context::api::ShareState(shared, kDesktop, R"({"kind":"memory","data":{}})").status == 400); + CHECK(context::api::ShareState(shared, kDesktop, R"({"kind":"scan","data":{"results":[{"address":"x"}]}})").status == 400); + json many = {{"kind", "scan"}, {"data", {{"results", json::array()}}}}; + for (int i = 0; i < 257; ++i) many["data"]["results"].push_back({{"address", 0x1000 + i}}); + CHECK(context::api::ShareState(shared, kDesktop, many.dump()).status == 400); + json stale = scan; + stale["expected_generation"] = kGeneration + 1; + CHECK(context::api::ShareState(shared, kDesktop, stale.dump()).status == 409); + + // Debugger state: the first share is the hit baseline, then increases + // become breakpoint.hit events; a breakpoint added later counts from 0. + auto debuggerShare = [&](uint64_t hitsA, bool withB) { + json data = {{"backend", "windows"}, + {"breakpoints", {{{"id", 1}, {"kind", "hw_write"}, {"address", "0x140001000"}, {"hit_count", hitsA}}}}}; + if (withB) data["breakpoints"].push_back({{"id", 2}, {"kind", "software"}, {"address", "0x140001100"}, {"hit_count", 3}}); + return context::api::ShareState(shared, kDesktop, json{{"kind", "debugger"}, {"data", data}}.dump()); + }; + events.clear(); + CHECK(debuggerShare(5, false).status == 200); + size_t hits = 0; + for (const auto& event : events) hits += event.type == "breakpoint.hit"; + CHECK(hits == 0); + events.clear(); + CHECK(debuggerShare(9, true).status == 200); + json hitA, hitB; + for (const auto& event : events) { + if (event.type != "breakpoint.hit") continue; + if (event.data["id"] == "1") hitA = event.data; + if (event.data["id"] == "2") hitB = event.data; + } + CHECK(hitA["new_hits"] == 4 && hitA["hits"] == 9 && hitA["debugger"] == "desktop"); + CHECK(hitA["location"]["expression"] == "game.exe+0x1000"); + CHECK(hitB["new_hits"] == 3); + events.clear(); + CHECK(debuggerShare(9, true).status == 200); + for (const auto& event : events) CHECK(event.type != "breakpoint.hit"); +} + +void TestBreakpointHitPolling() { + InvestigationContext shared; + std::vector events; + uint64_t clock = 1; + Prepare(shared, events, clock); + CHECK(shared.UpdateBreakpointHits("runtime", {{"3", 0x140001000ull, 10}}) == 0); // baseline + CHECK(shared.UpdateBreakpointHits("runtime", {{"3", 0x140001000ull, 10}}) == 0); + CHECK(shared.UpdateBreakpointHits("runtime", {{"3", 0x140001000ull, 12}, {"4", 0x140001004ull, 1}}) == 2); + CHECK(events.back().data["pid"] == 4242); + // Removed, then re-added under the same id: counts from zero again. + CHECK(shared.UpdateBreakpointHits("runtime", {}) == 0); + CHECK(shared.UpdateBreakpointHits("runtime", {{"3", 0x140001000ull, 2}}) == 1); + CHECK(events.back().data["new_hits"] == 2); + // A counter that went down (breakpoint replaced) publishes nothing. + CHECK(shared.UpdateBreakpointHits("runtime", {{"3", 0x140001000ull, 1}}) == 0); + // Debuggers are independent. + CHECK(shared.UpdateBreakpointHits("desktop", {{"3", 0x140001000ull, 50}}) == 0); +} + +void TestAnnotateLegacyResult() { + api::response::Operation meta; + meta.target = {{"pid", 1}, {"generation", 2}}; + meta.durationMs = 7; + meta.permissionsRequired = json::array({"control"}); + meta.rollback = {{"tool", "debug_breakpoint_delete"}}; + json ok = {{"ok", true}, {"id", 3}, {"limits", "route-owned"}}; + api::response::Annotate(ok, meta); + CHECK(ok["status"] == "completed" && ok["id"] == 3 && ok["target"]["generation"] == 2); + CHECK(ok["limits"] == "route-owned"); // never overwritten + CHECK(ok["duration_ms"] == 7 && ok["permissions_required"][0] == "control" && ok["rollback"]["tool"] == "debug_breakpoint_delete"); + json failed = {{"ok", false}, {"error", "scan_not_found"}}; + api::response::Annotate(failed, meta); + CHECK(failed["status"] == "failed" && failed["error"] == "scan_not_found"); + json withStatus = {{"ok", false}, {"status", "cancelled"}}; + api::response::Annotate(withStatus, meta); + CHECK(withStatus["status"] == "cancelled"); +} + +} // namespace + +int main() { + TestCallerScoping(); + TestLocateAndModuleEvents(); + TestSelectionAndObservation(); + TestValidation(); + TestGenerationIsolation(); + TestBoundsAndHistory(); + TestConcurrentWriters(); + TestSharedDesktopState(); + TestBreakpointHitPolling(); + TestAnnotateLegacyResult(); + if (failures) { + std::cerr << failures << " investigation context check(s) failed" << std::endl; + return 1; + } + std::cout << "investigation context tests passed" << std::endl; + return 0; +} diff --git a/tests/investigation_e2e.py b/tests/investigation_e2e.py new file mode 100644 index 00000000..63eb8d1a --- /dev/null +++ b/tests/investigation_e2e.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""E2E: investigations through the shipped cortex.exe (step 6). + + 1. tools/list has the investigation tools and no approval tool; + 2. a function_static investigation runs end to end: Cortex runs the static + steps, the agent records verified evidence, the investigation completes + citing it; + 3. a memory_value investigation waits for approval; the agent's result is + refused until the person approves with `cortex.exe investigation + approve`; the journal shows who approved; + 4. the CLI lists the investigations and prints the report. + + python tests/investigation_e2e.py --cortex stage/Cortex/cortex.exe \\ + --target stage/Cortex/e2e/cortex_test_target_x64.exe [--runner wine] +""" + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from artifact_mcp_e2e import Mcp, windows_path # noqa: E402 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--cortex", required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--runner", default="") + arguments = parser.parse_args() + runner = shlex.split(arguments.runner) + cortex = os.path.abspath(arguments.cortex) + bundle = os.path.dirname(cortex) + failures = [] + + def check(condition, message, detail=None): + print(("PASS " if condition else "FAIL ") + message) + if not condition: + failures.append(message) + if detail is not None: + print(" " + json.dumps(detail)[:800]) + + work = tempfile.mkdtemp(prefix="cortex-investigation-e2e-") + try: + root = os.path.join(work, "root") + os.makedirs(root) + target = os.path.join(root, "game.exe") + shutil.copyfile(arguments.target, target) + path = windows_path(runner, target) + stores = ["--artifact-root", windows_path(runner, root), + "--evidence-store", windows_path(runner, os.path.join(work, "evidence")), + "--investigation-store", windows_path(runner, os.path.join(work, "investigations"))] + server = Mcp(runner + [cortex, "mcp", "--tools", "all", "--allow-state"] + stores, bundle) + server.initialize() + tools = {t["name"] for t in (server.request("tools/list", {}) or {}).get("result", {}).get("tools", [])} + check({"cortex_investigation_create", "cortex_investigation_run_step", "cortex_investigation_step_result", + "cortex_investigation_conclude"} <= tools and not any("approve" in name for name in tools), + "investigation tools listed, no approval tool") + + def call(name, args): + failed, result = server.call(name, dict(args, mutation_permission=True) if name not in ( + "cortex_investigation_get", "cortex_investigation_list", "cortex_investigation_report", + "cortex_investigation_templates") else args) + return failed, result + + def revision(identifier): + return call("cortex_investigation_get", {"id": identifier})[1]["investigation"]["revision"] + + # Static investigation end to end. + failed, created = call("cortex_investigation_create", { + "title": "CortexDemoApplyDamage", "question": "Is CortexDemoApplyDamage exported, and who calls it?", + "workflow": "function_static", "parameters": {"path": path, "function": "CortexDemoApplyDamage"}}) + first = created.get("investigation", {}).get("id", "") + call("cortex_investigation_start", {"id": first, "expected_revision": 1}) + ran = [call("cortex_investigation_run_step", {"id": first, "index": i, "expected_revision": revision(first)}) + for i in range(3)] + check(all(not f and r["investigation"]["plan"][i]["status"] == "done" for i, (f, r) in enumerate(ran)), + "Cortex ran the three static steps") + failed, catalog = server.call("cortex_artifact_functions", {"path": path, "filter": "CortexDemoApplyDamage"}) + rva = catalog["functions"][0]["rva"] + failed, evidence = server.call("cortex_evidence_record", { + "statement": "CortexDemoApplyDamage is exported at this RVA", "class": "verified_observation", "origin": "static", + "subject": {"path": path, "location": "rva:" + rva}, + "produced_by": {"tool": "cortex_artifact_functions", "arguments": {"path": path, "filter": "CortexDemoApplyDamage"}, + "expect": [{"pointer": "/functions/0/rva", "value": rva}]}, + "mutation_permission": True}) + evidence_id = evidence.get("record", {}).get("id", "") + failed, result = call("cortex_investigation_step_result", { + "id": first, "index": 3, "expected_revision": revision(first), "status": "done", + "summary": "recorded the export", "references": [{"kind": "evidence", "id": evidence_id}]}) + check(not failed, "agent step recorded with checked evidence", result) + failed, result = call("cortex_investigation_conclude", { + "id": first, "expected_revision": revision(first), "state": "completed", + "conclusion": "Exported; CFG and callers recorded."}) + check(not failed and result["investigation"]["state"] == "completed" and result["investigation"]["evidence"] == [evidence_id], + "investigation completed citing its evidence") + + # Live investigation: approval comes from the person's CLI. + failed, created = call("cortex_investigation_create", { + "title": "Health", "question": "Which instruction writes health?", "workflow": "memory_value", + "parameters": {"value": "100"}}) + second = created.get("investigation", {}).get("id", "") + call("cortex_investigation_start", {"id": second, "expected_revision": 1}) + call("cortex_investigation_step_result", {"id": second, "index": 0, "expected_revision": revision(second), + "status": "done", "summary": "3 candidates", + "references": [{"kind": "tool_call", "id": "scan_new"}]}) + failed, state = call("cortex_investigation_get", {"id": second}) + check(state["investigation"]["state"] == "awaiting_approval", "live input step waits for approval") + failed, result = call("cortex_investigation_step_result", {"id": second, "index": 1, "expected_revision": revision(second), + "status": "done", "summary": "pressed D"}) + check(failed and result.get("error") == "approval_required", "agent result refused before approval") + store = windows_path(runner, os.path.join(work, "investigations")) + approve = subprocess.run(runner + [cortex, "investigation", "approve", second, "1", "--note", "D key once", + "--store", store], cwd=bundle, capture_output=True, text=True, timeout=120) + check(approve.returncode == 0, "person approves with cortex.exe investigation approve", approve.stdout[-400:]) + failed, result = call("cortex_investigation_step_result", {"id": second, "index": 1, "expected_revision": revision(second), + "status": "done", "summary": "health 100 -> 94", + "references": [{"kind": "tool_call", "id": "cortex_test_run"}]}) + approvals = [e for e in result.get("investigation", {}).get("journal", []) if e["event"] == "approved"] + check(not failed and approvals and approvals[0]["actor"] == "cli", "result accepted after the person's approval", + result) + server.close() + + listing = subprocess.run(runner + [cortex, "investigation", "list", "--store", store], cwd=bundle, + capture_output=True, text=True, timeout=120) + rows = json.loads(listing.stdout).get("investigations", []) if listing.stdout.strip().startswith("{") else [] + check(len(rows) == 2, "cortex.exe investigation list") + report = subprocess.run(runner + [cortex, "investigation", "report", first, "--store", store], cwd=bundle, + capture_output=True, text=True, timeout=120) + check(report.returncode == 0 and "**completed**" in report.stdout and evidence_id in report.stdout, + "cortex.exe investigation report") + finally: + shutil.rmtree(work, ignore_errors=True) + + if failures: + print("%d investigation E2E check(s) failed" % len(failures)) + return 1 + print("investigation E2E passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/investigation_tests.cpp b/tests/investigation_tests.cpp new file mode 100644 index 00000000..2f4be730 --- /dev/null +++ b/tests/investigation_tests.cpp @@ -0,0 +1,214 @@ +// Investigations (step 6): states, plan order, server-run static steps, +// approvals that only a person can give, limits, deadline, conclusions that +// cite real evidence, persistence and the report. +// +// cortex_investigation_tests + +#include "../mcp_bridge/investigation_tools.h" + +#include +#include + +namespace { + +namespace fs = std::filesystem; +namespace inv = cortex::investigation; +using json = nlohmann::json; + +int failures = 0; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + ++failures; \ + std::cerr << __FILE__ << ":" << __LINE__ << ": CHECK failed: " #condition \ + << std::endl; \ + } \ + } while (false) + +void CopyFile(const fs::path& from, const fs::path& to) { + std::ifstream in(from, std::ios::binary); + std::ofstream out(to, std::ios::binary | std::ios::trunc); + out << in.rdbuf(); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 4) { + std::cerr << "usage: cortex_investigation_tests " << std::endl; + return 2; + } + const fs::path base = fs::temp_directory_path() / + ("cortex-investigation-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + const fs::path root = base / "root"; + fs::create_directories(root); + const fs::path target = root / "game.exe", asar = root / "app.asar", addon = root / "addon.node"; + CopyFile(fs::u8path(argv[1]), target); + CopyFile(fs::u8path(argv[2]), asar); + CopyFile(fs::u8path(argv[3]), addon); + std::vector problems; + const auto roots = mcp_bridge::artifacts::CanonicalRoots({root.u8string()}, problems); + + cortex::evidence::Store evidence(base / "evidence"); + inv::Store store(base / "investigations"); + const mcp_bridge::evidence::TrialLookup trials = [](const std::string& id) -> json { + if (id == "test_7_x64_1") return {{"ok", true}, {"run", {{"id", id}, {"status", "completed"}, {"outcome", "observed"}}}}; + return {{"ok", false}, {"error", "test_not_found"}}; + }; + const inv::Services services = mcp_bridge::investigation::MakeServices(roots, trials, &evidence); + auto call = [&](const char* tool, json arguments) { + if (mcp_bridge::investigation::IsWriter(tool)) arguments["mutation_permission"] = true; + return mcp_bridge::investigation::Handle(tool, arguments, &store, services); + }; + auto revisionOf = [&](const std::string& id) { return call("cortex_investigation_get", {{"id", id}})["investigation"]["revision"].get(); }; + + // Creation checks. + CHECK(call("cortex_investigation_create", {{"title", "t"}, {"workflow", "function_static"}})["error"] == "question_required"); + CHECK(call("cortex_investigation_create", {{"title", "t"}, {"question", "q"}, {"workflow", "function_static"}, + {"parameters", {{"path", target.u8string()}}}})["error"] == "missing_parameter"); + CHECK(call("cortex_investigation_create", {{"title", "t"}, {"question", "q"}, {"workflow", "custom"}, + {"plan", json::array({{{"title", "x"}, {"tool", "memory_write"}, {"capability", "static"}, + {"effect", "read"}, {"runner", "server"}}})}})["error"] == "invalid_plan"); + CHECK(mcp_bridge::investigation::Handle("cortex_investigation_create", {{"title", "t"}, {"question", "q"}}, &store, services)["error"] == + "mutation_permission_required"); + + // A. Static function workflow, end to end, with real evidence. + json created = call("cortex_investigation_create", {{"title", "What does CortexDemoApplyDamage touch?"}, + {"question", "Which field does CortexDemoApplyDamage write?"}, + {"workflow", "function_static"}, + {"parameters", {{"path", target.u8string()}, {"function", "CortexDemoApplyDamage"}}}}); + CHECK(created["ok"] == true && created["investigation"]["state"] == "planned" && created["investigation"]["plan"].size() == 4); + const std::string a = created["investigation"].value("id", std::string()); + CHECK(inv::ValidId(a)); + CHECK(call("cortex_investigation_run_step", {{"id", a}, {"index", 0}, {"expected_revision", 1}})["error"] == "not_started"); + CHECK(call("cortex_investigation_start", {{"id", a}, {"expected_revision", 2}})["error"] == "revision_conflict"); + CHECK(call("cortex_investigation_start", {{"id", a}, {"expected_revision", 1}})["investigation"]["state"] == "running"); + CHECK(call("cortex_investigation_run_step", {{"id", a}, {"index", 1}, {"expected_revision", 2}})["error"] == "not_next_step"); + for (int step = 0; step < 3; ++step) { + const json ran = call("cortex_investigation_run_step", {{"id", a}, {"index", step}, {"expected_revision", revisionOf(a)}}); + CHECK(ran["ok"] == true && ran["investigation"]["plan"][step]["status"] == "done" && + ran["investigation"]["plan"][step]["result"]["by"] == "cortex"); + } + json state = call("cortex_investigation_get", {{"id", a}})["investigation"]; + CHECK(state["plan"][1]["result"]["output"]["ok"] == true && !state["plan"][1]["result"]["output"]["blocks"].empty()); + CHECK(call("cortex_investigation_run_step", {{"id", a}, {"index", 3}, {"expected_revision", revisionOf(a)}})["error"] == "agent_step"); + // The agent records evidence itself (Cortex verifies it), then the step. + const json catalog = mcp_bridge::artifacts::Handle("cortex_artifact_functions", {{"path", target.u8string()}, {"filter", "CortexDemoApplyDamage"}}, roots); + const std::string rva = catalog["functions"][0]["rva"]; + const json recorded = mcp_bridge::evidence::Handle( + "cortex_evidence_record", + {{"statement", "CortexDemoApplyDamage is exported at this RVA"}, {"class", "verified_observation"}, {"origin", "static"}, + {"subject", {{"path", target.u8string()}, {"location", "rva:" + rva}}}, + {"produced_by", {{"tool", "cortex_artifact_functions"}, {"arguments", {{"path", target.u8string()}, {"filter", "CortexDemoApplyDamage"}}}, + {"expect", json::array({{{"pointer", "/functions/0/rva"}, {"value", rva}}})}}}, + {"mutation_permission", true}}, + &evidence, roots, trials); + const std::string evidenceId = recorded["record"].value("id", std::string()); + CHECK(recorded["ok"] == true); + CHECK(call("cortex_investigation_step_result", {{"id", a}, {"index", 3}, {"expected_revision", revisionOf(a)}, {"status", "done"}, + {"summary", "recorded"}, {"references", json::array({{{"kind", "evidence"}, {"id", "ev_00000000000000000000"}}})}})["error"] == + "reference_not_found"); + CHECK(call("cortex_investigation_step_result", {{"id", a}, {"index", 3}, {"expected_revision", revisionOf(a)}, {"status", "done"}, + {"summary", "recorded the export as verified evidence"}, + {"references", json::array({{{"kind", "evidence"}, {"id", evidenceId}}})}})["ok"] == true); + json concluded = call("cortex_investigation_conclude", {{"id", a}, {"expected_revision", revisionOf(a)}, {"state", "completed"}, + {"conclusion", "The function is exported; its CFG and callers are recorded."}}); + CHECK(concluded["ok"] == true && concluded["investigation"]["state"] == "completed" && + concluded["investigation"]["evidence"] == json::array({evidenceId})); + CHECK(call("cortex_investigation_cancel", {{"id", a}, {"expected_revision", revisionOf(a)}, {"reason", "x"}})["error"] == "investigation_closed"); + const std::string report = call("cortex_investigation_report", {{"id", a}}).value("document", std::string()); + CHECK(report.find("**completed**") != std::string::npos && report.find("`cortex_artifact_cfg`") != std::string::npos && + report.find(evidenceId) != std::string::npos && report.find("not proof") != std::string::npos); + + // B. Live workflow: approvals come only from the person; limits hold. + created = call("cortex_investigation_create", {{"title", "Health value"}, {"question", "Which instruction writes health?"}, + {"workflow", "memory_value"}, {"parameters", {{"value", "100"}}}, + {"limits", {{"max_live_steps", 1}}}}); + const std::string b = created["investigation"].value("id", std::string()); + call("cortex_investigation_start", {{"id", b}, {"expected_revision", 1}}); + CHECK(call("cortex_investigation_step_result", {{"id", b}, {"index", 0}, {"expected_revision", revisionOf(b)}, {"status", "done"}, + {"summary", "scan_new found 3 candidates"}, + {"references", json::array({{{"kind", "tool_call"}, {"id", "scan_1"}}})}})["ok"] == true); + state = call("cortex_investigation_get", {{"id", b}})["investigation"]; + CHECK(state["state"] == "awaiting_approval" && state["plan"][1]["status"] == "awaiting_approval"); + CHECK(call("cortex_investigation_step_result", {{"id", b}, {"index", 1}, {"expected_revision", revisionOf(b)}, {"status", "done"}, + {"summary", "pressed D"}})["error"] == "approval_required"); + CHECK(store.Approve(b, 1, true, "", "agent")["error"] == "person_required"); + CHECK(!mcp_bridge::investigation::IsTool("cortex_investigation_approve")); + { const json approved = store.Approve(b, 1, true, "D key only", "cli"); + CHECK(approved["investigation"]["state"] == "running"); } + CHECK(call("cortex_investigation_step_result", {{"id", b}, {"index", 1}, {"expected_revision", revisionOf(b)}, {"status", "done"}, + {"summary", "trial"}, {"references", json::array({{{"kind", "trial"}, {"id", "test_8_x64_1"}}})}})["error"] == + "reference_not_found"); + json trialStep = call("cortex_investigation_step_result", {{"id", b}, {"index", 1}, {"expected_revision", revisionOf(b)}, {"status", "done"}, + {"summary", "health 100 -> 94"}, + {"references", json::array({{{"kind", "trial"}, {"id", "test_7_x64_1"}}})}}); + CHECK(trialStep["ok"] == true && trialStep["investigation"]["plan"][1]["result"]["references"][0]["checked"] == true); + call("cortex_investigation_step_result", {{"id", b}, {"index", 2}, {"expected_revision", revisionOf(b)}, {"status", "done"}, {"summary", "1 candidate"}}); + CHECK(call("cortex_investigation_get", {{"id", b}})["investigation"]["state"] == "awaiting_approval"); + CHECK(store.Approve(b, 3, true, "", "desktop")["error"] == "limit_exceeded"); // max_live_steps 1 + CHECK(store.Approve(b, 3, false, "not now", "desktop")["investigation"]["plan"][3]["status"] == "rejected"); + CHECK(call("cortex_investigation_step_result", {{"id", b}, {"index", 3}, {"expected_revision", revisionOf(b)}, {"status", "done"}, + {"summary", "x"}})["error"] == "approval_required"); + call("cortex_investigation_skip", {{"id", b}, {"index", 3}, {"expected_revision", revisionOf(b)}, {"reason", "breakpoint rejected"}}); + call("cortex_investigation_skip", {{"id", b}, {"index", 4}, {"expected_revision", revisionOf(b)}, {"reason", "nothing to record"}}); + CHECK(call("cortex_investigation_conclude", {{"id", b}, {"expected_revision", revisionOf(b)}, {"state", "completed"}, + {"conclusion", "x"}})["error"] == "evidence_required"); + CHECK(call("cortex_investigation_conclude", {{"id", b}, {"expected_revision", revisionOf(b)}, {"state", "inconclusive"}, + {"conclusion", "the writer was not observed"}})["investigation"]["state"] == "inconclusive"); + const json journal = call("cortex_investigation_get", {{"id", b}})["investigation"]["journal"]; + bool personApproved = false, personRejected = false; + for (const auto& entry : journal) { + personApproved = personApproved || (entry["event"] == "approved" && entry["actor"] == "cli"); + personRejected = personRejected || (entry["event"] == "rejected" && entry["actor"] == "desktop"); + } + CHECK(personApproved && personRejected); + + // C. Cross-runtime chain: Electron app -> native addon, static hops run by Cortex. + created = call("cortex_investigation_create", {{"title", "IPC to native"}, {"question", "Which native code handles settings:get?"}, + {"workflow", "electron_ipc_native"}, + {"parameters", {{"asar", asar.u8string()}, {"addon", addon.u8string()}, {"export", "cortex_shift_target"}}}}); + const std::string c = created["investigation"].value("id", std::string()); + call("cortex_investigation_start", {{"id", c}, {"expected_revision", 1}}); + for (int step = 0; step < 4; ++step) + CHECK(call("cortex_investigation_run_step", {{"id", c}, {"index", step}, {"expected_revision", revisionOf(c)}})["investigation"]["plan"][step]["status"] == "done"); + state = call("cortex_investigation_get", {{"id", c}})["investigation"]; + CHECK(!state["plan"][0]["result"]["output"]["data"]["electron"]["ipc"]["channels"].empty()); + CHECK(state["plan"][2]["result"]["output"]["functions"][0]["name"] == "cortex_shift_target"); + CHECK(state["state"] == "awaiting_approval" && state["plan"][4]["capability"] == "live"); + CHECK(call("cortex_investigation_cancel", {{"id", c}, {"expected_revision", revisionOf(c)}, {"reason", "no running app"}})["investigation"]["state"] == "cancelled"); + + // D. Deadline: a write after it fails the investigation. + created = call("cortex_investigation_create", {{"title", "late"}, {"question", "q"}, {"workflow", "function_static"}, + {"parameters", {{"path", target.u8string()}, {"function", "CortexNativeAdd"}}}}); + const std::string d = created["investigation"].value("id", std::string()); + call("cortex_investigation_start", {{"id", d}, {"expected_revision", 1}}); + { + const fs::path file = base / "investigations" / (d + ".json"); + std::ifstream in(file); + json record = json::parse(in); + in.close(); + record["deadline_ms"] = 1; + std::ofstream(file, std::ios::trunc) << record.dump(); + } + CHECK(call("cortex_investigation_run_step", {{"id", d}, {"index", 0}, {"expected_revision", revisionOf(d)}})["error"] == "deadline_exceeded"); + CHECK(call("cortex_investigation_get", {{"id", d}})["investigation"]["state"] == "failed"); + + // Persistence: another store over the folder reads the same investigations. + inv::Store reopened(base / "investigations"); + CHECK(reopened.Get(a)["investigation"] == store.Get(a)["investigation"]); + const json listed = reopened.List("", 100)["investigations"]; + CHECK(listed.size() == 4); + CHECK(reopened.List("completed", 100)["investigations"].size() == 1); + CHECK(inv::Templates().size() == 3); + + std::error_code ignored; + fs::remove_all(base, ignored); + if (failures) { + std::cerr << failures << " investigation check(s) failed" << std::endl; + return 1; + } + std::cout << "investigation tests passed" << std::endl; + return 0; +} diff --git a/tests/js_tests.cpp b/tests/js_tests.cpp new file mode 100644 index 00000000..f346e88c --- /dev/null +++ b/tests/js_tests.cpp @@ -0,0 +1,419 @@ +// JavaScript/TypeScript, source maps and Electron archives (step 4b). +// +// cortex_js_tests +// +// The fixture app (tests/fixtures/electron-app) is real source; its bundle +// and map come from esbuild and its archive from @electron/asar +// (tests/fixtures/make_electron_fixture.mjs). The expected source-map +// answers are Node's own (node:module SourceMap), for every column. + +#include "artifacts/artifact.h" +#include "artifacts/js_analysis.h" +#include "artifacts/js_lexer.h" +#include "artifacts/js_provider.h" +#include "artifacts/source_map.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; +namespace js = cortex::artifacts::js; +using namespace cortex::artifacts; +using json = nlohmann::json; + +int failures = 0; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + ++failures; \ + std::cerr << __FILE__ << ":" << __LINE__ << ": CHECK failed: " #condition \ + << std::endl; \ + } \ + } while (false) + +fs::path g_fixtures; + +std::string ReadFile(const fs::path& path) { + std::ifstream in(path, std::ios::binary); + std::ostringstream text; + text << in.rdbuf(); + return text.str(); +} + +std::string App(const std::string& relative) { return ReadFile(g_fixtures / "electron-app" / fs::u8path(relative)); } + +std::set Specifiers(const js::Analysis& analysis, const std::string& kind = {}) { + std::set out; + for (const auto& dependency : analysis.dependencies) + if (kind.empty() || dependency.kind == kind) out.insert(dependency.specifier); + return out; +} + +std::set Exports(const js::Analysis& analysis) { + std::set out; + for (const auto& name : analysis.exports) out.insert(name.name); + return out; +} + +std::set Channels(const js::Analysis& analysis, const std::string& role) { + std::set out; + for (const auto& use : analysis.ipc) + if (use.role == role) out.insert(use.channel); + return out; +} + +std::string Base64(const std::string& bytes) { + static const char* alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + size_t i = 0; + for (; i + 2 < bytes.size(); i += 3) { + const uint32_t v = (uint8_t(bytes[i]) << 16) | (uint8_t(bytes[i + 1]) << 8) | uint8_t(bytes[i + 2]); + for (int s = 18; s >= 0; s -= 6) out.push_back(alphabet[(v >> s) & 63]); + } + if (i + 1 == bytes.size()) { + const uint32_t v = uint8_t(bytes[i]) << 16; + out += {alphabet[(v >> 18) & 63], alphabet[(v >> 12) & 63], '=', '='}; + } else if (i + 2 == bytes.size()) { + const uint32_t v = (uint8_t(bytes[i]) << 16) | (uint8_t(bytes[i + 1]) << 8); + out += {alphabet[(v >> 18) & 63], alphabet[(v >> 12) & 63], alphabet[(v >> 6) & 63], '='}; + } + return out; +} + +void TestLexer() { + // Strings, templates (nested), regexes and comments hide their text. + const std::string tricky = App("lib/tricky.js"); + const auto analysis = js::Analyze(tricky, "tricky.js"); + CHECK(Specifiers(analysis) == std::set{"./store.cjs"}); + CHECK(analysis.lex.warnings.empty()); + CHECK(analysis.moduleKind == "cjs"); + CHECK(Exports(analysis) == std::set{"module.exports"}); + size_t regexes = 0, divisions = 0, templates = 0; + for (const auto& token : analysis.lex.tokens) { + if (token.kind == js::TokenKind::Regex) ++regexes; + if (token.kind == js::TokenKind::Template) ++templates; + if (token.kind == js::TokenKind::Punctuator && js::TokenText(tricky, token) == "/") ++divisions; + } + CHECK(regexes == 4); // re, the two in re2, the one in f + CHECK(divisions == 2); // 10 / 2 / 1 + CHECK(templates == 4); // outer head, nested head, nested tail, outer tail + + // Cooked strings and constant templates; a template with ${} is not a constant. + auto lexed = js::Lex(R"(a = 'x\tyA\u{1F600}\x41'; b = `t\`1`; c = `n${d}`;)"); + std::vector values; + for (const auto& token : lexed.tokens) + if (token.kind == js::TokenKind::String || (token.kind == js::TokenKind::Template && !token.substitutions)) + values.push_back(token.value); + CHECK(values.size() == 2 && values[0] == "x\tyA\xF0\x9F\x98\x80" "A" && values[1] == "t`1"); + + // Columns are UTF-16 units: é is one, an emoji two. + lexed = js::Lex("const s = 'é😀'; x"); + CHECK(lexed.tokens.back().line == 1 && lexed.tokens.back().column == 18); + lexed = js::Lex("a\r\nb\rc\nd"); + CHECK(lexed.tokens.size() == 4 && lexed.tokens[3].line == 4 && lexed.tokens[1].line == 2 && lexed.tokens[2].line == 3); + + // Broken input is reported, never fatal. + for (const char* broken : {"'abc\nrequire('x')", "/* never closed", "`a ${ b", "x = /never closed\nrequire('y')"}) { + const auto result = js::Analyze(broken, "broken.js"); + CHECK(!result.lex.warnings.empty() || std::string(broken).rfind("x = /", 0) == 0); + } + // After an unterminated string the next line is lexed again. + CHECK(Specifiers(js::Analyze("'abc\nrequire('x')", "a.js")) == std::set{"x"}); + // A regex that cannot close is a division after all. + CHECK(Specifiers(js::Analyze("x = a /b\nrequire('y')", "a.js")) == std::set{"y"}); + + // Source map comments. + lexed = js::Lex("x();\n//# sourceMappingURL=out.js.map\n"); + CHECK(lexed.sourceMappingUrl == "out.js.map" && lexed.sourceMappingLine == 2); + lexed = js::Lex("x(); /*# sourceMappingURL=a.map */ //@ sourceMappingURL=b.map"); + CHECK(lexed.sourceMappingUrl == "b.map"); + CHECK(js::Lex("s = '//# sourceMappingURL=no.map'").sourceMappingUrl.empty()); +} + +void TestFixtureScripts() { + // Electron main process (CommonJS). + auto main = js::Analyze(App("main.js"), "main.js"); + CHECK(main.moduleKind == "cjs"); + CHECK(Specifiers(main, "require") == (std::set{"electron", "node:path", "./lib/store.cjs"})); + CHECK(Specifiers(main, "dynamic_import") == std::set{"./lib/describe.mjs"}); + CHECK(Channels(main, "main_handler") == (std::set{"settings:get", "settings:set"})); + CHECK(Channels(main, "main_listener") == std::set{"log:write"}); + CHECK(Channels(main, "main_to_renderer") == std::set{"app:ready"}); // not did-finish-load + CHECK(main.windows.size() == 1); + if (main.windows.size() == 1) { + const auto& window = main.windows[0]; + CHECK(window.constructorName == "BrowserWindow" && window.at.line == 8); + CHECK(window.webPreferences["contextIsolation"] == true && window.webPreferences["nodeIntegration"] == false && + window.webPreferences["sandbox"] == true); + CHECK(window.preloadFromDirname && window.preloadLiterals == std::vector{"preload.js"}); + CHECK(window.preloadExpression == "path.join(__dirname, 'preload.js')"); + } + for (const auto& use : main.ipc) + if (use.channel == "settings:get") CHECK(use.at.line == 25 && use.at.column == 16); + + // Preload: bridge and renderer-side calls. + auto preload = js::Analyze(App("preload.js"), "preload.js"); + CHECK(Channels(preload, "bridge_exposure") == std::set{"cortexDemo"}); + CHECK(Channels(preload, "renderer_invoke") == (std::set{"settings:get", "settings:set"})); + CHECK(Channels(preload, "renderer_send") == std::set{"log:write"}); + CHECK(Channels(preload, "renderer_listener") == std::set{"app:ready"}); + + // ES modules. + auto describe = js::Analyze(App("lib/describe.mjs"), "describe.mjs"); + CHECK(describe.moduleKind == "esm"); + CHECK(Specifiers(describe, "import") == std::set{"./format.mjs"}); + CHECK(Specifiers(describe, "export_from") == std::set{"./format.mjs"}); + CHECK(Exports(describe) == (std::set{"format", "*", "default", "describeVersion"})); + auto format = js::Analyze(App("lib/format.mjs"), "format.mjs"); + CHECK(Exports(format) == (std::set{"format", "Formatter"})); + + // CommonJS exports. + auto store = js::Analyze(App("lib/store.cjs"), "store.cjs"); + CHECK(store.moduleKind == "cjs" && Exports(store) == (std::set{"get", "set", "size"})); + + // TypeScript: `type` members do not make the import type-only. + auto renderer = js::Analyze(App("src/renderer.ts"), "renderer.ts"); + CHECK(renderer.language == "typescript" && renderer.moduleKind == "esm"); + CHECK(Specifiers(renderer) == (std::set{"./greet", "./math"})); + for (const auto& dependency : renderer.dependencies) CHECK(!dependency.typeOnly); + CHECK(Exports(renderer) == std::set{"start"}); + auto greet = js::Analyze(App("src/greet.ts"), "greet.ts"); + bool typeExport = false; + for (const auto& name : greet.exports) typeExport = typeExport || (name.name == "Greeting" && name.kind == "ts_type"); + CHECK(typeExport); + + // The esbuild bundle: minified, external map, no imports left. + auto bundle = js::Analyze(App("renderer/bundle.js"), "bundle.js"); + CHECK(bundle.minified && bundle.dependencies.empty() && bundle.sourceMappingUrl == "bundle.js.map"); + CHECK(!js::Analyze(App("main.js"), "main.js").minified); + + // Minified aliases of Electron objects are followed. + auto minified = js::Analyze( + R"(const{ipcMain:o,BrowserWindow:n}=require("electron");o.handle("x:y",()=>1);)" + R"(new n({webPreferences:{preload:p,nodeIntegration:!0,contextIsolation:!1}});o.on(c,f))", + "m.js"); + CHECK(Channels(minified, "main_handler") == std::set{"x:y"}); + CHECK(minified.computedIpcChannels == 1); + CHECK(minified.windows.size() == 1 && minified.windows[0].webPreferences["nodeIntegration"] == true && + minified.windows[0].webPreferences["contextIsolation"] == false); + auto esm = js::Analyze("import { ipcRenderer as r } from 'electron'; r.invoke('a:b'); import type { T } from './t';", + "r.ts"); + CHECK(Channels(esm, "renderer_invoke") == std::set{"a:b"}); + bool typeOnly = false; + for (const auto& dependency : esm.dependencies) typeOnly = typeOnly || (dependency.specifier == "./t" && dependency.typeOnly); + CHECK(typeOnly); + + // Bundler signatures. + CHECK(js::Analyze("var __webpack_require__={};(self.webpackChunkapp=self.webpackChunkapp||[]).push([])", "w.js").bundlers == + std::vector{"webpack"}); + CHECK(js::Analyze("var __defProp=Object.defineProperty;var __commonJS=(cb,mod)=>()=>mod;", "e.js").bundlers == + std::vector{"esbuild"}); + // Computed specifiers are counted, not invented. + auto computed = js::Analyze("import(name); require(base + '/x'); require('ok')", "c.js"); + CHECK(computed.computedDynamicImports == 1 && computed.computedRequires == 1 && Specifiers(computed) == std::set{"ok"}); + // TypeScript import-equals. + CHECK(Specifiers(js::Analyze("import fs = require('fs');", "f.ts"), "import_equals") == std::set{"fs"}); + // Limits. + std::string many; + for (int i = 0; i < 50; ++i) many += "require('m" + std::to_string(i) + "');"; + auto limited = js::Analyze(many, "l.js", 10); + CHECK(limited.dependencies.size() == 10 && limited.truncated); +} + +void TestSourceMap() { + SourceMap map; + std::string error; + const std::string mapText = App("renderer/bundle.js.map"); + CHECK(ParseSourceMap(mapText, map, error)); + CHECK(map.sources == (std::vector{"../src/greet.ts", "../src/math.ts", "../src/renderer.ts"})); + CHECK(std::all_of(map.sourcesContent.begin(), map.sourcesContent.end(), [](const json& c) { return c.is_string(); })); + + const json expected = json::parse(ReadFile(g_fixtures / "electron-app-expected.json")); + // Node's parser keeps the previous segment's name on a 4-field segment + // (no name); the format says such a segment has none. Names are compared + // whenever the segment carries one. + size_t compared = 0, mismatched = 0, inheritedNames = 0; + for (const auto& row : expected["columns"]) { + const json cell = json::parse(row.get()); + const json got = LookupJson(map, cell[0], cell[1]); + const bool match = cell[2].is_null() + ? got["original"].is_null() + : (!got["original"].is_null() && got["original"]["source"] == cell[2] && got["original"]["line"] == cell[3] && + got["original"]["column"] == cell[4] && + (got["original"]["name"] == cell[5] || (got["original"]["name"].is_null() && ++inheritedNames))); + ++compared; + if (!match && ++mismatched <= 5) std::cerr << "lookup differs from node at " << cell.dump() << ": " << got.dump() << "\n"; + } + CHECK(compared > 200 && mismatched == 0 && inheritedNames < compared / 4); + for (const auto& lookup : expected["lookups"]) { + const json got = LookupJson(map, lookup["generated"]["line"], lookup["generated"]["column"]); + CHECK(got["original"]["line"] == lookup["original"]["line"]); + } + // The original line comes from sourcesContent. + const json greet = LookupJson(map, 1, 36); + CHECK(greet["original"].value("line_text", std::string()).find("Hello from the") != std::string::npos); + + // Malformed maps are refused with a reason. + SourceMap bad; + CHECK(!ParseSourceMap("{", bad, error) && error == "sourcemap_not_json"); + CHECK(!ParseSourceMap(R"({"version":2,"mappings":""})", bad, error) && error == "sourcemap_version_unsupported"); + CHECK(!ParseSourceMap(R"({"version":3,"sections":[]})", bad, error) && error == "sourcemap_index_map_unsupported"); + CHECK(!ParseSourceMap(R"({"version":3,"sources":[],"mappings":"A!"})", bad, error) && + error.rfind("sourcemap_vlq_invalid", 0) == 0); + CHECK(!ParseSourceMap(R"({"version":3,"sources":[],"mappings":"gggggggggggB"})", bad, error)); + CHECK(!ParseSourceMap(R"({"version":3,"sources":["a"],"mappings":"AAAAAA"})", bad, error) && + error.rfind("sourcemap_segment_too_long", 0) == 0); + CHECK(ParseSourceMap(R"()]}')" "\n" R"({"version":3,"sources":["a"],"mappings":"AAAA;AACA"})", bad, error) && + bad.mappings.size() == 2); + // A source index beyond the list is a warning, not a crash. + CHECK(ParseSourceMap(R"({"version":3,"sources":["a"],"mappings":"ACAA"})", bad, error) && !bad.warnings.empty() && + LookupJson(bad, 1, 1)["original"].is_null()); + + // A script with an inline map gives the same answers. + const std::string inlineScript = + App("renderer/bundle.js").substr(0, App("renderer/bundle.js").find("//# sourceMappingURL")) + + "//# sourceMappingURL=data:application/json;charset=utf-8;base64," + Base64(mapText) + "\n"; + const auto script = FromBytes(std::vector(inlineScript.begin(), inlineScript.end()), "inline.js"); + json position = SourcePosition(*script, 1, 97, nullptr, error); + CHECK(position.value("ok", false) && position["original"]["source"] == "../src/math.ts" && + position["original"]["name"] == "value" && position["map"]["inline"] == true); + const json inspected = DefaultRegistry().Inspect(*script); + CHECK(inspected["format"] == "javascript" && inspected["data"]["source_map"]["inline"] == true && + inspected["data"]["source_map"]["summary"]["sources_count"] == 3); + // Without a reachable map it says so. + const auto noMap = FromBytes({'x', '(', ')', ';'}, "plain.js"); + CHECK(SourcePosition(*noMap, 1, 1, nullptr, error).is_null() && error == "no_source_map"); +} + +void TestArchive() { + const std::string asarPath = (g_fixtures / "electron-app.asar").u8string(); + std::string error; + const auto archive = OpenFile(asarPath, &error); + CHECK(archive != nullptr); + if (!archive) return; + const auto& registry = DefaultRegistry(); + const json result = registry.Inspect(*archive); + CHECK(result["format"] == "asar" && result["provider"]["version"] == "2"); + const json& data = result["data"]; + CHECK(data["header_sha256"].get().size() == 64); + CHECK(data["integrity"]["checked"] == data["files_with_integrity"] && data["integrity"]["matched"] == data["files_with_integrity"] && + data["integrity"]["mismatches"].empty()); + CHECK(data["package_json"]["main"] == "main.js"); + + const json& electron = data["electron"]; + CHECK(electron["main_resolved"] == "main.js"); + CHECK(electron["windows"].size() == 1 && electron["windows"][0]["preload"]["resolved"] == "preload.js"); + CHECK(electron["bridge"].size() == 1 && electron["bridge"][0]["key"] == "cortexDemo"); + std::map channels; + for (const auto& row : electron["ipc"]["channels"]) channels[row["channel"].get()] = row; + CHECK(channels.size() == 4); + CHECK(channels["settings:get"]["handled_in_main"] == true && channels["settings:get"]["called_from_renderer"] == true); + CHECK(channels["app:ready"]["sent_to_renderer"] == true && channels["app:ready"]["renderer_listens"] == true); + CHECK(electron["ipc"]["called_without_handler"].empty() && electron["ipc"]["handled_without_caller"].empty()); + const auto has = [](const json& list, const char* item) { + return std::find(list.begin(), list.end(), json(item)) != list.end(); + }; + const json& processes = electron["processes"]; + CHECK(has(processes["main"], "main.js") && has(processes["main"], "lib/store.cjs") && + has(processes["main"], "lib/describe.mjs") && has(processes["main"], "lib/format.mjs")); + CHECK(processes["preload"] == json::array({"preload.js"})); + CHECK(processes["renderer"] == json::array({"renderer/bundle.js"})); + CHECK(!has(processes["main"], "lib/tricky.js")); // nothing requires it + + // Module graph: relative and TypeScript-style resolution, packages, builtins. + std::set> edges; + for (const auto& edge : data["modules"]["edges"]) edges.insert({edge["from"].get(), edge["to"].get()}); + CHECK(edges.count({"main.js", "lib/store.cjs"}) && edges.count({"src/renderer.ts", "src/greet.ts"}) && + edges.count({"lib/tricky.js", "lib/store.cjs"}) && edges.count({"lib/describe.mjs", "lib/format.mjs"})); + CHECK(data["modules"]["unresolved"].empty()); + CHECK(data["modules"]["packages"]["electron"]["provided_by_runtime"] == true); + CHECK(data["modules"]["builtins"]["path"] == 1); + CHECK(data["source_maps"].size() == 1 && data["source_maps"][0]["resolved"] == "renderer/bundle.js.map"); + + // A nested script and its sibling map, as the MCP tool resolves them. + const auto bundle = registry.OpenChild(archive, "renderer/bundle.js", error); + CHECK(bundle != nullptr); + if (bundle) { + CHECK(registry.Inspect(*bundle)["data"]["bundle"]["minified"] == true); + const json position = SourcePosition(*bundle, 1, 171, [&](const std::string& url, std::string& openError) { + std::string sibling; + JoinArchivePath("renderer", url, sibling); + return registry.OpenChild(archive, sibling, openError); + }, error); + CHECK(position.value("ok", false) && position["original"]["source"] == "../src/renderer.ts" && + position["original"]["line"] == 15 && position["original"]["column"] == 52); + } + const auto mapChild = registry.OpenChild(archive, "renderer/bundle.js.map", error); + CHECK(mapChild && registry.Inspect(*mapChild)["format"] == "sourcemap"); + + // One changed byte in main.js breaks its integrity record, and only its. + auto bytes = archive->ReadSome(0, static_cast(archive->Size())); + std::string mainOffset; + for (const auto& child : registry.Find("asar")->Children(*archive, {}, error)) + if (child.name == "main.js") bytes[static_cast(child.offset + 10)] ^= 0x20; + const auto tampered = FromBytes(bytes, "tampered.asar"); + const json damaged = registry.Inspect(*tampered)["data"]["integrity"]; + CHECK(damaged["mismatches"].size() == 1 && damaged["mismatches"][0]["file"] == "main.js" && + damaged["mismatches"][0]["result"] == "hash_mismatch"); + + // Path joining never climbs out. + std::string joined; + CHECK(JoinArchivePath("a/b", "../c/./d.js", joined) && joined == "a/c/d.js"); + CHECK(!JoinArchivePath("a", "../../x", joined)); +} + +void Fuzz() { + std::mt19937 random(4242); + const std::vector seeds = {App("main.js"), App("lib/tricky.js"), App("renderer/bundle.js"), + App("src/renderer.ts")}; + const std::string mapText = App("renderer/bundle.js.map"); + const char alphabet[] = "`'\"/\\*{}()[]$#!=<>.,;: \n\rrequireimportexport"; + for (int round = 0; round < 3000; ++round) { + std::string text = seeds[round % seeds.size()]; + const int edits = 1 + static_cast(random() % 16); + for (int e = 0; e < edits && !text.empty(); ++e) { + const size_t at = random() % text.size(); + switch (random() % 3) { + case 0: text[at] = alphabet[random() % (sizeof(alphabet) - 1)]; break; + case 1: text.erase(at, 1 + random() % 8); break; + default: text.insert(at, 1, static_cast(random() % 256)); break; + } + } + const auto analysis = js::Analyze(text, round % 2 ? "f.ts" : "f.js", 64); + (void)js::ToJson(analysis, 64); + std::string mutated = mapText; + for (int e = 0; e < 4; ++e) mutated[random() % mutated.size()] = alphabet[random() % (sizeof(alphabet) - 1)]; + SourceMap map; + std::string error; + if (ParseSourceMap(mutated, map, error)) (void)LookupJson(map, 1 + random() % 3, 1 + random() % 400); + } +} + +} // namespace + +int main(int argc, char** argv) { + g_fixtures = argc > 1 ? fs::u8path(argv[1]) : fs::path("tests/fixtures"); + if (!fs::exists(g_fixtures / "electron-app" / "main.js")) { + std::cerr << "fixtures not found under " << g_fixtures.u8string() << std::endl; + return 2; + } + TestLexer(); + TestFixtureScripts(); + TestSourceMap(); + TestArchive(); + Fuzz(); + if (failures) { + std::cerr << failures << " JavaScript check(s) failed" << std::endl; + return 1; + } + std::cout << "JavaScript, source map and Electron archive tests passed" << std::endl; + return 0; +} diff --git a/tests/live_baseline.py b/tests/live_baseline.py new file mode 100644 index 00000000..d5197957 --- /dev/null +++ b/tests/live_baseline.py @@ -0,0 +1,100 @@ +"""Live performance baselines through MCP against the test target. + +Measures, on a real process: exact and unknown-value scans (the runtime's +own duration_ms and the round trip), and the MCP round-trip latency of +memory_read, disasm and tools/list (p50/p95 over repeated calls). Writes a +JSON report. Numbers from a shared CI runner are noisy: compare trends. + + python tests/live_baseline.py --cortex stage/Cortex/cortex.exe \\ + --target stage/Cortex/e2e/cortex_test_target_x64.exe --out baseline-live.json [--runner wine] +""" + +import argparse +import json +import os +import platform +import shlex +import statistics +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from context_isolation_e2e import start_target, wait_for_runtime # noqa: E402 +from robustness_e2e import Client # noqa: E402 + + +def percentile(values, fraction): + ordered = sorted(values) + return ordered[min(len(ordered) - 1, int(round(fraction * (len(ordered) - 1))))] + + +def timed(server, name, arguments, repeat): + samples, errors = [], 0 + for _ in range(repeat): + reply, elapsed, error, _ = server.call(name, arguments, timeout=120) + if reply is None or error: + errors += 1 + samples.append(elapsed * 1000.0) + return {"calls": repeat, "errors": errors, "p50_ms": round(percentile(samples, 0.5), 2), + "p95_ms": round(percentile(samples, 0.95), 2), "max_ms": round(max(samples), 2), + "mean_ms": round(statistics.mean(samples), 2)} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--cortex", required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--out", required=True) + parser.add_argument("--runner", default="") + parser.add_argument("--repeat", type=int, default=50) + arguments = parser.parse_args() + runner = shlex.split(arguments.runner) + cortex = os.path.abspath(arguments.cortex) + bundle = os.path.dirname(cortex) + workdir = tempfile.mkdtemp(prefix="cortex-baseline-") + + process, manifest = start_target(runner, os.path.abspath(arguments.target), workdir, "baseline") + report = {"schema": "cortex.baseline.live.v1", "host": platform.platform(), "runner": arguments.runner or None, + "target": os.path.basename(arguments.target), "measurements": {}} + try: + subprocess.run(runner + [cortex, "inject", str(manifest["pid"])], cwd=bundle, capture_output=True, timeout=120) + if not wait_for_runtime(bundle, manifest["pid"]): + print("runtime did not start") + return 1 + server = Client(runner + [cortex, "mcp", "--tools", "all", "--pid", str(manifest["pid"])], bundle) + server.initialize() + m = report["measurements"] + for label, args in [("scan_exact_i32", {"type": "i32", "value": "100"}), + ("scan_unknown_i32", {"type": "i32"})]: + runs = [] + for _ in range(6): + reply, elapsed, error, content = server.call("scan_new", args, timeout=300) + body = content.get("result", content) if isinstance(content, dict) else {} + runs.append({"round_trip_ms": round(elapsed * 1000.0, 1), "runtime_ms": body.get("duration_ms"), + "count": body.get("count"), "truncated": body.get("truncated"), "error": error}) + m[label] = runs + m["memory_read_u32"] = timed(server, "memory_read", {"address": manifest["u32"], "type": "u32"}, arguments.repeat) + m["disasm_32"] = timed(server, "disasm", {"_query": {"address": manifest["demo_apply_damage"], "count": 32}}, + arguments.repeat) + started = time.time() + listed = server.request("tools/list", {}, timeout=60) + m["tools_list"] = {"ms": round((time.time() - started) * 1000.0, 2), + "tools": len((listed or {}).get("result", {}).get("tools", []))} + server.close() + finally: + process.kill() + with open(arguments.out, "w") as handle: + json.dump(report, handle, indent=1) + print(json.dumps(report, indent=1)) + failed = [k for k, v in report["measurements"].items() if isinstance(v, dict) and v.get("errors")] + failed += [k for k, v in report["measurements"].items() if isinstance(v, list) and any(r["error"] for r in v)] + if failed: + print("calls failed in: " + ", ".join(failed)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/live_e2e.py b/tests/live_e2e.py new file mode 100644 index 00000000..13124029 --- /dev/null +++ b/tests/live_e2e.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""E2E: the CDP live provider through the shipped cortex.exe (step 8). + +Starts tests/fixtures/node-live/app.js with `node --inspect=127.0.0.1:` +and drives the cortex_live_* MCP tools and `cortex.exe live`: + + - a port the operator did not allow (--inspect-port) is refused; + - targets, attach, scripts and the exact source of the fixture; + - without --allow-writes a breakpoint is refused (write_authority_required), + even with mutation_permission; + - with --allow-writes a log breakpoint on the health write records hits + whose locals follow the fixture's rule, then removes itself; the program + keeps running (console ticks continue) and a `debugger;` statement + Cortex did not set is recorded and resumed; + - detach, and `cortex.exe live watch` on the same line. + + python tests/live_e2e.py --cortex stage/Cortex/cortex.exe [--node node] [--runner wine] + +Exit code 0 = pass. +""" + +import argparse +import json +import os +import random +import shlex +import subprocess +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from artifact_mcp_e2e import Mcp # noqa: E402 + +FIXTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "node-live", "app.js") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--cortex", required=True) + parser.add_argument("--node", default="node") + parser.add_argument("--runner", default="") + arguments = parser.parse_args() + runner = shlex.split(arguments.runner) + cortex = os.path.abspath(arguments.cortex) + bundle = os.path.dirname(cortex) + failures = [] + + def check(condition, message, detail=None): + print(("PASS " if condition else "FAIL ") + message) + if not condition: + failures.append(message) + if detail is not None: + print(" " + json.dumps(detail)[:1500]) + + with open(FIXTURE, encoding="utf-8") as handle: + source_lines = handle.read().split("\n") + line = next(i + 1 for i, text in enumerate(source_lines) if "CORTEX_HEALTH_WRITE" in text) + port = random.randint(9400, 9900) + node = subprocess.Popen([arguments.node, "--inspect=127.0.0.1:%d" % port, FIXTURE], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + time.sleep(1.5) + read_only = Mcp(runner + [cortex, "mcp", "--tools", "all", "--inspect-port", str(port)], bundle) + read_only.initialize() + failed, result = read_only.call("cortex_live_targets", {"port": port + 1}) + check(failed and result.get("error") == "port_not_allowed", "a port the operator did not allow is refused", result) + failed, result = read_only.call("cortex_live_targets", {"port": port}) + targets = result.get("targets", []) + check(not failed and targets and targets[0].get("type") == "node", "inspector targets listed", result) + failed, attached = read_only.call("cortex_live_attach", {"port": port}) + session = attached.get("session_id", "") + check(not failed and attached.get("connected") is True, "attached", attached) + time.sleep(0.5) + failed, scripts = read_only.call("cortex_live_scripts", {"session_id": session, "query": "app.js"}) + url = (scripts.get("scripts") or [{}])[0].get("url", "") + check(url.startswith("file://") and url.endswith("app.js"), "the fixture script is listed", scripts) + failed, source = read_only.call("cortex_live_source", {"session_id": session, "script": url, "first_line": line, "lines": 1}) + check("CORTEX_HEALTH_WRITE" in source.get("text", ""), "source line %d is the health write" % line, source) + failed, refused = read_only.call("cortex_live_breakpoint_set", {"session_id": session, "url": url, "line": line, + "mutation_permission": True}) + check(failed and refused.get("error") == "write_authority_required", + "without --allow-writes a breakpoint is refused", refused) + failed, detached = read_only.call("cortex_live_detach", {"session_id": session}) + check(detached.get("detached") is True, "detached") + read_only.close() + + server = Mcp(runner + [cortex, "mcp", "--tools", "all", "--allow-writes", "--inspect-port", str(port)], bundle) + server.initialize() + failed, attached = server.call("cortex_live_attach", {"port": port}) + session = attached.get("session_id", "") + failed, missing = server.call("cortex_live_breakpoint_set", {"session_id": session, "url": url, "line": line, + "mutation_permission": False}) + check(failed and missing.get("error") == "mutation_permission_required", "mutation_permission is still required", missing) + failed, bp = server.call("cortex_live_breakpoint_set", {"session_id": session, "url": url, "line": line, "max_hits": 5, + "mutation_permission": True}) + check(not failed and bp.get("resolved"), "log breakpoint set and resolved", bp) + deadline = time.time() + 20 + hits = {} + while time.time() < deadline: + failed, hits = server.call("cortex_live_hits", {"session_id": session, "breakpoint": bp.get("breakpoint", 0)}) + if hits.get("hits") == 5: + break + time.sleep(0.3) + check(hits.get("hits") == 5 and hits.get("active") is False, "5 hits, then the breakpoint removed itself", hits) + previous = None + rule = True + for record in hits.get("records", []): + top = record["frames"][0] + locals_ = top.get("locals", {}) + health = next((int(p["value"]) for p in locals_.get("target", {}).get("preview", []) if p["name"] == "health"), None) + absorbed = locals_.get("absorbed", {}).get("value") + if top.get("function") != "applyDamage" or locals_.get("amount", {}).get("value") != 9 or health is None: + rule = False + if previous and health not in (previous[0] - (9 - previous[1]), 100): + rule = False + previous = (health, absorbed) + check(rule, "each hit's locals follow the fixture's rule (health drops by amount - absorbed, or resets)", hits) + deadline = time.time() + 15 + status = {} + while time.time() < deadline: + failed, status = server.call("cortex_live_status", {"session_id": session}) + if status.get("foreign_pauses", 0) >= 1: + break + time.sleep(0.5) + check(status.get("foreign_pauses", 0) >= 1, "a debugger statement Cortex did not set was recorded and resumed", status) + failed, console = server.call("cortex_live_console", {"session_id": session, "limit": 500}) + first = console.get("total", 0) + time.sleep(1.0) + failed, console = server.call("cortex_live_console", {"session_id": session, "limit": 1}) + check(console.get("total", 0) > first + 10, "the program keeps running (console ticks continue)", console) + failed, detached = server.call("cortex_live_detach", {"session_id": session}) + check(detached.get("detached") is True, "detached after the breakpoint") + server.close() + + run = subprocess.run(runner + [cortex, "live", "watch", "--port", str(port), "--url", url, "--line", str(line), + "--hits", "3", "--seconds", "20"], cwd=bundle, capture_output=True, text=True, + encoding="utf-8", timeout=120) + report = json.loads(run.stdout) if run.stdout.strip().startswith("{") else {} + check(run.returncode == 0 and report.get("hits") == 3, "cortex.exe live watch records 3 hits", run.stdout[-1500:]) + check(node.poll() is None, "the fixture is still running after every session") + finally: + node.kill() + print("live E2E: %s" % ("PASS" if not failures else "FAIL (%d)" % len(failures))) + return 0 if not failures else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/manual_demo_e2e.py b/tests/manual_demo_e2e.py new file mode 100644 index 00000000..17946180 --- /dev/null +++ b/tests/manual_demo_e2e.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""E2E: the manual reverse-engineering workflow of roadmap step 3, scripted. + +On the test target's demonstration structure (a heap-allocated actor whose +health only CortexDemoApplyDamage writes, reached from an exported global), +using the same primitives a person drives from the desktop: + + 1. find the value: first scan for 100, apply the action, next scan for 94; + 2. watch it: a live watch records the next change (94 -> 88); + 3. find what writes it: a hardware write breakpoint in log mode catches the + instruction, which lies in CortexDemoApplyDamage; + 4. navigate the code: module+RVA of the hit, disassembly of the function + with the writing instruction (the trap reports the *next* one), its CFG; + 5. keep it: a pointer path from the exported global that resolves to the + same address, a named address and a knowledge record whose evidence is + the breakpoint hit; + 6. clean up: breakpoint and watch removed, the target still alive. + +The "action" is the file the target watches (.damage), standing for +the person pressing D. The server runs with --allow-writes because +breakpoints are control operations. + + python tests/manual_demo_e2e.py --cortex stage/Cortex/cortex.exe \\ + --target stage/Cortex/e2e/cortex_test_target_x64.exe [--runner wine] +""" + +import argparse +import json +import os +import shlex +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from context_isolation_e2e import Mcp, route, start_target, wait_for_runtime # noqa: E402 + + +def as_int(value): + if isinstance(value, int): + return value + return int(str(value), 0) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--cortex", required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--runner", default="") + arguments = parser.parse_args() + + runner = shlex.split(arguments.runner) + cortex = os.path.abspath(arguments.cortex) + target = os.path.abspath(arguments.target) + bundle = os.path.dirname(cortex) + workdir = tempfile.mkdtemp(prefix="cortex-demo-") + failures = [] + + def check(condition, message, detail=None): + print(("ok " if condition else "FAIL ") + message) + if not condition: + if detail is not None: + print(" " + json.dumps(detail, default=str)[:700]) + failures.append(message) + return condition + + process, manifest = start_target(runner, target, workdir, "demo") + server = None + try: + import subprocess + pid = manifest["pid"] + health_address = as_int(manifest["demo_actor_health"]) + damage_function = as_int(manifest["demo_apply_damage"]) + damage_file = manifest["demo_damage_file"] + module_name = os.path.basename(manifest["module_path"].replace("\\", "/")) + + def act(): + """The person presses D: one call of CortexDemoApplyDamage.""" + with open(damage_file, "w") as handle: + handle.write("hit") + deadline = time.time() + 10 + while os.path.exists(damage_file) and time.time() < deadline: + time.sleep(0.05) + time.sleep(0.2) + + inject = subprocess.run(runner + [cortex, "inject", str(pid)], cwd=bundle, + capture_output=True, text=True, timeout=120) + check(inject.returncode == 0, "runtime loaded (cortex.exe inject)", inject.stdout + inject.stderr) + check(wait_for_runtime(bundle, pid), "runtime started") + server = Mcp(runner + [cortex, "mcp", "--pid", str(pid), "--tools", "all", "--allow-writes"], bundle) + server.initialize() + + def call(name, args): + error, content = server.call(name, args) + return error, route(content) + + # 1. Find the value. + error, first = call("scan_new", {"type": "i32", "value": "100"}) + check(not error and first.get("ok") and first.get("count", 0) >= 1, "first scan for 100", first) + check(first.get("status") == "completed" and first.get("target", {}).get("pid") == pid, + "scan result follows the common contract", first) + scan_id = first.get("scan_id") + act() + error, narrowed = call("scan_next", {"scan_id": scan_id, "filter": "exact", "value": "94"}) + check(not error and narrowed.get("ok"), "next scan for 94 after the action", narrowed) + error, results = call("scan_results", {"_path": {"scan_id": scan_id}, "_query": {"limit": 1000}}) + found = [as_int(row["address"]) for row in results.get("results", [])] + check(health_address in found, "the actor's health is among %d candidate(s)" % len(found), + {"expected": hex(health_address), "found": [hex(a) for a in found[:20]]}) + + # 2. Watch it. + error, watch = call("watch_add", {"address": hex(health_address), "type": "i32", "label": "demo health", + "mutation_permission": True}) + check(not error and watch.get("ok"), "live watch on the value", watch) + time.sleep(0.5) # the watch thread takes its first reading + call("watch_events", {}) # drain + act() + rows = [] + deadline = time.time() + 5 + while time.time() < deadline and not any("88" in json.dumps(row) for row in rows): + time.sleep(0.25) + error, changes = call("watch_events", {}) + rows += changes.get("events", []) + if not check(any("88" in json.dumps(row) for row in rows), "the watch recorded 94 -> 88", rows[:5]): + print(" watch_list: " + json.dumps(call("watch_list", {})[1])[:400]) + + # 3. Find what writes it. + error, breakpoint = call("debug_breakpoint_add", {"address": hex(health_address), "kind": "hw_write", + "size": 4, "action": "log", "mutation_permission": True}) + check(not error and breakpoint.get("ok") and breakpoint.get("id"), "hardware write breakpoint (log mode)", + breakpoint) + breakpoint_id = breakpoint.get("id") + act() + hits = [] + deadline = time.time() + 10 + while not hits and time.time() < deadline: + error, log = call("debug_breakpoint_log", {"id": breakpoint_id, "_path": {"id": breakpoint_id}}) + hits = log.get("entries", []) + if not hits: + time.sleep(0.3) + check(len(hits) >= 1, "the breakpoint logged the write", log) + trap_ip = as_int(hits[0]["instruction"]) if hits else 0 + check(damage_function <= trap_ip < damage_function + 0x200, + "the write happened in CortexDemoApplyDamage", {"ip": hex(trap_ip), "function": hex(damage_function)}) + + # 4. Navigate the code. + error, where = call("symbols_module", {"_query": {"address": hex(trap_ip)}}) + check(not error and module_name.lower() in json.dumps(where).lower(), + "the hit resolves to %s+RVA" % module_name, where) + error, listing = call("disasm", {"_query": {"address": hex(damage_function), "count": 60}}) + instructions = listing.get("instructions", []) + writer = None + for row in instructions: + start = as_int(row["address"]) + if start + int(row.get("size", 0)) == trap_ip: + writer = row + # A write trap reports the next instruction, so the writer ends at the + # trap address. Its destination is the health field (+0x04); compilers + # emit a plain store (mov) or a read-modify-write (sub/add) for it. + operands = writer.get("text", "").split(None, 1)[1] if writer and " " in writer.get("text", "") else "" + destination = operands.split(",", 1)[0] + check(writer is not None and "[" in destination and "+0x04" in destination.replace("+4]", "+0x04]") and + writer.get("mnemonic", "") in ("mov", "sub", "add"), + "the writing instruction ends at the trap address and writes the health field", writer) + error, cfg = call("analysis_cfg", {"address": hex(damage_function)}) + check(not error and len(cfg.get("blocks", [])) >= 1, "CFG of the function", cfg) + + # 5. Keep it. + error, saved = call("project_pointer_path_set", { + "name": "demo.actor.health", "module": module_name, + "base_offset": as_int(manifest["demo_actor_global_rva"]), "offsets": [4], "final_type": "i32", + "notes": "exported g_cortex_demo_actor -> +4 health", "mutation_permission": True}) + check(not error and saved.get("ok"), "pointer path saved", saved) + error, resolved = call("project_pointer_path_resolve", {"_path": {"name": "demo.actor.health"}}) + check(not error and as_int(resolved.get("address", 0)) == health_address, + "the pointer path resolves to the scanned address", resolved) + error, named = call("project_address_set", {"name": "demo.health", "address": hex(health_address), + "type": "i32", "notes": "found by scan, written by " + "CortexDemoApplyDamage", "mutation_permission": True}) + check(not error and named.get("ok"), "named address saved", named) + # The project persists across runs: an update must name the revision + # it replaces (optimistic concurrency), as any agent's would. + error, existing = call("project_knowledge_get", {"_path": {"id": "demo.apply_damage.writes_health"}}) + record = existing.get("record", existing) if not error else {} + revision = record.get("revision") if isinstance(record, dict) else None + error, claim = call("project_knowledge_put", dict({ + "id": "demo.apply_damage.writes_health", "kind": "function", + "statement": "CortexDemoApplyDamage writes the actor's health (actor+4).", + "status": "observed", + "evidence": [{"source": "debug_breakpoint_log", "reference": "breakpoint:%s" % breakpoint_id, + "detail": "hw_write hit, trap ip %s" % hex(trap_ip)}], + "mutation_permission": True}, **({"expected_revision": revision} if revision else {}))) + stored = claim.get("record", claim.get("stored", claim)) + check(not error and claim.get("ok"), "knowledge record saved with the hit as evidence", claim) + error, back = call("project_knowledge_get", {"_path": {"id": "demo.apply_damage.writes_health"}}) + check(not error and "CortexDemoApplyDamage" in json.dumps(back), "knowledge record reads back", back) + del stored + + # 6. Clean up. + error, removed = call("debug_breakpoint_delete", {"id": breakpoint_id, "_path": {"id": breakpoint_id}, + "mutation_permission": True}) + check(not error and removed.get("ok", True), "breakpoint removed", removed) + act() + error, value = call("memory_read", {"address": hex(health_address), "type": "i32"}) + check(not error and value.get("value") == 76, "the target still runs (health 76 after the 4th hit)", value) + finally: + if server: + server.close() + if process.poll() is None: + process.kill() + + if failures: + print("\n%d check(s) failed" % len(failures)) + return 1 + print("\nPASS: manual workflow - scan, watch, find what writes, navigate, save") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/memory_tools_tests.cpp b/tests/memory_tools_tests.cpp index 57536820..b346451d 100644 --- a/tests/memory_tools_tests.cpp +++ b/tests/memory_tools_tests.cpp @@ -4,6 +4,7 @@ #include "services/memory_tools.h" #include "services/pointer_scanner.h" +#include "services/byte_diff.h" #include "services/signature.h" #include "services/instruction_operands.h" #include "fake_process.h" @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -348,7 +350,117 @@ void TestGroupedScan() { CHECK(hits.size() == 1); } +// Paths found in one process are only candidates; a check in another +// process lifetime confirms or invalidates each one, and the history +// survives Save/Load. +void TestPointerPathValidation() { + auto first = std::make_shared(); + first->Add(0x400000, 0x2000, true, false, MemoryRegionType::Image); + first->Add(0x10000000, 0x10000, true, false, MemoryRegionType::Private); + first->Put(0x400100, 0x10000000); // game.exe+100 -> object + first->Put(0x10000020, 0x10001000); // object+20 -> player + first->Put(0x400200, 0x10001040); // game.exe+200 -> near the player (coincidence) + + PointerScanOptions options; + options.target = 0x10001048; + options.maxLevel = 3; + options.maxOffset = 0x100; + options.modules = {{"game.exe", 0x400000, 0x2000}}; + PointerScanResult result; + std::string error; + CHECK(PointerScanner::Scan(first, options, result, &error)); + result.scanGeneration = 1001; + CHECK(result.paths.size() == 2); + for (const auto& path : result.paths) + CHECK(PointerScanner::Status(result, path) == PointerPathStatus::Candidate); + + // Next run: the module loads elsewhere and the player moves; only the + // two-level chain still leads to it. + auto second = std::make_shared(); + second->Add(0x500000, 0x2000, true, false, MemoryRegionType::Image); + second->Add(0x20000000, 0x10000, true, false, MemoryRegionType::Private); + second->Put(0x500100, 0x20000000); + second->Put(0x20000020, 0x20003000); + second->Put(0x500200, 0x20001040); + const std::vector secondModules = {{"game.exe", 0x500000, 0x2000}}; + auto summary = PointerScanner::Validate(second, result, 0x20003048, secondModules, 2002); + CHECK(summary.stable == 1 && summary.invalidated == 1 && summary.candidate == 0); + CHECK(result.paths.size() == 2); // nothing dropped, unlike Rescan + for (const auto& path : result.paths) { + const auto status = PointerScanner::Status(result, path); + const std::string text = PointerScanner::Format(result, path); + if (text == "game.exe+100 -> 20 -> 48") CHECK(status == PointerPathStatus::Stable); + else CHECK(status == PointerPathStatus::Invalidated); + } + + // Re-checking the same process replaces its entry, it does not count twice. + PointerScanner::Validate(second, result, 0x20003048, secondModules, 2002); + for (const auto& path : result.paths) CHECK(path.checks.size() == 1); + + // A later miss invalidates a stable path for good. + auto third = std::make_shared(); + third->Add(0x600000, 0x2000, true, false, MemoryRegionType::Image); + third->Add(0x30000000, 0x10000, true, false, MemoryRegionType::Private); + summary = PointerScanner::Validate(third, result, 0x30003048, {{"game.exe", 0x600000, 0x2000}}, 3003); + CHECK(summary.stable == 0 && summary.invalidated == 2); + + // Saved and loaded with its history (format v2); v1 files still load. + const std::string file = (std::filesystem::temp_directory_path() / "cortex-pointer-checks.json").u8string(); + CHECK(PointerScanner::Save(file, result, &error)); + PointerScanResult loaded; + CHECK(PointerScanner::Load(file, loaded, &error)); + CHECK(loaded.scanGeneration == 1001 && loaded.paths.size() == 2 && loaded.paths[0].checks.size() == 2); + for (const auto& path : loaded.paths) CHECK(PointerScanner::Status(loaded, path) == PointerPathStatus::Invalidated); + { + std::ofstream v1(std::filesystem::u8path(file), std::ios::trunc); + v1 << R"({"format":"cortex-pointer-scan","version":1,"target":1,"pointer_size":8,)" + << R"("modules":[{"name":"game.exe","base":4194304,"size":8192}],"paths":[{"m":0,"b":256,"o":[32,72]}]})"; + } + CHECK(PointerScanner::Load(file, loaded, &error) && loaded.paths.size() == 1 && loaded.paths[0].checks.empty()); + CHECK(PointerScanner::Status(loaded, loaded.paths[0]) == PointerPathStatus::Candidate); + std::filesystem::remove(std::filesystem::u8path(file)); + + // Without a known scan generation, two checks in two known processes + // are needed; checks of unknown generation never prove a second one. + PointerScanResult unknown; + unknown.modules = {{"game.exe", 0x400000, 0x2000}}; + PointerPath path; + path.offsets = {0x20}; + path.checks = {{0, true}, {0, true}, {77, true}}; + unknown.paths = {path}; + CHECK(PointerScanner::Status(unknown, unknown.paths[0]) == PointerPathStatus::Candidate); + unknown.paths[0].checks.push_back({78, true}); + CHECK(PointerScanner::Status(unknown, unknown.paths[0]) == PointerPathStatus::Stable); + CHECK(std::string(PointerScanner::StatusName(PointerPathStatus::Invalidated)) == "invalidated"); +} + +// Capture comparison in the memory viewer. +void TestByteDiff() { + std::vector before(0x3000, 0), after(0x3000, 0); + after[0x10] = 1; after[0x11] = 2; // one run of 2 + after[0x20] = 7; // a run of 1 + after[0x1800] = 9; // on page 1, unreadable below + after[0x2FFF] = 5; // last byte + bool truncated = true; + auto runs = DiffRuns(before, after, {}, {}, 0x1000, 100, &truncated); + CHECK(!truncated && runs.size() == 4); + CHECK(runs[0].offset == 0x10 && runs[0].length == 2 && runs[1].offset == 0x20 && runs[1].length == 1); + CHECK(ChangedBytes(runs) == 5); + // A page unreadable in either capture says nothing about its bytes. + runs = DiffRuns(before, after, {1, 1, 1}, {1, 0, 1}, 0x1000, 100, &truncated); + CHECK(runs.size() == 3 && ChangedBytes(runs) == 4); + // Bounded. + runs = DiffRuns(before, after, {}, {}, 0x1000, 2, &truncated); + CHECK(runs.size() == 2 && truncated); + // Next change, wrapping. + runs = DiffRuns(before, after, {}, {}, 0x1000, 100, &truncated); + CHECK(NextRun(runs, 0) == 0 && NextRun(runs, 0x10) == 1 && NextRun(runs, 0x2FFF) == 0); + CHECK(DiffRuns(before, before, {}, {}, 0x1000, 100).empty()); +} + int main() { + TestByteDiff(); + TestPointerPathValidation(); TestGroupedScan(); TestPeImage(); TestStringsAndCaves(); diff --git a/tests/native_code_tests.cpp b/tests/native_code_tests.cpp new file mode 100644 index 00000000..b619d21f --- /dev/null +++ b/tests/native_code_tests.cpp @@ -0,0 +1,228 @@ +// Offline native code analysis and annotations (step 4d). +// +// cortex_native_code_tests +// +// The fixture is the repository's own test target, compiled by the same +// build. Function ranges from the exception directory are checked against +// the runtime's own unwind reader (mcp_bridge/pe_unwind.h); the CFG of +// CortexDemoApplyDamage against what the source does (the health write); +// the call graph against objdump in tests/native_static_e2e.py. + +#include "../mcp_bridge/artifact_tools.h" +#include "../mcp_bridge/pe_unwind.h" + +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; +namespace native = cortex::artifacts::native; +using namespace cortex::artifacts; +using json = nlohmann::json; + +int failures = 0; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + ++failures; \ + std::cerr << __FILE__ << ":" << __LINE__ << ": CHECK failed: " #condition \ + << std::endl; \ + } \ + } while (false) + +uint32_t Rva(const json& value) { return static_cast(std::stoul(value.get(), nullptr, 16)); } + +const json* FindFunction(const json& functions, const std::string& name) { + for (const auto& function : functions["functions"]) + if (function["name"] == name) return &function; + return nullptr; +} + +void TestTarget(const std::string& path) { + std::string error; + const auto source = OpenFile(path, &error); + CHECK(source != nullptr); + if (!source) return; + PeFile pe; + CHECK(ParsePe(*source, pe, error)); + native::CodeIndex index; + CHECK(native::BuildIndex(*source, pe, index, error)); + const bool x64 = pe.machine == 0x8664; + CHECK(index.x64 == x64); + + const json functions = native::FunctionsJson(index, "", 1 << 20, {}); + CHECK(functions["total"].get() > 100 && functions["by_source"]["entry"] == 1); + for (const char* name : {"CortexDemoApplyDamage", "CortexNativeAdd", "CortexBreakpointCanary"}) { + const json* function = FindFunction(functions, name); + CHECK(function != nullptr); + if (!function) continue; + const auto& sources = (*function)["sources"]; + CHECK(std::find(sources.begin(), sources.end(), "export") != sources.end()); + if (x64) CHECK(std::find(sources.begin(), sources.end(), "pdata") != sources.end() && (*function)["size_source"] == "pdata"); + } + // Data exports are not functions. + CHECK(FindFunction(functions, "g_cortex_demo_actor") == nullptr); + + // x64: every pdata range equals what the runtime's unwind reader finds + // for an address inside it. + if (x64) { + CHECK(index.pdataEntries > 100); + const auto read = [&](uint64_t address, size_t count, std::vector& out) { + const uint32_t rva = static_cast(address - pe.imageBase); + uint64_t offset = rva; + uint32_t available = static_cast(count); + if (rva >= pe.sizeOfHeaders && !pe.RvaToOffset(rva, offset, &available)) return false; + out = source->ReadSome(offset, std::min(count, available)); + return out.size() == count; + }; + size_t checked = 0, matched = 0; + for (const auto& function : index.functions) { + if (!function.sizeFromPdata || checked >= 400) continue; + ++checked; + const json unwind = cortex::test::pe_unwind::Find(pe.imageBase, pe.sizeOfImage, pe.imageBase + function.rva + function.size / 2, read); + if (unwind.value("status", "") == "unwind_range" && + unwind["begin_rva"] == cortex::test::pe_unwind::Hex(function.rva) && + unwind["end_rva"] == cortex::test::pe_unwind::Hex(function.rva + function.size)) + ++matched; + else if (checked - matched <= 3) + std::cerr << "pdata " << function.rva << " vs " << unwind.dump() << "\n"; + } + CHECK(checked > 100 && matched == checked); + } + + // CortexDemoApplyDamage: called, and its CFG holds the health write + // (actor+4 -= damage), as the source says. + uint32_t damage = 0; + CHECK(native::ResolveLocation(index, "CortexDemoApplyDamage", {}, damage)); + const json callers = native::XrefsJson(index, damage, 100, {}); + bool called = false; + for (const auto& ref : callers["references"]) called = called || ref["type"] == "call"; + CHECK(called && callers["target_function"] == "CortexDemoApplyDamage"); + const json cfg = native::CfgJson(*source, pe, index, damage, {damage}, 100, {}); + CHECK(cfg["ok"] == true && cfg["blocks"].size() >= 2 && cfg["coverage"]["blocks_covered"] == 1); + bool healthWrite = false, ends = false; + for (const auto& block : cfg["blocks"]) + for (const auto& instruction : block["instructions"]) { + const std::string text = instruction["text"]; + healthWrite = healthWrite || (text.find("+0x04]") != std::string::npos && text.find("[") < text.find(",") && + (text.rfind("mov", 0) == 0 || text.rfind("sub", 0) == 0)); + ends = ends || text == "ret"; + } + CHECK(healthWrite && ends); + + // The data export is read or written by code. + uint32_t actor = 0; + const json exports = PeExports(*source, pe, {}, *std::make_unique(false)); + for (const auto& row : exports["functions"]) + if (row["name"] == "g_cortex_demo_actor") actor = Rva(row["rva"]); + CHECK(actor != 0); + const json dataRefs = native::XrefsJson(index, actor, 100, {}); + bool accessed = false; + for (const auto& ref : dataRefs["references"]) + accessed = accessed || ref["type"] == "read" || ref["type"] == "write" || ref["type"] == "address"; + CHECK(accessed); + + // Strings: found in data sections; those referenced by code point back + // to them. (Not every literal has a static reference: compilers also + // materialise addresses in ways a sweep does not see, as objdump agrees + // for "--e2e-manifest" here.) + const json manifest = native::StringsJson(index, "--e2e-manifest", 10); + CHECK(manifest["strings"].size() == 1 && manifest["strings"][0]["text"] == "--e2e-manifest"); + const json strings = native::StringsJson(index, "", 1 << 20); + size_t referenced = 0; + for (const auto& row : strings["strings"]) { + if (row["references"].empty()) continue; + if (++referenced > 20) continue; + const json back = native::XrefsJson(index, Rva(row["rva"]), 10, {}); + CHECK(back["target_string"] == row["text"] && back["total"].get() == row["references"].size()); + } + CHECK(referenced > 20); + + // Location forms. + uint32_t rva = 0; + CHECK(native::ResolveLocation(index, "va:" + cortex::test::pe_unwind::Hex(pe.imageBase + damage), {}, rva) && rva == damage); + CHECK(native::ResolveLocation(index, "rva:" + cortex::test::pe_unwind::Hex(damage), {}, rva) && rva == damage); + char sub[32]; + std::snprintf(sub, sizeof(sub), "sub_%X", damage); + CHECK(native::ResolveLocation(index, sub, {}, rva) && rva == damage); + CHECK(native::ResolveLocation(index, "renamed", {{"renamed", damage}}, rva) && rva == damage); + CHECK(!native::ResolveLocation(index, "nope", {}, rva)); + CHECK(native::CfgJson(*source, pe, index, 0x10, {}, 10, {})["error"] == "not_in_executable_section"); + + // Fuzzing: damaged headers and code never crash the index. + std::mt19937 random(77); + const auto original = source->ReadSome(0, static_cast(source->Size())); + for (int round = 0; round < 60; ++round) { + auto bytes = original; + for (int e = 0; e < 64; ++e) { + const size_t at = round % 2 ? random() % 1024 : random() % bytes.size(); + bytes[at] = static_cast(random()); + } + const auto mutated = FromBytes(bytes, "fuzz.exe"); + PeFile fuzzPe; + native::CodeIndex fuzzIndex; + if (ParsePe(*mutated, fuzzPe, error) && native::BuildIndex(*mutated, fuzzPe, fuzzIndex, error) && + !fuzzIndex.functions.empty()) + (void)native::CfgJson(*mutated, fuzzPe, fuzzIndex, fuzzIndex.functions[random() % fuzzIndex.functions.size()].rva, + {}, 50, {}); + } +} + +void TestAnnotations(const std::string& path) { + const fs::path directory = + fs::temp_directory_path() / ("cortex-annotations-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + AnnotationStore store(directory); + const auto source = OpenFile(path); + const std::string sha = Sha256Of(*source); + json state = store.Get(sha); + CHECK(state["ok"] == true && state["revision"] == 0 && state["annotations"].empty()); + CHECK(store.Put(sha, "rva:0x14c0", "name", "apply_damage", "agent", 0)["revision"] == 1); + CHECK(store.Put(sha, "rva:0x14c0", "comment", "writes health at +4", "desktop", 1)["revision"] == 2); + CHECK(store.Put(sha, "rva:0x14c0", "name", "stale", "agent", 1)["error"] == "revision_conflict"); + CHECK(store.Put(sha, "../x", "name", "bad", "agent", -1)["error"] == "invalid_location"); + CHECK(store.Put("../../etc", "rva:0x1", "name", "bad", "agent", -1)["error"] == "invalid_sha256"); + CHECK(store.Put(sha, "rva:0x1", "name", "two\nlines", "agent", -1)["error"] == "invalid_text"); + CHECK(store.Put(sha, "token:0x06000004", "comment", "Heal", "agent", -1)["ok"] == true); + CHECK(store.Put(sha, "js:app.asar!main.js:25:16", "comment", "IPC handler", "agent", -1)["ok"] == true); + CHECK(store.NativeNames(sha) == (std::map{{0x14c0, "apply_damage"}})); + // Persisted: a new store over the same folder reads the same records. + AnnotationStore reopened(directory); + CHECK(reopened.Get(sha)["revision"] == 4 && reopened.Get(sha)["annotations"].size() == 4); + CHECK(reopened.Put(sha, "rva:0x14c0", "comment", "", "agent", 4)["removed"] == true); + CHECK(reopened.Put(sha, "rva:0x9999", "comment", "", "agent", -1)["error"] == "annotation_not_found"); + + // Through the tools: the name shows in the catalog; writing needs mutation_permission. + const auto& tools = mcp_bridge::artifacts::HandleCode; + CHECK(tools("cortex_artifact_annotate", {{"location", "rva:0x1"}, {"kind", "name"}, {"text", "x"}}, *source, &store, "agent")["error"] == + "mutation_permission_required"); + json listed = tools("cortex_artifact_functions", {{"filter", "apply_damage"}}, *source, &store, "agent"); + if (FindFunction(listed, "apply_damage") == nullptr) { + // Only the x64 fixture has a function at 0x14c0. + CHECK(listed["matched"] == 0); + } else { + CHECK((*FindFunction(listed, "apply_damage"))["annotated"] == true); + CHECK(tools("cortex_artifact_cfg", {{"function", "apply_damage"}}, *source, &store, "agent")["name"] == "apply_damage"); + } + std::error_code ignored; + fs::remove_all(directory, ignored); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::cerr << "usage: cortex_native_code_tests " << std::endl; + return 2; + } + TestTarget(argv[1]); + TestAnnotations(argv[1]); + if (failures) { + std::cerr << failures << " native code check(s) failed" << std::endl; + return 1; + } + std::cout << "native code and annotation tests passed" << std::endl; + return 0; +} diff --git a/tests/native_mcp_stdio_e2e.ps1 b/tests/native_mcp_stdio_e2e.ps1 index 69616b16..34ef0d24 100644 --- a/tests/native_mcp_stdio_e2e.ps1 +++ b/tests/native_mcp_stdio_e2e.ps1 @@ -191,6 +191,60 @@ try { Assert-True ($missingStepsResult.structuredContent.error -eq "missing_execution_steps") ` "missing execution steps returned wrong error" + # Shared investigation context on the live runtime: readable from the + # compact profile, common result contract, the earlier primitive calls in + # its history as agent operations, and no way to reach the desktop-only + # selection route as a step. + $contextCall = Invoke-Mcp -Process $process -Payload ([ordered]@{ + jsonrpc = "2.0" + id = 7 + method = "tools/call" + params = [ordered]@{ + name = "capture_runtime_state" + arguments = [ordered]@{ + objective = "Read the shared investigation context" + execute = $true + timeout_ms = 5000 + steps = @( + [ordered]@{ tool = "context_get"; arguments = @{} }, + [ordered]@{ tool = "context_events"; arguments = @{ _query = @{ since = 0; limit = 16 } } } + ) + } + } + }) + $contextResult = Get-McpResult -Response $contextCall -Phase "context tools/call" + Assert-True (-not [bool]$contextResult.isError) "context read through capture_runtime_state returned isError" + $contextEvidence = @($contextResult.structuredContent.evidence) + Assert-True ($contextEvidence.Count -eq 2) "context read did not run both steps" + $snapshot = $contextEvidence[0].output.result + Assert-True ([int]$contextEvidence[0].output.status -eq 200) "context_get did not answer 200" + Assert-True ([bool]$snapshot.ok -and $snapshot.status -eq "completed") "context_get broke the result contract" + Assert-True ([uint64]$snapshot.target.pid -gt 0 -and [uint64]$snapshot.target.generation -gt 0) ` + "context_get did not report pid and generation" + Assert-True ($snapshot.data.schema -eq "cortex.context.v1") "context_get schema mismatch" + Assert-True (@($snapshot.data.modules).Count -gt 0) "context_get listed no modules" + $agentHealth = @($snapshot.data.history | Where-Object { $_.tool -eq "health" -and $_.provenance.source -eq "agent" }) + Assert-True ($agentHealth.Count -ge 1) "earlier agent primitive calls are missing from the context history" + $eventsPage = $contextEvidence[1].output.result + Assert-True ([bool]$eventsPage.ok) "context_events failed" + Assert-True ($null -ne $eventsPage.data.gap -and $null -ne $eventsPage.data.latest_id) "context_events lacks gap reporting" + + $selectCall = Invoke-Mcp -Process $process -Payload ([ordered]@{ + jsonrpc = "2.0" + id = 8 + method = "tools/call" + params = [ordered]@{ + name = "capture_runtime_state" + arguments = [ordered]@{ + objective = "An agent must not fake the person's selection" + execute = $true + steps = @([ordered]@{ tool = "context_select"; arguments = @{ address = "0x1000" } }) + } + } + }) + $selectResult = Get-McpResult -Response $selectCall -Phase "context select tools/call" + Assert-True ([bool]$selectResult.isError) "an agent reached the desktop-only selection route" + Write-Host "PASS: native MCP stdio -> named pipe -> semantic executor -> native route dispatcher" } finally { try { $process.StandardInput.Close() } catch {} diff --git a/tests/native_static_e2e.py b/tests/native_static_e2e.py new file mode 100644 index 00000000..dfdc355c --- /dev/null +++ b/tests/native_static_e2e.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""E2E: offline native code analysis through the shipped cortex.exe, checked +against objdump (an independent disassembler). + + 1. every direct call target objdump finds in .text is in the function + catalog (`cortex.exe inspect --functions`); + 2. for a fixed sample of objdump's direct calls and address operands + (RIP-relative lea/mov on x64, absolute or immediate addresses on x86), + cortex_artifact_xrefs lists the same instruction as a reference; + 3. the CFG of CortexDemoApplyDamage has the health write and coverage; + 4. annotations: refused without --allow-state, then recorded with it, + shown in the catalog, and refused on a stale revision. + + python tests/native_static_e2e.py --cortex stage/Cortex/cortex.exe \\ + --target stage/Cortex/e2e/cortex_test_target_x64.exe --objdump objdump [--runner wine] +""" + +import argparse +import json +import os +import random +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from artifact_mcp_e2e import Mcp, windows_path # noqa: E402 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--cortex", required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--objdump", required=True, help="objdump that reads PE files (MinGW binutils)") + parser.add_argument("--runner", default="") + arguments = parser.parse_args() + runner = shlex.split(arguments.runner) + cortex = os.path.abspath(arguments.cortex) + bundle = os.path.dirname(cortex) + failures = [] + + def check(condition, message, detail=None): + print(("PASS " if condition else "FAIL ") + message) + if not condition: + failures.append(message) + if detail is not None: + print(" " + json.dumps(detail)[:800]) + + root = tempfile.mkdtemp(prefix="cortex-native-e2e-") + try: + target = os.path.join(root, os.path.basename(arguments.target)) + shutil.copyfile(arguments.target, target) + headers = subprocess.run([arguments.objdump, "-p", target], capture_output=True, text=True).stdout + base = int(re.search(r"ImageBase\s+([0-9a-fA-F]+)", headers).group(1), 16) + size = int(re.search(r"SizeOfImage\s+([0-9a-fA-F]+)", headers).group(1), 16) + x64 = "pe-x86-64" in headers or "x86-64" in headers + + listing = subprocess.run([arguments.objdump, "-d", "-j", ".text", target], capture_output=True, text=True).stdout + calls, addresses = set(), set() + for line in listing.splitlines(): + m = re.match(r"\s*([0-9a-f]+):\s+(?:[0-9a-f]{2} )+\s*(\S+)\s+(.*)$", line) + if not m: + continue + at, mnemonic, operands = int(m.group(1), 16), m.group(2), m.group(3) + if mnemonic.startswith("call"): + d = re.match(r"([0-9a-f]+)\s*<", operands) + if d and base <= int(d.group(1), 16) < base + size: + calls.add((at - base, int(d.group(1), 16) - base)) + elif x64: + d = re.search(r"\(%rip\).*#\s*([0-9a-f]+)", operands) + if d and base <= int(d.group(1), 16) < base + size: + addresses.add((at - base, int(d.group(1), 16) - base)) + else: + d = re.search(r"\$0x([0-9a-f]+)", operands) or re.search(r"(? 1000 and not missing, + "all %d objdump call targets are in the catalog (%d functions)" % (len({t for _, t in calls}), len(starts)), + [hex(x) for x in missing[:10]]) + check(catalog.get("architecture") == ("x64" if x64 else "x86"), "architecture") + + store = os.path.join(root, "annotations") + server = Mcp(runner + [cortex, "mcp", "--tools", "all", "--artifact-root", windows_path(runner, root), + "--annotation-store", windows_path(runner, store)], bundle) + server.initialize() + sample = random.Random(1234) + picked = sample.sample(sorted(calls), min(60, len(calls))) + sample.sample(sorted(addresses), min(60, len(addresses))) + missed = [] + for source, destination in picked: + failed, result = server.call("cortex_artifact_xrefs", {"path": windows_path(runner, target), + "target": hex(destination), "max_entries": 65536}) + froms = {int(ref["from"], 16) for ref in result.get("references", [])} + if failed or source not in froms: + missed.append([hex(source), hex(destination)]) + check(len(addresses) > 50 and not missed, + "xrefs match objdump on %d sampled calls and address operands" % len(picked), missed[:10]) + + failed, cfg = server.call("cortex_artifact_cfg", {"path": windows_path(runner, target), "function": "CortexDemoApplyDamage", + "covered_rvas": [catalog and next( + (f["rva"] for f in catalog["functions"] if f["name"] == "CortexDemoApplyDamage"), "0x0")]}) + texts = [i["text"] for b in cfg.get("blocks", []) for i in b["instructions"]] + check(not failed and any("+0x04]" in t and t.split(",")[0].find("[") >= 0 for t in texts) and + cfg.get("coverage", {}).get("blocks_covered") == 1, "CFG of CortexDemoApplyDamage: health write, coverage") + + location = next(f["rva"] for f in catalog["functions"] if f["name"] == "CortexDemoApplyDamage") + failed, result = server.call("cortex_artifact_annotate", {"path": windows_path(runner, target), "location": "rva:" + location, + "kind": "name", "text": "apply_damage", "mutation_permission": True}) + refusal = result.get("error") + refusal = refusal.get("code") if isinstance(refusal, dict) else refusal + check(failed and refusal == "state_authority_required", "annotate refused without --allow-state", result) + server.close() + + server = Mcp(runner + [cortex, "mcp", "--tools", "all", "--allow-state", "--artifact-root", windows_path(runner, root), + "--annotation-store", windows_path(runner, store)], bundle) + server.initialize() + failed, result = server.call("cortex_artifact_annotate", {"path": windows_path(runner, target), "location": "rva:" + location, + "kind": "name", "text": "apply_damage", "mutation_permission": False}) + check(failed and result.get("error") == "mutation_permission_required", "annotate needs mutation_permission") + failed, result = server.call("cortex_artifact_annotate", {"path": windows_path(runner, target), "location": "rva:" + location, + "kind": "name", "text": "apply_damage", "expected_revision": 0, + "mutation_permission": True}) + check(not failed and result.get("revision") == 1, "annotation recorded with --allow-state", result) + failed, result = server.call("cortex_artifact_functions", {"path": windows_path(runner, target), "filter": "apply_damage"}) + check(not failed and [f["name"] for f in result.get("functions", [])] == ["apply_damage"], "catalog shows the annotation") + failed, result = server.call("cortex_artifact_annotate", {"path": windows_path(runner, target), "location": "rva:" + location, + "kind": "comment", "text": "late", "expected_revision": 0, + "mutation_permission": True}) + check(failed and result.get("error") == "revision_conflict", "stale revision refused") + failed, result = server.call("cortex_artifact_annotations", {"path": windows_path(runner, target)}) + check(not failed and result.get("revision") == 1 and result["annotations"][0]["source"] == "agent", + "annotations read back with their source") + server.close() + check(len(os.listdir(store)) == 1, "one annotation file, named by the artifact's SHA-256") + finally: + shutil.rmtree(root, ignore_errors=True) + + if failures: + print("%d native static E2E check(s) failed" % len(failures)) + return 1 + print("native static E2E passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/process_tree_tests.cpp b/tests/process_tree_tests.cpp new file mode 100644 index 00000000..7d7fb037 --- /dev/null +++ b/tests/process_tree_tests.cpp @@ -0,0 +1,102 @@ +// The process tree (step 7): Chromium/Electron roles from command lines, and +// a live tree of this test's own child processes. +// +// cortex_process_tree_tests (spawns three children of itself) + +#include "../core/target/process_tree.h" + +#include +#include +#include + +#if defined(_WIN32) +#include +#endif + +namespace { + +int failures = 0; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + ++failures; \ + std::cerr << __FILE__ << ":" << __LINE__ << ": CHECK failed: " #condition \ + << std::endl; \ + } \ + } while (false) + +using cortex::target::ChromiumRole; + +} // namespace + +int main(int argc, char** argv) { + if (argc > 1 && std::string(argv[1]) == "--child") { +#if defined(_WIN32) + Sleep(20000); // killed by the parent long before +#endif + return 0; + } + + // Roles from command lines, as Electron and Chromium write them. + CHECK(ChromiumRole("\"C:\\app\\app.exe\" --type=renderer --renderer-client-id=4 --lang=en") == "renderer"); + CHECK(ChromiumRole("app.exe --type=gpu-process --gpu-preferences=x") == "gpu-process"); + CHECK(ChromiumRole("app.exe --type=utility --utility-sub-type=network.mojom.NetworkService") == + "utility:network.mojom.NetworkService"); + CHECK(ChromiumRole("app.exe --type=\"crashpad-handler\"") == "crashpad-handler"); + CHECK(ChromiumRole("\"C:\\app\\app.exe\"").empty()); + CHECK(ChromiumRole("app.exe --not-type=renderer").empty()); + CHECK(ChromiumRole("app.exe --x=1 --type=zygote") == "zygote"); + + // A synthetic tree: roots, depth, and a recycled parent PID not followed. + std::vector nodes = { + {10, 1, 100, "app.exe", "", "app.exe", true}, + {11, 10, 101, "app.exe", "", "app.exe --type=renderer", true}, + {12, 11, 102, "app.exe", "", "app.exe --type=utility --utility-sub-type=audio.mojom.AudioService", true}, + {13, 10, 50, "old.exe", "", "old.exe", true}, // created before pid 10: not its child + }; + auto tree = cortex::target::ProcessTreeJson(nodes, 10); + CHECK(tree["ok"] == true && tree["electron"] == true && tree["processes"].size() == 3); + CHECK(tree["processes"][0]["role"] == "main" && tree["processes"][1]["depth"] == 1 && tree["processes"][2]["depth"] == 2 && + tree["processes"][2]["role"] == "utility:audio.mojom.AudioService"); + CHECK(cortex::target::ProcessTreeJson(nodes, 99)["error"] == "process_not_found"); + +#if defined(_WIN32) + // Live: three children of this process with Chromium switches. + wchar_t self[32768]; + GetModuleFileNameW(nullptr, self, 32768); + const wchar_t* switches[] = {L" --child --type=renderer --renderer-client-id=4", + L" --child --type=gpu-process", + L" --child --type=utility --utility-sub-type=network.mojom.NetworkService"}; + std::vector children; + for (const wchar_t* extra : switches) { + std::wstring command = std::wstring(L"\"") + self + L"\"" + extra; + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION info{}; + if (CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &startup, &info)) + children.push_back(info); + } + CHECK(children.size() == 3); + Sleep(500); + tree = cortex::target::ProcessTreeJson(cortex::target::SnapshotProcesses(), GetCurrentProcessId()); + std::vector roles; + for (size_t i = 1; i < tree.value("processes", nlohmann::json::array()).size(); ++i) + roles.push_back(tree["processes"][i]["role"]); + std::sort(roles.begin(), roles.end()); + CHECK(tree["ok"] == true && tree["electron"] == true && tree["processes"][0]["role"] == "main"); + CHECK(roles == (std::vector{"gpu-process", "renderer", "utility:network.mojom.NetworkService"})); + if (roles != (std::vector{"gpu-process", "renderer", "utility:network.mojom.NetworkService"})) std::cerr << tree.dump(1) << std::endl; + for (auto& info : children) { + TerminateProcess(info.hProcess, 0); + CloseHandle(info.hThread); + CloseHandle(info.hProcess); + } +#endif + if (failures) { + std::cerr << failures << " process tree check(s) failed" << std::endl; + return 1; + } + std::cout << "process tree tests passed" << std::endl; + return 0; +} diff --git a/tests/project_store_tests.cpp b/tests/project_store_tests.cpp new file mode 100644 index 00000000..b3c56d66 --- /dev/null +++ b/tests/project_store_tests.cpp @@ -0,0 +1,173 @@ +// Project persistence recovery (core/project/project.cpp). project::Init runs +// once per process, so each scenario is its own CTest entry: +// +// cortex_project_store_tests backup damaged primary, valid .bak +// cortex_project_store_tests damaged damaged primary and .bak +// cortex_project_store_tests fresh no project file at all +// cortex_project_store_tests locked primary unreadable and uncopyable +// +// The project file is named after this executable, in a fresh directory. + +#include "project/project.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; +using json = nlohmann::json; + +int failures = 0; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + ++failures; \ + std::cerr << __FILE__ << ":" << __LINE__ << ": CHECK failed: " #condition \ + << std::endl; \ + } \ + } while (false) + +std::string ReadAll(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + std::ostringstream text; + text << input.rdbuf(); + return text.str(); +} + +void WriteAll(const fs::path& path, const std::string& text) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output << text; +} + +std::string ProjectName() { + char path[MAX_PATH] = {}; + GetModuleFileNameA(nullptr, path, MAX_PATH); + return fs::path(path).stem().string(); +} + +std::vector DamagedCopies(const fs::path& directory) { + std::vector copies; + for (const auto& entry : fs::directory_iterator(directory)) + if (entry.path().filename().string().find(".damaged-") != std::string::npos) copies.push_back(entry.path()); + return copies; +} + +const char* kDamaged = "{\"schema_version\": 3, \"addresses\": {\"lost\": "; + +} // namespace + +int main(int argc, char** argv) { + const std::string scenario = argc > 1 ? argv[1] : "backup"; + const fs::path directory = fs::temp_directory_path() / + ("cortex-project-store-" + scenario + "-" + std::to_string(GetCurrentProcessId())); + fs::remove_all(directory); + fs::create_directories(directory); + const fs::path primary = directory / (ProjectName() + ".json"); + const fs::path backup = directory / (ProjectName() + ".json.bak"); + + const std::string goodBackup = + json{{"schema_version", 3}, + {"addresses", {{"health", {{"address", "0x1234"}, {"type", "i32"}, {"notes", "from backup"}}}}}} + .dump(); + + if (scenario == "backup") { + WriteAll(primary, kDamaged); + WriteAll(backup, goodBackup); + } else if (scenario == "damaged") { + WriteAll(primary, kDamaged); + WriteAll(backup, "not json either"); + } else if (scenario == "locked") { + WriteAll(primary, goodBackup); + } else if (scenario != "fresh") { + std::cerr << "unknown scenario " << scenario << std::endl; + return 2; + } + + // "locked": another process holds the primary open with no sharing, so it + // can be neither read nor copied. + HANDLE lock = INVALID_HANDLE_VALUE; + if (scenario == "locked") { + lock = CreateFileW(primary.wstring().c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + CHECK(lock != INVALID_HANDLE_VALUE); + } + + project::Init(directory.string()); + const json status = project::LoadStatus(); + const json all = project::GetAll(); + CHECK(status.value("path", std::string()) == primary.string()); + CHECK(all.value("schema_version", 0) == 3); + + if (scenario == "backup") { + CHECK(status.value("source", std::string()) == "backup"); + CHECK(all["addresses"].contains("health")); + const auto copies = DamagedCopies(directory); + CHECK(copies.size() == 1); + CHECK(status["quarantined"].size() == 1); + if (copies.size() == 1) CHECK(ReadAll(copies[0]) == kDamaged); + + // Two saves: the damaged bytes must not rotate into the backup, and + // the quarantined copy must survive. + CHECK(project::SetAddress("ammo", 0x5678, "i32", "first save")); + CHECK(project::SetAddress("armor", 0x9abc, "i32", "second save")); + const json saved = json::parse(ReadAll(primary)); + CHECK(saved["addresses"].contains("health") && saved["addresses"].contains("armor")); + const json rotated = json::parse(ReadAll(backup), nullptr, false); + CHECK(!rotated.is_discarded() && rotated.is_object()); + CHECK(DamagedCopies(directory).size() == 1); + } else if (scenario == "damaged") { + CHECK(status.value("source", std::string()) == "defaults_after_damage"); + CHECK(all["addresses"].empty()); + const auto copies = DamagedCopies(directory); + CHECK(copies.size() == 2); + CHECK(status["quarantined"].size() == 2); + bool primaryKept = false, backupKept = false; + for (const auto& copy : copies) { + const std::string bytes = ReadAll(copy); + primaryKept = primaryKept || bytes == kDamaged; + backupKept = backupKept || bytes == "not json either"; + } + CHECK(primaryKept && backupKept); + + CHECK(project::SetAddress("ammo", 0x5678, "i32", "first save")); + CHECK(project::SetAddress("armor", 0x9abc, "i32", "second save")); + CHECK(DamagedCopies(directory).size() == 2); + CHECK(json::parse(ReadAll(primary))["addresses"].contains("armor")); + } else if (scenario == "locked") { + CHECK(status.value("source", std::string()) == "defaults_after_damage"); + CHECK(status.value("saves_blocked", false)); + CHECK(status["quarantined"].empty()); + CHECK(!project::SetAddress("ammo", 0x5678, "i32", "must not replace the locked file")); + CHECK(!project::GetAll()["addresses"].contains("ammo")); + CloseHandle(lock); + lock = INVALID_HANDLE_VALUE; + CHECK(ReadAll(primary) == goodBackup); + CHECK(!project::SetAddress("ammo", 0x5678, "i32", "still blocked for this session")); + CHECK(ReadAll(primary) == goodBackup); + } else { + CHECK(!status.value("saves_blocked", true)); + CHECK(status.value("source", std::string()) == "new"); + CHECK(status["quarantined"].empty()); + CHECK(project::SetAddress("ammo", 0x5678, "i32", "first save")); + CHECK(json::parse(ReadAll(primary))["addresses"].contains("ammo")); + CHECK(DamagedCopies(directory).empty()); + } + + if (lock != INVALID_HANDLE_VALUE) CloseHandle(lock); + std::error_code ignored; + fs::remove_all(directory, ignored); + if (failures) { + std::cerr << failures << " project store check(s) failed in scenario " << scenario << std::endl; + return 1; + } + std::cout << "project store scenario '" << scenario << "' passed" << std::endl; + return 0; +} diff --git a/tests/robustness_e2e.py b/tests/robustness_e2e.py new file mode 100644 index 00000000..8b23bd79 --- /dev/null +++ b/tests/robustness_e2e.py @@ -0,0 +1,348 @@ +"""Robustness end-to-end tests: what happens when things go away. + +- The target is killed while the MCP server is attached (idle, and in the + middle of a long scan): every call must return within a bound, with an + error, and the server must keep serving (tools/list, attach a new target). +- An external engine is absent, or exits with an error: the job reports why, + and the job service keeps working. + + python tests/robustness_e2e.py --cortex stage/Cortex/cortex.exe \\ + --target stage/Cortex/e2e/cortex_test_target_x64.exe [--runner wine] + +Exit code 0 = pass. +""" + +import argparse +import json +import os +import queue +import shlex +import subprocess +import sys +import tempfile +import threading +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from context_isolation_e2e import start_target, wait_for_runtime # noqa: E402 + + +class Client: + """MCP stdio client with one reader thread: a reply that comes after its + caller gave up waiting is dropped, never mistaken for a later reply.""" + + def __init__(self, command, cwd): + self.proc = subprocess.Popen(command, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True, bufsize=1) + self.lock = threading.Lock() + self.waiting = {} + self.next_id = 0 + threading.Thread(target=self._read, daemon=True).start() + + def _read(self): + for line in self.proc.stdout: + try: + message = json.loads(line) + except ValueError: + continue + with self.lock: + slot = self.waiting.pop(message.get("id"), None) + if slot is not None: + slot.put(message) + + def send(self, method, params=None): + with self.lock: + self.next_id += 1 + request_id = self.next_id + slot = queue.Queue() + self.waiting[request_id] = slot + message = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + message["params"] = params + self.proc.stdin.write(json.dumps(message) + "\n") + self.proc.stdin.flush() + return slot + + def request(self, method, params=None, timeout=60): + try: + return self.send(method, params).get(timeout=timeout) + except queue.Empty: + return None + + def call(self, name, arguments, timeout=60): + """(reply or None, seconds, is_error, content).""" + started = time.time() + reply = self.request("tools/call", {"name": name, "arguments": arguments}, timeout) + elapsed = time.time() - started + if reply is None: + return None, elapsed, True, {"_transport": "no_reply"} + if "error" in reply: + return reply, elapsed, True, {"_rpc_error": reply["error"]} + result = reply.get("result", {}) + return reply, elapsed, bool(result.get("isError")), result.get("structuredContent", result) + + def initialize(self): + reply = self.request("initialize", {"protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "robustness-e2e", "version": "1"}}) + self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") + self.proc.stdin.flush() + return reply + + def close(self): + try: + self.proc.stdin.close() + self.proc.wait(timeout=15) + except Exception: + self.proc.kill() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--cortex", required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--runner", default="") + arguments = parser.parse_args() + runner = shlex.split(arguments.runner) + cortex = os.path.abspath(arguments.cortex) + target = os.path.abspath(arguments.target) + bundle = os.path.dirname(cortex) + workdir = tempfile.mkdtemp(prefix="cortex-robust-") + failures = [] + bound = 30.0 # seconds: no call may take longer once the target is gone + + def check(condition, message, detail=None): + print(("ok " if condition else "FAIL ") + message) + if not condition: + if detail is not None: + print(" " + json.dumps(detail, default=str)[:600]) + failures.append(message) + return condition + + def launch(tag): + process, manifest = start_target(runner, target, workdir, tag) + # A token left by an earlier process with the same PID would make + # wait_for_runtime return before this runtime is listening. + for folder in (bundle, os.path.join(bundle, "runtime", "x64"), os.path.join(bundle, "runtime", "x86")): + stale = os.path.join(folder, "cortex.mcp.%d.token" % manifest["pid"]) + if os.path.exists(stale): + os.remove(stale) + subprocess.run(runner + [cortex, "inject", str(manifest["pid"])], cwd=bundle, capture_output=True, timeout=120) + check(wait_for_runtime(bundle, manifest["pid"]), "runtime started in target %s" % tag) + return process, manifest + + def alive(server, label): + started = time.time() + tools = server.request("tools/list", {}, timeout=bound) + check(tools is not None and len(tools.get("result", {}).get("tools", [])) > 0 and time.time() - started < bound, + "the server still answers tools/list " + label) + + processes = [] + server = None + try: + # Stale runtime tokens: a killed runtime cannot delete its + # cortex.mcp..token. The server removes those of processes that + # are gone, or that were written before the process with that PID + # started; a live runtime's token stays. + ghost = os.path.join(bundle, "cortex.mcp.4000000000.token") + with open(ghost, "w") as handle: + handle.write("0" * 64 + "\n") + process, manifest = start_target(runner, target, workdir, "stale") + processes.append(process) + old = os.path.join(bundle, "cortex.mcp.%d.token" % manifest["pid"]) + with open(old, "w") as handle: + handle.write("1" * 64 + "\n") + an_hour_ago = time.time() - 3600 + os.utime(old, (an_hour_ago, an_hour_ago)) + sweeper = Client(runner + [cortex, "mcp", "--tools", "all"], bundle) + sweeper.initialize() + check(not os.path.exists(ghost), "the token of a process that no longer exists is removed") + check(not os.path.exists(old), "a token older than the live process with that PID is removed") + _, _, error, content = sweeper.call("cortex_attach", {"pid": manifest["pid"]}) + check(error or not content.get("ok"), "attach without a runtime still fails cleanly", content) + sweeper.close() + subprocess.run(runner + [cortex, "inject", str(manifest["pid"])], cwd=bundle, capture_output=True, timeout=120) + check(wait_for_runtime(bundle, manifest["pid"]), "the runtime starts and writes a fresh token") + tokens = [os.path.join(folder, "cortex.mcp.%d.token" % manifest["pid"]) + for folder in (bundle, os.path.join(bundle, "runtime", "x64"), os.path.join(bundle, "runtime", "x86"))] + live = [path for path in tokens if os.path.exists(path)] + sweeper = Client(runner + [cortex, "mcp", "--tools", "all"], bundle) + sweeper.initialize() + check(live and all(os.path.exists(path) for path in live), "a live runtime's token is kept") + _, _, error, content = sweeper.call("cortex_attach", {"pid": manifest["pid"]}) + check(not error and content.get("ok"), "attach after the fresh token", content) + sweeper.close() + process.kill() + + # Target a: malformed requests, then killed during a request. + process, manifest = launch("a") + processes.append(process) + server = Client(runner + [cortex, "mcp", "--tools", "all", "--allow-writes"], bundle) + server.initialize() + _, _, error, content = server.call("cortex_attach", {"pid": manifest["pid"]}) + if not check(not error and content.get("ok"), "attach target a", content): + raise SystemExit("cannot test a kill without an attached target") + # 0. Malformed requests: wrong argument types for every tool (the + # target's runtime and the server's own tools), and malformed + # JSON-RPC. Each gets an answer; neither the server nor the target dies. + listed = server.request("tools/list", {}, timeout=bound) + names = [tool["name"] for tool in (listed or {}).get("result", {}).get("tools", [])] + skip = {"cortex_launch", "cortex_restart", "cortex_stop", "cortex_detach"} + unanswered = [] + killer = None + for name in names: + if name in skip: + continue + for bad in (None, "x", {}, -1): + args = {key: bad for key in ("address", "type", "value", "id", "job_id", "scan_id", "session_id", + "count", "limit", "size", "path", "name", "pid", "kind", "provider")} + args["_query"] = {"address": bad, "count": bad} + reply, _, _, _ = server.call(name, args, timeout=bound) + if reply is None: + unanswered.append((name, bad)) + if server.proc.poll() is not None or process.poll() is not None: + killer = {"tool": name, "argument": bad, "server_exit": server.proc.poll(), + "target_exit": process.poll()} + break + if server.proc.poll() is not None or process.poll() is not None: + break + check(len(names) > 50, "tools listed for the malformed-argument sweep (%d)" % len(names)) + check(not unanswered, "every malformed tool call is answered", unanswered[:10]) + check(server.proc.poll() is None, "the server survives wrongly typed arguments to every tool", killer) + check(process.poll() is None, "the target survives wrongly typed arguments to every tool", killer) + for raw in ('{"jsonrpc":"2.0","method":5}', '{"jsonrpc":"2.0","id":901,"method":"tools/call","params":5}', + '{"jsonrpc":"2.0","id":902,"method":"tools/call","params":{"name":7}}', '[1,2,3]', '"text"', + '{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":null}}'): + server.proc.stdin.write(raw + "\n") + server.proc.stdin.flush() + time.sleep(2) + check(server.proc.poll() is None, "the server survives malformed JSON-RPC messages") + alive(server, "after malformed requests") + + # 1. Killed while a request is running inside it: a Lua busy loop + # bounded to 20 s keeps the runtime busy for as long as needed. + sent = time.time() + slot = server.send("tools/call", {"name": "lua_exec", "arguments": { + "code": "local n = 0 for i = 1, 4e9 do n = n + 1 end return n", "timeout_ms": 20000, + "mutation_permission": True}}) + time.sleep(1.5) + slot_was_pending = slot.empty() + process.kill() + killed = time.time() + try: + reply = slot.get(timeout=bound) + except queue.Empty: + reply = None + check(reply is not None, "the request in flight is answered within %d s of the kill" % bound) + if reply is not None: + failed = "error" in reply or reply.get("result", {}).get("isError") + check(failed, "the request in flight reports a failure, not a result", reply) + check(slot_was_pending, "the request was still running when the target was killed") + print(" answered %.1f s after the kill, %.1f s after it was sent" % (time.time() - killed, time.time() - sent)) + for name, args in [("memory_read", {"address": manifest["u32"], "type": "u32"}), + ("scan_new", {"type": "i32", "value": "100"}), ("debug_threads", {}), + ("context_get", {})]: + reply, elapsed, error, content = server.call(name, args, timeout=bound) + check(reply is not None and error and elapsed < bound, + "%s on the dead target fails within %d s (%.1f s)" % (name, bound, elapsed), content) + _, _, _, targets = server.call("cortex_targets", {}) + check(targets.get("count") == 0, "the dead target is no longer listed", targets) + alive(server, "after the target died") + + # 2. Killed while the debugger holds a hardware breakpoint. + process, manifest = launch("b") + processes.append(process) + _, _, error, content = server.call("cortex_attach", {"pid": manifest["pid"]}) + check(not error and content.get("ok"), "attach target b", content) + reply, _, error, content = server.call("debug_breakpoint_add", { + "address": manifest["u32"], "kind": "hw_write", "size": 4, "action": "log", "mutation_permission": True}) + check(not error, "hardware write breakpoint set on target b", content) + process.kill() + time.sleep(1) + reply, elapsed, error, content = server.call("debug_breakpoint_list", {}, timeout=bound) + check(reply is not None and elapsed < bound, + "debug_breakpoint_list answers within %d s after the debuggee died (%.1f s)" % (bound, elapsed), content) + alive(server, "after the debuggee died") + + # 3. A new target is usable on the same server. + process, manifest = launch("c") + processes.append(process) + _, _, error, content = server.call("cortex_attach", {"pid": manifest["pid"]}) + check(not error and content.get("ok"), "attach target c on the same server", content) + _, _, error, content = server.call("memory_read", {"address": manifest["u32"], "type": "u32"}) + check(not error and content.get("result", {}).get("value") == 0xDEADBEEF, "memory_read on target c", content) + _, _, error, content = server.call("debug_breakpoint_add", { + "address": manifest["u32"], "kind": "hw_write", "size": 4, "action": "log", "mutation_permission": True}) + check(not error, "the debugger works on target c", content) + server.close() + server = None + + # 4. External engines that are absent or fail. + failing = os.path.join(workdir, "failing-engine.bat") + with open(failing, "w") as handle: + handle.write("@echo engine failure 1>&2\r\n@exit /b 3\r\n") + fixture = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "dotnet", "Cortex.Fixture.dll") + engines = Client(runner + [cortex, "mcp", "--tools", "all", "--artifact-root", os.path.dirname(fixture), + "--ilspycmd", failing, "--ghidra-dir", os.path.join(workdir, "no-ghidra-here")], + bundle) + engines.initialize() + reply, elapsed, error, providers = engines.call("cortex_analysis_providers", {"refresh": True}, timeout=bound) + check(reply is not None and not error and elapsed < bound, "providers listed within %d s" % bound, providers) + rows = {row.get("name"): row for row in providers.get("providers", [])} if isinstance(providers, dict) else {} + for name in ("ghidra", "ilspy"): + row = rows.get(name, {}) + check(row and not row.get("available"), "%s is reported unavailable" % name, row) + check(bool(row.get("availability")), "%s says why: %s" % (name, str(row.get("availability"))[:90]), row) + reply, elapsed, error, content = engines.call("cortex_analysis_start", { + "provider": "ilspy", "feature": "list_types", "path": fixture}, timeout=bound) + check(reply is not None and error and elapsed < bound, + "starting a job on an unavailable engine is refused within %d s" % bound, content) + reply, elapsed, error, content = engines.call("cortex_analysis_jobs", {}, timeout=bound) + check(reply is not None and not error, "the job service still answers", content) + alive(engines, "after the engine failures") + engines.close() + + # 5. An engine that starts, then fails in the middle of a job. + crashing = os.path.join(workdir, "crashing-engine.bat") + with open(crashing, "w") as handle: + handle.write('@if "%~1"=="--version" (echo ilspycmd: 9.1.0.7988& exit /b 0)\r\n' + "@echo decompiler crashed 1>&2\r\n@exit /b 5\r\n") + engines = Client(runner + [cortex, "mcp", "--tools", "all", "--artifact-root", os.path.dirname(fixture), + "--ilspycmd", crashing, "--analysis-cache", os.path.join(workdir, "cache")], bundle) + engines.initialize() + _, _, error, providers = engines.call("cortex_analysis_providers", {"refresh": True}, timeout=bound) + rows = {row.get("name"): row for row in providers.get("providers", [])} if isinstance(providers, dict) else {} + check(rows.get("ilspy", {}).get("available"), "the crashing engine passes its version check", rows.get("ilspy")) + job_ids = [] + for attempt in range(2): + _, _, error, started = engines.call("cortex_analysis_start", { + "provider": "ilspy", "feature": "list_types", "path": fixture}, timeout=bound) + check(not error and started.get("job_id"), "job %d starts" % (attempt + 1), started) + job_ids.append(started.get("job_id")) + for index, job_id in enumerate(job_ids): + state = {} + deadline = time.time() + 120 + while time.time() < deadline: + _, _, _, state = engines.call("cortex_analysis_get", {"job_id": job_id}, timeout=bound) + if state.get("state") in ("failed", "succeeded", "cancelled", "completed"): + break + time.sleep(0.5) + check(state.get("state") == "failed", "job %d ends as failed, not stuck (%s)" % (index + 1, state.get("state")), state) + check("crashed" in json.dumps(state) or "5" in str(state.get("exit_code", "")), + "job %d keeps the engine's error output or exit code" % (index + 1), state) + alive(engines, "after an engine failed mid-job") + engines.close() + finally: + if server is not None: + server.close() + for process in processes: + try: + process.kill() + except Exception: + pass + + print("\nrobustness E2E: %s" % ("PASS" if not failures else "FAIL (%d)" % len(failures))) + return 0 if not failures else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/session_manager_tests.cpp b/tests/session_manager_tests.cpp index 70c37be2..23cf16a0 100644 --- a/tests/session_manager_tests.cpp +++ b/tests/session_manager_tests.cpp @@ -1,8 +1,10 @@ #include "target/session_manager.h" +#include #include #include #include +#include #include #include #include @@ -130,6 +132,55 @@ int main() { check(recycledSession && recycledSession->Target().generation == 1002, "active session carries the new process generation"); + // A target snapshot is a value: detaching the session afterwards must not + // invalidate what a caller already holds. + const auto snapshot = sessions.ActiveTarget(); + const auto capabilities = sessions.ActiveCapabilities(); + check(snapshot && snapshot->generation == 1002, "active target snapshot"); + check(capabilities && capabilities->Has(Capability::MemoryRead), "active capabilities snapshot"); + sessions.Detach(recycled.id); + check(snapshot && snapshot->name == "first.exe" && snapshot->processId == 101, + "snapshot outlives the detached session"); + check(!sessions.ActiveTarget() && !sessions.ActiveCapabilities(), "no snapshot without an active target"); + + // The desktop frame loop, its background workers and the debugger backend + // share one manager. Readers racing attach/detach/prune must always see a + // consistent session or none. + { + std::atomic stop{false}; + std::atomic inconsistent{0}; + std::vector readers; + for (int i = 0; i < 4; ++i) { + readers.emplace_back([&] { + while (!stop.load()) { + const auto session = sessions.Active(); + if (session && session->Target().id != first.id && session->Target().id != second.id) + ++inconsistent; + const auto target = sessions.ActiveTarget(); + if (target && target->processId != 101 && target->processId != 202) ++inconsistent; + for (const auto& attached : sessions.AttachedTargets()) + if (attached.name.empty()) ++inconsistent; + (void)sessions.ActiveTargetId(); + (void)sessions.SessionCount(); + } + }); + } + std::thread writer([&] { + std::string writerError; + for (int i = 0; i < 2000; ++i) { + sessions.Attach(i % 2 ? first : second, &writerError); + if (i % 3 == 0) sessions.Activate(first.id); + if (i % 5 == 0) sessions.Detach(second.id); + if (i % 7 == 0) sessions.PruneDeadSessions(); + if (i % 11 == 0) sessions.Detach(); + } + }); + writer.join(); + stop = true; + for (auto& reader : readers) reader.join(); + check(inconsistent.load() == 0, "concurrent readers see consistent sessions"); + } + sessions.DetachAll(); check(sessions.SessionCount() == 0, "detach all clears every target"); check(!sessions.HasActiveSession(), "detach all clears active target"); diff --git a/tests/settings_store_tests.cpp b/tests/settings_store_tests.cpp new file mode 100644 index 00000000..be225800 --- /dev/null +++ b/tests/settings_store_tests.cpp @@ -0,0 +1,151 @@ +// Desktop settings persistence: the settings file and the runtime cortex.ini +// are replaced atomically, round-trip, and keep the keys Cortex does not own. +// Needs no window and no target. + +#include "application/settings.h" +#include "services/user_data.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; +using cortex::application::SettingsStore; + +int failures = 0; + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + ++failures; \ + std::cerr << __FILE__ << ":" << __LINE__ << ": CHECK failed: " #condition \ + << std::endl; \ + } \ + } while (false) + +std::string ReadAll(const fs::path& path) { + std::ifstream input(path, std::ios::binary); + std::ostringstream text; + text << input.rdbuf(); + return text.str(); +} + +void WriteAll(const fs::path& path, const std::string& text) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output << text; +} + +fs::path FreshDirectory(const char* name) { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + fs::path directory = fs::temp_directory_path() / (std::string(name) + "-" + std::to_string(stamp)); + fs::remove_all(directory); + fs::create_directories(directory); + return directory; +} + +void TestAtomicWriteHelper() { + const fs::path directory = FreshDirectory("cortex-atomic-write"); + const fs::path file = directory / "state.json"; + + std::string error; + CHECK(cortex::services::WriteFileAtomically(file, "first", &error)); + CHECK(ReadAll(file) == "first"); + CHECK(cortex::services::WriteFileAtomically(file, "second", &error)); + CHECK(ReadAll(file) == "second"); + CHECK(!fs::exists(directory / "state.json.tmp")); + + // A stale temporary left by a crashed write is replaced, not appended to. + WriteAll(directory / "state.json.tmp", "garbage from a crashed write that is longer"); + CHECK(cortex::services::WriteFileAtomically(file, "third", &error)); + CHECK(ReadAll(file) == "third"); + CHECK(!fs::exists(directory / "state.json.tmp")); + + // A failed write reports an error and leaves no partial file behind. + const fs::path missing = directory / "missing" / "state.json"; + CHECK(!cortex::services::WriteFileAtomically(missing, "never", &error)); + CHECK(!error.empty()); + CHECK(!fs::exists(missing)); + + // When the destination cannot be replaced the previous content survives. + const fs::path blocked = directory / "blocked"; + fs::create_directories(blocked / "child"); + error.clear(); + CHECK(!cortex::services::WriteFileAtomically(blocked, "never", &error)); + CHECK(fs::is_directory(blocked / "child")); + CHECK(!fs::exists(directory / "blocked.tmp")); + + fs::remove_all(directory); +} + +void TestSettingsRoundTripAndIniSync() { + const fs::path directory = FreshDirectory("cortex-settings-store"); + WriteAll(directory / cortex::services::kPortableMarker, ""); + WriteAll(directory / "cortex_core.dll", ""); + WriteAll(directory / "cortex.ini", + "# operator comment\n" + "api_token=keep-me\n" + "http_api_enabled=true\n" + "HTTP_API_ENABLED=duplicate\n"); + + { + SettingsStore store(directory.u8string()); + CHECK(store.Path() == directory / "cortex-ui-settings.json"); + store.Values().memoryBytesPerRow = 32; + store.Values().httpApiEnabled = false; + store.Values().sessionHistoryLimit = 42; + store.Values().projectDirectory = "D:/projects"; + std::string error; + CHECK(store.SaveAndSync(&error)); + CHECK(error.empty()); + } + + CHECK(!fs::exists(directory / "cortex-ui-settings.json.tmp")); + CHECK(!fs::exists(directory / "cortex.ini.tmp")); + + { + SettingsStore reloaded(directory.u8string()); + CHECK(reloaded.Values().memoryBytesPerRow == 32); + CHECK(!reloaded.Values().httpApiEnabled); + CHECK(reloaded.Values().sessionHistoryLimit == 42); + CHECK(reloaded.Values().projectDirectory == "D:/projects"); + } + + const std::string ini = ReadAll(directory / "cortex.ini"); + CHECK(ini.find("# operator comment") != std::string::npos); + CHECK(ini.find("api_token=keep-me") != std::string::npos); + CHECK(ini.find("http_api_enabled=false") != std::string::npos); + CHECK(ini.find("duplicate") == std::string::npos); + CHECK(ini.find("session_history_limit=42") != std::string::npos); + CHECK(ini.find("project_directory=D:/projects") != std::string::npos); + + // A crash between writing the temporary and renaming it leaves the + // previous settings readable. + WriteAll(directory / "cortex-ui-settings.json.tmp", "{ truncated"); + { + SettingsStore afterCrash(directory.u8string()); + CHECK(afterCrash.Values().memoryBytesPerRow == 32); + std::string error; + CHECK(afterCrash.Save(&error)); + } + CHECK(!fs::exists(directory / "cortex-ui-settings.json.tmp")); + + fs::remove_all(directory); +} + +} // namespace + +int main() { + TestAtomicWriteHelper(); + TestSettingsRoundTripAndIniSync(); + if (failures) { + std::cerr << failures << " settings store check(s) failed" << std::endl; + return 1; + } + std::cout << "settings store tests passed" << std::endl; + return 0; +} diff --git a/tools/evidence_main.cpp b/tools/evidence_main.cpp new file mode 100644 index 00000000..cf80fcf2 --- /dev/null +++ b/tools/evidence_main.cpp @@ -0,0 +1,19 @@ +// `cortex.exe evidence export|list`: reads the evidence store from the +// command line (mcp_bridge/evidence_tools.h). Changes nothing. + +#include "../host/cli_entry_points.h" +#include "../mcp_bridge/evidence_tools.h" + +#if defined(_WIN32) +#include +#endif + +int CortexEvidenceMain(int argc, char** argv) { + std::filesystem::path directory = std::filesystem::current_path(); +#if defined(_WIN32) + wchar_t module[32768]; + const DWORD length = GetModuleFileNameW(nullptr, module, 32768); + if (length && length < 32768) directory = std::filesystem::path(std::wstring(module, length)).parent_path(); +#endif + return mcp_bridge::evidence::EvidenceMain(argc, argv, cortex::services::UserDataDirectory(directory) / "evidence"); +} diff --git a/tools/inspect_main.cpp b/tools/inspect_main.cpp new file mode 100644 index 00000000..edd1207a --- /dev/null +++ b/tools/inspect_main.cpp @@ -0,0 +1,9 @@ +// `cortex.exe inspect`: static artifact inspection from the command line +// (mcp_bridge/artifact_tools.h). The person's own files, so no root limit. + +#include "../host/cli_entry_points.h" +#include "../mcp_bridge/artifact_tools.h" + +int CortexInspectMain(int argc, char** argv) { + return mcp_bridge::artifacts::InspectMain(argc, argv); +} diff --git a/tools/investigation_main.cpp b/tools/investigation_main.cpp new file mode 100644 index 00000000..ca54ab20 --- /dev/null +++ b/tools/investigation_main.cpp @@ -0,0 +1,21 @@ +// `cortex.exe investigation ...`: the person's view of investigations and +// the only place, with the desktop, where steps are approved +// (mcp_bridge/investigation_tools.h). + +#include "../host/cli_entry_points.h" +#include "../mcp_bridge/investigation_tools.h" + +#if defined(_WIN32) +#include +#endif + +int CortexInvestigationMain(int argc, char** argv) { + std::filesystem::path directory = std::filesystem::current_path(); +#if defined(_WIN32) + wchar_t module[32768]; + const DWORD length = GetModuleFileNameW(nullptr, module, 32768); + if (length && length < 32768) directory = std::filesystem::path(std::wstring(module, length)).parent_path(); +#endif + return mcp_bridge::investigation::InvestigationMain(argc, argv, + cortex::services::UserDataDirectory(directory) / "investigations"); +} diff --git a/tools/live_main.cpp b/tools/live_main.cpp new file mode 100644 index 00000000..bb8cd83f --- /dev/null +++ b/tools/live_main.cpp @@ -0,0 +1,7 @@ +// `cortex.exe live targets|scripts|watch`: the CDP live provider on a local +// Node.js/Electron inspector (mcp_bridge/live_tools.h). + +#include "../host/cli_entry_points.h" +#include "../mcp_bridge/live_tools.h" + +int CortexLiveMain(int argc, char** argv) { return mcp_bridge::live::LiveMain(argc, argv); } diff --git a/tools/provider_main.cpp b/tools/provider_main.cpp new file mode 100644 index 00000000..7fb96733 --- /dev/null +++ b/tools/provider_main.cpp @@ -0,0 +1,20 @@ +// `cortex.exe provider list|run`: external analysis engines (Ghidra headless, +// ILSpy) on a file, with the result cache in the user data folder +// (mcp_bridge/analysis_tools.h). Touches no process but the engine's own. + +#include "../host/cli_entry_points.h" +#include "../mcp_bridge/analysis_tools.h" + +#if defined(_WIN32) +#include +#endif + +int CortexProviderMain(int argc, char** argv) { + std::filesystem::path directory = std::filesystem::current_path(); +#if defined(_WIN32) + wchar_t module[32768]; + const DWORD length = GetModuleFileNameW(nullptr, module, 32768); + if (length && length < 32768) directory = std::filesystem::path(std::wstring(module, length)).parent_path(); +#endif + return mcp_bridge::analysis::AnalyzeMain(argc, argv, cortex::services::UserDataDirectory(directory)); +} diff --git a/tools/unified_host/CMakeLists.txt b/tools/unified_host/CMakeLists.txt index 8d972ce2..1fae7c47 100644 --- a/tools/unified_host/CMakeLists.txt +++ b/tools/unified_host/CMakeLists.txt @@ -56,6 +56,35 @@ add_executable(cortex_host ${CORTEX_INJECT_SOURCE} ${CORTEX_MCP_SOURCE} ${CORTEX_ROOT}/mcp_bridge/host_mode.cpp + ${CORTEX_ROOT}/core/artifacts/artifact.cpp + ${CORTEX_ROOT}/core/artifacts/pe_provider.cpp + ${CORTEX_ROOT}/core/artifacts/asar_provider.cpp + ${CORTEX_ROOT}/core/artifacts/registry_default.cpp + ${CORTEX_ROOT}/core/artifacts/js_lexer.cpp + ${CORTEX_ROOT}/core/artifacts/js_analysis.cpp + ${CORTEX_ROOT}/core/artifacts/js_provider.cpp + ${CORTEX_ROOT}/core/artifacts/source_map.cpp + ${CORTEX_ROOT}/core/artifacts/inflate.cpp + ${CORTEX_ROOT}/core/artifacts/zip_provider.cpp + ${CORTEX_ROOT}/core/artifacts/dotnet_metadata.cpp + ${CORTEX_ROOT}/core/artifacts/cil.cpp + ${CORTEX_ROOT}/core/artifacts/dotnet_provider.cpp + ${CORTEX_ROOT}/core/artifacts/native_code.cpp + ${CORTEX_ROOT}/core/artifacts/annotations.cpp + ${CORTEX_ROOT}/core/evidence/evidence.cpp + ${CORTEX_ROOT}/core/investigation/investigation.cpp + ${CORTEX_ROOT}/core/target/process_tree.cpp + ${CORTEX_ROOT}/core/providers/process_runner.cpp + ${CORTEX_ROOT}/core/providers/analysis_provider.cpp + ${CORTEX_ROOT}/core/providers/ghidra_provider.cpp + ${CORTEX_ROOT}/core/providers/ilspy_provider.cpp + ${CORTEX_ROOT}/core/providers/vineflower_provider.cpp + ${CORTEX_ROOT}/core/live/websocket.cpp + ${CORTEX_ROOT}/core/live/protocol_frames.cpp + ${CORTEX_ROOT}/core/live/cdp.cpp + ${CORTEX_ROOT}/core/live/dap.cpp + ${CORTEX_ROOT}/core/live/jdwp.cpp + ${CORTEX_ROOT}/core/live/live_service.cpp ${CORTEX_DIAG_SOURCE} ${CORTEX_SYMBOLIZE_SOURCE} ${CORTEX_ROOT}/host/diagnostics/external_host.cpp