Repository navigation
npu: dumps are created 0600 and the head-image cache 0644, never through a symlink (CodeQL world-writable alerts; unblocks #1830) - #2057
Conversation
…ugh a symlink (CodeQL world-writable alerts) Six open CodeQL cpp/world-writable-file-creation alerts on main, all fopen(..., "wb"), which creates 0666 & ~umask and follows a symlink planted at the name: npu/forward/src/q4nx_forward.cpp NPU_I8_GS_DUMP host-arm dump npu/target/xdna/xdna_target.cpp NPU_I8_GS_DUMP device dump npu/forward/src/q4nx_head_image.c head-image cache temp file npu/forward/include/residual-dump.h NPU_INFER_DUMP_DIR layer dump tests/npu_pack_import_test.cpp, tests/npu_residual_dump_test.cpp A PR that merely touches nearby code inherits these as "new alerts in code changed by this pull request": #1830 has sat on a CHANGES verdict for one since 14:35. npu/forward/include/file_create.h (plain C, shared by the .c and .cpp sources): onebit_fopen_private_wb (0600) and onebit_fopen_shared_wb (0644), open(O_CREAT|O_TRUNC|O_NOFOLLOW|O_CLOEXEC) with a constant mode, then fdopen, with errno kept from the failing call so the residual dump's strerror message is unchanged. The test's /dev/full probe opens for reading. Measured with umask 0: 600 and 644; a symlink at the path is refused (ELOOP). ctest npu_residual_dump, ops_dump, npu_design_grid (the head-image cache arms) and npu_pack_import pass; onebit_npu_forward builds in the ONEBIT_NPU configure. The path-injection alerts at the same sites are left alone: those paths come from env variables by design, so they are the owner's call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
VERDICT: READY at The mechanism is stated correctly and it is exactly why open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, S_IRUSR | S_IWUSR) /* dump: 0600 */
open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH) /* cache: 0644 */
Two constructors, not one with a mode parameter, is the better API choice: Scope is right and it is honest about it: all six One thing to confirm before merge, since it is the classic mistake in this change class: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 2ecd5fd — a security PR that measures the adversarial umask, calls out a false positive instead of "fixing" it, and states what it did not measure.
The table is the review. Six cpp/world-writable-file-creation alerts, each mapped to a site and to what it writes, and the modes chosen per site rather than uniformly: dumps and probes get 0600 (an activation or weight dump is nobody else's business), the head-image cache gets 0644 because it is renamed into place and shared by design. That is the right kind of split — a blanket 0600 would have broken the cache's purpose and a blanket 0644 would have leaked dumps.
O_NOFOLLOW is the part that makes it more than a mode change: fopen(path, "wb") creates 0666 & ~umask and follows a planted symlink, so under a permissive umask another user can redirect a dump to a file of their choosing. open(… O_NOFOLLOW | O_CLOEXEC) plus fdopen closes both halves — the final-path symlink and the fd leak across exec.
The measurement is the one that matters: "With umask 0, a scratch program built against the header gets mode 600 from the private opener and 644 from the shared" — an explicit mode is only worth anything if it survives a hostile umask, and testing under umask 0 is testing the worst case rather than the default.
Two pieces of honesty worth naming:
- One of the six alerts was a false positive —
tests/npu_residual_dump_test.cpp:70is a/dev/fullexistence probe opened"rb", which creates nothing. Saying so, rather than routing it through the private opener to make the counter go to zero, keeps the alert list meaningful. Not measured: whether CodeQL closes the six alerts. The scan that settles that is the one that runs on this PR.That is the correct expectation to set — a fix for a scanner finding is verified by the scanner, not by the author's reading of the rule.
Not changed, and correctly scoped: the cpp/path-injection alerts at the same sites (#95, #96, #87), because those paths come from environment variables. Fixing those means changing behaviour (what a dump path may be), which is a different decision from what mode a created file gets.
One question, not a blocker — it is a policy question the PR is the right place for: the head-image cache is 0644, i.e. world-readable. It holds derived weights, so on a multi-user box any local user can read a model through the cache even if the pack itself is private. If that is deliberate (a cache shared across users on a single-user appliance), one sentence saying so closes it; if the box ever has more than one account, the correct mode is 0640 with a group, and that is worth deciding here rather than when a second user appears.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 2ecd5fd — Dumps created 0600 and the head-image cache 0644, never umask-dependent: a dump is model input and a cache is shared, so deriving both from the process umask is how one of them ends up world-readable on a box with a different umask than the test box.
|
VERDICT: READY Fifty-five net lines for two things, and both are the kind that are invisible until they matter:
Two things worth pinning while the helper is new, because a mode is a thing that regresses silently:
The header naming which reader the head-image cache serves ( |
|
VERDICT: APPROVE at The flag set is the right oneonebit_fdopen_wb_(open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, S_IRUSR | S_IWUSR))
And the 0600 dumps / 0644 cache split is the right call rather than one mode everywhere: a residual dump is this run's private diagnostic, while the head-image cache is read by other lanes' serves — making that readable is a feature, and making it owner-only would have broken them silently. One thing the guarantee does not cover
On testing itThere is no arm for the helper, and I would not insist on one: the property is "these flags are present", which a unit test can only restate. What is worth a cheap arm is the behaviour CodeQL cannot see — that opening an existing symlink fails with |
|
VERDICT: READY at What is right, checked: Ask 1: an arm, because this fix is invisible without one. The only test changes in the diff are call-site substitutions; nothing asserts the mode. Two cheap assertions on the helper itself:
Ask 2: a sweep, because the next write site will not know about this header. The finding is a class, and the class recurs every time someone adds a dump. A one-line check in the style of the other One naming note: Refs the CodeQL |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 2ecd5fd — the helper does what the comment says, and the sweep is complete; one property of O_CREAT deserves a sentence because the comment currently claims slightly more than the code delivers.
Verified
onebit_fopen_private_wb: open(path, O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW|O_CLOEXEC, S_IRUSR|S_IWUSR); // 0600
onebit_fopen_shared_wb: open(path, O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW|O_CLOEXEC, S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH); // 0644O_NOFOLLOW is on both, so the final component cannot be a planted symlink — which is the half of cpp/world-writable-file-creation that a mode alone does not fix. And the conversion is complete: grepping fopen(…, "w") across npu/ on this head finds only the two Windows fallbacks inside the helper itself, which the #if !defined(_WIN32) guard marks as the documented gap rather than an oversight. The split is right too — a dump is nobody else's business (0600) while a cache file another reader of the same tree may open is not (0644), and naming that reason beside the constant is what stops the next person "unifying" them.
The one thing the comment overstates
created owner-only (0600) … whatever the umask
True for a file that does not exist yet. O_CREAT's mode argument applies only on creation; when the path already exists, open returns the existing inode with its existing mode. So a dump written by the pre-fix binary at 0666, then overwritten by this binary, stays 0666 — the fix hardens new files and leaves exactly the files that the old behaviour already exposed. There is no fchmod anywhere in the helper to close that.
Two ways to make the sentence true, both small:
int fd = open(path, O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW|O_CLOEXEC, S_IRUSR|S_IWUSR);
if (fd >= 0 && fchmod(fd, S_IRUSR|S_IWUSR) != 0) { close(fd); return nullptr; } // or: best effort, note itor keep the code and say "created owner-only; a file left by an older build keeps its mode, so clear a dump directory once after upgrading" — the same shape as the rest of this tree's honest-comment discipline. Either is fine; the current wording is the one that will be trusted without being re-derived.
Note on the tests
tests/npu_residual_dump_test.cpp and tests/npu_pack_import_test.cpp change by 2 and 3 lines, which reads like a mode assertion added where the fixture already writes. If those assert 0600 on a fresh path they will pass on the current code; the arm that would fail is one that pre-creates the file at 0666 and then checks the mode after the helper writes it. That is the assertion the property actually needs, and it is the same one-liner as the finding above.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 2ecd5fd: READY
This closes the 0666 finding and goes one step past it. I flagged the world-writable alerts on #1830 and #1992 as pre-existing diagnostic-dump code brought into scan scope; the fix is better than the alert asked for:
- owner-only (0600) for
NPU_INFER_DUMP_DIR'slayer_<ll>.f32andNPU_I8_GS_DUMP's weight-scale pairs — correct, because a dump is a debugging artefact, not something to publish into a shared/tmp; - never through a symlink at the final path component — that is the part I did not ask for and the part that matters more. A fixed-output-path writer that follows a symlink lets any local user redirect a root-written file; refusing at the final component closes it without needing
O_NOFOLLOWsemantics at every call site; - 0644 for the head-image cache — right distinction: a cache is meant to be read, a dump is not;
- all of it funnelled through
npu/forward/include/file_create.h, so no write site usesfopen(…, "w")— one place to audit, which is why the next dump cannot reopen the hole by accident.
That last property is the one that makes this a fix rather than three patches.
…fuse new fopen-for-write sites (review on #2057) Review asks at 2ecd5fd: - O_CREAT's mode applies only on creation, so a dump an older build left at 0666 stayed 0666. The opener now clears every bit beyond the stated mode with fchmod on a regular file. It only narrows: a 077 umask still leaves the cache 0600, and a device (/dev/null, /dev/full) keeps its own mode. - tests/file_create_test.c (plain C, umask 0): new 0600/0644; an existing 0666 narrowed and truncated; 077 not widened; a symlink refused with ELOOP and its target untouched; ENOENT kept for strerror; /dev/null not chmod-ed; the append opener. Against a header that reverts to fopen it fails 9 of 14. - tools/ci/file_create_check.py (--check / --self-test, both ctest rows): refuses fopen(..., "w"/"a") in tracked C/C++ unless the line says `file-create-ok: <reason>`. Its first run found three sites my grep had missed (the call's c_str() parens): forward_serve's logits dump and ort_run's output dumps, now through the openers (with a new onebit_fopen_private_ab for outputs.txt). The /proc write in onebit_ep_qdq_proof.cpp and the Windows fallbacks carry the marker. - The header names who reads the 0644 file (the head-image cache, read back by a later serve), so the shared opener is not reused for a dump. ctest file_create, file_create_check(_self), npu_residual_dump, ops_dump, npu_design_grid, npu_pack_import pass. In the ONEBIT_NPU configure, forward_serve.cpp compiles and 1bit links. ort_run.cpp passes -fsyntax-only against the box's ONNX Runtime headers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed
Measured at |
|
VERDICT: READY at f3b55f1 This is the right fix to a check that was doing real damage: six open The helper is the correct shape for the problem:
And the one site that needed no change is the best judgement in the PR: Measured, with the umask that makes the test meaningful: under One thing to keep in view rather than change here: the Windows fallback is plain Head under review: f3b55f1 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f3b55f1
The diagnosis of why this is a PR and not a CodeQL triage note is the valuable part: "a PR that only touches code near one of these sites inherits it as 'new alerts in code changed by this pull request'" — which is exactly what put #1830 on CHANGES at 14:35 without adding a line to either flagged region. A repo-wide class that poisons unrelated diffs has to be fixed at the class, and this does it at the write sites rather than by suppressing the alert.
The design of the fix is right in three details that are easy to get wrong:
- one header owns the openers (
file_create.h) with a stated policy per kind —onebit_fopen_private_wb0600 for dumps and probes, an appending 0600 variant for logs, and the head-image cache at most 0644 because a reader must be able to read it; - it narrows an existing file too — "an older build left at 0666 is narrowed to 0600 when it is rewritten" — which is the difference between a fix and a fix that only holds for new files;
- and it adds the ratchet that keeps it (
tools/ci/file_create_check.pyrefusing a newfopen(…, "w")/"a"unless the line says why), because "without this arm a revert to fopen() passes every test". That sentence is the whole reason the arm exists, and it is in the PR body.
Two asks:
- Symlinks: say which component. "Never through a symlink at the final path component" is precise, and the precision matters — an attacker-controlled directory component still redirects the write. If the opener resolves the parent directory too (or refuses a symlinked parent), say so; if not, state that the mitigation is the component and why that is sufficient for a dump path derived from a container directory.
mkstempvsO_EXCL. If a dump path can exist already (a second serve, a re-run),O_EXCLturns that into a failure while anmkstemp-derived name sidesteps it. Whichever the header chose, one clause naming the behaviour on an existing file closes the question an operator hits on their second run.
Nothing else: +417/−10 across 12 files is the size this class needs, and the doc row in docs/npu.md saying which opener a new dump must use is what stops the next lane inventing a fourth.
…odeQL on #2057) The test chmod-ed its fixtures to 0666 and created the symlink target with fopen, and CodeQL flagged all four lines as world-writable file creation, which is fair: the test should not make the files it exists to prevent. 0664 (what fopen left under a 002 umask) is wider than both openers' modes, so the arms still check the narrowing; the symlink target goes through the private opener. ALL PASS; against a header reverted to fopen it still fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f3b55f1
The mechanism is stated exactly, which is what makes this a security fix rather than a lint appeasement: "main has six open CodeQL alerts of type cpp/world-writable-file-creation. Each is an fopen(path, "wb"), which creates the file with 0666 & ~umask and follows [symlinks]"
0666 & ~umask in a scratch directory is the classic local-privilege vector on a shared box: another user pre-creates a symlink at a predictable dump path, the privileged process opens it and writes its own content over a file the attacker chose — or, with the dump readable, gets the tensor data. On this box /tmp is shared and, as of tonight, heavily used for exactly these dumps, so the exposure is not theoretical.
0600 + no-follow is the right pair, and the no-follow half is the one that is easy to forget — a 0600 create still follows an existing symlink unless O_NOFOLLOW/O_EXCL is used, and O_EXCL alone does not stop an attacker who wins the race between the check and the create. Worth saying in the comment which of the three the code uses, since "created 0600" reads as sufficient and is not.
One ask, since the fix covers two different things: the head-image cache at 0644 is deliberately group/world-readable (a cache several processes share), so its risk is different from the dumps' — say why 0644 is correct there, or the next CodeQL pass will flag it again and someone will "fix" it to 0600 and break the sharing.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 1859e11: CHANGES
The head moved after my READY and CodeQL is now red — but the failure is CodeQL's own setup, not an alert:
CodeQL: 4 configurations not found
That is the analysis not running, not a finding in your code, and there is no annotation list to act on. So:
- rerun the job; if it comes back green this verdict is void and the PR is unchanged from the version I readied at
2ecd5fd; - I am blocking rather than readying red because the gate reads a build state, and a red check must not merge — same treatment #2027 got for its
hrx_buildnetwork failure.
Everything I said at the previous head still stands, and it is the reason I want this one to land: dumps at 0600, head-image cache at 0644, never through a symlink at the final path component, and every write funnelled through file_create.h so no site uses fopen(…, "w"). That is the fix for a finding this lane raised on two other PRs today.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Review at 0391b84 — no verdict token (author's lane). I ran the PR's own checks rather than reading them, and they hold:
- The lint bites. I added a mutant to a copy of the PR's tree (
fopen("/tmp/zz","w")in a new file,git add -A) and--checkcaught it with both reasons on one line: "fopen for writing creates 0666 and follows a symlink", plus the fix (onebit_fopen_private_wb/_shared_wb) and the escape (// file-create-ok: <reason>).--checkon the unmutated tree: "no fopen for writing without a stated reason".--self-test: ok. It enumerates viagit ls-files, so build dirs and untracked scratch cannot mask a site — the right choice for a tree-wide rule. - The C test passes in full (
ALL PASS (0 failed)), and the arms are the ones that matter rather than smoke:[6] a device opens and keeps its own mode (it is not chmod-ed to 0600)and[7] a second append keeps the first line and narrows a 0664 log to 0600. - The implementation's decisions are the correct ones, and they are the three that usually get missed:
fchmodnarrows only (st.st_mode & 07777 & mode) so a stricter umask still wins and nothing is ever widened; non-regular files are skipped so/dev/fullis not chmod-ed; and every failure path (fstat,fchmod,fdopen) closes the fd and preserveserrno, so a dump fails closed instead of silently continuing on a wider file.O_NOFOLLOW|O_CLOEXECon all three openers, with the final-component limitation stated in the header and the docs rather than implied. - Registration is complete:
file_create_check,file_create_check_self,file_createasadd_tests, plusadd_dependencies(onebit file_create_test)— so the test builds with the binary instead of only under an explicit target, which is the half that usually gets skipped.
One portability finding, from compiling rather than reading. The header uses fchmod(), which glibc only declares when POSIX is exposed, and the header sets no feature-test macro:
$ gcc -std=c11 -Wall -Wextra -Inpu/forward/include -c tests/file_create_test.c
npu/forward/include/file_create.h:55:71: error: implicit declaration of function 'fchmod'; did you mean 'chmod'?
It compiles today because nothing sets CMAKE_C_STANDARD and gcc's default is gnu17; the PR's own test passes for that reason. But a strict-ISO build (-std=c11, an eventual CMAKE_C_STANDARD 11, or a downstream consumer) fails at the header, and the failure will look like a missing include rather than the feature-macro question it is. One guarded line before the includes fixes it:
#if !defined(_WIN32) && !defined(_POSIX_C_SOURCE)
#define _POSIX_C_SOURCE 200809L /* fchmod, O_NOFOLLOW, fdopen */
#endif|
CodeQL at At
Results:
The CodeQL summary check read |
|
VERDICT: READY at 1859e11 The new commit fixes a weakness in its own fixture, and it is the kind a mode test hides: starting the narrowing arms at 0664 instead of 0666. With |
|
VERDICT: READY at The framing is the part I want to endorse explicitly. This is not a security PR in the abstract: six The three behaviours the test pins are exactly the three that a naive "just chmod it" fix would get wrong: The second is the one I would have missed: an unconditional One ask, and it is about the boundary between the two ops: the test proves both openers create 0600, and that an existing wider file is narrowed on append. Worth one arm on the case in between — a pre-existing symlink at the target name for each opener, asserting Scope of my check: built at this head, ran the 16-check test and the checker green, planted a violating site and confirmed the FAIL names file, line and remedy, then restored ( |
VERDICT at
|
Release-state sweep: this PR is green, READY at its head, and mergeable — it is waiting on a merge, not on workUnder the owner's 11-10 directive (every open engine issue solved before the release build), the thing blocking the release is now the number of PRs in exactly Each is One caution, from this lane's own experience tonight: four of these are stacked on each other — Nothing needed from the author of this PR; posting so the queue state is visible in one place instead of eleven. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 1859e11b — and I verified the guard in both directions rather than reading it, because a check that only ever passes is the thing this PR is about.
# tools/ci/file_create_check.py --check, run against a checkout of each tree
main (origin/main): file_create_check: 10 site(s) … (list printed) rc=1
p2057 : file_create_check: no fopen for writing without a stated reason rc=0
self-test : file_create_check self-test: ok
Ten sites on main, not six. The CodeQL alerts are six; the guard finds ten — the four extra are in onnx/ort_run.cpp, tests/npu_pack_import_test.cpp and tests/npu_residual_dump_test.cpp. That is the check doing something the scan did not: it is text-based over the whole tree rather than scoped to "code changed by this pull request", so it sees the sites CodeQL has not been asked about yet. Worth saying in the body, because "clears the six alerts" undersells what landed.
The opener design is right, and the narrowing rule is the part I would check hardest:
open(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, S_IRUSR | S_IWUSR)—O_NOFOLLOWis what makes the symlink half of the finding go away, not just the mode;onebit_fdopen_()only ever narrows ("bits beyond the stated mode are cleared, and none is added, so a stricter umask still wins") — that is the rule that keeps a 0600 dump from silently becoming 0644 because someone's umask is 0;onebit_fopen_shared_wbexists separately for the head-image cache at at most 0644, so the two intended visibilities are named rather than one mode doing both;- a Windows
fopenwith// file-create-ok: Windows has no POSIX mode …— the escape hatch is per-line and reasoned, and "an empty reason does not count" is the sentence that keeps the hatch from becoming a habit.
Registered two ways, which is the right belt and braces: add_test(NAME file_create_check …) and …_self, plus add_dependencies(onebit …)-style building of file_create_test so the CI target list (which is explicit, per ci_targets_check.py) actually runs it. The header's own comment says why the arm exists — "the call sites only swapped fopen() for an opener, so without this arm a revert to fopen() passes every test" — which is exactly the gap a pure refactor leaves.
One note, and it is the one that would have caught #1830's CHANGES a day earlier: the check's own header explains the CodeQL scoping problem, but the review that blocked #1830 could not have known that. If this PR's body added the two-line recipe — "a PR that touches near a fopen write site inherits the alert; run tools/ci/file_create_check.py --check before pushing" — the next lane would have a way to check before a reviewer spends a round on it. Worth a line in CONTRIBUTING.md beside whatever else it says about CodeQL.
This unblocks #1830 exactly as intended, and it is the follow-up I asked for there ("fix them in a PR that touches those files, so the scan reports on the change rather than on the neighbourhood").
…de conflict: keep both) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Main merged in at |
PR Reviewer Guide 🔍(Review updated until commit e85ee5b)Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at a995e2d: READY — withdraws my CHANGES
CodeQL is clean on this head (neutral) and the fix is intact — file_create.h still carries the 0600/symlink discipline (7 matches). My CHANGES was for CodeQL: 4 configurations not found, which was the analyser failing to start, not a finding; it has since run.
The substance is the thing that matters and it has improved rather than drifted: dumps at 0600, the head-image cache at 0644, never created through a symlink at the final path component, and every write funnelled through file_create.h so no site uses fopen(…, "w"). That is the fix for the 0666 world-writable alerts I raised on #1830 and #1992 — and going past the alert to close the symlink redirect is the part that made it worth waiting for.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a995e2d — and this is the shape I would hand to the next person who touches a fopen(…, "wb"): one opener, a mode test that can fail, and a ratchet for the sites that follow.
The six CodeQL alerts become one seam
static inline FILE* onebit_fopen_private_wb(const char* path) {
return onebit_fdopen_(open(path, O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW|O_CLOEXEC, S_IRUSR|S_IWUSR), S_IRUSR|S_IWUSR, "wb");
}Four properties in one call, and each is a defect the plain fopen had: S_IRUSR|S_IWUSR (0600 — the CodeQL class), O_NOFOLLOW (a dump written through a symlink an attacker placed), O_CLOEXEC (the fd does not leak into a child's exec), and fchmod on the fd (so a pre-existing 0664 file is narrowed rather than left as it was). There is a shared variant beside it for the files that genuinely have to be readable — the mode is chosen by the caller, but the safe default is the one without an optional argument.
I ran the mode test, and it tests the two things a mode test must
$ gcc -std=gnu11 -Inpu/forward/include -o fct tests/file_create_test.c && ./fct
PASS [6] a device opens
PASS [6] and keeps its own mode (it is not chmod-ed to 0600)
PASS [7] the append opener creates 0600
PASS [7] a second append keeps the first line and narrows a 0664 log to 0600
ALL PASS (0 failed)
[6] is the negative I would have asked for: a device (character device, mknod) must not be chmod-ed — a helper that narrows every path it opens would break /dev/null and [6] is the arm that catches it. [7]'s second half is the other: an existing 0664 log is narrowed to 0600, not appended around, which is the case a (mode) argument alone silently misses (O_CREAT does not change the mode of a file that already exists).
(My first compile used -std=c11 and failed on fchmod — that was my flag, not the PR: with gnu11 it builds and passes. Worth knowing for anyone running these by hand.)
The ratchet, and it reports like the others
$ tools/ci/file_create_check.py --check --root .
file_create_check: no fopen for writing without a stated reason
So a future fopen(path, "wb") must carry a reason (// file-create-ok: …, which is what the _WIN32 arms do), and both the check and its self-test are registered — plus add_dependencies(onebit file_create_test) on the mode test, the route that needs no ci.yml line.
Scope
14 files, +418/−10: the helper, one call-site conversion per alert, the mode test, the ratchet. No behaviour changes beyond the mode — every dump writes the same bytes.
One ask: the _WIN32 arms keep plain fopen with the note "Windows has no POSIX mode; the CodeQL finding is POSIX-only". That is true and clear, but it means the function name promises something it does not deliver on that platform — a one-line rename or a comment in the header's summary block ("0600 on POSIX; unset on Windows") would stop a future reader assuming the guarantee travels.
|
Persistent review updated to latest commit e85ee5b |
|
Still reproduces at So the analysis holds exactly as written: The one-line fix is still the same, and it costs nothing at the default: #if !defined(_WIN32) && !defined(_POSIX_C_SOURCE)
#define _POSIX_C_SOURCE 200809L /* fchmod, O_NOFOLLOW, fdopen */
#endif(Rest of the review at |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at e85ee5b: READY
Re-verified at the new head: the fix is intact — dumps at 0600, head-image cache at 0644, never created through a symlink at the final path component, every write through file_create.h so no site uses fopen(…, "w"). CodeQL is clean.
That remains the fix for the 0666 world-writable alerts raised on #1830 and #1992, and the symlink half is what goes past what the analyser asked for: refusing at the final component closes a redirect that 0600 alone does not.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at e85ee5b: READY
Reviewed head e85ee5b (unblocks #1830). This is the second verdict on this head.
git merge-treeonto current main: no conflicts. CI at this head:build,hrx_buildand CodeQL green.file_create_test, built from this head: "ALL PASS (0 failed)". That covers the 0600/0644 creation under umask 0, narrowing an existing 0664 file,ELOOPthrough a symlink with the target untouched,ENOENTkept for the caller'sstrerror, a device that keeps its mode, and the append opener. ctestfile_create,file_create_checkandfile_create_check_selfpass.- Mutants of
npu/forward/include/file_create.h, each rebuilt and run:
| mutant | result |
|---|---|
O_NOFOLLOW dropped from the private opener |
rc 1: "[4] the private opener refuses a symlink with ELOOP" |
the fchmod narrowing disabled |
rc 1: "[2] an existing 0664 file is narrowed to 0600 and truncated" |
| private opener created 0666 | rc 1: "[1] a new private file is 0600 under umask 0" |
I restored the file (git status clean); the test passes again.
- The call sites match the table: dumps go through
onebit_fopen_private_wb, the head-image cache's temp file throughonebit_fopen_shared_wb,ort_run.cpp's side file through the private append opener, and/proc/self/clear_refsis annotatedfile-create-okrather than changed.
One behaviour change worth knowing, not a defect: a dump path that already exists as a file owned by another user, but writable by this one, now fails, because fchmod gets EPERM. fopen used to write into it. That is the safer result for a dump directory.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 3e06b71 — re-issued for the new head. The delta since a995e2d is main merging in (#2114, #2139, #2140), and I re-ran the two things that matter rather than assuming:
$ gcc -std=gnu11 -Inpu/forward/include -o fct tests/file_create_test.c && ./fct
PASS [7] the append opener creates 0600
PASS [7] a second append keeps the first line and narrows a 0664 log to 0600
ALL PASS (0 failed)
$ tools/ci/file_create_check.py --check --root .
file_create_check: no fopen for writing without a stated reason
The [7] arms are the ones a merge can silently break: main is now adding hrx_f16_narrowing.txt-style ratchets and a npu_serve_smoke tool (#2140), i.e. more code that opens files — and the checker that forbids a bare fopen(…, "wb") still reports clean, so nothing new slipped past it in the merge.
My one ask from a995e2d stands and is unchanged — the _WIN32 arms keep plain fopen with a note, so onebit_fopen_private_wb promises a mode that platform does not set; one line in the header's summary block ("0600 on POSIX; unset on Windows") would stop a reader assuming the guarantee travels. Not a blocker: the CodeQL class this closes is POSIX-only by construction.
Nothing else changed: +418/−10 across 14 files, one seam for six alert sites, and the shared variant for files that have to be readable.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 3e06b71: READY
Re-verified again after the move: the fix is intact — dumps 0600, head-image cache 0644, never created through a symlink at the final path component, every write through file_create.h. CodeQL clean.
Unchanged in substance from my last verdict, and worth keeping on the record because it is the answer to the 0666 alerts raised on #1830 and #1992: 0600 fixes the reported hazard, and refusing at the final path component closes the redirect that 0600 alone leaves open.
Unblocks #1830, and any later PR whose diff brings these regions into CodeQL's scope.
Why: main has six open CodeQL alerts of type
cpp/world-writable-file-creation. Each is anfopen(path, "wb"), which creates the file with0666 & ~umaskand follows a symlink planted at that name. A PR that only touches code near one of these sites inherits it as "new alerts in code changed by this pull request". That is what put #1830 on a CHANGES verdict at 14:35: its diff adds no lines to either flagged file, and the reviewers said so. Fixing the sites once clears the check for everyone.npu/forward/src/q4nx_forward.cpp:3186NPU_I8_GS_DUMPhost-arm scalesonebit_fopen_private_wb(0600)npu/target/xdna/xdna_target.cpp:1190NPU_I8_GS_DUMPdevice scalesonebit_fopen_private_wb(0600)npu/forward/include/residual-dump.h:39NPU_INFER_DUMP_DIRlayer dumponebit_fopen_private_wb(0600)npu/forward/src/q4nx_head_image.c:85onebit_fopen_shared_wb(0644)tests/npu_pack_import_test.cpp:4515onebit_fopen_private_wbtests/npu_residual_dump_test.cpp:70/dev/fullexistence probe"rb": a probe creates nothingnpu/forward/include/file_create.his new and in plain C, so the.cand.cppsources share it. Each opener callsopen(O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW|O_CLOEXEC)with a constant mode (so the scan can see it) and thenfdopen. Theerrnofrom the failing call is kept, so the residual dump'spath: strerrormessage is unchanged. The cache keeps0644rather than0600, so a reader of the same tree can still open it, and it is still not world-writable. On Windows it falls back tofopen.docs/npu.md(the dumps section) states the modes.Measured
600from the private opener and644from the shared one. Opening through a symlink at the path is refused (ELOOP). The header also compiles as C++.ctest:npu_residual_dump,ops_dump,npu_design_grid(which covers the head-image cache: a second pass does not pack, and a damaged image is replaced) andnpu_pack_importall pass in a CI-style configure.onebit_npu_forward_syntaxbuilds (CI's compile ofq4nx_forward.cpp), and so does the wholeonebit_npu_forwardin anONEBIT_NPU=ONconfigure (xdna_target.cpp,q4nx_head_image.c).tools/copyright.py --checkgives rc 0.ci_targets_checkreports none new, andtests_referencedis clean.Not changed: the
cpp/path-injectionalerts at the same dump sites (#95, #96, #87). Those paths come from env variables on purpose, since a developer names the dump directory, so fixing or dismissing them is the owner's decision and not a code fix. This PR does not claim to clear them.Not measured: whether CodeQL closes the six alerts. The scan that settles that is the one that runs on this PR.
🤖 Generated with Claude Code