Security
waxcut parses untrusted, attacker-controllable binary input (arbitrary MP3 files) with no external decoder in the loop, so the parser itself is the attack surface. Here's how that risk is managed, and how to report a problem.
Reporting a vulnerability
Please report security vulnerabilities privately using GitHub's private vulnerability reporting rather than filing a public issue.
You should receive an initial response within 14 days. If the report is confirmed, a fix will be prepared and a security advisory published once a patched release is available.
waxcut is pre-1.0, and only the latest released version is supported with security fixes — see SECURITY.md in the repository for the current policy.
Continuous fuzzing
The frame parser is fuzzed continuously with
ClusterFuzzLite,
via the
cflite_pr.yml
workflow. Every pull request is fuzzed against the parsing entry points with
malformed and adversarial byte sequences — truncated headers, corrupted
sync words, bogus bitrate/sample-rate indices, malformed ID3v2/Xing/Info/VBRI
tags — looking for crashes, hangs, or memory issues rather than correctness
per se. This matters specifically because load_audio_stream and
scan_frames read raw, untrusted bytes directly (offsets, lengths, and tag
fields all come from attacker-controlled header bits).
Resource limits
Fuzzing (above) catches crashes and hangs on small mutated inputs within a CI time budget — it does not exercise deliberately large adversarial input, which is a different threat: a file packed with minimum-size MPEG2/2.5 Layer III frames (as little as ~24 bytes each) parses in linear time and never crashes, but produces one located frame per frame found.
This used to cost real memory amplification: AudioStream.frames was
originally a list of individually-allocated Frame objects (~168 bytes
each once you count the object itself plus its boxed int/float fields), so
a 10 MB adversarial file produced ~58 MB of Frame objects — ~6x
amplification on top of the input bytes, scaling linearly with input size.
AudioStream.frames is now backed by compact packed arrays instead
(Frames, ~24 bytes/frame, unboxed), with individual Frame objects
constructed lazily only when you actually index into or iterate the
sequence. Re-measured on the same adversarial construction: the same 10 MB
file now produces ~9.4 MB of Frames storage — amplification of
~0.95x, i.e. the parsed structure uses less memory than the input
itself.
That removes amplification as a concern, but a single call still costs
real, bounded time and memory proportional to input size — scan_frames
and load_audio_stream both reject input over 250 MB by default,
raising FileTooLargeError, so a
single call's worst-case cost stays bounded regardless. A load_audio_stream
call checks the file's size on disk before reading it, so an oversized
file is never fully loaded into memory in the first place.
load_audio_stream(path, use_mmap=True) changes that calculus. Instead of
reading the file into a Python bytes object, it memory-maps it, so the
250 MB default's memory-cost rationale doesn't apply — the file's bytes
are never materialized in Python's heap in the first place, the OS pages
them in on demand. What still applies is time: parsing is O(n) in file
size no matter what backs the bytes, so a large enough mmap'd file still
costs real wall-clock time to scan. use_mmap=True is therefore governed
by its own, larger 2 GB limit, sized to bound that worst-case scan time
rather than memory — measured at roughly 150 MB/s against the same
adversarial construction described above (a file packed edge-to-edge with
minimum-size MPEG2.5 frames), so a 2 GB adversarial input costs on the
order of ten seconds to scan rather than being unbounded.
Because the file stays memory-mapped for as long as the AudioStream is
alive, callers using use_mmap=True are responsible for calling
AudioStream.close() (or using it as a context manager) when they're done
with it — unlike the non-mmap path, where the file handle is closed once
the bytes are read, the mmap'd file's handle stays open for the
AudioStream's whole lifetime.
use_mmap=True is exercised in CI on Linux only — the ci.yml
workflow runs exclusively on ubuntu-latest, so the mmap code path isn't
independently verified on Windows or macOS. mmap's underlying semantics
differ enough across platforms (page-alignment behavior, file-locking
interaction, close-on-exec) that this is worth calling out explicitly
rather than assuming portability.
Neither limit is currently a configurable parameter — if your use case legitimately needs to process larger files, please open an issue rather than relying on undocumented internals to work around it.
parse_cue_sheet (untrusted .cue file text) is a different case, and
deliberately has no size cap. scan_frames/load_audio_stream read from a
Path, so they need to reject an oversized file before ever reading it off
disk into memory. parse_cue_sheet takes an already-materialized str --
by the time it's called, the caller has already paid the cost of holding
that text in memory, so a cap inside parse_cue_sheet wouldn't bound
anything the caller doesn't already control. This is a deliberate
consequence of the two functions sitting at different I/O boundaries, not
an oversight.
For completeness, the amplification from cue text to parsed timestamps is
higher than the ~0.95x figure above for frame parsing — measured at
roughly 3.9x: a 5.93 MB cue sheet producing 99,999 timestamps peaks
around 22.9 MB. Robustness has been checked the same way as the frame
parser, if not yet via the same continuous ClusterFuzzLite harness: 40,000
adversarial and mutated cue inputs run through parse_cue_sheet produced
zero exceptions other than the documented CueSheetError.
write_id3v2_tag's input guards
write_id3v2_tag is the one function in the public surface that writes
text supplied by the caller — a title, artist, or track number — into
binary tag frames, rather than only reading and validating bytes handed to
it. That text isn't necessarily hand-typed: a realistic pipeline pulls it
straight from parse_cue_sheet's TITLE/PERFORMER fields, which are
themselves attacker-influenceable if the .cue file came from an untrusted
source. Two guards keep that path bounded and unambiguous:
- Size: the combined
TIT2/TPE1/TRCKframe payload is capped by the ID3v2 tag format's own size field — a 4-byte syncsafe integer, whose maximum representable value is2**28 - 1(~256 MB). A pathologically large title (e.g. a caller accidentally passing an entire file's contents astitle) raisesValueErrorrather than silently truncating or overflowing. - No stacked tags: if
dataalready starts with an ID3v2 tag,write_id3v2_tagraisesValueErrorinstead of prepending a second one. This isn't just a correctness fix — a stacked tag shifts where frame scanning actually starts, soscan_frames(which only skips one leading tag) would misinterpret real audio bytes as tag-adjacent data. That's a parser-confusion bug class, the same family fuzzing (above) exists to catch, even though this particular case is a deterministic input-validation guard rather than something fuzzing found.
Supply-chain and process posture
waxcut's security posture is checked and scored by two independent, automated programs:
- OpenSSF Scorecard — evaluates the repository against a set of automated security health checks (branch protection, dependency pinning, CI configuration, and more) and publishes a score.
- OpenSSF Best Practices — a self-assessed but publicly verifiable checklist covering the OpenSSF Best Practices Badge criteria (change control, quality, security). waxcut is currently at 100% passing.
Ordinary CI — build, lint, and the full test suite, including the
mutagen/ffmpeg cross-validation described in
How It Works — runs on every pull request
via the ci.yml
workflow.