diff --git a/pkg/github/params.go b/pkg/github/params.go index 416020abb7..9e1b582714 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -60,6 +60,11 @@ func isAcceptedError(err error) bool { return errors.As(err, &acceptedError) } +func inSignedIntRange(f float64, bitSize int) bool { + limit := math.Ldexp(1, bitSize-1) + return f >= -limit && f < limit +} + // toInt converts a value to int, handling both float64 and string representations. // Some MCP clients send numeric values as strings. It rejects NaN, ±Inf, // fractional values, and values outside the int range. @@ -83,7 +88,7 @@ func toInt(val any) (int, error) { if f != math.Trunc(f) { return 0, fmt.Errorf("non-integer numeric value: %v", f) } - if f > math.MaxInt || f < math.MinInt { + if !inSignedIntRange(f, strconv.IntSize) { return 0, fmt.Errorf("numeric value out of int range: %v", f) } return int(f), nil @@ -91,7 +96,7 @@ func toInt(val any) (int, error) { // toInt64 converts a value to int64, handling both float64 and string representations. // Some MCP clients send numeric values as strings. It rejects NaN, ±Inf, -// fractional values, and values that lose precision in the float64→int64 conversion. +// fractional values, and values outside the int64 range. func toInt64(val any) (int64, error) { var f float64 switch v := val.(type) { @@ -112,12 +117,10 @@ func toInt64(val any) (int64, error) { if f != math.Trunc(f) { return 0, fmt.Errorf("non-integer numeric value: %v", f) } - result := int64(f) - // Check round-trip to detect precision loss for large int64 values - if float64(result) != f { + if !inSignedIntRange(f, 64) { return 0, fmt.Errorf("numeric value %v is too large to fit in int64", f) } - return result, nil + return int64(f), nil } // RequiredParam is a helper function that can be used to fetch a requested parameter from the request. diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go index 3c0d7f43d1..3527e910a6 100644 --- a/pkg/github/params_test.go +++ b/pkg/github/params_test.go @@ -184,6 +184,65 @@ func TestOptionalNullableStringParam(t *testing.T) { } } +func Test_inSignedIntRange(t *testing.T) { + tests := []struct { + name string + value float64 + bitSize int + want bool + }{ + {"int64 min", math.MinInt64, 64, true}, + {"largest float64 below 2^63", math.Nextafter(math.Ldexp(1, 63), 0), 64, true}, + {"2^63", math.Ldexp(1, 63), 64, false}, + {"MaxInt64 rounds up to 2^63", float64(math.MaxInt64), 64, false}, + {"below int64 min", math.Nextafter(math.MinInt64, math.Inf(-1)), 64, false}, + {"int32 max", math.MaxInt32, 32, true}, + {"2^31", math.MaxInt32 + 1, 32, false}, + {"int32 min", math.MinInt32, 32, true}, + {"below int32 min", math.MinInt32 - 1, 32, false}, + {"NaN", math.NaN(), 64, false}, + {"positive infinity", math.Inf(1), 64, false}, + {"negative infinity", math.Inf(-1), 64, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, inSignedIntRange(tc.value, tc.bitSize)) + }) + } +} + +func Test_RequiredBigInt(t *testing.T) { + tests := []struct { + name string + value any + expected int64 + expectedErr string + }{ + {"valid number", float64(42), 42, ""}, + {"valid string", "42", 42, ""}, + {"MinInt64 string", "-9223372036854775808", math.MinInt64, ""}, + {"largest float64 below 2^63", math.Nextafter(math.Ldexp(1, 63), 0), 9223372036854774784, ""}, + {"2^63 string", "9223372036854775808", 0, "too large to fit in int64"}, + {"MaxInt64 string rounds up to 2^63", "9223372036854775807", 0, "too large to fit in int64"}, + {"2^63 float64", math.Ldexp(1, 63), 0, "too large to fit in int64"}, + {"below MinInt64", "-9223372036854777856", 0, "too large to fit in int64"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := RequiredBigInt(map[string]any{"id": tc.value}, "id") + + if tc.expectedErr != "" { + assert.ErrorContains(t, err, tc.expectedErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } +} + func Test_RequiredInt(t *testing.T) { tests := []struct { name string @@ -376,6 +435,34 @@ func Test_OptionalIntParam(t *testing.T) { expected: 0, expectError: true, }, + { + name: "2^63 string parameter", + params: map[string]any{"count": "9223372036854775808"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "MaxInt64 string parameter rounds up to 2^63", + params: map[string]any{"count": "9223372036854775807"}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "2^63 float64 parameter", + params: map[string]any{"count": math.Ldexp(1, 63)}, + paramName: "count", + expected: 0, + expectError: true, + }, + { + name: "MinInt float64 parameter", + params: map[string]any{"count": float64(math.MinInt)}, + paramName: "count", + expected: math.MinInt, + expectError: false, + }, } for _, tc := range tests { diff --git a/pkg/github/security_outputs.go b/pkg/github/security_outputs.go index 88fb34f12b..b44bf42f8d 100644 --- a/pkg/github/security_outputs.go +++ b/pkg/github/security_outputs.go @@ -301,7 +301,7 @@ func normalizeSecurityIntegerArguments(fields ...string) inventory.InputNormaliz } parsed, err := strconv.ParseFloat(number, 64) if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || - parsed != math.Trunc(parsed) || parsed > float64(math.MaxInt) || parsed < float64(math.MinInt) { + parsed != math.Trunc(parsed) || !inSignedIntRange(parsed, strconv.IntSize) { return nil, fmt.Errorf("parameter %s is not a valid number", field) } arguments[field], err = json.Marshal(int(parsed)) diff --git a/pkg/github/typed_read_normalizers.go b/pkg/github/typed_read_normalizers.go index 1bec77b7ce..d53f3ad2f8 100644 --- a/pkg/github/typed_read_normalizers.go +++ b/pkg/github/typed_read_normalizers.go @@ -42,7 +42,7 @@ func normalizeTypedReadArguments(uppercaseFields []string, preserveZeroPage bool } if text, ok := number.(string); ok { parsed, err := strconv.ParseFloat(text, 64) - if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed != math.Trunc(parsed) || parsed > float64(math.MaxInt) || parsed < float64(math.MinInt) { + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed != math.Trunc(parsed) || !inSignedIntRange(parsed, strconv.IntSize) { return nil, fmt.Errorf("parameter %s is not a valid number", field) } number = int(parsed) diff --git a/pkg/github/typed_search_outputs_test.go b/pkg/github/typed_search_outputs_test.go index 93aab4ff99..b92d3c6a59 100644 --- a/pkg/github/typed_search_outputs_test.go +++ b/pkg/github/typed_search_outputs_test.go @@ -377,6 +377,20 @@ func TestNormalizeTypedReadArgumentsPreservesLegacyValues(t *testing.T) { assert.JSONEq(t, `{"state":"OPEN","orderBy":"UPDATED_AT","direction":"ASC","perPage":25,"page":null}`, string(normalized)) } +func TestNormalizeTypedReadArgumentsRejectsOutOfRangeIntegers(t *testing.T) { + normalize := normalizeTypedReadArguments(nil, false) + for _, input := range []string{ + `{"page":"9223372036854775808"}`, + `{"perPage":"9223372036854775807"}`, + `{"page":"-9223372036854777856"}`, + } { + t.Run(input, func(t *testing.T) { + _, err := normalize(json.RawMessage(input)) + require.ErrorContains(t, err, "is not a valid number") + }) + } +} + func mustMarshalJSON(t *testing.T, value any) string { t.Helper() encoded, err := json.Marshal(value) diff --git a/pkg/github/typed_security_outputs_test.go b/pkg/github/typed_security_outputs_test.go index e793fcbbd2..edd28fe359 100644 --- a/pkg/github/typed_security_outputs_test.go +++ b/pkg/github/typed_security_outputs_test.go @@ -390,3 +390,22 @@ func TestSecurityOutputDTOs(t *testing.T) { check(typ) } } + +func TestNormalizeSecurityIntegerArgumentsRejectsOutOfRangeIntegers(t *testing.T) { + normalize := normalizeSecurityIntegerArguments("alertNumber") + for _, input := range []string{ + `{"alertNumber":9223372036854775807}`, + `{"alertNumber":"9223372036854775807"}`, + `{"alertNumber":"9223372036854775808"}`, + `{"alertNumber":"-9223372036854777856"}`, + } { + t.Run(input, func(t *testing.T) { + _, err := normalize(json.RawMessage(input)) + require.EqualError(t, err, "parameter alertNumber is not a valid number") + }) + } + + normalized, err := normalize(json.RawMessage(`{"alertNumber":"2147483647"}`)) + require.NoError(t, err) + assert.JSONEq(t, `{"alertNumber":2147483647}`, string(normalized)) +}