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.
- ghostrules/__init__.py +6 -0
- ghostrules/_compat.py +12 -0
- ghostrules/clean.py +41 -0
- ghostrules/cli.py +319 -0
- ghostrules/config.py +93 -0
- ghostrules/jsonloc.py +194 -0
- ghostrules/markdown_utils.py +59 -0
- ghostrules/mcp_config.py +243 -0
- ghostrules/models.py +188 -0
- ghostrules/py.typed +0 -0
- ghostrules/reporters/__init__.py +1 -0
- ghostrules/reporters/common.py +86 -0
- ghostrules/reporters/json_.py +45 -0
- ghostrules/reporters/markdown.py +55 -0
- ghostrules/reporters/sarif.py +137 -0
- ghostrules/reporters/table.py +235 -0
- ghostrules/reveal.py +90 -0
- ghostrules/rules/__init__.py +16 -0
- ghostrules/rules/agent_settings.py +159 -0
- ghostrules/rules/helpers.py +118 -0
- ghostrules/rules/injection.py +317 -0
- ghostrules/rules/mcp.py +445 -0
- ghostrules/rules/patterns.py +187 -0
- ghostrules/rules/registry.py +78 -0
- ghostrules/rules/unicode.py +503 -0
- ghostrules/scanner.py +244 -0
- ghostrules/secrets.py +219 -0
- ghostrules/targets.py +220 -0
- ghostrules/unicode_utils.py +138 -0
- ghostrules-0.1.0.dist-info/METADATA +333 -0
- ghostrules-0.1.0.dist-info/RECORD +34 -0
- ghostrules-0.1.0.dist-info/WHEEL +4 -0
- ghostrules-0.1.0.dist-info/entry_points.txt +2 -0
- ghostrules-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
ghostrules/mcp_config.py
ADDED
|
@@ -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("<", "<").replace(">", ">")
|
|
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"
|