Skip to content
96 changes: 96 additions & 0 deletions runner/md_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package runner

import (
"fmt"
"reflect"
"strings"
)

func (r Result) MarkdownHeader() string { //nolint
var headers []string

t := reflect.TypeOf(r)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("md")
if tag == "" || tag == "-" {
continue
}
headers = append(headers, tag)
}

var b strings.Builder
b.WriteString("|")
for _, h := range headers {
fmt.Fprintf(&b, " %s |", h)
}
b.WriteString("\n")

b.WriteString("|")
for range headers {
b.WriteString("---|")
}
b.WriteString("\n")

return b.String()
}

func (r Result) MarkdownRow(scanopts *ScanOptions) string { //nolint
var values []string

v := reflect.ValueOf(r)
t := v.Type()

for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("md")
if tag == "" || tag == "-" {
continue
}

fieldValue := v.Field(i)
values = append(values, formatMarkdownValue(fieldValue))
}

var b strings.Builder
b.WriteString("|")
for _, val := range values {
fmt.Fprintf(&b, " %s |", val)
}
b.WriteString("\n")

return b.String()
}

func formatMarkdownValue(v reflect.Value) string {
switch v.Kind() {
case reflect.String:
return escapeMarkdown(v.String())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return fmt.Sprintf("%d", v.Int())
case reflect.Bool:
return fmt.Sprintf("%t", v.Bool())
case reflect.Slice:
if v.Len() == 0 {
return ""
}
var items []string
for i := 0; i < v.Len(); i++ {
items = append(items, fmt.Sprintf("%v", v.Index(i).Interface()))
}
return escapeMarkdown(strings.Join(items, ", "))
default:
if v.CanInterface() {
return escapeMarkdown(fmt.Sprintf("%v", v.Interface()))
}
return ""
}
}

func escapeMarkdown(s string) string {
replacer := strings.NewReplacer(
"|", "\\|",
"\n", " ",
)
return strings.TrimSpace(replacer.Replace(s))
}
2 changes: 2 additions & 0 deletions runner/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ type Options struct {
RespectHSTS bool
StoreResponse bool
JSONOutput bool
MarkDownOutput bool
CSVOutput bool
CSVOutputEncoding string
PdcpAuth string
Expand Down Expand Up @@ -491,6 +492,7 @@ func ParseOptions() *Options {
flagSet.BoolVar(&options.CSVOutput, "csv", false, "store output in csv format"),
flagSet.StringVarP(&options.CSVOutputEncoding, "csv-output-encoding", "csvo", "", "define output encoding"),
flagSet.BoolVarP(&options.JSONOutput, "json", "j", false, "store output in JSONL(ines) format"),
flagSet.BoolVarP(&options.MarkDownOutput, "markdown", "md", false, "store output in Markdown table format"),
flagSet.BoolVarP(&options.ResponseHeadersInStdout, "include-response-header", "irh", false, "include http response (headers) in JSON output (-json only)"),
flagSet.BoolVarP(&options.ResponseInStdout, "include-response", "irr", false, "include http request/response (headers + body) in JSON output (-json only)"),
flagSet.BoolVarP(&options.Base64ResponseInStdout, "include-response-base64", "irrb", false, "include base64 encoded http request/response in JSON output (-json only)"),
Expand Down
58 changes: 48 additions & 10 deletions runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,8 @@ func (r *Runner) RunEnumeration() {
}
}()

var plainFile, jsonFile, csvFile, indexFile, indexScreenshotFile *os.File
var plainFile, jsonFile, csvFile, mdFile, indexFile, indexScreenshotFile *os.File
markdownHeaderWritten := false // guard to prevent writing the header multiple times

if r.options.Output != "" && r.options.OutputAll {
plainFile = openOrCreateFile(r.options.Resume, r.options.Output)
Expand All @@ -946,11 +947,15 @@ func (r *Runner) RunEnumeration() {
defer func() {
_ = csvFile.Close()
}()
mdFile = openOrCreateFile(r.options.Resume, r.options.Output+".md")
defer func() {
_ = mdFile.Close()
}()
}

jsonOrCsv := (r.options.JSONOutput || r.options.CSVOutput)
jsonAndCsv := (r.options.JSONOutput && r.options.CSVOutput)
if r.options.Output != "" && plainFile == nil && !jsonOrCsv {
jsonOrCsvOrMD := (r.options.JSONOutput || r.options.CSVOutput || r.options.MarkDownOutput)
jsonAndCsvAndMD := (r.options.JSONOutput && r.options.CSVOutput && r.options.MarkDownOutput)
if r.options.Output != "" && plainFile == nil && !jsonOrCsvOrMD {
Comment on lines +956 to +958
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical logic bug: jsonAndCsvAndMD breaks existing JSON+CSV behavior.

The condition jsonAndCsvAndMD requires all three outputs (JSON, CSV, and Markdown) to be enabled simultaneously. This breaks the previous behavior where enabling just JSON and CSV would add .json/.csv extensions to avoid file overwrites.

Scenario: User runs httpx -json -csv -o output

  • Before this change: files written to output.json and output.csv
  • After this change: both try to write to output (no extensions), causing overwrites

The correct approach is to check if more than one structured output format is enabled:

🐛 Proposed fix
 		jsonOrCsvOrMD := (r.options.JSONOutput || r.options.CSVOutput || r.options.MarkDownOutput)
-		jsonAndCsvAndMD := (r.options.JSONOutput && r.options.CSVOutput && r.options.MarkDownOutput)
+		// Count how many structured outputs are enabled to determine if extensions are needed
+		structuredOutputCount := 0
+		if r.options.JSONOutput {
+			structuredOutputCount++
+		}
+		if r.options.CSVOutput {
+			structuredOutputCount++
+		}
+		if r.options.MarkDownOutput {
+			structuredOutputCount++
+		}
+		multipleStructuredOutputs := structuredOutputCount > 1

Then replace all occurrences of jsonAndCsvAndMD with multipleStructuredOutputs at lines 967, 978, 989, 1012, 1229, 1340, and 1353.

🤖 Prompt for AI Agents
In `@runner/runner.go` around lines 956 - 958, The variable jsonAndCsvAndMD
currently checks for all three structured outputs being enabled which breaks
behavior when exactly two (e.g., JSON+CSV) are set; change the boolean logic to
detect multiple structured outputs instead: replace the definition
jsonAndCsvAndMD := (r.options.JSONOutput && r.options.CSVOutput &&
r.options.MarkDownOutput) with something like multipleStructuredOutputs that
counts the enabled structured outputs (JSONOutput, CSVOutput, MarkDownOutput)
and returns true when the count > 1, then replace every use of jsonAndCsvAndMD
with multipleStructuredOutputs (i.e., every place referencing jsonAndCsvAndMD in
runner.go) so filenames get extensions when more than one structured output is
selected.

plainFile = openOrCreateFile(r.options.Resume, r.options.Output)
defer func() {
_ = plainFile.Close()
Expand All @@ -959,7 +964,7 @@ func (r *Runner) RunEnumeration() {

if r.options.Output != "" && r.options.JSONOutput && jsonFile == nil {
ext := ""
if jsonAndCsv {
if jsonAndCsvAndMD {
ext = ".json"
}
jsonFile = openOrCreateFile(r.options.Resume, r.options.Output+ext)
Expand All @@ -970,7 +975,7 @@ func (r *Runner) RunEnumeration() {

if r.options.Output != "" && r.options.CSVOutput && csvFile == nil {
ext := ""
if jsonAndCsv {
if jsonAndCsvAndMD {
ext = ".csv"
}
csvFile = openOrCreateFile(r.options.Resume, r.options.Output+ext)
Expand All @@ -979,6 +984,17 @@ func (r *Runner) RunEnumeration() {
}()
}

if r.options.Output != "" && r.options.MarkDownOutput && mdFile == nil {
ext := ""
if jsonAndCsvAndMD {
ext = ".md"
}
mdFile = openOrCreateFile(r.options.Resume, r.options.Output+ext)
defer func() {
_ = mdFile.Close()
}()
}

if r.options.CSVOutput {
outEncoding := strings.ToLower(r.options.CSVOutputEncoding)
switch outEncoding {
Expand All @@ -993,7 +1009,7 @@ func (r *Runner) RunEnumeration() {
gologger.Fatal().Msgf("unknown csv output encoding: %s\n", r.options.CSVOutputEncoding)
}
headers := Result{}.CSVHeader()
if !r.options.OutputAll && !jsonAndCsv {
if !r.options.OutputAll && !jsonAndCsvAndMD {
gologger.Silent().Msgf("%s\n", headers)
}

Expand Down Expand Up @@ -1210,7 +1226,7 @@ func (r *Runner) RunEnumeration() {
}
}

if !r.options.DisableStdout && (!jsonOrCsv || jsonAndCsv || r.options.OutputAll) {
if !r.options.DisableStdout && (!jsonOrCsvOrMD || jsonAndCsvAndMD || r.options.OutputAll) {
gologger.Silent().Msgf("%s\n", resp.str)
}

Expand Down Expand Up @@ -1321,7 +1337,7 @@ func (r *Runner) RunEnumeration() {
if r.options.JSONOutput {
row := resp.JSON(&r.scanopts)

if !r.options.OutputAll && !jsonAndCsv {
if !r.options.OutputAll && !jsonAndCsvAndMD {
gologger.Silent().Msgf("%s\n", row)
}

Expand All @@ -1334,7 +1350,7 @@ func (r *Runner) RunEnumeration() {
if r.options.CSVOutput {
row := resp.CSVRow(&r.scanopts)

if !r.options.OutputAll && !jsonAndCsv {
if !r.options.OutputAll && !jsonAndCsvAndMD {
gologger.Silent().Msgf("%s\n", row)
}

Expand All @@ -1344,6 +1360,28 @@ func (r *Runner) RunEnumeration() {
}
}

if r.options.MarkDownOutput || r.options.OutputAll {
if !markdownHeaderWritten {
header := resp.MarkdownHeader()
if !r.options.OutputAll {
gologger.Silent().Msgf("%s", header)
}
if mdFile != nil {
_, _ = mdFile.WriteString(header)
}
markdownHeaderWritten = true
}

row := resp.MarkdownRow(&r.scanopts)

if !r.options.OutputAll {
gologger.Silent().Msgf("%s", row)
}
if mdFile != nil {
_, _ = mdFile.WriteString(row)
}
}
Comment on lines +1363 to +1383
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the full context around lines 1363-1383 in runner.go
cat -n runner/runner.go | sed -n '1330,1390p'

Repository: projectdiscovery/httpx

Length of output: 1877


🏁 Script executed:

# Find where jsonAndCsvAndMD is defined and used
rg -n "jsonAndCsvAndMD" runner/runner.go | head -20

Repository: projectdiscovery/httpx

Length of output: 512


🏁 Script executed:

# Search for the MarkdownHeader and MarkdownRow method implementations
rg -n "func.*MarkdownHeader|func.*MarkdownRow" --type go

Repository: projectdiscovery/httpx

Length of output: 230


🏁 Script executed:

# Let's examine the MarkdownHeader and MarkdownRow implementations
cat -n runner/md_output.go

Repository: projectdiscovery/httpx

Length of output: 2614


Markdown stdout should check !jsonAndCsvAndMD like JSON and CSV output.

The Markdown stdout conditions at lines 1366 and 1377 only check !r.options.OutputAll, while JSON (line 1340) and CSV (line 1353) additionally check !jsonAndCsvAndMD. This causes inconsistent behavior when all three structured outputs are enabled—Markdown prints to stdout while JSON/CSV suppress it.

🔧 Proposed fix for consistent stdout behavior
 		if r.options.MarkDownOutput || r.options.OutputAll {
 			if !markdownHeaderWritten {
 				header := resp.MarkdownHeader()
-				if !r.options.OutputAll {
+				if !r.options.OutputAll && !jsonAndCsvAndMD {
 					gologger.Silent().Msgf("%s", header)
 				}
 				if mdFile != nil {
 					_, _ = mdFile.WriteString(header)
 				}
 				markdownHeaderWritten = true
 			}

 			row := resp.MarkdownRow(&r.scanopts)

-			if !r.options.OutputAll {
+			if !r.options.OutputAll && !jsonAndCsvAndMD {
 				gologger.Silent().Msgf("%s", row)
 			}
 			if mdFile != nil {
 				_, _ = mdFile.WriteString(row)
 			}
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if r.options.MarkDownOutput || r.options.OutputAll {
if !markdownHeaderWritten {
header := resp.MarkdownHeader()
if !r.options.OutputAll {
gologger.Silent().Msgf("%s", header)
}
if mdFile != nil {
_, _ = mdFile.WriteString(header)
}
markdownHeaderWritten = true
}
row := resp.MarkdownRow(&r.scanopts)
if !r.options.OutputAll {
gologger.Silent().Msgf("%s", row)
}
if mdFile != nil {
_, _ = mdFile.WriteString(row)
}
}
if r.options.MarkDownOutput || r.options.OutputAll {
if !markdownHeaderWritten {
header := resp.MarkdownHeader()
if !r.options.OutputAll && !jsonAndCsvAndMD {
gologger.Silent().Msgf("%s", header)
}
if mdFile != nil {
_, _ = mdFile.WriteString(header)
}
markdownHeaderWritten = true
}
row := resp.MarkdownRow(&r.scanopts)
if !r.options.OutputAll && !jsonAndCsvAndMD {
gologger.Silent().Msgf("%s", row)
}
if mdFile != nil {
_, _ = mdFile.WriteString(row)
}
}
🤖 Prompt for AI Agents
In `@runner/runner.go` around lines 1363 - 1383, The Markdown stdout branch
currently only checks !r.options.OutputAll, causing Markdown to print to stdout
when JSON/CSV suppress output; update the Markdown output conditions in the
runner loop (the block using r.options.MarkDownOutput, markdownHeaderWritten,
resp.MarkdownHeader(), resp.MarkdownRow(&r.scanopts), and mdFile) to also check
!jsonAndCsvAndMD before writing to stdout (i.e., mirror the JSON/CSV checks) so
Markdown respects the combined jsonAndCsvAndMD suppression while still writing
to mdFile when appropriate.


for _, nextStep := range nextSteps {
nextStep <- resp
}
Expand Down
Loading