fix: create temp dirs with mkdtemp, not a name built from Date.now() - #3241
Conversation
Closes nine open `js/insecure-temporary-file` alerts — the technically correct ones. An audit of all 29 open alerts for that rule split them three ways: - 19 false positives: the write lands inside a directory the caller already made with `mkdtempSync`, and CodeQL's dataflow reaches `tmpdir()` without seeing the mkdtemp in between. - 1 mitigated: `fontCompression.ts` writes with `flag: "wx"` and only takes the tmpdir branch inside Lambda, where /tmp is single-tenant. - 9 real, and these are them. A name built from `Date.now()` under the shared temp dir, followed by `mkdirSync`, is guessable to the millisecond AND leaves a window between choosing the name and creating it, so on a shared machine another user can pre-create or symlink the path first. `mkdtempSync` closes both halves: it picks the random suffix and creates the directory 0700 in one syscall. Same shape, one line shorter, and the alerts go away rather than being dismissed. Six sites in `normalize.test.ts` (its `mkdirSync` import goes with them), one in `generate-catalog-previews.ts` — that single construction accounted for three alerts, since the other two were writes into the directory it made. No shared helper. `mkdtempSync` is already the stdlib primitive for exactly this, and the two callers live in different packages, so a wrapper would need a home in core to serve one CLI test and one build script — more indirection than the line it saves. Deliberately not touching the other 20: excluding the rule repo-wide would hide this class of bug from future code, which is the reason these are fixed rather than silenced.
miguel-heygen
left a comment
There was a problem hiding this comment.
Exact-head review at d82fa5f429c1baac55ad1a2cce16305aef77b531
The security mechanics are correct: every changed mkdtempSync(join(tmpdir(), prefix)) call atomically allocates a unique owner-only directory and removes the predictable-name/symlink race. Catalog-preview cleanup still runs in finally, Windows checks are green, and CodeQL is clear.
One introduced cleanup regression to fix:
packages/cli/src/whisper/normalize.test.ts:570-572creates<tmp>/hf-wav-test-XXXXXX/tone.wav, but pushes the file path intodirs.afterEachat:26-28therefore deletes onlytone.wavand leaves the newly created parent directory behind. Four tests call this helper, so each suite run leaks four empty temp directories. Keepconst dir = mkdtempSync(...), pushdir, then returnjoin(dir, "tone.wav").
Non-blocking SSOT note: scripts/catalog-preview-temp.ts already exports this exact createCatalogPreviewTempDir() primitive and catalog-preview-temp.test.ts pins uniqueness + mode 0700. Importing that helper in generate-catalog-previews.ts would connect the production caller to the existing regression test instead of duplicating it inline.
Local targeted collection was unavailable because this clean worktree had no workspace dependency link for @hyperframes/parsers/ff-binaries; exact-head CI is fully green, including Test, Windows, CodeQL, lint, build, and preview jobs.
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at d82fa5f4. Additive to @miguel-heygen's review at the same head — I'm not re-litigating the temp-dir mechanics, the cleanup regression, or CI, all of which it covers. My contribution is a sweep for whether this class of bug is actually gone, plus verification of the description's numbers.
On the cleanup regression: I reached normalize.test.ts:570-572 independently, before that review posted, and landed on the same fix — dirs gets the file path while the newly created hf-wav-test-XXXXXX/ directory goes untracked, so afterEach's rmSync removes tone.wav and leaves the directory, four per run. Recording the convergence rather than restating it: two reviewers arriving there separately is worth more than the finding twice. One thing to add on severity, since it argues for fixing it in this PR rather than after — sweeping a shared machine that runs these suites, I counted 3,015 leaked /tmp/hf-* directories, some weeks old. This shape is what produces that, so it isn't a theoretical tidiness point.
Strengths
- Every quantitative claim in the description verifies. I pulled the alerts rather than taking the table's word: 29 open for
js/insecure-temporary-file, and the 9 called "real" are exactlynormalize.test.ts× 6 andgenerate-catalog-previews.ts× 3. The remaining 20 partition 19 + 1 as claimed. The "one construction accounted for three alerts" detail holds too — the other two were writes into the directory it made. An audit table that survives being checked row by row is rare enough to say so. - Fixing over dismissing, with the tradeoff written down. A repo-wide exclusion would have hidden this shape from everything written later. That's called correctly and explicitly, and it's the right instinct — which is why the sweep below matters.
- No assertions were lost in the rewrite, which -12/+7 could easily have hidden. It reconciles exactly: five sites collapse 2 lines → 1 (-10/+5), the wav site is 1 → 1, the import is 1 → 1. Every removed line is a
join(tmpdir(), …), amkdirSync, or the import. Import hygiene is right in both directions —mkdirSyncis fully gone fromnormalize.test.ts, and correctly kept ingenerate-catalog-previews.ts, where it stays live at:151,:308,:340.
important — the class survives, in the near-twin of the file you fixed
The title is about the pattern, so I swept packages/ and scripts/ rather than trusting the diff's footprint. 476 tmpdir() references; after classifying all of them, three sites still build a predictable name and then create it:
// scripts/generate-template-previews.ts:129-130
const tmpDir = join(tmpdir(), `hf-preview-${templateId}-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
// packages/producer/src/benchmark.ts:207-208
const tmpRoot = join(tmpdir(), `benchmark-${fixture.id}-${Date.now()}`);
mkdirSync(tmpRoot, { recursive: true });
// packages/producer/src/transparency-test.ts:312-313
const workRoot = join(tmpdir(), `hf-transparency-${process.pid}-${Date.now()}`);
mkdirSync(workRoot, { recursive: true });(process.pid in the third adds entropy but stays enumerable, and the pre-create window is identical.)
The first is the one that matters. Put it beside the sample your own description uses to define the bug:
// the description's example of what's wrong
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });Same shape, same directory, same prepare*Dir role, followed by the same cpSync and then patchTemplateHtml writing into it (:65 reads, :73 writes). And generate-catalog-previews.ts:80 — a line inside the file this PR does fix — points straight at it: "examples use the existing generate-template-previews.ts."
The part I'd most want on the record: none of the three appears in the 29 alerts. CodeQL's dataflow reaches patchTemplateHtml's write through a readdir walk (join(entry.parentPath, entry.name)) and doesn't connect it back to the tmpdir()-derived root. So the audit was scoped to the alert list, the alert list is narrower than the pattern, and closing these nine turns the rule green while the class stays exactly where nothing will re-flag it. That cuts against the "don't hide this from future code" reasoning the description is otherwise right about.
Calibration, since it cuts against me: all three survivors are dev and build tooling — one preview generator, two producer dev entry points — the same category as what this PR fixed (a test file and a build script), not the render path. Nothing here regresses and nothing is worse than before. It's that the title claims the class while the diff reaches two of five sites. Follow-up, not a change request on this diff.
The inlined expression is byte-identical to an existing tested helper
Building on @miguel-heygen's SSOT note rather than repeating it — the duplication is exact, which makes it worse than stylistic:
// scripts/catalog-preview-temp.ts:7 (pre-existing)
return mkdtempSync(join(tmpdir(), `hf-catalog-${itemName}-`));
// scripts/generate-catalog-previews.ts:177 (this PR)
const tmpDir = mkdtempSync(join(tmpdir(), `hf-catalog-${item.name}-`));Same call, same hf-catalog- prefix, same argument role. The addition I'd make: createCatalogPreviewTempDir's only importer today is its own test (catalog-preview-temp.test.ts:5), so that test pins uniqueness and mode 0700 on a function with zero production callers — it protects nothing that runs. Adopting the helper doesn't just deduplicate, it makes an existing test load-bearing for the first time. Left as-is, the prefix string lives in two places and whichever copy drifts is the one the test isn't watching. Worth reconciling with the description's "## No shared helper" section, which reads as though one would have to be created.
nits
packages/cli/src/whisper/transcribe.ts:299already solves this a third way —hyperframes-audio-${process.pid}-${randomUUID()}.wav. Unguessable, so out of the alert class, but the tree now carries three temp-naming conventions and only two are safe. If the two producer files get converted, worth landing them asmkdtemprather than a thirdrandomUUIDvariant.packages/engine/src/config.ts:659builds a uid-keyedhyperframes-extract-cache-directory undertmpdir(). That one is deliberately stable and shared across runs, somkdtempis inapplicable by construction. Flagging only so whoever runs this sweep next doesn't "fix" it into a per-run directory and silently disable the cache.- Three sweep hits are not hits, listed so the sweep reads as classified rather than filtered:
frameDirCache.test.ts:101andproject.test.ts:22are deliberately-nonexistent paths nothing ever creates, andprobe-beginframe.ts:204is prose — a comment explaining this very choice, withmkdtempSyncon the line below it.
CI
All 8 required contexts on main are success at d82fa5f4, latest-run-per-name: Semantic PR title, Test: runtime contract, Typecheck, Build, regression, Test, Render on windows-latest, Tests on windows-latest. Gates come from rulesets here — classic protection 404s, and the empty legacy-status list is an artifact, not a pending gate. Ten contexts are skipped, none required. CLI smoke (required) is green but is not in the required set despite the name, so it shouldn't be read as a gate either way.
Verdict
Verdict: COMMENT
Reasoning: The diff is correct and minimal, mkdtempSync is the right primitive, and the nine alerts it closes are verifiably the nine real ones. I'm not stamping only because there's a live CHANGES_REQUESTED on this head for a defect I independently confirmed is real — that's the reviewer gate, not CI, which is fully green. Land the dirs.push(dir) fix and this is an approve from me. The unflagged twin at generate-template-previews.ts:129 is worth a follow-up while it's fresh: same bug, same directory, and CodeQL will never tell you about it.
— Rames Jusso
The wav helper pushed the file path into `dirs`, so `afterEach` removed `tone.wav` and left the directory it had just made — four per suite run. Push the directory and derive the file path from it. Measured: the old code leaks 4 directories per run, the new code leaks 0. Three sites still built a predictable name and then created it. CodeQL never flagged them — its dataflow reaches the template preview writes through a `readdir` walk and does not connect them back to the `tmpdir()` root — so the alert list was narrower than the pattern, and closing only the alerts would turn the rule green while the shape survived where nothing would re-flag it. `generate-template-previews.ts` is the near-twin of the file this change started from, and the other two are producer dev entry points. All three use the path only through the variable, so the random suffix changes nothing. Catalog previews now call the existing `createCatalogPreviewTempDir` instead of repeating its body. That test was in no runner, so it pinned uniqueness and mode 0700 on a function nothing called; adding it to `test:scripts` alongside a real caller makes it load-bearing. The rationale for the primitive moves to the helper, which is now the only place it lives.
|
Addressed at The cleanup regression — fixed, and measured both ways@miguel-heygen and @jrusso1020 both landed on const dir = mkdtempSync(join(tmpdir(), "hf-wav-test-"));
dirs.push(dir);
const path = join(dir, "tone.wav");
writeFileSync(path, buf);
return path;Rather than assume the fix works, I counted the directories in So the "four per run" figure is exactly right, and it's now zero. (The I also checked the five sibling call sites in the same file rather than just the one reported: all six SSOT — adopted, and the test was in worse shape than reported
const tmpDir = createCatalogPreviewTempDir(item.name);
One correction to the premise, in the direction that makes the point stronger. @jrusso1020 noted the helper's only importer is its own test, so the test "protects nothing that runs." It's worse than that: Adding it to The surviving three — converted here rather than deferred@jrusso1020 scoped this as follow-up and I've gone against that; saying so explicitly so it's easy to push back on. The reasoning that moved me is the one in that review: closing the nine alerts turns the rule green while the class survives in the three places CodeQL structurally cannot reach, and the PR title claims the class. Deferring means the follow-up competes with a rule that now looks clean. // scripts/generate-template-previews.ts
const tmpDir = mkdtempSync(join(tmpdir(), `hf-preview-${templateId}-`));
// packages/producer/src/benchmark.ts
const tmpRoot = mkdtempSync(join(tmpdir(), `benchmark-${fixture.id}-`));
// packages/producer/src/transparency-test.ts
const workRoot = mkdtempSync(join(tmpdir(), "hf-transparency-"));Checked before converting: each path is consumed only through its variable — no re-derivation of the name, no external consumer — so the random suffix is inert. A repo-wide sweep for Verification
|
Routing the renderer through `createCatalogPreviewTempDir` made that module part of its runtime path, and the workflow already states the rule for the sibling case: a module the renderer imports has to appear in the trigger, or a change to it alone never re-runs the job that exercises it. Add it to the `paths:` filter and to the renderer canary, so a PR touching only the temp-dir allocation still renders both shape canaries. Verified against this branch's own range: the previous argument list does not report the file, so a helper-only PR was invisible to both checks.
|
Fixed at The change-detection gap — real, and self-inflictedConfirmed and fixed. Routing the renderer through Added to both places, and generalised the comment to cover both modules: # Modules the renderer imports. Without them a change to path-traversal
# defence or temp-directory allocation alone never re-runs the job that
# exercises it.
- "scripts/registry-target-paths.mjs"
- "scripts/catalog-preview-temp.ts"RENDERER_CHANGED=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD \
-- scripts/generate-catalog-previews.ts scripts/registry-target-paths.mjs \
scripts/catalog-preview-temp.ts)Verified rather than assumed, both directions, against this branch's own range — the old argument list reports only Worth naming the general shape, since it will recur: extracting a shared module can silently remove a file from a path-based CI trigger. The trigger enumerates files, so moving code out of an enumerated file and into a new one drops it from the filter unless the filter moves too. Description notes — two takenRewrote The misclassified false-positive bucket — not taken here, but worth recordingThe Agreed, and useful to have measuredThe |
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review at aa3c8eb8. Two new commits since my pass at d82fa5f4: bdf9445b (cleanup fix + the sweep) and aa3c8eb8 (CI paths).
Everything I conditioned the approve on has landed, and I verified each at source rather than reading the commit subjects:
- The cleanup leak is fixed as specified —
normalize.test.ts:570-573now pushes the directory and writestone.wavinside it. That closes @miguel-heygen's blocker and the thing I'd independently reached. - All three predictable-name survivors from my sweep are converted —
generate-template-previews.ts:130,benchmark.ts:208,transparency-test.ts:312, each now a singlemkdtempSync. I'd explicitly filed that as a follow-up rather than a change request, so fixing it in-PR is scope I didn't ask for: the title claims the class, and now the diff's reach actually matches the claim. - The SSOT point landed —
generate-catalog-previews.tsimportscreateCatalogPreviewTempDir, and the rationale comment moved into the helper, which is the right place for it.
The half of that neither review caught, and it's the better half
I wrote last time that the helper's test "protects nothing that runs," because the helper had no production caller. That was understated. The test never executed at all.
On main, scripts/catalog-preview-temp.test.ts (883 bytes) imports describe/it from node:test, so only node --test can collect it. It is absent from test:scripts's explicit file list; there is no root vitest config (vitest.config.* / vitest.workspace.* all 404); and the vitest run scripts/catalog/ filter cannot match scripts/catalog-preview-temp.test.ts — hyphen, not slash. I also scanned all 14 workflows for an out-of-band node --test over scripts/: zero hits. So the file was dead in every lane, and its assertions on uniqueness and mode 0700 had never run.
bdf9445b registers it in test:scripts, which runs at ci.yml:302 inside the Test job — a required context — and Test is green at this head. So those two assertions execute for the first time here, against a helper that now also has a production caller. Adding the caller without registering the test would have left it half-done in a way that still reads as fixed; you found the other half.
Import hygiene, checked both directions again
This is where a five-site conversion regresses, and I praised it last time, so I re-ran it rather than assuming it held:
mkdirSyncretained and still live:benchmark.ts:297,transparency-test.ts:166,193,221,generate-template-previews.ts:182,generate-catalog-previews.ts:150,304,336.generate-catalog-previews.tscorrectly dropsmkdtempSyncandtmpdir— both are now zero-use there.
Nothing unused, nothing missing.
The red regression at this head is a superseded run, not a failure
Flagging so nobody hunts a broken fixture. There are two waves of runs at aa3c8eb8. Wave 1 (31726141881) is run-level cancelled: its regression-shards reads cancelled and the aggregate regression consequently reads failure on its "Check results" step. Wave 2 (31726196015) is the live one — all nine shards success, aggregate success.
Latest-run-per-name, all 8 required contexts are green: Semantic PR title, Test: runtime contract, Typecheck, Build, regression, Test, Render on windows-latest, Tests on windows-latest. mergeable is MERGEABLE.
nit — the shared allocation now covers one twin, not both
generate-template-previews.ts:130 allocates inline while its sibling routes through the helper. Defensible exactly as written: the helper hardcodes hf-catalog-, so the template script would inherit the wrong prefix. But the rationale comment now lives in the helper, and the script that doesn't call it is the one that carried this bug last time. Parameterizing (createPreviewTempDir(prefix, name)) would put both twins behind the one tested allocation; leaving it means the SSOT comment sits in a file that one of the two callers never opens. Either answer is fine — worth choosing rather than defaulting.
What I verified vs. trusted
Verified: the three converted sites and the cleanup fix, read at this head; the helper's importer; that the helper test is node:test-flavoured, absent from main's runner list, unmatched by the vitest filter, and unreferenced by any of the 14 workflows; that test:scripts runs inside the required Test job; import usage counts per file; the two-wave run anatomy per shard and run id; required-context state at this head, latest-run-per-name.
Trusted: the temp-dir mechanics and symlink/race reasoning from @miguel-heygen's pass, which I did not re-derive; that the CodeQL alert set closes out as the description states, which I checked last time at d82fa5f4 and did not re-pull here.
Verdict: APPROVE
Reasoning: Both conditions I named at d82fa5f4 are met and verified at this head, the sweep I filed as a follow-up was fixed in-PR, and the test-registration half went beyond what either review asked for. Required CI is green once the superseded cancelled wave is set aside. One nit, non-gating. Note this approve does not unblock the merge: @miguel-heygen's CHANGES_REQUESTED (4922116923, submitted at d82fa5f4) is still recorded and this repo does not dismiss stale reviews on push, so reviewDecision stays CHANGES_REQUESTED until that review is updated. The defect it named is fixed, so what's left is a re-review, not another commit.
— Rames Jusso
|
@miguel-heygen (Magi) — your CHANGES_REQUESTED review is at older head — Via |
miguel-heygen
left a comment
There was a problem hiding this comment.
Fresh-head re-review at aa3c8eb83175d9df15dc162598e0b573d77624b6.
The cleanup blocker is closed correctly at packages/cli/src/whisper/normalize.test.ts:567-576: the suite records the allocated directory and derives tone.wav from it, so afterEach removes the directory rather than only the file. The broader sweep also converts the three surviving predictable-name sites to mkdtempSync, and the catalog renderer now calls the tested shared allocator. Registering catalog-preview-temp.test.ts in test:scripts plus adding the helper to the preview workflow path/canary filters makes those assertions load-bearing.
All eight required contexts are green at this exact head, including both Windows lanes and regression. The older cancelled/failing superseded wave is not the current result.
Verdict: APPROVE
Reasoning: The introduced temp-directory leak is fixed, the same security pattern is closed at the remaining sites, and exact-head required CI is green.
— Magi
Nine
js/insecure-temporary-filealerts, closed by fixing them rather than dismissing them.The audit
All 29 open alerts for that rule, traced to how each path is actually built:
mkdtempSyncdirectorytmpdir()without seeing the mkdtemp in betweenfontCompression.tsflag: "wx"(exclusive create), and only takes the tmpdir branch underAWS_LAMBDA_FUNCTION_NAME, where /tmp is single-tenantWhy the nine are real
Two separate problems. The name is guessable to the millisecond, and there is a window between choosing it and creating it — so on a shared machine another user can pre-create the path or drop a symlink there first.
mkdtempSynccloses both: it picks the random suffix and creates the directory0700in a single syscall.Same shape, one line shorter, and the alert disappears instead of being suppressed.
What changed
Alert-closing sites:
normalize.test.ts— six sites; its now-unusedmkdirSyncimport goes with them. The wav helper also pushes the directory intodirs, not the file path, soafterEachcleans it up — measured, the previous shape leaked four directories per suite run.generate-catalog-previews.ts— one construction, which accounted for three alerts on its own since the other two were writes into the directory it made.Three further sites of the same shape that carried no alert — CodeQL's dataflow reaches the template-preview writes through a
readdirwalk and never connects them back to thetmpdir()root, so the alert list was narrower than the pattern:generate-template-previews.ts— the near-twin of the file above;generate-catalog-previews.ts:80points straight at it.benchmark.tsandtransparency-test.ts— producer dev entry points.All three use the path only through their variable, so the random suffix is inert. Converting them is why the title can claim the class: closing only the alerts would have turned the rule green while the shape survived where nothing would re-flag it.
The shared helper already existed
scripts/catalog-preview-temp.tsexports exactly this primitive, sogenerate-catalog-previews.tsnow callscreateCatalogPreviewTempDirrather than repeating its body, and the rationale formkdtemplives on the helper as the only copy. That test was in no runner — not intest:scripts, and no vitest config globsscripts/*.test.ts— so it pinned uniqueness and mode0700on a function nothing called; adding it totest:scriptsalongside a real caller makes it load-bearing for the first time.Because that import puts the helper on the renderer's runtime path, it is also added to the Catalog Previews
paths:trigger and renderer canary, per the rule the workflow already states forregistry-target-paths.mjs.Deliberately not touching the other 20
Excluding the rule repo-wide was the obvious shortcut and would have hidden this class of bug from future code. The false positives are better handled per-site.
Verification
normalize.test.ts— 37 tests pass;bun run test:scripts— 177 pass, including the newly-wired helper testos.tmpdir()across a run: previous shape +4 per run, current 0tsc --noEmitclean inproducerandcli; oxlint and oxfmt cleantmpdir()and then creates it. What's left is deliberately stable (config.ts:659,fontCompression.ts:34,regression-harness.ts:975), unguessable (transcribe.ts:299usesrandomUUID), or a negative-test path nothing creates🤖 Generated with Claude Code