Skip to content

Commit 118d70b

Browse files
authored
Merge pull request #69 from techgaun/feat/output-formats
feat: add structured output formats
2 parents 6163fbe + a26d4a9 commit 118d70b

5 files changed

Lines changed: 296 additions & 53 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ GH_TOKEN=<github_token> github-dorks -u dev-nepal # search using au
6262
GH_URL=https://github.example.com github-dorks -u dev-nepal # search a GitHub Enterprise instance
6363

6464
github-dorks -r techgaun/github-dorks --max-retries 5 # retry recoverable failures up to five times
65+
66+
github-dorks -r techgaun/github-dorks --format json # stream JSON to stdout
67+
68+
github-dorks -u dev-nepal --format jsonl -o results.jsonl # write newline-delimited JSON
69+
70+
github-dorks -u dev-nepal --format csv -o results.csv --force # explicitly replace an existing file
6571
```
6672

6773
The legacy `python github-dork.py ...` invocation remains available for
@@ -72,6 +78,13 @@ elapsed time. The command exits with status `0` after a complete scan, `2` when
7278
one or more queries failed, and `1` for fatal configuration, file, or
7379
authentication errors.
7480

81+
Supported result formats are `text`, `csv`, `json`, and `jsonl`. Text is the
82+
default for terminal output; using `-o/--output` without `--format` preserves
83+
the historical CSV default. Existing files are protected unless `--force` is
84+
provided. Use `--quiet` to suppress progress and summaries, or `--verbose` to
85+
print every query as it runs. Structured stdout remains machine-readable because
86+
status messages are sent to stderr.
87+
7588
### Development
7689

7790
Run the dependency-free unit test suite with:

‎github_dorks/cli.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,26 @@ def build_parser():
6060
help='GitHub dorks file. Eg: github-dorks.txt',
6161
)
6262
parser.add_argument(
63-
'-o', '--outputFile', dest='output_filename',
64-
help='CSV file to write results to. This overwrites the provided file.',
63+
'-o', '--output', '--outputFile', dest='output_filename',
64+
help='Write results to this file instead of stdout',
65+
)
66+
parser.add_argument(
67+
'--format', choices=('text', 'csv', 'json', 'jsonl'),
68+
dest='output_format',
69+
help='Result format (default: text, or CSV when -o is used)',
70+
)
71+
parser.add_argument(
72+
'-f', '--force', action='store_true',
73+
help='Overwrite an existing output file',
74+
)
75+
detail_group = parser.add_mutually_exclusive_group()
76+
detail_group.add_argument(
77+
'-q', '--quiet', action='store_true',
78+
help='Suppress progress and summary messages',
79+
)
80+
detail_group.add_argument(
81+
'--verbose', action='store_true',
82+
help='Report each query as it runs',
6583
)
6684
parser.add_argument(
6785
'--max-retries', type=int, default=3,
@@ -84,6 +102,10 @@ def main():
84102
user_to_search=args.user_to_search,
85103
gh_dorks_file=args.gh_dorks_file,
86104
output_filename=args.output_filename,
105+
output_format=args.output_format,
106+
force=args.force,
107+
quiet=args.quiet,
108+
verbose=args.verbose,
87109
client=create_client(),
88110
max_retries=args.max_retries,
89111
)

‎github_dorks/output.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Streaming result writers for supported output formats."""
2+
3+
import csv
4+
import json
5+
from dataclasses import asdict
6+
7+
8+
FIELDNAMES = ('dork', 'text_matches', 'path', 'score', 'url')
9+
CSV_HEADERS = (
10+
'Issue Type (Dork)', 'Text Matches', 'File Path',
11+
'Score/Relevance', 'URL of File',
12+
)
13+
14+
15+
class TextWriter:
16+
def __init__(self, stream):
17+
self.stream = stream
18+
19+
def start(self):
20+
pass
21+
22+
def write(self, result):
23+
self.stream.write('\n'.join([
24+
'Found result for {dork}',
25+
'Text matches: {text_matches}',
26+
'File path: {path}',
27+
'Score/Relevance: {score}',
28+
'URL of File: {url}',
29+
'',
30+
]).format(**result) + '\n')
31+
32+
def finish(self, stats):
33+
pass
34+
35+
36+
class CsvWriter:
37+
def __init__(self, stream):
38+
self.writer = csv.DictWriter(stream, fieldnames=FIELDNAMES)
39+
40+
def start(self):
41+
self.writer.writerow(dict(zip(FIELDNAMES, CSV_HEADERS)))
42+
43+
def write(self, result):
44+
self.writer.writerow(result)
45+
46+
def finish(self, stats):
47+
pass
48+
49+
50+
class JsonWriter:
51+
def __init__(self, stream):
52+
self.stream = stream
53+
self.first_result = True
54+
55+
def start(self):
56+
self.stream.write('{"results":[')
57+
58+
def write(self, result):
59+
if not self.first_result:
60+
self.stream.write(',')
61+
json.dump(result, self.stream, default=str, separators=(',', ':'))
62+
self.first_result = False
63+
64+
def finish(self, stats):
65+
self.stream.write('],"summary":')
66+
json.dump(asdict(stats), self.stream, separators=(',', ':'))
67+
self.stream.write('}\n')
68+
69+
70+
class JsonLinesWriter:
71+
def __init__(self, stream):
72+
self.stream = stream
73+
74+
def start(self):
75+
pass
76+
77+
def write(self, result):
78+
json.dump(
79+
{'type': 'result', **result}, self.stream,
80+
default=str, separators=(',', ':'),
81+
)
82+
self.stream.write('\n')
83+
84+
def finish(self, stats):
85+
json.dump(
86+
{'type': 'summary', **asdict(stats)}, self.stream,
87+
separators=(',', ':'),
88+
)
89+
self.stream.write('\n')
90+
91+
92+
WRITERS = {
93+
'text': TextWriter,
94+
'csv': CsvWriter,
95+
'json': JsonWriter,
96+
'jsonl': JsonLinesWriter,
97+
}
98+
99+
100+
def create_writer(output_format, stream):
101+
try:
102+
return WRITERS[output_format](stream)
103+
except KeyError as error:
104+
supported = ', '.join(WRITERS)
105+
raise ValueError(
106+
f'unsupported output format {output_format!r}; choose from {supported}'
107+
) from error

‎github_dorks/search.py‎

Lines changed: 55 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""GitHub client construction and reliable code-search execution."""
22

3-
import csv
43
import os
54
import time
65
from contextlib import nullcontext
@@ -11,6 +10,8 @@
1110
import github3 as github
1211
from requests import exceptions as requests_exceptions
1312

13+
from github_dorks.output import create_writer
14+
1415

1516
@dataclass
1617
class ScanStats:
@@ -128,29 +129,19 @@ def iter_search_results(client, query, stats, max_retries=3,
128129
sleep(delay)
129130

130131

131-
def _write_result(result, query, csv_writer, stdout):
132-
values = {
132+
def _result_record(result, query):
133+
return {
133134
'dork': query,
134135
'text_matches': result.text_matches,
135136
'path': result.path,
136137
'score': result.score,
137138
'url': result.html_url,
138139
}
139-
if csv_writer:
140-
csv_writer.writerow(values.values())
141-
return
142-
stdout.write('\n'.join([
143-
'Found result for {dork}',
144-
'Text matches: {text_matches}',
145-
'File path: {path}',
146-
'Score/Relevance: {score}',
147-
'URL of File: {url}',
148-
'',
149-
]).format(**values) + '\n')
150140

151141

152142
def search(repo_to_search=None, user_to_search=None, gh_dorks_file=None,
153-
output_filename=None, client=None, max_retries=3,
143+
output_filename=None, output_format=None, force=False,
144+
quiet=False, verbose=False, client=None, max_retries=3,
154145
sleep=time.sleep, now=time.time, monotonic=time.monotonic,
155146
stdout=None, stderr=None):
156147
"""Run every dork and return statistics, isolating per-query failures."""
@@ -162,50 +153,63 @@ def search(repo_to_search=None, user_to_search=None, gh_dorks_file=None,
162153
raise ValueError('exactly one repository or user scope is required')
163154
client = create_client() if client is None else client
164155
dorks_path = find_dorks_file(gh_dorks_file)
156+
output_format = output_format or ('csv' if output_filename else 'text')
165157
stats = ScanStats()
166158
started_at = monotonic()
167159
scope = f' repo:{repo_to_search}' if repo_to_search else f' user:{user_to_search}'
168160
label = 'Repo' if repo_to_search else 'User'
169-
stdout.write(f'Scanning {label}: {scope.split(":", 1)[1]}\n')
161+
status_stream = stderr if not output_filename and output_format != 'text' else stdout
162+
if not quiet:
163+
status_stream.write(f'Scanning {label}: {scope.split(":", 1)[1]}\n')
170164

171165
output_context = (
172-
open(output_filename, 'w', newline='', encoding='utf-8')
173-
if output_filename else nullcontext(None)
166+
open(
167+
output_filename, 'w' if force else 'x', newline='', encoding='utf-8'
168+
) if output_filename else nullcontext(stdout)
174169
)
175170
with dorks_path.open(encoding='utf-8') as dork_file, output_context as output_file:
176-
csv_writer = csv.writer(output_file) if output_file else None
177-
if csv_writer:
178-
csv_writer.writerow([
179-
'Issue Type (Dork)', 'Text Matches', 'File Path',
180-
'Score/Relevance', 'URL of File',
181-
])
182-
for line in dork_file:
183-
dork = line.strip()
184-
if not dork or dork[0] in '#;':
185-
continue
186-
query = dork + scope
187-
stats.queries += 1
188-
try:
189-
for result in iter_search_results(
171+
writer = create_writer(output_format, output_file)
172+
writer.start()
173+
try:
174+
for line in dork_file:
175+
dork = line.strip()
176+
if not dork or dork[0] in '#;':
177+
continue
178+
query = dork + scope
179+
stats.queries += 1
180+
if verbose and not quiet:
181+
status_stream.write(f'Searching: {query}\n')
182+
results = iter_search_results(
190183
client, query, stats, max_retries, sleep, now, stderr
191-
):
192-
stats.matches += 1
193-
_write_result(result, query, csv_writer, stdout)
194-
except Exception as error:
195-
authentication_error = getattr(
196-
github.exceptions, 'AuthenticationFailed', ()
197184
)
198-
if authentication_error and isinstance(error, authentication_error):
199-
raise
200-
stats.failures += 1
201-
stderr.write(f'Query failed: {query}\n{error}\n')
202-
203-
stats.elapsed_seconds = monotonic() - started_at
204-
if not stats.matches and not stats.failures:
205-
stdout.write(f'No results for your dork search{scope}. Hurray!\n')
206-
stdout.write(
207-
f'Summary: {stats.queries} queries, {stats.matches} matches, '
208-
f'{stats.failures} failures, {stats.retries} retries, '
209-
f'{stats.elapsed_seconds:.1f}s elapsed\n'
210-
)
185+
while True:
186+
try:
187+
result = next(results)
188+
except StopIteration:
189+
break
190+
except Exception as error:
191+
authentication_error = getattr(
192+
github.exceptions, 'AuthenticationFailed', ()
193+
)
194+
if authentication_error and isinstance(
195+
error, authentication_error
196+
):
197+
raise
198+
stats.failures += 1
199+
stderr.write(f'Query failed: {query}\n{error}\n')
200+
break
201+
stats.matches += 1
202+
writer.write(_result_record(result, query))
203+
finally:
204+
stats.elapsed_seconds = monotonic() - started_at
205+
writer.finish(stats)
206+
207+
if not quiet:
208+
if not stats.matches and not stats.failures:
209+
status_stream.write(f'No results for your dork search{scope}. Hurray!\n')
210+
status_stream.write(
211+
f'Summary: {stats.queries} queries, {stats.matches} matches, '
212+
f'{stats.failures} failures, {stats.retries} retries, '
213+
f'{stats.elapsed_seconds:.1f}s elapsed\n'
214+
)
211215
return stats

0 commit comments

Comments
 (0)