Skip to content

Commit 2d1a318

Browse files
TMHSDigitalclaude
andcommitted
feat(cli): -log-format json writes stderr diagnostics as JSON Lines (#198)
With -log-format json every notice, warning, error, verbose line and (when -progress is explicit) progress update on stderr is one JSON object: time, level, kind, target, message, plus the setting codes an engine message names (settings) and progress counters. Formatting lives in the output Writer, so call sites are unchanged; engine notices and errors pass their raw text so the codes survive. Flag-parse and config errors become one JSON error line under json. Results on stdout and text-mode stderr are unchanged (checked byte for byte against the previous build). SUBENUM_LOG_FORMAT and the config key work too. Documented in the README and site flag tables, docs/pipelines.md and the CHANGELOG. Fixes #198 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LpkeXSPwrzd7ZCuX3no7gD
1 parent 7193b88 commit 2d1a318

13 files changed

Lines changed: 664 additions & 31 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- `-log-format json` writes the diagnostics on stderr (notices, warnings, errors, `-v` lines) as JSON Lines, one object per line with `time`, `level` (`error`, `warn`, `notice`, `info`, `debug`), `kind` (`message`, `scan`, `verbose`, `progress`, `simulation`, or an engine notice's category such as `wildcard` or `cap`), the `target` being scanned, the `message`, and the `settings` codes an engine message names (`["max_queries"]`). The simulation banner becomes one `simulation` notice, progress is an event with its numbers and only with an explicit `-progress`, and `-silent` keeps its meaning; results on stdout are unchanged. `SUBENUM_LOG_FORMAT` and the config file set it like any flag (#198).
1112
- `-dry-run` prints what a scan would do without scanning: per target the candidates left after dedupe, invalid entries and `-exclude`, the queries (one per name and record type, and the most with every `-attempts` retry, both capped by `-max-queries`), how long they take at `-rate`, and whether the resolver passes the preflight, which is its only DNS query. `-format json` or `jsonl` prints the plan as JSON. No `-o` file is written, and the run exits 1 only if a preflight fails. `-ct`, `-permute` and `-recursive` add work it cannot count without querying, and it says so (#192).
1213
- `pkg/subenum` exports `Permutations(seeds, words []string) []string`, the candidate generator behind `-permute`: from names already found it returns the nearby variants (`api-dev`, `dev-api`, `dev.api`, `api2`), relative to the apex and ready to pass as a second scan's `Words`. `nil` words selects the CLI's built-in list. It sends no DNS queries (#197).
1314
- `-tui` pre-fills its form from the command line: `subenum -tui example.com -w big.txt -r resolvers.txt` opens with the domain, wordlist and resolver file filled in, over `SUBENUM_*`, config file and remembered values. Flags the form has no field for are named in a warning instead of being dropped silently; the form takes the first `-w`. The form gains **Resolver Pool** (`-r`) and **Rate/Resolver** (`-rate-per-resolver`) fields, remembered in `last.json`. After a scan, `r` restores the values just scanned rather than re-applying the launch flags over them (#195).

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ make help # list all targets
270270
| `-max-duration <d>` | `0` | Time budget for the whole run, e.g. `20m` (0 = unlimited). Then no new names are tested, lookups already under way finish, and the run ends normally with its results, report (`stopped_by: "duration"`) and a `degraded` verdict. Under `-dL` it covers all targets |
271271
| `-o <file>` | n/a | Write results to file in addition to stdout |
272272
| `-format <fmt>` | `text` | Output format: `text`, `json`, `jsonl` (one object per line, streamed), or `csv` |
273+
| `-log-format <fmt>` | `text` | Diagnostics on stderr: `text`, or `json` for one JSON object per line (`time`, `level`, `kind`, `target`, `message`, and `settings` codes on engine notices). Progress is then an event, only with an explicit `-progress`. Results on stdout are unchanged |
273274
| `-show-records` | `false` | In `text` format, append each result's records as `TYPE=value` |
274275
| `-rate <qps>` | `0` | Max DNS queries per second on the wire, all workers combined, including every record type, retry, wildcard probe and the preflight (0 = unlimited) |
275276
| `-type <list>` | `A,AAAA` | Comma-separated record types to look up: `A`, `AAAA`, `CNAME` |

‎docs/_includes/flags.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
| `-force` | - | Continue scanning even if wildcard DNS is detected |
1616
| `-format <string>` | text | Output format: text, json, jsonl, or csv |
1717
| `-hit-rate <int>` | 15 | In simulation mode, percentage of names that resolve (1-100) |
18+
| `-log-format <string>` | text | Diagnostics format on stderr: text, or json for one JSON object per line (level, kind, target, message); results on stdout are unchanged |
1819
| `-max-duration <duration>` | 0s | Time budget for the whole run, e.g. 20m (0 = unlimited); then no new names are tested, lookups in flight finish, and the run ends with its report and a degraded verdict |
1920
| `-max-queries <int>` | - | Max candidate names to test (0 = unlimited); each name sends one query per record type, per attempt |
2021
| `-no-abort` | - | Do not abort when the resolver failure rate exceeds 20% (warning is still emitted) |

‎docs/pipelines.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@ subenum -silent -dL scope.txt -exclude-file out-of-scope.txt -stats run.json \
7878
| httpx -silent
7979
```
8080

81+
## Machine-readable diagnostics
82+
83+
`-log-format json` turns stderr into JSON Lines, so CI can act on a warning
84+
without matching `Note:` or `Warning:` text. Each line has `time`, `level`
85+
(`error`, `warn`, `notice`, `info`, `debug`), `kind`, `target` (the domain
86+
being scanned, when there is one) and `message`; engine notices also list the
87+
settings they name as codes in `settings`. Results on stdout are unchanged:
88+
89+
```bash
90+
subenum -log-format json -stats run.json example.com 2> log.jsonl > names.txt
91+
jq -r 'select(.level == "warn" or .level == "error") | "\(.kind): \(.message)"' log.jsonl
92+
```
93+
94+
```json
95+
{"time":"2026-11-09T12:00:00.000Z","level":"warn","kind":"cap","target":"example.com","message":"query cap reached (-max-queries 3); skipped 3 additional jobs","settings":["max_queries"]}
96+
```
97+
98+
Progress is an event (`"kind":"progress"` with a `progress` object of
99+
`percent`, `processed`, `total`, `found` and `eta_seconds`) and, as on a piped
100+
stderr, appears only when `-progress` is given explicitly. Errors in the
101+
command line itself are JSON lines too, once `-log-format json` has been read
102+
(from the command line before the error, `SUBENUM_LOG_FORMAT` or the config
103+
file); `-h` help stays text.
104+
81105
## Tips
82106

83107
- Pace the scan with `-rate` when the next tool shares the same resolver or

‎internal/output/log.go‎

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package output
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"math"
8+
"strings"
9+
"time"
10+
11+
"github.com/TMHSDigital/subenum/internal/scan"
12+
)
13+
14+
// LogFormat selects how diagnostics (notices, warnings, errors, verbose
15+
// lines and the progress line) are written to stderr. Results on stdout are
16+
// not affected (#198).
17+
type LogFormat int
18+
19+
const (
20+
// LogText is the default: free-text lines, as a person reads them.
21+
LogText LogFormat = iota
22+
// LogJSON writes every diagnostic as one JSON object per line (JSONL).
23+
LogJSON
24+
)
25+
26+
// ParseLogFormat converts a -log-format value into a LogFormat.
27+
func ParseLogFormat(s string) (LogFormat, error) {
28+
switch strings.ToLower(strings.TrimSpace(s)) {
29+
case "", "text":
30+
return LogText, nil
31+
case "json", "jsonl":
32+
return LogJSON, nil
33+
default:
34+
return LogText, fmt.Errorf("invalid -log-format %q (want text or json)", s)
35+
}
36+
}
37+
38+
// Diagnostic levels in -log-format json.
39+
const (
40+
LevelError = "error"
41+
LevelWarn = "warn"
42+
LevelNotice = "notice" // shown even under -silent
43+
LevelInfo = "info"
44+
LevelDebug = "debug" // -v lookup lines
45+
)
46+
47+
// Diagnostic kinds in -log-format json. Engine notices use their own
48+
// category instead (wildcard, cap, ceiling, skip).
49+
const (
50+
KindMessage = "message" // any other diagnostic
51+
KindScan = "scan" // an error reported by the scan engine
52+
KindVerbose = "verbose" // a -v lookup line
53+
KindProgress = "progress" // a progress tick
54+
)
55+
56+
// logEntry is one -log-format json line.
57+
type logEntry struct {
58+
Time string `json:"time"`
59+
Level string `json:"level"`
60+
Kind string `json:"kind"`
61+
Target string `json:"target,omitempty"`
62+
Message string `json:"message"`
63+
Settings []scan.Setting `json:"settings,omitempty"`
64+
Progress *logProgress `json:"progress,omitempty"`
65+
}
66+
67+
// logProgress is the payload of a progress event.
68+
type logProgress struct {
69+
Percent float64 `json:"percent"`
70+
Processed int64 `json:"processed"`
71+
Total int64 `json:"total"`
72+
Found int64 `json:"found"`
73+
ETASeconds *int64 `json:"eta_seconds,omitempty"`
74+
}
75+
76+
// SetLogFormat selects text or JSON diagnostics on stderr (#198).
77+
func (w *Writer) SetLogFormat(f LogFormat) {
78+
w.mu.Lock()
79+
defer w.mu.Unlock()
80+
w.logJSON = f == LogJSON
81+
}
82+
83+
// LogJSON reports whether diagnostics are written as JSON lines.
84+
func (w *Writer) LogJSON() bool {
85+
w.mu.Lock()
86+
defer w.mu.Unlock()
87+
return w.logJSON
88+
}
89+
90+
// SetTarget names the domain being scanned, carried as "target" by every
91+
// JSON diagnostic until it is changed; "" leaves it out. Targets are scanned
92+
// one at a time, so one current target is enough.
93+
func (w *Writer) SetTarget(domain string) {
94+
w.mu.Lock()
95+
defer w.mu.Unlock()
96+
w.target = domain
97+
}
98+
99+
// textPrefixes are the free-text markers a JSON line carries as its level
100+
// instead of in its message. An empty level keeps the caller's.
101+
var textPrefixes = []struct{ prefix, level string }{
102+
{"Error: ", LevelError},
103+
{"Warning: ", LevelWarn},
104+
{"WARNING: ", LevelWarn},
105+
{"Note: ", ""},
106+
}
107+
108+
// diagLocked writes one diagnostic. text is exactly what text mode prints
109+
// (without the newline). In JSON mode a leading marker such as "Warning: "
110+
// becomes the level, and a blank line, which only spaces out text output, is
111+
// dropped.
112+
func (w *Writer) diagLocked(level, kind, text string, settings []scan.Setting) {
113+
w.clearProgressLocked()
114+
if !w.logJSON {
115+
_, _ = fmt.Fprintln(w.errOut(), text)
116+
return
117+
}
118+
msg := text
119+
for _, p := range textPrefixes {
120+
if rest, ok := strings.CutPrefix(msg, p.prefix); ok {
121+
msg = rest
122+
if p.level != "" && level != LevelError {
123+
level = p.level
124+
}
125+
break
126+
}
127+
}
128+
// Indentation only aligns text columns.
129+
if msg = strings.TrimSpace(msg); msg == "" {
130+
return
131+
}
132+
w.writeLogLocked(logEntry{Level: level, Kind: kind, Message: msg, Settings: settings})
133+
}
134+
135+
// writeLogLocked encodes e as one line on stderr.
136+
func (w *Writer) writeLogLocked(e logEntry) {
137+
now := time.Now
138+
if w.now != nil {
139+
now = w.now
140+
}
141+
e.Time = now().UTC().Format("2006-01-02T15:04:05.000Z07:00")
142+
e.Target = w.target
143+
var buf bytes.Buffer
144+
enc := json.NewEncoder(&buf)
145+
enc.SetEscapeHTML(false)
146+
if err := enc.Encode(e); err != nil {
147+
return // only strings and numbers: cannot fail
148+
}
149+
_, _ = w.errOut().Write(buf.Bytes())
150+
}
151+
152+
// progressJSONLocked writes a progress tick as a JSON event.
153+
func (w *Writer) progressJSONLocked(line string, pct float64, processed, total, found int64, left time.Duration, etaOK bool) {
154+
p := &logProgress{Percent: math.Round(pct*10) / 10, Processed: processed, Total: total, Found: found}
155+
if etaOK {
156+
secs := int64(math.Ceil(left.Seconds()))
157+
p.ETASeconds = &secs
158+
}
159+
w.writeLogLocked(logEntry{Level: LevelInfo, Kind: KindProgress, Message: strings.TrimSpace(line), Progress: p})
160+
}
161+
162+
// Verbose writes a -v lookup line: level debug, kind verbose in JSON. It is
163+
// quiet under SetQuiet like Info.
164+
func (w *Writer) Verbose(format string, a ...any) {
165+
if w.quiet {
166+
return
167+
}
168+
w.mu.Lock()
169+
defer w.mu.Unlock()
170+
w.diagLocked(LevelDebug, KindVerbose, fmt.Sprintf(format, a...), nil)
171+
}
172+
173+
// NoticeOf is Notice with a kind for JSON output, such as "simulation".
174+
func (w *Writer) NoticeOf(kind, format string, a ...any) {
175+
w.mu.Lock()
176+
defer w.mu.Unlock()
177+
w.diagLocked(LevelNotice, kind, fmt.Sprintf(format, a...), nil)
178+
}
179+
180+
// ScanNotice writes an engine notice. msg is the engine's text with its
181+
// {setting} tokens, rendered as flag names; JSON output also lists the
182+
// settings as codes. kind is the notice category. Quiet under SetQuiet.
183+
func (w *Writer) ScanNotice(kind, msg string) {
184+
if w.quiet {
185+
return
186+
}
187+
w.mu.Lock()
188+
defer w.mu.Unlock()
189+
w.diagLocked(LevelWarn, kind, scan.Render(msg, scan.FlagName), scan.SettingsIn(msg))
190+
}
191+
192+
// ScanError writes an engine error like Error, with its {setting} tokens
193+
// rendered as flag names and listed as codes in JSON output. It returns the
194+
// rendered message.
195+
func (w *Writer) ScanError(msg string) string {
196+
text := scan.Render(msg, scan.FlagName)
197+
w.mu.Lock()
198+
defer w.mu.Unlock()
199+
w.diagLocked(LevelError, KindScan, "Error: "+text, scan.SettingsIn(msg))
200+
return text
201+
}

0 commit comments

Comments
 (0)