Skip to content

Commit c457f4a

Browse files
committed
robust parse hub JSON output (backend+frontend)
Handle noisy cscli hub output by extracting the first JSON payload instead of assuming clean stdout. backend: add firstJSONStartIndex and parseHubJSONOutput to strip preamble and decode either object or array, replace ad-hoc slicing in hub handlers, and add unit tests. frontend: add parseHubJSONString and extractHubArray to robustly parse mixed text/JSON responses (including iterative trimming) and accept alternate keys for appsec configs/rules. This makes hub listing resilient to informational logs surrounding the JSON payload.
1 parent 36cdfb0 commit c457f4a

3 files changed

Lines changed: 193 additions & 30 deletions

File tree

internal/api/handlers/hub.go

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,41 @@ func upsertHubPreference(db *database.Database, category, mode, defaultPath, las
195195
}
196196
}
197197

198+
func firstJSONStartIndex(output string) int {
199+
objectStart := strings.Index(output, "{")
200+
arrayStart := strings.Index(output, "[")
201+
202+
switch {
203+
case objectStart == -1:
204+
return arrayStart
205+
case arrayStart == -1:
206+
return objectStart
207+
case objectStart < arrayStart:
208+
return objectStart
209+
default:
210+
return arrayStart
211+
}
212+
}
213+
214+
// parseHubJSONOutput extracts the first JSON value from mixed CLI output.
215+
// cscli may print informational preamble/trailing lines around the JSON body.
216+
func parseHubJSONOutput(output string) (interface{}, error) {
217+
start := firstJSONStartIndex(output)
218+
if start < 0 {
219+
return nil, fmt.Errorf("no JSON payload found")
220+
}
221+
222+
decoder := json.NewDecoder(strings.NewReader(output[start:]))
223+
decoder.UseNumber()
224+
225+
var parsed interface{}
226+
if err := decoder.Decode(&parsed); err != nil {
227+
return nil, err
228+
}
229+
230+
return parsed, nil
231+
}
232+
198233
// ListHubCategories returns the supported category metadata.
199234
func ListHubCategories() gin.HandlerFunc {
200235
return func(c *gin.Context) {
@@ -221,16 +256,8 @@ func ListHubItems(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFu
221256
return
222257
}
223258

224-
// cscli hub list may output informational preamble lines (e.g.
225-
// "Loaded: 161 parsers...") to stdout before the JSON object.
226-
// Strip any text before the opening '{' to get clean JSON.
227-
jsonOutput := output
228-
if idx := strings.Index(output, "{"); idx > 0 {
229-
jsonOutput = output[idx:]
230-
}
231-
232-
var parsed interface{}
233-
if err := json.Unmarshal([]byte(jsonOutput), &parsed); err != nil {
259+
parsed, err := parseHubJSONOutput(output)
260+
if err != nil {
234261
logger.Warn("Failed to parse hub list JSON", "error", err, "output_preview", truncateString(output, 200))
235262
c.JSON(http.StatusOK, models.Response{
236263
Success: true,
@@ -265,16 +292,8 @@ func ListHubItemsByCategory(dockerClient *docker.Client, cfg *config.Config) gin
265292
return
266293
}
267294

268-
// Strip preamble text before JSON (cscli may output info lines to stdout)
269-
jsonOutput := output
270-
if idx := strings.Index(output, "{"); idx > 0 {
271-
jsonOutput = output[idx:]
272-
} else if idx := strings.Index(output, "["); idx > 0 {
273-
jsonOutput = output[idx:]
274-
}
275-
276-
var parsed interface{}
277-
if err := json.Unmarshal([]byte(jsonOutput), &parsed); err != nil {
295+
parsed, err := parseHubJSONOutput(output)
296+
if err != nil {
278297
c.JSON(http.StatusOK, models.Response{Success: true, Data: gin.H{"category": spec, "raw_output": output}})
279298
return
280299
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package handlers
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParseHubJSONOutput(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
input string
11+
wantError bool
12+
assert func(t *testing.T, parsed interface{})
13+
}{
14+
{
15+
name: "parses clean object",
16+
input: `{"collections":[{"name":"a"}]}`,
17+
assert: func(t *testing.T, parsed interface{}) {
18+
t.Helper()
19+
record, ok := parsed.(map[string]interface{})
20+
if !ok {
21+
t.Fatalf("expected object, got %T", parsed)
22+
}
23+
items, ok := record["collections"].([]interface{})
24+
if !ok || len(items) != 1 {
25+
t.Fatalf("expected one collection item, got %#v", record["collections"])
26+
}
27+
},
28+
},
29+
{
30+
name: "parses object with preamble and trailing logs",
31+
input: "Loaded: 1 parser\\nUnmanaged items: 0\\n" +
32+
`{"scenarios":[{"name":"test/scenario"}]}` +
33+
"\\noperation completed",
34+
assert: func(t *testing.T, parsed interface{}) {
35+
t.Helper()
36+
record, ok := parsed.(map[string]interface{})
37+
if !ok {
38+
t.Fatalf("expected object, got %T", parsed)
39+
}
40+
items, ok := record["scenarios"].([]interface{})
41+
if !ok || len(items) != 1 {
42+
t.Fatalf("expected one scenario item, got %#v", record["scenarios"])
43+
}
44+
},
45+
},
46+
{
47+
name: "parses list payload",
48+
input: "Info line\\n" + `[{"name":"crowdsecurity/test"}]`,
49+
assert: func(t *testing.T, parsed interface{}) {
50+
t.Helper()
51+
items, ok := parsed.([]interface{})
52+
if !ok || len(items) != 1 {
53+
t.Fatalf("expected one array item, got %#v", parsed)
54+
}
55+
},
56+
},
57+
{
58+
name: "fails without json",
59+
input: "Loaded: 1 parser\\nNo JSON output",
60+
wantError: true,
61+
},
62+
}
63+
64+
for _, tt := range tests {
65+
t.Run(tt.name, func(t *testing.T) {
66+
parsed, err := parseHubJSONOutput(tt.input)
67+
if tt.wantError {
68+
if err == nil {
69+
t.Fatalf("expected error, got parsed=%#v", parsed)
70+
}
71+
return
72+
}
73+
74+
if err != nil {
75+
t.Fatalf("unexpected error: %v", err)
76+
}
77+
if tt.assert != nil {
78+
tt.assert(t, parsed)
79+
}
80+
})
81+
}
82+
}
83+
84+
func TestFirstJSONStartIndex(t *testing.T) {
85+
tests := []struct {
86+
name string
87+
input string
88+
want int
89+
}{
90+
{name: "object first", input: "prefix {\"k\":1}", want: 7},
91+
{name: "array first", input: "prefix [1,2]", want: 7},
92+
{name: "none", input: "no json", want: -1},
93+
}
94+
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
if got := firstJSONStartIndex(tt.input); got != tt.want {
98+
t.Fatalf("got %d want %d", got, tt.want)
99+
}
100+
})
101+
}
102+
}

web/src/pages/HubBrowser.tsx

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,33 +59,75 @@ type ParsedHubItems = {
5959
rawParseError: boolean
6060
}
6161

62+
function extractHubArray(record: Record<string, unknown>, ...keys: string[]): HubBrowserItem[] {
63+
for (const key of keys) {
64+
if (Array.isArray(record[key])) return record[key] as HubBrowserItem[]
65+
}
66+
return []
67+
}
68+
69+
function parseHubJSONString(raw: string): unknown {
70+
const trimmed = raw.trim()
71+
if (!trimmed) return null
72+
73+
try {
74+
return JSON.parse(trimmed)
75+
} catch {
76+
// Continue with robust fallback below.
77+
}
78+
79+
const firstObject = trimmed.indexOf('{')
80+
const firstArray = trimmed.indexOf('[')
81+
82+
let start = -1
83+
if (firstObject >= 0 && firstArray >= 0) start = Math.min(firstObject, firstArray)
84+
else if (firstObject >= 0) start = firstObject
85+
else if (firstArray >= 0) start = firstArray
86+
87+
if (start < 0) {
88+
throw new Error('No JSON payload found in hub response')
89+
}
90+
91+
const openChar = trimmed[start]
92+
const closeChar = openChar === '{' ? '}' : ']'
93+
for (let end = trimmed.length - 1; end > start; end -= 1) {
94+
if (trimmed[end] !== closeChar) continue
95+
const candidate = trimmed.slice(start, end + 1)
96+
try {
97+
return JSON.parse(candidate)
98+
} catch {
99+
// Keep shrinking until we find a parseable payload.
100+
}
101+
}
102+
103+
throw new Error('Unable to parse JSON payload from hub response')
104+
}
105+
62106
function parseHubItems(data: unknown): ParsedHubItems {
63107
if (!data) return { items: EMPTY_HUB_ITEMS, rawParseError: false }
64108
if (Array.isArray(data)) {
65109
return { items: { ...EMPTY_HUB_ITEMS, scenarios: data as HubBrowserItem[] }, rawParseError: false }
66110
}
67111
if (typeof data === 'string') {
68112
try {
69-
const parsed = JSON.parse(data)
113+
const parsed = parseHubJSONString(data)
70114
return parseHubItems(parsed)
71115
} catch {
72116
return { items: EMPTY_HUB_ITEMS, rawParseError: true }
73117
}
74118
}
75119
if (typeof data === 'object') {
76120
const record = data as Record<string, unknown>
77-
const extract = (key: string): HubBrowserItem[] =>
78-
Array.isArray(record[key]) ? (record[key] as HubBrowserItem[]) : []
79121
return {
80122
rawParseError: false,
81123
items: {
82-
collections: extract('collections'),
83-
scenarios: extract('scenarios'),
84-
parsers: extract('parsers'),
85-
postoverflows: extract('postoverflows'),
86-
'appsec-configs': extract('appsec-configs'),
87-
'appsec-rules': extract('appsec-rules'),
88-
contexts: extract('contexts'),
124+
collections: extractHubArray(record, 'collections'),
125+
scenarios: extractHubArray(record, 'scenarios'),
126+
parsers: extractHubArray(record, 'parsers'),
127+
postoverflows: extractHubArray(record, 'postoverflows'),
128+
'appsec-configs': extractHubArray(record, 'appsec-configs', 'appsec_configs'),
129+
'appsec-rules': extractHubArray(record, 'appsec-rules', 'appsec_rules'),
130+
contexts: extractHubArray(record, 'contexts'),
89131
},
90132
}
91133
}

0 commit comments

Comments
 (0)