The evidence: a full Article 14 gap audit of my own firmware

The teardown argues a case. This page is the evidence it rests on, published in full so you can disagree with it specifically rather than generally. Every row traces to a file and a line, and §8 has the commands to re-run the whole thing.

Read the dates before you read the findings. This is a snapshot of one firmware on 4 September 2026, not a status page. Two of the three engineering defects below (D1 and D2) were remediated on 5 September 2026; D3 was not and is now the worst defect in the product. The snapshot is left standing because dated evidence is the useful artefact — a document that quietly updates itself proves nothing about what was true on the day.

The subject is not placed on the market. DCSwitch is my own hardware, sold to nobody. Every finding here is a finding about a firmware that would be in scope if it shipped, which is the only reason it can be published in this detail.

1. How to read the grades

Every claim carries an evidence grade, and they are not decoration — the difference between grade A and grade B below is the difference between a finding and a hypothesis.

Evidence grades used in this audit
GradeMeans
APrimary. I read the file, the binary, or the regulation.
BIndustry body or trade press. Credible, not first-hand.
CVendor blog or aggregator. A hypothesis, not a fact.

2. Group A — provenance: you cannot say what you shipped

Group A findings: provenance
#FindingEvidenceEv
A1The network stack is fetched unpinned from a moving branch. REPO=".../cesanta/mongoose/master", then curl -sL for mongoose.c and mongoose.h. No tag, no commit, no checksum. Two builds a week apart can contain different TCP/IP stacks.scripts/get_mongoose.sh:5,8,11A
A2The build tree is not a working copy — and the repository that appears to cover it does not. git statusfatal: not a git repository; no remote, no commit identifier in the artifact. A repository named DCSwitch does exist on my network: three commits, the last in February 2025, commit message “Upload files to /”, containing Python scripts from an earlier unrelated iteration (README: solarman). None of the audited firmware is in it. The namesake repo is worse than no repo — it answers “is this under version control?” with a truthful yes while the binary stays untraceable.repo root; LAN GiteaA
A3The only build identity in the running firmware is a compile timestamp: printf("Compiled: %s %s\r\n", __DATE__, __TIME__). A timestamp does not tell you which Mongoose is inside.src/main.c:111A
A4No SBOM, in any format. No SPDX, no CycloneDX, nothing enumerating components.find over repo, §8A
A5No CI. No workflow files anywhere; the build is a local make. Nothing records what went into an artifact.find over repo, §8A

The inventory below was assembled by hand for this audit. It is the missing SBOM, and it took twenty minutes — which is the point.

Component inventory
ComponentVersionLicenceSource
Mongoose7.20 (mongoose.h:23)GPL-2.0-only or commercialvendored, unpinned
newlibas packaged by libnewlib-arm-none-eabimixed BSD-likeapt
gcc-arm-none-eabi runtimeas packagedGPL + exceptionapt
First-party src/, boot/, linker/own

The third-party surface is unusually small: no CMSIS, no ST HAL, direct register access in src/hal.c. That is a genuine strength and worth saying out loud — an SBOM here is a morning's work, not a quarter's.

3. Group B — the CVEs that provenance was hiding

Mongoose 7.20 is the version on disk; 7.23 is current. Every identifier below was confirmed against OSV (api.osv.dev/v1/vulns/<CVE-ID>), not against a search summary.

Applicability was decided by two artefacts, not by the version string: the symbol table of the built ELF, and the linker map. Anything listed in the Discarded input sections region of build/firmware.map was garbage-collected and is not in the binary.

Re-verified 5 September 2026, and it cost us. The firmware was rebuilt for the D1/D2 remediation, and every address and map line in the original audit was invalidated — the discarded-sections boundary alone moved from line 1521 to 1747. All thirteen linked/discarded verdicts were re-tested against the new artefacts and none of them changed. Only the coordinates did. Which is finding A3 happening to the auditor: nothing in the artefact records what went into it, so nothing in the audit could be trusted to survive a rebuild. The addresses below are from the 5 September build.

B1 — Applies to this build (2)

CVEs that apply to the built firmware
CVEComponentIn the binary
CVE-2026-6985handle_opt, TCP option handler — remote unauthenticated infinite loop (CWE-404, CWE-835).0800c25c t handle_opt; map line 2139
CVE-2026-2967getpeer, net_builtin.c — improper verification of communication source (CWE-940).0800a954 t getpeer.isra.0; map line 2016

On severity. OSV publishes a CVSS vector for each of these and no numeric score; all three are VulDB-assigned. The vectors are CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N/E:P for CVE-2026-6985, and the same with AC:H for the other two (VA:L for CVE-2026-2967, VI:L for CVE-2026-2966). An earlier draft of this page printed “4.0” in a column headed CVSS v4. That was the specification version read as a rating, and it is corrected here rather than quietly deleted.

One grade-B mapping. CVE-2026-2966's advisory names mg_sendnsreq, which does not appear under that name anywhere in the amalgamated build. The resolver path is unambiguously present, but the symbol-to-advisory mapping is grade B, unlike the other two, which are A. Saying “the resolver path is in the binary” is defensible; saying “mg_sendnsreq is in the binary” is not.

Correction, 6 September 2026. An earlier version of this page counted CVE-2026-2966 in B1 on the strength of that linkage. That was one rung too shallow and it has moved to B3. Linked in is not the same as reached: mg_resolve() short-circuits to mg_connect_resolved() whenever the host parses as a literal address (mongoose.c:397–404), the resolver's only possible caller is the MQTT client, and its configuration field is named broker_ip (src/mqtt_cl.c:171). What decides it is not the build but the configured value — and the field is a char[48] (src/mqtt_cl.h:13) validated only for non-emptiness (src/mqtt_cl.c:184), while the firmware's own IP parser (parse_ip4, src/main.c:29) is applied to the static network config at :54–56 and never to this field. My own documentation describes it as “MQTT broker IP or hostname” (docs/DCSwitch2_Firmware.md:249) while the worked example at :192 is an address literal. The name says one thing, the documentation says another, the code enforces neither. That is finding A2 in a different costume, and it is the reason the ladder has four rungs and not three: version → build configuration → reachability → deployment, each level getting the one above it wrong.

The fix note: “upgrade to 7.23” does not close all three

An earlier draft said 7.21 fixes all three. It does not, and finding that out required reading the upstream source, because OSV alone answers it wrongly in both directions.

Fix status of the three applicable CVEs
CVEOSV saysThe source saysNet
CVE-2026-6985fixed: 7.21, with a FIX reference to the release tagFixed in 7.21. Grade A
CVE-2026-2967No fixed version. last_affected: 7.20, no FIX reference7.21 adds the missing source-address check to getpeer: 7.20 matches a TCP segment on destination and source port only, 7.21 also requires c->rem.addr.ip4 == pkt->ip->srcFixed in 7.21 — evidenced by the diff, not the database. Grade A
CVE-2026-2966No fixed version. last_affected: 7.20, no FIX reference7.21 is byte-identical to 7.20 here, TODO comment included. 7.23 changes id = reqs->txnid + 1 to id = reqs->txnid + (id & 0xFF) + 1Not fixed. Partially mitigated in 7.23. Grade A

The 7.23 resolver still calls mg_random(&id, sizeof(uint16_t)) and then overwrites it whenever a request is already in flight. 7.20 replaced sixteen random bits with a deterministic previous + 1; 7.23 replaces them with previous + rand8 + 1. That is eight bits of entropy instead of zero, not the sixteen the random call was meant to supply — and the upstream comment now reads “no seq, no collision for upto 256 in-flight requests”, which tells you the change was made for collision avoidance. The spoofing resistance is a side effect.

Two things follow, and together they are the whole argument for reading source instead of trusting a version number:

  1. last_affected: 7.20 is not a fix record. It is a CPE range that ends where the release train happened to be on the disclosure date. Two of these three read identically in OSV and have opposite answers in the source.
  2. Upgrading 7.20 → 7.23 closes two of the three and mitigates the third. That is still the right move — but “we upgraded, we are clear” would be a false statement, and a manufacturer who repeats it in an Article 14 context has been misled by their own tooling.

B2 — Does not apply: removed by the compiler or the linker (12)

CVEs removed by the build
CVE(s)Why notEvidence
CVE-2026-6986, -5246, -5244, -2968, -11404, -73251, -73253All TLS. MG_TLS MG_TLS_NONE — preprocessed away before compilation.mongoose_config.h:27; mg_aes_gcm_decrypt and mg_tls_verify_cert_signature absent from the map entirely; mg_tls_init discarded (line 1228)
CVE-2026-5245 — handle_mdns_record stack overflowmDNS garbage-collectedmap line 1274 (discarded)
CVE-2026-73255 — SSI directory traversalMG_ENABLE_SSI 0mg_http_serve_ssi map line 1196 (discarded)
CVE-2026-73258 — multipart header scanmultipart parser unreferencedmg_http_next_multipart map line 1095 (discarded)
CVE-2026-73254, -73259 — XSS in directory listingsMG_ENABLE_DIRECTORY_LISTING 0mg_http_serve_dir map line 1299 (discarded)

An earlier pass recorded handle_mdns_record as merely “not observed in the symbol table,” hedged because a static function may be inlined. The linker map settles it: the section was discarded. Absence is grade A here, not a hint.

B3 — Depends on deployment, not on the build (3)

CVE-2026-73256 and CVE-2026-73257 — HTTP request smuggling (a CL.TE desync and an HTTP/1.0 detection off-by-one), fixed in 7.22. The HTTP parser is linked in (mg_http_parse 0800ccc4), but both advisories need a reverse proxy in front of Mongoose to desynchronise against. Standalone on a LAN: not exploitable. Behind an HTTP/1.0 proxy: exploitable. The build cannot answer this one — only the deployment can, which is why an SBOM that stops at the component list stops one step short of the answer.

CVE-2026-2966 — weak DNS transaction IDs, not fixed in any released version (see the fix note above). The resolver is linked in, and fires only if the MQTT broker is configured by hostname rather than by address; configured by address, no query is ever sent. Moved here from B1 on 6 September — the reasoning and the citations are in §3 above. The build cannot answer this one either — the config value can.

B4 — The finding underneath the findings

Seventeen CVEs match the version string. Two actually apply. Twelve were removed by the compiler and the linker, and three depend on how the box is deployed — two on whether a proxy sits in front of it, one on whether the broker is configured by hostname or by address.

And the twelve are not absent by design. They are absent because --gc-sections dropped sections nothing referenced. That is a security property held by accident. Enable mDNS for device discovery — an obvious future feature for this product — and CVE-2026-5245 returns silently, with no code review that would flag it.

An SBOM that records the component version but not the build configuration and link map is wrong in both directions: alarming about twelve issues that are not there, and silent about the day one comes back.

4. Group C — process: no way to hear, no way to tell

Group C findings: process
#FindingCRA hookEv
C1No channel to receive a report. No security.txt, no SECURITY.md, no published contact. The 24-hour clock starts when you become aware; with no inbound channel, awareness arrives via a customer or a journalist.Art. 14 trigger; Annex I Part IIA
C2No way to reach affected users. No user register, no notification channel. The update mechanism exists but there is no path from we know to they know.Art. 14(8)A
C3No upstream watch. Nothing monitors Cesanta advisories against the version in the field. Group B was found by searching during this audit, not by any standing process.Annex I Part IIA

5. Group D — what reading the code found

These are not paperwork. A questionnaire would have missed all three, because the firmware has authentication, has access control and has a documented update path.

Group D findings: engineering defects
#FindingEvidenceStatus
D1The OTA image is unauthenticated. The bootloader accepts an image on the strength of a scanned magic word 0x4F544100 and a size bound — no signature, no hash, no version check — then erases Bank 1 and copies Bank 2 over it. Anything that can write Bank 2 executes at next boot.boot/flash_util.c:28; boot/main.c:67–91Fixed in source 5 Sep 2026 — not yet run on hardware
D2Default credentials are admin/admin, and the config parser re-applies them as the fallback when the config fields are empty. The password is changeable; nothing forces the change.src/web.c:19,304–305Password fixed in source 5 Sep 2026 — not yet run on hardware; username unchanged
D3Plain HTTP on all interfaces. MG_TLS MG_TLS_NONE; the listener is http://0.0.0.0:80. HTTP Basic Auth therefore puts base64 credentials on the wire in clear — including on the request that uploads firmware.mongoose_config.h:27; src/web.c listener and auth handlerOpen

Two of the rows above no longer trace to a line. D1 cites boot/flash_util.c:28 and the function it named, boot_flash_ota_size, is gone; D2 cites src/web.c:19, which is now an unrelated extern declaration. Both moved when the fixes landed on 5 September. This page opens by claiming every row traces to a file and a line, and two of them now do not. There is no commit to point you at instead — because of finding A2. I could have renumbered them silently and you would never have known, which is the entire problem this audit is about, so they stay as they were written and this note explains why.

D3 is now the worst defect in this product, and it got worse in relative terms the moment D1 and D2 were fixed. Signing the update image is worth much less while the credentials authorising the upload cross the network in clear. Findings do not queue politely — fixing two of three changes which one matters.

6. The pass

P1 — the update mechanism itself is sound and already exists. Dual-bank OTA: the bootloader scans Bank 2, erases Bank 1 sectors 1–6, copies, clears the flag and resets. Config in sector 7 survives updates. Annex I Part I requires the ability to distribute security updates — this can. Evidence: boot/main.c:5–6,67–91. Grade A.

P1 is why D1 mattered rather than being fatal: the delivery path was already built, so signing it was a bounded piece of work rather than a redesign. It took a day.

7. The count

Eleven findings, one pass, one afternoon. Five are provenance, three are process, three are engineering defects found by reading code. On the CVEs: two apply to this build unconditionally, three depend on how it is deployed, and twelve are not in the binary — seventeen in total. The provenance and process gaps are what make Article 14 unanswerable. The engineering defects are what a compliance questionnaire would have scored as compliant.

8. Re-run it yourself

Point these at your own tree. The interesting one is the second block — it is the difference between “our SBOM lists a vulnerable version” and “the vulnerable code is in our binary.”

cat -n scripts/get_mongoose.sh                  # A1
git status                                      # A2
grep -n '__DATE__' src/main.c                   # A3
find . -iname '*sbom*' -o -iname '*.spdx*' \
       -o -iname '*.yml' -o -iname 'SECURITY*' \
       -o -iname 'security.txt' | grep -v ./build   # A4, A5, C1
grep -n 'broker_ip' src/mqtt_cl.c src/mqtt_cl.h  # B3: what decides CVE-2026-2966
grep -n 'MG_VERSION' mongoose.h                 # inventory
grep -n 'SPDX' mongoose.h                       # licence

Is a function in the binary, or did the linker discard it? Do not hardcode the boundary — it moves on every rebuild, which is the lesson from the note in §3.

BOUND=$(grep -n '^Memory Configuration' build/firmware.map | head -1 | cut -d: -f1)
echo "discarded-section boundary: line $BOUND"
for s in handle_opt getpeer handle_mdns_record mg_http_serve_ssi \
         mg_http_next_multipart mg_http_serve_dir mg_tls_init \
         mg_aes_gcm_decrypt mg_tls_verify_cert_signature mg_http_parse; do
  ln=$(grep -n "\.text\.$s\b" build/firmware.map | head -1 | cut -d: -f1)
  [ -z "$ln" ] && echo "$s: ABSENT FROM MAP" && continue
  [ "$ln" -lt "$BOUND" ] && echo "$s: DISCARDED ($ln)" || echo "$s: LINKED IN ($ln)"
done

And check the fix status against OSV — then check the source anyway, because §3 is what happens when you do not.

for id in CVE-2026-6985 CVE-2026-2967 CVE-2026-2966; do
  echo "== $id"
  curl -s "https://api.osv.dev/v1/vulns/$id" | python3 -c "
import json,sys; d=json.load(sys.stdin)
ev=[e for a in d.get('affected',[]) for r in a.get('ranges',[])
      for e in r.get('events',[])+r.get('database_specific',{}).get('extracted_events',[])]
print(' fixed:', [e['fixed'] for e in ev if 'fixed' in e] or 'NONE RECORDED')"
done

9. Open items — what I have not verified

Listed because an audit that does not say where it stops is not an audit.

  1. The penalty tier. Article 64's top tier is reported to cover Article 13 and 14 breaches. That is grade B — law-firm commentary, not read from the article — which is why no penalty figure appears anywhere on this site.
  2. Article 33. Micro and small enterprises may use a simplified Annex VII technical-documentation format, to be specified by Commission implementing act (grade B). There is no reporting exemption. Anyone telling you otherwise has read a summary.
  3. security.txt is good practice, not a CRA requirement. Annex I Part II asks for a coordinated vulnerability disclosure policy; security.txt is one cheap way to publish the contact point. Do not conflate the two.
  4. The French reporting route. CERT-FR (ANSSI) is the designated CSIRT coordinator and notifications route through the ENISA Single Reporting Platform — grade B, from ANSSI and gouv.fr pages rather than from the implementing act.

And the caveat that should not need saying: none of this is legal advice, and reading it does not make anyone CRA-compliant. It is one engineer's audit of one firmware, published because the failures are more useful than the successes.

I will do this for your firmware

Free, 30 minutes, no deck. You tell me what you ship and how you build it; I tell you what would fail. You get what I got: a list, with file names, and the commands to check me.