Skip to content

Commit c52441a

Browse files
authored
fix: gojq module review — permissions, collision, rename, upgrade (#3451)
Addresses findings from the Go Fan gojq module review. ### Security - **Payload file permissions `0644` → `0600`** — payloads may contain sensitive API responses; no reason for world-readable ### Bug fix - **`generateRandomID` fallback collision** — when `crypto/rand.Read` fails, the fallback was `fmt.Sprintf("fallback-%d", os.Getpid())` which is identical for every call in the same process. Added `time.Now().UnixNano()`: ```go return fmt.Sprintf("fallback-%d-%d", os.Getpid(), time.Now().UnixNano()) ``` ### Maintenance - **Rename `def walk(f)` → `def walk_schema`** — avoids shadowing gojq's built-in `walk/1`. Now a zero-arity recursive def since the `f` parameter was never meaningfully used (always called as `walk(.)`) - **Upgrade `github.com/itchyny/gojq` v0.12.18 → v0.12.19** ### Tests - **`TestApplyJqSchema_SingleOutputContract`** — asserts the jq filter iterator yields exactly one value across multiple input shapes, documenting the invariant - Updated `TestPayloadStorage_FilePermissions` expectation to `0600` > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `example.com` > - Triggering command: `/tmp/go-build511967270/b514/launcher.test /tmp/go-build511967270/b514/launcher.test -test.testlogfile=/tmp/go-build511967270/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build511967270/b211/vet.cfg 6718451/b186/_pkg_.a ache/go/1.25.8/x64/src/vendor/golang.org/x/text/-Wl,--no-gc-sections x_amd64/vet . --gdwarf2 --64 x_amd64/vet` (dns block) > - `invalid-host-that-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build511967270/b496/config.test /tmp/go-build511967270/b496/config.test -test.testlogfile=/tmp/go-build511967270/b496/testlog.txt -test.paniconexit0 -test.timeout=10m0s -o ache/go/1.25.8/x64/src/net -trimpath x_amd64/vet -p net/url -lang=go1.25 x_amd64/vet` (dns block) > - `nonexistent.local` > - Triggering command: `/tmp/go-build511967270/b514/launcher.test /tmp/go-build511967270/b514/launcher.test -test.testlogfile=/tmp/go-build511967270/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build511967270/b211/vet.cfg 6718451/b186/_pkg_.a ache/go/1.25.8/x64/src/vendor/golang.org/x/text/-Wl,--no-gc-sections x_amd64/vet . --gdwarf2 --64 x_amd64/vet` (dns block) > - `slow.example.com` > - Triggering command: `/tmp/go-build511967270/b514/launcher.test /tmp/go-build511967270/b514/launcher.test -test.testlogfile=/tmp/go-build511967270/b514/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build511967270/b211/vet.cfg 6718451/b186/_pkg_.a ache/go/1.25.8/x64/src/vendor/golang.org/x/text/-Wl,--no-gc-sections x_amd64/vet . --gdwarf2 --64 x_amd64/vet` (dns block) > - `this-host-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build511967270/b523/mcp.test /tmp/go-build511967270/b523/mcp.test -test.testlogfile=/tmp/go-build511967270/b523/testlog.txt -test.paniconexit0 -test.timeout=10m0s -plu�� g_.a -plugin-opt=/usrgithub.qkg1.top/github/gh-aw-mcpg/internal/launcher x_amd64/vet -plugin-opt=-pasbash rs/otlp/otlptrac/usr/bin/runc -plugin-opt=-pas--version x_amd64/vet -uns�� .cfg elemetry.io/otel-nolocalimports x_amd64/vet -m elf_x86_64 --hash-style=gnu--version x_amd64/vet` (dns block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/github/gh-aw-mcpg/settings/copilot/coding_agent) (admins only) > > </details>
2 parents 7209a3b + dc33e07 commit c52441a

File tree

5 files changed

+66
-27
lines changed

5 files changed

+66
-27
lines changed

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ require (
1010
)
1111

1212
require (
13-
github.com/itchyny/gojq v0.12.18
13+
github.com/itchyny/gojq v0.12.19
1414
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
1515
github.com/stretchr/testify v1.11.1
1616
github.com/tetratelabs/wazero v1.11.0
@@ -30,7 +30,7 @@ require (
3030
github.com/google/uuid v1.6.0 // indirect
3131
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
3232
github.com/inconshreveable/mousetrap v1.1.0 // indirect
33-
github.com/itchyny/timefmt-go v0.1.7 // indirect
33+
github.com/itchyny/timefmt-go v0.1.8 // indirect
3434
github.com/pmezard/go-difflib v1.0.0 // indirect
3535
github.com/segmentio/asm v1.1.3 // indirect
3636
github.com/segmentio/encoding v0.5.4 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz
2626
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
2727
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
2828
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
29-
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
30-
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
31-
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
32-
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
29+
github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs=
30+
github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY=
31+
github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI=
32+
github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
3333
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
3434
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
3535
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

internal/middleware/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,19 +96,19 @@ with open(payload_path) as f:
9696
The middleware uses the same jq filter logic as the gh-aw jqschema utility:
9797

9898
```jq
99-
def walk(f):
99+
def walk_schema:
100100
. as $in |
101101
if type == "object" then
102-
reduce keys[] as $k ({}; . + {($k): ($in[$k] | walk(f))})
102+
reduce keys[] as $k ({}; . + {($k): ($in[$k] | walk_schema)})
103103
elif type == "array" then
104-
if length == 0 then [] else [.[0] | walk(f)] end
104+
if length == 0 then [] else [.[0] | walk_schema] end
105105
else
106106
type
107107
end;
108-
walk(.)
108+
walk_schema
109109
```
110110

111-
This recursively walks the JSON structure and replaces values with their type names.
111+
This recursively walks the JSON structure and replaces values with their type names. The function is named `walk_schema` to avoid shadowing gojq's built-in `walk/1`.
112112

113113
### Go Implementation
114114

internal/middleware/jqschema.go

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ type PayloadMetadata struct {
4242
}
4343

4444
// jqSchemaFilter is the jq filter that transforms JSON to schema
45-
// This filter leverages gojq v0.12.18 features including:
45+
// This filter leverages gojq v0.12.19 features including:
4646
// - Enhanced array handling (supports up to 536,870,912 elements / 2^29)
4747
// - Improved concurrent execution performance
4848
// - Better error messages for type errors
@@ -55,25 +55,26 @@ type PayloadMetadata struct {
5555
// For arrays, only the first element's schema is retained to represent the array structure.
5656
// Empty arrays are preserved as [].
5757
//
58-
// NOTE: This defines a custom walk function rather than using gojq's built-in walk(f).
58+
// NOTE: This defines a custom walk_schema function rather than using gojq's built-in walk(f).
5959
// The built-in walk(f) applies f to every node but preserves the original structure.
60-
// Our custom walk does two things the built-in cannot:
60+
// Our custom walk_schema does two things the built-in cannot:
6161
// 1. Replaces leaf values with their type name (e.g., "test" → "string")
6262
// 2. Collapses arrays to only the first element for schema inference
6363
//
6464
// These behaviors are incompatible with standard walk(f) semantics, which would
6565
// apply f post-recursion without structural changes to arrays.
66+
// Using a distinct name avoids shadowing gojq's built-in walk/1.
6667
const jqSchemaFilter = `
67-
def walk(f):
68+
def walk_schema:
6869
. as $in |
6970
if type == "object" then
70-
reduce keys[] as $k ({}; . + {($k): ($in[$k] | walk(f))})
71+
reduce keys[] as $k ({}; . + {($k): ($in[$k] | walk_schema)})
7172
elif type == "array" then
72-
if length == 0 then [] else [.[0] | walk(f)] end
73+
if length == 0 then [] else [.[0] | walk_schema] end
7374
else
7475
type
7576
end;
76-
walk(.)
77+
walk_schema
7778
`
7879

7980
// Pre-compiled jq query code for performance
@@ -107,7 +108,7 @@ func init() {
107108
return
108109
}
109110

110-
logMiddleware.Printf("Successfully compiled jq schema filter at init (gojq v0.12.18)")
111+
logMiddleware.Printf("Successfully compiled jq schema filter at init")
111112
logger.LogInfo("startup", "jq schema filter compiled successfully - array limit: 2^29 elements, timeout: %v", DefaultJqTimeout)
112113
}
113114

@@ -135,7 +136,7 @@ func generateRandomID() string {
135136
// Error handling:
136137
// - Returns compilation errors if init() failed
137138
// - Returns context.DeadlineExceeded if query times out
138-
// - Returns enhanced error messages for type errors (gojq v0.12.18+)
139+
// - Returns enhanced error messages for type errors (gojq v0.12.19+)
139140
// - Properly handles gojq.HaltError for clean halt conditions
140141
func applyJqSchema(ctx context.Context, jsonData interface{}) (interface{}, error) {
141142
// Check if compilation succeeded at init time
@@ -152,7 +153,7 @@ func applyJqSchema(ctx context.Context, jsonData interface{}) (interface{}, erro
152153
}
153154

154155
// Run the pre-compiled query with context support (much faster than Parse+Run)
155-
// The iterator is consumed only once because the walk(.) filter produces exactly
156+
// The iterator is consumed only once because the walk_schema filter produces exactly
156157
// one output value (the fully-transformed schema). There is no need to drain it.
157158
iter := jqSchemaCode.RunWithContext(ctx, jsonData)
158159
v, ok := iter.Next()
@@ -177,7 +178,7 @@ func applyJqSchema(ctx context.Context, jsonData interface{}) (interface{}, erro
177178
return nil, fmt.Errorf("jq schema filter halted with error (exit code %d): %w", haltErr.ExitCode(), err)
178179
}
179180

180-
// Generic error case (includes enhanced v0.12.18+ type error messages)
181+
// Generic error case (includes enhanced v0.12.19+ type error messages)
181182
return nil, fmt.Errorf("jq schema filter error: %w", err)
182183
}
183184

@@ -209,19 +210,27 @@ func savePayload(baseDir, pathPrefix, sessionID, queryID string, payload []byte)
209210
logger.LogInfo("payload", "Writing large payload to filesystem: path=%s, size=%d bytes (%.2f KB, %.2f MB)",
210211
filePath, payloadSize, float64(payloadSize)/1024, float64(payloadSize)/(1024*1024))
211212

212-
if err := os.WriteFile(filePath, payload, 0644); err != nil {
213+
if err := os.WriteFile(filePath, payload, 0600); err != nil {
213214
logger.LogError("payload", "Failed to write payload file: path=%s, size=%d bytes, error=%v",
214215
filePath, payloadSize, err)
215216
return "", fmt.Errorf("failed to write payload file: %w", err)
216217
}
217218

218-
logger.LogInfo("payload", "Successfully saved large payload to filesystem: path=%s, size=%d bytes, permissions=0644",
219-
filePath, payloadSize)
219+
// Enforce permissions even if the file already existed (WriteFile only sets mode on create)
220+
if err := os.Chmod(filePath, 0600); err != nil {
221+
logger.LogError("payload", "Failed to enforce payload file permissions: path=%s, size=%d bytes, error=%v",
222+
filePath, payloadSize, err)
223+
return "", fmt.Errorf("failed to set payload file permissions: %w", err)
224+
}
220225

221-
// Verify file was written correctly
226+
// Verify file was written correctly and log actual resulting mode
222227
if stat, err := os.Stat(filePath); err != nil {
223228
logger.LogWarn("payload", "Could not verify payload file after write: path=%s, error=%v", filePath, err)
229+
logger.LogInfo("payload", "Successfully saved large payload to filesystem: path=%s, size=%d bytes",
230+
filePath, payloadSize)
224231
} else {
232+
logger.LogInfo("payload", "Successfully saved large payload to filesystem: path=%s, size=%d bytes, permissions=%#o",
233+
filePath, payloadSize, stat.Mode().Perm())
225234
logger.LogDebug("payload", "Payload file verified: path=%s, size=%d bytes, mode=%s",
226235
filePath, stat.Size(), stat.Mode())
227236
}

internal/middleware/jqschema_test.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,36 @@ func TestApplyJqSchema(t *testing.T) {
108108
}
109109
}
110110

111+
// TestApplyJqSchema_SingleOutputContract verifies that the walk_schema filter produces
112+
// exactly one output value. This documents the invariant that the iterator yields a single
113+
// result, catching any future filter changes that accidentally produce multiple outputs.
114+
func TestApplyJqSchema_SingleOutputContract(t *testing.T) {
115+
require := require.New(t)
116+
117+
require.Nil(jqSchemaCompileErr, "jq schema filter must compile without error")
118+
require.NotNil(jqSchemaCode, "jq schema compiled code must not be nil")
119+
120+
inputs := []interface{}{
121+
map[string]interface{}{"name": "test", "count": 42},
122+
[]interface{}{map[string]interface{}{"id": 1}},
123+
map[string]interface{}{"nested": map[string]interface{}{"a": []interface{}{1, 2, 3}}},
124+
}
125+
126+
for _, input := range inputs {
127+
iter := jqSchemaCode.RunWithContext(context.Background(), input)
128+
129+
// First call must return a value
130+
v, ok := iter.Next()
131+
require.True(ok, "walk_schema should produce at least one output")
132+
_, isErr := v.(error)
133+
require.False(isErr, "walk_schema should not produce an error: %v", v)
134+
135+
// Second call must signal exhaustion (no more values)
136+
v2, ok2 := iter.Next()
137+
require.False(ok2, "walk_schema should produce exactly one output, got second value: %v", v2)
138+
}
139+
}
140+
111141
func TestSavePayload(t *testing.T) {
112142
// Create temporary directory for test
113143
baseDir := filepath.Join(os.TempDir(), "test-jq-payloads")
@@ -517,7 +547,7 @@ func TestPayloadStorage_FilePermissions(t *testing.T) {
517547
// Check file permissions
518548
fileInfo, err := os.Stat(filePath)
519549
require.NoError(t, err)
520-
assert.Equal(t, os.FileMode(0644), fileInfo.Mode().Perm(), "File should have 0644 permissions")
550+
assert.Equal(t, os.FileMode(0600), fileInfo.Mode().Perm(), "File should have 0600 permissions")
521551
}
522552

523553
// TestPayloadStorage_DefaultSessionID verifies behavior when session ID is empty

0 commit comments

Comments
 (0)