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,137 @@
|
|
|
1
|
+
"""SARIF 2.1.0 report (GitHub code scanning)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from ghostrules import PROJECT_URL, __version__
|
|
11
|
+
from ghostrules.models import Finding, ScanResult, Severity
|
|
12
|
+
from ghostrules.reporters.common import safe
|
|
13
|
+
from ghostrules.rules.registry import Rule, all_rules
|
|
14
|
+
|
|
15
|
+
SCHEMA_URL = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
16
|
+
_LEVEL = {
|
|
17
|
+
Severity.CRITICAL: "error",
|
|
18
|
+
Severity.HIGH: "error",
|
|
19
|
+
Severity.MEDIUM: "warning",
|
|
20
|
+
Severity.LOW: "note",
|
|
21
|
+
Severity.INFO: "note",
|
|
22
|
+
}
|
|
23
|
+
_SECURITY_SEVERITY = {
|
|
24
|
+
Severity.CRITICAL: "9.5",
|
|
25
|
+
Severity.HIGH: "8.0",
|
|
26
|
+
Severity.MEDIUM: "5.5",
|
|
27
|
+
Severity.LOW: "3.0",
|
|
28
|
+
Severity.INFO: "1.0",
|
|
29
|
+
}
|
|
30
|
+
_NOT_REPO_RELATIVE = re.compile(r"^(?:~|/|[A-Za-z]:[\\/])")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_repo_relative(path: str) -> bool:
|
|
34
|
+
"""False for user-level files (``~/...``) and absolute paths, which SARIF cannot place."""
|
|
35
|
+
return _NOT_REPO_RELATIVE.match(path) is None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _rule_entry(rule: Rule) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"id": rule.id,
|
|
41
|
+
"name": rule.title,
|
|
42
|
+
"shortDescription": {"text": rule.title},
|
|
43
|
+
"fullDescription": {"text": rule.description},
|
|
44
|
+
"helpUri": f"{PROJECT_URL}#{rule.id.lower()}",
|
|
45
|
+
"defaultConfiguration": {"level": _LEVEL[rule.severity]},
|
|
46
|
+
"properties": {
|
|
47
|
+
"security-severity": _SECURITY_SEVERITY[rule.severity],
|
|
48
|
+
"tags": ["security", "ai-agents"],
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _result(finding: Finding, rule_index: dict[str, int]) -> dict[str, Any]:
|
|
54
|
+
region: dict[str, Any] = {
|
|
55
|
+
"startLine": finding.line,
|
|
56
|
+
"startColumn": finding.column,
|
|
57
|
+
"endLine": finding.line,
|
|
58
|
+
"endColumn": max(finding.end_column, finding.column),
|
|
59
|
+
"snippet": {"text": safe(finding.snippet)},
|
|
60
|
+
}
|
|
61
|
+
properties: dict[str, Any] = {"security-severity": _SECURITY_SEVERITY[finding.severity]}
|
|
62
|
+
if finding.decoded:
|
|
63
|
+
properties["decodedHiddenText"] = safe(finding.decoded)
|
|
64
|
+
if finding.fix_hint:
|
|
65
|
+
properties["fixHint"] = safe(finding.fix_hint)
|
|
66
|
+
result: dict[str, Any] = {
|
|
67
|
+
"ruleId": finding.rule_id,
|
|
68
|
+
"level": _LEVEL[finding.severity],
|
|
69
|
+
"message": {"text": safe(f"{finding.title}: {finding.message}")},
|
|
70
|
+
"locations": [
|
|
71
|
+
{
|
|
72
|
+
"physicalLocation": {
|
|
73
|
+
"artifactLocation": {
|
|
74
|
+
"uri": quote(safe(finding.path), safe="/"),
|
|
75
|
+
"uriBaseId": "%SRCROOT%",
|
|
76
|
+
},
|
|
77
|
+
"region": region,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
"properties": properties,
|
|
82
|
+
}
|
|
83
|
+
if finding.rule_id in rule_index:
|
|
84
|
+
result["ruleIndex"] = rule_index[finding.rule_id]
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def to_dict(result: ScanResult) -> dict[str, Any]:
|
|
89
|
+
"""The SARIF log as plain data. Findings in user-level files are left out (and counted)."""
|
|
90
|
+
rules = all_rules()
|
|
91
|
+
rule_index = {r.id: i for i, r in enumerate(rules)}
|
|
92
|
+
reportable = [f for f in result.findings if is_repo_relative(f.path)]
|
|
93
|
+
skipped = len(result.findings) - len(reportable)
|
|
94
|
+
notifications = [
|
|
95
|
+
{"level": "warning", "message": {"text": safe(w)}} for w in result.warnings
|
|
96
|
+
] + [{"level": "error", "message": {"text": safe(e)}} for e in result.errors]
|
|
97
|
+
if skipped:
|
|
98
|
+
notifications.append(
|
|
99
|
+
{
|
|
100
|
+
"level": "note",
|
|
101
|
+
"message": {
|
|
102
|
+
"text": f"{skipped} finding(s) in files outside the repository (--home) "
|
|
103
|
+
"are not included in SARIF."
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
return {
|
|
108
|
+
"$schema": SCHEMA_URL,
|
|
109
|
+
"version": "2.1.0",
|
|
110
|
+
"runs": [
|
|
111
|
+
{
|
|
112
|
+
"tool": {
|
|
113
|
+
"driver": {
|
|
114
|
+
"name": "ghostrules",
|
|
115
|
+
"version": __version__,
|
|
116
|
+
"semanticVersion": __version__,
|
|
117
|
+
"informationUri": PROJECT_URL,
|
|
118
|
+
"rules": [_rule_entry(r) for r in rules],
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"columnKind": "unicodeCodePoints",
|
|
122
|
+
"originalUriBaseIds": {"%SRCROOT%": {"uri": "file:///"}},
|
|
123
|
+
"results": [_result(f, rule_index) for f in reportable],
|
|
124
|
+
"invocations": [
|
|
125
|
+
{
|
|
126
|
+
"executionSuccessful": not result.errors,
|
|
127
|
+
"toolExecutionNotifications": notifications,
|
|
128
|
+
}
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def render(result: ScanResult, **_options: Any) -> str:
|
|
136
|
+
"""Render the SARIF 2.1.0 log."""
|
|
137
|
+
return json.dumps(to_dict(result), indent=2, ensure_ascii=False) + "\n"
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Rich terminal report: findings grouped by file, with the hidden text front and centre."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import re
|
|
7
|
+
from typing import IO
|
|
8
|
+
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
|
|
11
|
+
from rich.padding import Padding
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.rule import Rule
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from ghostrules import __version__
|
|
18
|
+
from ghostrules.models import Finding, ScanResult, Severity
|
|
19
|
+
from ghostrules.reporters.common import (
|
|
20
|
+
SEVERITY_ORDER,
|
|
21
|
+
FileGroup,
|
|
22
|
+
config_notes,
|
|
23
|
+
group_by_file,
|
|
24
|
+
safe,
|
|
25
|
+
severity_counts,
|
|
26
|
+
)
|
|
27
|
+
from ghostrules.rules.patterns import COLLECTOR_PATTERNS, EXFIL_PATTERNS, INJECTION_PATTERNS
|
|
28
|
+
|
|
29
|
+
BADGE_STYLE = {
|
|
30
|
+
Severity.CRITICAL: "bold white on red3",
|
|
31
|
+
Severity.HIGH: "bold black on dark_orange",
|
|
32
|
+
Severity.MEDIUM: "bold black on yellow",
|
|
33
|
+
Severity.LOW: "bold white on dodger_blue2",
|
|
34
|
+
Severity.INFO: "bold white on grey42",
|
|
35
|
+
}
|
|
36
|
+
TEXT_STYLE = {
|
|
37
|
+
Severity.CRITICAL: "bold red",
|
|
38
|
+
Severity.HIGH: "bold dark_orange",
|
|
39
|
+
Severity.MEDIUM: "bold yellow",
|
|
40
|
+
Severity.LOW: "bold dodger_blue1",
|
|
41
|
+
Severity.INFO: "bold grey62",
|
|
42
|
+
}
|
|
43
|
+
_TOKEN_RE = re.compile(r"⟦[^⟧]*⟧")
|
|
44
|
+
_URL_RE = re.compile(r"https?://[^\s)\]>\"']+")
|
|
45
|
+
_TOKEN_STYLE = "bold bright_red"
|
|
46
|
+
_DECODED_STYLE = "bold bright_yellow"
|
|
47
|
+
_MATCH_STYLE = "bold white on red3"
|
|
48
|
+
_MAX_WIDTH = 120
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def styled_snippet(snippet: str) -> Text:
|
|
52
|
+
"""The snippet with every ``⟦U+XXXX⟧`` token highlighted, so invisible characters stand out."""
|
|
53
|
+
text = Text(safe(snippet), no_wrap=False)
|
|
54
|
+
for match in _TOKEN_RE.finditer(text.plain):
|
|
55
|
+
text.stylize(_TOKEN_STYLE, match.start(), match.end())
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def styled_decoded(decoded: str) -> Text:
|
|
60
|
+
"""Hidden text with the phrases that look malicious (and URLs) highlighted."""
|
|
61
|
+
text = Text(safe(decoded), style=_DECODED_STYLE)
|
|
62
|
+
plain = text.plain
|
|
63
|
+
patterns = [p for group in INJECTION_PATTERNS.values() for p in group]
|
|
64
|
+
for pattern in (*patterns, *EXFIL_PATTERNS, *COLLECTOR_PATTERNS):
|
|
65
|
+
for match in pattern.finditer(plain):
|
|
66
|
+
text.stylize(_MATCH_STYLE, match.start(), match.end())
|
|
67
|
+
for match in _URL_RE.finditer(plain):
|
|
68
|
+
text.stylize("underline", match.start(), match.end())
|
|
69
|
+
return text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _badge(severity: Severity) -> Text:
|
|
73
|
+
return Text(f" {severity.label.upper():^8} ", style=BADGE_STYLE[severity])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Head:
|
|
77
|
+
"""The first line of a finding: badge, rule id, title and ``path:line:col``.
|
|
78
|
+
|
|
79
|
+
On one row when it fits. On narrow terminals or with long paths the location moves to a
|
|
80
|
+
second line instead of squeezing the title into a few characters.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, finding: Finding) -> None:
|
|
84
|
+
self.badge = _badge(finding.severity)
|
|
85
|
+
self.rule_id = Text(finding.rule_id, style="bold")
|
|
86
|
+
self.title = Text(safe(finding.title), style="bold")
|
|
87
|
+
self.location = Text(
|
|
88
|
+
safe(f"{finding.path}:{finding.line}:{finding.column}"), style="dim", no_wrap=True
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
92
|
+
needed = (
|
|
93
|
+
self.badge.cell_len
|
|
94
|
+
+ self.rule_id.cell_len
|
|
95
|
+
+ self.title.cell_len
|
|
96
|
+
+ self.location.cell_len
|
|
97
|
+
+ 4
|
|
98
|
+
)
|
|
99
|
+
grid = Table.grid(expand=True, padding=(0, 1))
|
|
100
|
+
grid.add_column(no_wrap=True)
|
|
101
|
+
grid.add_column(no_wrap=True)
|
|
102
|
+
grid.add_column(ratio=1)
|
|
103
|
+
if needed <= options.max_width:
|
|
104
|
+
grid.add_column(no_wrap=True, justify="right")
|
|
105
|
+
grid.add_row(self.badge, self.rule_id, self.title, self.location)
|
|
106
|
+
yield grid
|
|
107
|
+
return
|
|
108
|
+
grid.add_row(self.badge, self.rule_id, self.title)
|
|
109
|
+
yield grid
|
|
110
|
+
indent = self.badge.cell_len + 1
|
|
111
|
+
yield Padding(Text.assemble(("at ", "dim"), self.location), (0, 0, 0, indent))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _finding_block(finding: Finding, repeated_hidden_text: bool) -> RenderableType:
|
|
115
|
+
"""One finding. When it was found inside hidden text already shown above, stay compact."""
|
|
116
|
+
parts: list[RenderableType] = [_Head(finding)]
|
|
117
|
+
body: list[RenderableType] = []
|
|
118
|
+
if repeated_hidden_text:
|
|
119
|
+
body.append(Text.assemble(("↳ ", "dim"), safe(finding.message)))
|
|
120
|
+
else:
|
|
121
|
+
gutter = Text(f"{finding.line:>4} │ ", style="dim")
|
|
122
|
+
body.append(
|
|
123
|
+
Text.assemble(
|
|
124
|
+
gutter, styled_snippet(finding.snippet), no_wrap=True, overflow="ellipsis"
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
if finding.decoded:
|
|
128
|
+
body.append(
|
|
129
|
+
Panel(
|
|
130
|
+
styled_decoded(finding.decoded),
|
|
131
|
+
title="Decoded hidden text",
|
|
132
|
+
title_align="left",
|
|
133
|
+
border_style="red",
|
|
134
|
+
box=box.ROUNDED,
|
|
135
|
+
padding=(0, 1),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
body.append(Text(safe(finding.message)))
|
|
139
|
+
if finding.fix_hint:
|
|
140
|
+
body.append(Text.assemble(("fix ", "green"), (safe(finding.fix_hint), "dim")))
|
|
141
|
+
parts.append(Padding(Group(*body), (0, 0, 0, 2)))
|
|
142
|
+
parts.append(Text(""))
|
|
143
|
+
return Group(*parts)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _file_block(group: FileGroup) -> RenderableType:
|
|
147
|
+
count = len(group.findings)
|
|
148
|
+
title = Text.assemble(
|
|
149
|
+
(safe(group.path), "bold cyan"),
|
|
150
|
+
(f" {count} finding{'s' if count != 1 else ''}", "dim"),
|
|
151
|
+
)
|
|
152
|
+
shown_hidden: set[tuple[int, int, str]] = set()
|
|
153
|
+
blocks: list[RenderableType] = [Rule(title, align="left", style="grey37", characters="─")]
|
|
154
|
+
for finding in group.findings:
|
|
155
|
+
key = (finding.line, finding.column, finding.decoded or "")
|
|
156
|
+
repeated = bool(finding.decoded) and key in shown_hidden
|
|
157
|
+
if finding.decoded:
|
|
158
|
+
shown_hidden.add(key)
|
|
159
|
+
blocks.append(_finding_block(finding, repeated))
|
|
160
|
+
return Group(*blocks)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _summary(result: ScanResult) -> RenderableType:
|
|
164
|
+
if not result.findings and result.errors:
|
|
165
|
+
return Text.assemble(
|
|
166
|
+
("✖ Scan incomplete", "bold red"), (f" (scanned {result.scanned_files} files)", "dim")
|
|
167
|
+
)
|
|
168
|
+
if not result.findings:
|
|
169
|
+
return Text.assemble(
|
|
170
|
+
("✔ No findings", "bold green"), (f" (scanned {result.scanned_files} files)", "dim")
|
|
171
|
+
)
|
|
172
|
+
counts = severity_counts(result.findings)
|
|
173
|
+
line = Text()
|
|
174
|
+
for i, severity in enumerate(s for s in SEVERITY_ORDER if s.label in counts):
|
|
175
|
+
if i:
|
|
176
|
+
line.append(" · ", style="dim")
|
|
177
|
+
line.append(f"{counts[severity.label]} {severity.label}", style=TEXT_STYLE[severity])
|
|
178
|
+
files = len({f.path for f in result.findings})
|
|
179
|
+
line.append(
|
|
180
|
+
f" in {files} file{'s' if files != 1 else ''} (scanned {result.scanned_files})", style="dim"
|
|
181
|
+
)
|
|
182
|
+
return line
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build(result: ScanResult, *, target: str | None = None) -> RenderableType:
|
|
186
|
+
"""Assemble the whole report as a rich renderable."""
|
|
187
|
+
header = Text.assemble(("◆ ghostrules", "bold"), (f" v{__version__}", "dim"))
|
|
188
|
+
if target:
|
|
189
|
+
header.append(f" scanning {target}", style="dim")
|
|
190
|
+
parts: list[RenderableType] = [header, Text("")]
|
|
191
|
+
parts.extend(_file_block(group) for group in group_by_file(result.findings))
|
|
192
|
+
parts.append(Rule(style="grey37", characters="─"))
|
|
193
|
+
parts.append(_summary(result))
|
|
194
|
+
for note in config_notes(result):
|
|
195
|
+
parts.append(Text.assemble(("note: ", "bold yellow"), (note, "yellow")))
|
|
196
|
+
for warning in result.warnings:
|
|
197
|
+
parts.append(Text.assemble(("warning: ", "bold yellow"), safe(warning)))
|
|
198
|
+
for error in result.errors:
|
|
199
|
+
parts.append(Text.assemble(("error: ", "bold red"), safe(error)))
|
|
200
|
+
return Group(*parts)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def make_console(
|
|
204
|
+
*, color: bool | None = None, width: int | None = None, file: IO[str] | None = None
|
|
205
|
+
) -> Console:
|
|
206
|
+
"""Console used for reports: no emoji/markup surprises, bounded width.
|
|
207
|
+
|
|
208
|
+
``color=None`` follows the terminal (and ``NO_COLOR``), ``False`` never emits escape codes,
|
|
209
|
+
``True`` always does (used when rendering to a string).
|
|
210
|
+
"""
|
|
211
|
+
options: dict[str, object] = {
|
|
212
|
+
"file": file,
|
|
213
|
+
"width": width,
|
|
214
|
+
"highlight": False,
|
|
215
|
+
"emoji": False,
|
|
216
|
+
"legacy_windows": False,
|
|
217
|
+
}
|
|
218
|
+
if color is False:
|
|
219
|
+
options["color_system"] = None
|
|
220
|
+
elif color is True:
|
|
221
|
+
options.update(color_system="truecolor", force_terminal=True)
|
|
222
|
+
console = Console(**options) # type: ignore[arg-type]
|
|
223
|
+
if width is None and console.width > _MAX_WIDTH:
|
|
224
|
+
console.width = _MAX_WIDTH
|
|
225
|
+
return console
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def render(
|
|
229
|
+
result: ScanResult, *, color: bool = False, width: int = 100, target: str | None = None
|
|
230
|
+
) -> str:
|
|
231
|
+
"""Render the table report to a string (plain text unless ``color``)."""
|
|
232
|
+
buffer = io.StringIO()
|
|
233
|
+
console = make_console(color=color, width=width, file=buffer)
|
|
234
|
+
console.print(build(result, target=target))
|
|
235
|
+
return buffer.getvalue()
|
ghostrules/reveal.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""``ghostrules reveal``: show a file with its invisible characters made visible."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from rich.console import Group, RenderableType
|
|
8
|
+
from rich.padding import Padding
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from ghostrules.reporters.common import safe
|
|
12
|
+
from ghostrules.reporters.table import styled_decoded
|
|
13
|
+
from ghostrules.secrets import mask_secrets
|
|
14
|
+
from ghostrules.unicode_utils import (
|
|
15
|
+
TAG,
|
|
16
|
+
VARIATION_SELECTOR,
|
|
17
|
+
classify,
|
|
18
|
+
decode_tags,
|
|
19
|
+
decode_variation_selectors,
|
|
20
|
+
find_runs,
|
|
21
|
+
render_char,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_COMMENT_RE = re.compile(r"<!--.*?(?:-->|\Z)", re.DOTALL)
|
|
25
|
+
_TOKEN_STYLE = "bold white on red3"
|
|
26
|
+
_COMMENT_STYLE = "black on yellow"
|
|
27
|
+
_VS_MIN_RUN = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_invisible(ch: str) -> bool:
|
|
31
|
+
return classify(ch) is not None or ch == "\u00ad"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _comment_offsets(text: str) -> set[int]:
|
|
35
|
+
inside: set[int] = set()
|
|
36
|
+
for match in _COMMENT_RE.finditer(text):
|
|
37
|
+
inside.update(range(match.start(), match.end()))
|
|
38
|
+
return inside
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _line_text(line: str, base: int, comments: set[int]) -> Text:
|
|
42
|
+
out = Text()
|
|
43
|
+
for i, ch in enumerate(line):
|
|
44
|
+
if _is_invisible(ch):
|
|
45
|
+
# Tag characters and variation selectors come in long runs: names would drown them.
|
|
46
|
+
named = classify(ch) not in (TAG, VARIATION_SELECTOR)
|
|
47
|
+
out.append(render_char(ch, with_name=named), style=_TOKEN_STYLE)
|
|
48
|
+
else:
|
|
49
|
+
out.append(ch, style=_COMMENT_STYLE if base + i in comments else "")
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _hidden_lines(line: str) -> list[RenderableType]:
|
|
54
|
+
"""Decoded ASCII for tag characters, and bytes hidden in variation selectors."""
|
|
55
|
+
found: list[RenderableType] = []
|
|
56
|
+
for run in find_runs(line, TAG):
|
|
57
|
+
decoded = decode_tags(line[run.start : run.end])
|
|
58
|
+
if decoded:
|
|
59
|
+
found.append(_decoded_row("tag characters", mask_secrets(decoded)))
|
|
60
|
+
for run in find_runs(line, VARIATION_SELECTOR):
|
|
61
|
+
if run.end - run.start >= _VS_MIN_RUN:
|
|
62
|
+
decoded_vs = decode_variation_selectors(line[run.start : run.end])
|
|
63
|
+
if decoded_vs:
|
|
64
|
+
found.append(_decoded_row("variation selectors", mask_secrets(decoded_vs)))
|
|
65
|
+
return found
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _decoded_row(source: str, decoded: str) -> RenderableType:
|
|
69
|
+
label = Text.assemble((" ↳ Decoded hidden text", "bold red"), (f" ({source}): ", "dim"))
|
|
70
|
+
return Text.assemble(label, styled_decoded(decoded))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build(text: str) -> RenderableType:
|
|
74
|
+
"""Numbered listing of ``text``; secrets are masked like everywhere else."""
|
|
75
|
+
text = safe(mask_secrets(text))
|
|
76
|
+
comments = _comment_offsets(text)
|
|
77
|
+
rows: list[RenderableType] = []
|
|
78
|
+
offset = 0
|
|
79
|
+
for number, raw in enumerate(text.split("\n"), start=1):
|
|
80
|
+
line = raw.removesuffix("\r")
|
|
81
|
+
row = Text.assemble((f"{number:>4} │ ", "dim"), _line_text(line, offset, comments))
|
|
82
|
+
rows.append(row)
|
|
83
|
+
rows.extend(Padding(extra, (0, 0, 0, 0)) for extra in _hidden_lines(line))
|
|
84
|
+
offset += len(raw) + 1
|
|
85
|
+
return Group(*rows)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def has_hidden_content(text: str) -> bool:
|
|
89
|
+
"""Whether the text contains invisible characters or HTML comments."""
|
|
90
|
+
return any(_is_invisible(ch) for ch in text) or _COMMENT_RE.search(text) is not None
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Rule registry and built-in rule modules."""
|
|
2
|
+
|
|
3
|
+
from ghostrules.rules import agent_settings, injection, mcp, unicode
|
|
4
|
+
from ghostrules.rules.registry import Rule, all_rules, get_rule, register, rule
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Rule",
|
|
8
|
+
"agent_settings",
|
|
9
|
+
"all_rules",
|
|
10
|
+
"get_rule",
|
|
11
|
+
"injection",
|
|
12
|
+
"mcp",
|
|
13
|
+
"register",
|
|
14
|
+
"rule",
|
|
15
|
+
"unicode",
|
|
16
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Agent settings rules GR107-GR108 (``.claude/settings*.json``, Gemini settings, MCP trust)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from ghostrules.jsonloc import JNode, parse_located
|
|
9
|
+
from ghostrules.mcp_config import Span, is_settings_file
|
|
10
|
+
from ghostrules.models import Finding, Kind, ScanTarget, Severity
|
|
11
|
+
from ghostrules.rules.helpers import build_finding
|
|
12
|
+
from ghostrules.rules.patterns import COLLECTOR_PATTERNS, EXFIL_PATTERNS, INJECTION_PATTERNS
|
|
13
|
+
from ghostrules.rules.registry import rule
|
|
14
|
+
|
|
15
|
+
CONFIG: tuple[Kind, ...] = ("config",)
|
|
16
|
+
|
|
17
|
+
_SENSITIVE_PATH = (
|
|
18
|
+
r"(?:(?<!\w)\.env\b(?!\.(?:example|sample|template|dist)\b)|~/\.ssh|id_rsa|id_ed25519"
|
|
19
|
+
r"|\.aws/credentials|\.npmrc|\.netrc|\.pypirc|/etc/shadow)"
|
|
20
|
+
)
|
|
21
|
+
_SENDER = r"(?:curl|wget|nc|ncat|netcat)"
|
|
22
|
+
_HOOK_EXFIL_PATTERNS = tuple(
|
|
23
|
+
re.compile(p, re.IGNORECASE)
|
|
24
|
+
for p in (
|
|
25
|
+
rf"\b{_SENDER}\b[^\n|;&]{{0,200}}{_SENSITIVE_PATH}",
|
|
26
|
+
rf"{_SENSITIVE_PATH}[^\n]{{0,200}}\|\s*{_SENDER}\b",
|
|
27
|
+
rf"\b(?:env|printenv)\b[^\n]{{0,60}}\|\s*{_SENDER}\b",
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
_BROAD_ALLOW = re.compile(
|
|
31
|
+
r"Bash(?:\(\s*\*{0,2}\s*\))?"
|
|
32
|
+
r"|Bash\(\s*(?:curl|sudo|rm)\s*:\s*\*\s*\)"
|
|
33
|
+
r"|WebFetch(?:\(\s*\*?\s*\))?",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _finding(
|
|
39
|
+
target: ScanTarget,
|
|
40
|
+
rule_id: str,
|
|
41
|
+
severity: Severity,
|
|
42
|
+
title: str,
|
|
43
|
+
message: str,
|
|
44
|
+
span: Span,
|
|
45
|
+
fix_hint: str,
|
|
46
|
+
) -> Finding:
|
|
47
|
+
return build_finding(
|
|
48
|
+
target=target,
|
|
49
|
+
rule_id=rule_id,
|
|
50
|
+
severity=severity,
|
|
51
|
+
title=title,
|
|
52
|
+
message=message,
|
|
53
|
+
start=span[0],
|
|
54
|
+
end=max(span[1], span[0]),
|
|
55
|
+
fix_hint=fix_hint,
|
|
56
|
+
limit=80,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _hook_commands(node: JNode | None) -> Iterator[JNode]:
|
|
61
|
+
"""Every string ``command`` value nested anywhere under ``hooks``."""
|
|
62
|
+
if node is None:
|
|
63
|
+
return
|
|
64
|
+
if node.members is not None:
|
|
65
|
+
for key, (_start, child) in node.members.items():
|
|
66
|
+
if key == "command" and isinstance(child.value, str):
|
|
67
|
+
yield child
|
|
68
|
+
else:
|
|
69
|
+
yield from _hook_commands(child)
|
|
70
|
+
elif node.elements is not None:
|
|
71
|
+
for element in node.elements:
|
|
72
|
+
yield from _hook_commands(element)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _hook_problem(command: str) -> str | None:
|
|
76
|
+
if any(p.search(command) for p in INJECTION_PATTERNS["GR012"]):
|
|
77
|
+
return "downloads and executes remote code"
|
|
78
|
+
if any(
|
|
79
|
+
p.search(command) for p in (*EXFIL_PATTERNS, *_HOOK_EXFIL_PATTERNS, *COLLECTOR_PATTERNS)
|
|
80
|
+
):
|
|
81
|
+
return "sends sensitive data to a remote host"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@rule(
|
|
86
|
+
"GR107",
|
|
87
|
+
Severity.HIGH,
|
|
88
|
+
"Over-permissive agent settings",
|
|
89
|
+
"Agent settings that skip permission prompts, allow unrestricted tools, or run risky hooks.",
|
|
90
|
+
CONFIG,
|
|
91
|
+
)
|
|
92
|
+
def gr107_permissive_settings(target: ScanTarget) -> Iterator[Finding]:
|
|
93
|
+
"""``bypassPermissions``, blanket ``Bash``/``WebFetch``/``curl``/``sudo``/``rm`` allows, Gemini
|
|
94
|
+
``yolo``; hooks that fetch-and-run code or send secrets are critical."""
|
|
95
|
+
if not is_settings_file(target.path):
|
|
96
|
+
return
|
|
97
|
+
root = parse_located(target.text)
|
|
98
|
+
if root is None:
|
|
99
|
+
return
|
|
100
|
+
permissions = root.get("permissions")
|
|
101
|
+
mode = permissions.get("defaultMode") if permissions else None
|
|
102
|
+
if mode is not None and mode.value == "bypassPermissions":
|
|
103
|
+
yield _finding(
|
|
104
|
+
target, "GR107", Severity.HIGH, "Over-permissive agent settings",
|
|
105
|
+
"defaultMode is bypassPermissions: the agent runs every tool without asking.",
|
|
106
|
+
mode.string_span(), "Remove defaultMode or use a mode that asks for permission.",
|
|
107
|
+
) # fmt: skip
|
|
108
|
+
allow = permissions.get("allow") if permissions else None
|
|
109
|
+
for item in (allow.elements or []) if allow else []:
|
|
110
|
+
if isinstance(item.value, str) and _BROAD_ALLOW.fullmatch(item.value.strip()):
|
|
111
|
+
yield _finding(
|
|
112
|
+
target, "GR107", Severity.HIGH, "Over-permissive agent settings",
|
|
113
|
+
f"Permission rule '{item.value}' allows unrestricted use of a powerful tool.",
|
|
114
|
+
item.string_span(),
|
|
115
|
+
"Allow only specific commands or domains, for example Bash(npm run test:*).",
|
|
116
|
+
) # fmt: skip
|
|
117
|
+
yolo = root.get("yolo")
|
|
118
|
+
if yolo is not None and yolo.value is True and ".gemini/" in target.path.replace("\\", "/"):
|
|
119
|
+
yield _finding(
|
|
120
|
+
target, "GR107", Severity.HIGH, "Over-permissive agent settings",
|
|
121
|
+
"yolo is enabled: Gemini CLI runs every tool call without confirmation.",
|
|
122
|
+
(yolo.start, yolo.end), "Remove \"yolo\" so tool calls need confirmation.",
|
|
123
|
+
) # fmt: skip
|
|
124
|
+
for command in _hook_commands(root.get("hooks")):
|
|
125
|
+
problem = _hook_problem(command.value)
|
|
126
|
+
if problem:
|
|
127
|
+
yield _finding(
|
|
128
|
+
target, "GR107", Severity.CRITICAL, "Over-permissive agent settings",
|
|
129
|
+
f"A hook {problem}: {command.value[:100]}",
|
|
130
|
+
command.string_span(), "Remove the hook; hooks run with your full privileges.",
|
|
131
|
+
) # fmt: skip
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@rule(
|
|
135
|
+
"GR108",
|
|
136
|
+
Severity.MEDIUM,
|
|
137
|
+
"Auto-enabled project MCP servers",
|
|
138
|
+
"Settings that start MCP servers from a cloned repository without review.",
|
|
139
|
+
CONFIG,
|
|
140
|
+
)
|
|
141
|
+
def gr108_auto_trust(target: ScanTarget) -> Iterator[Finding]:
|
|
142
|
+
"""``enableAllProjectMcpServers: true`` and servers marked ``trust: true`` (Gemini)."""
|
|
143
|
+
if is_settings_file(target.path):
|
|
144
|
+
root = parse_located(target.text)
|
|
145
|
+
flag = root.get("enableAllProjectMcpServers") if root else None
|
|
146
|
+
if flag is not None and flag.value is True:
|
|
147
|
+
yield _finding(
|
|
148
|
+
target, "GR108", Severity.MEDIUM, "Auto-enabled project MCP servers",
|
|
149
|
+
"enableAllProjectMcpServers is true: any cloned repo's .mcp.json starts its "
|
|
150
|
+
"servers without review.",
|
|
151
|
+
(flag.start, flag.end), "Remove it and approve project servers one by one.",
|
|
152
|
+
) # fmt: skip
|
|
153
|
+
for server in target.servers:
|
|
154
|
+
if server.trust:
|
|
155
|
+
yield _finding(
|
|
156
|
+
target, "GR108", Severity.MEDIUM, "Auto-trusted MCP server",
|
|
157
|
+
f"Server '{server.name}' is marked trust: its tool calls skip confirmation.",
|
|
158
|
+
server.span("trust"), "Remove \"trust\" so tool calls need confirmation.",
|
|
159
|
+
) # fmt: skip
|