Repository navigation
Conversation
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#119 "fix(linux_security): close the gaps in the shell-path guard, and test it"
head: a064481 author: Kartikey1306 ci: pass
Verdict: The three diagnosed holes are real and the fixes for them are correct, and
the counter-check that a well-formed path still reaches the shell is the right instinct.
But the guard on eos_busybox_configure() is placed above the line that populates the
field it protects, so the default path — the one taken whenever a caller does not set
source_dir — still reaches system() with unvalidated data; and the file's larger
fail-open problem, security steps whose exit status is discarded, is untouched while the
PR's Validation section reads as though the file is now sound.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | High | services/linux/src/linux_security.c:481-484 (post-patch: guard inserted above :482) |
The new guard is bypassed on the default path. The check runs first, then if (!bb->source_dir[0]) builds source_dir from bb->version — and bb->version is validated nowhere: eos_busybox_set_version() (:442-445) is a bare strncpy. After eos_busybox_init() zeroes the struct, source_dir is empty, is_path_safe("") returns 1 because the loop body never executes, the guard passes, and .eos/build/src/busybox-<version> goes into make -C "%s" at :488. A backtick in version is command substitution inside those double quotes. This is the same injection class the PR closes elsewhere, on the path a caller gets by default, in a function the PR modifies. It also persists: configure writes the poisoned string back into bb->source_dir, so eos_busybox_build()'s guard will reject it afterwards — after configure already ran the command. |
Validate in eos_busybox_set_version() (return -1 on !is_word_safe(version)), and move the configure guard below the default-fill at :485 so it validates the value actually used. Both, not either: the setter stops the bad value entering, the re-ordered guard stops any other route to the same field. Add the case to test_busybox_refuses_hostile_fields() — empty source_dir plus version = "1.36.1\touch /tmp/pwned`"— driven throughtest_injection_does_not_execute()`'s sentinel so it discriminates. |
| 2 | High | services/linux/src/linux_security.c:191,195 (also :82, :104, :177, :527) |
Security steps report success without having run. eos_ima_sign_file() discards system()'s return entirely and ends return 0;, and the command it built ends || echo 'evmctl not available', so the shell's own exit status is masked too. On any host without evmctl the function signs nothing and tells its caller it signed. .ai/security.md, "Fail closed": "A verification step that cannot run must fail, not pass" and "A security step whose result is discarded is a finding even when the happy path is correct." The same discard is at eos_selinux_install_to_rootfs():82, eos_selinux_label_rootfs():104 (|| echo '…skipping labeling'), eos_ima_install_to_rootfs():177 (|| true) and eos_busybox_install_to_rootfs():527. Pre-existing, not introduced here — raised against this PR because it modifies four of those five functions and its Validation section presents the file as hardened. |
Separate "the tool is absent" from "the tool ran and failed", and never collapse either into 0. Drop the || echo … / || true tails, keep rc = system(cmd), and return non-zero when the step did not complete. If a missing evmctl/setfiles must be tolerated, make that an explicit caller-visible state (a distinct return code or a flag on the struct) rather than a 0 indistinguishable from success. eos_dmverity_verify():309-311 already does this correctly and is the pattern to copy. |
| 3 | Medium | tests/test_linux_security_paths.c:356-363 |
The suite covers eos_dmverity_verify, eos_dmverity_create, eos_busybox_build, eos_busybox_configure and eos_ima_sign_file. It covers none of eos_selinux_install_to_rootfs, eos_selinux_label_rootfs, eos_ima_install_to_rootfs or eos_busybox_install_to_rootfs — including the specific gap the PR body names as its second finding, rootfs_dir reaching cp unchecked through path at :80. That guard (services/linux/src/linux_security.c:70-76) ships with no test. |
Add one hostile-rootfs_dir case per *_install_to_rootfs entry point. eos_selinux_install_to_rootfs() and eos_ima_install_to_rootfs() create directories under rootfs_dir, so give them a mkdtemp() root the way test_ordinary_paths_still_reach_the_shell() does and assert both refusal and the absence of the sentinel. |
| 4 | Medium | services/linux/src/linux_security.c:61-68 (is_word_safe) |
The PR's own argument is that a denylist for shell metacharacters is the wrong shape — it was incomplete, and the two missing entries were exploitable. is_word_safe() answers that with a second denylist: space, tab, *, ?, ~. It does not reject [ or ] (glob character class) or { / } (brace expansion, which /bin/sh performs when it is bash). Both reach make unquoted through defconfig at :489 and cross_compile at :492. The consequence is argument mangling rather than command execution, which is why this is Medium — but the list will need extending again, which is the failure mode the PR is fixing. |
"One shell word" has a small positive definition; write it as one. Replace the character loop with an allowlist — isalnum() plus ._/+=:- — and reject everything else. That covers every real defconfig, cross_compile prefix and hashalgo value, cannot be incomplete, and needs no future maintenance. |
| 5 | Low | services/linux/src/linux_security.c:139-141 |
The new guard is inserted above if (ima->mode == EOS_IMA_OFF) return 0;, so eos_ima_install_to_rootfs() with IMA disabled and a malformed rootfs_dir now returns -1 where it previously returned 0. Failing closed is the right call, but it is a change to the function's contract that the body does not mention and no test pins. |
Keep the ordering; say so in the body, and add a case asserting -1 for EOS_IMA_OFF with a hostile rootfs_dir so the choice is deliberate and recorded. |
| 6 | Low | net/src/net_posix.c:16,216; tests/test_net.c:213-250 |
Two files outside the stated scope change with no mention in the PR body, which describes only linux_security.c and the new test. Both changes are correct and worth having — the #if EOS_ENABLE_NET wrapper restores what 8480276 (#103) established and 3df89a8 (#89) undid by recreating the file without it (master has neither guard), and the ten assert → CHECK conversions finish what 39640bb (#104) started: master still has bare assert() at tests/test_net.c:213-250, and under the Release/NDEBUG build those vanish together with the calls inside them, which is precisely the hang the comment at tests/test_net.c:13-17 warns about. Being right does not make them in scope for a shell-injection PR. |
Split them into their own PR, or state them in the body. Separately, that comment names the wrong macro twice — it reads "Not CHECK():" and "NDEBUG deletes CHECK()" where both mean assert(); this PR puts ten more converted lines under it, so it is worth one word of repair while you are there. |
| 7 | Low | tests/test_linux_security_paths.c:321-354 |
The counter-check invokes make on the test host. Without make installed, eos_busybox_configure() returns -1, CHECK(... == 0) fails, and the suite reports a failure that says nothing about the guard. |
Probe for the tool first and print [SKIP] instead of counting a failure, the way mkdtemp failure is already handled at :328. |
| 8 | Low | tests/test_linux_security_paths.c:210-221,268-276 |
Acknowledged in the body and re-stated here because it is a real limit on the evidence: the != 0 assertions in test_dmverity_verify_refuses_hostile_paths and ..._hostile_hash_device pass against the unfixed file on any host without veritysetup, which is every CI runner. Twelve of the suite's assertions therefore prove nothing in CI. |
Mitigated, not unmitigated — test_injection_does_not_execute() (:233-266) discriminates anywhere via the sentinel, and it covers dmverity_verify, dmverity_create and ima_sign_file. Extending that sentinel technique to the remaining entry points (see finding 3) would let the non-discriminating assertions be deleted rather than explained. |
No check is weakened by this PR: the ten assert → CHECK conversions in tests/test_net.c
strengthen them (CHECK at tests/test_net.c:18-23 calls exit(1) and, unlike assert,
survives NDEBUG), no test is disabled, no lint loosened, no permission widened. All 26
required checks in checks.txt pass. The body's uid=501(kartikey) transcript is real
evidence of the original defect, and the "Left for a separate change" section correctly
declines to smuggle in the fork/execv rewrite.
Architecture conformance
§14.1 Security principles — conforms as far as the section reaches. No primitive is
invented; evmctl, veritysetup and setfiles are reviewed external tools, which is
what §14.1 asks for. Nothing in this diff touches key material, and no key, token or
device credential appears in the added code or in the test payloads.
§5.1 architectural law — conforms. services/linux is a platform service and depends
on nothing above it; the new test links eos_linux_security only.
§4 product hierarchy — conforms. SELinux, IMA, dm-verity and kaudit sit inside eos
as the "eSec subset" §4 explicitly places in EmbeddedOS Core, so the code is in the right
repo. (These functions do build-time rootfs construction on a Linux host, which brushes
against the eBuild boundary raised in the eos#118 review, but §4's wording covers it.)
Design gap. §14.1 has five security principles and none of them governs how a security
step invokes anything: nothing in §14.1, §8.1 or §15.1 says whether a trusted component
may build a shell command string, what validating its inputs requires, or that its exit
status must be honoured. Findings 1, 2 and 4 are all the same missing rule. Proposal
appended to .ai/autoreview/proposals/2026-09.md.
Proposed changes
eos_busybox_set_version()(:442):if (!is_word_safe(version)) return -1;.eos_busybox_configure(): move the guard added by this PR to after the default-fill
block at:482-485, sois_path_safe(bb->source_dir)sees the constructed value.- Replace the
is_word_safe()character loop with an allowlist (isalnum()plus
._/+=:-). No behaviour change for any value inexamples/; removes finding 4. eos_ima_sign_file(): keeprc = system(cmd), drop the|| echo 'evmctl not available'
tail, return non-zero when the signing step did not complete. Then the same for the
four other discard sites. This one changes caller-visible behaviour and is the only
step here that could break a caller — it deserves its own commit and its own test.- Tests: hostile-
versioncase with the sentinel (finding 1); one hostile-rootfs_dir
case per*_install_to_rootfsentry point (finding 3); tool-probe skip (finding 7).
Steps 1–3 are self-contained and safe to land together. Step 4 is a contract change and
should be separate. Step 5 should land with whichever of 1–4 it covers.
Not checked
- I did not build this branch or run
ctest; the35/35and theuid=transcript are
the author's. The independent evidence ischecks.txt, green for this head SHA. - Finding 1 is read from the source, not demonstrated — I did not compile a harness to
execute the injection throughbb->version. The reasoning isis_path_safe("") == 1
(:51-55, empty string, loop body never runs) and the default-fill at:482-484
running after the guard; both are visible in the file, but I have not run it. - Windows: every shell-out is inside
#ifndef _WIN32and the#elsearms differ per
function (eos_busybox_configurereturns -1,eos_ima_sign_filereturns 0). I did not
review the Windows behaviour of the new guards. - I did not audit the non-shell paths in this file —
eos_kaudit_install_to_rootfs()
(:361-374) andeos_busybox_install_to_rootfs()'s/initwriter (:531-540)
interpolaterootfs_dirintosnprintfpaths and thenfopenthem. No shell is
involved, sois_path_safeis not the right control there, but path traversal through
rootfs_diris a different question that this PR does not raise and I did not pursue. - Whether any caller in the tree relies on
eos_ima_sign_file()returning 0 — the
contract change in step 4 needs that survey before it lands, and I did not do it.
Automated architecture review of a06448110a5a — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
…reporting unrun steps as done Answers both High findings from the review on embeddedos-org#119. Finding 1 -- the new guard was bypassed on the default path. eos_busybox_configure() validated bb->source_dir, then filled it from bb->version when empty. Empty is what eos_busybox_init() leaves, so on the path a caller gets by default is_path_safe("") returned 1 (its loop body never runs), the guard passed, and ".eos/build/src/busybox-<version>" went into `make -C "%s"`. A backtick in version is command substitution inside those double quotes, and eos_busybox_set_version() was a bare strncpy with no check at all. Fixed at both ends, as the finding asks: the setter refuses a version that is not word-safe, and the guard moved below the default-fill so it validates the value actually used rather than the empty string. This is demonstrated, not argued. Running the new test against the unmodified source: [FAIL] the default path executed the injection: /tmp/eos_lsp_ver_uNG5ro/pwned The sentinel file exists, so the injected command ran. Finding 2 -- security steps reported success without having run. eos_ima_sign_file() discarded system()'s return and its command ended `|| echo 'evmctl not available'`, which masked the shell's status too, so on any host without evmctl it signed nothing and returned 0. .ai/security.md: a verification step that cannot run must fail, not pass. Now returns -1 and logs through EOS_ERROR, following eos_dmverity_verify() which already gets this right. The Windows branch returns -1 rather than 0 for the same reason. eos_linux_security gains a link to eos_core for EOS_ERROR -- the same resolution embeddedos-org#108 took for eos_ota, and no cycle: eos_core does not depend on eos_linux_security. The four remaining `|| true` / `|| echo` discards named in the finding (eos_selinux_install_to_rootfs, eos_selinux_label_rootfs, eos_ima_install_to_rootfs, eos_busybox_install_to_rootfs) are NOT fixed here. They are install/copy steps rather than verification steps, and distinguishing "the tool is absent" from "the tool ran and failed" for each needs a caller-visible state that does not exist yet. Left deliberately, said here rather than implied by silence. Also: the [PASS] lines printed unconditionally, so a function could report success after its own CHECKs had failed -- the same shape as the defects under test. Now guarded on the failure count not having moved. Verified: ctest 39/39 PASS test_linux_security_paths 9/9 PASS discrimination, against the source with only these two fixes reverted: set_version accepts the hostile version FAIL the sentinel file is created FAIL <- injection ran ima_sign_file returns 0 with no evmctl FAIL rebased onto master; the tests/CMakeLists.txt conflict with the four suites now on master is resolved additively. Refs embeddedos-org#119
a064481 to
0b4dd9c
Compare
…reporting unrun steps as done Answers both High findings from the review on embeddedos-org#119. Finding 1 -- the new guard was bypassed on the default path. eos_busybox_configure() validated bb->source_dir, then filled it from bb->version when empty. Empty is what eos_busybox_init() leaves, so on the path a caller gets by default is_path_safe("") returned 1 (its loop body never runs), the guard passed, and ".eos/build/src/busybox-<version>" went into `make -C "%s"`. A backtick in version is command substitution inside those double quotes, and eos_busybox_set_version() was a bare strncpy with no check at all. Fixed at both ends, as the finding asks: the setter refuses a version that is not word-safe, and the guard moved below the default-fill so it validates the value actually used rather than the empty string. This is demonstrated, not argued. Running the new test against the unmodified source: [FAIL] the default path executed the injection: /tmp/eos_lsp_ver_uNG5ro/pwned The sentinel file exists, so the injected command ran. Finding 2 -- security steps reported success without having run. eos_ima_sign_file() discarded system()'s return and its command ended `|| echo 'evmctl not available'`, which masked the shell's status too, so on any host without evmctl it signed nothing and returned 0. .ai/security.md: a verification step that cannot run must fail, not pass. Now returns -1 and logs through EOS_ERROR, following eos_dmverity_verify() which already gets this right. The Windows branch returns -1 rather than 0 for the same reason. eos_linux_security gains a link to eos_core for EOS_ERROR -- the same resolution embeddedos-org#108 took for eos_ota, and no cycle: eos_core does not depend on eos_linux_security. The four remaining `|| true` / `|| echo` discards named in the finding (eos_selinux_install_to_rootfs, eos_selinux_label_rootfs, eos_ima_install_to_rootfs, eos_busybox_install_to_rootfs) are NOT fixed here. They are install/copy steps rather than verification steps, and distinguishing "the tool is absent" from "the tool ran and failed" for each needs a caller-visible state that does not exist yet. Left deliberately, said here rather than implied by silence. Also: the [PASS] lines printed unconditionally, so a function could report success after its own CHECKs had failed -- the same shape as the defects under test. Now guarded on the failure count not having moved. Verified: ctest 39/39 PASS test_linux_security_paths 9/9 PASS discrimination, against the source with only these two fixes reverted: set_version accepts the hostile version FAIL the sentinel file is created FAIL <- injection ran ima_sign_file returns 0 with no evmctl FAIL rebased onto master; the tests/CMakeLists.txt conflict with the four suites now on master is resolved additively. Refs embeddedos-org#119
0b4dd9c to
995ee7f
Compare
…removed `CI — eos` and `Python Unit Tests` are red on master (769191a), and both fail for one reason: these suites exist in tests/ but no add_executable() in tests/CMakeLists.txt builds them, so they never run: ['test_crypto_ed25519_loworder.c'] 3aa8644 (embeddedos-org#93, "remove duplicate test targets from tests/CMakeLists.txt") removed all four lines that build and register test_crypto_ed25519_loworder. It was not a duplicate — there was exactly one registration before that commit, and it took it. What stopped running is the suite that guards embeddedos-org#101: Ed25519 rejecting low-order public keys. A low-order key makes every term of the verification equation collapse to the identity regardless of the message, so a signature of all zeros verifies against any content at all. The fix is still in services/crypto/src/ed25519_verify.c and still correct; nothing has been checking it since embeddedos-org#93 merged, and nothing would have noticed if a later change had removed it too. Restored verbatim from 3aa8644^, in its original position before test_crypto_sha512. No other file changes. The guard that caught this is test_cmake_test_registration.py, added recently. It did exactly its job — this is the first thing it found. Verified: test_cmake_test_registration.py -> 5 passed (1 failed on master) pytest tests/ -> 15 passed (14 passed 1 failed on master) test_crypto_ed25519_loworder -> 5/5 tests passed ctest -> 39/39 passed (master builds 38)
CodeQL alert 404 on this branch's head, at tests/test_linux_security_paths.c:266 -- "A file may be created here with mode 0666, which would make it world-writable." Third instance of this in the same file. Commit bba834a fixed the Makefile fixture for exactly this reason and the comment there explains it, and I introduced a new `fopen(target, "w")` in the evmctl test two commits later without applying the same rule. fopen creates 0666 masked by umask, so under a permissive umask the file a signing tool is pointed at is world-writable. Now open(O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR), matching the fixture above it. O_EXCL as well, since the path is inside a fresh mkdtemp directory and an existing file there would mean something is wrong. No `fopen(..., "w")` remains in the file. Verified: ctest 40/40 PASS test_linux_security_paths 9/9 PASS Refs embeddedos-org#119
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#119 "fix(linux_security): close the gaps in the shell-path guard, and test it"
head: 8dca787 author: Kartikey1306 ci: fail (both failures inherited from eBoot master — see CI)
Verdict: The injection fix is correct and the test is the real thing — I weakened the denylist by one character and watched four checks fail with the injected command actually executing, so this suite discriminates rather than rubber-stamps. What it does not do is finish the job it starts: the PR removes one || echo + discarded-system() pair with an explicit .ai/security.md citation and leaves four more in the same file, each reporting success for a security step that did not run.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | High | services/linux/src/linux_security.c:110-112, :126-134, :205-207, :596-598 |
Four system() calls still swallow their result behind a ` |
|
| 2 | Low | services/linux/src/linux_security.c:71-78 |
is_word_safe() rejects *, ? and ~ but not [ or ]. bb->defconfig is interpolated unquoted (make -C "%s" %s, :558-559), which is precisely where the shell globs, so a bracket expression still expands. Nothing rejects a leading - either, so defconfig = "--version" reaches make as an option rather than a target. Neither is command execution — glob expansion happens after tokenisation, so an expanded filename containing ; is not re-parsed — so the impact is a wrong make invocation, not injection. Flagged because a glob denylist that lists three of five metacharacters reads as an oversight rather than a decision. |
Add [ and ] to the is_word_safe() set and reject a leading -. Alternatively quote the two unquoted sites and drop the second predicate entirely — that removes the weaker of the two guards, though it changes defconfig from "several make arguments" to "one argument", so it is a behaviour choice rather than a pure fix. |
| 3 | Low | services/linux/src/linux_security.c:558-566, :578-582 |
offset += snprintf(cmd + offset, sizeof(cmd) - (size_t)offset, …) accumulates snprintf's would-be length, not what it wrote. If any segment truncates, offset exceeds sizeof(cmd), so cmd + offset is out of bounds and sizeof(cmd) - (size_t)offset underflows to a huge size_t — an unbounded write. Not reachable today, and I checked rather than assumed: the fields cap the total at 9 + 511 + 2 + 127 = 649, then +15 + 127 = 791, then +16 = 807, against cmd[2048]. It becomes reachable the moment source_dir[512], defconfig[128] or cross_compile[128] grows, or cmd shrinks, and nothing connects the two. Pre-existing, but it is in the two functions this PR rewrites the guards of. |
if (offset < 0 || (size_t)offset >= sizeof(cmd)) return -1; after each snprintf, in both functions. |
| 4 | Low | tests/test_linux_security_paths.c:226-227 |
New -Wformat-truncation= warning on a target built with -Wall -Wextra -Wpedantic: snprintf(hostile_version, sizeof hostile_version /* 192 */, "1.36.1\touch %s`", sentinel /* 320 */) — "output between 15 and 334 bytes into a destination of size 192". No truncation occurs in practice (sentinelis ~30 bytes frommkdtemp`), but the failure mode matters here specifically: truncation would cut the closing backtick, defanging the payload, and the test would then pass because it is no longer testing anything. A security test that can silently stop being hostile should not depend on a temp-path length. |
Size hostile_version from sizeof sentinel + 32, or assert snprintf's return is < (int)sizeof hostile_version so a truncated payload fails loudly instead of passing quietly. |
| 5 | Low | services/linux/CMakeLists.txt:12 |
target_link_libraries(eos_linux_security PUBLIC eos_core) exports eos_core's include directories to every consumer in order to make one .c file see eos/log.h. Nothing under services/linux/include/ includes it. .ai/architect.md lists "widen a public header to make one caller compile" under "Do not", and this is the CMake form of it. Verified PRIVATE is sufficient: CMake still records a static library's PRIVATE dependency as a $<LINK_ONLY:> interface requirement, so test_linux_security_paths — which names only eos_linux_security — links and passes unchanged. |
PRIVATE eos_core. Confirmed by building and running the test with it. |
| 6 | Low | tests/test_linux_security_paths.c:172-206 |
test_ordinary_paths_still_reach_the_shell() needs make on PATH and has no skip, unlike every other environment dependency in this file (mkdtemp failure at :180, evmctl presence at :265). Without make, eos_busybox_configure() returns -1, CHECK(… == 0) fails, and the suite reports a security failure that is really a missing build tool. test_the_default_source_dir_path_is_validated() also invokes make relative to the current working directory. |
The command -v skip already used for evmctl: if (system("command -v make >/dev/null 2>&1") != 0) { printf("[SKIP] …"); return; }. |
| 7 | Low | tests/CMakeLists.txt:93-96 |
The test_crypto_ed25519_loworder registration block is eos#125's fix, carried here so this branch is not red on the registration guard. Necessary — origin/master has tests/test_crypto_ed25519_loworder.c but does not register it, so tests/unit/test_cmake_test_registration.py fails on master. But eos#118 and eos#126 add the identical block at the identical anchor, so #118, #119, #125 and #126 all collide in tests/CMakeLists.txt. Same situation the author flagged for #115 on #118. |
Land #125 first, then rebase the other three and drop the duplicated hunk. Worth folding into the existing merge-order comment so the fourth collision is not found at merge time. |
CI
Two checks red, and neither is caused by this PR — identical to eos#118:
Cross-compile ARM64 kernel—.github/workflows/eos-simulation.yml:28-33checks outembeddedos-org/eBoot@masterand builds it. eBoot'smasterdoes not compile (eBoot/include/eos_image.h:135,142,error: 'eos_image_header_t' has no member named 'reserved'). I reproduced this by building eBootorigin/masterin a clean worktree; it has three pre-existing errors from one bad merge, and eBoot#94 already targets them.Full-stack integration summary—needs: [build-kernel, …], so it fails purely downstream.QEMU ARM64 simulation (13 TCs)showsskippingfor the same reason.
Everything else is green. Reproduced locally at head 8dca787:
cmake -S . -B bt -DEOS_BUILD_TESTS=ON && cmake --build bt # rc=0
ctest --test-dir bt --no-tests=error # 40/40 passed
./bt/tests/test_linux_security_paths # all 9 sub-tests PASS
pytest tests/ -q # 15 passed
40, not the 35 in the body — the branch has been rebased onto a newer master since that figure was recorded. Not a discrepancy worth fixing, but the body's number no longer matches the head.
Negative control, because a security test that cannot fail is worth nothing. I removed the backtick and backslash from the new denylist and rebuilt:
[FAIL] test_linux_security_paths.c:242: access(sentinel, F_OK) != 0
[FAIL] the default path executed the injection: /tmp/eos_lsp_ver_QR1JoG/pwned
4 check(s) failed
Both sentinel-based tests caught it and the injected touch genuinely ran. The suite discriminates. Worth noting what the same run shows about the rest of it: dmverity_verify refuses hostile image paths, dmverity_verify refuses a hostile hash device, dmverity_create refuses hostile paths, busybox refuses hostile fields and ima_sign_file refuses a hostile file path all reported PASS against the weakened guard. The author already says the dm-verity assertions do not discriminate without veritysetup; on this evidence the same is true of the busybox and IMA return-code assertions. The two side-effect tests are carrying the whole suite. That is not a defect — it is worth knowing which five of the nine sub-tests would notice a regression.
Architecture conformance
Conforms. §14.1 / .ai/security.md, with finding 1 as the deviation.
- §14.1, "Do not invent primitives" — not engaged; no cryptography here.
.ai/security.md, input validation — conforms and improves. "Every externally reachable parser is attack surface … define behaviour for malformed, truncated, oversized, zero-length and duplicated input." Makingis_path_safe(NULL)return 0 is the zero-length/absent case done right, and the""case is the sharpest catch in the PR:is_path_safe("")returns 1 because the loop body never runs, and the old code validatedsource_dirbefore filling in the default, so the guard ran on the empty string and the value actually used went unchecked. Moving the fill above the check is the correct fix and the accompanying test watches for the side effect rather than the return code, which is the only thing that proves it..ai/security.md, "Fail closed" — deviates, via finding 1. Four of the five instances of this file's signature defect survive the PR.- §5.1 / tier placement — conforms.
services/linuxsits abovecore; the neweos_coredependency points downward andeos_coredoes not depend back, as the comment states. No product-repo dependency, nothing points up a tier. Tier 1 repo, platform-service code, correct home. Finding 5 is aboutPUBLICvsPRIVATE, not direction. - No secrets in logs — checked.
EOS_ERRORat:230logsfile_pathonly;ima->key_fileis not logged anywhere, and no key material is printed. - Weakened checks — none. The PR adds a test and tightens four predicates; nothing was disabled or loosened.
Proposed changes
- Finding 1, in this PR. It is the same defect class the PR is about, in the same file, and the fix is the one already written at
:229applied four more times. Leaving it means the next reader has to rediscover thateos_selinux_install_to_rootfsreturning 0 means nothing. - Findings 4 and 6 — two lines in the test file, both about the test failing or passing for the right reason.
- Findings 2, 3 and 5 — one-liners, no behaviour change for any valid input.
- Finding 7 — merge order: land #125, rebase, drop the duplicated hunk.
- The
fork/execvrewrite the author defers is the right call to defer. Validation is weaker than never involving a shell, and two of these commands are pipelines; that is a genuinely larger change and it should not ride along here.
I opened no fix PR. Finding 1 is High but it is not "small and provable" in the brief's sense — changing four functions from "always 0" to "0 or -1" changes their contract for every caller, and I have not traced the callers. It belongs in this PR, whose author has the context.
Not checked
- Nothing was run against
veritysetup,setfiles,evmctlor a real SELinux/IMA rootfs. None is installed here, which is the same condition as CI. Every dm-verity and SELinux assertion in this suite is therefore untested against a host where the underlying tool exists, and finding 1's consequences (a rootfs with a config naming an uncopied policy, an appraise policy with no key) were reasoned from the code, not observed on a booted system. - The callers of these functions were not traced. Finding 1's fix changes return values, and I did not check whether anything in
systems/orcmd/treats a non-zero return fromeos_selinux_install_to_rootfsoreos_busybox_install_to_rootfsas fatal. That determines whether fixing it surfaces as a clear failure or a new crash path. - The 11 hostile payloads in
HOSTILE[]were not extended.!(history expansion),{/}(brace expansion),#,%, and non-ASCII bytes ≥ 0x80 are all accepted byis_path_safe(); I reasoned that none is shell syntax in a non-interactivesh -cinside double quotes, but I did not test them. - No fuzzing.
.ai/security.mdrequires fuzz coverage for externally reachable parsers andis_path_safe/is_word_safeare the validators for nine shell commands.tests/fuzz/exists in this repo; neither predicate has a harness there, and this PR adds none. Pre-existing gap, not assessed further. - Windows was not built. The
#elsebranches nowreturn -1where two previously returned 0 — a behaviour change on Windows that no test covers and that I did not exercise. Analyze (C/C++),CodeQLandStatic Analysiswere read as pass/fail only; their findings for this head were not reviewed.
Automated architecture review of 8dca78738648 — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
…reporting unrun steps as done Answers both High findings from the review on embeddedos-org#119. Finding 1 -- the new guard was bypassed on the default path. eos_busybox_configure() validated bb->source_dir, then filled it from bb->version when empty. Empty is what eos_busybox_init() leaves, so on the path a caller gets by default is_path_safe("") returned 1 (its loop body never runs), the guard passed, and ".eos/build/src/busybox-<version>" went into `make -C "%s"`. A backtick in version is command substitution inside those double quotes, and eos_busybox_set_version() was a bare strncpy with no check at all. Fixed at both ends, as the finding asks: the setter refuses a version that is not word-safe, and the guard moved below the default-fill so it validates the value actually used rather than the empty string. This is demonstrated, not argued. Running the new test against the unmodified source: [FAIL] the default path executed the injection: /tmp/eos_lsp_ver_uNG5ro/pwned The sentinel file exists, so the injected command ran. Finding 2 -- security steps reported success without having run. eos_ima_sign_file() discarded system()'s return and its command ended `|| echo 'evmctl not available'`, which masked the shell's status too, so on any host without evmctl it signed nothing and returned 0. .ai/security.md: a verification step that cannot run must fail, not pass. Now returns -1 and logs through EOS_ERROR, following eos_dmverity_verify() which already gets this right. The Windows branch returns -1 rather than 0 for the same reason. eos_linux_security gains a link to eos_core for EOS_ERROR -- the same resolution embeddedos-org#108 took for eos_ota, and no cycle: eos_core does not depend on eos_linux_security. The four remaining `|| true` / `|| echo` discards named in the finding (eos_selinux_install_to_rootfs, eos_selinux_label_rootfs, eos_ima_install_to_rootfs, eos_busybox_install_to_rootfs) are NOT fixed here. They are install/copy steps rather than verification steps, and distinguishing "the tool is absent" from "the tool ran and failed" for each needs a caller-visible state that does not exist yet. Left deliberately, said here rather than implied by silence. Also: the [PASS] lines printed unconditionally, so a function could report success after its own CHECKs had failed -- the same shape as the defects under test. Now guarded on the failure count not having moved. Verified: ctest 39/39 PASS test_linux_security_paths 9/9 PASS discrimination, against the source with only these two fixes reverted: set_version accepts the hostile version FAIL the sentinel file is created FAIL <- injection ran ima_sign_file returns 0 with no evmctl FAIL rebased onto master; the tests/CMakeLists.txt conflict with the four suites now on master is resolved additively. Refs embeddedos-org#119
CodeQL alert 404 on this branch's head, at tests/test_linux_security_paths.c:266 -- "A file may be created here with mode 0666, which would make it world-writable." Third instance of this in the same file. Commit bba834a fixed the Makefile fixture for exactly this reason and the comment there explains it, and I introduced a new `fopen(target, "w")` in the evmctl test two commits later without applying the same rule. fopen creates 0666 masked by umask, so under a permissive umask the file a signing tool is pointed at is world-writable. Now open(O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR), matching the fixture above it. O_EXCL as well, since the path is inside a fresh mkdtemp directory and an existing file there would mean something is wrong. No `fopen(..., "w")` remains in the file. Verified: ctest 40/40 PASS test_linux_security_paths 9/9 PASS Refs embeddedos-org#119
…list Review findings 2, 3 and 5 on embeddedos-org#119, none of which changes what any caller sees. is_word_safe() answered an incomplete metacharacter denylist with a second denylist -- space, tab, *, ?, ~ -- and it had the same shape of hole: no [ or ] (a glob character class) and no { or } (brace expansion, which /bin/sh performs when it is bash). Both reach make unquoted through defconfig and cross_compile, and a leading '-' turns the defconfig target into an option. None of that is command execution, but a denylist that lists three of five glob characters is the failure mode this PR exists to fix, so the predicate is now a positive definition: isalnum() plus ._/+=:- and nothing else. It cannot be incomplete, and the new test asserts both halves -- twelve strings that are not one word are refused, and sixteen real defconfig targets, cross-compile prefixes and hash algorithm names are still accepted. The counter-check in test_ordinary_paths_still_reach_the_shell() now passes CROSS_COMPILE=arm-linux-gnueabihf- through to a real make invocation. offset += snprintf(...) accumulated the would-be length rather than what was written, so a truncating segment would put cmd + offset out of bounds and underflow sizeof(cmd) - offset to a huge size_t. Unreachable at the current field widths (512 + 128 + 128 against a 2048 buffer) and nothing ties the two together, so both busybox functions check instead of relying on the arithmetic staying true. eos_core becomes a PRIVATE dependency: eos/log.h is used by linux_security.c and by nothing under include/, so there was no reason to push eos_core's include directories onto every consumer. CMake still records a static library's PRIVATE dependency as a $<LINK_ONLY:> interface requirement, so test_linux_security_paths -- which names only eos_linux_security -- still links; verified by building and running it. Also from the review: the injection payload in the default-source_dir test was formatted into a 192-byte buffer from a 320-byte sentinel path. No truncation occurred in practice, but a truncated payload loses its closing backtick and stops being an injection, and the test would then pass while testing nothing. The buffers are sized from each other now and snprintf's return is asserted, as is the payload's length against bb.version[64], which it also has to survive intact. make is probed with command -v before the test that needs it, the way evmctl already was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review finding 1 on embeddedos-org#119, and a contract change: five functions that always returned 0 can now return -1. This PR already removed one `|| echo` + discarded system() pair from eos_ima_sign_file() and left four more instances of the same defect in the same file. Each one reported a security step as done without having run it: eos_selinux_install_to_rootfs() cp -r ... || true, result discarded -- a failed policy copy left /etc/selinux/config naming a policy the rootfs does not contain. eos_selinux_label_rootfs() setfiles ... || echo, result discarded -- on any host without setfiles this labeled nothing. With no file_contexts set it echoed a "skipping" line and also returned 0. eos_ima_install_to_rootfs() cp key ... || true, result discarded -- an appraise policy installed with no key to appraise against. The non-shell half had the same shape: a policy_file that could not be opened left /etc/ima/policy empty and returned 0. eos_busybox_install_to_rootfs() make install, result discarded -- a rootfs with no busybox in it, and then an /init written with fopen()'s result ignored, so an unwritable rootfs produced an initramfs with no /init. All of them now separate "the tool is absent or failed" from "the step completed", log through EOS_ERROR and return -1, following eos_dmverity_verify() which already did this. The Windows arms return -1 for the same reason rather than 0 for a step that cannot run there. .ai/security.md, Never: "Report a check as passing without running it." Caller survey, since this changes return values: outside its own header and this test file, nothing in the tree calls any of these five functions -- nothing in systems/, cmd/ or examples/. No caller can break on the new -1. That survey is what the review asked for before this landed. Every fix is pinned by a test that fails without it. Verified by mutation -- eleven reversions, each caught: || true restored on the SELinux copy 1 check failed || echo restored on setfiles 1 the file_contexts branch back to 0 1 || true restored on the IMA key copy 1 make install's result discarded again 2 an unreadable policy_file swallowed again 1 is_word_safe back to the old denylist 1 rootfs_dir dropped from the SELinux guard 1 + sentinel created rootfs_dir dropped from the busybox guard 1 + sentinel created rootfs_dir dropped from the IMA guard 1 /init's fopen() silent again 1 The two sentinel lines are the ones worth reading: with the rootfs_dir term deleted from those guards the injected `touch` genuinely ran. The first draft of the SELinux case did not discriminate -- a hostile rootfs_dir fails at the fopen() long before any command is built, so it passed on an unguarded build too. It now uses a real directory whose *name* is the injection, with the /etc the function needs and a loaded policy, so the cp is actually reached. The counter-checks matter as much: eos_ima_install_to_rootfs() with no key and no policy file must still return 0, eos_selinux_install_to_rootfs() with the policy present must still return 0, eos_selinux_label_rootfs() must still return 0 when SELinux is disabled, and a busybox install whose make target succeeds must still write its /init. The SELinux one caught a fixture bug that had made two of these tests pass for the wrong reason -- these functions MKDIR("<rootfs>/etc/<x>") without creating <rootfs>/etc first, so a bare mkdtemp() rootfs fails at the fopen(), not at the step under test. Also covered here, from the first review: one hostile-rootfs_dir case per *_install_to_rootfs entry point -- the guard the PR body named as its second finding shipped with no test -- and the EOS_IMA_OFF ordering, where a malformed rootfs_dir is now refused even with IMA off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8dca787 to
3768a14
Compare
|
All seven findings addressed at Finding 1 (High) — done, in this PR, in its own commit ( You said the caller survey was the thing that had to happen first, so: outside its own header and the test file, nothing in this tree calls any of those five functions — nothing in One correction on the citation, since a wrong quote is worth reporting: Finding 2 (Low) — done, but as your first review's finding 4 rather than this one. I took the allowlist instead of adding Findings 3, 5, 6, 7 — done. Truncation guards on every Finding 4 — done, and the reasoning was right. Buffers sized from each other, Finding 7 — merge order, with one update: #125 is closed; #127 is its replacement and carries the identical block. #118 adds the identical block at the identical anchor too, and #126 adds a stray blank line at the same anchor (lines 90-93 of On "the two side-effect tests are carrying the whole suite" — that was the most actionable line in the review. The suite is 17 sub-tests now and the side-effect technique is extended to the entry points that had no coverage at all. Eleven mutations, each caught:
Two of those cost me a rewrite, which seems worth recording:
Still not covered, and stated in the body: no host here or in CI has |
Residue from 0957ac4, which dropped the borrowed test_crypto_ed25519_loworder registration and left the blank line that had separated it from the block below. It is the only remaining change this PR makes to tests/CMakeLists.txt, it changes nothing, and it sits at the exact anchor where embeddedos-org#118, embeddedos-org#119 and embeddedos-org#127 each add the real registration block -- so it would have conflicted with whichever of those lands first, for no reason at all. tests/CMakeLists.txt is now untouched by this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Residue from 0957ac4, which dropped the borrowed test_crypto_ed25519_loworder registration and left the blank line that had separated it from the block below. It is the only remaining change this PR makes to tests/CMakeLists.txt, it changes nothing, and it sits at the exact anchor where embeddedos-org#118, embeddedos-org#119 and embeddedos-org#127 each add the real registration block -- so it would have conflicted with whichever of those lands first, for no reason at all. tests/CMakeLists.txt is now untouched by this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#119 "fix(linux_security): close the gaps in the shell-path guard, and test it"
head: 3768a14 author: Kartikey1306 ci: fail (2 of 26 checks — both pre-existing, see below)
Verdict: The seven findings from the previous round are genuinely closed, and I verified the fix by execution rather than by reading: clean build under GCC 15.2, ctest 40/40, and all 17 sub-tests of the new suite pass. The allowlist replacement, the fill-before-validate reordering, and the five contract changes are all correct, and the caller survey that justifies them checks out. One defect of the same class the PR exists to eliminate survives in eos_dmverity_create(), in the one function that establishes the dm-verity integrity root — and I have executed proof of it.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Critical | services/linux/src/linux_security.c:355-378 | eos_dmverity_create() reports success for an operation that did not run, by inferring success from a stale buffer. The function's only success test is return dv->root_hash[0] != '\0' ? 0 : -1; (:389), but dv->root_hash is never cleared before the popen. If fgets (:357) yields nothing — veritysetup absent, unreadable image, empty pipeline output — the field keeps whatever a previous call left in it, and the function returns 0. pclose's status (:362) is discarded entirely, and because the command is a pipeline ending in awk, popen's status would report awk's success rather than veritysetup's failure even if it were checked — so root_hash[0] is the only failure signal in the function, which is exactly why leaving it stale defeats the whole guard. Executed against this head (veritysetup absent, struct reused without re-init, no eos_dmverity_init between calls):rc = 0 — reported SUCCESSdv.root_hash = deadbeefdeadbeefdeadbeefdeadbeef — the previous hashdv.data_device = /tmp/definitely-not-an-image.img — a file that does not existand eos_dmverity_generate_table() then emitted0 0 verity 1 /tmp/definitely-not-an-image.img /tmp/h2.img 4096 4096 0 0 sha256 deadbeef… -a dm-verity table asserting a root hash for an image that was never hashed. .ai/reviewer.md is explicit: "A security step that reports success without having run is Critical." The new test suite never reaches this because every dmverity case calls eos_dmverity_init(&dv) first (tests/test_linux_security_paths.c:85,118,124,144,155,157), so the struct is always clean and the suite is green. |
One line, at the top of the #ifndef _WIN32 block before the popen:dv->root_hash[0] = '\0';Then add the regression test that the current suite structurally cannot express — reuse the struct instead of re-initialising it: eos_dmverity_init(&dv);strncpy(dv.root_hash, "deadbeef…", sizeof(dv.root_hash) - 1);CHECK(eos_dmverity_create(&dv, "/nonexistent.img", "/tmp/h.img") != 0);CHECK(dv.root_hash[0] == '\0');While there, capture pclose's status and log through EOS_ERROR on a non-zero, so the failure is diagnosable and not only detectable. |
| 2 | Medium | services/linux/src/linux_security.c:428-429 | eos_dmverity_verify() returns the raw system() status, not the file's 0/-1 contract. int rc = system(cmd); dv->verified = (rc == 0); return rc; — on a failed verification system() returns the wait status, so an exit code of 1 becomes 256, not -1. Every other function in this file, including eos_dmverity_create at :389, returns 0 or -1, and the header (services/linux/include/eos/linux_security.h:91) documents no exception. A caller written the ordinary way — if (eos_dmverity_verify(...) < 0) { /* failed */ } — reads a failed integrity verification as success, because 256 is not less than zero. dv->verified is set correctly, so the damage is limited to callers that trust the return value. The PR's own comment at :292 cites this function as the model — "Follows eos_dmverity_verify(), which already gets this right" — which is true about the fail-closed intent and not about the returned value. The new tests assert != 0 (tests/test_linux_security_paths.c:89,147) and so pass either way. |
return rc == 0 ? 0 : -1; and assert == -1 rather than != 0 in the two dmverity refusal tests, so the contract is pinned. |
| 3 | Low | services/linux/src/linux_security.c:653, 672 | The NULL hardening is applied inconsistently. This round correctly made is_path_safe(NULL) return 0 and added if (!bb) return -1; to eos_busybox_configure (:609) and !bb to eos_busybox_set_version (:561). But eos_busybox_build (:653) and eos_busybox_install_to_rootfs (:672) dereference bb->source_dir in their very first statement with no such guard, so a NULL bb faults before is_path_safe can refuse anything. The same asymmetry applies to se (:101, :155), ima (:219, :282) and dv (:339, :417). Given that the round's stated principle is "the wrong default for a predicate guarding command construction", the struct pointer deserves the same treatment as the string. |
Add the matching if (!bb) return -1; / if (!se) … / if (!ima) … / if (!dv) … to the six functions that take a struct pointer and lack one, or state in the header that these are non-NULL preconditions. Either is fine; the current mix means a reader cannot tell which. |
| 4 | Low | services/linux/src/linux_security.c:136, 176, 266, 295 | 2>/dev/null is retained at every site that now depends on the exit status, so the operator learns that a security step failed but never why. The EOS_ERROR messages are a real improvement and correctly state the consequence, but they have to guess the cause — "setfiles failed or is not installed" (:179) cannot distinguish a missing binary from a malformed file_contexts from a permission denial, because the tool's own stderr was discarded. Previously the suppression was harmless because the result was ignored anyway; now that the result is load-bearing, the diagnostic is the thing that makes a -1 actionable, which is what §9.2 asks for. |
Drop 2>/dev/null and let the tool's stderr reach the build log, or capture it (popen with 2>&1) and include the first line in the EOS_ERROR. The cp at :136 is the clearest case: "no such file", "permission denied" and "policy dir is empty" are three different operator actions and currently read identically. |
| 5 | Low | (CI) | 2 of 26 checks fail, and neither is caused by this PR. Cross-compile ARM64 kernel fails at the Build eBoot (ARM64 qemu_arm64 board) step with eBoot/include/eos_image.h:135:23: error: 'eos_image_header_t' has no member named 'reserved' (also :142), breaking eboot_core.dir/core/bootctl.c.o and image_verify.c.o; Full-stack integration summary then fails as its dependent. This is a breakage in the eBoot repository's header, not in this diff, which touches only services/linux/ and tests/. Confirmed pre-existing: the same workflow fails on origin/master at feee2726, and eos#121, #126, #129 and #134 each report the same 2 failures. QEMU ARM64 simulation (13 TCs) is skipped as a downstream consequence. All 24 other checks pass, including Build & Test (Linux x86_64), Host Tests (x86_64), Static Analysis (cppcheck + clang-tidy), Analyze (C/C++) and CodeQL. |
Nothing for this author. The eos_image_header_t.reserved static assertion needs fixing in eBoot; until then no eos PR can show a fully green run and the failure is not a signal about any of them. |
Architecture conformance
Conforms. services/linux/ is a platform service — master design §21 Tier 2 — Core Platform (eSec's device/data/software concerns per §14) — and §5.1 permits platform services to depend on EoS. The change moves in the permitted direction and one notch further: services/linux/CMakeLists.txt narrows eos_core from PUBLIC to PRIVATE on eos_linux_security, so the dependency stops being re-exported to anything that links the service. That is .ai/architect.md's "dependencies point downward and inward" applied correctly, and it is the right call given nothing under include/ needed eos/log.h. The new #include "eos/log.h" is a downward include into EoS core logging (§5 Core Services → Logging/trace), not upward into any product.
No tier boundary is crossed, no repository split is implied (§21.1 not in play), and nothing touches eBoot's TCB (§5.1, §8).
No check was weakened — the opposite. I read every hunk for this specifically. Five functions stop returning 0 for work they did not do; || true is removed from three commands and || echo … from two; the sha256sum fallback that substituted a flat digest for a Merkle root is deleted with a correct explanation; dv->verified is no longer set by create(), which had made every format look like a completed verification. Test fixtures move from fopen("w") to open(O_CREAT|O_EXCL, 0600), closing the three CodeQL sites. Nothing is skipped, xfailed, #if 0'd or deleted.
The contract change is properly justified. I re-ran the caller survey rather than taking it: git grep -nE "eos_(selinux|ima|busybox)_(install_to_rootfs|label_rootfs|sign_file)" across systems/, cmd/, examples/, services/, core/ and kernel/ at this head returns nothing outside linux_security.c and its header and test. No in-tree caller can break on the new -1. This also means findings 1 and 2 break nobody today — they are traps for the first real caller, which is why they are worth fixing before one exists rather than after.
The author's correction about the citation is right, and I am recording it as correct. .ai/security.md has no "Fail closed" heading, and the sentences "A verification step that cannot run must fail, not pass" and "A security step whose result is discarded is a finding even when the happy path is correct" are not in that file or anywhere in the repo. The rule that is there — .ai/security.md, Never: "Report a check as passing without running it" — carries the finding on its own. Misquoting a specification in a review is worth flagging back, and flagging it back was the right thing to do.
The design gap this PR keeps running into is already recorded as a proposal for eos#119 in .ai/autoreview/proposals/2026-09.md ("§14.1 governs which crypto a security component uses, but not how it invokes anything"). Finding 1 is a failure mode that proposal does not cover — success inferred from residual output state rather than from a discarded exit status — so I have appended a short addendum there rather than duplicating the proposal.
Proposed changes
In order, smallest first:
- Finding 1, one line in
eos_dmverity_create()before thepopen:
/* A previous call's hash must not be able to stand in for this one:
* root_hash[0] is the only success signal this function has. */
dv->root_hash[0] = '\0';- Finding 2, normalise the return:
int rc = system(cmd);
dv->verified = (rc == 0);
return rc == 0 ? 0 : -1;- Add the two regression tests (struct-reuse for
create,== -1forverify). Both are pure additions totests/test_linux_security_paths.cand need no new fixture. - Finding 3, the six missing struct-pointer NULL guards.
- Finding 4, drop
2>/dev/nullat the four sites whose exit status is now load-bearing.
Findings 1–3 are small enough to land in this PR. Finding 4 changes build-log output and could reasonably follow separately.
On merge order: the tests/CMakeLists.txt observation is correct and still current — #118 and #127 add the identical test_crypto_ed25519_loworder registration block at the identical anchor, and #126 adds a stray blank line at the same place. Whichever lands second conflicts. I have not verified that #125 is closed and #127 replaces it beyond the author's statement.
Not checked
- Windows. The
#elsearms now return-1where several returned0, for steps that genuinely cannot run there. I did not build for Windows and no test covers it. This remains the largest untested behaviour change in the PR, and the author says so in the body. veritysetup,setfiles,evmctlare all absent from my host (whichreturns nothing for each) — the same condition the author reports. So every assertion that depends on one of those tools succeeding does not discriminate in my run either; only the sentinel-file/side-effect assertions do. That is the right technique for this and it is why finding 1 needed a hand-written probe rather than a test to surface.- Path traversal through
rootfs_dirintoeos_kaudit_install_to_rootfs()and the/initwriter — acknowledged as out of scope by the author, and I agreeis_path_safeis the wrong control for it. I did not assess it. It is a real open question and should not be lost. - No fuzz harness for either predicate in
tests/fuzz/. Pre-existing; not closed here; I did not fuzz them myself. - The argv-vector rewrite the author defers is the correct larger fix and I did not attempt to evaluate a design for it. Two of the nine sites are pipelines and would need restructuring.
- The other seven
system()/popen()security paths ineos/services/were not swept. This review coveredlinux_security.conly. - cppcheck / clang-tidy / CodeQL results I read from
checks.txt(all pass); I did not run them locally. - What I did run, and on what:
git archiveof the head into a temp directory,cmake -S . -B buildthencmake --build build -j4→ exit 0;ctest --no-tests=error --output-on-failure→ 40/40 passed;./tests/test_linux_security_paths→ 17/17 sub-tests PASS; the finding-1 probe linked against the builtlibeos_linux_security.a. GCC 15.2.0 (Ubuntu), x86_64 Linux, single configuration. The-Wformat-truncation=warning the author could not reproduce locally is confirmed fixed — my build emits 18 warnings, none of them inlinux_security.c; they are incore/src/scheduler.c:189,services/pkg/eos_pkg.c(5),systems/src/rootfs.c(several) andtests/test_crypto_aes.c(2), all pre-existing and outside this diff. I did not runpytest tests/ -q(the author reports 15 passed) and I did not build any cross target.
Automated architecture review of 3768a14e36ac — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
linux_security.c builds shell commands with snprintf and hands them to
system() or popen() — nine call sites. is_path_safe() is the only thing
between a caller's path and the shell. It had three holes.
1. The denylist was incomplete. It rejected ;|&><$()"' but not the
backtick, which is command substitution in every POSIX shell, and not
a newline, which ends one command and begins the next. Both were
reported safe:
is_path_safe("/tmp/`touch /tmp/pwned`") -> 1
is_path_safe("/tmp/x\ntouch /tmp/pwned") -> 1
2. It was applied at two of the nine call sites. eos_dmverity_create(),
eos_dmverity_verify(), eos_ima_install_to_rootfs(),
eos_ima_sign_file(), and the three busybox entry points interpolated
their arguments with no validation at all. eos_selinux_install_to_
rootfs() checked policy_dir and policy_name but not rootfs_dir, which
reaches the same command through `path`.
3. is_path_safe(NULL) returned 1 — "safe" is the wrong default for a
predicate guarding command construction.
Fixed the validator, added backslash and control characters while there,
and applied it at every site. Two busybox commands interpolate WITHOUT
surrounding quotes (`make -C "%s" %s` and ` CROSS_COMPILE=%s`), where a
single space already injects an extra make argument; those use a
stricter is_word_safe().
Adds tests/test_linux_security_paths.c — eleven hostile payloads against
the public API, plus a counter-check that a well-formed path still
reaches the shell, proved by a generated Makefile whose target creates a
sentinel file. A guard that refused everything would pass the first half
alone.
Verified. Against the unfixed file the new test fails and `id` runs:
uid=501(kartikey) gid=20(staff) groups=...
Note the two dmverity assertions do not discriminate on a host without
veritysetup — system() returns non-zero either way — so on this machine
the failures came from the busybox and IMA paths plus that observable
execution. On a host with veritysetup they discriminate too.
ctest -> 35/35 passed (34 before, +test_linux_security_paths)
Not addressed here, and worth its own change: these commands would be
better run through fork/execv with an argv array than validated as
strings. Two of them need a shell for pipelines, so that is a larger
restructure than this fix.
CodeQL, high severity, on this PR's own new test: fopen(mk, "w") creates with mode 0666 masked by umask, so on a permissive umask the fixture Makefile would be world-writable -- and its contents are handed to make and executed. mkdtemp() already gives the containing directory 0700, so this was not reachable in practice, but a test that demonstrates command-injection defences should not itself create an executable file with default permissions. open(O_WRONLY|O_CREAT|O_EXCL, S_IRUSR|S_IWUSR) + fdopen(), with O_EXCL so the fixture cannot be pre-created by anyone else. test_linux_security_paths -> 6/6 passed ctest -> 35/35 passed
… it returns non-zero
The two dm-verity refusal assertions in this file check for a non-zero
return. That does not discriminate on a host without veritysetup:
system() returns non-zero whether the command was refused or merely
failed to find the binary. CI installs no veritysetup, so those two
assertions pass against the unfixed file on every CI run. I flagged this
in the PR; better to fix it than to document it.
test_injection_does_not_execute() watches for the side effect instead.
The hostile path carries a command substitution that would create a
sentinel file. If the guard refuses the path, the command is never built
and the sentinel cannot appear — regardless of what is installed. Run
against the three entry points that take a caller-supplied path into a
shell string: eos_dmverity_verify(), eos_dmverity_create() and
eos_ima_sign_file().
Against the unfixed file it fails on all three, and says why:
[FAIL] the injected command executed: /tmp/eos_lsp_inj_QIrR09/pwned exists
which is the defect stated as an observation rather than an argument.
ctest -> 35/35 passed
…reporting unrun steps as done Answers both High findings from the review on embeddedos-org#119. Finding 1 -- the new guard was bypassed on the default path. eos_busybox_configure() validated bb->source_dir, then filled it from bb->version when empty. Empty is what eos_busybox_init() leaves, so on the path a caller gets by default is_path_safe("") returned 1 (its loop body never runs), the guard passed, and ".eos/build/src/busybox-<version>" went into `make -C "%s"`. A backtick in version is command substitution inside those double quotes, and eos_busybox_set_version() was a bare strncpy with no check at all. Fixed at both ends, as the finding asks: the setter refuses a version that is not word-safe, and the guard moved below the default-fill so it validates the value actually used rather than the empty string. This is demonstrated, not argued. Running the new test against the unmodified source: [FAIL] the default path executed the injection: /tmp/eos_lsp_ver_uNG5ro/pwned The sentinel file exists, so the injected command ran. Finding 2 -- security steps reported success without having run. eos_ima_sign_file() discarded system()'s return and its command ended `|| echo 'evmctl not available'`, which masked the shell's status too, so on any host without evmctl it signed nothing and returned 0. .ai/security.md: a verification step that cannot run must fail, not pass. Now returns -1 and logs through EOS_ERROR, following eos_dmverity_verify() which already gets this right. The Windows branch returns -1 rather than 0 for the same reason. eos_linux_security gains a link to eos_core for EOS_ERROR -- the same resolution embeddedos-org#108 took for eos_ota, and no cycle: eos_core does not depend on eos_linux_security. The four remaining `|| true` / `|| echo` discards named in the finding (eos_selinux_install_to_rootfs, eos_selinux_label_rootfs, eos_ima_install_to_rootfs, eos_busybox_install_to_rootfs) are NOT fixed here. They are install/copy steps rather than verification steps, and distinguishing "the tool is absent" from "the tool ran and failed" for each needs a caller-visible state that does not exist yet. Left deliberately, said here rather than implied by silence. Also: the [PASS] lines printed unconditionally, so a function could report success after its own CHECKs had failed -- the same shape as the defects under test. Now guarded on the failure count not having moved. Verified: ctest 39/39 PASS test_linux_security_paths 9/9 PASS discrimination, against the source with only these two fixes reverted: set_version accepts the hostile version FAIL the sentinel file is created FAIL <- injection ran ima_sign_file returns 0 with no evmctl FAIL rebased onto master; the tests/CMakeLists.txt conflict with the four suites now on master is resolved additively. Refs embeddedos-org#119
CodeQL alert 404 on this branch's head, at tests/test_linux_security_paths.c:266 -- "A file may be created here with mode 0666, which would make it world-writable." Third instance of this in the same file. Commit bba834a fixed the Makefile fixture for exactly this reason and the comment there explains it, and I introduced a new `fopen(target, "w")` in the evmctl test two commits later without applying the same rule. fopen creates 0666 masked by umask, so under a permissive umask the file a signing tool is pointed at is world-writable. Now open(O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR), matching the fixture above it. O_EXCL as well, since the path is inside a fresh mkdtemp directory and an existing file there would mean something is wrong. No `fopen(..., "w")` remains in the file. Verified: ctest 40/40 PASS test_linux_security_paths 9/9 PASS Refs embeddedos-org#119
…list Review findings 2, 3 and 5 on embeddedos-org#119, none of which changes what any caller sees. is_word_safe() answered an incomplete metacharacter denylist with a second denylist -- space, tab, *, ?, ~ -- and it had the same shape of hole: no [ or ] (a glob character class) and no { or } (brace expansion, which /bin/sh performs when it is bash). Both reach make unquoted through defconfig and cross_compile, and a leading '-' turns the defconfig target into an option. None of that is command execution, but a denylist that lists three of five glob characters is the failure mode this PR exists to fix, so the predicate is now a positive definition: isalnum() plus ._/+=:- and nothing else. It cannot be incomplete, and the new test asserts both halves -- twelve strings that are not one word are refused, and sixteen real defconfig targets, cross-compile prefixes and hash algorithm names are still accepted. The counter-check in test_ordinary_paths_still_reach_the_shell() now passes CROSS_COMPILE=arm-linux-gnueabihf- through to a real make invocation. offset += snprintf(...) accumulated the would-be length rather than what was written, so a truncating segment would put cmd + offset out of bounds and underflow sizeof(cmd) - offset to a huge size_t. Unreachable at the current field widths (512 + 128 + 128 against a 2048 buffer) and nothing ties the two together, so both busybox functions check instead of relying on the arithmetic staying true. eos_core becomes a PRIVATE dependency: eos/log.h is used by linux_security.c and by nothing under include/, so there was no reason to push eos_core's include directories onto every consumer. CMake still records a static library's PRIVATE dependency as a $<LINK_ONLY:> interface requirement, so test_linux_security_paths -- which names only eos_linux_security -- still links; verified by building and running it. Also from the review: the injection payload in the default-source_dir test was formatted into a 192-byte buffer from a 320-byte sentinel path. No truncation occurred in practice, but a truncated payload loses its closing backtick and stops being an injection, and the test would then pass while testing nothing. The buffers are sized from each other now and snprintf's return is asserted, as is the payload's length against bb.version[64], which it also has to survive intact. make is probed with command -v before the test that needs it, the way evmctl already was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review finding 1 on embeddedos-org#119, and a contract change: five functions that always returned 0 can now return -1. This PR already removed one `|| echo` + discarded system() pair from eos_ima_sign_file() and left four more instances of the same defect in the same file. Each one reported a security step as done without having run it: eos_selinux_install_to_rootfs() cp -r ... || true, result discarded -- a failed policy copy left /etc/selinux/config naming a policy the rootfs does not contain. eos_selinux_label_rootfs() setfiles ... || echo, result discarded -- on any host without setfiles this labeled nothing. With no file_contexts set it echoed a "skipping" line and also returned 0. eos_ima_install_to_rootfs() cp key ... || true, result discarded -- an appraise policy installed with no key to appraise against. The non-shell half had the same shape: a policy_file that could not be opened left /etc/ima/policy empty and returned 0. eos_busybox_install_to_rootfs() make install, result discarded -- a rootfs with no busybox in it, and then an /init written with fopen()'s result ignored, so an unwritable rootfs produced an initramfs with no /init. All of them now separate "the tool is absent or failed" from "the step completed", log through EOS_ERROR and return -1, following eos_dmverity_verify() which already did this. The Windows arms return -1 for the same reason rather than 0 for a step that cannot run there. .ai/security.md, Never: "Report a check as passing without running it." Caller survey, since this changes return values: outside its own header and this test file, nothing in the tree calls any of these five functions -- nothing in systems/, cmd/ or examples/. No caller can break on the new -1. That survey is what the review asked for before this landed. Every fix is pinned by a test that fails without it. Verified by mutation -- eleven reversions, each caught: || true restored on the SELinux copy 1 check failed || echo restored on setfiles 1 the file_contexts branch back to 0 1 || true restored on the IMA key copy 1 make install's result discarded again 2 an unreadable policy_file swallowed again 1 is_word_safe back to the old denylist 1 rootfs_dir dropped from the SELinux guard 1 + sentinel created rootfs_dir dropped from the busybox guard 1 + sentinel created rootfs_dir dropped from the IMA guard 1 /init's fopen() silent again 1 The two sentinel lines are the ones worth reading: with the rootfs_dir term deleted from those guards the injected `touch` genuinely ran. The first draft of the SELinux case did not discriminate -- a hostile rootfs_dir fails at the fopen() long before any command is built, so it passed on an unguarded build too. It now uses a real directory whose *name* is the injection, with the /etc the function needs and a loaded policy, so the cp is actually reached. The counter-checks matter as much: eos_ima_install_to_rootfs() with no key and no policy file must still return 0, eos_selinux_install_to_rootfs() with the policy present must still return 0, eos_selinux_label_rootfs() must still return 0 when SELinux is disabled, and a busybox install whose make target succeeds must still write its /init. The SELinux one caught a fixture bug that had made two of these tests pass for the wrong reason -- these functions MKDIR("<rootfs>/etc/<x>") without creating <rootfs>/etc first, so a bare mkdtemp() rootfs fails at the fopen(), not at the step under test. Also covered here, from the first review: one hostile-rootfs_dir case per *_install_to_rootfs entry point -- the guard the PR body named as its second finding shipped with no test -- and the EOS_IMA_OFF ordering, where a malformed rootfs_dir is now refused even with IMA off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…act change Every merged fix in this repo carries an [Unreleased] entry and this one did not. The third bullet is the one that matters to a reader: five functions that always returned 0 can now return -1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3768a14 to
1a5039a
Compare
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#119 "fix(linux_security): close the gaps in the shell-path guard, and test it"
head: 1a5039a author: Kartikey1306 ci: fail (2 of 26 — see finding 5, cause is external)
Verdict: Conforms, and the security direction is right. I re-derived the two claims the change turns on and both hold: is_path_safe now rejects backtick, newline, control characters and backslash and refuses NULL; is_word_safe is a genuine allowlist (isalnum() plus ._/+=:-, leading - refused); the fill-then-validate reordering in eos_busybox_configure closes the default path; and the caller survey is accurate — git grep across the head for the five contract-changed functions returns only the header, the implementation, the new test and docs/coverage/*.html, so nothing can break on the new -1. Nothing was weakened: every removed line in linux_security.c is a || true, an || echo, a discarded system() result, or the old if (!path) return 1. What remains is one step that still reports success without achieving its purpose, and a test summary that reports green over sub-tests that did not run.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Medium | services/linux/src/linux_security.c:699-712 |
/init is written with fopen(path, "w") and never made executable, so it lands 0666 & umask — 0644 in practice. The kernel execs /init from an initramfs; a non-executable /init panics at Failed to execute /init. So the success path of eos_busybox_install_to_rootfs() still produces an initramfs that cannot boot, and returns 0 — while the new failure text two lines above says "cannot write %s; the initramfs has no /init" and the install branch says "its /init would not run". Pre-existing, but it is the same defect class the PR exists to close, on a line the PR modified: a step reporting success for work that did not achieve its purpose. |
if (fchmod(fileno(fp), 0755) != 0) { EOS_ERROR(...); fclose(fp); return -1; } before writing, and extend the positive control to assert access(init, X_OK) == 0. Without the assertion the next refactor loses it again. |
| 2 | Medium | tests/test_linux_security_paths.c:36-45, main |
The suite prints All linux_security path checks passed and exits 0 even when sub-tests did not run. have_tool("make")/("evmctl") and every if (!mkdtemp(dir)) { ... return; } return from the test function before PASS_IF_CLEAN, so the skipped case prints neither [PASS] nor [FAIL], failures is untouched, and the final line still claims everything passed. Nothing distinguishes "17 of 17 ran" from "11 of 17 ran" — including the 17/17 sub-tests PASS figure in the PR body, which is a count of what the author's host happened to be able to execute. .ai/security.md: "a security suite that silently collected nothing is a failed check reported as green." The [SKIP] markers themselves are right and were asked for; the missing piece is that nothing accounts for them. |
Add static int skipped;, increment at each [SKIP], and print %d skipped in the summary; make the final line conditional on it (All … passed (N skipped)). Then have CI install make/evmctl/setfiles on one leg and fail that leg when skipped != 0, so the coverage claim is enforced somewhere rather than depending on the host. |
| 3 | Low | services/linux/src/linux_security.c:59-67 vs :91 |
is_word_safe explicitly refuses a leading - — "it turns make -C dir <defconfig> into an option rather than a target" — and is_path_safe does not. Every is_path_safe value is interpolated inside double quotes, so the shell is contained; quoting does not stop the invoked program from parsing the value as an option. setfiles -r "-Z" "$fc" "-Z", cp -r "--parents/"* …, make -C "--version" all reach the tool as options, from the same caller-controlled fields this change is hardening. Same defect class as the one the sibling predicate already guards, one layer up. |
if (path[0] == '-') return 0; in is_path_safe; add "-r" and "--reference=/etc/shadow" to the HOSTILE table. A legitimate relative path can always be written ./-name. |
| 4 | Low | services/linux/src/linux_security.c:136, 175, 266, 294, 679 (and :110, :114, :125) |
The truncation guards were added to eos_busybox_configure and eos_busybox_build only. The PR body says "truncation guards on every snprintf in both busybox functions", but eos_busybox_install_to_rootfs (:679) is a busybox function that builds a command from an unbounded const char *rootfs_dir with no check, and the four SELinux/IMA command builders are unchecked too. In the command cases truncation ends the string inside an open ", so sh reports a syntax error and the new non-zero return catches it — safe by an accident of quoting, not by a check. :110/:114/:125 are the ones not covered by that accident: they truncate path[1024] and hand the result to MKDIR/fopen, so a long rootfs_dir writes /etc/selinux/config somewhere other than where the caller asked (in practice fopen then fails, so the outcome is a confusing -1 rather than a wrong write). |
Check the return of those snprintfs and return -1 on truncation, exactly as the two busybox functions now do. Either that, or narrow the body's claim to the two functions it is actually true of. |
| 5 | Low | CI | Cross-compile ARM64 kernel and the Full-stack integration summary that depends on it fail on this head. The cause is not in this PR. The failing step is Build eBoot (ARM64 qemu_arm64 board), and the errors are in the eBoot checkout: eBoot/include/eos_image.h:135 and :142 static-assert on offsetof(eos_image_header_t, reserved) / sizeof(…->reserved) and the struct has no reserved member (error: 'eos_image_header_t' has no member named 'reserved', run 33844971108). The workflow checks eBoot out at a floating master, so eBoot's trunk drifting breaks this repo's PRs — the same two jobs pass on eos#127, #129 and #135, which share #119's merge base and ran before the drift. eos#135 ("pin the eBoot and ebuild checkouts instead of floating on master") is the fix. |
Nothing to do here beyond rebasing once #135 lands. Do not chase it in this branch, and do not read the red X as a verdict on this diff. |
Architecture conformance
Conforms. §21 tier placement: services/linux is a Tier-1 eos core service and that is where a rootfs-provisioning helper belongs; nothing moved. §5.1 dependency direction: the one new link is target_link_libraries(eos_linux_security PRIVATE eos_core), which points inward within Tier 1, and PRIVATE is the correct choice — I checked, nothing under services/linux/include/ includes eos/log.h, so no consumer needs eos_core's include directories, and CMake still records the link requirement as $<LINK_ONLY:> for a static library. No upward include, import or link is introduced; no cross-repo path dependency (.ai/platform.md). §14.1 "use reviewed libraries, do not invent primitives" is untouched — this change is input validation, not cryptography.
The deeper architectural point — that a Tier-1 core service builds shell command strings from configuration data at nine call sites at all, and that string validation is a weaker guarantee than never involving a shell — is the author's own "left for a separate change" and is already recorded as a design proposal triggered by this PR (.ai/autoreview/proposals/2026-09.md, "§14.1 governs which crypto a security component uses, but not how it invokes anything", 2026-09-03), including the argv-vector rule, the allowlist-not-denylist rule, and the exit-status rule. No new proposal is appended for it, and it is not re-raised as a finding against this diff.
Proposed changes
fchmod(fileno(fp), 0755)on/init, checked, plus theX_OKassertion in the positive control. This is the one finding that changes what the shipped artifact does.static int skipped;+%d skippedin the summary; then a CI leg with the tools installed that requiresskipped == 0.if (path[0] == '-') return 0;inis_path_safe, with two moreHOSTILErows.- Truncation checks on the five remaining command builders and the three
path[1024]writes, or a narrower claim in the body.
None of these interact, and none of them touches the contract change, so they can land in any order. Findings 3 and 4 are the kind of thing that is cheaper now than after the argv-vector rewrite lands.
Not checked
- Nothing was built or executed. No compiler was invoked,
ctestwas not run, and the40/40,17/17andpytest 15 passedfigures are neither confirmed nor disputed. Everything above is read from the file contents at1a5039a1(fetched read-only into a private ref; the working tree was not touched) — labelled Observed, not Verified. - The eleven-mutation table is not reproduced. Mutation testing is the right method here and the two self-reported non-discriminating tests are exactly the trap it exists to catch, but I have no independent evidence for any row of it.
- Windows: the
#elsearms now return-1where several returned0, and no test covers any of them. Already stated in the body under Not covered; recorded here, not re-raised. - Path traversal through
rootfs_dirintoeos_kaudit_install_to_rootfs()and the/initwriter —..is not a shell metacharacter andis_path_safepermits it, so arootfs_dirof/tmp/x/../../etcwrites outside the rootfs with no shell involved. The author names this and explicitly excludes it. I did not assess it and it is still open, not fixed. - No fuzz harness for either predicate (
tests/fuzz/); pre-existing, not closed here. - The other
system()/popen()sites inservices/andeBoot/tools/were not swept, so I cannot say this file is the only instance of the pattern.
Automated architecture review of 1a5039a128aa — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
…cs is passed twice (#126) * fix(build): restore the low-order Ed25519 suite that #93 removed `CI — eos` and `Python Unit Tests` are red on master (769191a), and both fail for one reason: these suites exist in tests/ but no add_executable() in tests/CMakeLists.txt builds them, so they never run: ['test_crypto_ed25519_loworder.c'] 3aa8644 (#93, "remove duplicate test targets from tests/CMakeLists.txt") removed all four lines that build and register test_crypto_ed25519_loworder. It was not a duplicate — there was exactly one registration before that commit, and it took it. What stopped running is the suite that guards #101: Ed25519 rejecting low-order public keys. A low-order key makes every term of the verification equation collapse to the identity regardless of the message, so a signature of all zeros verifies against any content at all. The fix is still in services/crypto/src/ed25519_verify.c and still correct; nothing has been checking it since #93 merged, and nothing would have noticed if a later change had removed it too. Restored verbatim from 3aa8644^, in its original position before test_crypto_sha512. No other file changes. The guard that caught this is test_cmake_test_registration.py, added recently. It did exactly its job — this is the first thing it found. Verified: test_cmake_test_registration.py -> 5 passed (1 failed on master) pytest tests/ -> 15 passed (14 passed 1 failed on master) test_crypto_ed25519_loworder -> 5/5 tests passed ctest -> 39/39 passed (master builds 38) * fix(toolchain): stop passing nosys.specs twice on a Cortex-M4 link arm-cortex-m4.cmake sets -specs=nosys.specs in CMAKE_C_FLAGS_INIT and again in CMAKE_EXE_LINKER_FLAGS_INIT. CMake links through the compiler driver, so both reach the link line and the specs file is read twice. A specs file may only be read once -- the second read tries to re-rename a spec it has already renamed: arm-none-eabi-gcc: fatal error: nosys.specs: attempt to rename spec 'link_gcc_c_sequence' to already defined spec 'nosys_link_gcc_c_sequence' It has been latent because CMAKE_TRY_COMPILE_TARGET_TYPE is STATIC_LIBRARY and nothing in the cross build links an executable, so the duplicate never reached a link line. It surfaced the moment one did. The flag belongs on the link line only; arm-none-eabi-stm32f4.cmake already keeps it there and nowhere else. This matches it. The compile line does not need it: nosys.specs supplies the syscall stubs the linker wants. Verified with arm-none-eabi-gcc rather than by reading. The mechanism, using a specs file this toolchain does ship: once: arm-none-eabi-gcc ... m.o -specs=dup.specs -o a.elf -> links twice: arm-none-eabi-gcc -specs=dup.specs m.o -specs=dup.specs -> fatal error: attempt to rename spec 'link_gcc_c_sequence' to already defined spec 'dup_link_gcc_c_sequence' the same error CI reports, from the same cause. And the generated build: before, every compile command carried -specs=nosys.specs; after, none does and the exe link flags still carry it once. The full cross build could not be run here -- the Homebrew arm-none-eabi-gcc ships no newlib, so nosys.specs and stdint.h are both absent. Configure succeeds with this change and fails without it on that toolchain, which is the part it governs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(toolchain): guard the duplicate-specs class, and align the third ARM file Answers the review on #126. Finding 1 (Medium) -- the head carried an undisclosed second change, the test_crypto_ed25519_loworder registration, which contradicted this PR's own 2026-09-02 comment ("this PR changes one file ... and does not touch tests/"). It was also the fourth open copy of that registration, at a different position from #127's. Dropped: the branch is rebased to the toolchain change alone, and #127 is the one to take for the registration -- it restores the four lines to their original position, keeps the crypto suites grouped, and is the position #122 is already written against. Finding 2 (Medium) -- nothing would catch this coming back. Every ARM toolchain here sets CMAKE_TRY_COMPILE_TARGET_TYPE to STATIC_LIBRARY and no cross build links an executable, so `Cross-compile ARM Cortex-M4` passed identically with the duplicate present and absent; it passed on the unfixed commit. The defect surfaced by accident when #118 briefly put cmd/eos into the cross build, and #118 has since gated that to host builds -- removing the accident too. Adds tests/unit/test_toolchain_specs.py, modelled on test_cmake_test_registration.py: parse every toolchains/*.cmake, extract each -specs=<file>, and fail if one appears in more than one variable that reaches a link line (CMake links through the compiler driver, so C/CXX flags do). A second test asserts specs are declared on the linker flags rather than the compiler flags, which is the arrangement that turns into a duplicate the moment someone adds the linker entry that looks missing. No ARM toolchain needed; runs in the existing pytest job. Finding 3 (Low) -- arm-none-eabi-r5.cmake put --specs=nosys.specs in CMAKE_C_FLAGS_INIT with nothing in CMAKE_EXE_LINKER_FLAGS_INIT. Not broken today, and one added linker -specs= away from the same failure. Moved, so all three ARM bare-metal toolchains state it the same way and the new guard is green across all of them. Verified: pytest tests/unit/test_toolchain_specs.py 2 passed pytest tests/ all pass discrimination: reintroducing the duplicate in arm-cortex-m4.cmake gives arm-cortex-m4.cmake: -specs=nosys.specs is set in CMAKE_C_FLAGS_INIT and CMAKE_EXE_LINKER_FLAGS_INIT, so it reaches the link line more than once on both tests, so the guard catches the original defect rather than restating the fix. No ARM toolchain on this machine, so the cross link itself is NOT verified here; that remains CI's to show. Refs #126 * test(toolchains): see the spec wherever it is written, and drop the borrowed hunk Addresses findings 1-4 of the review on 126bd0c. Finding 4 first, because it is the one that was making the branch look better than it is. The test_crypto_ed25519_loworder registration was carried here from #127. It made Run Python tests green on a branch that has nothing to do with it, contradicted this PR's own 2026-09-02 comment, and sat at a different anchor to #127's, so it would have conflicted. Dropped. That check is red again until #127 lands, which is the honest state -- folding a master repair into a toolchain PR to get a green tick is how a red master becomes everyone's problem to route around. Finding 1: the parser recognised one CMake idiom, set(VAR "literal"). It could not see string(APPEND ...), an unquoted set(), or -- the one that matters here -- a spec arriving through ${CPU_FLAGS}, which is how both Cortex-M4 toolchains build their flag strings. Putting -specs= there would have reached both CMAKE_C_FLAGS_INIT and CMAKE_EXE_LINKER_FLAGS_INIT while every check passed. The file is now walked in order with a variable table, ${...} resolved against what is in force at that point, and string(APPEND ...) applied. Two tests pin those two bypasses. Finding 3: CMAKE_ASM_FLAGS_INIT added to LINK_REACHING. CMake picks a target's linker language from its sources, so an executable built only from .S files links with the ASM flags. Both Cortex-M4 files set it from ${CPU_FLAGS}. The current tree still passes; no toolchain names a spec there today. Finding 2: the .yaml descriptors are scanned too. They are the second authority on these flags -- toolchains/src/toolchain.c parses them into EosToolchain.cflags/ldflags, a separate consumer from CMake -- and all five already keep specs in ldflags, which is a good argument for this change and a better one for holding both descriptions to one rule. Found while verifying rather than assuming: my first regex required the closing paren on the same line, so arm-none-eabi-stm32f4.cmake's three-line set(CMAKE_EXE_LINKER_FLAGS_INIT ...) was invisible and every check passed vacuously for that file. That is this test's own failure mode one level up. Fixed, and pinned by test_a_multi_line_set_is_still_seen and test_every_arm_toolchain_is_actually_parsed -- the latter asserts the parser finds specs in all three ARM files, so a parser that silently sees nothing can no longer pass. Verified: the parser reports arm-cortex-m4 [nosys, nano], arm-none-eabi-r5 [nosys], arm-none-eabi-stm32f4 [nano, nosys], all on CMAKE_EXE_LINKER_FLAGS_INIT. Mutations caught: a spec hidden in CPU_FLAGS, and the original duplicate re-introduced on CMAKE_C_FLAGS_INIT. 7 toolchain tests pass, ctest 38/38. * test(toolchains): drop the blank line the removed hunk left behind Residue from 0957ac4, which dropped the borrowed test_crypto_ed25519_loworder registration and left the blank line that had separated it from the block below. It is the only remaining change this PR makes to tests/CMakeLists.txt, it changes nothing, and it sits at the exact anchor where #118, #119 and #127 each add the real registration block -- so it would have conflicted with whichever of those lands first, for no reason at all. tests/CMakeLists.txt is now untouched by this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): take the loworder registration from #127, not a copy of it Restacked onto #127. The previous head dropped the registration hunk entirely, per finding 4, which left Run Python tests red by design until #127 landed. Stacking gets the same result without a duplicate: the hunk comes from #127, this branch adds none of its own, and the diff against #127 is toolchain-only. Verified locally before pushing: pytest 22 passed (including test_every_c_suite_is_built_or_listed, the check that was failing), cmake configure and build clean, ctest 39/39 with test_crypto_ed25519_loworder among them. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine PRs were merged into master within minutes on 09-08, each on the base it was written against. Master has not compiled since, and the Python guards that would have named the rest never ran because the C build failed first. Compile (every C job, CodeQL, and the ARM64 kernel in the simulation): - services/linux/src/linux_security.c: embeddedos-org#119 and embeddedos-org#132 each added an #else to the same #ifndef _WIN32 in eos_busybox_install_to_rootfs(), so master has "#else after #else". The embeddedos-org#132 arm (`(void)bb;`) is the one removed -- the embeddedos-org#119 arm already uses bb and reports the unsupported platform. Guards from embeddedos-org#121 / embeddedos-org#93 that later merges walked back: - ci.yml: embeddedos-org#132 added windows-test after embeddedos-org#121's gate; the gate did not wait for it, so "CI Gate" could be green with the MSVC leg red. - test_ci_gate.py: embeddedos-org#129 gave eosim-sanity.yml and simulation-test.yml a path-filtered pull_request trigger (they test their own edits); a path-filtered check cannot be required, so both are recorded in NOT_REQUIRED with the book-build.yml reason. - tests/CMakeLists.txt: test_linux_security_paths (embeddedos-org#119) and test_pkg_fetch (embeddedos-org#115) had no add_executable(). embeddedos-org#115's replay replaced embeddedos-org#119's block with its own, and embeddedos-org#118's replay replaced that; two suites compiled against nothing. Both registered again. 41 -> 43 suites. - tests/test_kernel.c: four tests from embeddedos-org#130 and embeddedos-org#131 were defined and never called -- their RUN() lines did not survive the replay of main(). And the one that was not bookkeeping: - kernel/src/task.c: embeddedos-org#130 was merged after embeddedos-org#131 from a base that predates it, and its copy of task.c replaced embeddedos-org#131's. embeddedos-org#131 had also flattened 574 CRLF line endings, so its 1166-line diff hid a 39/21 change and the replay took embeddedos-org#130's side wholesale. Master kept embeddedos-org#131's tests and lost its kernel: the idle task could be deleted and suspended, a half-initialised TCB was published to the scheduler before its stack existed, and eos_schedule() pointed g_current_sp at the outgoing task. test_idle_task_is_permanent fails on master the moment it is called. embeddedos-org#131's task.c diff re-applied on top of embeddedos-org#130's; the result is embeddedos-org#131's file plus embeddedos-org#130's wake_armed hunk and nothing else (verified by diff against 3a00bd9). Verified locally (macOS, clang): Release build clean, 43/43 ctest; the README default configuration builds; 47/47 pytest. Not in this PR: the nightly "Upstream drift" job builds eBoot at master and eBoot master is broken separately (its own repair PR); bump EBOOT_COMMIT in eos-simulation.yml once that lands.
Residue from 0957ac4, which dropped the borrowed test_crypto_ed25519_loworder registration and left the blank line that had separated it from the block below. It is the only remaining change this PR makes to tests/CMakeLists.txt, it changes nothing, and it sits at the exact anchor where embeddedos-org#118, embeddedos-org#119 and embeddedos-org#127 each add the real registration block -- so it would have conflicted with whichever of those lands first, for no reason at all. tests/CMakeLists.txt is now untouched by this branch.
…list Review findings 2, 3 and 5 on embeddedos-org#119, none of which changes what any caller sees. is_word_safe() answered an incomplete metacharacter denylist with a second denylist -- space, tab, *, ?, ~ -- and it had the same shape of hole: no [ or ] (a glob character class) and no { or } (brace expansion, which /bin/sh performs when it is bash). Both reach make unquoted through defconfig and cross_compile, and a leading '-' turns the defconfig target into an option. None of that is command execution, but a denylist that lists three of five glob characters is the failure mode this PR exists to fix, so the predicate is now a positive definition: isalnum() plus ._/+=:- and nothing else. It cannot be incomplete, and the new test asserts both halves -- twelve strings that are not one word are refused, and sixteen real defconfig targets, cross-compile prefixes and hash algorithm names are still accepted. The counter-check in test_ordinary_paths_still_reach_the_shell() now passes CROSS_COMPILE=arm-linux-gnueabihf- through to a real make invocation. offset += snprintf(...) accumulated the would-be length rather than what was written, so a truncating segment would put cmd + offset out of bounds and underflow sizeof(cmd) - offset to a huge size_t. Unreachable at the current field widths (512 + 128 + 128 against a 2048 buffer) and nothing ties the two together, so both busybox functions check instead of relying on the arithmetic staying true. eos_core becomes a PRIVATE dependency: eos/log.h is used by linux_security.c and by nothing under include/, so there was no reason to push eos_core's include directories onto every consumer. CMake still records a static library's PRIVATE dependency as a $<LINK_ONLY:> interface requirement, so test_linux_security_paths -- which names only eos_linux_security -- still links; verified by building and running it. Also from the review: the injection payload in the default-source_dir test was formatted into a 192-byte buffer from a 320-byte sentinel path. No truncation occurred in practice, but a truncated payload loses its closing backtick and stops being an injection, and the test would then pass while testing nothing. The buffers are sized from each other now and snprintf's return is asserted, as is the payload's length against bb.version[64], which it also has to survive intact. make is probed with command -v before the test that needs it, the way evmctl already was.
Review finding 1 on embeddedos-org#119, and a contract change: five functions that always returned 0 can now return -1. This PR already removed one `|| echo` + discarded system() pair from eos_ima_sign_file() and left four more instances of the same defect in the same file. Each one reported a security step as done without having run it: eos_selinux_install_to_rootfs() cp -r ... || true, result discarded -- a failed policy copy left /etc/selinux/config naming a policy the rootfs does not contain. eos_selinux_label_rootfs() setfiles ... || echo, result discarded -- on any host without setfiles this labeled nothing. With no file_contexts set it echoed a "skipping" line and also returned 0. eos_ima_install_to_rootfs() cp key ... || true, result discarded -- an appraise policy installed with no key to appraise against. The non-shell half had the same shape: a policy_file that could not be opened left /etc/ima/policy empty and returned 0. eos_busybox_install_to_rootfs() make install, result discarded -- a rootfs with no busybox in it, and then an /init written with fopen()'s result ignored, so an unwritable rootfs produced an initramfs with no /init. All of them now separate "the tool is absent or failed" from "the step completed", log through EOS_ERROR and return -1, following eos_dmverity_verify() which already did this. The Windows arms return -1 for the same reason rather than 0 for a step that cannot run there. .ai/security.md, Never: "Report a check as passing without running it." Caller survey, since this changes return values: outside its own header and this test file, nothing in the tree calls any of these five functions -- nothing in systems/, cmd/ or examples/. No caller can break on the new -1. That survey is what the review asked for before this landed. Every fix is pinned by a test that fails without it. Verified by mutation -- eleven reversions, each caught: || true restored on the SELinux copy 1 check failed || echo restored on setfiles 1 the file_contexts branch back to 0 1 || true restored on the IMA key copy 1 make install's result discarded again 2 an unreadable policy_file swallowed again 1 is_word_safe back to the old denylist 1 rootfs_dir dropped from the SELinux guard 1 + sentinel created rootfs_dir dropped from the busybox guard 1 + sentinel created rootfs_dir dropped from the IMA guard 1 /init's fopen() silent again 1 The two sentinel lines are the ones worth reading: with the rootfs_dir term deleted from those guards the injected `touch` genuinely ran. The first draft of the SELinux case did not discriminate -- a hostile rootfs_dir fails at the fopen() long before any command is built, so it passed on an unguarded build too. It now uses a real directory whose *name* is the injection, with the /etc the function needs and a loaded policy, so the cp is actually reached. The counter-checks matter as much: eos_ima_install_to_rootfs() with no key and no policy file must still return 0, eos_selinux_install_to_rootfs() with the policy present must still return 0, eos_selinux_label_rootfs() must still return 0 when SELinux is disabled, and a busybox install whose make target succeeds must still write its /init. The SELinux one caught a fixture bug that had made two of these tests pass for the wrong reason -- these functions MKDIR("<rootfs>/etc/<x>") without creating <rootfs>/etc first, so a bare mkdtemp() rootfs fails at the fopen(), not at the step under test. Also covered here, from the first review: one hostile-rootfs_dir case per *_install_to_rootfs entry point -- the guard the PR body named as its second finding shipped with no test -- and the EOS_IMA_OFF ordering, where a malformed rootfs_dir is now refused even with IMA off.
services/linux/src/linux_security.cbuilds shell commands withsnprintfand hands them tosystem()orpopen()— nine call sites.is_path_safe()is the only thing standing between a caller's input and the shell.The defect
It rejected
;|&><$()"'. It did not reject the backtick — command substitution in every POSIX shell — or a newline, which ends one command and starts the next. It was applied at two of the nine call sites. Andis_path_safe(NULL)returned 1. Compiled verbatim from master:Against the unfixed file the test suite fails and the injected command runs:
That
uid=line isidexecuting out of a path string.A fourth hole, found in review:
eos_busybox_configure()validatedsource_dirbefore filling in its default, andis_path_safe("")returns 1 because its loop never runs. So the default path — the one a caller gets by not setting the field — validated the empty string and handed.eos/build/src/busybox-<version>tosystem()unchecked, withversionvalidated nowhere. The fill now happens before the check, andeos_busybox_set_version()refuses at the boundary as well.The fix
is_path_safe(): rejects backtick, newline, other control characters and backslash;NULLis unsafe; applied at every entry point that builds a command.is_word_safe()— fordefconfigandcross_compile, which are interpolated unquoted — is now an allowlist:isalnum()plus._/+=:-, and no leading-. It was a second denylist (space, tab,*,?,~) that missed[/]and{/}; answering an incomplete denylist with another denylist only moves the next gap further out.|| trueor|| echo …and discardedsystem()'s result, so a missingevmctlorsetfiles, a failed policy or key copy, and amake installthat never ran all returned 0. Each now separates "the step completed" from "the tool is absent or failed", logs throughEOS_ERROR, and returns -1.offset += snprintf(...)in both busybox functions accumulated the would-be length; a truncating segment would have put the write pointer past the end ofcmdand underflowed the remaining size. Unreachable at the current field widths (512 + 128 + 128 against 2048), and now checked rather than inferred.eos_coreis aPRIVATEdependency ofeos_linux_security. It wasPUBLICto let one.cfile seeeos/log.h; nothing underinclude/needs it.0600viaopen(O_CREAT|O_EXCL)rather thanfopen("w"), which creates 0666-masked-by-umask (CodeQL, three sites).Contract change — read this one
eos_ima_sign_file(),eos_selinux_install_to_rootfs(),eos_selinux_label_rootfs(),eos_ima_install_to_rootfs()andeos_busybox_install_to_rootfs()previously always returned 0. They can now return -1. Also:eos_selinux_label_rootfs()with SELinux enabled and nofile_contextsreturns -1 where it used to echo a "skipping" line and return 0; and the guard ineos_ima_install_to_rootfs()sits above themode == EOS_IMA_OFFearly return, so a malformedrootfs_diris refused even with IMA off.Caller survey, since that is the question this change turns on: outside its own header and the test file, nothing in this tree calls any of these five functions — nothing in
systems/,cmd/orexamples/. No caller can break on the new -1. It is in its own commit (087142b).Validation
tests/test_linux_security_paths.c— 17 sub-tests. 11 shell payloads per path entry point, 12 strings that are not one shell word plus 16 real values that must still be accepted, one hostilerootfs_dirper*_install_to_rootfs, and the absent-tool / failed-copy case for each security step. Refusal is asserted by side effect — a sentinel file a successful injection would create — because these functions have several ways to return -1 and only one of them means the guard caught it.Every fix is pinned by a test that fails without it. Eleven reversions, each caught:
|| truerestored on the SELinux policy copy|| echorestored onsetfilesfile_contextsbranch back toreturn 0|| truerestored on the IMA key copymake install's result discarded againpolicy_fileswallowed againis_word_safe()back to the old denylistrootfs_dirdropped from the SELinux guardtouchranrootfs_dirdropped from the busybox guardtouchranrootfs_dirdropped from the IMA guard/init'sfopen()silent againTwo things that came out of doing this rather than asserting it:
rootfs_dircase did not discriminate. A hostilerootfs_dirfails at thefopen()long before any command is built, so it passed on an unguarded build too. It now uses a real directory whose name is the injection, with the/etcthe function needs and a loaded policy, so thecpis genuinely reached.MKDIR("<rootfs>/etc/<x>")without creating<rootfs>/etcfirst, so a baremkdtemp()rootfs fails at thefopen(), not at the step under test.40, not the 35 recorded in an earlier revision of this description — the branch has been rebased onto a newer master since.
Not covered
veritysetup,setfilesorevmctlinstalled, here or in CI. The dm-verity and SELinux assertions that rely on a return code do not discriminate under that condition; the sentinel-based ones do, anywhere. That is why the coverage added in this round is side-effect based.eos_kaudit_install_to_rootfs()and the/initwriter interpolaterootfs_dirintosnprintfpaths andfopenthem. No shell is involved, sois_path_safeis not the right control there — path traversal throughrootfs_diris a different question and this PR does not address it.#elsearms now return -1 where several returned 0, for steps that cannot run there. No test covers it.tests/fuzz/. Pre-existing gap; not closed here.-Wformat-truncation=warning reported on the GCC leg is fixed by sizing the buffers from each other and assertingsnprintf's return, but there is no GCC on this machine, so that one is verified by construction and by CI rather than reproduced locally.Merge order
tests/CMakeLists.txtcarries thetest_crypto_ed25519_loworderregistration block, becauseorigin/masterhas that test file and never registers it, which failstests/unit/test_cmake_test_registration.py. #118 and #127 add the identical block at the identical anchor, and #126 adds a stray blank line at the same anchor — whichever lands second conflicts. Land #127 first, then rebase this one and drop the duplicated hunk. (An earlier note here named #125; it is closed, and #127 is its replacement.)Left for a separate change
These commands would be better run through
fork/execvwith an argv array than validated as strings — validation is a weaker guarantee than never involving a shell. Two of them are pipelines (veritysetup format … | grep | awk) and would need restructuring, so that is a larger change than this one and it is not smuggled in here.