Skip to content

fix: audit conformance pass — LDF frame validation, E2E masquerade detection - #76

Merged
SoundMatt merged 1 commit into
mainfrom
fix/audit-conformance
Jul 31, 2026
Merged

fix: audit conformance pass — LDF frame validation, E2E masquerade detection#76
SoundMatt merged 1 commit into
mainfrom
fix/audit-conformance

Conversation

@SoundMatt

Copy link
Copy Markdown
Owner

Summary

Implements the go-LIN gap-audit worklist (audit_20260730-1552), most-severe first. Base commit audited: 7b45f6457cba4e6f1774a9b5047f029627b2e908 (origin/main, unchanged at time of this PR).

ID Severity Status
go-LIN-01 medium fixed (pre-made diff applied cleanly + one additional fix, see below)
go-LIN-02 medium fixed (pre-made diff applied cleanly)
go-LIN-A2 low fixed (implemented from scratch)
go-LIN-A3 low fixed (implemented from scratch)
go-LIN-A1 low fixed (implemented from scratch)

go-LIN-01 — LDF frame ID/length validation (CWE-20/CWE-789)

Spec citation: LIN Specification Package 2.2A §2.3.1 — protected identifier = 6-bit frame ID (ID0–ID5) + 2 parity bits, so raw frame ID MUST be 0x00–0x3F; LIN data field is 1–8 bytes (matches this package's own LINMaxDataLen).

parseFrameHeader discarded the length-parse error and never range-checked ID or length. Reproduced the exact panic against pre-fix code:

=== RUN   TestScratchPanicProof
    zzpanic_test.go:24: confirmed panic (pre-fix): runtime error: makeslice: len out of range

(Frames { BadFrame: 0x10, MasterNode, -4 { EngineSpeed, 0; } }db.Encode(0x10, nil)make([]byte, f.Length) with f.Length == -4.) An out-of-range ID (e.g. 300) was silently truncated via uint8(id) (300 → 44/0x2C), corrupting whichever legitimate frame lived at the truncated ID.

Fix: parseFrameHeader now rejects (returns an error, causing the frame to be skipped) any ID outside 0..lin.LINMaxID or length outside 0..lin.LINMaxDataLen, and no longer discards the length-parse error.

Additional fix surfaced by this change: once invalid frames are rejected instead of silently corrupted, a pre-existing bug in parseFrames became load-bearing: on a header-parse error it did continue without skipping the frame's body, so the next peeked line (the frame's own signal-ref lines) fell through to the "skip one line" branch — and the frame's own closing } was mistaken for the Frames section's closing }, silently truncating the whole section and dropping every subsequent frame. Fixed by skipping forward to the matching closing brace before continuing. Verified:

// BadFrame (invalid ID) followed by GoodFrame
db, _ := ldf.Parse(...)
db.Frame(0x20) // GoodFrame — now present; was nil before this fix

Also fixed the sibling gap (go-LIN-A3, not spec-mandated but same CWE-20 class): signal-ref bit offsets are now rejected when negative instead of relying on incidental Go shift/comparison semantics.

Regression tests (ldf/parser_test.go), each independently verified to fail against pre-fix parser.go:

  • TestParse_rejectsNegativeFrameLength — asserts the panic-triggering frame is never stored, and Encode on the rejected ID returns nil instead of panicking
  • TestParse_rejectsOutOfRangeFrameID — asserts ID 300 is not stored under its truncated 0x2C key
  • TestParse_badFrameDoesNotSwallowLaterFrames — asserts a good frame after a bad one still parses
  • TestParse_rejectsNegativeSignalBitOffset
  • FuzzParse strengthened to call db.Encode(id, nil) for every frame ID after every parse (the existing fuzz target never called Encode, so it could not have caught the original panic) — 200,000 iterations clean

go-LIN-02 — E2E masquerade detection (AUTOSAR E2E)

Spec citation: AUTOSAR E2E Protocol Specification — DataID is included in the CRC specifically for masquerade protection; a frame protected under a different DataID/SourceID must fail validation, not just produce a self-consistent CRC. Matches this repo's own HARA.md H-06/SG-04 and REQ-SAFETY-001/002.

Receiver.Unwrap stored Config{DataID, SourceID} but never compared it against the wire values — the comment _ = dataID // validated implicitly via CRC was false. Fix: after CRC success, compare wire DataID/SourceID against r.cfg and return a new ErrIDMismatch on mismatch; removed the misleading comment.

Regression tests (safety/e2e_test.go): TestUnwrap_rejectsDataIDMismatch, TestUnwrap_rejectsSourceIDMismatch — both fail to compile against pre-fix e2e.go (ErrIDMismatch didn't exist), confirming the symbol and behavior are new.

go-LIN-A2 — virtual bus empty-payload rejection

Spec citation: LIN Specification Package 2.2A — data field is 1–8 bytes; matches this repo's own lin.ValidateFrame ("data must not be empty").

Publish/PublishClassic accepted []byte{} (non-nil, zero-length) and later broadcast it via SendHeader as a 0-byte frame lin.ValidateFrame would itself reject. Fixed both to reject non-nil empty data the same way they reject over-length data; PublishClassic also gained the LINMaxDataLen guard Publish already had (it had none before).

Regression tests (virtual/bus_test.go): TestPublish_rejectsEmptyPayload, TestPublishClassic_rejectsEmptyPayload — both fail against pre-fix bus.go.

go-LIN-A3 — negative bit offset

Folded into the go-LIN-01 fix above (ldf/parser.go, parseFrames), since both stem from the same discarded-error pattern.

go-LIN-A1 — Action SHA pinning

All uses: lines in .github/workflows/{ci,dco,docker,release}.yml now pin to full commit SHAs (resolved via gh api repos/<owner>/<repo>/commits/<tag>) with a # vX comment, matching the repo's own SLSA/supply-chain evidence posture. YAML re-validated with yaml.safe_load after the rewrite.

Verification

  • go build ./..., go vet ./... — clean
  • go test -race -count=1 ./... — all packages pass
  • Per-package coverage floor (CI's 85% gate): ldf 90.4%, safety 100%, virtual 94.5% (all above floor)
  • Short fuzz runs (CI's exact invocations, 200,000 iterations each): FuzzParse (ldf), FuzzProtectUnwrap (safety), FuzzSendHeader (virtual) — all clean
  • gofusa CLI is not available in this environment; CI's gofusa job (v0.48.0, ERROR-gating + 100% req/sec-tested coverage) will be the first real check. No new fusa:req IDs were introduced — new tests are tagged against existing requirement IDs (REQ-LDF-005, REQ-LDF-006, REQ-SEC-001, REQ-SAFETY-001/002, REQ-SEC-002) that already describe the fixed behavior at the appropriate level of abstraction, consistent with how PR fix: audit pass 2 — diagnostic-frame checksum + 8 more findings #72's fix-pass didn't touch .fusa-reqs.json either.

Not merging — leaving for review/CI per repo convention.

…tection

Gap-audit findings, most-severe first:

- ldf.parseFrameHeader now rejects a frame whose ID falls outside
  0x00-0x3F or whose length falls outside 0-8 bytes, matching LIN
  Specification Package 2.2A §2.3.1 (6-bit frame ID) and this package's
  own LINMaxDataLen constant. Previously a negative length (e.g. from
  `f: 0x10, MASTER, -4;`) reached DB.Encode's `make([]byte, f.Length)`
  and panicked with `runtime error: makeslice: len out of range`
  (confirmed by reproduction against the pre-fix code — see PR body),
  and an out-of-range ID (e.g. 300) was silently truncated via a bare
  uint8() cast, corrupting the frame table entry at the truncated ID
  (CWE-20/CWE-789). Fixing this exposed a second latent bug: a rejected
  frame's single `continue` didn't skip its body, so its own closing
  brace was mistaken for the Frames section's closing brace, silently
  dropping every frame after it — fixed by skipping to the matching
  closing brace before continuing. Signal-ref bit offsets are now
  rejected when negative (previously the parse error was discarded).

- safety.Receiver.Unwrap now compares the wire-transmitted DataID/
  SourceID against the receiver's configured Config and returns a new
  ErrIDMismatch on mismatch. Per the AUTOSAR E2E Protocol Specification,
  the DataID is included in the CRC computation specifically to provide
  masquerade protection, but Unwrap only ever recomputed the CRC over
  the transmitted bytes themselves — proving self-consistency, not that
  the frame belonged to the receiver's expected stream. The misleading
  `_ = dataID // validated implicitly via CRC` comment (which claimed a
  check that didn't happen) is removed.

- virtual.Bus.Publish/PublishClassic now reject a non-nil, zero-length
  payload the same way they already reject an over-length one, so the
  in-process virtual bus can never broadcast a 0-data-byte frame that
  lin.ValidateFrame itself would consider malformed (LIN Specification
  Package 2.2A: the data field carries 1-8 bytes). PublishClassic also
  gained the LINMaxDataLen over-length guard Publish already had.

- All GitHub Actions in .github/workflows/ are now pinned to immutable
  commit SHAs (with a `# vX` comment) instead of mutable tags, matching
  the repo's own SLSA/supply-chain evidence posture.

Regression tests added for every fix (verified to fail against the
pre-fix code, see PR body); go build/vet/test/test -race and the
FuzzParse/FuzzProtectUnwrap/FuzzSendHeader short fuzz runs all pass.

Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com>
Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com>
@SoundMatt
SoundMatt merged commit 57a25fd into main Jul 31, 2026
14 checks passed
@SoundMatt
SoundMatt deleted the fix/audit-conformance branch July 31, 2026 13:26
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.

1 participant