Skip to content

Commit d80da54

Browse files
authored
feat(report): add json output format (#11)
- add --format flag and validation for human or json modes - emit structured json reports with findings, skips, and errors - include commit metadata, timestamps, and skip details in outputs - add CLI and report tests covering json success and error paths
1 parent 30f5f88 commit d80da54

17 files changed

Lines changed: 1057 additions & 28 deletions

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Fingerprint: /Users/johnsmith/ghostscan/testdata/invisible/single.txt:unicode/in
4848
- **Payload-aware heuristics**: Flags long hidden sequences, dense suspicious regions, and explicit payload-plus-decoder correlations while keeping standalone decoder noise out of default results.
4949
- **Noise reduction for asset contexts**: Suppresses obvious private-use glyph mappings in font-like SVG assets so icon fonts do not dominate the report.
5050
- **Safe repository traversal**: Skips symlinks, NUL-containing files, oversize files, and common dependency or build directories.
51-
- **CI-friendly behavior**: Uses deterministic ordering, plain-text output, and exit codes `0`, `1`, and `2`.
51+
- **CI-friendly behavior**: Uses deterministic ordering, human or JSON output, and exit codes `0`, `1`, and `2`.
5252

5353
## Installation
5454

@@ -84,6 +84,7 @@ ghostscan [flags] [path]
8484
8585
Flags:
8686
--exclude strings exclude files or directories matching this glob; repeatable
87+
--format string output format: human or json (default "human")
8788
--max-file-size int skip files larger than this many bytes
8889
-n, --no-color disable color
8990
--no-default-excludes disable built-in exclude globs
@@ -118,11 +119,16 @@ ghostscan . --no-default-excludes --exclude "**/*.gen.js"
118119

119120
# Enforce a smaller max file size
120121
ghostscan --max-file-size 1048576 .
122+
123+
# Emit machine-readable JSON
124+
ghostscan --format json ./testdata/invisible/single.txt
121125
```
122126

123127
## Output and Exit Codes
124128

125-
`ghostscan` prints a human-readable terminal report. In verbose mode, each finding includes:
129+
`ghostscan` prints a human-readable terminal report by default and emits a single JSON document when `--format json` is selected.
130+
131+
In human verbose mode, each finding includes:
126132

127133
- file path
128134
- line and column
@@ -140,6 +146,8 @@ SKIP dist/app.min.js (matched exclude: "**/*.min.js")
140146
SKIP vendor (matched exclude: "vendor/**")
141147
```
142148

149+
JSON output always writes one document to stdout with `tool`, `scan`, `summary`, `findings`, `skipped_files`, and `errors` keys, without ANSI color or human log lines. In JSON mode, fatal execution errors are emitted as a structured report with `errors` populated and exit code `2`.
150+
143151
Exit codes:
144152

145153
| Exit code | Description |

cmd/root.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ import (
2626
"fmt"
2727
"io"
2828
"os"
29+
"time"
2930

3031
"github.com/spf13/pflag"
3132

3233
"github.com/jcouture/ghostscan/internal/app"
3334
"github.com/jcouture/ghostscan/internal/exitcode"
35+
"github.com/jcouture/ghostscan/internal/report"
3436
)
3537

3638
func Execute() int {
@@ -63,12 +65,14 @@ func execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
6365
var maxFileSize int64
6466
var excludes []string
6567
var noDefaultExcludes bool
68+
var format string
6669
flags.BoolVarP(&noColor, "no-color", "n", false, "disable color")
6770
flags.BoolVarP(&version, "version", "v", false, "print version")
6871
flags.BoolVar(&verbose, "verbose", false, "print detailed structured finding blocks")
6972
flags.BoolVar(&silent, "silent", false, "suppress the startup banner")
7073
flags.Int64Var(&maxFileSize, "max-file-size", 0, "skip files larger than this many bytes")
7174
flags.StringArrayVar(&excludes, "exclude", nil, "exclude files or directories matching this glob; repeatable")
75+
flags.StringVar(&format, "format", string(app.OutputFormatHuman), "output format: human or json")
7276
flags.BoolVar(&noDefaultExcludes, "no-default-excludes", false, "disable built-in exclude globs")
7377

7478
if err := flags.Parse(args); err != nil {
@@ -78,6 +82,12 @@ func execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
7882
return exitcode.ExecutionError
7983
}
8084

85+
outputFormat := app.OutputFormat(format)
86+
if err := outputFormat.Validate(); err != nil {
87+
_, _ = fmt.Fprintln(stderr, err)
88+
return exitcode.ExecutionError
89+
}
90+
8191
if version {
8292
_, _ = fmt.Fprintln(stdout, versionString())
8393
return exitcode.Success
@@ -104,12 +114,26 @@ func execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
104114
Color: !noColor,
105115
Verbose: verbose,
106116
Silent: silent,
117+
Format: outputFormat,
107118
MaxFileSize: maxFileSize,
108119
Excludes: excludes,
109120
UseDefaultExcludes: !noDefaultExcludes,
110121
Version: Version,
122+
Commit: Commit,
111123
})
112124
if err != nil {
125+
if outputFormat == app.OutputFormatJSON {
126+
now := time.Now().UTC()
127+
if jsonErr := report.WriteJSONError(stdout, report.Options{
128+
Version: Version,
129+
Commit: Commit,
130+
Target: path,
131+
StartedAt: now,
132+
CompletedAt: now,
133+
}, err); jsonErr == nil {
134+
return exitcode.ExecutionError
135+
}
136+
}
113137
_, _ = fmt.Fprintln(stderr, err)
114138
return exitcode.ExecutionError
115139
}

cmd/root_bench_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
1+
// Copyright 2026 Jean-Philippe Couture
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in all
11+
// copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
121
package cmd
222

323
import (

cmd/root_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ package cmd
2222

2323
import (
2424
"context"
25+
"encoding/json"
2526
"path/filepath"
2627
"strings"
2728
"testing"
@@ -112,6 +113,12 @@ func TestExecute(t *testing.T) {
112113
args: []string{"--no-color", "--no-default-excludes", filepath.Join("..", "testdata", "clean")},
113114
wantCode: exitcode.Success,
114115
},
116+
{
117+
name: "invalid output format",
118+
args: []string{"--format", "sarif"},
119+
wantCode: exitcode.ExecutionError,
120+
wantErr: "unsupported --format",
121+
},
115122
}
116123

117124
for _, tt := range tests {
@@ -160,6 +167,103 @@ func TestExecute(t *testing.T) {
160167
}
161168
}
162169

170+
func TestExecuteJSONFormat(t *testing.T) {
171+
t.Parallel()
172+
173+
var stdout strings.Builder
174+
var stderr strings.Builder
175+
176+
code := execute(context.Background(), []string{"--format", "json", filepath.Join("..", "testdata", "clean")}, &stdout, &stderr)
177+
if code != exitcode.Success {
178+
t.Fatalf("execute() code = %d, want %d", code, exitcode.Success)
179+
}
180+
if stderr.Len() != 0 {
181+
t.Fatalf("stderr = %q, want empty output", stderr.String())
182+
}
183+
if strings.Contains(stdout.String(), "########") || strings.Contains(stdout.String(), "INF ") || strings.Contains(stdout.String(), "\x1b[") {
184+
t.Fatalf("stdout = %q, want JSON-only output", stdout.String())
185+
}
186+
187+
var decoded struct {
188+
Tool struct {
189+
Name string `json:"name"`
190+
Version string `json:"version"`
191+
Commit string `json:"commit"`
192+
} `json:"tool"`
193+
Findings []any `json:"findings"`
194+
Errors []any `json:"errors"`
195+
}
196+
if err := json.Unmarshal([]byte(stdout.String()), &decoded); err != nil {
197+
t.Fatalf("json.Unmarshal() error = %v\nstdout=%s", err, stdout.String())
198+
}
199+
if decoded.Tool.Name != "ghostscan" || decoded.Tool.Version == "" || decoded.Tool.Commit == "" {
200+
t.Fatalf("tool = %+v, want populated ghostscan metadata", decoded.Tool)
201+
}
202+
if len(decoded.Findings) != 0 || len(decoded.Errors) != 0 {
203+
t.Fatalf("decoded = %+v, want clean report", decoded)
204+
}
205+
}
206+
207+
func TestExecuteJSONFormatFindingsExitCode(t *testing.T) {
208+
t.Parallel()
209+
210+
var stdout strings.Builder
211+
var stderr strings.Builder
212+
213+
code := execute(context.Background(), []string{"--format", "json", filepath.Join("..", "testdata", "invisible")}, &stdout, &stderr)
214+
if code != exitcode.FindingsDetected {
215+
t.Fatalf("execute() code = %d, want %d", code, exitcode.FindingsDetected)
216+
}
217+
if stderr.Len() != 0 {
218+
t.Fatalf("stderr = %q, want empty output", stderr.String())
219+
}
220+
221+
var decoded struct {
222+
Summary struct {
223+
FindingsTotal int `json:"findings_total"`
224+
} `json:"summary"`
225+
Findings []struct {
226+
RuleID string `json:"rule_id"`
227+
} `json:"findings"`
228+
}
229+
if err := json.Unmarshal([]byte(stdout.String()), &decoded); err != nil {
230+
t.Fatalf("json.Unmarshal() error = %v\nstdout=%s", err, stdout.String())
231+
}
232+
if decoded.Summary.FindingsTotal == 0 || len(decoded.Findings) == 0 {
233+
t.Fatalf("decoded = %+v, want findings", decoded)
234+
}
235+
if decoded.Findings[0].RuleID == "" {
236+
t.Fatalf("findings[0] = %+v, want rule id", decoded.Findings[0])
237+
}
238+
}
239+
240+
func TestExecuteJSONFormatExecutionError(t *testing.T) {
241+
t.Parallel()
242+
243+
var stdout strings.Builder
244+
var stderr strings.Builder
245+
246+
code := execute(context.Background(), []string{"--format", "json", filepath.Join(t.TempDir(), "missing")}, &stdout, &stderr)
247+
if code != exitcode.ExecutionError {
248+
t.Fatalf("execute() code = %d, want %d", code, exitcode.ExecutionError)
249+
}
250+
if stderr.Len() != 0 {
251+
t.Fatalf("stderr = %q, want no stderr when JSON error report is emitted", stderr.String())
252+
}
253+
254+
var decoded struct {
255+
Errors []struct {
256+
Message string `json:"message"`
257+
} `json:"errors"`
258+
}
259+
if err := json.Unmarshal([]byte(stdout.String()), &decoded); err != nil {
260+
t.Fatalf("json.Unmarshal() error = %v\nstdout=%s", err, stdout.String())
261+
}
262+
if len(decoded.Errors) != 1 || !strings.Contains(decoded.Errors[0].Message, "discover files from") {
263+
t.Fatalf("errors = %+v, want structured fatal execution error", decoded.Errors)
264+
}
265+
}
266+
163267
func TestExecuteHelp(t *testing.T) {
164268
t.Parallel()
165269

0 commit comments

Comments
 (0)