Repository navigation
fix(dicompot): close DICOM fingerprint tells 2 and 5 by patching the dependency at build time - #3428
Merged
Merged
Conversation
Xore
force-pushed
the
oc/3179-decision
branch
from
September 27, 2026 18:25
7791bec to
9b54fc5
Compare
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
Xore
force-pushed
the
oc/3179-decision
branch
3 times, most recently
from
September 27, 2026 18:53
efd74d3 to
0355c26
Compare
Xore
enabled auto-merge (squash)
September 27, 2026 20:04
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
force-pushed
the
oc/3179-decision
branch
from
September 28, 2026 00:45
0355c26 to
15da65a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 anddicompot.RunProviderForConn. The last two are decided below that boundary, insidegithub.com/nsmfoo/dicompot, with no exported hook, callback or config knob to override them:contextmanager.go'sonAssociateRequestansweredResult: 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 answers0x03("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.serviceprovider.go'shandleCFind/handleCMove/handleCGetall ended on0x0000, 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 the0xA7xxfamily (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.dicompotis ago getdependency whose committedgo.mod/go.sumis 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 togo mod.So
dimse_status_patch/rewrites a writable copy of the pinned module inside the image build, andgo mod edit -replacepoints the build at that copy.go.mod/go.sumstay 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
AffectedSOPClassUIDandcs.context.abstractSyntaxUID. Nothing added here reads a received data set or introduces a decoder.TestInjectedSourceDeserializesNothingasserts the injected source references none of[]byte,dicomio,ReadElement,ReadPDU,ReadMessage,DecodeMessage,bufio,regexp.The C-MOVE destination that would decide
0xA701more precisely (Move Destination0008,0100) lives inside the query data set, which is exactly why it is not consulted.RED against the unpatched dependency
ax_status_test.gocopies the pinned module twice, writes the same in-package suite into both, and runsgo testin each — so the proof is structural, not a claim in a commit message.GREEN with the patch
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:
TestAssociationStillAcceptsEverySupportedAbstractSyntaxVerificationClasses∪StorageClasses∪QRFindClasses∪QRMoveClasses∪QRGetClassesstill negotiate with result 0, one transfer-syntax sub-item, and stay usableTestCFindStillAnswersSuccessForASupportedQuery0x0000TestCMoveStillAnswersSuccessWhenADestinationIsConfigured0x0000when RemoteAEs are configuredTestARefusedQueryStillRunsTheCallbackTestAssociationStillAcceptsEverySupportedAbstractSyntaxalso 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:
TestApplyFailsWhenThePinnedTextHasDriftedcovers the two most likely moves).PinnedRevisionis asserted against thego.modpin by the driver test, so the three places that must agree —go.mod,PinnedRevision, the Dockerfile comment — cannot drift apart silently.replacetook effect (test "$(go list -m -f '{{.Dir}}' …)" = /src/dicompot-patched), so a silently ineffectivego mod editfails 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.gothat does not carry theAPIARY #3179marker.Verified
gofmt -l .— clean, repo-wide. The injected helper is a realtestdata/*.gofile (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) andok dicompot-honeypot/dimse_status_patch.hadolint --config .hadolint.yaml(the exact CI invocation, repo-wide) — no new findings.go runtrippedDL3062, so the patcher is built to a binary and exec'd instead; nothing was allowlisted.RUNchain was replayed locally end-to-end, and the resulting binary contains the injectedPresentation context refusedlog line where a control build without thereplacedoes not (5,243,042 vs 5,222,562 bytes).scripts/check-compose-env-docs.py— contract holds.go.mod/go.sumbyte-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.
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.
buildFailureInandmoduleResolutionFailureInnow detect both and report them as what they are.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 thev0.47.0/v0.41.0this 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:warmDependencyDepsnow 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
GOMODCACHEreproducing the runner exactly:v0.1.0alongsidev0.47.0GOPROXY=offDeliberate 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_findJSON event. The refusal sits after the callback launch, so the probe is still recorded — as a logrusPresentation context refusedline 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 regeneratingtestdata/apistatus3179.goagainst 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/dicompotis already in the go-test matrix andgo test ./...picks up the new package. No new action is pinned and no new zizmor surface is introduced.