ghostrules 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ """Small Markdown helpers shared by rules (fenced code block detection)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import NamedTuple
7
+
8
+ _OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
9
+
10
+
11
+ class FencedBlock(NamedTuple):
12
+ """A fenced code block. ``start``/``end`` are character offsets (end exclusive) covering the
13
+ fence lines too; ``lang`` is the first word of the info string (lower-case, may be empty)."""
14
+
15
+ start: int
16
+ end: int
17
+ lang: str
18
+
19
+
20
+ def fenced_blocks(text: str) -> list[FencedBlock]:
21
+ """Find fenced code blocks. An unterminated fence runs to the end of the text, as it does
22
+ when rendered."""
23
+ blocks: list[FencedBlock] = []
24
+ offset = 0
25
+ open_fence: tuple[str, int, int, str] | None = None # (char, length, start, lang)
26
+ for raw in text.split("\n"):
27
+ line = raw.removesuffix("\r")
28
+ line_end = offset + len(raw) + 1
29
+ if open_fence is None:
30
+ match = _OPEN_RE.match(line)
31
+ if match and not (match.group(1)[0] == "`" and "`" in match.group(2)):
32
+ info = match.group(2).strip().split()
33
+ lang = info[0].lower() if info else ""
34
+ open_fence = (match.group(1)[0], len(match.group(1)), offset, lang)
35
+ else:
36
+ char, length, start, lang = open_fence
37
+ stripped = line.strip()
38
+ indent = len(line) - len(line.lstrip(" "))
39
+ if indent <= 3 and len(stripped) >= length and set(stripped) == {char}:
40
+ blocks.append(FencedBlock(start, min(line_end, len(text)), lang))
41
+ open_fence = None
42
+ offset = line_end
43
+ if open_fence is not None:
44
+ blocks.append(FencedBlock(open_fence[2], len(text), open_fence[3]))
45
+ return blocks
46
+
47
+
48
+ _INLINE_CODE_RE = re.compile(r"(?<!`)(`+)(?!`)(.+?)(?<!`)\1(?!`)")
49
+
50
+
51
+ def inline_code_spans(text: str) -> list[tuple[int, int]]:
52
+ """Spans (start, end offsets) of inline code such as ``<!-- x -->``. A span never crosses a
53
+ line, and an unmatched backtick does not open one."""
54
+ spans: list[tuple[int, int]] = []
55
+ offset = 0
56
+ for raw in text.split("\n"):
57
+ spans.extend((offset + m.start(), offset + m.end()) for m in _INLINE_CODE_RE.finditer(raw))
58
+ offset += len(raw) + 1
59
+ return spans
@@ -0,0 +1,243 @@
1
+ """Normalise MCP server definitions from agent config files, keeping source locations.
2
+
3
+ Supported shapes: ``mcpServers`` (Claude Code/Desktop, Cursor, Gemini, Roo, Windsurf, also nested
4
+ under ``projects.*`` in ``~/.claude.json``), ``servers`` (VS Code, optionally under ``mcp``) and
5
+ ``[mcp_servers.*]`` tables in Codex TOML. Locations are character offsets into the file text, so
6
+ rules can point at the exact value (a secret, a URL, an argument) rather than at the file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from ghostrules.jsonloc import JNode, parse_located
17
+
18
+ if TYPE_CHECKING:
19
+ from ghostrules.models import ScanTarget
20
+
21
+ Span = tuple[int, int]
22
+ _URL_KEYS = ("url", "serverUrl", "httpUrl")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ServerDef:
27
+ """One MCP server. ``spans`` maps ``name``, ``command``, ``url``, ``trust``, ``args.<i>``,
28
+ ``env.<KEY>`` and ``headers.<KEY>`` to the span of that value in the file text."""
29
+
30
+ name: str
31
+ command: str | None = None
32
+ args: tuple[str, ...] = ()
33
+ env: Mapping[str, str] = field(default_factory=dict)
34
+ url: str | None = None
35
+ headers: Mapping[str, str] = field(default_factory=dict)
36
+ trust: bool = False
37
+ spans: Mapping[str, Span] = field(default_factory=dict)
38
+
39
+ def span(self, key: str) -> Span:
40
+ """Span of a field, falling back to the server's own key."""
41
+ return self.spans.get(key) or self.spans.get("name") or (0, 0)
42
+
43
+ @property
44
+ def command_name(self) -> str:
45
+ """Executable name without its directory or Windows extension, lower-cased."""
46
+ if not self.command:
47
+ return ""
48
+ base = re.split(r"[\\/]", self.command.strip())[-1].lower()
49
+ return re.sub(r"\.(?:exe|cmd|bat|ps1)$", "", base)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class EnvEntry:
54
+ """A ``KEY=value`` pair from an ``env`` table, with the span of its value."""
55
+
56
+ key: str
57
+ value: str
58
+ span: Span
59
+
60
+
61
+ def _str_map(node: JNode | None, prefix: str, spans: dict[str, Span]) -> dict[str, str]:
62
+ out: dict[str, str] = {}
63
+ if node is None or node.members is None:
64
+ return out
65
+ for key, (_key_start, child) in node.members.items():
66
+ if isinstance(child.value, str):
67
+ out[key] = child.value
68
+ spans[f"{prefix}.{key}"] = child.string_span()
69
+ return out
70
+
71
+
72
+ def _server_from_json(name: str, key_start: int, node: JNode) -> ServerDef | None:
73
+ if node.members is None:
74
+ return None
75
+ spans: dict[str, Span] = {"name": (key_start, key_start + len(name) + 2)}
76
+ command_node = node.get("command")
77
+ command = command_node.value if command_node and isinstance(command_node.value, str) else None
78
+ if command_node is not None and command is not None:
79
+ spans["command"] = command_node.string_span()
80
+ url: str | None = None
81
+ for key in _URL_KEYS:
82
+ url_node = node.get(key)
83
+ if url_node is not None and isinstance(url_node.value, str):
84
+ url = url_node.value
85
+ spans["url"] = url_node.string_span()
86
+ break
87
+ if command is None and url is None:
88
+ return None
89
+ args: list[str] = []
90
+ args_node = node.get("args")
91
+ if args_node is not None and args_node.elements is not None:
92
+ for element in args_node.elements:
93
+ if isinstance(element.value, str):
94
+ spans[f"args.{len(args)}"] = element.string_span()
95
+ args.append(element.value)
96
+ trust_node = node.get("trust")
97
+ trust = bool(trust_node and trust_node.value is True)
98
+ if trust_node is not None:
99
+ spans["trust"] = trust_node.start, trust_node.end
100
+ return ServerDef(
101
+ name=name,
102
+ command=command,
103
+ args=tuple(args),
104
+ env=_str_map(node.get("env"), "env", spans),
105
+ url=url,
106
+ headers=_str_map(node.get("headers"), "headers", spans),
107
+ trust=trust,
108
+ spans=spans,
109
+ )
110
+
111
+
112
+ def _json_containers(root: JNode) -> list[JNode]:
113
+ found: list[JNode] = []
114
+
115
+ def add(parent: JNode | None, key: str) -> None:
116
+ child = parent.get(key) if parent else None
117
+ if child is not None and child.members is not None:
118
+ found.append(child)
119
+
120
+ add(root, "mcpServers")
121
+ add(root, "servers")
122
+ add(root.get("mcp"), "servers")
123
+ projects = root.get("projects")
124
+ if projects is not None and projects.members is not None:
125
+ for _key_start, project in projects.members.values():
126
+ add(project, "mcpServers")
127
+ return found
128
+
129
+
130
+ def _servers_from_json(text: str) -> list[ServerDef]:
131
+ root = parse_located(text)
132
+ if root is None or root.members is None:
133
+ return []
134
+ servers: list[ServerDef] = []
135
+ for container in _json_containers(root):
136
+ assert container.members is not None
137
+ for name, (key_start, node) in container.members.items():
138
+ server = _server_from_json(name, key_start, node)
139
+ if server is not None:
140
+ servers.append(server)
141
+ return servers
142
+
143
+
144
+ def _toml_region(text: str, name: str) -> Span:
145
+ """Span from the ``[mcp_servers.NAME]`` header to the next unrelated table header."""
146
+ quoted = re.escape(name)
147
+ header = re.compile(
148
+ rf"^[ \t]*\[\s*mcp_servers\s*\.\s*(?:\"{quoted}\"|'{quoted}'|{quoted})\s*[.\]]",
149
+ re.MULTILINE,
150
+ )
151
+ first = header.search(text)
152
+ if first is None:
153
+ return 0, len(text)
154
+ end = len(text)
155
+ for other in re.finditer(r"^[ \t]*\[", text[first.end() :], re.MULTILINE):
156
+ line_start = first.end() + other.start()
157
+ if not header.match(text, line_start):
158
+ end = line_start
159
+ break
160
+ return first.start(), end
161
+
162
+
163
+ def _toml_find(text: str, region: Span, needle: str, fallback: Span) -> Span:
164
+ index = text.find(needle, region[0], region[1])
165
+ return (index, index + len(needle)) if index != -1 and needle else fallback
166
+
167
+
168
+ def _servers_from_toml(text: str, parsed: dict[str, Any]) -> list[ServerDef]:
169
+ table = parsed.get("mcp_servers")
170
+ if not isinstance(table, dict):
171
+ return []
172
+ servers: list[ServerDef] = []
173
+ for name, node in table.items():
174
+ if not isinstance(node, dict):
175
+ continue
176
+ region = _toml_region(text, name)
177
+ header = (region[0], region[0])
178
+ spans: dict[str, Span] = {"name": header}
179
+ command = node.get("command") if isinstance(node.get("command"), str) else None
180
+ url = next((node[k] for k in _URL_KEYS if isinstance(node.get(k), str)), None)
181
+ if command is None and url is None:
182
+ continue
183
+ if command:
184
+ spans["command"] = _toml_find(text, region, command, header)
185
+ if url:
186
+ spans["url"] = _toml_find(text, region, url, header)
187
+ args = tuple(a for a in node.get("args", []) if isinstance(a, str))
188
+ for i, arg in enumerate(args):
189
+ spans[f"args.{i}"] = _toml_find(text, region, arg, header)
190
+ maps: dict[str, dict[str, str]] = {"env": {}, "headers": {}}
191
+ for prefix, keys in (("env", ("env",)), ("headers", ("http_headers", "headers"))):
192
+ for key in keys:
193
+ raw = node.get(key)
194
+ if isinstance(raw, dict):
195
+ for k, v in raw.items():
196
+ if isinstance(v, str):
197
+ maps[prefix][k] = v
198
+ spans[f"{prefix}.{k}"] = _toml_find(text, region, v, header)
199
+ servers.append(
200
+ ServerDef(
201
+ name=name,
202
+ command=command,
203
+ args=args,
204
+ env=maps["env"],
205
+ url=url,
206
+ headers=maps["headers"],
207
+ trust=node.get("trust") is True,
208
+ spans=spans,
209
+ )
210
+ )
211
+ return servers
212
+
213
+
214
+ def extract_servers(target: ScanTarget) -> list[ServerDef]:
215
+ """All MCP servers defined in a config target (empty for other file kinds)."""
216
+ if target.kind != "config" or not isinstance(target.parsed, dict):
217
+ return []
218
+ if target.path.lower().endswith(".toml"):
219
+ return _servers_from_toml(target.text, target.parsed)
220
+ return _servers_from_json(target.text)
221
+
222
+
223
+ _SETTINGS_RE = re.compile(r"(?:^|/)settings(?:\.local)?\.json$", re.IGNORECASE)
224
+
225
+
226
+ def is_settings_file(path: str) -> bool:
227
+ """Whether the path is an agent ``settings.json`` / ``settings.local.json``."""
228
+ return _SETTINGS_RE.search(path.replace("\\", "/")) is not None
229
+
230
+
231
+ def settings_env_entries(target: ScanTarget) -> list[EnvEntry]:
232
+ """Entries of the top-level ``env`` table of a settings file."""
233
+ if target.kind != "config" or not is_settings_file(target.path):
234
+ return []
235
+ root = parse_located(target.text)
236
+ env = root.get("env") if root else None
237
+ if env is None or env.members is None:
238
+ return []
239
+ return [
240
+ EnvEntry(key, node.value, node.string_span())
241
+ for key, (_start, node) in env.members.items()
242
+ if isinstance(node.value, str)
243
+ ]
ghostrules/models.py ADDED
@@ -0,0 +1,188 @@
1
+ """Core data models: severities, findings, scan targets and scan results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from bisect import bisect_right
7
+ from dataclasses import dataclass, field
8
+ from functools import cached_property
9
+ from typing import TYPE_CHECKING, Any, Literal
10
+
11
+ from ghostrules.markdown_utils import FencedBlock, fenced_blocks, inline_code_spans
12
+ from ghostrules.secrets import mask_secrets
13
+
14
+ if TYPE_CHECKING:
15
+ from ghostrules.mcp_config import ServerDef
16
+
17
+ Kind = Literal["instructions", "config", "other"]
18
+ """What a scanned file is: agent instructions, agent/MCP config, or any other text file."""
19
+
20
+
21
+ class Severity(enum.IntEnum):
22
+ """Finding severity. Ordered: ``CRITICAL > HIGH > MEDIUM > LOW > INFO``."""
23
+
24
+ INFO = 0
25
+ LOW = 1
26
+ MEDIUM = 2
27
+ HIGH = 3
28
+ CRITICAL = 4
29
+
30
+ @classmethod
31
+ def parse(cls, value: str) -> Severity:
32
+ """Parse a case-insensitive severity name, raising ``ValueError`` if unknown."""
33
+ try:
34
+ return cls[value.strip().upper()]
35
+ except KeyError:
36
+ names = ", ".join(s.label for s in cls)
37
+ raise ValueError(f"unknown severity {value!r} (expected one of: {names})") from None
38
+
39
+ @property
40
+ def label(self) -> str:
41
+ """Lower-case name, as used in CLI flags, config and reports."""
42
+ return self.name.lower()
43
+
44
+ def bumped(self) -> Severity:
45
+ """One level more severe, capped at ``CRITICAL``."""
46
+ return Severity(min(int(self) + 1, int(Severity.CRITICAL)))
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Finding:
51
+ """A single issue reported by a rule. Line/column are 1-based, in Unicode code points."""
52
+
53
+ rule_id: str
54
+ severity: Severity
55
+ title: str
56
+ message: str
57
+ path: str
58
+ line: int
59
+ column: int
60
+ end_column: int
61
+ snippet: str
62
+ decoded: str | None = None
63
+ fix_hint: str = ""
64
+
65
+ def __post_init__(self) -> None:
66
+ # Single choke point: whatever built this finding, no secret survives in its text, so no
67
+ # reporter can print one.
68
+ for name in ("title", "message", "snippet", "decoded", "fix_hint"):
69
+ value = getattr(self, name)
70
+ if value:
71
+ object.__setattr__(self, name, mask_secrets(value))
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ """JSON-serialisable representation."""
75
+ return {
76
+ "rule_id": self.rule_id,
77
+ "severity": self.severity.label,
78
+ "title": self.title,
79
+ "message": self.message,
80
+ "path": self.path,
81
+ "line": self.line,
82
+ "column": self.column,
83
+ "end_column": self.end_column,
84
+ "snippet": self.snippet,
85
+ "decoded": self.decoded,
86
+ "fix_hint": self.fix_hint,
87
+ }
88
+
89
+
90
+ @dataclass
91
+ class ScanTarget:
92
+ """A file (or derived text) handed to rules. Rules must treat it as read-only.
93
+
94
+ ``origin`` is ``None`` for real files; for text derived from a finding (e.g. decoded hidden
95
+ text) it holds the id of the rule that produced it. ``allow_domains`` is the configured
96
+ allow-list, handed to rules as read-only input so they stay pure.
97
+ """
98
+
99
+ path: str
100
+ kind: Kind
101
+ text: str
102
+ parsed: Any = None
103
+ parse_error: str | None = None
104
+ origin: str | None = None
105
+ allow_domains: tuple[str, ...] = ()
106
+ lines: list[str] = field(init=False)
107
+
108
+ def __post_init__(self) -> None:
109
+ self.lines = [line.removesuffix("\r") for line in self.text.split("\n")]
110
+
111
+ @cached_property
112
+ def _line_starts(self) -> list[int]:
113
+ starts = [0]
114
+ for i, ch in enumerate(self.text):
115
+ if ch == "\n":
116
+ starts.append(i + 1)
117
+ return starts
118
+
119
+ @cached_property
120
+ def normalized(self) -> tuple[str, list[int]]:
121
+ """Whitespace-normalised text plus, for each of its characters, the original offset.
122
+
123
+ Every run of whitespace becomes one space, so phrases broken across lines still match.
124
+ """
125
+ chars: list[str] = []
126
+ offsets: list[int] = []
127
+ in_space = False
128
+ for i, ch in enumerate(self.text):
129
+ if ch.isspace():
130
+ if not in_space:
131
+ chars.append(" ")
132
+ offsets.append(i)
133
+ in_space = True
134
+ else:
135
+ chars.append(ch)
136
+ offsets.append(i)
137
+ in_space = False
138
+ return "".join(chars), offsets
139
+
140
+ @cached_property
141
+ def servers(self) -> list[ServerDef]:
142
+ """MCP servers defined in this config file (computed once)."""
143
+ from ghostrules.mcp_config import extract_servers
144
+
145
+ return extract_servers(self)
146
+
147
+ @cached_property
148
+ def code_blocks(self) -> list[FencedBlock]:
149
+ """Fenced Markdown code blocks in the text (computed once)."""
150
+ return fenced_blocks(self.text)
151
+
152
+ @cached_property
153
+ def inline_code(self) -> list[tuple[int, int]]:
154
+ """Spans of inline code (backticks) in the text (computed once)."""
155
+ return inline_code_spans(self.text)
156
+
157
+ def in_inline_code(self, offset: int) -> bool:
158
+ """Whether ``offset`` is inside an inline code span."""
159
+ return any(start <= offset < end for start, end in self.inline_code)
160
+
161
+ def code_block_at(self, offset: int) -> FencedBlock | None:
162
+ """The fenced code block containing ``offset``, if any."""
163
+ for block in self.code_blocks:
164
+ if block.start <= offset < block.end:
165
+ return block
166
+ return None
167
+
168
+ def position(self, offset: int) -> tuple[int, int]:
169
+ """Convert a character offset into a 1-based ``(line, column)`` pair."""
170
+ index = bisect_right(self._line_starts, offset) - 1
171
+ return index + 1, offset - self._line_starts[index] + 1
172
+
173
+
174
+ @dataclass
175
+ class ScanResult:
176
+ """Everything a scan produced.
177
+
178
+ ``suppressed`` counts findings hidden by inline ``ghostrules-ignore`` comments, so reports can
179
+ surface it. ``warnings`` are non-fatal (unreadable file, unparsable JSON); ``errors`` are rule
180
+ crashes, which callers should treat as a failed scan.
181
+ """
182
+
183
+ findings: list[Finding] = field(default_factory=list)
184
+ scanned_files: int = 0
185
+ suppressed: int = 0
186
+ config_source: str | None = None
187
+ warnings: list[str] = field(default_factory=list)
188
+ errors: list[str] = field(default_factory=list)
ghostrules/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Output formatters (table, json, sarif, markdown)."""
@@ -0,0 +1,86 @@
1
+ """Helpers shared by the reporters (no ``render`` here: this is not a reporter)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from dataclasses import dataclass
7
+
8
+ from ghostrules.models import Finding, ScanResult, Severity
9
+
10
+ SEVERITY_ORDER = (
11
+ Severity.CRITICAL,
12
+ Severity.HIGH,
13
+ Severity.MEDIUM,
14
+ Severity.LOW,
15
+ Severity.INFO,
16
+ )
17
+
18
+
19
+ def safe(text: str) -> str:
20
+ """Replace lone surrogates (from undecodable input bytes) so output can always be encoded."""
21
+ return text.encode("utf-8", errors="replace").decode("utf-8")
22
+
23
+
24
+ def severity_counts(findings: list[Finding]) -> dict[str, int]:
25
+ """Finding count per severity label, most severe first, omitting zeros."""
26
+ counts = Counter(f.severity for f in findings)
27
+ return {s.label: counts[s] for s in SEVERITY_ORDER if counts[s]}
28
+
29
+
30
+ def summary_line(result: ScanResult) -> str:
31
+ """One-line summary, e.g. ``3 critical Β· 2 high in 4 files (scanned 17)``."""
32
+ counts = severity_counts(result.findings)
33
+ if not counts and result.errors:
34
+ return f"Scan incomplete (scanned {result.scanned_files} files)"
35
+ if not counts:
36
+ return f"No findings (scanned {result.scanned_files} files)"
37
+ parts = " Β· ".join(f"{n} {label}" for label, n in counts.items())
38
+ files = len({f.path for f in result.findings})
39
+ noun = "file" if files == 1 else "files"
40
+ return f"{parts} in {files} {noun} (scanned {result.scanned_files})"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class FileGroup:
45
+ """All findings of one file, ordered for display."""
46
+
47
+ path: str
48
+ findings: list[Finding]
49
+
50
+ @property
51
+ def worst(self) -> Severity:
52
+ return max(f.severity for f in self.findings)
53
+
54
+
55
+ def group_by_file(findings: list[Finding]) -> list[FileGroup]:
56
+ """Group findings by file. Files with the worst findings come first; among equals, a file
57
+ hiding decoded text comes before one that does not, then files nearer the root, then path."""
58
+ by_path: dict[str, list[Finding]] = {}
59
+ for f in findings:
60
+ by_path.setdefault(f.path, []).append(f)
61
+ groups = [
62
+ FileGroup(
63
+ path, sorted(items, key=lambda f: (-int(f.severity), f.line, f.column, f.rule_id))
64
+ )
65
+ for path, items in by_path.items()
66
+ ]
67
+ return sorted(
68
+ groups,
69
+ key=lambda g: (
70
+ -int(g.worst),
71
+ not any(f.decoded for f in g.findings),
72
+ g.path.count("/"),
73
+ g.path,
74
+ ),
75
+ )
76
+
77
+
78
+ def config_notes(result: ScanResult) -> list[str]:
79
+ """Things a reader should know that can hide findings: repo-supplied config, suppressions."""
80
+ notes = []
81
+ if result.config_source:
82
+ notes.append(f"configuration loaded from {result.config_source}")
83
+ if result.suppressed:
84
+ noun = "finding" if result.suppressed == 1 else "findings"
85
+ notes.append(f"{result.suppressed} {noun} hidden by ghostrules-ignore comments")
86
+ return notes
@@ -0,0 +1,45 @@
1
+ """JSON report: ``{"version", "scanned_files", "findings", "summary", ...}``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from ghostrules import __version__
9
+ from ghostrules.models import ScanResult
10
+ from ghostrules.reporters.common import safe, severity_counts
11
+
12
+
13
+ def _clean(value: Any) -> Any:
14
+ if isinstance(value, str):
15
+ return safe(value)
16
+ if isinstance(value, dict):
17
+ return {k: _clean(v) for k, v in value.items()}
18
+ if isinstance(value, list):
19
+ return [_clean(v) for v in value]
20
+ return value
21
+
22
+
23
+ def to_dict(result: ScanResult) -> dict[str, Any]:
24
+ """The report as plain data."""
25
+ return _clean(
26
+ {
27
+ "version": __version__,
28
+ "scanned_files": result.scanned_files,
29
+ "findings": [f.to_dict() for f in result.findings],
30
+ "summary": {
31
+ "total": len(result.findings),
32
+ "by_severity": severity_counts(result.findings),
33
+ "files_with_findings": len({f.path for f in result.findings}),
34
+ "suppressed": result.suppressed,
35
+ "config_source": result.config_source,
36
+ },
37
+ "warnings": result.warnings,
38
+ "errors": result.errors,
39
+ }
40
+ )
41
+
42
+
43
+ def render(result: ScanResult, **_options: Any) -> str:
44
+ """Render the JSON report."""
45
+ return json.dumps(to_dict(result), indent=2, ensure_ascii=False) + "\n"
@@ -0,0 +1,55 @@
1
+ """Markdown report, compact enough for a PR comment or ``$GITHUB_STEP_SUMMARY``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ghostrules.models import Finding, ScanResult, Severity
8
+ from ghostrules.reporters.common import config_notes, safe, summary_line
9
+
10
+ MAX_ROWS = 100
11
+ _ICON = {
12
+ Severity.CRITICAL: "πŸ”΄",
13
+ Severity.HIGH: "🟠",
14
+ Severity.MEDIUM: "🟑",
15
+ Severity.LOW: "πŸ”΅",
16
+ Severity.INFO: "βšͺ",
17
+ }
18
+
19
+
20
+ def _cell(text: str) -> str:
21
+ """Make text safe inside a table cell and inert as Markdown/HTML."""
22
+ text = safe(text).replace("\\", "\\\\").replace("|", "\\|").replace("`", "'")
23
+ text = text.replace("<", "&lt;").replace(">", "&gt;")
24
+ return " ".join(text.split())
25
+
26
+
27
+ def _row(f: Finding) -> str:
28
+ where = f"`{_cell(f.path)}:{f.line}:{f.column}`"
29
+ detail = _cell(f.message)
30
+ if f.decoded:
31
+ detail += f"<br>**Decoded hidden text:** `{_cell(f.decoded)}`"
32
+ return (
33
+ f"| {_ICON[f.severity]} {f.severity.label} | `{f.rule_id}` | {where} | "
34
+ f"**{_cell(f.title)}**<br>{detail} |"
35
+ )
36
+
37
+
38
+ def render(result: ScanResult, **_options: Any) -> str:
39
+ """Render the Markdown report."""
40
+ lines = ["## ghostrules", ""]
41
+ if not result.findings:
42
+ lines.append(f"βœ… **{summary_line(result)}**")
43
+ else:
44
+ lines += [f"**{summary_line(result)}**", ""]
45
+ lines += ["| Severity | Rule | Location | Finding |", "| --- | --- | --- | --- |"]
46
+ lines += [_row(f) for f in result.findings[:MAX_ROWS]]
47
+ extra = len(result.findings) - MAX_ROWS
48
+ if extra > 0:
49
+ lines += ["", f"_…and {extra} more findings (see the JSON or SARIF report)._"]
50
+ notes = config_notes(result)
51
+ if notes:
52
+ lines += [""] + [f"> **Note:** {_cell(n)}" for n in notes]
53
+ lines += [f"> **Warning:** {_cell(w)}" for w in result.warnings]
54
+ lines += [f"> **Error:** {_cell(e)}" for e in result.errors]
55
+ return "\n".join(lines) + "\n"