Skip to content

Security

How to report a vulnerability, what the library guarantees, and the audit log.

Mirrored from SECURITY.md at v0.27.0.

Reporting

Report a vulnerability privately through GitHub’s Report a vulnerability button on the repository (Security → Advisories). Please do not open a public issue for it.

What the library guarantees

go-ndarray is pure Go (CGO_ENABLED=0) except for its SIMD kernels, which are Plan 9 assembly generated by go-asmgen for amd64, arm64, ppc64le, loong64, riscv64 and s390x. Those kernels are the only code Go’s bounds checks do not cover, so they are held to the following:

  • Every wrapper checks the reach of its kernel. Before an assembly call, the Go wrapper indexes the last element of every operand the kernel reads or writes (internal/kernels/{reduce,gemm}_<arch>.go). A caller’s mistake is an index-out-of-range panic in Go, never an access outside a slice. TestWrappersRefuseShortOperands passes each wrapper an operand one element short and requires the panic.

  • The kernels stay inside their slices. TestKernelsStayInsideTheirSlices and TestGemmMicroStaysInsideItsOperands (internal/kernels/fence_test.go, Linux and macOS) map each operand flush against a PROT_NONE page, once ending at it and once starting after one, for every length from 1 to 70 and every GEMM depth from 1 to 9. A read or write one element past either end is a fault. The fence itself was checked by a kernel deliberately reading one element too far, which faults on amd64 (4 KiB pages), arm64 (16 KiB), loong64 (16 KiB) and ppc64le (64 KiB). Prefetch instructions may point past the end: they are hints and do not fault.

  • Shapes are validated, results included. A shape whose element count, or whose size in bytes, does not fit in an int is an error wrapping ErrShapeMismatch, even when a zero dimension makes it empty, as in NumPy. This applies to the shapes operations compute: MatMul, Dot, Inner, Concatenate, Stack, broadcasting and Linspace. Outer has no error result and panics instead.

  • Empty arrays cost nothing. Operations on an empty array with a huge axis, such as (2^58, 0), return at once instead of walking that axis. TestEmptyArraysWithHugeAxesReturnPromptly covers concatenation, the axis reductions and the scans.

  • Memory is the caller’s to bound. Validation guarantees a shape is representable, not that it fits in memory, and a Go program that runs out of memory dies (NumPy raises MemoryError). Broadcasting, Outer, MatMul and summing the empty axis of (0, n) produce more than their operands hold; with untrusted shapes, check the result’s size first.

  • Fuzzed against an oracle. FuzzOps (fuzz_test.go) drives slices, reshapes, transposes, squeezes, broadcasting arithmetic, axis reductions, Take, MaskSelect, concatenation, MatMul and scans from arbitrary bytes, and compares every result element by element with an oracle written in the test from NumPy’s definitions (Python’s slice.indices, the broadcasting rule, …). The library must reject exactly the calls the oracle rejects and never panic. FuzzMixedDTypes (dtype_fuzz_test.go) does the same for the binary operations on operands of two arbitrary dtypes: NumPy’s own promotion table gives the result dtype, and each element is recomputed by casting both operands to it and using Go’s arithmetic of that type, which wraps integers as NumPy does.

  • Decoding takes bytes from anywhere. UnmarshalBinary refuses, with an error wrapping ErrFormat, any input whose shape the bytes that follow do not account for exactly, before it allocates; an array it accepts holds no more than it was given. FuzzUnmarshalBinary (marshal_fuzz_test.go) requires that, that it never panics, and that re-encoding is a fixed point.

The minimum Go version (go in go.mod) is kept on a release that fixes the standard library’s known vulnerabilities; govulncheck ./... reports none.

Audit log

2026-10-27 (v0.27.0). The unpacked-A GEMM kernels (gemmUA* on amd64 and arm64) read A in place: mr rows lda elements apart, kc elements each. Their Go wrapper bounds-checks the whole reach, a[(mr-1)*lda+kc-1], the packed B panel and the C tile before the call. gemmBand takes this path only for a unit column stride and a positive row stride, so a reversed or broadcast view is still packed. The kernels are fenced for every tile height and two row strides. A sabotaged kernel on each architecture failed CI.

2026-10-26 (v0.26.0). The float32 GEMM kernels, gemmMicro8x8S (arm64) and gemmMicro6x16FMA (amd64, behind the FMA probe), read kc·MR and kc·NR packed floats and read and write one MR×NR tile of C. Their Go wrapper bounds-checks all three reaches before the call. They are fenced (PROT_NONE on both sides of each operand) and tested against a float32 oracle. A sabotaged kernel failed every CI job that runs it. The edge tile now lives in the worker’s pooled A buffer: its capacity adds MR·NR elements, and gemmBand slices the tile from the end. The operand views are the same Off/RS/CS triples the float64 GEMM reads, built from the array’s own strides. A non-Float32 operand is converted to a fresh contiguous copy first. Shapes are validated (overflow included) before any allocation.

2026-10-26 (v0.25.0). The loong64 extremum kernels (maxLSX, minLSX, max32LSX, min32LSX) read the whole blocks of a behind the useLASX gate. They write one 16-byte vector to an out array on the wrapper’s stack, and read two 16-byte read-only constants. A test gap was found and closed: no extremum test used a negative NaN, so a kernel whose NaN test failed could pass, as a sabotaged one did. Both signs are tested now. The sabotage then failed CI’s loong64 lane, and every other kernel passed.

2026-10-26 (v0.24.0). The ppc64le extremum kernels (maxVSX, minVSX, max32VSX, min32VSX) read the whole blocks of a (len(a) &^ 7 or &^ 15, never 0, since the wrapper takes the scalar path below a block). They write one 16-byte vector to an out array on the wrapper’s stack, and read two 16-byte read-only constants. They are fenced and tested against the sequential oracle on a POWER9, where a sabotaged kernel (one NaN-mask OR removed) fails.

2026-10-26 (v0.23.0). The s390x float32 extremum kernels (max32VXE, min32VXE) read len(a) elements of a slice of at least 16, behind the useVXE gate. On a real IBM Z the PROT_NONE fence and the sequential-oracle test pass, and a sabotaged kernel fails the latter. The CI s390x lane runs VX and VXE (NDARRAY_REQUIRE_VXE) since #68.

2026-10-26 (v0.22.0).

  • Correctness defect. On riscv64 with V, MeanAxis(1) of the int64 row [MaxInt64, MinInt64, -3] was 0, NumPy’s -1. The strip-mined float64 sum took even 3 elements as one vector, and its unordered reduction cancelled MaxInt64 + (-3) against MinInt64. Below 8 elements the sum is now sequential, as on the other targets. Found by running the root suite on a real SpacemiT X60.
  • CI gap behind it. The riscv64 qemu lane ran on qemu’s default CPU, which has no V, so the RVV kernels had never run in CI. It now enables V and sets NDARRAY_REQUIRE_RVV, so a CPU without V fails the lane (TestRVVWhenRequired).
  • New kernels. The riscv64 extremum kernels (maxRVV, minRVV, max32RVV, min32RVV) read len(a) elements of a non-empty slice, behind the useRVV gate. They are fenced and tested against the sequential oracle on the X60. A sabotaged one, which drops its NaN count, fails that test.

2026-10-26 (v0.20.0). The float32 extremum kernels (max32SSE2, min32SSE2, max32NEON, min32NEON) read len(a) elements from a non-empty slice. The Go wrappers pass len(a) and never an empty slice (Max/Min refuse an empty array before), the NEON ones only from 16 up. TestBin32StaysInsideItsOperands now fences them too: it passes on Zen 3, Neoverse N1 and Apple M. They are tested against one sequential pass of Go’s max/min, with NaN, real signed zeros and infinities at every position, and a sabotaged kernel fails that test on both architectures.

2026-10-25 (v0.19.1). A correctness defect, found while reading the float64 extremum kernels before writing float32 ones. On amd64, Max, Min, MaxAxis and MinAxis over a mix of signed zeros returned the sign of whichever zero the kernel met last: [+0, -0] gave Max = -0 and [-0, +0] gave Min = +0. MAXPD/MINPD return their source operand on a zero tie. Every other architecture, and the scalar oracle, give Max = +0 and Min = -0, as Go’s max/min order them.

The tests had meant to cover this. They wrote -0.0, which Go’s constant arithmetic makes +0, so no negative zero was ever tested: seven cases in two files. They now use math.Copysign(0, -1), guarded by a test of the helper; corrected, they failed on a Zen 3.

The fix leaves the kernel alone. A zero result is the only one whose sign can be wrong, and it needs every element <= 0 with a zero among them. The wrapper then asks the scalar oracle. Combining both operand orders inside the kernel was also correct, but cost 53-60% on a Zen 3; the wrapper check costs nothing measurable, +3.5% (6 ns) at n=1024 and ~ elsewhere. TestExtremaOfSignedZeros checks the public API; it fails on v0.19.0 (amd64) and passes now.

2026-10-20 (v0.19.0). kernels.Sqrt32 follows Bin32’s guards: it reslices src to len(dst) before the call (a short src panics in Go, tested), never passes an empty slice to a kernel, and keeps the LASX, V and VXE gates. The PROT_NONE fence now also covers it, and passes on Zen 3, Apple M, POWER9, IBM Z, SpacemiT X60 and Loongson 3C5000L.

2026-10-18 (v0.18.0). The float32 elementwise kernels (kernels.Bin32, on all six 64-bit targets) take raw pointers and a count. Bin32 reslices a and b to len(dst) before the call, so a short operand panics in Go rather than being read past its end, and an empty dst never reaches a kernel. TestBin32StaysInsideItsOperands puts dst, a and b against PROT_NONE pages at every length from 1 to 40, and passes on real hardware for each kernel: Zen 3, Apple M, POWER9, IBM Z, SpacemiT X60 and Loongson 3C5000L. A sabotaged kernel failed the bit-identity test. The *Into direct path keeps the contiguity and shape checks of the copying one, and computes in place, as the float64 path does. A partial overlap between out and an operand cannot reach it. out must be contiguous, which a view with an offset (x[1:], even x[:39]) is not, so it is refused. An operand sharing out’s storage with out’s shape is then out itself, the exact alias, which a lane-wise kernel handles. Any other view of it is gathered into a copy first. A probe of x[1:] = x[:-1] + 1 and x[:39] = x[1:40] + 1 got the contiguity error in both directions, for float64 and float32.

2026-10-18 (v0.16.0). The float32 pairwise-sum block kernels (SSE2, NEON, RVV, LSX since v0.14.0/v0.15.0, VSX now) take raw pointers and a block count. AddBlocks8F32 refuses a length that is not a positive multiple of 8, and each wrapper touches the last element before the call, so a kernel never sees a count its operand cannot hold. TestBlocks8StaysInsideItsOperands puts the accumulators and the input against PROT_NONE pages, and it passes on real hardware for each kernel: Zen 3, Neoverse N1, SpacemiT X60, Loongson 3C5000L and POWER9. The ppc64le kernel’s xvaddsp is a WORD whose encoding go-asmgen pins against GNU as and llvm-mc. A sabotaged one (xvsubsp) failed the bit-identity test on the POWER9.

2026-10-18 (v0.17.0). The s390x block kernel (blocks8VXE) follows the same guards: AddBlocks8F32’s length check, the wrapper’s touch of the last element, and the useVXE gate, so a CPU without vector-enhancements facility 1 never reaches vfasb. On an IBM Z (LinuxONE) the PROT_NONE fence test and the whole suite pass, and a sabotaged kernel (vfssb) failed the bit-identity test. Its vfasb is WORD+BYTE+BYTE, pinned in go-asmgen against GNU as and llvm-mc and executed bit-exact on that machine.

2026-10-17 (after v0.12.0, tests only). v0.10.0 added thirteen dtypes, and FuzzOps builds only float64 arrays; values.json checks every operation on operands of one dtype, so the values of a mixed-dtype operation (int8 + uint64, bool * float32, …) were checked by nothing. FuzzMixedDTypes fuzzes Add, Sub, Mul, Maximum, Minimum and Equal over every dtype pair, shapes broadcastable or not, and transposed views, with edge values (integer extremes, -0, NaN, the infinities, complex combinations). It requires no panic, an error exactly where the oracle refuses (shapes that do not broadcast, and bool - bool, which NumPy refuses), NumPy’s dtype, and every element. 35 million inputs, no failure. A conversion made to turn true into 2 for int16 failed it in under a second (bool + int16), so it sees the defect it guards against. Complex Maximum and Minimum are checked for dtype and shape only: NumPy orders complex numbers lexicographically with its own NaN rules.

2026-10-11 (after v0.11.0, tests only). v0.10.0 added UnmarshalBinary, whose input may come from a file, a notebook or the network; no entry covered it. It checks the magic and the dtype, bounds the rank at 64 and each dimension at maxSize, validates the shape, and compares the data length with the element count by dividing, not multiplying, so a complex128 shape cannot overflow the comparison. Only then does it allocate, so the allocation is the size of the input. FuzzUnmarshalBinary holds it to that, never panicking and re-encoding to a fixed point. It ran 11 million inputs on an M-series Mac with no failure. A copy that accepted fewer bytes than the shape needs failed it in 0.09 s (6 bytes decoded as 48 bools), so the fuzzer can see the defect it guards against. govulncheck ./... at go1.27.2 with go-fft v0.23.0 reports none.

2026-10-09 (v0.9.1). New s390x maxVXE/minVXE kernels, gated on AT_HWCAP VXRS_EXT (z14 and later), each behind its wrapper: from eight elements up, with no pointer arithmetic beyond len(a). On the real z17 they pass TestMaxMinSIMD, the specials-in-every-lane test, the fence tests and the parallel stress test. A copy using mode 0 (IEEE maxNum, which returns the number when the other operand is a NaN) fails there, so the NaN rule is checked, not assumed.

2026-10-09 (v0.9.0).

  • The Go floor is 1.27.2. At 1.27.1, govulncheck listed standard-library vulnerabilities fixed in 1.27.2 (GO-2026-6604, -6612, -6613, -6617, among others), none of them reachable from this library. At 1.27.2 it reports none, for the library or for its generator module.
  • The loong64 miscompile is not fixed in 1.27.2. An earlier entry said golang/go#81000 was fixed in 1.27.2; it was not. The backport (golang/go#81147) is on the 1.27.3 milestone. On real hardware (cfarm401, Loongson 3C5000L), the upstream reproducer (go-gfx/gfx ./color, TestSkimageLabToRGBOutOfGamut) FAILS when built with go1.27.1 and with go1.27.2 alike. The evidence that this library does not hit the pattern stands as before: the suite and FuzzOps on that machine. Raise the floor to 1.27.3 when it ships.
  • The s390x kernels now run on real hardware: an IBM z17, through the LinuxONE Community Cloud (/proc/sysinfo: Type 9175, a z/VM 7.3.0 guest with 8 vCPUs in a shared LPAR). The whole suite and the fence tests pass, and sumVX reading one element past the end faults (4 KiB pages). The new 4×8 GEMM micro-kernel is held to the same checks: it is guarded by its wrapper and fenced, and a sabotaged copy that drops tile row 3 fails (MatMul m=50 k=70 n=90 [270]: 0 != -249).

2026-10-08 (v0.8.0). New s390x vector-facility kernels, held to the same guards (wrappers check each kernel’s reach; the PROT_NONE fence tests now include s390x). ⚠ They were validated under emulation only (Docker’s qemu-s390x locally, qemu-s390x in CI): the whole suite and the fence tests pass, and two sabotaged kernels (a subtraction with swapped operands, a dot that does not advance b) both failed. No s390x host was reachable. Found on the way: hwcapFrom read /proc/self/auxv as little-endian; on big-endian s390x that would have read the wrong word, so it now uses the machine’s byte order, and useVX comes out true under emulation as it should.

2026-10-08 (v0.7.0). New riscv64 RVV kernels. They are held to the same guards: each wrapper checks the reach of its kernel (TestWrappersRefuseShortOperands), and the PROT_NONE fence tests pass on a real SpacemiT X60 with V (cfarm95), as does the whole suite. On a riscv64 CPU without V (cfarm94, SiFive U74) the same build takes the scalar path, since V is gated on AT_HWCAP, and passes too. As a positive control for the fence on this hardware, sumRVV reading one element past the end faults (4 KiB pages). The tests were checked against two sabotaged kernels, a subtraction with swapped operands and a dot that does not advance b; both failed on the real hardware.

2026-10-07 (after v0.6.1, tests only). TestParallelStress runs the parallel kernels from 6 goroutines at once while another keeps changing GOMAXPROCS, with the thresholds lowered so that tiny arrays go parallel. The pool is contended, and some calls take the fork fallback. Every result is compared with the serial kernel on integer data, so the comparison is exact. A lost block hangs the call rather than corrupting it, so the test also fails, by name, when a call does not return in time. Both kinds of sabotage were caught: a pool that skips the first block of each run (hang) and a fork fallback that drops the last element (wrong value). It passes under -race and on real POWER9 (59,154 calls in 5 runs), X-Gene arm64 (65,798) and Loongson 3C5000L (36,174).

2026-10-07 (v0.6.1). An audit of the helper pool added in v0.6.0 found two defects. Both are fixed, each with a test that fails without the fix.

  • Retention. The pool kept the last job, whose closure holds the operation’s arrays, so the arrays of the last parallel operation could not be collected until another one replaced it: up to the size of the largest array, for as long as the program ran. TestPoolDoesNotRetainLastJob checks the data is collectable once parallelFor returns. A first version of that test passed on the defective code, because its closure captured a variable the test then set to nil.
  • Writes after a recovered panic. Since v0.6.0 the caller runs blocks itself. If one panicked there, for example a Map function, the panic propagated while the helpers went on writing. A caller that recovered could find its arrays changing under it. The caller now stops handing out blocks and waits for the running ones before the panic propagates (TestPoolCallerPanicStopsTheHelpers). A panic on a helper goroutine still ends the program, as it did before v0.6.0; Map’s documentation says so.

2026-10-06 (v0.5.1).

  • The Go floor is 1.27.1 since v0.5.0. govulncheck reports no vulnerability for the library or for its generator module.
  • go1.27.0 and go1.27.1 miscompile one loong64 pattern (golang/go#81000: a byte-sized inlined result spilled with an 8-byte store, clobbering the stack slot next to it), fixed in 1.27.2 — wrong, corrected 2026-10-09: the backport (golang/go#81147) moved to the 1.27.3 milestone, and go1.27.2 still fails the upstream reproducer on cfarm401 (see the v0.9.0 entry). On a real Loongson 3C5000L with LASX (cfarm401), built with go1.27.1, the whole suite passes, including the fence tests, and FuzzOps ran 52.7 million executions natively without a divergence from its oracle. That is evidence that this library does not hit the pattern, not proof. Raise the floor to 1.27.2 once it is released.
  • go-asmgen v0.16.0 regenerates the ppc64le and loong64 kernels with different comments only. The instructions, and the WORD encodings Go’s assembler still lacks, are unchanged line for line.

2026-10-04 (v0.4.0).

  • FuzzOps ran 47 million executions without a divergence. A first version that only checked an array against its own Copy was blind: with Squeeze deliberately given wrong strides it found nothing in 31 million executions, because a wrong view reads the same wrong values both ways. The oracle version finds the same sabotage in 11 seconds; its failing input is kept in testdata/fuzz/FuzzOps.
  • It found two places where the library and NumPy differ. Slice with fewer indices than axes was an error; it now takes the rest whole, as NumPy does. MaskSelect accepts a mask that broadcasts to the array, which NumPy rejects; this is kept as a documented extension.
  • SumAxis/ProdAxis along a zero-length axis now return 0s/1s as NumPy does (they were errors, unlike Sum/Prod of an empty array). This makes the memory caveat above concrete, and it is documented there.

2026-10-04 (v0.3.0).

  • govulncheck found no reachable vulnerability. At go 1.26.4, 10 standard library vulnerabilities were in modules the library requires but did not call; go 1.26.6 fixes all of them.
  • Found and fixed:
    • Concatenate summed the axis lengths without an overflow check, so 17 arrays of shape (2^59, 0) gave a negative dimension.
    • MatMul of (2^32, 0) by (0, 2^32) returned a (2^32, 2^32) array whose Size() was 0. A product wrapping to −1 panicked in makeslice.
    • Linspace with a count above the limit panicked in makeslice.
    • Stack of 9 arrays (2^58, 0) returned (9, 2^58, 0), which New rejects.
    • Reducing (2^58, 0) over axis 0, concatenating it along its last axis, or scanning (0, 2^58) walked the 2^58-long axis: effectively a hang.
  • Hardened: the operand checks in front of every assembly call (above). The fence test found no out-of-bounds access in any kernel, on real amd64 (Zen 3), arm64, ppc64le (POWER9) and loong64 (3C5000L, with LASX).
  • Cost: none measurable. A single-core A/B on Zen 3 (10 interleaved rounds) measured MatMul 256², Dot1D and AddInto 16 Ki within ±2%, inside the run-to-run spread.

Created on by tannevaled

Modified on by tannevaled