@cosyte/dicom 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +376 -0
- package/README.md +6 -2
- package/dist/index.cjs +276 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +276 -10
- package/dist/index.d.ts +276 -10
- package/dist/index.mjs +276 -16
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,8 +4,372 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **The `attw` publish gate exited 0 on a tarball carrying no type declarations, so a broken publish
|
|
10
|
+
read as a pass** (`ATTW-FALSE-GREEN-PORT`). `pnpm attw` now runs `scripts/attw.mjs`, ported from
|
|
11
|
+
`@cosyte/terminology` (#28, `bf153cb`), where the defect was diagnosed; `typecheck:exports` runs
|
|
12
|
+
through the same wrapper with its `--profile node16` forwarded. The code ported, the measurements
|
|
13
|
+
were re-taken on this package rather than carried over.
|
|
14
|
+
|
|
15
|
+
**The cause is unconditional CLI semantics, not a lenient invocation and not concurrency.**
|
|
16
|
+
`getExitCode.js` in `@arethetypeswrong/cli@0.18.4` opens with `if (!analysis.types) return 0`,
|
|
17
|
+
returning before the problem list is read, because an untyped package is a legitimate npm package.
|
|
18
|
+
Reproduced here with **zero concurrency**: on a quiet box `rm -f dist/index.d.ts dist/index.d.cts
|
|
19
|
+
&& pnpm attw` and `rm -rf dist && pnpm attw` both print "This package does not contain types." and
|
|
20
|
+
exit **0**. Concurrency only supplies the condition. `tsup` writes the JS bundles before the
|
|
21
|
+
declaration files, so every build of this package has a window in which `dist/` holds
|
|
22
|
+
`index.mjs`/`index.cjs` and no `index.d.ts`: measured over three clean `pnpm build` runs on an idle
|
|
23
|
+
box, polling every 25ms, at **1.06s, 1.23s and 1.43s** (JS at +4.83s to +5.36s, declarations at
|
|
24
|
+
+6.06s to +6.49s). That is why the remedy is not a lock, a lease or a build queue - a lock would
|
|
25
|
+
leave the defect intact on an idle box, and the gate is supposed to be able to say its own inputs
|
|
26
|
+
were missing whatever removed them.
|
|
27
|
+
|
|
28
|
+
**Two nets, which catch different things.** A **preflight** that every relative path
|
|
29
|
+
`package.json` promises (`main`, `module`, `types`, `typings`, and every string leaf of `exports`,
|
|
30
|
+
which here is `./dist/index.cjs`, `./dist/index.mjs`, `./dist/index.d.ts` and
|
|
31
|
+
`./dist/index.d.cts`) exists and is non-empty; it catches the build window and names the missing
|
|
32
|
+
file rather than leaving a reader to infer it from a sentence about types. And a **post-check**
|
|
33
|
+
that promotes `attw`'s untyped sentence to a failure, which the preflight structurally cannot see:
|
|
34
|
+
the declarations can be on disk and still be absent from the tarball because `files` or
|
|
35
|
+
`.npmignore` left them out. No instance of that second case is on record in this repo. **Neither
|
|
36
|
+
net covers the rest of `files`** (`README.md`, `LICENSE`, `TRADEMARKS.md`, `CHANGELOG.md`), stated
|
|
37
|
+
plainly rather than left to be discovered: `attw` analyses types and never looks at them.
|
|
38
|
+
|
|
39
|
+
**The post-check reads a string, so the routes that would hide that string are refused**, by option
|
|
40
|
+
and not by value. **Eleven were measured here** on a fixture whose tarball genuinely carries no
|
|
41
|
+
types, each restoring the exact exit-0 with the sentence unreadable: `--quiet`, `-q`,
|
|
42
|
+
`--format json`, `-f json`, `--format=json`, `-fjson`, `-qf json`, `-Pfjson`, a `.attw.json`
|
|
43
|
+
setting `quiet` or `format` (`readConfig()` applies config after argv), and **`--config-path`**
|
|
44
|
+
pointing at a file that sets one - that last is the difference from terminology's copy, which
|
|
45
|
+
refused it by inference and said so.
|
|
46
|
+
|
|
47
|
+
**The three cluster forms are why the predicate is not an exact-token set, and a first draft of
|
|
48
|
+
this change got that wrong.** It matched each whole argument, before any `=`, against a set of
|
|
49
|
+
exact spellings; commander lets a short option's value attach to it and lets shorts combine, so
|
|
50
|
+
`-f` is not visible as a token in `-fjson`. Measured on that draft, on the untyped fixture:
|
|
51
|
+
`-fjson` gave **exit 0 with the gate silent**. A single-dash argument is now refused if any
|
|
52
|
+
character in its cluster is `q` or `f`, which is sound because `-f` is `attw`'s only value-taking
|
|
53
|
+
short option.
|
|
54
|
+
|
|
55
|
+
Measured the other way too, and both directions are stated because the bound matters: `--format
|
|
56
|
+
table-flipped` and `--format ascii` still print the sentence and blind nothing, and are refused
|
|
57
|
+
anyway (value-parsing them would be a third moving part in the guard); while `--form json`,
|
|
58
|
+
`--quiet=true` and `-f=json` each look like a route and are not, because commander rejects them
|
|
59
|
+
outright with exit 1. **The over-strictness is bounded and nothing else is refused**: a forwarded
|
|
60
|
+
`--profile node16` or `-P` still reaches `attw`. A forwarded extra positional does not retarget the
|
|
61
|
+
run either - `--pack .` supplies the first positional and `attw` ignores the second, so the
|
|
62
|
+
analysis stays on this package.
|
|
63
|
+
|
|
64
|
+
`test/scripts/attw-gate.test.ts` pins both nets against the real binary: the upstream exit-0
|
|
65
|
+
itself, so an `attw` upgrade that fixes it or rewords the sentence reds instead of letting the net
|
|
66
|
+
go slack; the `tsup` window in this package's own dual ESM/CJS `exports` shape, asserting both that
|
|
67
|
+
bare `attw` passes it with exit 0 and that the wrapper reds naming `./dist/index.d.ts`; a negative
|
|
68
|
+
control on a well-formed package; that a real `attw` failure still fails with `attw`'s own status;
|
|
69
|
+
and that other arguments are still forwarded. **Proved non-vacuous by putting the bare invocation
|
|
70
|
+
back into the script: 15 of the 22 tests red**, re-measured on the file as it stands. The 7 that
|
|
71
|
+
survive are exactly the ones that must pass on both: the upstream exit-0 pin, the well-formed
|
|
72
|
+
negative control, the real-failure parity case, argument forwarding, and the three controls that
|
|
73
|
+
only ever invoke the bare tool.
|
|
74
|
+
`scripts/verify.sh` in the meta-repo needed no change and was not touched, and no lock, lease,
|
|
75
|
+
semaphore or queue was added (ADR 0015).
|
|
76
|
+
|
|
77
|
+
### Changed
|
|
78
|
+
|
|
79
|
+
- `format` and `format:check` now cover `scripts/**/*.mjs` as well as `scripts/**/*.ts`, so the new
|
|
80
|
+
gate script is actually format-gated. Exactly one pre-existing file entered that glob,
|
|
81
|
+
`scripts/sync-version.mjs`, and it was verified Prettier-clean before the glob widened, so nothing
|
|
82
|
+
is red-flagged retroactively. (The tree's other `.mjs` file, `test/smoke/esm/index.mjs`, is under
|
|
83
|
+
`test/` and is not covered by this glob or by the `test/**/*.ts` one.)
|
|
84
|
+
|
|
85
|
+
**`lint` was deliberately NOT widened the same way, and the reason is measured rather than
|
|
86
|
+
assumed.** ESLint applies no rules at all to `.mjs` here: the shared `@cosyte/eslint-config` rule
|
|
87
|
+
blocks are scoped to `**/*.ts`, so `eslint --print-config scripts/attw.mjs` returns only the
|
|
88
|
+
Prettier-conflict disables, and a deliberately seeded unused variable plus a missing semicolon in
|
|
89
|
+
`scripts/attw.mjs` produced **zero** findings. Widening the `lint` glob would have added a
|
|
90
|
+
gate-shaped thing that gates nothing, which is the exact failure this change exists to remove.
|
|
91
|
+
Making ESLint genuinely cover `.mjs` means changing the shared config, and that is its own slice.
|
|
92
|
+
|
|
7
93
|
### Security
|
|
8
94
|
|
|
95
|
+
- **A symbolic link under `test/fixtures/` pointing at a PHI-bearing file scanned CLEAN on BOTH of
|
|
96
|
+
`scripts/phi-scan.ts`'s enumerating routes, so the commit gate passed it twice over**
|
|
97
|
+
(`PHI-SCAN-SYMLINK-BLIND-ON-BOTH-ROUTES`; pre-existing). Reproduced on `0f898be` with a synthetic
|
|
98
|
+
name-bearing payload outside the walk root and a link to it at `test/fixtures/leak.txt`: all-mode
|
|
99
|
+
exited **0** printing `OK - no hits`, `--staged` exited **0** after `git add`, and naming the
|
|
100
|
+
target explicitly exited **1** with three hits (the PN plus both dates). The payload was always
|
|
101
|
+
detectable; the two routes never looked at it.
|
|
102
|
+
|
|
103
|
+
**Two mechanisms, two fixes.** `walk()` enumerates `Dirent.isFile()`, which is an lstat answer, so
|
|
104
|
+
a link is neither a file nor a directory and fell out of the loop silently. `isDirectory()`
|
|
105
|
+
answers false for a **linked directory** too, so a whole subtree vanished the same way (measured:
|
|
106
|
+
a link to a directory holding the payload, exit 0). And `--staged` reads content with
|
|
107
|
+
`git show :<path>`, while git stores a link as its **target path** under mode `120000`, so that
|
|
108
|
+
route was handed the path text and never the target's bytes. `--staged` is this repo's
|
|
109
|
+
`pre-commit` hook.
|
|
110
|
+
|
|
111
|
+
**Neither route is made to follow the link.** Following would read bytes the enumeration does not
|
|
112
|
+
control (outside the repo, a loop, a device, a FIFO that blocks the gate forever), and git does not
|
|
113
|
+
carry those bytes anyway, so a hit on them would be a claim about something no commit contains.
|
|
114
|
+
The enumeration is narrowed instead: an **in-scope entry that is not a regular file refuses the
|
|
115
|
+
scan** (exit 2), naming every offender rather than the first. `--staged` now reads
|
|
116
|
+
`git diff --cached --raw -z` so the destination mode is visible; an unparseable `--raw` record
|
|
117
|
+
refuses too, because a silently shortened list is exactly what this scan must never report clean
|
|
118
|
+
over. **Be precise about which mode was blind:** `120000` was the leak. A staged **gitlink**
|
|
119
|
+
(`160000`) already exited 2 on the base scanner, because `git show :<path>` fails outright on one
|
|
120
|
+
(`fatal: bad object`, exit 128) and the read-error path caught it. Its refusal is now a named one
|
|
121
|
+
with a reason instead of a relayed git error, which is an improvement in the message, not the
|
|
122
|
+
closing of a hole.
|
|
123
|
+
|
|
124
|
+
**`--diff-filter=AMT`, not `AM`, and the one-letter difference is what makes the mode check
|
|
125
|
+
reachable.** Replacing a **tracked** fixture with a link is neither an add nor a modify: git raises
|
|
126
|
+
it as a typechange, `:100644 120000 <sha> <sha> T`. Measured on git 2.39.5, both
|
|
127
|
+
`git diff --cached --raw --diff-filter=AM` and `--name-only --diff-filter=AM` print **nothing** for
|
|
128
|
+
that stage, so the record died before any mode could be read and the hook would have passed a
|
|
129
|
+
mode-`120000` blob green while this entry claimed it refuses one. Typechange carries a single path,
|
|
130
|
+
exactly like `A` and `M`, so admitting it costs the two-field record stride nothing, and the
|
|
131
|
+
reverse typechange (a link replaced by a real file) is now scanned as the file it became.
|
|
132
|
+
|
|
133
|
+
**`--no-renames` as well, and the status filter alone was not enough - the gate's first pass
|
|
134
|
+
claimed otherwise and was refuted by measurement.** Rename detection is on by default, so
|
|
135
|
+
`git mv <link> test/fixtures/<name>` - an ordinary developer action, no crafted input - stages as
|
|
136
|
+
`:120000 120000 <sha> <sha> R100` with **two** paths, which `--diff-filter=AMT` then deletes
|
|
137
|
+
outright: the index held `120000 ... test/fixtures/toplink.txt` and this route printed
|
|
138
|
+
`OK - no hits` and exited **0**. The first pass disclosed that as out of scope on the grounds that
|
|
139
|
+
admitting a rename "needs the two-path record shape handled". It does not. With detection off the
|
|
140
|
+
destination arrives as an ordinary single-path `A` (`:000000 120000 0000000 <sha> A`) and the
|
|
141
|
+
source as a `D` the filter drops, so the two-field stride is untouched and the entry is refused.
|
|
142
|
+
It also makes that stride **structural rather than conditional**: with detection off, no `R` or `C`
|
|
143
|
+
record can be produced whatever the caller's `diff.renames` setting is. The cost is that the
|
|
144
|
+
destination of an ordinary clean rename into `test/fixtures/` is now scanned where it used to be
|
|
145
|
+
dropped, which is more coverage rather than less, and is pinned in both directions.
|
|
146
|
+
|
|
147
|
+
**The fixture root's own path is in scope too, not just what is under it.** A prefix test requiring
|
|
148
|
+
the trailing slash let a staged `test/fixtures` through (measured: mode `120000` at exactly that
|
|
149
|
+
path, exit 0) - the corpus root replaced by a link, so the whole corpus goes unscanned. Git records
|
|
150
|
+
no index entry for a directory, so that path can only mean a blob, a link or a gitlink, and it is
|
|
151
|
+
now refused. Only the "never a directory" half is load-bearing; the other three are all handled.
|
|
152
|
+
|
|
153
|
+
**A refusal names the entry's own repo-relative path and an engine-owned kind token, never the
|
|
154
|
+
link target**, which is working-tree text that can itself carry PHI. That is not hypothetical
|
|
155
|
+
here: on the base scanner, a link whose target filename carried a name and a date made `--staged`
|
|
156
|
+
exit **1** and print a hit whose value was **the date out of the filename** - a report about the
|
|
157
|
+
working tree's own text, not about anything the target contained. For the same reason the shape of
|
|
158
|
+
such a filename is written out in the scanner's docblock rather than exemplified: a diagnostic
|
|
159
|
+
about a PHI leak is itself a PHI surface, and that applies to the prose explaining it.
|
|
160
|
+
|
|
161
|
+
**Almost all of the scope is unchanged, and the two places it moved both admit MORE.** All-mode
|
|
162
|
+
still walks only `test/fixtures/` and still excludes a gitignored entry, by the same rule that
|
|
163
|
+
already excludes a gitignored fixture (`git check-ignore` does not answer for a tracked path, so
|
|
164
|
+
force-adding the link puts it back in scope, and that is pinned). `--staged` still covers only the
|
|
165
|
+
fixture path, and only the staged records git reports as added, modified or typechanged - a
|
|
166
|
+
deletion has no staged blob to scan and an unmerged path has no single one, both pre-existing and
|
|
167
|
+
both stated in the scanner's own banner rather than left to be inferred from the path prefix. The
|
|
168
|
+
two movements are the rename destination and the root path above; neither widens a route to a new
|
|
169
|
+
directory, and a link outside either boundary is still left alone, tested in both directions. The
|
|
170
|
+
`readme.md` exemption deliberately does **not** reach a link: that exemption is a judgement about a
|
|
171
|
+
file whose bytes the walk could have read, and a link's name is no evidence at all about what is on
|
|
172
|
+
the other side.
|
|
173
|
+
|
|
174
|
+
Pinned in `test/scripts/phi-scan.test.ts` with a synthetic name-bearing payload whose target
|
|
175
|
+
filename also carries a name, so the no-echo assertions cannot pass by fixture: **13 of the 31
|
|
176
|
+
cases are red on `0f898be`**, and the four cases covering the three corrections above are red on the
|
|
177
|
+
first pass of this change as well. The file also carries a negative control asserting the scanner
|
|
178
|
+
under test is this package's and not a sibling's.
|
|
179
|
+
|
|
180
|
+
**Not covered, deliberately, each measured rather than assumed.** Explicit-path mode already read
|
|
181
|
+
through a link and is unchanged. The **enumerate-then-read race** is untouched:
|
|
182
|
+
a file that vanishes between `walk()` and `readFileSync` aborts the whole sweep at exit 2, which
|
|
183
|
+
fails closed and is a different defect from this one. **There is still no rule refusing a scan that
|
|
184
|
+
observed nothing, and that has a measured instance worth naming rather than leaving abstract:** if
|
|
185
|
+
`test/fixtures` is itself a DANGLING link, `existsSync` follows it, answers false, and all-mode
|
|
186
|
+
prints `OK - no hits` and exits **0** over a corpus it never opened (identical on the base
|
|
187
|
+
scanner). If it is a link to a regular file, `readdirSync` raises an uncaught `ENOTDIR` - noisy and
|
|
188
|
+
fail-closed, but exit 1, which this contract reserves for "hits found". The `--staged` half of that
|
|
189
|
+
same shape is closed above; the all-mode half needs the observed-nothing rule, which is its own
|
|
190
|
+
slice. Also `--allow-fixture <path>` with no positional path still scans **nothing** and
|
|
191
|
+
prints `OK - no hits` even for a path that does not exist (measured), which makes this suite's
|
|
192
|
+
existing "honors `--allow-fixture` with an override-log entry" case vacuous. All pre-existing,
|
|
193
|
+
disclosed rather than fixed here.
|
|
194
|
+
|
|
195
|
+
No source, parser, de-identify or public API change.
|
|
196
|
+
|
|
197
|
+
- **An element whose on-wire VR is not one of the 34 PS3.5 §6.2 defines was kept verbatim by
|
|
198
|
+
`deidentify()`, carrying a source `(0010,0020)` Patient ID into de-identified output next to the
|
|
199
|
+
`(0012,0062) PatientIdentityRemoved = YES` this library writes** (`DICOM-CARRIER-LEAF-LEAKS`,
|
|
200
|
+
mechanism 2; pre-existing, live on the published `0.0.6`, identical on both trees). Re-derived on
|
|
201
|
+
`scripts/measure-sq-bound-grid.ts` at `35adc2d` before anything changed: **19 leaking cells → 11**.
|
|
202
|
+
Negative control first - the grid run against `d1031f5`'s `src/` restored **1,174** leaking cells
|
|
203
|
+
and reproduced `#54`'s published 2,448-cell cost - plus a second control confirming the harness
|
|
204
|
+
fails outright when pointed at another package.
|
|
205
|
+
|
|
206
|
+
**This is the _under_-declare, not the swallow the entry below closed.** An under-declared Value
|
|
207
|
+
Length desynchronizes the reader: it finishes the short value early, reads the leftover bytes of
|
|
208
|
+
the value that was actually encoded as the next Data Element header, and consumes the element that
|
|
209
|
+
genuinely followed as that fabricated element's "value". Measured: a 14-byte carrier
|
|
210
|
+
under-declaring by 6 yields tag `(4156,554C)` with the VR bytes `"E "`, holding the Patient ID in
|
|
211
|
+
full, **silently** and with a clean report. It reaches **string** carriers as readily as binary
|
|
212
|
+
ones, so it is not bounded by the binary-VR story that frames the residual below.
|
|
213
|
+
|
|
214
|
+
**The remedy reads a field the parser already recorded, not the bytes.** PS3.5 2026c §6.2: "All
|
|
215
|
+
new VRs defined in future versions of DICOM shall be of the same Data Element Structure as defined
|
|
216
|
+
in [§7.1.2] with reserved bytes after the VR and a 32-bit unsigned integer VL". An unrecognized VR
|
|
217
|
+
is long-form by that rule while this parser reads it short-form (Postel's Law on the read path), so
|
|
218
|
+
its bytes are not a Value Field this library decoded under any VR and PS3.15 2026c §E.1.1's
|
|
219
|
+
obligation cannot be discharged inside them. Read from the vendored SHA-pinned documents, pins
|
|
220
|
+
re-derived, each sentence unique in its document. Such an element is **emptied**,
|
|
221
|
+
`report.undefinedVrElements` names the **byte offset** and the byte length dropped, and the new
|
|
222
|
+
`DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` is raised. **The finding names no tag, uniquely among
|
|
223
|
+
the report's findings**: the header _may_ have been fabricated out of the middle of some element's
|
|
224
|
+
value, in which case its four tag bytes are document content - on a synthetic `ST` carrier holding
|
|
225
|
+
`"MR BRAIN SMITHSON"` the tag renders as `48544F53`, four letters of the surname. An honestly
|
|
226
|
+
written unrecognized VR raises the same code with an ordinary tag, and the two are
|
|
227
|
+
indistinguishable here, so the tag is withheld either way.
|
|
228
|
+
`renderTag` shape-checks a tag and cannot refuse one, so the withholding happens at the call site.
|
|
229
|
+
An undefined-VR carrier whose bytes happened to tile was reported in `report.embeddedAttributes`
|
|
230
|
+
before and now reports here instead. The test is a set-membership check on
|
|
231
|
+
`el.vr`: O(1), **no scan**. The record is **capped at 64 across the run** and the emptying never is
|
|
232
|
+
- an undefined-VR element costs an attacker only an 8-byte header, so 1 MiB is 131,072 of them.
|
|
233
|
+
|
|
234
|
+
**No carve-out, and structurally so:** `keepOrEmpty` is the only path that keeps a source value
|
|
235
|
+
verbatim and the test sits at its top, so `RetainSafePrivate` does not exempt an element - pinned
|
|
236
|
+
by a test. **`UN` is untouched**, being one of the 34, and the rule cannot fire under Implicit VR
|
|
237
|
+
LE at all, where the VR comes from the dictionary; 0 Implicit VR LE cells moved.
|
|
238
|
+
|
|
239
|
+
**Cost, published rather than described:** 23 grid cells lose a marker from de-identified output,
|
|
240
|
+
**15 of which were not leaking**. On a file conformant to PS3.5 2026c the cost is zero.
|
|
241
|
+
|
|
242
|
+
**The root cause is a parse behaviour this slice does not touch, and it is disclosed rather than
|
|
243
|
+
implied fixed.** §6.2's note says informatively that an unrecognized VR may be handled "by applying
|
|
244
|
+
the rules stated in [§7.1.2]" - i.e. read long-form, value copied unchanged. This parser reads it
|
|
245
|
+
**short-form**. Emptying at the de-identify boundary is therefore compensation, not conformance.
|
|
246
|
+
`PRE-EXISTING`, its own slice. No claim is made here about what a §6.2-conformant future-VR file
|
|
247
|
+
does on either tree.
|
|
248
|
+
|
|
249
|
+
**Still leaking, measured, and now priced:** the 11 remaining cells are the over-declare swallow
|
|
250
|
+
into a **binary** carrier at `delta=18`. The one candidate remedy was built and measured - it
|
|
251
|
+
takes 11 to 0 and empties **all 5** conformant binary tiling controls, i.e. deletes a legal
|
|
252
|
+
`OB`/`UN` value because 8 of its bytes read as a zero-length `(0010,0020)`. That is a product
|
|
253
|
+
decision, not a bug fix, and it has its own item.
|
|
254
|
+
|
|
255
|
+
- **A sequence the parser could not open was passed through `deidentify()` as raw bytes, so a
|
|
256
|
+
`(0010,0020)` Patient ID inside it reached de-identified output next to the
|
|
257
|
+
`(0012,0062) PatientIdentityRemoved = YES` this library writes** (`DICOM-DEIDENT-RAWBYTES-PASSTHROUGH`,
|
|
258
|
+
pre-existing and live on the published `0.0.6`). It is the larger half of the 2,127 leaking grid
|
|
259
|
+
cells the previous entry decomposed: **1,155 of the 6,348 cells that parse**, all Implicit VR LE,
|
|
260
|
+
all carrying exactly `["DICOM_SQ_NOT_DESCENDED"]`. Re-measured before anything was changed, with a
|
|
261
|
+
negative control (reverting the previous entry's call site restored exactly 2,127) so the harness
|
|
262
|
+
is known live rather than assumed.
|
|
263
|
+
|
|
264
|
+
When an element resolves to `SQ` from PS3.6 under Implicit VR LE and its defined-length value is
|
|
265
|
+
not a valid `(FFFE,E000)` item stream, the parser refuses the descent and keeps the declared span
|
|
266
|
+
on `Element.rawBytes` with `Element.items` undefined. That is right for a parser. It was not safe
|
|
267
|
+
for `deidentify()`, which recurses only into a sequence whose items exist: for a carrier such as
|
|
268
|
+
`(0008,1115)` with no Table E.1-1 row, the action table resolved "keep" and the span was re-emitted
|
|
269
|
+
verbatim, with the report naming nothing.
|
|
270
|
+
|
|
271
|
+
**The remedy reads the parser's recorded refusal, not the bytes.** PS3.5 2026c §7.5.1 "Item
|
|
272
|
+
Encoding Rules" states "Each Item Value shall contain a DICOM Data Set composed of Data Elements",
|
|
273
|
+
so an `SQ` value is never legitimately opaque the way an `OB` value is; PS3.15 2026c §E.1.1
|
|
274
|
+
"De-identifier" obliges an implementation claiming the Basic Profile to "protect or retain all
|
|
275
|
+
instances of the Attributes listed in [Table E.1-1], whether contained in the top level Data Set or
|
|
276
|
+
embedded in an Item of a Sequence of Items". Unable to enumerate the items, the obligation falls on
|
|
277
|
+
the carrier, which is the escalation §E.1.1 itself makes for a SOP Instance UID inside a Sequence.
|
|
278
|
+
Both sentences read from the vendored SHA-pinned documents, pins re-derived, each unique in its
|
|
279
|
+
document. A **standard** `SQ` with no items is therefore **emptied**,
|
|
280
|
+
`report.unauditableSequences` names the tag and the byte length dropped, and the new
|
|
281
|
+
`DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` is raised. That record is **capped at 64 across the run**
|
|
282
|
+
(element count is attacker-chosen too, and `#48` bound every other consumer-controlled
|
|
283
|
+
diagnostic); the **emptying is never capped**, so an array exactly 64 long means "at least 64". A
|
|
284
|
+
listed sequence kept by a Retain Option takes the same branch and its audit line now reads
|
|
285
|
+
`emptied` rather than `kept` - previously it produced an empty sequence anyway while claiming the
|
|
286
|
+
attribute was retained.
|
|
287
|
+
|
|
288
|
+
**It costs content, and the number is published rather than described:** 2,448 grid cells lose a
|
|
289
|
+
value from their de-identified output, of which **1,293 were not leaking anything** and pay purely
|
|
290
|
+
for the guarantee. The accompanying `DICOM_SQ_NOT_DESCENDED` says why the parse refused, which is
|
|
291
|
+
_usually_ a sender defect - but **not always**: a conformant file nested deeper than this
|
|
292
|
+
library's own `NESTING_DEPTH_LIMIT` of 64 (ours; PS3.5 sets no nesting bound) is refused the same
|
|
293
|
+
way and loses that sequence too. Because the trigger is `items === undefined` rather than a
|
|
294
|
+
content test there is no scan, so no cost follows an attacker-chosen value length; the cost tests
|
|
295
|
+
use a carrier where **every even offset** is a tiling candidate and assert that property rather
|
|
296
|
+
than describing it, as a **forward tripwire** - this path never traverses those bytes.
|
|
297
|
+
|
|
298
|
+
**New surface:** `UnauditableSequenceFinding`, `DeidentifyReport.unauditableSequences`, and
|
|
299
|
+
`DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` (**27 Tier-2 codes, was 26**; snapshot updated
|
|
300
|
+
deliberately). `DICOM_SQ_NOT_DESCENDED`'s message changed, since it said nested content was
|
|
301
|
+
invisible to de-identification.
|
|
302
|
+
|
|
303
|
+
**Not covered, and one of these is measured here for the first time.** An undefined-length `UN`
|
|
304
|
+
whose CP-246 descent was refused keeps `vr === "UN"`, and the rule cannot be extended to it because
|
|
305
|
+
every ordinary `UN` element also has no items - measured on a hand-built file, the identifier still
|
|
306
|
+
reaches output with no report entry. A **private** `SQ` under `RetainSafePrivate` plus a `Profile`
|
|
307
|
+
is still kept verbatim, deliberately - `keepsPrivate` decides first, so it never reaches this rule,
|
|
308
|
+
and measured on a synthetic vendor block the identifier does still reach output. Both carve-outs
|
|
309
|
+
are pinned by tests. And the **binary-VR carrier** residual the previous entry
|
|
310
|
+
disclosed but could not measure now has a number: the grid gained an over-declaring **leaf**
|
|
311
|
+
carrier dimension and finds **19 leaking cells, identical on both trees** - 11 at `delta=18` (the
|
|
312
|
+
swallow into `OB`/`OW`/`US`/`UN`, silent, with `LO`/`ST` controls on the identical fixture at 0)
|
|
313
|
+
and 8 at `delta=-6` (an _under_-declare, where leftover bytes are read as a Data Element header and
|
|
314
|
+
the identifier lands in a manufactured element with an unknown on-wire VR; this one hits string
|
|
315
|
+
carriers too and was not previously disclosed anywhere).
|
|
316
|
+
|
|
317
|
+
**Measured, on the now 76,599-cell grid against `d1031f5`:** sequence-sweep leaks 1,155 → **0**;
|
|
318
|
+
**0 cells differing in any parse respect**, now a printed count rather than an inference from
|
|
319
|
+
`changed`, because no parser file is touched; 0 new fatals, 0 lost parse values, 0 reports that
|
|
320
|
+
lose an attribute, 0 new strict fatals.
|
|
321
|
+
|
|
322
|
+
- **An element that over-declared its own Value Length swallowed the next element into its value,
|
|
323
|
+
where the PS3.15 Annex E action table cannot see it, and `deidentify()` wrote the identifier into
|
|
324
|
+
its output with a clean report** (`DICOM-OVERDECLARE-SWALLOWS-INTO-VALUE`, pre-existing and live
|
|
325
|
+
on the published `0.0.6`). **No sequence is involved.** PS3.5 2026c defines Value Length as "The
|
|
326
|
+
length of the Value Field of the Data Element" - that element's own value. A sender that writes a
|
|
327
|
+
larger number produces a file that is not detectably broken: the reader consumes the declared
|
|
328
|
+
count, the bytes it over-consumes are the following element header and all, every subsequent
|
|
329
|
+
offset still lines up, and nothing on the wire says which length field lied. After the swallow
|
|
330
|
+
there is no `(0010,0020)` in the object for Table E.1-1 to match, only bytes inside some other
|
|
331
|
+
attribute's value, so de-identification passed over PHI it never recognised as an attribute.
|
|
332
|
+
Measured on the committed grid at `244a372`: **877 of the 6,348 cells that parse** wrote a source
|
|
333
|
+
value into de-identified output this way, on Explicit VR LE and Explicit VR BE, **871 of them with
|
|
334
|
+
no warning on either channel and no throw under `{ strict: true }`**.
|
|
335
|
+
|
|
336
|
+
**`parseDicom` is unchanged, deliberately.** The two readings - "the length is right and the value
|
|
337
|
+
is odd" and "the length lied and the next element was absorbed" - produce identical bytes, so a
|
|
338
|
+
parser cannot choose between them and this release does not pretend to. `deidentify()` answers a
|
|
339
|
+
strictly narrower question, and only about values it was about to **keep**: does this value's tail
|
|
340
|
+
decode, in the file's own transfer syntax, as a complete run of Data Elements ending exactly at the
|
|
341
|
+
end of the value, at least one of which this run would have acted on, and containing a byte the
|
|
342
|
+
carrier's VR cannot legally hold (PS3.5 §6.1.3 and Table 6.1-1 permit five C0 control characters in
|
|
343
|
+
DICOM text; **Table 6.2-1 decides which of the five each VR may hold**, in three tiers)? All three have to hold. If they do, the value is **emptied**
|
|
344
|
+
rather than kept, a new `DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED` warning appears on
|
|
345
|
+
`report.warnings`, and the new `report.embeddedAttributes` names the carrier, its VR and the tags
|
|
346
|
+
that were hiding inside it. PS3.15 §E.1 requires a de-identifier to "protect or retain all
|
|
347
|
+
instances of the Attributes listed in [Table E.1-1]"; §E.3.5 is the standard's own statement that
|
|
348
|
+
identifying information embedded **inside a string attribute's value** is in scope for removal.
|
|
349
|
+
Emptying is the fail-safe direction; keeping is not.
|
|
350
|
+
|
|
351
|
+
**What this does not cover, stated rather than implied.** Carriers are **string VRs only**, and the
|
|
352
|
+
residual is live rather than theoretical: the identical over-declare into an `OB`, `OW`, `UN` or
|
|
353
|
+
`US` carrier still writes the identifier into de-identified output, with **no warning and no report
|
|
354
|
+
entry**, exactly as before. Arbitrary bytes are what those VRs are for, so no content test can tell
|
|
355
|
+
a swallow from a legitimate value there, and scanning them would trade a measured guarantee for a
|
|
356
|
+
coin-flip that deletes real pixel and lookup-table data. The committed grid never puts a binary VR
|
|
357
|
+
in the over-declaring role, so this residual is **disclosed but unmeasured** and needs its own
|
|
358
|
+
item. (It was measured later in this same release cycle, by the entry above: **19 grid cells**,
|
|
359
|
+
once the grid gained an over-declaring leaf carrier.) A sequence the
|
|
360
|
+
parser declined to descend and kept as opaque bytes is a **different** defect with a different
|
|
361
|
+
remedy and is untouched here (1,155 grid cells, all Implicit VR LE, all carrying
|
|
362
|
+
`DICOM_SQ_NOT_DESCENDED`). And nothing here recovers the correct reading of a malformed file: the
|
|
363
|
+
carrier's parsed value still holds the absorbed bytes, and `ds.get("00100020")` still returns
|
|
364
|
+
nothing. This closes the leak, not the mis-read.
|
|
365
|
+
|
|
366
|
+
**Measured, on the same 76,293-cell grid, against `244a372`:** 2,127 leaking cells → 1,155; every
|
|
367
|
+
Explicit VR cell → **0**; and **0 cells whose parse differs in any respect** - reading, both
|
|
368
|
+
warning channels, `{ strict: true }`, and which values survive anywhere in the object are
|
|
369
|
+
byte-identical, because no parser file is touched. 0 new fatals, 0 lost values, 0 reports that lose
|
|
370
|
+
an attribute. `scripts/measure-sq-bound-grid.ts`, previously only on an unmerged branch, is
|
|
371
|
+
committed so every figure above is one command away.
|
|
372
|
+
|
|
9
373
|
- **A defined-length sequence under Implicit VR Little Endian was never opened, so PHI nested inside
|
|
10
374
|
one survived `deidentify()` into serialized output while the report said it had been removed**
|
|
11
375
|
(`DICOM-IMPLICIT-SQ-NOT-DESCENDED`, pre-existing and live on the published `0.0.5`). The
|
|
@@ -249,6 +613,18 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
249
613
|
|
|
250
614
|
### Added
|
|
251
615
|
|
|
616
|
+
- **`UndefinedVrFinding`, `DeidentifyReport.undefinedVrElements` and
|
|
617
|
+
`DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE`** (**28 Tier-2 codes, was 27**; the locked
|
|
618
|
+
`WARNING_CODES` snapshot was updated deliberately). `MAX_UNDEFINED_VR_FINDINGS` (64) caps the
|
|
619
|
+
record, never the emptying.
|
|
620
|
+
|
|
621
|
+
- **A conformant-tiling control family in `scripts/measure-sq-bound-grid.ts`**
|
|
622
|
+
(`LEGIT_TILING_CARRIERS`) plus the `conformant tiling control emptied` counters in `--diff`. It
|
|
623
|
+
exists to price the remaining binary-carrier leak, which `LEAKING` structurally cannot: a rule that
|
|
624
|
+
empties every binary value whose tail tiles reads as `11 → 0` there and says nothing about what it
|
|
625
|
+
destroyed. Restricted to stride-0 VRs, because `buildDicom` byte-swaps `OW`/`US` values under
|
|
626
|
+
Explicit VR BE - a first draft included them and read 9 emptied rows where the honest number was 6.
|
|
627
|
+
|
|
252
628
|
- **`DICOM_SQ_NOT_DESCENDED`**, a Tier-2 warning code (25 total, was 24). Public surface: the locked
|
|
253
629
|
`WARNING_CODES` snapshot was updated deliberately, and a profile may `escalate` or `suppress` it
|
|
254
630
|
like any other code.
|
package/README.md
CHANGED
|
@@ -207,6 +207,8 @@ const safe = serializeDicom(dataset); // safe to share: input dataset never muta
|
|
|
207
207
|
|
|
208
208
|
report.attributes.length; // count of attributes acted on (each carries tag/keyword/action, no values)
|
|
209
209
|
report.warnings; // e.g. DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED
|
|
210
|
+
report.unauditableSequences; // SQ elements emptied because their items could not be walked
|
|
211
|
+
report.undefinedVrElements; // elements emptied because their on-wire VR is not a VR
|
|
210
212
|
```
|
|
211
213
|
|
|
212
214
|
UIDs are remapped to deterministic `2.25` replacements that stay consistent across files, so a de-identified study still hangs together. Opt into any of the nine metadata-affecting Annex E Options to keep specific classes of attribute:
|
|
@@ -316,7 +318,7 @@ for (const w of ds.warnings) {
|
|
|
316
318
|
}
|
|
317
319
|
```
|
|
318
320
|
|
|
319
|
-
The
|
|
321
|
+
The 26 Tier-2 codes (`DICOM_MISSING_PREAMBLE`, `DICOM_FILE_META_GROUP_LENGTH_MISMATCH`, `DICOM_UN_PARSED_AS_SQ`, `DICOM_ODD_LENGTH_VALUE_PADDED`, `DICOM_PRIVATE_CREATOR_UNKNOWN`, `DICOM_VR_MISMATCH`, `DICOM_DA_LEGACY_FORMAT`, … ) live in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.…` for typo-free comparisons, or pass `{ onWarning }` to `parseDicom` to stream them.
|
|
320
322
|
|
|
321
323
|
The 4 Tier-3 fatal codes (`NOT_DICOM_PART_10`, `INVALID_FILE_META`, `UNSUPPORTED_TRANSFER_SYNTAX`, `EMPTY_INPUT`) always throw a `DicomParseError`; they represent input the parser cannot meaningfully recover.
|
|
322
324
|
|
|
@@ -362,7 +364,9 @@ Thrown by `serializeDicom` for `MISSING_TRANSFER_SYNTAX` (the dataset names no t
|
|
|
362
364
|
|
|
363
365
|
- **Pixel data.** No decode/decompression, no rendering, no measurements: Pixel Data is exposed as raw bytes. And v1 does not read a **compressed object at all**, not even structurally: a transfer syntax outside the four listed below is the fatal `UNSUPPORTED_TRANSFER_SYNTAX`, so JPEG / JPEG-LS / JPEG2000 / RLE / HTJ2K objects do not parse. → `@cosyte/dicom-pixel`.
|
|
364
366
|
- **Burned-in PHI.** v1 **warns** it cannot remove burned-in annotation; a "de-identified" output is **metadata-de-identified only**.
|
|
365
|
-
- **A sequence the parser could not open.** `deidentify()` recurses only into a sequence whose items the parser materialized, so
|
|
367
|
+
- **A sequence the parser could not open: content is dropped, not passed through.** `deidentify()` recurses only into a sequence whose items the parser materialized. A **standard** `SQ` element with no items is now **emptied**: its bytes are Data Sets by PS3.5 §7.5.1 and PS3.15 §E.1.1 obliges the de-identifier to reach the listed attributes inside them, so a run that cannot enumerate them must not ship them. `report.unauditableSequences` names the carrier and the byte length dropped (capped at 64 entries; the emptying itself is never capped), and `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` is raised. Expect **data loss** on such a file. The parse refusal is usually a sender-side encoding defect, but not always: a **conformant** file nested deeper than this library's own `NESTING_DEPTH_LIMIT` of 64 is refused the same way and loses that sequence too. **Two shapes are exempt and still leak.** A **private** `SQ` kept under `RetainSafePrivate` + a `Profile` is kept verbatim and unaudited, because the profile vouched for it. And an undefined-length **`UN`** the CP-246 descent could not read as a sequence keeps `vr === "UN"`, so the rule does not reach it and cannot be extended there: every ordinary `UN` element also has no items. For either, the reliable test remains `el.items === undefined` on the element itself.
|
|
368
|
+
- **An element whose on-wire VR is not a VR: the value is dropped, not passed through.** Under an Explicit VR syntax the VR is two bytes the sender wrote and this parser trusts them. If those two bytes are not one of the 34 PS3.5 §6.2 defines, `deidentify()` **empties** the element: §6.2 requires every undefined VR to be long-form with a 32-bit VL, this parser reads it short-form, so those bytes are not a Value Field the library decoded and PS3.15 §E.1.1's obligation cannot be discharged inside them. `report.undefinedVrElements` names the **byte offset** and the byte length dropped (capped at 64 entries; the emptying itself is never capped) and `DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` is raised. It names **no tag**, deliberately and uniquely among the report's findings: this header _may_ have been fabricated out of the middle of some element's value, in which case its tag bytes are document content - and nothing distinguishes that case from an honestly-written unrecognized VR, so the tag is withheld either way. The usual cause is an **under**-declared Value Length earlier in the file, which desynchronizes the reader so that leftover value bytes are read as a Data Element header - and the element that genuinely followed becomes the fabricated element's value, which is how a `(0010,0020)` Patient ID used to reach de-identified output. **A file conformant to PS3.5 2026c never trips this**, and an Implicit VR LE file cannot: there the VR comes from the dictionary. (The edition matters: §6.2 exists precisely to say how a _future_ VR will be encoded.) `UN` is one of the 34 and is not affected. **There is no exemption** - unlike the sequence rule above, `RetainSafePrivate` does not keep such an element.
|
|
369
|
+
- **An over-declared Value Length into a binary carrier still leaks, and it is a known trade.** The mirror case - an _over_-declared length that swallows the following element into an `OB`/`OW`/`US`/`UN` value - is **not** detected, and a `(0010,0020)` inside it reaches de-identified output with no warning and no report entry. Arbitrary bytes are exactly what those VRs are for, so no content test can decide it; the only candidate remedy was measured and empties conformant binary values (a legal LUT or blob deleted because 8 of its bytes read as a zero-length `(0010,0020)`). String carriers **are** covered, because there the same bytes are provably outside the VR's repertoire. If you accept files from a sender you do not control, treat a binary attribute's length as untrusted.
|
|
366
370
|
- **Networking & web.** No DIMSE (C-STORE/FIND/MOVE, MWL, MPPS); no DICOMweb (QIDO/WADO/STOW). → `@cosyte/dicom-net`, `@cosyte/dicomweb`.
|
|
367
371
|
- **Transcoding.** No transfer-syntax conversion. The serializer re-emits in the dataset's source syntax only.
|
|
368
372
|
- **Terminology resolution.** Coded values are surfaced (designator + canonical source) but not validated against SNOMED/LOINC/etc.
|