Skip to content

fix(packages): make the third-party supply chain verifiable, and its pins real - #67

Open
Kartikey1306 wants to merge 7 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/package-supply-chain-integrity
Open

fix(packages): make the third-party supply chain verifiable, and its pins real#67
Kartikey1306 wants to merge 7 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/package-supply-chain-integrity

Conversation

@Kartikey1306

Copy link
Copy Markdown
Contributor

The architectural problem

ebuild downloads third-party source over the network and builds it. That makes recipes/*.yaml the tool's supply chain, and a recipe is a pin: a URL plus the digest of exactly what should be at it.

Four of the five shipped recipes are not usable pins, and the code let them ship that way.

The recipes

Each digest below was checked by downloading the artifact twice and hashing it; both downloads agreed.

recipe recorded actual
littlefs sha256:placeholder 9cf2e7db…cad67
lwip sha256:placeholder c79255f6…ed87
mbedtls …b1490fcd73 …b1490fcd38
freertos e36e5a2f…e3140 eebd58aa…271eb
zlib 9a93b2b7…df23 9a93b2b7…df23
  • mbedtls has the last two hex characters transposed — 73 where the published digest ends 38.
  • freertos matches none of the release's assets. That tag publishes exactly one: FreeRTOS-KernelV11.1.0.zip, 3324125 bytes, eebd58aa…271eb.

All four corrected. The practical effect today is that ebuild cannot fetch four of the five libraries it ships recipes for — every attempt dies on a checksum mismatch.

Why nothing caught it

"placeholder" parsed as a valid checksum

PackageRecipe.validate() checked the field's shape in no way at all, so sha256:placeholder sailed through and then failed every fetch. A checksum that is present must now look like a sha256 digest.

It is deliberately still optional at the recipe level — a recipe also models packages that are never downloaded, and requiring it there broke 20 resolver tests that build in-memory recipes. The requirement lands at the boundary that actually matters:

No checksum meant no verification

archive_path = self._download(recipe)
if recipe.checksum:          # ← absent: download and extract, unverified
    self._verify_checksum(archive_path, recipe.checksum)

Omitting one field silently bought an unverified download. fetch() now refuses before touching the network.

tests/ebuild/test_package_fetcher.py contained test_empty_checksum_skips_verification, which asserted exactly this behaviour as if it were intended. It is replaced by one asserting the refusal.

Plaintext http:// was accepted

_download() allowed http:// and https:// alike. A pin is worth much less over a transport anyone on the path can rewrite, and it leaks what is being built. https only — no shipped recipe used http.

Keeping it fixed

tests/unit/test_shipped_recipes.py walks recipes/ and asserts each one pins a syntactically real sha256 over an https URL and loads through the real loader. Against the recipes as they were, it fails on both placeholders.

It deliberately does not assert digest values: that needs the network, and pinning them in a test would only duplicate the recipe. Catching the mbedtls-style transposition needs a fetch, which belongs in a scheduled job rather than the unit suite — happy to add one if you want it.

Verification

Check Result
pytest tests/ 223 passed (was 202)

16 new recipe tests, plus the inverted fetcher tests.

Relationship to #66

Independent — different files. #66 restores the package import and the ninja backend; this is the packages/recipes layer. Both are on master as the base.

🤖 Generated with Claude Code

Kartikey1306 and others added 6 commits August 28, 2026 19:18
…viour

`import ebuild.build.dispatch` raises SyntaxError on master, so the CLI and
five test modules fail outright. As with the other breakage below, the cause
is overlapping PRs squash-merged on stale bases with no re-verification.

- dispatch.py: configure() ended with two consecutive `else:` blocks from an
  unresolved conflict. The two blocks also disagreed about the exception --
  tests/ebuild expects ValueError("Unknown build backend '<name>'") while
  tests/unit expects RuntimeError matching "ninja". Both are legitimate
  readings: an unrecognized name is a bad argument, and a "ninja" that
  reaches the dispatcher is a CLI routing failure. Introduced
  UnknownBackendError(ValueError, RuntimeError) with a message covering both,
  raised from configure(), build(), and clean(). "ninja" is no longer a
  silent no-op in configure(), which is what let `ebuild build` report
  "Build completed successfully" without running a compiler.

- ninja_backend.py: NinjaBackend._object_path() was deleted by a cflags
  refactor while both of its callers survived, so generate() died with
  AttributeError -- the default build backend did not work at all. Restored
  with its target-namespaced object paths, which is what stops two targets
  sharing a source from claiming the same output.

- ninja_backend.py: the emitted `link_shared` rule was dead -- nothing used
  it -- and it hardcoded `-shared`, which is wrong on macOS. Shared libraries
  already link through the generic `link` rule with the platform's flag
  (-dynamiclib/-shared) and the -L/-l wiring in ldflags. Dropped the rule and
  updated tests/ebuild/test_ninja_backend.py, which asserted the dead rule,
  to cover the surviving behaviour instead. Its removal also unblocks
  test_static_library_unaffected, which asserts no "-shared" anywhere in the
  generated file.

Verified: pytest tests/ 202 passed (201 passed + 1 skipped without the ninja
package installed); tests/performance 1 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The matrix includes windows-2022, where the default shell is PowerShell. The
"Run test suite" step uses backslash line continuations, which PowerShell does
not accept:

    ParserError: Missing expression after unary operator '--'.
       3 |    --cov=ebuild --cov-report=xml --cov-report=term-missing \

So the Windows jobs failed before pytest started — on master and on every
branch. Marked `shell: bash`, which GitHub provides on Windows runners via Git
Bash, keeping the command readable and identical across the three platforms.

Also: `mypy .` aborted immediately with

    tests\__init__.py: error: Duplicate module named "tests"
        (also at ".\layers\eosuite\tests\__init__.py")
    Found 1 error in 1 file (errors prevented further checking)

layers/eosuite/ vendors its own tests/ package. Because the step is
continue-on-error, this went unnoticed and the type check has been checking
zero files. Excluding layers/ makes it check 81 source files; it stays
continue-on-error, so the 11 pre-existing findings it now reports are visible
without gating the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the Windows jobs actually running pytest, they surface a backend that
has never worked on Windows:

    ninja: error: build.ninja:20: expected build command name
    build C:\...\main.o: cc main.c
          ^ near here

Ninja splits build statements on unescaped spaces and colons, so a Windows
absolute path puts a drive-letter colon where Ninja expects the separator
between outputs and the rule name. Every generated build.ninja was rejected
before a single command ran. A POSIX path containing a space fails the same
way.

_ninja_path() escapes `$`, `:` and ` ` and is applied to the paths in build
statements only. Variable values (cflags, ldflags) are read to end of line and
are deliberately left alone -- escaping them would hand the compiler mangled
flags.

Also skipped tests/ebuild/test_integration_initramfs_security.py on hosts
without find(1)/cpio(1). _create_initramfs() drives both directly, so on a
Windows runner all three died with WinError 2 before reaching the injection
behaviour they exist to check. Building a Linux initramfs is not a Windows
operation; the skip matches how test_ninja_backend.py already skips when no
host C compiler is present.

Adds four regression tests for the escaping, including one asserting that each
build statement contains exactly one unescaped colon.

Verified: pytest tests/ 206 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ci.yml is the only workflow in this repo without a concurrency group, and it
is the heaviest one -- a matrix spanning ubuntu, macos and windows. Every push
to a pull request therefore left the previous run queued, and all of them
competed for the same scarce windows/macos runners. On this branch three
superseded runs sat ahead of the current one for over an hour, testing commits
that were no longer HEAD.

Uses the same group expression the sibling workflows already use, with
cancel-in-progress: true, because a superseded commit's result is not wanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macos-13 image is retired, so jobs requesting it are never assigned a
runner -- they sit queued until they time out. Across four runs on this branch
every ubuntu-22.04 and windows-2022 job started and finished within minutes
while all three macos-13 jobs stayed queued for more than two hours, leaving
the workflow permanently incomplete.

Every other workflow in this repo already targets macos-latest; ci.yml was the
one place still pinning macos-13.

release.yml also lists macos-13, alongside macos-14, in its wheel-build matrix.
Left alone: building wheels against an older macOS deployment target may be
deliberate there, and it does not gate pull requests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pins real

ebuild downloads third-party source over the network and builds it. That makes
recipes/*.yaml the tool's supply chain, and a recipe is a pin: a URL plus the
digest of exactly what should be at it. Four of the five shipped recipes are
not usable pins, and the code let them ship that way.

**Four recipes cannot be fetched.** Each digest below was checked by
downloading the artifact twice and hashing it; both downloads agreed.

| recipe   | recorded                | actual                  |
|----------|-------------------------|-------------------------|
| littlefs | `sha256:placeholder`    | `9cf2e7db...cad67`      |
| lwip     | `sha256:placeholder`    | `c79255f6...ed87`       |
| mbedtls  | `...b1490fcd73`         | `...b1490fcd38`         |
| freertos | `e36e5a2f...e3140`      | `eebd58aa...271eb`      |
| zlib     | `9a93b2b7...df23`       | `9a93b2b7...df23` (ok)  |

mbedtls has the last two hex characters transposed. freertos matches none of
the release's assets — that tag publishes exactly one, `FreeRTOS-KernelV11.1.0.zip`,
3324125 bytes, `eebd58aa...271eb`. Corrected all four.

**"placeholder" parsed as a valid checksum.** `PackageRecipe.validate()` checked
that the field was well-formed in no way at all, so `sha256:placeholder` sailed
through and then failed every fetch with a mismatch. A checksum that is present
must now look like a sha256 digest. It is deliberately still optional at the
recipe level — a recipe also models packages that are never downloaded — the
requirement lands at the boundary that matters, below.

**No checksum meant no verification.** `PackageFetcher.fetch()` did
`if recipe.checksum:` and, when absent, downloaded and extracted with nothing
checked. Omitting one field silently bought an unverified download. fetch() now
refuses before touching the network. `tests/ebuild/test_package_fetcher.py`
contained `test_empty_checksum_skips_verification`, which asserted exactly this
behaviour; it is replaced by one asserting the refusal.

**Plaintext http:// was accepted.** `_download()` allowed `http://` and
`https://` alike. A pin is worth much less over a transport anyone on the path
can rewrite, and it leaks what is being built. https only; no shipped recipe
used http.

**tests/unit/test_shipped_recipes.py** walks recipes/ and asserts each one pins
a syntactically real sha256 over an https URL and loads through the real
loader. Against the recipes as they were, it fails on both placeholders. It
deliberately does not assert digest *values*: that needs the network, and
pinning them here would only duplicate the recipe.

Verified: pytest tests/ 223 passed (up from 202; 16 new recipe tests, plus the
inverted fetcher tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 79.16667% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
ebuild/packages/recipe.py 33.33% 2 Missing and 2 partials ⚠️
ebuild/build/dispatch.py 87.50% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Running the test suite leaves a generated _build/ (build.ninja and
compile_commands.json) in the repo root, and it was not gitignored, so a
`git add -A` swept it into this branch. Removed, and gitignored so it cannot
happen again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@srpatcha srpatcha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — and the framing is right

recipes/*.yaml is the tool's supply chain, and treating a recipe as a pin rather than a hint is the correct model. Merged onto current master locally: 223 passed.

The defect that matters most

archive_path = self._download(recipe)
if recipe.checksum:          # ← absent: download and extract, unverified
    self._verify_checksum(archive_path, recipe.checksum)

Omitting one optional field silently bought an unverified download. That is a fail-open default on the one code path where failing open means executing someone else's bytes.

Worse, tests/ebuild/test_package_fetcher.py carried test_empty_checksum_skips_verification — the insecure behaviour was pinned by a test as though it were intended, so anyone who fixed it would have looked like they were breaking something. Replacing that test with one asserting the refusal is the part of this PR I would least want dropped.

Checked the new gate reads the way it should:

Refusing to fetch <name> v<version>: the recipe carries no checksum, so there
is nothing to verify the download against.

Keeping checksum optional on PackageRecipe while making it mandatory at fetch() is the right seam. A recipe also models packages that are never downloaded, and forcing it at the dataclass punished twenty resolver tests that build recipes in memory. The requirement belongs at the boundary where bytes actually arrive.

Independently confirmed one of the digests

I did not re-download all five, but I checked the FreeRTOS claim against the API, since "matches none of the release's assets" is a strong statement:

$ gh api repos/FreeRTOS/FreeRTOS-Kernel/releases/tags/V11.1.0
  FreeRTOS-KernelV11.1.0.zip  3324125 bytes

Exactly one asset, exactly the size and name you record, and the recipe URL points at it. That matches your account precisely.

sha256:placeholder passing validate() is the enabling bug behind all of it — a checksum field that accepts any string is not a checksum field. Requiring the shape when the value is present closes it.

Also right

Rejecting plaintext http:// outright rather than warning. A pinned digest over plaintext is still better than nothing, but there is no reason to accept the downgrade when every URL here is available over TLS.

Verification

Merged onto origin/master; pytest 223 passed; FreeRTOS release assets checked via the GitHub API. Not re-verified: the littlefs, lwip, mbedtls and zlib digests — I am taking those on your stated method of downloading twice and comparing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants