Skip to content

Add /export slash command (SARIF 2.1.0, Markdown, JSON) - #28

Open
aijadugar wants to merge 3 commits into
openhackai:mainfrom
aijadugar:main
Open

aijadugar wants to merge 3 commits into
openhackai:mainfrom
aijadugar:main

Conversation

@aijadugar

Copy link
Copy Markdown

Summary

Adds /export, a slash command that writes all loaded findings to a shareable file. Today findings can only be copied one at a time with /copy, and the only report is the raw session JSON. There's no way to get results into CI or GitHub code scanning.

/export                      # SARIF → ./openhack-findings.sarif
/export md                   # Markdown report → ./openhack-findings.md
/export json ~/reports/      # JSON into a directory (default filename)
/export @out/results.sarif   # lone path: format inferred from the extension

Works on a fresh scan or on a session reopened via /sessions.

What's included

  • SARIF 2.1.0 (default). It can be uploaded to GitHub code scanning (github/codeql-action/upload-sarif) and opened in VS Code's SARIF viewer. Each category becomes a rule tagged with its CWE. Paths are relative to the scanned directory (%SRCROOT%), and fingerprints are unique per finding so alerts don't collapse together.
  • Markdown report with a severity summary table.
  • JSON findings dump.
  • Safe to share: token-like strings are redacted in every format using the existing redact() helper. PoC payloads are omitted from SARIF and Markdown. The JSON export keeps them (redacted), so it should be treated like the session report.
  • Safe writes: atomic (temp file + rename) and owner-only permissions, since the contents are vulnerability data. Existing files are overwritten.
  • Docs: README command-table row and a short "Exporting findings" section. /export also appears in /help, command completion and the rotating tips.

Design notes

  • Follows AGENTS.md: this is a slash command inside the interactive agent, not a new CLI subcommand.
  • The logic lives in openhack/export.py with no TUI or prompt_toolkit imports, so it's tested directly. tui.py only gets a thin _cmd_export handler, the registry entry, a tip and the dispatch branch.
  • parse_format() returns None for a non-format word, so /export <path> can be told apart from /export md. write_export() accepts a directory and uses the default filename for the format.

Testing

tests/test_export.py adds 33 tests covering:

  • SARIF structure, level mapping, relative paths, path:line parsing and fingerprint uniqueness.
  • Redaction and PoC omission.
  • Markdown fence safety and the empty case.
  • write_export behaviour.
  • Command-level cases: default SARIF in the current directory, format word then path or directory, a lone path with format inference, an unknown extension, a missing directory, no findings, and a live scan supplying the target.

The /export <path> and plain /export cases are regression tests. Reverting the parse_format and directory-handling changes makes 8 of them fail.

uv sync --dev && uv run python -m pytest tests/ -q   # 544 passed

Run on Linux with Python 3.12. CI covers 3.11–3.13.

Not in this PR

  • The SARIF security-severity is a per-category default, not derived from each finding's CVSS score. That would be a reasonable follow-up.
  • A Findings-tab key binding for export (e.g. e) could call the same function later.

@greptile-apps

greptile-apps Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5

[Medium impact] Adds a new slash command to export findings to files.

Fix the default and path-only export forms and remove tokens from Markdown headings before merging.

Fix All in CodexFindings

  1. P1 Path-only export fails ▶
  2. P1 Default export writes nothing ▶
  3. P1 Security Report headings expose tokens ▶
  4. P2 Report paths point elsewhere ▶
  5. P2 Write failures lack feedback ▶
  6. P2 Repeated findings get new identities ▶
Fix with agent prompt
### Issue 1
openhack/tui.py:5149-5151
`/export @out/results.sarif` calls `normalize_format()` on the path. That helper raises `ExportError` instead of returning `None`, so format inference is never reached. The call also sits outside the error handler, leaving the user with no export and no helpful status message. Use a non-raising format lookup here, then infer the format from the remaining path.

### Issue 2
openhack/export.py:261-263
Plain `/export` passes the current directory to `write_export()`, which rejects directories. `/export md` and `/export json ~/reports/` fail for the same reason. Append `default_filename(fmt)` when the destination is a directory so the advertised default and directory forms write a file.

```suggestion
    dest_path = Path(dest)
    if dest_path.is_dir():
        dest_path = dest_path / default_filename(fmt)
```

### Issue 3
openhack/export.py:195
Markdown headings write `finding.title` without `_scrub()`. If a Hardcoded Secret finding includes a token in its title, such as `ghp_` followed by 20 letters, the report preserves it unchanged. `report_finding()` stores the supplied title without removing tokens. Descriptions, snippets, and fixes use the protection that the heading skips. Scrub the title before adding it to the report.

**How this was verified:** `report_finding()` stores the supplied title unchanged, and the Markdown heading writes it directly even though `redact()` removes matching tokens.

```suggestion
        lines += [f"### {index}. {_scrub(finding.title or category)}", ""]
```

### Issue 4
openhack/export.py:354-355
`_artifact_uri()` writes relative filenames into a URI without escaping them. A filename such as `src/a#b.py` becomes a reference to `src/a` with a fragment. A literal `%20` in a filename can be read as a space. Reports for these files can point readers to the wrong source. Escape relative path components while preserving `/`, including the branch that makes absolute paths relative to the target.

### Issue 5
openhack/export.py:268-270
`tempfile.mkstemp()` runs outside the block that converts write errors into `ExportError`. If `/export json /readonly/report.json` targets an existing but unwritable directory, the directory check passes and temporary-file creation raises an uncaught `OSError`. The user gets no useful error in the status line. Catch temporary-file creation errors too, without trying to clean up a file that was never created.

### Issue 6
openhack/export.py:374-377
Findings with the same category, file, and title get fingerprints that depend on discovery order and a random `finding.id`. `report_finding()` allows two same-title findings at different lines. The first gets the shared base fingerprint; later findings get fingerprints that change on a fresh scan because their IDs change. Consumers that match by fingerprint can treat unchanged findings as new. Build fingerprints from stable location details instead of order and random IDs.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds /export to save loaded findings as SARIF, Markdown, or JSON.

  • OpenHack can save all loaded findings in three report formats.
  • Export files are replaced in one step and kept owner-only.

Reviews (1) · Last reviewed commit: "Merge pull request #3 from aijadugar/fea..." · Reviewed by Greptile

Comment thread openhack/tui.py
Comment on lines +5149 to +5151
fmt = export_mod.normalize_format(tokens[0])
if fmt is not None:
tokens = tokens[1:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Path-only export fails

/export @out/results.sarif calls normalize_format() on the path. That helper raises ExportError instead of returning None, so format inference is never reached. The call also sits outside the error handler, leaving the user with no export and no helpful status message. Use a non-raising format lookup here, then infer the format from the remaining path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: openhack/tui.py
Line: 5149-5151

Comment:
**Path-only export fails**

`/export @out/results.sarif` calls `normalize_format()` on the path. That helper raises `ExportError` instead of returning `None`, so format inference is never reached. The call also sits outside the error handler, leaving the user with no export and no helpful status message. Use a non-raising format lookup here, then infer the format from the remaining path.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

Comment thread openhack/export.py
Comment on lines +261 to +263
dest_path = Path(dest)
if dest_path.is_dir():
raise ExportError(f"{dest_path} is a directory")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Default export writes nothing

Plain /export passes the current directory to write_export(), which rejects directories. /export md and /export json ~/reports/ fail for the same reason. Append default_filename(fmt) when the destination is a directory so the advertised default and directory forms write a file.

Suggested change
dest_path = Path(dest)
if dest_path.is_dir():
raise ExportError(f"{dest_path} is a directory")
dest_path = Path(dest)
if dest_path.is_dir():
dest_path = dest_path / default_filename(fmt)
Prompt To Fix With AI
This is a comment left during a code review.
Path: openhack/export.py
Line: 261-263

Comment:
**Default export writes nothing**

Plain `/export` passes the current directory to `write_export()`, which rejects directories. `/export md` and `/export json ~/reports/` fail for the same reason. Append `default_filename(fmt)` when the destination is a directory so the advertised default and directory forms write a file.

```suggestion
    dest_path = Path(dest)
    if dest_path.is_dir():
        dest_path = dest_path / default_filename(fmt)
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

Comment thread openhack/export.py
uri, _relative, line, _column = _location(finding, target_path)
location = f"{uri}:{line}" if line else uri

lines += [f"### {index}. {finding.title or category}", ""]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Report headings expose tokens

Markdown headings write finding.title without _scrub(). If a Hardcoded Secret finding includes a token in its title, such as ghp_ followed by 20 letters, the report preserves it unchanged. report_finding() stores the supplied title without removing tokens. Descriptions, snippets, and fixes use the protection that the heading skips. Scrub the title before adding it to the report.

How this was verified: report_finding() stores the supplied title unchanged, and the Markdown heading writes it directly even though redact() removes matching tokens.

Suggested change
lines += [f"### {index}. {finding.title or category}", ""]
lines += [f"### {index}. {_scrub(finding.title or category)}", ""]
Prompt To Fix With AI
This is a comment left during a code review.
Path: openhack/export.py
Line: 195

Comment:
**Report headings expose tokens**

Markdown headings write `finding.title` without `_scrub()`. If a Hardcoded Secret finding includes a token in its title, such as `ghp_` followed by 20 letters, the report preserves it unchanged. `report_finding()` stores the supplied title without removing tokens. Descriptions, snippets, and fixes use the protection that the heading skips. Scrub the title before adding it to the report.

**How this was verified:** `report_finding()` stores the supplied title unchanged, and the Markdown heading writes it directly even though `redact()` removes matching tokens.

```suggestion
        lines += [f"### {index}. {_scrub(finding.title or category)}", ""]
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

Comment thread openhack/export.py
Comment on lines +354 to +355
if not path.is_absolute():
return PurePosixPath(*path.parts).as_posix(), True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Report paths point elsewhere

_artifact_uri() writes relative filenames into a URI without escaping them. A filename such as src/a#b.py becomes a reference to src/a with a fragment. A literal %20 in a filename can be read as a space. Reports for these files can point readers to the wrong source. Escape relative path components while preserving /, including the branch that makes absolute paths relative to the target.

Prompt To Fix With AI
This is a comment left during a code review.
Path: openhack/export.py
Line: 354-355

Comment:
**Report paths point elsewhere**

`_artifact_uri()` writes relative filenames into a URI without escaping them. A filename such as `src/a#b.py` becomes a reference to `src/a` with a fragment. A literal `%20` in a filename can be read as a space. Reports for these files can point readers to the wrong source. Escape relative path components while preserving `/`, including the branch that makes absolute paths relative to the target.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

Comment thread openhack/export.py
Comment on lines +268 to +270
fd, tmp_name = tempfile.mkstemp(dir=parent, prefix=f".{dest_path.name}.", suffix=".tmp")
tmp_path = Path(tmp_name)
try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Write failures lack feedback

tempfile.mkstemp() runs outside the block that converts write errors into ExportError. If /export json /readonly/report.json targets an existing but unwritable directory, the directory check passes and temporary-file creation raises an uncaught OSError. The user gets no useful error in the status line. Catch temporary-file creation errors too, without trying to clean up a file that was never created.

Prompt To Fix With AI
This is a comment left during a code review.
Path: openhack/export.py
Line: 268-270

Comment:
**Write failures lack feedback**

`tempfile.mkstemp()` runs outside the block that converts write errors into `ExportError`. If `/export json /readonly/report.json` targets an existing but unwritable directory, the directory check passes and temporary-file creation raises an uncaught `OSError`. The user gets no useful error in the status line. Catch temporary-file creation errors too, without trying to clean up a file that was never created.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

Comment thread openhack/export.py
Comment on lines +374 to +377
base = hashlib.sha256(f"{finding.fingerprint()}::{finding.title}".encode()).hexdigest()[:32]
fingerprint = base
if fingerprint in seen:
fingerprint = hashlib.sha256(f"{base}::{finding.id}".encode()).hexdigest()[:32]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Repeated findings get new identities

Findings with the same category, file, and title get fingerprints that depend on discovery order and a random finding.id. report_finding() allows two same-title findings at different lines. The first gets the shared base fingerprint; later findings get fingerprints that change on a fresh scan because their IDs change. Consumers that match by fingerprint can treat unchanged findings as new. Build fingerprints from stable location details instead of order and random IDs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: openhack/export.py
Line: 374-377

Comment:
**Repeated findings get new identities**

Findings with the same category, file, and title get fingerprints that depend on discovery order and a random `finding.id`. `report_finding()` allows two same-title findings at different lines. The first gets the shared base fingerprint; later findings get fingerprints that change on a fresh scan because their IDs change. Consumers that match by fingerprint can treat unchanged findings as new. Build fingerprints from stable location details instead of order and random IDs.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant