From 3d5dab16d9cc4ddd0c9101b3a0583ade8a6de2ca Mon Sep 17 00:00:00 2001 From: Sakri Koskimies Date: Thu, 6 Aug 2026 19:17:37 +0300 Subject: [PATCH 1/3] fix: Repair make build and add working end-to-end test --- Makefile | 17 +++++++++------ test/run-test.sh | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100755 test/run-test.sh diff --git a/Makefile b/Makefile index 8056245..0521acf 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,23 @@ -.PHONY: test clean build build-test-dump-generator +.PHONY: test clean build build-go-app run-go-app build-shieldbreak-image \ + run-shieldbreak-image build-test-dump-generator run-test-dump-generator -# Run tests +# Run the end-to-end test: generate a dump, extract and unshield the key, +# and verify the recovered key matches the original. test: - ./test-dump-generator/dump.sh --rebuild + ./test/run-test.sh # Clean test dump files clean: rm -f ./dumps/* -build: build-go-app build-test-dump-generator build-shieldbreak-image - rm -f shieldbreak - go build -o shieldbreak +# Build the container images the tool runs in. Everything is compiled inside +# the images, so no Go or OpenSSH toolchain is needed on the host. +build: build-test-dump-generator build-shieldbreak-image +# Build the parser binary on the host (optional, for local development). build-go-app: rm -f shieldbreak - go build -o shieldbreak + go build -C shielded-key-parser -o ../shieldbreak run-go-app: ./shieldbreak diff --git a/test/run-test.sh b/test/run-test.sh new file mode 100755 index 0000000..e83c12b --- /dev/null +++ b/test/run-test.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# End-to-end test for shieldbreak. +# +# Generates an ssh-agent core dump with a known key, extracts and unshields the +# key with the tool, then verifies the recovered key is identical to the +# original by comparing SSH key fingerprints (which are independent of the key +# comment). Exits non-zero if the recovered key does not match. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +KEY_TYPE="${KEY_TYPE:-rsa}" +KEY_COMMENT="${KEY_COMMENT:-test@key}" +DUMPS_DIR="$ROOT/dumps" +DUMP_FILE="id_${KEY_TYPE}.mem" +PLAINTEXT_FILE="${KEY_COMMENT}.plaintext" + +echo "[*] Building images" +docker build -t ssh-agent-test-dump-generator ./test/test-dump-generator +docker build -t shieldbreak . + +echo "[*] Generating test ssh-agent core dump" +mkdir -p "$DUMPS_DIR" +docker run --rm --privileged \ + -e KEY_TYPE="$KEY_TYPE" \ + -e KEY_COMMENT="$KEY_COMMENT" \ + -v "$DUMPS_DIR:/out" \ + ssh-agent-test-dump-generator + +echo "[*] Extracting and unshielding the key" +docker run --rm \ + -e KEY_COMMENT="$KEY_COMMENT" \ + -e DUMP_PATH="/data/${DUMP_FILE}" \ + -v "$DUMPS_DIR:/data:ro" \ + -v "$DUMPS_DIR:/out" \ + shieldbreak + +echo "[*] Verifying the recovered key matches the original" +# ssh-keygen is available in the generator image; run the comparison as root +# there so it can read the root-owned dump artifacts. +docker run --rm --entrypoint bash -v "$DUMPS_DIR:/d" ssh-agent-test-dump-generator -c ' + set -eu + chmod 600 "/d/id_'"$KEY_TYPE"'" "/d/'"$PLAINTEXT_FILE"'" + original=$(ssh-keygen -lf "/d/id_'"$KEY_TYPE"'" | cut -d" " -f2) + recovered=$(ssh-keygen -lf "/d/'"$PLAINTEXT_FILE"'" | cut -d" " -f2) + echo " original fingerprint: $original" + echo " recovered fingerprint: $recovered" + if [ "$original" != "$recovered" ]; then + echo "[!] FAIL: recovered key does not match the original" >&2 + exit 1 + fi +' + +echo "[+] PASS: recovered key is identical to the original" From 3d944aa7cb784e8a41aa562a3efb8cd3d465c5d9 Mon Sep 17 00:00:00 2001 From: Sakri Koskimies Date: Thu, 6 Aug 2026 19:50:44 +0300 Subject: [PATCH 2/3] test: Add structural unit tests, CI workflow, and test badges --- .github/workflows/ci.yml | 75 +++++++++++++++++++ .gitignore | 4 + Makefile | 16 +++- README.md | 30 ++++++++ shielded-key-parser/parser/mem_region_test.go | 13 ++++ .../parser/memory_dump_parser_test.go | 52 +++++++++++++ .../parser/ssh_key_reader_test.go | 72 ++++++++++++++++++ .../readelf/elf_manager_test.go | 67 +++++++++++++++++ test/run-unit-tests.sh | 67 +++++++++++++++++ 9 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 shielded-key-parser/parser/mem_region_test.go create mode 100644 shielded-key-parser/parser/memory_dump_parser_test.go create mode 100644 shielded-key-parser/parser/ssh_key_reader_test.go create mode 100644 shielded-key-parser/readelf/elf_manager_test.go create mode 100755 test/run-unit-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d3b891 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + +permissions: + contents: write + +jobs: + unit: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: shielded-key-parser/go.mod + cache-dependency-path: shielded-key-parser/go.sum + + # make test-unit installs a pinned gotestsum on demand if it is missing. + - name: Run structural unit tests + run: make test-unit + + - name: Job summary + if: always() + run: | + { + echo '### Unit test results' + echo '' + echo '```json' + cat badge/tests.json 2>/dev/null || echo '{}' + echo '' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + test-results/unit.xml + test-results/unit.json + badge/tests.json + + # Publish the badge JSON to a disposable `badges` branch so shields.io can + # render it. Uses the built-in GITHUB_TOKEN, no gist or secret required. + - name: Publish test badge + if: always() && github.ref == 'refs/heads/master' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tmp="$(mktemp -d)" + cp badge/tests.json "$tmp/tests.json" + cd "$tmp" + git init -q -b badges + git add tests.json + git -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -qm "ci: update test badge" + git push -f "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:badges + + integration: + name: Integration (end-to-end) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Builds a debug ssh-keygen and the parser image, generates an ssh-agent + # core dump, extracts and unshields the key, and verifies it matches. + - name: Run end-to-end test + run: make test diff --git a/.gitignore b/.gitignore index 90b8bbe..d160521 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ result* # Binary files shieldbreak + +# Test reports and generated badges +test-results/ +badge/ diff --git a/Makefile b/Makefile index 0521acf..578d2f9 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,24 @@ -.PHONY: test clean build build-go-app run-go-app build-shieldbreak-image \ - run-shieldbreak-image build-test-dump-generator run-test-dump-generator +.PHONY: test test-unit test-all clean build build-go-app run-go-app \ + build-shieldbreak-image run-shieldbreak-image \ + build-test-dump-generator run-test-dump-generator + +# Run every test: fast structural unit tests, then the end-to-end test. +test-all: test-unit test + +# Run the structural Go unit tests via gotestsum, writing JUnit XML, JSON, and a +# badge summary under test-results/ and badge/. +test-unit: + ./test/run-unit-tests.sh # Run the end-to-end test: generate a dump, extract and unshield the key, # and verify the recovered key matches the original. test: ./test/run-test.sh -# Clean test dump files +# Clean test dump files and generated reports clean: rm -f ./dumps/* + rm -rf ./test-results ./badge # Build the container images the tool runs in. Everything is compiled inside # the images, so no Go or OpenSSH toolchain is needed on the host. diff --git a/README.md b/README.md index 8c50189..bc8272e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # shieldbreak +[![CI](https://github.com/Saggre/shieldbreak/actions/workflows/ci.yml/badge.svg)](https://github.com/Saggre/shieldbreak/actions/workflows/ci.yml) +[![Tests](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Saggre/shieldbreak/badges/tests.json)](https://github.com/Saggre/shieldbreak/actions/workflows/ci.yml) + A tool for extracting OpenSSH `ssh-agent` **shielded** private keys from a process **core dump**, offline, on x86_64 Linux. @@ -85,6 +88,33 @@ docker run --rm \ `KEY_COMMENT` is the comment string of the target key, used to locate it in the dump. The recovered plaintext key is written to the mounted `/out` directory. +## Testing + +The parser has structural unit tests (Go's `testing` package) covering the ELF +segment math and the `sshkey` struct offset parsing, plus an end-to-end test +that generates a real `ssh-agent` core dump, extracts the key, and verifies the +recovered key matches the original by fingerprint. + +```sh +# Fast structural unit tests +make test-unit + +# End-to-end test: generate a dump, extract, unshield, and verify recovery +make test + +# Both +make test-all +``` + +`make test-unit` runs the tests through +[gotestsum](https://github.com/gotestyourself/gotestsum) and writes +machine-readable reports to `test-results/` (JUnit XML and line-delimited JSON), +plus a shields.io badge summary to `badge/tests.json`. + +Continuous integration runs on GitHub Actions (`.github/workflows/ci.yml`): the +unit tests on every push and pull request, the end-to-end test as a separate +job, and the test-count badge above is published from the unit-test results. + ## Intended use For research, education, and authorised testing only, for example diff --git a/shielded-key-parser/parser/mem_region_test.go b/shielded-key-parser/parser/mem_region_test.go new file mode 100644 index 0000000..302a8a0 --- /dev/null +++ b/shielded-key-parser/parser/mem_region_test.go @@ -0,0 +1,13 @@ +package parser + +import "testing" + +func TestNewMemRegion(t *testing.T) { + r := NewMemRegion(0x1000, 0x2000) + if r.Start != 0x1000 { + t.Errorf("Start = 0x%x, want 0x1000", r.Start) + } + if r.End != 0x2000 { + t.Errorf("End = 0x%x, want 0x2000", r.End) + } +} diff --git a/shielded-key-parser/parser/memory_dump_parser_test.go b/shielded-key-parser/parser/memory_dump_parser_test.go new file mode 100644 index 0000000..fb0001e --- /dev/null +++ b/shielded-key-parser/parser/memory_dump_parser_test.go @@ -0,0 +1,52 @@ +package parser + +import ( + "encoding/binary" + "reflect" + "testing" +) + +func TestFindOccurrences(t *testing.T) { + p := NewMemoryDumpParser() + + cases := []struct { + name string + mem string + needle string + want []uint64 + }{ + {"repeated overlapping-free", "ababXabab", "ab", []uint64{0, 2, 5, 7}}, + {"single match", "hello world", "world", []uint64{6}}, + {"no match", "hello world", "zzz", nil}, + {"overlapping needle", "aaaa", "aa", []uint64{0, 1, 2}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := p.findOccurrences([]byte(tc.mem), []byte(tc.needle)) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("findOccurrences(%q, %q) = %v, want %v", tc.mem, tc.needle, got, tc.want) + } + }) + } +} + +func TestReadUint64(t *testing.T) { + p := NewMemoryDumpParser() + mem := make([]byte, 16) + binary.LittleEndian.PutUint64(mem[4:], 0xdeadbeefcafef00d) + + if got := p.readUint64(mem, 4); got != 0xdeadbeefcafef00d { + t.Fatalf("readUint64 = 0x%x, want 0xdeadbeefcafef00d", got) + } +} + +func TestReadUint32(t *testing.T) { + p := NewMemoryDumpParser() + mem := make([]byte, 16) + binary.LittleEndian.PutUint32(mem[8:], 0x11223344) + + if got := p.readUint32(mem, 8); got != 0x11223344 { + t.Fatalf("readUint32 = 0x%x, want 0x11223344", got) + } +} diff --git a/shielded-key-parser/parser/ssh_key_reader_test.go b/shielded-key-parser/parser/ssh_key_reader_test.go new file mode 100644 index 0000000..aca473f --- /dev/null +++ b/shielded-key-parser/parser/ssh_key_reader_test.go @@ -0,0 +1,72 @@ +package parser + +import ( + "bytes" + "encoding/binary" + "testing" + + "shieldbreak/readelf" +) + +// buildKeyDump synthesises a memory image laid out like a real ssh-agent dump: +// an sshkey struct at keyOffset whose shielding fields (at ShieldedPrivatePtrOffset) +// point at a private-key blob and a prekey blob elsewhere in the buffer. The +// segment maps virtual address == file offset (Vaddr and Offset both 0) so the +// pointers can be plain buffer indices. +func buildKeyDump(prekeyLen uint64) (data []byte, keyOffset uint64, seg *readelf.Segment, priv, pre []byte) { + seg = &readelf.Segment{Offset: 0, Vaddr: 0, Filesz: 0x100000} + data = make([]byte, 0x9000) + keyOffset = 0x100 + + priv = []byte("SHIELDED-PRIVATE-KEY-BLOB") + pre = bytes.Repeat([]byte{0xAB}, 16384) + + privOff := uint64(0x2000) + preOff := uint64(0x4000) + copy(data[privOff:], priv) + copy(data[preOff:], pre) + + base := keyOffset + defaultOffsets.ShieldedPrivatePtrOffset + binary.LittleEndian.PutUint64(data[base:], privOff) // shielded_private ptr + binary.LittleEndian.PutUint64(data[base+8:], uint64(len(priv))) // shielded_len + binary.LittleEndian.PutUint64(data[base+16:], preOff) // shield_prekey ptr + binary.LittleEndian.PutUint64(data[base+24:], prekeyLen) // shield_prekey_len + + return data, keyOffset, seg, priv, pre +} + +func TestReadAtOffset_Success(t *testing.T) { + data, keyOffset, seg, priv, pre := buildKeyDump(16384) + + gotPriv, gotPre, err := NewSshKeyReader().ReadAtOffset(data, keyOffset, seg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(gotPriv, priv) { + t.Errorf("private blob = %q, want %q", gotPriv, priv) + } + if len(gotPre) != 16384 { + t.Errorf("prekey length = %d, want 16384", len(gotPre)) + } + if !bytes.Equal(gotPre, pre) { + t.Errorf("prekey blob does not match expected contents") + } +} + +func TestReadAtOffset_InvalidPrekeyLen(t *testing.T) { + data, keyOffset, seg, _, _ := buildKeyDump(1234) // not 16384 + + if _, _, err := NewSshKeyReader().ReadAtOffset(data, keyOffset, seg); err == nil { + t.Fatal("expected error for invalid prekey length, got nil") + } +} + +func TestReadAtOffset_OffsetExceedsData(t *testing.T) { + seg := &readelf.Segment{Offset: 0, Vaddr: 0, Filesz: 0x100000} + data := make([]byte, 0x80) + + // keyOffset + ShieldedPrivatePtrOffset lands past the end of data. + if _, _, err := NewSshKeyReader().ReadAtOffset(data, 0x80, seg); err == nil { + t.Fatal("expected error when key offset exceeds data length, got nil") + } +} diff --git a/shielded-key-parser/readelf/elf_manager_test.go b/shielded-key-parser/readelf/elf_manager_test.go new file mode 100644 index 0000000..ab8c64f --- /dev/null +++ b/shielded-key-parser/readelf/elf_manager_test.go @@ -0,0 +1,67 @@ +package readelf + +import ( + "bytes" + "testing" +) + +func TestCalculateVA(t *testing.T) { + e := NewElfManager() + + // va = pVaddr + (fileOffset - pOffset) + got := e.CalculateVA(0x1500, 0x1000, 0x400000) + want := uint64(0x400500) + if got != want { + t.Fatalf("CalculateVA = 0x%x, want 0x%x", got, want) + } +} + +func TestFindSegmentForOffset(t *testing.T) { + e := NewElfManager() + segs := []Segment{ + {Offset: 0x0000, Vaddr: 0x400000, Filesz: 0x1000}, + {Offset: 0x2000, Vaddr: 0x600000, Filesz: 0x1000}, + } + + cases := []struct { + name string + offset uint64 + wantVaddr uint64 + wantErr bool + }{ + {"start of first", 0x0000, 0x400000, false}, + {"inside first", 0x0800, 0x400000, false}, + {"end of first is exclusive", 0x1000, 0, true}, + {"gap between segments", 0x1800, 0, true}, + {"inside second", 0x2500, 0x600000, false}, + {"past all segments", 0x9999, 0, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + seg, err := e.FindSegmentForOffset(segs, tc.offset) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for offset 0x%x, got segment %+v", tc.offset, seg) + } + return + } + if err != nil { + t.Fatalf("unexpected error for offset 0x%x: %v", tc.offset, err) + } + if seg.Vaddr != tc.wantVaddr { + t.Fatalf("segment Vaddr = 0x%x, want 0x%x", seg.Vaddr, tc.wantVaddr) + } + }) + } +} + +func TestToLittleEndian(t *testing.T) { + e := NewElfManager() + + got := e.ToLittleEndian(0x0102030405060708) + want := []byte{0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01} + if !bytes.Equal(got, want) { + t.Fatalf("ToLittleEndian = % x, want % x", got, want) + } +} diff --git a/test/run-unit-tests.sh b/test/run-unit-tests.sh new file mode 100755 index 0000000..d408883 --- /dev/null +++ b/test/run-unit-tests.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Run the structural Go unit tests through gotestsum, producing machine-readable +# reports (JUnit XML + line-delimited JSON) and a shields.io endpoint badge JSON +# summarising the run. Exits non-zero if any test fails. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +RESULTS_DIR="$ROOT/test-results" +BADGE_DIR="$ROOT/badge" +mkdir -p "$RESULTS_DIR" "$BADGE_DIR" + +# gotestsum wraps `go test --json`; install it on demand if it is not on PATH. +if command -v gotestsum >/dev/null 2>&1; then + GOTESTSUM="$(command -v gotestsum)" +else + echo "[*] gotestsum not found, installing" + # Pinned to a release compatible with the module's Go version (1.23). + go install gotest.tools/gotestsum@v1.12.0 + GOTESTSUM="$(go env GOPATH)/bin/gotestsum" +fi + +echo "[*] Running structural unit tests" +set +e +( cd shielded-key-parser && "$GOTESTSUM" \ + --format testname \ + --junitfile "$RESULTS_DIR/unit.xml" \ + --jsonfile "$RESULTS_DIR/unit.json" \ + -- -count=1 ./... ) +STATUS=$? +set -e + +# Derive counts from the JUnit report and write a shields.io endpoint badge. +python3 - "$RESULTS_DIR/unit.xml" "$BADGE_DIR/tests.json" <<'PY' +import sys, json, xml.etree.ElementTree as ET + +xml_path, out_path = sys.argv[1], sys.argv[2] +root = ET.parse(xml_path).getroot() +suites = [root] if root.tag == "testsuite" else root.iter("testsuite") + +tests = failures = errors = skipped = 0 +for s in suites: + tests += int(s.get("tests", 0) or 0) + failures += int(s.get("failures", 0) or 0) + errors += int(s.get("errors", 0) or 0) + skipped += int(s.get("skipped", 0) or 0) + +bad = failures + errors +passed = tests - bad - skipped + +if bad: + message, color = f"{bad} failed, {passed} passed", "red" +else: + message, color = f"{passed} passed", "brightgreen" + +badge = {"schemaVersion": 1, "label": "tests", "message": message, "color": color} +with open(out_path, "w") as f: + json.dump(badge, f) +print(f"[*] Badge: {json.dumps(badge)}") +PY + +echo "[*] Reports: $RESULTS_DIR/unit.xml, $RESULTS_DIR/unit.json" +echo "[*] Badge: $BADGE_DIR/tests.json" +exit "$STATUS" From af985bdd1e7fecf1cff39e4132273a522c788e6c Mon Sep 17 00:00:00 2001 From: Sakri Koskimies Date: Thu, 6 Aug 2026 20:25:17 +0300 Subject: [PATCH 3/3] ci: Use Codecov for coverage instead of a badges branch --- .github/workflows/ci.yml | 52 +++----------- .gitignore | 6 +- Makefile | 16 +++-- README.md | 17 +++-- codecov.yml | 10 +++ .../parser/ssh_key_reader_test.go | 19 ++++++ test/run-unit-tests.sh | 67 ------------------- 7 files changed, 59 insertions(+), 128 deletions(-) create mode 100644 codecov.yml delete mode 100755 test/run-unit-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d3b891..bb05612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,6 @@ on: branches: [ master ] pull_request: -permissions: - contents: write - jobs: unit: name: Unit tests @@ -20,48 +17,17 @@ jobs: go-version-file: shielded-key-parser/go.mod cache-dependency-path: shielded-key-parser/go.sum - # make test-unit installs a pinned gotestsum on demand if it is missing. - - name: Run structural unit tests - run: make test-unit - - - name: Job summary - if: always() - run: | - { - echo '### Unit test results' - echo '' - echo '```json' - cat badge/tests.json 2>/dev/null || echo '{}' - echo '' - echo '```' - } >> "$GITHUB_STEP_SUMMARY" + - name: Run tests with coverage + working-directory: shielded-key-parser + run: go test -race -covermode=atomic -coverprofile=../coverage.out ./... - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 with: - name: test-results - path: | - test-results/unit.xml - test-results/unit.json - badge/tests.json - - # Publish the badge JSON to a disposable `badges` branch so shields.io can - # render it. Uses the built-in GITHUB_TOKEN, no gist or secret required. - - name: Publish test badge - if: always() && github.ref == 'refs/heads/master' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - tmp="$(mktemp -d)" - cp badge/tests.json "$tmp/tests.json" - cd "$tmp" - git init -q -b badges - git add tests.json - git -c user.name="github-actions[bot]" \ - -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ - commit -qm "ci: update test badge" - git push -f "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:badges + token: ${{ secrets.CODECOV_TOKEN }} + slug: Saggre/shieldbreak + files: ./coverage.out + fail_ci_if_error: false integration: name: Integration (end-to-end) diff --git a/.gitignore b/.gitignore index d160521..ba250ae 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,6 @@ result* # Binary files shieldbreak -# Test reports and generated badges -test-results/ -badge/ +# Coverage output +coverage.out +coverage.txt diff --git a/Makefile b/Makefile index 578d2f9..8024bcc 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,28 @@ -.PHONY: test test-unit test-all clean build build-go-app run-go-app \ +.PHONY: test test-unit test-cover test-all clean build build-go-app run-go-app \ build-shieldbreak-image run-shieldbreak-image \ build-test-dump-generator run-test-dump-generator # Run every test: fast structural unit tests, then the end-to-end test. test-all: test-unit test -# Run the structural Go unit tests via gotestsum, writing JUnit XML, JSON, and a -# badge summary under test-results/ and badge/. +# Run the structural Go unit tests. test-unit: - ./test/run-unit-tests.sh + cd shielded-key-parser && go test ./... + +# Run the unit tests with coverage and write a profile plus a summary. +test-cover: + cd shielded-key-parser && go test -covermode=atomic -coverprofile=../coverage.out ./... + go tool cover -func=coverage.out | tail -n 1 # Run the end-to-end test: generate a dump, extract and unshield the key, # and verify the recovered key matches the original. test: ./test/run-test.sh -# Clean test dump files and generated reports +# Clean test dump files and coverage output clean: rm -f ./dumps/* - rm -rf ./test-results ./badge + rm -f ./coverage.out ./coverage.txt # Build the container images the tool runs in. Everything is compiled inside # the images, so no Go or OpenSSH toolchain is needed on the host. diff --git a/README.md b/README.md index bc8272e..0f13cd6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # shieldbreak [![CI](https://github.com/Saggre/shieldbreak/actions/workflows/ci.yml/badge.svg)](https://github.com/Saggre/shieldbreak/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Saggre/shieldbreak/badges/tests.json)](https://github.com/Saggre/shieldbreak/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/Saggre/shieldbreak/branch/master/graph/badge.svg)](https://codecov.io/gh/Saggre/shieldbreak) A tool for extracting OpenSSH `ssh-agent` **shielded** private keys from a process **core dump**, offline, on x86_64 Linux. @@ -99,21 +99,20 @@ recovered key matches the original by fingerprint. # Fast structural unit tests make test-unit +# Unit tests with a coverage profile and summary +make test-cover + # End-to-end test: generate a dump, extract, unshield, and verify recovery make test -# Both +# Both unit and end-to-end make test-all ``` -`make test-unit` runs the tests through -[gotestsum](https://github.com/gotestyourself/gotestsum) and writes -machine-readable reports to `test-results/` (JUnit XML and line-delimited JSON), -plus a shields.io badge summary to `badge/tests.json`. - Continuous integration runs on GitHub Actions (`.github/workflows/ci.yml`): the -unit tests on every push and pull request, the end-to-end test as a separate -job, and the test-count badge above is published from the unit-test results. +unit tests run with the race detector and coverage on every push and pull +request, coverage is uploaded to [Codecov](https://codecov.io/) (the badge +above), and the end-to-end test runs as a separate job. ## Intended use diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..b923dd3 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +# Coverage is reported for information only and never fails CI. This is a small +# research tool, so a hard coverage gate would add noise without much value. +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true diff --git a/shielded-key-parser/parser/ssh_key_reader_test.go b/shielded-key-parser/parser/ssh_key_reader_test.go index aca473f..b15dc24 100644 --- a/shielded-key-parser/parser/ssh_key_reader_test.go +++ b/shielded-key-parser/parser/ssh_key_reader_test.go @@ -61,6 +61,25 @@ func TestReadAtOffset_InvalidPrekeyLen(t *testing.T) { } } +func TestReadAtOffset_PrivatePointerOutOfRange(t *testing.T) { + data, keyOffset, seg, _, pre := buildKeyDump(16384) + + // Point shielded_private outside the buffer; the prekey pointer stays valid. + base := keyOffset + defaultOffsets.ShieldedPrivatePtrOffset + binary.LittleEndian.PutUint64(data[base:], uint64(len(data))+0x1000) + + gotPriv, gotPre, err := NewSshKeyReader().ReadAtOffset(data, keyOffset, seg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPriv != nil { + t.Errorf("expected nil private blob for out-of-range pointer, got %d bytes", len(gotPriv)) + } + if !bytes.Equal(gotPre, pre) { + t.Errorf("prekey blob should still be recovered when only the private pointer is bad") + } +} + func TestReadAtOffset_OffsetExceedsData(t *testing.T) { seg := &readelf.Segment{Offset: 0, Vaddr: 0, Filesz: 0x100000} data := make([]byte, 0x80) diff --git a/test/run-unit-tests.sh b/test/run-unit-tests.sh deleted file mode 100755 index d408883..0000000 --- a/test/run-unit-tests.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# -# Run the structural Go unit tests through gotestsum, producing machine-readable -# reports (JUnit XML + line-delimited JSON) and a shields.io endpoint badge JSON -# summarising the run. Exits non-zero if any test fails. -# -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" - -RESULTS_DIR="$ROOT/test-results" -BADGE_DIR="$ROOT/badge" -mkdir -p "$RESULTS_DIR" "$BADGE_DIR" - -# gotestsum wraps `go test --json`; install it on demand if it is not on PATH. -if command -v gotestsum >/dev/null 2>&1; then - GOTESTSUM="$(command -v gotestsum)" -else - echo "[*] gotestsum not found, installing" - # Pinned to a release compatible with the module's Go version (1.23). - go install gotest.tools/gotestsum@v1.12.0 - GOTESTSUM="$(go env GOPATH)/bin/gotestsum" -fi - -echo "[*] Running structural unit tests" -set +e -( cd shielded-key-parser && "$GOTESTSUM" \ - --format testname \ - --junitfile "$RESULTS_DIR/unit.xml" \ - --jsonfile "$RESULTS_DIR/unit.json" \ - -- -count=1 ./... ) -STATUS=$? -set -e - -# Derive counts from the JUnit report and write a shields.io endpoint badge. -python3 - "$RESULTS_DIR/unit.xml" "$BADGE_DIR/tests.json" <<'PY' -import sys, json, xml.etree.ElementTree as ET - -xml_path, out_path = sys.argv[1], sys.argv[2] -root = ET.parse(xml_path).getroot() -suites = [root] if root.tag == "testsuite" else root.iter("testsuite") - -tests = failures = errors = skipped = 0 -for s in suites: - tests += int(s.get("tests", 0) or 0) - failures += int(s.get("failures", 0) or 0) - errors += int(s.get("errors", 0) or 0) - skipped += int(s.get("skipped", 0) or 0) - -bad = failures + errors -passed = tests - bad - skipped - -if bad: - message, color = f"{bad} failed, {passed} passed", "red" -else: - message, color = f"{passed} passed", "brightgreen" - -badge = {"schemaVersion": 1, "label": "tests", "message": message, "color": color} -with open(out_path, "w") as f: - json.dump(badge, f) -print(f"[*] Badge: {json.dumps(badge)}") -PY - -echo "[*] Reports: $RESULTS_DIR/unit.xml, $RESULTS_DIR/unit.json" -echo "[*] Badge: $BADGE_DIR/tests.json" -exit "$STATUS"