Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions pkg/github/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -83,15 +88,15 @@ 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
}

// 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) {
Expand All @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions pkg/github/params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/security_outputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/typed_read_normalizers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions pkg/github/typed_search_outputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions pkg/github/typed_security_outputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Loading