Repository navigation
Conversation
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#156 "fix(backends): build every shell command through one checked builder"
head: 9bb30e6 author: Kartikey1306 ci: pass (29 green, 0 red; Create GitHub Release skipped)
Verdict: First look. Reviewed 9569226..9bb30e6 (two commits — the body says ..64ccb9f, and
9bb30e6 has landed since; the extra commit is a test refinement). The change is correct and I
could not break it: I audited all 158 eos_shell_cmd_text() calls for a non-literal argument and
found none, checked the length arithmetic for an off-by-one and found it sound, and confirmed the
suite fails when the refusal is removed. Nothing was weakened — the || true count is unchanged
at 5 and output masking went down from 12 to 10.
But the PR's own framing is what I followed to the finding below. It says: "This is the class
#119 removed from services/linux/src/linux_security.c; that file kept its two predicates private,
so the backends never got them." That sentence is now true of a third file. services/os/src/os_services.c
keeps its own private denylist, it is holed in exactly the ways this PR's comments describe, and
the path it guards is OTA download and install. I compiled it and ran it, and it executed my
command. That is a P0, it is pre-existing and not this PR's doing, and it is here because this PR
is what makes it fixable in four lines.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Critical (P0) — pre-existing, not introduced by this PR | services/os/src/os_services.c:83-87 (is_url_safe), :103-107 (eos_ota_download), :127-133 (eos_ota_install) |
Command injection in the OTA download and install paths. Demonstrated by execution, not by reading. is_url_safe() is strpbrk(url, ";|><$()\'") — it omits **"**, **&**, **`**, newline and control characters. The URL is interpolated into wget -q -O "%s" "%s" (:105), so one unescaped double quote leaves the quoting and & starts a second command. I compiled os_services.c with stubs, called eos_ota_set_source() with http://example.invalid/fw.bin" & touch /tmp/eos156_probe_download & ", then eos_ota_download(): the sentinel file was created. The function returned -1 because wget then failed — so the caller sees a failed download while the injected command has already run. eos_ota_install() is worse in one respect: its denylist ;|&><$()\"' does include " and & but omits the backtick, which is command substitution inside double quotes. Probe with target_path = /tmp/eos156_probe_x\touch /tmp/eos156_probe_install`: **sentinel created, and the function returned 0 with state = EOS_OTA_COMPLETE** — an install that ran an attacker's command and reported success. This is the exact defect this PR's own header comment documents at core/src/shell_cmd.c:12-22 ("A backtick is command substitution in every POSIX shell, so ``/tmp/id`` passed as safe and ran id")..ai/security.md` puts OTA in the TCB and says to hold it to a stricter standard and not round severity down; §15 makes OTA foundational for real devices. |
Four lines, and this PR has already written the hard part. Delete is_url_safe() and the strpbrk at :127, #include "eos/shell_cmd.h", and guard both with eos_shell_arg_is_safe(). Better, and barely longer: build both lines with EosShellCmd the way the backends now do — text("wget -q -O "), arg(local_path), text(" "), arg(url), run(...) — which also fixes local_path, currently unvalidated and on Windows derived from getenv("TEMP"). Then a test in the shape of tests/test_shell_cmd.c's, asserting the refusal happens before system() — an injected command that runs and then reports failure is the case the current code produces. Its own PR, not this one, and it needs a maintainer's eyes rather than mine. |
| 2 | High (P1) — pre-existing, not introduced here | services/pkg/eos_pkg.c:1030-1040, :76-86 |
The same class in the package manager, with no validation at all. eos_pkg.c:461 does fread(&hdr, sizeof(hdr), 1, f) straight from the .eapp file; :705 copies hdr.name into pkg->name; :1032 and :1038 interpolate that into taskkill /IM "%s" >nul 2>&1 and pkill -f "%s" 2>/dev/null and call system(). There is no predicate anywhere on that path. So a package whose header name field carries a backtick or a quote executes commands when someone runs eos-pkg stop — installing a package is not running it, so that is a real gap between "I installed something untrusted" and "it ran". Separately eos_rmdir_recursive() at :76-86 puts an unvalidated path into rm -rf "%s" / rmdir /s /q "%s", called at :752 with pkg->install_path. .ai/security.md: "Every externally reachable parser is attack surface" — a package header is one. I rated this High rather than Critical for one reason and I want to be explicit about it: I did not demonstrate it. The dataflow above is Observed — I read every step — but I did not build a hostile .eapp and run eos-pkg stop against it. If someone does, it is the same Critical as finding 1 and should be re-rated, not argued down. |
Same fix: eos_shell_arg_is_safe() on pkg->name and on path before either system(), or build the lines through EosShellCmd. pkg->name additionally deserves validation at the point it is parsed (:705), not only where it is used — a name that cannot be a shell word probably should not be installable. |
| 3 | Low (P3) | core/src/shell_cmd.c:136-137 |
system() returning -1 — fork failed, or the status could not be retrieved — and 127 — /bin/sh could not be executed — are both reported as EOS_ERR_BUILD, the same value a genuine compile failure produces. "The build failed" and "nothing ran at all" are then indistinguishable to every caller, and the second is an environment problem the operator needs to know about. The old code had the same behaviour, so this is not a regression; it is a new function where the distinction was available and is now the only place it would have to be made. |
if (rc == -1) { EOS_ERROR("%s: could not run a shell at all", what); return EOS_ERR_INVALID; } before the rc == 0 test. |
Observations, recorded because they look like findings and are not:
- Five
\|\| truetails survive in the install lines ofcargo,zephyr,buildroot,nuttx
andfreertos. Brief item 4 treats a\|\| trueas a finding — but these are pre-existing and
the count is identical before and after (5 → 5), and2>/dev/null/>nulmasking actually
decreased (12 → 10). Nothing was weakened here; the masking is a separate, older problem.
Worth its own issue: an install that copied nothing currently reportsEOS_OK. tests/test_shell_cmd.c's NuttX case assertsEOS_OK, and thatEOS_OKcomes from the
\|\| truerather than from the install succeeding. The body says so in plain words, and the
test's claim is the narrow one it needs — "the line reached the shell whole", for which
EOS_OKversus the oldrc 7is a valid discriminator. Disclosed and correct; recorded so the
next reader does not mistake it for coverage of the install itself.kbuild_backend.c:32andnuttx_backend.c:30passtoolchain_filetoword(), which refuses a
path containing a space. That reads wrong until you see:31— the value is used as a
CROSS_COMPILE=prefix with a-appended, not as a path, soword()is right and the
parameter is misnamed in the pre-existing backend interface. Not a finding; the comment at:30
already says it.core/src/shell_cmd.ccallingsystem()fromeos_corelooked like a tier-placement question
until I checked whateos_coreis:log/config/graph/cache/lockfile/layer/scheduler, the host
build-tool core, not the kernel (kernel/is a separate target). It belongs where it landed.
What this PR gets right
The two predicates are allowlist where an allowlist is possible and a denylist only where the
complete set of shell syntax is genuinely enumerable, and the comments explain why each earlier
denylist was holed rather than just replacing it — which is what stops the next person from
"simplifying" it back. eos_shell_cmd_arg() appending its quotes and value as a unit so an
overflow cannot leave a dangling quote is the detail that separates this from a half-fix, and the
author says their own test caught the first draft doing exactly that. The fault flag making every
later append a no-op, with the refusal enforced in run() rather than at each call site, means a
backend author cannot forget to check. And moving linux_security.c's predicates to core instead
of copying them is the right direction: the PR's whole diagnosis is that keeping them private is
what let the backends go eight files without them.
Verification performed for this review
Detached worktree at .ai/autoreview/state/scratch/eos-156 on refs/pull/156/head. The eos
checkout was clean before and after and was not touched; nothing was committed or pushed. The
injection probes were compiled into /tmp and their sentinel files deleted afterwards.
| Check | Result |
|---|---|
cmake -B build -DEOS_BUILD_TESTS=ON → cmake --build --parallel 4 |
PASS, no new warnings from this diff (the five -Wformat-truncation / unused-variable warnings are in core/src/scheduler.c, net/src/net.c and services/pkg/eos_pkg.c, none of them touched here) |
ctest --test-dir build --output-on-failure --no-tests=error |
PASS — 44/44, 0.91s — matches the body (43 before) |
pytest tests/ -q |
PASS — 47 passed — matches the body |
ctest -R shell_cmd |
PASS — 10 assertions in 1 binary |
Negative control — if (cmd->fault) in eos_shell_cmd_run() forced to if (0), rebuilt |
test_a_refused_value_marks_the_command_and_stops_it_growing FAILS at tests/test_shell_cmd.c:113. The refusal is genuinely pinned. Restored; git status clean. |
Audit: does any eos_shell_cmd_text() take a non-literal? All 158 calls across the 11 backends |
One — freertos_backend.c:69, eos_shell_cmd_text(&cmd, kinds[i]), where kinds is static const char *const kinds[] = { "bin", "elf", "hex" } at :64. Literals in this tree. The text() escape hatch is not used on configuration data anywhere. This is the check the whole design rests on. |
Length arithmetic in append_raw() and eos_shell_cmd_arg() |
Sound. append_raw faults unless len + n < sizeof(buf), so the memcpy of n+1 bytes ends at most at sizeof(buf). arg() faults unless strlen + 2 < sizeof(buf) - len, leaving room for both quotes and the terminator. No off-by-one. |
eos_shell_arg_is_safe() against an escape from double quotes |
Sound. ", $, ` and \ are all refused and the value is always emitted inside "…", so nothing else on the allowed side (*, ?, [, {, ~, space, !) is special there. system() runs /bin/sh -c non-interactively, so ! history expansion does not apply. |
eos_shell_word_is_safe() |
Allowlist isalnum + ._/+=:-, leading - refused, unsigned char used for isalnum (no negative-index UB). Sound. Locale-dependent isalnum could accept high-bit bytes in a non-C locale; none of those are shell-special. |
linux_security.c delegation |
Wrappers only; the rule bodies are byte-for-byte the ones that were there. test_linux_security_paths passes. |
| Was anything weakened? | No, and I counted rather than assumed: || true 5 → 5, 2>/dev/null/>nul 12 → 10. No test disabled, no assertion removed, no lint loosened, no permission widened. |
Injection probe 1 — eos_ota_download() with …/fw.bin" & touch /tmp/eos156_probe_download & " |
/tmp/eos156_probe_download created. Function returned -1. Finding 1. |
Injection probe 2 — eos_ota_install() with /tmp/eos156_probe_x`touch /tmp/eos156_probe_install` |
/tmp/eos156_probe_install created, and the function returned 0 / EOS_OTA_COMPLETE. Finding 1. |
| Dataflow for finding 2 | eos_pkg.c:461 fread(&hdr, …) → :705 strncpy(pkg->name, hdr.name, …) → :1032/:1038 system("… \"%s\"" , pkg->name). Read end to end; not executed. |
Architecture conformance
Conforms. §21 places eos in Tier 1 — Foundation, and everything is inside the owning repo;
§21.1 is not engaged — a shared predicate earned a file, not a repository, which is exactly what
that section says should happen ("keep the subsystem inside the owning repository"). §5.1's
direction holds and improves: backends/ and services/linux/ now both depend downward on
core/, and the previous shape — two private copies of a security predicate with no common
owner — was the dependency graph failing to express a real relationship. .ai/architect.md's
"do not introduce a layer that only forwards calls" is worth checking against the
linux_security.c wrappers, and they survive it: they exist to keep that file's call sites and
git blame readable, the comment says so, and they are two lines each rather than a layer.
§14.1's "use reviewed cryptographic libraries; do not invent primitives" has an analogue here that
this PR gets right — it did not invent a quoting scheme, it constrained the input until the
obvious quoting is sufficient. The section findings 1 and 2 are measured against is §15
(eOTA: "signed metadata and package provenance", "verify" before "install") and §10/§11 (the
component and registry model), by way of .ai/security.md's rule that every externally reachable
parser is attack surface.
No architecture proposal appended. Findings 1 and 2 are the design being ignored, not the
design being wrong — §14.1 and §15 already require what is missing, and .ai/security.md already
names the failure mode. Nothing here shows the master design stale or silent.
Proposed changes
This PR: mergeable on its own merits. The fix is correct, complete for the
eleven backends, and pinned by tests that fail when reverted.
core/src/shell_cmd.c:136 distinguish system() == -1 from a build failure (finding 3)
Separate PR, and the highest-priority work this run produced:
services/os/src/os_services.c delete is_url_safe() and the strpbrk at :127;
use eos_shell_arg_is_safe(), or build both
lines with EosShellCmd (P0)
services/pkg/eos_pkg.c validate pkg->name and path before system() (P1)
tests/ a probe per site asserting the refusal happens
BEFORE system() -- the current OTA code runs the
command and then reports failure
Separate issue, older and unrelated to this diff:
the five `|| true` install tails -- an install that copied nothing reports EOS_OK
No fix PR opened. Finding 1 is Critical and the brief's autofix rule is High-only — and I agree
with the rule here rather than merely obeying it: the fix touches the OTA install path, and
tightening it will refuse URLs and paths that work today. I cannot run an OTA cycle to see what
that breaks. Finding 2 is High but not "small and provable" for the same reason, and I have not
demonstrated its reachability. Finding 3 is Low. So: nothing opened, and the P0 needs a human this
week rather than a patch from me tonight.
Not checked
- Finding 2 was not executed. Stated above and worth repeating: I read the dataflow, I did not
build a hostile.eapp. The severity should go up, not down, if someone does. - MSVC and the Windows code paths — NOT BUILT. No Windows host. Every
#ifdef _WIN32branch in
the backends, inos_services.cand ineos_pkg.cwas read, not compiled or run. Thecmd.exe
copy/mkdirlines that9bb30e6adjusts a test for are therefore Inferred correct from
the source. CI on this PR is the check the author points to, and I did not read its logs. - The ARM/aarch64 cross toolchains — NOT USED for this PR. I did not repeat the author's
CMAKE_SYSTEM_NAME=Genericcross configure ofeos_core/eos_backends. Their claim that
nothing in a cross build callssystem()is consistent with what I read —shell_cmd.cis only
reached frombackends/andservices/linux/— but it is Inferred. - No backend was driven end to end. All eleven are exercised through
tests/test_shell_cmd.c's refusal assertions, which prove the hostile case returns
EOS_ERR_INVALIDbefore a shell sees it. I did not run a realcmake/ninja/meson
configure through the rewritten builders to confirm the benign lines are still byte-identical
to what they were. A regression that quoted something that must not be quoted would not be caught
by the suite, and reading 11 files is not the same as running them. - ASan/UBSan, Valgrind, CodeQL — NOT RUN.
- The #144 half of the 25-file diff was not reviewed here —
kernel/include/eos/kernel.h,
kernel/src/task.c,tests/test_kernel.c,tests/test_firmware.cand the CI/gate changes belong
to that PR. This review covers9569226..9bb30e6. mergeStateStatusisBLOCKED—REVIEW_REQUIREDplus the #144 stack.
Automated architecture review of 9bb30e670c0c — 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.
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#156 "fix(backends): build every shell command through one checked builder"
head: 9bb30e6 author: Kartikey1306 ci: pass (29 green, 0 red; Create GitHub Release skipped)
Verdict: First look. Reviewed 9569226..9bb30e6 (two commits — the body says ..64ccb9f, and
9bb30e6 has landed since; the extra commit is a test refinement). The change is correct and I
could not break it: I audited all 158 eos_shell_cmd_text() calls for a non-literal argument and
found none, checked the length arithmetic for an off-by-one and found it sound, and confirmed the
suite fails when the refusal is removed. Nothing was weakened — the || true count is unchanged
at 5 and output masking went down from 12 to 10.
But the PR's own framing is what I followed to the finding below. It says: "This is the class
#119 removed from services/linux/src/linux_security.c; that file kept its two predicates private,
so the backends never got them." That sentence is now true of a third file. services/os/src/os_services.c
keeps its own private denylist, it is holed in exactly the ways this PR's comments describe, and
the path it guards is OTA download and install. I compiled it and ran it, and it executed my
command. That is a P0, it is pre-existing and not this PR's doing, and it is here because this PR
is what makes it fixable in four lines.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Critical (P0) — pre-existing, not introduced by this PR | services/os/src/os_services.c:83-87 (is_url_safe), :103-107 (eos_ota_download), :127-133 (eos_ota_install) |
Command injection in the OTA download and install paths. Demonstrated by execution, not by reading. is_url_safe() is strpbrk(url, ";|><$()\'") — it omits **"**, **&**, **`**, newline and control characters. The URL is interpolated into wget -q -O "%s" "%s" (:105), so one unescaped double quote leaves the quoting and & starts a second command. I compiled os_services.c with stubs, called eos_ota_set_source() with http://example.invalid/fw.bin" & touch /tmp/eos156_probe_download & ", then eos_ota_download(): the sentinel file was created. The function returned -1 because wget then failed — so the caller sees a failed download while the injected command has already run. eos_ota_install() is worse in one respect: its denylist ;|&><$()\"' does include " and & but omits the backtick, which is command substitution inside double quotes. Probe with target_path = /tmp/eos156_probe_x\touch /tmp/eos156_probe_install`: **sentinel created, and the function returned 0 with state = EOS_OTA_COMPLETE** — an install that ran an attacker's command and reported success. This is the exact defect this PR's own header comment documents at core/src/shell_cmd.c:12-22 ("A backtick is command substitution in every POSIX shell, so ``/tmp/id`` passed as safe and ran id")..ai/security.md` puts OTA in the TCB and says to hold it to a stricter standard and not round severity down; §15 makes OTA foundational for real devices. |
Four lines, and this PR has already written the hard part. Delete is_url_safe() and the strpbrk at :127, #include "eos/shell_cmd.h", and guard both with eos_shell_arg_is_safe(). Better, and barely longer: build both lines with EosShellCmd the way the backends now do — text("wget -q -O "), arg(local_path), text(" "), arg(url), run(...) — which also fixes local_path, currently unvalidated and on Windows derived from getenv("TEMP"). Then a test in the shape of tests/test_shell_cmd.c's, asserting the refusal happens before system() — an injected command that runs and then reports failure is the case the current code produces. Its own PR, not this one, and it needs a maintainer's eyes rather than mine. |
| 2 | High (P1) — pre-existing, not introduced here | services/pkg/eos_pkg.c:1030-1040, :76-86 |
The same class in the package manager, with no validation at all. eos_pkg.c:461 does fread(&hdr, sizeof(hdr), 1, f) straight from the .eapp file; :705 copies hdr.name into pkg->name; :1032 and :1038 interpolate that into taskkill /IM "%s" >nul 2>&1 and pkill -f "%s" 2>/dev/null and call system(). There is no predicate anywhere on that path. So a package whose header name field carries a backtick or a quote executes commands when someone runs eos-pkg stop — installing a package is not running it, so that is a real gap between "I installed something untrusted" and "it ran". Separately eos_rmdir_recursive() at :76-86 puts an unvalidated path into rm -rf "%s" / rmdir /s /q "%s", called at :752 with pkg->install_path. .ai/security.md: "Every externally reachable parser is attack surface" — a package header is one. I rated this High rather than Critical for one reason and I want to be explicit about it: I did not demonstrate it. The dataflow above is Observed — I read every step — but I did not build a hostile .eapp and run eos-pkg stop against it. If someone does, it is the same Critical as finding 1 and should be re-rated, not argued down. |
Same fix: eos_shell_arg_is_safe() on pkg->name and on path before either system(), or build the lines through EosShellCmd. pkg->name additionally deserves validation at the point it is parsed (:705), not only where it is used — a name that cannot be a shell word probably should not be installable. |
| 3 | Low (P3) | core/src/shell_cmd.c:136-137 |
system() returning -1 — fork failed, or the status could not be retrieved — and 127 — /bin/sh could not be executed — are both reported as EOS_ERR_BUILD, the same value a genuine compile failure produces. "The build failed" and "nothing ran at all" are then indistinguishable to every caller, and the second is an environment problem the operator needs to know about. The old code had the same behaviour, so this is not a regression; it is a new function where the distinction was available and is now the only place it would have to be made. |
if (rc == -1) { EOS_ERROR("%s: could not run a shell at all", what); return EOS_ERR_INVALID; } before the rc == 0 test. |
Observations, recorded because they look like findings and are not:
- Five
\|\| truetails survive in the install lines ofcargo,zephyr,buildroot,nuttx
andfreertos. Brief item 4 treats a\|\| trueas a finding — but these are pre-existing and
the count is identical before and after (5 → 5), and2>/dev/null/>nulmasking actually
decreased (12 → 10). Nothing was weakened here; the masking is a separate, older problem.
Worth its own issue: an install that copied nothing currently reportsEOS_OK. tests/test_shell_cmd.c's NuttX case assertsEOS_OK, and thatEOS_OKcomes from the
\|\| truerather than from the install succeeding. The body says so in plain words, and the
test's claim is the narrow one it needs — "the line reached the shell whole", for which
EOS_OKversus the oldrc 7is a valid discriminator. Disclosed and correct; recorded so the
next reader does not mistake it for coverage of the install itself.kbuild_backend.c:32andnuttx_backend.c:30passtoolchain_filetoword(), which refuses a
path containing a space. That reads wrong until you see:31— the value is used as a
CROSS_COMPILE=prefix with a-appended, not as a path, soword()is right and the
parameter is misnamed in the pre-existing backend interface. Not a finding; the comment at:30
already says it.core/src/shell_cmd.ccallingsystem()fromeos_corelooked like a tier-placement question
until I checked whateos_coreis:log/config/graph/cache/lockfile/layer/scheduler, the host
build-tool core, not the kernel (kernel/is a separate target). It belongs where it landed.
What this PR gets right
The two predicates are allowlist where an allowlist is possible and a denylist only where the
complete set of shell syntax is genuinely enumerable, and the comments explain why each earlier
denylist was holed rather than just replacing it — which is what stops the next person from
"simplifying" it back. eos_shell_cmd_arg() appending its quotes and value as a unit so an
overflow cannot leave a dangling quote is the detail that separates this from a half-fix, and the
author says their own test caught the first draft doing exactly that. The fault flag making every
later append a no-op, with the refusal enforced in run() rather than at each call site, means a
backend author cannot forget to check. And moving linux_security.c's predicates to core instead
of copying them is the right direction: the PR's whole diagnosis is that keeping them private is
what let the backends go eight files without them.
Verification performed for this review
Detached worktree at .ai/autoreview/state/scratch/eos-156 on refs/pull/156/head. The eos
checkout was clean before and after and was not touched; nothing was committed or pushed. The
injection probes were compiled into /tmp and their sentinel files deleted afterwards.
| Check | Result |
|---|---|
cmake -B build -DEOS_BUILD_TESTS=ON → cmake --build --parallel 4 |
PASS, no new warnings from this diff (the five -Wformat-truncation / unused-variable warnings are in core/src/scheduler.c, net/src/net.c and services/pkg/eos_pkg.c, none of them touched here) |
ctest --test-dir build --output-on-failure --no-tests=error |
PASS — 44/44, 0.91s — matches the body (43 before) |
pytest tests/ -q |
PASS — 47 passed — matches the body |
ctest -R shell_cmd |
PASS — 10 assertions in 1 binary |
Negative control — if (cmd->fault) in eos_shell_cmd_run() forced to if (0), rebuilt |
test_a_refused_value_marks_the_command_and_stops_it_growing FAILS at tests/test_shell_cmd.c:113. The refusal is genuinely pinned. Restored; git status clean. |
Audit: does any eos_shell_cmd_text() take a non-literal? All 158 calls across the 11 backends |
One — freertos_backend.c:69, eos_shell_cmd_text(&cmd, kinds[i]), where kinds is static const char *const kinds[] = { "bin", "elf", "hex" } at :64. Literals in this tree. The text() escape hatch is not used on configuration data anywhere. This is the check the whole design rests on. |
Length arithmetic in append_raw() and eos_shell_cmd_arg() |
Sound. append_raw faults unless len + n < sizeof(buf), so the memcpy of n+1 bytes ends at most at sizeof(buf). arg() faults unless strlen + 2 < sizeof(buf) - len, leaving room for both quotes and the terminator. No off-by-one. |
eos_shell_arg_is_safe() against an escape from double quotes |
Sound. ", $, ` and \ are all refused and the value is always emitted inside "…", so nothing else on the allowed side (*, ?, [, {, ~, space, !) is special there. system() runs /bin/sh -c non-interactively, so ! history expansion does not apply. |
eos_shell_word_is_safe() |
Allowlist isalnum + ._/+=:-, leading - refused, unsigned char used for isalnum (no negative-index UB). Sound. Locale-dependent isalnum could accept high-bit bytes in a non-C locale; none of those are shell-special. |
linux_security.c delegation |
Wrappers only; the rule bodies are byte-for-byte the ones that were there. test_linux_security_paths passes. |
| Was anything weakened? | No, and I counted rather than assumed: || true 5 → 5, 2>/dev/null/>nul 12 → 10. No test disabled, no assertion removed, no lint loosened, no permission widened. |
Injection probe 1 — eos_ota_download() with …/fw.bin" & touch /tmp/eos156_probe_download & " |
/tmp/eos156_probe_download created. Function returned -1. Finding 1. |
Injection probe 2 — eos_ota_install() with /tmp/eos156_probe_x`touch /tmp/eos156_probe_install` |
/tmp/eos156_probe_install created, and the function returned 0 / EOS_OTA_COMPLETE. Finding 1. |
| Dataflow for finding 2 | eos_pkg.c:461 fread(&hdr, …) → :705 strncpy(pkg->name, hdr.name, …) → :1032/:1038 system("… \"%s\"" , pkg->name). Read end to end; not executed. |
Architecture conformance
Conforms. §21 places eos in Tier 1 — Foundation, and everything is inside the owning repo;
§21.1 is not engaged — a shared predicate earned a file, not a repository, which is exactly what
that section says should happen ("keep the subsystem inside the owning repository"). §5.1's
direction holds and improves: backends/ and services/linux/ now both depend downward on
core/, and the previous shape — two private copies of a security predicate with no common
owner — was the dependency graph failing to express a real relationship. .ai/architect.md's
"do not introduce a layer that only forwards calls" is worth checking against the
linux_security.c wrappers, and they survive it: they exist to keep that file's call sites and
git blame readable, the comment says so, and they are two lines each rather than a layer.
§14.1's "use reviewed cryptographic libraries; do not invent primitives" has an analogue here that
this PR gets right — it did not invent a quoting scheme, it constrained the input until the
obvious quoting is sufficient. The section findings 1 and 2 are measured against is §15
(eOTA: "signed metadata and package provenance", "verify" before "install") and §10/§11 (the
component and registry model), by way of .ai/security.md's rule that every externally reachable
parser is attack surface.
No architecture proposal appended. Findings 1 and 2 are the design being ignored, not the
design being wrong — §14.1 and §15 already require what is missing, and .ai/security.md already
names the failure mode. Nothing here shows the master design stale or silent.
Proposed changes
This PR: mergeable on its own merits. The fix is correct, complete for the
eleven backends, and pinned by tests that fail when reverted.
core/src/shell_cmd.c:136 distinguish system() == -1 from a build failure (finding 3)
Separate PR, and the highest-priority work this run produced:
services/os/src/os_services.c delete is_url_safe() and the strpbrk at :127;
use eos_shell_arg_is_safe(), or build both
lines with EosShellCmd (P0)
services/pkg/eos_pkg.c validate pkg->name and path before system() (P1)
tests/ a probe per site asserting the refusal happens
BEFORE system() -- the current OTA code runs the
command and then reports failure
Separate issue, older and unrelated to this diff:
the five `|| true` install tails -- an install that copied nothing reports EOS_OK
No fix PR opened. Finding 1 is Critical and the brief's autofix rule is High-only — and I agree
with the rule here rather than merely obeying it: the fix touches the OTA install path, and
tightening it will refuse URLs and paths that work today. I cannot run an OTA cycle to see what
that breaks. Finding 2 is High but not "small and provable" for the same reason, and I have not
demonstrated its reachability. Finding 3 is Low. So: nothing opened, and the P0 needs a human this
week rather than a patch from me tonight.
Not checked
- Finding 2 was not executed. Stated above and worth repeating: I read the dataflow, I did not
build a hostile.eapp. The severity should go up, not down, if someone does. - MSVC and the Windows code paths — NOT BUILT. No Windows host. Every
#ifdef _WIN32branch in
the backends, inos_services.cand ineos_pkg.cwas read, not compiled or run. Thecmd.exe
copy/mkdirlines that9bb30e6adjusts a test for are therefore Inferred correct from
the source. CI on this PR is the check the author points to, and I did not read its logs. - The ARM/aarch64 cross toolchains — NOT USED for this PR. I did not repeat the author's
CMAKE_SYSTEM_NAME=Genericcross configure ofeos_core/eos_backends. Their claim that
nothing in a cross build callssystem()is consistent with what I read —shell_cmd.cis only
reached frombackends/andservices/linux/— but it is Inferred. - No backend was driven end to end. All eleven are exercised through
tests/test_shell_cmd.c's refusal assertions, which prove the hostile case returns
EOS_ERR_INVALIDbefore a shell sees it. I did not run a realcmake/ninja/meson
configure through the rewritten builders to confirm the benign lines are still byte-identical
to what they were. A regression that quoted something that must not be quoted would not be caught
by the suite, and reading 11 files is not the same as running them. - ASan/UBSan, Valgrind, CodeQL — NOT RUN.
- The #144 half of the 25-file diff was not reviewed here —
kernel/include/eos/kernel.h,
kernel/src/task.c,tests/test_kernel.c,tests/test_firmware.cand the CI/gate changes belong
to that PR. This review covers9569226..9bb30e6. mergeStateStatusisBLOCKED—REVIEW_REQUIREDplus the #144 stack.
Automated architecture review of 9bb30e670c0c — 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.
|
Thanks — the audit of all 158 Finding 1 — OTA command injection ( Finding 2 — Finding 3 — On the observations: agreed on all four, and the |
…ue' — Closes #166 The cargo, zephyr, buildroot, nuttx and freertos install commands ended in '|| true', so a cp/find that copied nothing (wrong path, missing artifact, permission) still exited 0 and eos_shell_cmd_run() reported EOS_OK. - cargo/buildroot/freertos: drop the '|| true' tail and the '2>/dev/null' mask, so a failed copy propagates EOS_ERR_BUILD with its reason visible. - zephyr/nuttx: the .bin -> .elf fallback was a genuine 'try A then B', now expressed explicitly — test for the artifact with '[ -f ... ]', copy the one that exists, and exit 1 with a clear message when neither does, instead of 'cp ... || cp ... || true'. - add tests/test_backend_install.c (wired into tests/CMakeLists.txt): for each of the five backends, installing from an artifact-less build dir must not return EOS_OK, and installing with the artifact present must return EOS_OK with the file in the install dir. The negative tests fail against the old tails and pass with this fix. Note: PR #156's NuttX shell test (Kartikey1306, open) relied on the old tail as its discriminator; it will need the adjustment the issue anticipates when rebased onto this.
All eleven build backends assembled their system() lines with snprintf from a project's src_dir, build_dir, install_dir, toolchain file and option values. The values were quoted and nothing else: a build directory of /x"; touch pwned; " ran the touch (reproduced against the CMake backend's clean operation, sentinel created), and an option value with a space split into two arguments. Three install commands -- freertos, cargo, nuttx -- named EOS_MAX_PATH (512) paths three or four times in a 1024-byte buffer, so a long install path ran a cut-off line; the configure loops that grow a 2048-byte buffer with offset += snprintf stopped only on a -64 guard and also ran whatever fit. core/include/eos/shell_cmd.h is the one place a command is built now. Program text written in this tree is appended as text; every value from configuration is appended with eos_shell_cmd_arg() -- double-quoted, and refused if it carries a control character or any of ; | & > < $ ( ) " ' ` \ -- or eos_shell_cmd_word() -- unquoted, an allowlist of isalnum plus ._/+=:- with no leading '-', for tokens a program reads as one word (a make target, a board, a cross-compile prefix). A refused value or a line that does not fit marks the command; later appends are no-ops and eos_shell_cmd_run() returns EOS_ERR_INVALID without spawning. A quoted value is appended with its quotes or not at all, so an overflow leaves no dangling quote. The two rules are byte-for-byte the ones services/linux/src/linux_security.c reviewed as is_path_safe() and is_word_safe(); they live in core now and that file's predicates delegate to them, so there is one definition. tests/test_shell_cmd.c: the rules (every metacharacter refused, ordinary paths and flags accepted, the word allowlist including the leading-dash case); the builder (quoting, refusal stops growth, exact-fit accepted and one byte more refused, exit status reported); and every backend's build, install and clean refusing a hostile directory with EOS_ERR_INVALID -- not EOS_ERR_BUILD, which would mean a shell saw it -- plus configure refusing a hostile option value and a two-word key, and the NuttX install line naming a 511-byte path four times (over 2000 bytes; the old buffer cut it inside a quoted path and the shell died on the unterminated quote, rc 7) now reaching the shell whole (rc 0 through its own || true). (cherry picked from commit 64ccb9f) Rebased onto bdddc44 (embeddedos-org#166, @Aswin-V), which landed the same five install lines in a different shape. Both changes are kept: embeddedos-org#166 fixed what the lines do, this fixes how they are built. The cargo, buildroot and freertos install lines drop the `2>/dev/null || true` tail as embeddedos-org#166 does, so a copy that copies nothing reports EOS_ERR_BUILD with its reason visible; zephyr and nuttx express the .bin -> .elf fallback explicitly -- test for the artifact, copy the one that exists, exit 1 with a message when neither does -- in embeddedos-org#166's words and logic, but with every path appended through eos_shell_cmd_arg() rather than interpolated raw into a 1024-byte snprintf buffer. embeddedos-org#166's tests/test_backend_install.c passes 10/10 against this.
… non-zero The whole-line check on the NuttX install expected EOS_OK everywhere. The POSIX line ends in || true; the Windows line does not, so when its mkdir fails cmd.exe exits non-zero and the backend reports EOS_ERR_BUILD. Both are a line that reached the shell whole; EOS_ERR_INVALID is the only result that would mean it was refused. The MSVC leg failed on the first push of this branch for exactly that reason. (cherry picked from commit 9bb30e6)
eos_shell_cmd_run() reported system() returning -1 (no shell could be started, or its status could not be collected) and a shell exiting 127 (it could not run the command at all) as EOS_ERR_BUILD, the value a genuine compile failure produces. "The build failed" and "nothing ran" were indistinguishable to every caller, and the second is the environment problem the operator needs to hear about. Both are EOS_ERR_SYSTEM now; the header says so. The old code had the same conflation, but this is the one place that can make the distinction. tests/test_shell_cmd.c: a command that does not exist yields EOS_ERR_SYSTEM on POSIX; cmd.exe reports an unknown command through an ordinary non-zero status, so the Windows expectation is only that it is not success. (cherry picked from commit 519a700)
…d, not its exit status Rebasing onto bdddc44 (embeddedos-org#166) broke test_install_never_runs_a_truncated_line in two ways, one of which embeddedos-org#166 anticipated and one it did not. The anticipated one: the POSIX assertion was `== EOS_OK`, and that only held because the NuttX install line ended in `|| true`. That tail was also the discriminator. A line that reached the shell whole hit `|| true` and exited 0; a line cut inside a quoted path lost its tail and died on the unterminated quote. embeddedos-org#166 removed the tail, so both now exit non-zero and the exit status can no longer tell them apart. The one embeddedos-org#166 did not anticipate: making the .bin -> .elf fallback explicit means the POSIX line now names the two directories eight times instead of four. At the test's 511-byte path that is a 4303-byte line, which no longer fits the 4096-byte builder, so it was refused (EOS_ERR_INVALID) before reaching a shell at all. Both are fixed by observing what only a whole line can do: - POSIX uses a 300-byte path: a 2615-byte line, over the old 1024-byte limit and inside the new 4096-byte one. - The directory is creatable and absent beforehand. A whole line runs `mkdir -p` first, then finds no NuttX image and exits 1. A line the shell cannot parse runs nothing. So the directory existing afterwards proves the line ran whole, and EOS_ERR_BUILD is the known reason it then stopped -- the discriminator embeddedos-org#166 asked for, "a line that reaches the shell whole and fails for a known reason". - cmd.exe still names the directories four times, so the 511-byte NUL path fits and its existing EOS_ERR_BUILD assertion is unchanged. - The overflow half of the test keeps the 511-byte path, which is what it needs. Negative control, with the binary confirmed relinked each time. The NuttX install was changed to build its line through the same validating builder and then copy it into a 1024-byte buffer before running it -- the original truncation, isolated so that hostile-input refusal still works: 10 of 11 tests pass; test_install_never_runs_a_truncated_line fails, at test_shell_cmd.c:294, the S_ISDIR check. The exit-status assertion on the line above passes under the bug, which is the point: it cannot discriminate, the directory can. An earlier control that replaced the builder with raw snprintf was discarded: it also dropped input validation, so test_every_backend_refuses_a_hostile_directory failed first and the result said nothing about this test.
519a700 to
8be5827
Compare
|
@Aswin-V thanks for flagging this in You were right that the NuttX test lost its discriminator with the One thing you may want to know about |
srpatcha
left a comment
There was a problem hiding this comment.
Reviewed. Approve. The builder is sound: quoted values refuse $ " ` and control characters, and unquoted words use an allowlist. The new test fails against master's backends and passes here (11/11). Follow-ups: the title overclaims, because unconverted system()/popen calls remain in pkg/src/fetch.c (194, 239, 260, 320, 357), systems/src/docs.c (85/127/142) and systems/src/image.c (64-99). linux_security.c still builds its commands with snprintf. eos_shell_cmd_arg() also accepts a leading -.
…what cmd.exe expands eos_shell_arg_is_safe() refused the backslash everywhere. That is correct for a POSIX shell, which reads \ inside double quotes. On Windows it is the path separator, so after embeddedos-org#156 the cargo/pip/npm backends refused every real path, and the MSVC leg failed test_backend_install with 0xc0000409. On Windows the rule now follows cmd.exe and the CRT: - a backslash is accepted, since it means nothing inside double quotes; - a trailing backslash is refused, because the CRT reads \" as a literal quote and the arguments after it would merge; - %VAR% and !VAR! are refused, because they expand even inside quotes. The POSIX rule is unchanged, and tests assert both. Host ctest passes 46/46 and test_shell_cmd 12/12. The Windows branch compiles with -D_WIN32 -fsyntax-only, but has not been run on Windows here; the MSVC CI leg is the check. Fixes embeddedos-org#182
Based on
masteratbdddc44— #144 has merged, so this is no longer stacked. Reviewbdddc44..8be5827: four commits.7fedeabis the fix (18 files);0e8e019is the cmd.exe expectation for the NuttX test;db297f0answers the review's finding 3 (core/src/shell_cmd.c, its header,tests/test_shell_cmd.c,CHANGELOG.md);8be5827re-grounds the NuttX test after #166 (see Rebased onto #166 below). Findings 1 and 2 are pre-existing and are handled in #165 and #163 respectively.Problem (eos #155)
All eleven build backends under
backends/— wired into the build by #118 — assembled theirsystem()lines withsnprintffrom a project'ssrc_dir,build_dir,install_dir, toolchain file and option values. The values were quoted and nothing else.Verified by execution (the unfixed
cmake_backend.ccompiled against a probe):Three more things in the same lines:
-D%s=%sunquoted, so a value with a space (CMAKE_C_FLAGS=-O2 -g) split into two arguments;freertos_install,cargo_install,nuttx_installnamedEOS_MAX_PATH(512) paths three or four times in a 1024-byte buffer. Verified: with two 511-byte paths the old NuttX line is cut inside a quoted path and the shell dies on the unterminated quote (unexpected EOF while looking for matching '"', rc 7) — a truncated command was executed;offset += snprintfand stopped only on a-64guard, running whatever fit.This is the class #119 removed from
services/linux/src/linux_security.c; that file kept its two predicates private, so the backends never got them.Change
core/include/eos/shell_cmd.h+core/src/shell_cmd.c— one place a command is built:eos_shell_cmd_text()— program text written in this tree;eos_shell_cmd_arg()— a configuration value, double-quoted, refused if it carries a control character or any of; | & > < $ ( ) " '`;eos_shell_cmd_word()— a configuration value the program reads as one word (a make target, a board, aCROSS_COMPILEprefix): an allowlist ofisalnumplus._/+=:-, no leading-;eos_shell_cmd_run()— refuses a command that carries a refused value or did not fit withEOS_ERR_INVALID, before anything runs; otherwisesystem()andEOS_OK/EOS_ERR_BUILD.A refused value or overflow marks the command and every later append is a no-op; a quoted value is appended with its quotes or not at all, so an overflow never leaves a dangling quote (the first draft did — its own test caught it).
The two rules are byte-for-byte the ones
linux_security.creviewed asis_path_safe()/is_word_safe(), moved tocorewith their reasoning;linux_security.c's predicates now delegate to them, so there is one definition.test_linux_security_pathsstill passes.Every backend (
cmake,ninja,make,kbuild,zephyr,freertos,nuttx,meson,autotools,buildroot,cargo) builds its lines through it. Option values are now quoted; keys go in as words. Behaviour otherwise unchanged, including the Windowscopy/mkdirvariants and Zephyr'swest-then-cmakefallback (a refused line is not retried ascmake).Tests —
tests/test_shell_cmd.c(11, Verified)NULLrefused, empty accepted;run()returnsEOS_ERR_INVALID; an exact fit is accepted and one byte more refused, leaving no partial line;exit 0/exit 3reported;build,installandcleanrefusing a hostile directory withEOS_ERR_INVALID— notEOS_ERR_BUILD, which would mean a shell saw it — andconfigurerefusing a hostile option value and a two-word key;mkdir -pcreates a directory that was absent beforehand, and then the line stops withEOS_ERR_BUILDfor the known reason that there is no NuttX image.Full suite on this head:
ctest44/45 on macOS — the one failure is #166'stest_backend_install, which fails identically onmasterbecause its cargo line usesfind -executable, a GNU extension macOS's BSDfindlacks; it passes 10/10 against this branch with that one flag swapped for-perm -u+x, and CI's Ubuntu runner has GNUfind,pytest47, the README default configuration builds, andeos_core/eos_backendscompile under aCMAKE_SYSTEM_NAME=Genericcross configure (system()is standard C; nothing in a cross build calls it).Review follow-up (
db297f0)services/os/src/os_services.cEosShellCmd: the fetch runswget/curlwith an argv throughposix_spawnp()/_spawnvp()and the install is a C file copy, so there is no shell line left to build (and a signed URL's&/;/=stay fetchable, whicheos_shell_cmd_arg()would rightly refuse). Both of the review's probes are intests/test_os_services.cas data; the negative control reproduces the injectedtouch.eos_pkg.cpkill -f "%s"/taskkill /IM "%s"andrm -rf "%s"fed from the package header and DBnameandpackage_idas single path components ([A-Za-z0-9._-], terminated, not./..) atverify/install/update, which is the "validate at the point it is parsed" half; its review follow-up covers the DB-ingress path (eos_pkg_load_db) so a name loaded from.eapp_dbis held to the same rule beforestoporremovecan use it.system()returning -1 / 127 reported asEOS_ERR_BUILDeos_shell_cmd_run()returnsEOS_ERR_SYSTEM(the enum's "system error") when no shell could be started (-1) or the shell could not run the command (WEXITSTATUS == 127, POSIX), with its own log line; the header documents the four outcomes.test_run_tells_a_missing_command_from_a_failed_one: a command that does not exist isEOS_ERR_SYSTEMon POSIX and not-success on Windows (cmd.exe reports an unknown command through an ordinary status).Rebased onto #166
#166 (
bdddc44, @Aswin-V) landed changes to the same five install lines while this was open, and noted that this PR's NuttX test "relied on the old tail as its discriminator". Both changes are kept: #166 fixed what those lines do, this fixes how they are built.2>/dev/null || truetail exactly as backends: five install lines end in|| true, so an install that copied nothing reports EOS_OK #166 does, so a copy that copies nothing reportsEOS_ERR_BUILDwith its reason visible..bin→.elffallback in backends: five install lines end in|| true, so an install that copied nothing reports EOS_OK #166's logic and wording — test for the artifact, copy the one that exists, exit 1 with a message when neither does — but every path is appended througheos_shell_cmd_arg()rather than interpolated raw into a 1024-bytesnprintfbuffer, whichmasterstill does.|| true, so an install that copied nothing reports EOS_OK #166's owntests/test_backend_install.cpasses 10/10 against this branch.The NuttX test needed more than #166 anticipated. Losing the
|| truetail removed the discriminator, as #166 said. But making the fallback explicit also means the POSIX line now names the directories eight times instead of four, so the old 511-byte path became a 4303-byte line that no longer fits the 4096-byte builder — it was being refused outright, before reaching a shell.8be5827moves to a 300-byte path and asserts on the side effect instead.Negative control for
8be5827, binary confirmed relinked: the NuttX install was changed to build its line through the same validating builder and then copy it into a 1024-byte buffer before running it — the original truncation, isolated so that hostile-input refusal still works. 10 of 11 tests pass, and onlytest_install_never_runs_a_truncated_linefails, attest_shell_cmd.c:294, theS_ISDIRcheck. The exit-status assertion on the line above passes under the bug, which is the point.Not verified here
MSVC and the ARM cross toolchains — no Windows host or cross compilers on this machine. The new code is C11 with
<ctype.h>/<string.h>/<stdlib.h>only; CI on this PR is the check.Closing issue
Fixes #155