Skip to content

fix(dicompot): close DICOM fingerprint tells 2 and 5 by patching the dependency at build time - #3428

Merged
Xore merged 1 commit into
mainfrom
oc/3179-decision
Sep 28, 2026
Merged

Xore merged 1 commit into
mainfrom
oc/3179-decision

Conversation

@Xore

@Xore Xore commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Refs #3179 — decision + implementation.

What was wrong

Tells 1, 3 and 4 were closed in arcane/home/honeypot-dicompot/dicompot/aetitle.go, on the raw A-ASSOCIATE bytes between the socket and dicompot.RunProviderForConn. The last two are decided below that boundary, inside github.com/nsmfoo/dicompot, with no exported hook, callback or config knob to override them:

  • Tell 2 — contextmanager.go's onAssociateRequest answered Result: 0 (acceptance) in the A-ASSOCIATE-AC for every abstract syntax it could parse, including private-root UIDs no SCP serves and strings that are not UIDs at all. A real SCP answers 0x03 ("abstract syntax not supported") in that context's response item, with no transfer-syntax sub-item attached (PS3.8 9.3.3.2, Table 9-9), and does not register the context.
  • Tell 5 — serviceprovider.go's handleCFind / handleCMove / handleCGet all ended on 0x0000, claiming success for a C-MOVE that moved nothing and a C-FIND for a patient the archive has never heard of. A real SCP refuses from the 0xA7xx family (PS3.4 Table C.4-8).

Vendoring shape: build-time patch, not a committed vendored copy

This repo has never committed a vendored copy of a patched dependency — all ~25 existing patches (txtcmds_priority_patch.py, service_request_log_patch.py, json_log_patch.py, dionaea's six, conpot's seven, …) are build-time exact-match rewrites of a source tree fetched at a pinned revision. dicompot is a go get dependency whose committed go.mod/go.sum is the pin, so a committed copy would turn a one-line pin bump into a hand-merged tree for files this repo does not own, and would make the drift invisible to go mod.

So dimse_status_patch/ rewrites a writable copy of the pinned module inside the image build, and go mod edit -replace points the build at that copy. go.mod/go.sum stay exactly as committed — which is also what makes the RED run below possible. It is written in Go only because the target is a Go module and the anchors must be byte-exact Go source.

No data set is deserialized

Both refusals are status codes on paths that already exist, decided from state the DIMSE command layer has already parsed: the command's own AffectedSOPClassUID and cs.context.abstractSyntaxUID. Nothing added here reads a received data set or introduces a decoder. TestInjectedSourceDeserializesNothing asserts the injected source references none of []byte, dicomio, ReadElement, ReadPDU, ReadMessage, DecodeMessage, bufio, regexp.

The C-MOVE destination that would decide 0xA701 more precisely (Move Destination 0008,0100) lives inside the query data set, which is exactly why it is not consulted.

RED against the unpatched dependency

ax_status_test.go copies the pinned module twice, writes the same in-package suite into both, and runs go test in each — so the proof is structural, not a claim in a commit message.

=== RUN   TestSuiteIsRedAgainstThePinnedDependencyAndGreenWithThePatch/red_against_the_unpatched_dependency
    --- FAIL: TestAssociationRefusesUnsupportedAbstractSyntaxWith0x03 (0.00s)
        ax_status_3179_test.go:82: presentation context result = 0, want 3 (abstract syntax not supported) for 1.3.6.1.4.1.99999.1.2.1
        ax_status_3179_test.go:86: refused presentation context carried 1 sub-item(s), want 0: PS3.8 Table 9-9 attaches the transfer syntax only on acceptance
        ax_status_3179_test.go:90: a refused presentation context is still registered and usable for data transfer, so a rejected SOP Class could still carry a C-FIND (upstream's addContextMapping)
    --- FAIL: TestAssociationRefusesSyntacticallyInvalidAbstractSyntaxWith0x03/trailing_separator
        ax_status_3179_test.go:116: presentation context result = 0, want 3 for "1.2.840.10008.5.1.4.1.2.1.1."
    ... (7 malformed-UID subtests: trailing/leading separator, not a UID at all,
         empty component, leading zero, non-numeric component, over PS3.5's 64-char ceiling)
    --- FAIL: TestAssociationAcceptsABlanketListOfSupportedClassesInOneProposal (0.00s)
        ax_status_3179_test.go:217: presentation context 1 result = 0, want 3
        ax_status_3179_test.go:222: the refused context 3 is still usable, so a C-FIND could run on a SOP Class that was rejected
    --- FAIL: TestCFindRefusesAnUnsupportedSOPClassWithA702 (0.00s)
        ax_status_3179_test.go:346: C-FIND status = 0x0000 (StatusSuccess), want 0xa702 (CMoveOutOfResourcesUnableToPerformSubOperations)
    --- FAIL: TestCFindRefusesAMismatchedAbstractSyntaxWithA702 (0.00s)
        ax_status_3179_test.go:362: C-FIND status = 0x0000 (StatusSuccess), want 0xa702
    --- FAIL: TestCMoveRefusesWithA701WhenNoDestinationIsConfigured (0.00s)
        ax_status_3179_test.go:392: C-MOVE for 1.2.840.10008.5.1.4.1.2.1.2 status = 0x0000, want 0xa701 (…UnableToCalculateNumberOfMatches)
    --- FAIL: TestCGetRefusesWithA701WhenNoDestinationIsConfigured (0.00s)
        ax_status_3179_test.go:422: C-GET for 1.2.840.10008.5.1.4.1.2.1.3 status = 0x0000, want 0xa701
    --- FAIL: TestCGetRefusesAnUnsupportedSOPClassWithA702 (0.00s)
        ax_status_3179_test.go:432: C-GET status = 0x0000 (StatusSuccess), want 0xa702
    --- FAIL: TestCMoveRefusesAnUnsupportedSOPClassWithA702 (0.00s)
        ax_status_3179_test.go:440: C-MOVE status = 0x0000 (StatusSuccess), want 0xa702

GREEN with the patch

=== RUN   TestSuiteIsRedAgainstThePinnedDependencyAndGreenWithThePatch/green_with_the_patch_applied
--- PASS: TestSuiteIsRedAgainstThePinnedDependencyAndGreenWithThePatch (1.35s)
    --- PASS: .../red_against_the_unpatched_dependency (0.68s)
    --- PASS: .../green_with_the_patch_applied (0.68s)
ok  	dicompot-honeypot/dimse_status_patch	1.356s

The negative subtests are mandatory and present. They pass in both runs, which is the point — the patch must not cost the decoy its working paths:

Test Asserts
TestAssociationStillAcceptsEverySupportedAbstractSyntax all ~120 classes in VerificationClasses ∪ StorageClasses ∪ QRFindClasses ∪ QRMoveClasses ∪ QRGetClasses still negotiate with result 0, one transfer-syntax sub-item, and stay usable
TestCFindStillAnswersSuccessForASupportedQuery a valid C-FIND still answers 0x0000
TestCMoveStillAnswersSuccessWhenADestinationIsConfigured C-MOVE still answers 0x0000 when RemoteAEs are configured
TestARefusedQueryStillRunsTheCallback the honeypot still logs a refused probe

TestAssociationStillAcceptsEverySupportedAbstractSyntax also fails the build if the dependency's SOP Class lists ever shrink below 100 entries, so it cannot quietly stop covering the acceptance path.

Drift protection

Three independent guards, all failing loudly rather than silently shipping an unpatched decoy:

  1. Anchors are exact byte matches, match count must be 1. A pin bump that moves the anchored code fails the image build (TestApplyFailsWhenThePinnedTextHasDrifted covers the two most likely moves).
  2. PinnedRevision is asserted against the go.mod pin by the driver test, so the three places that must agree — go.mod, PinnedRevision, the Dockerfile comment — cannot drift apart silently.
  3. The Dockerfile re-checks that the replace took effect (test "$(go list -m -f '{{.Dir}}' …)" = /src/dicompot-patched), so a silently ineffective go mod edit fails the build rather than producing an unpatched image.

The patcher is idempotent (a second apply is a no-op) and refuses to overwrite an apistatus3179.go that does not carry the APIARY #3179 marker.

Verified

  • gofmt -l . — clean, repo-wide. The injected helper is a real testdata/*.go file (via //go:embed) rather than an inline string, so the repo's own gofmt gate formats the code that actually lands in the dependency.
  • go vet ./... — clean.
  • go test -count=1 ./... — ok dicompot-honeypot (existing tests unchanged) and ok dicompot-honeypot/dimse_status_patch.
  • hadolint --config .hadolint.yaml (the exact CI invocation, repo-wide) — no new findings. go run tripped DL3062, so the patcher is built to a binary and exec'd instead; nothing was allowlisted.
  • The RUN chain was replayed locally end-to-end, and the resulting binary contains the injected Presentation context refused log line where a control build without the replace does not (5,243,042 vs 5,222,562 bytes).
  • scripts/check-compose-env-docs.py — contract holds. go.mod/go.sum byte-identical to before.

Two harness details a warm module cache would have hidden

CI found these; a locally-warm cache hides both, so they are fixed here rather than papered over.

  1. A non-zero exit is not proof of RED. A suite that stopped compiling against the pinned dependency, or that could not resolve a module, also exits non-zero — and would have left both the red and green subtests green while saying nothing about either tell. buildFailureIn and moduleResolutionFailureIn now detect both and report them as what they are.

  2. The copy is built under the pinned module's go.mod. Minimal version selection there resolves its requirements — golang.org/x/sys v0.1.0, golang.org/x/text v0.3.8 — not the v0.47.0/v0.41.0 this module's own build list upgraded them to. A clean runner has only the higher versions, so the copy could not resolve anything the outer build never downloaded:

    ##[error] .../go-dicom@v0.0.0-20190117035129-c30d9eaca591/dicomio/buffer.go:13:2: module lookup disabled by GOPROXY=off
    FAIL	github.com/nsmfoo/dicompot [setup failed]
    

    warmDependencyDeps now populates the cache once per test process with the ambient proxy, which keeps the nested runs themselves hermetic (GOPROXY=off). It is best-effort by design: a developer running offline against an already-warm cache cannot fetch but does not need to.

Verified against a throwaway GOMODCACHE reproducing the runner exactly:

Scenario Result
clean cache + network (CI) passes; cache gains v0.1.0 alongside v0.47.0
offline + warm cache (developer) passes
clean cache + GOPROXY=off fails with the cache diagnostic, explicitly not reported as a behavioural RED

Deliberate trade-off

A C-FIND arriving on a now-rejected presentation context gets an A-ABORT (what a real SCP does with a rejected context) rather than a c_find JSON event. The refusal sits after the callback launch, so the probe is still recorded — as a logrus Presentation context refused line with the abstract syntax, context ID and result.

Ongoing cost of tracking upstream

A pin bump requires re-deriving 5 anchors (contextmanager.go ×1, serviceprovider.go ×4) and regenerating testdata/apistatus3179.go against the new revision. In practice the guards above fail first and name the file and anchor, so the cost is one build/test cycle and a re-derivation — not a silent regression. The suite's own comment records the same expectation.

Note

No workflow change: arcane/home/honeypot-dicompot/dicompot is already in the go-test matrix and go test ./... picks up the new package. No new action is pinned and no new zizmor surface is introduced.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@Xore
Xore force-pushed the oc/3179-decision branch 3 times, most recently from efd74d3 to 0355c26 Compare September 27, 2026 18:53
@Xore
Xore enabled auto-merge (squash) September 27, 2026 20:04
@Xore
Xore disabled auto-merge September 27, 2026 23:22
…t build time (#3179)

#3155's sensor-fidelity audit found five fingerprint tells in the DICOM
decoy. Tells 1, 3 and 4 were closed in aetitle.go, on the raw A-ASSOCIATE
bytes between the socket and dicompot.RunProviderForConn. The last two are
decided below that boundary, inside the dependency, with no exported hook,
callback or config knob to override them:

  - Tell 2: contextmanager.go's onAssociateRequest answered result 0
    (acceptance) in the A-ASSOCIATE-AC for every abstract syntax it could
    parse, including private-root UIDs no SCP serves and strings that are
    not UIDs at all. A real SCP answers 0x03 in that context's response
    item, with no transfer-syntax sub-item attached (PS3.8 9.3.3.2,
    Table 9-9).
  - Tell 5: serviceprovider.go's handleCFind / handleCMove / handleCGet
    always ended on 0x0000, claiming success for a C-MOVE that moved
    nothing.

Both are status codes on paths that already exist, computed from state the
DIMSE command layer has already parsed -- the command's own Affected SOP
Class UID and the abstract syntax of the presentation context it arrived on.
No received DICOM object is deserialized by anything added here, and no new
decoder is introduced; TestInjectedSourceDeserializesNothing asserts the
injected source references none of the reader APIs.

Vendoring shape: a build-time patch program, not a committed vendored copy.
This repo has never committed a vendored copy of a patched dependency --
all ~25 existing patches are build-time rewrites of a source tree fetched at
a pinned revision -- and dicompot is a `go get` dependency whose committed
go.mod/go.sum is the whole pin. A committed copy would turn a one-line pin
bump into a hand-merged tree for files this repo does not own, and would
make the drift invisible to `go mod`. So dimse_status_patch/ rewrites a
writable copy of the pinned module inside the image build and
`go mod edit -replace` points the build at that copy. go.mod/go.sum stay
exactly as committed, which is what lets the suite below run RED against
the unpatched pin.

The patcher anchors are verbatim from the pinned revision and a match count
other than 1 is a hard error, so a pin bump that moves the patched code
fails the image build instead of shipping an unpatched decoy. The Dockerfile
re-checks that the replace took effect, and a test asserts the patcher's
PinnedRevision against the go.mod pin. Written in Go only because the target
is a Go module and the anchors must be byte-exact Go source.

Tests (ax_status_test.go) copy the pinned module twice, write the same
in-package suite into both, and assert red-unpatched / green-patched -- so
the proof is structural rather than a claim in a commit message. The
negative subtests are mandatory and present: every one of the ~120 supported
SOP Classes still negotiates, and a valid C-FIND still answers 0x0000.

Two test-harness details that a locally-warm module cache would have hidden:

  - A non-zero exit is not proof of RED. A suite that no longer compiles
    against the pinned dependency, or cannot resolve a module, also exits
    non-zero and would leave the red/green subtests green while saying
    nothing about either tell. Both are now detected and reported as what
    they are.
  - The copy is built under the *pinned module's* go.mod, so minimal version
    selection there resolves its requirements (golang.org/x/sys v0.1.0,
    golang.org/x/text v0.3.8) rather than the higher versions this module's
    own build list upgraded them to. A clean runner has only the higher ones,
    so the copy cannot resolve anything the outer build never downloaded.
    The cache is warmed once per test process with the ambient proxy, which
    keeps the nested runs themselves hermetic.

No workflow change: the dicompot module is already in the go-test matrix,
and `go test ./...` picks up the new package, so no new action is pinned and
no new zizmor surface is introduced.
@Xore
Xore merged commit d1d8218 into main Sep 28, 2026
122 of 123 checks passed
@Xore
Xore deleted the oc/3179-decision branch September 28, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant