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,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