fix(nemo-speech-cpp): audit the gosec unsafe and file-inclusion sites

gosec flags 13 alerts on this backend: one G304 and twelve G103. Each was
checked individually rather than blanket-suppressed, and each annotation
states what makes that particular site safe.

The G304 at audio.go is a false positive. The opened path is
filepath.Join of a directory the function just created with os.MkdirTemp
and a constant basename; the request-controlled path is the input to
AudioToWav and never reaches the open.

The twelve G103 sites are the package's three established shapes, and
every one was verified against them: cstr and pinPtr take the address of
something pinned on the line above and return it one-way (nothing in the
package converts either result back, which is what keeps checkptr out of
it under -race), and each *Create hands C a stack-local POD config whose
uintptr members are cstr allocations or pinPtr addresses held by a pinner
the loader unpins only after the call. The two slice-building sites are
bounded by construction: DiarSegments is handed exactly len(buf) with the
buffer sized under maxDiarSegments and a reported count larger than it
rejected rather than sliced to, and the TTS callback copies out a slice
whose length is the length the runtime declared for that buffer.

Separately, sampleRateOf gets a real fix rather than an annotation.
go-audio reads the WAV header's sample rate from an unsigned 32-bit field
into an int, so a header claiming more than 2^31-1 passed the "> 0" test
and then narrowed to a NEGATIVE rate, which the runtime would take as a
resampling ratio. AudioToWav cannot produce one today, but that is a
property of another package and this function exists precisely because
the rate is read back rather than assumed, so the bound is enforced here
and pinned by a spec.

The four remaining integer narrowings are annotated with the bound that
makes each safe: the WAV payload length is already checked against
maxWAVDataBytes, the speaker count is bounded by maxDiarSegments, and the
two segment ids are the proto's own int32 wire type.

Assisted-by: Claude Code:claude-opus-5[1m]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-08-06 23:55:15 +00:00
parent 6f301d1cf4
commit eb4863e2f7
8 changed files with 92 additions and 1 deletions

View File

@@ -34,6 +34,10 @@ type asrWord struct {
// address C holds stays the address of the object.
func pinPtr[T any](p *runtime.Pinner, v *T) uintptr {
p.Pin(v)
// #nosec G103 -- v is pinned into p on the previous line, so its address is
// stable and traced for as long as p lives; every caller defers p.Unpin only
// after the create call that reads it. One-way, like cstr: nothing converts
// this uintptr back to a pointer.
return uintptr(unsafe.Pointer(v))
}
@@ -137,6 +141,10 @@ func (n *NemoSpeech) loadASR(modelFile string) error {
"itn", n.opts.itnDir != "",
"diarization", n.opts.diarModel != "")
// #nosec G103 -- cfg is a local POD struct passed as a pointer for the
// duration of this call only; every uintptr member it carries is either a
// cstr allocation or a pinPtr address, all pinned above and released by the
// defers, and nemo_speech_asr_create deep-copies and retains nothing.
if st := ASRCreate(unsafe.Pointer(&cfg), &n.recognizer); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: asr create: %s", ASRLastError())
}
@@ -158,6 +166,10 @@ func recognizeF32(recognizer uintptr, opts *cASRRecognitionOptions, pcm []float3
}
var result uintptr
// #nosec G103 -- opts is the caller's live struct, borrowed for this call
// only; its LanguageCode is a cstr allocation the caller keeps pinned across
// it. &pcm[0] is guarded by the empty check above and the length handed over
// is exactly len(pcm), so the runtime cannot read past the slice.
if st := ASRRecognizeF32(recognizer, unsafe.Pointer(opts),
&pcm[0], uint64(len(pcm)), sampleRate, &result); st != 0 {
return 0, statusErrorf(st, "nemo-speech-cpp: recognize: %s", ASRLastError())
@@ -231,6 +243,9 @@ func wordsToSegments(words []asrWord, withWords bool) []*pb.TranscriptSegment {
texts = append(texts, w.Text)
}
seg := &pb.TranscriptSegment{
// #nosec G115 -- TranscriptSegment.Id is int32 on the wire, and segs
// holds one entry per speaker run over the words of a single decode
// result, which exhausts memory long before it reaches 2^31.
Id: int32(len(segs)),
Text: strings.Join(texts, " "),
Start: msToNanos(run[0].Start),

View File

@@ -141,6 +141,10 @@ func (n *NemoSpeech) openSession(language string) (asrSession, error) {
// them.
var handle uintptr
// #nosec G103 -- opts is a local POD struct borrowed for this call only, and
// its one uintptr member (LanguageCode) is the cstr allocation pinned by the
// deferred freeLang above. to_options copies the struct, so nothing here
// outlives the call.
if st := ASRStreamingRecognize(n.recognizer, unsafe.Pointer(&opts), &handle); st != 0 {
return nil, statusErrorf(st, "nemo-speech-cpp: streaming recognize: %s", ASRLastError())
}
@@ -244,6 +248,9 @@ func streamPCM(ctx context.Context, sess asrSession, pcm []float32, sampleRate i
segs = []*pb.TranscriptSegment{{Text: r.Text}}
}
for _, s := range segs {
// #nosec G115 -- TranscriptSegment.Id is int32 on the wire, and
// segments holds one entry per speaker run per finalized utterance of
// a single request, which exhausts memory long before it reaches 2^31.
s.Id = int32(len(segments))
segments = append(segments, s)
}

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"math"
"os"
"path/filepath"
"time"
@@ -278,6 +279,22 @@ var _ = Describe("sampleRateOf", func() {
Expect(err).To(HaveOccurred())
})
// The WAV header carries the sample rate as an unsigned 32-bit field, which
// go-audio widens to int. Anything above the int32 range therefore passes a
// "> 0" test and then narrows to a NEGATIVE rate, which the runtime would take
// as a resampling ratio rather than reject. The failure is silent, so the
// bound is asserted rather than left to the caller.
//
// Written as a conversion plus one rather than as the constant MaxInt32+1:
// the untyped form does not fit an int on a 32-bit build and would not
// compile there, while this wraps to a negative rate the same guard rejects.
It("rejects a rate that would not survive the narrowing to int32", func() {
_, err := sampleRateOf(&audio.IntBuffer{
Format: &audio.Format{SampleRate: int(math.MaxInt32) + 1, NumChannels: 1},
})
Expect(err).To(HaveOccurred())
})
It("returns the decoded rate", func() {
rate, err := sampleRateOf(&audio.IntBuffer{Format: &audio.Format{SampleRate: 22050, NumChannels: 1}})
Expect(err).ToNot(HaveOccurred())

View File

@@ -2,6 +2,7 @@ package main
import (
"errors"
"math"
"os"
"path/filepath"
@@ -33,6 +34,9 @@ func decodeAudioMono16k(path string) ([]float32, int32, error) {
return nil, 0, err
}
// #nosec G304 -- converted is filepath.Join of a directory this function just
// created with os.MkdirTemp and a constant basename. The request-controlled
// path is the INPUT to AudioToWav and never reaches this open.
fh, err := os.Open(converted)
if err != nil {
return nil, 0, err
@@ -64,8 +68,18 @@ func decodeAudioMono16k(path string) ([]float32, int32, error) {
// decoder could not read would not fail, it would silently pitch-shift the
// audio and quietly degrade the transcript, which is the same failure the
// caller comment warns about for a wrong rate.
//
// The upper bound is what makes the narrowing to int32 safe rather than merely
// unlikely. go-audio reads the WAV header's sample rate as an unsigned 32-bit
// field into an int, so on a 64-bit build a header claiming more than 2^31-1
// survives the "> 0" test and then narrows to a NEGATIVE rate, which the runtime
// would take as a resampling ratio. Nothing this backend decodes can reach that
// today (AudioToWav either passes through a WAV it has confirmed is exactly
// 16 kHz or runs ffmpeg with -ar 16000), but that is a property of a helper in
// another package, and this function exists precisely because the rate is read
// back rather than assumed.
func sampleRateOf(buf *audio.IntBuffer) (int32, error) {
if buf.Format == nil || buf.Format.SampleRate <= 0 {
if buf.Format == nil || buf.Format.SampleRate <= 0 || buf.Format.SampleRate > math.MaxInt32 {
return 0, errors.New("nemo-speech-cpp: decoded audio has no usable sample rate")
}
return int32(buf.Format.SampleRate), nil

View File

@@ -87,6 +87,10 @@ func (s *cDiarStream) cfgPtr() unsafe.Pointer {
if s.cfg == nil {
return nil
}
// #nosec G103 -- a plain *T to unsafe.Pointer conversion of a non-nil,
// GC-traced field. cDiarSegmentationConfig is pure scalars (no uintptr
// members to pin) and the stream owns it for its whole life, so the only
// requirement is that it outlive the DiarSegments call, which it does.
return unsafe.Pointer(s.cfg)
}
@@ -129,6 +133,10 @@ func (s *cDiarStream) fillSegments(buf []cDiarSegment) (uint64, error) {
"nemo-speech-cpp: diarization segment fill needs a buffer")
}
var count uint64
// #nosec G103 -- &buf[0] is guarded by the empty check above, and the
// capacity handed over is exactly len(buf), so the runtime cannot write past
// the caller's allocation. collectSegments sizes buf under maxDiarSegments
// and rejects a reported count larger than it rather than slicing to it.
st := DiarSegments(s.handle, s.cfgPtr(), unsafe.Pointer(&buf[0]), uint64(len(buf)), &count)
if st != 0 {
// count is returned alongside the error on purpose: a too-small buffer
@@ -187,6 +195,10 @@ func (n *NemoSpeech) loadDiarizer(modelFile string) error {
xlog.Info("nemo-speech-cpp: creating diarizer", "gpu", n.opts.gpu)
// #nosec G103 -- cfg is a local POD struct borrowed for this call only. Its
// only uintptr member is ModelPath, the cstr allocation pinned by the
// deferred freePath above (Preset is deliberately NULL), and
// nemo_speech_diar_create deep-copies the path and retains nothing.
if st := DiarCreate(unsafe.Pointer(&cfg), &n.diarizer); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: diarizer create: %s", ASRLastError())
}
@@ -379,6 +391,9 @@ func distinctSpeakers(segs []*pb.DiarizeSegment) int32 {
for _, s := range segs {
seen[s.GetSpeaker()] = struct{}{}
}
// #nosec G115 -- seen holds at most one entry per segment, and collectSegments
// refuses any count above maxDiarSegments (2^22), so this is orders of
// magnitude below the int32 the proto field is.
return int32(len(seen))
}

View File

@@ -98,6 +98,11 @@ func cstr(s string) (uintptr, func()) {
b := append([]byte(s), 0)
pin := new(runtime.Pinner)
pin.Pin(&b[0])
// #nosec G103 -- b is non-empty (s != "" above) and &b[0] is pinned on the
// previous line, so the address C receives cannot be collected or moved
// until the returned release runs. One-way by construction: the doc comment
// above forbids converting this uintptr back, which is what keeps checkptr
// (and therefore -race) out of it.
return uintptr(unsafe.Pointer(&b[0])), func() {
if pin == nil {
return

View File

@@ -178,6 +178,10 @@ func (n *NemoSpeech) loadNMT(modelFile string) error {
"source_language", n.opts.sourceLanguage,
"target_language", n.opts.targetLanguage)
// #nosec G103 -- cfg is a local POD struct borrowed for this call only. Its
// Backend and Model members are pinPtr addresses held by the pinner unpinned
// on return, Model.Path is the cstr allocation freed by the defer above, and
// nemo_speech_nmt_create deep-copies everything it reads.
if st := NMTCreate(unsafe.Pointer(&cfg), &n.nmt); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: nmt create: %s", NMTLastError())
}

View File

@@ -143,6 +143,10 @@ func ttsDeliverPCM(pcm unsafe.Pointer, nBytes uint64, userData uintptr) bool {
}
buf := make([]byte, nBytes)
// #nosec G103 -- pcm and nBytes are the C-owned buffer and its length from
// one callback invocation, both null/zero-checked above. The slice is read
// only, its length is the length the runtime declared for that buffer, and it
// is copied into Go memory here and never retained past this return.
copy(buf, unsafe.Slice((*byte)(pcm), nBytes))
return sink(buf)
}
@@ -200,6 +204,10 @@ func (s *cSynthesizer) synthesize(req *pb.TTSRequest, defaultLanguage string, si
// stats_out is NULL: nemo_speech_tts_synthesis_stats is 300-odd bytes of
// timing detail with nowhere to go on either RPC, and the C API documents
// NULL as the way to decline it.
// #nosec G103 -- opts is a local POD struct borrowed for this call only. Its
// two uintptr members (LanguageCode, VoiceName) are cstr allocations pinned
// by the defers above, and this entry point is synchronous, so it returns
// before those pins are released even though the callbacks run off-thread.
st := TTSSynthesizeText(s.handle, unsafe.Pointer(&opts), req.GetText(), ttsPCMCallback(), id, nil)
if st != 0 {
// An unknown voice_name arrives here as INVALID_ARGUMENT
@@ -394,6 +402,10 @@ func (n *NemoSpeech) loadTTS(modelFile string) error {
// load, where the operator can see it, rather than the first synthesis.
ttsPCMCallback()
// #nosec G103 -- cfg is a local POD struct borrowed for this call only. Model
// and Runtime are pinPtr addresses held by the pinner unpinned on return, the
// paths they carry are cstr allocations freed by the defers above, and
// nemo_speech_tts_create deep-copies every string it reads.
if st := TTSCreate(unsafe.Pointer(&cfg), &n.synth); st != 0 {
return statusErrorf(st, "nemo-speech-cpp: tts create: %s", TTSLastError())
}
@@ -453,6 +465,8 @@ func wavFile(pcm []byte, sampleRate uint32) ([]byte, error) {
}
var buf bytes.Buffer
// #nosec G115 -- len(pcm) is checked against maxWAVDataBytes (MaxUint32 minus
// the header) immediately above, so the narrowing to uint32 cannot wrap.
h := laudio.NewWAVHeaderWithRate(uint32(len(pcm)), sampleRate)
if err := h.Write(&buf); err != nil {
return nil, status.Errorf(codes.Internal, "nemo-speech-cpp: write WAV header: %v", err)