orithos-cli 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.
Files changed (40) hide show
  1. orithos_cli/__init__.py +6 -0
  2. orithos_cli/agent.py +78 -0
  3. orithos_cli/agent_wizard.py +255 -0
  4. orithos_cli/auth.py +21 -0
  5. orithos_cli/cli.py +82 -0
  6. orithos_cli/compliance.py +133 -0
  7. orithos_cli/config.py +114 -0
  8. orithos_cli/configure.py +103 -0
  9. orithos_cli/connection.py +69 -0
  10. orithos_cli/connection_wizard.py +129 -0
  11. orithos_cli/discovery.py +78 -0
  12. orithos_cli/graph.py +102 -0
  13. orithos_cli/guardrail.py +131 -0
  14. orithos_cli/mcp.py +170 -0
  15. orithos_cli/output.py +178 -0
  16. orithos_cli/probes.py +50 -0
  17. orithos_cli/remediation.py +111 -0
  18. orithos_cli/runtime.py +100 -0
  19. orithos_cli/scan.py +758 -0
  20. orithos_cli/skill.py +64 -0
  21. orithos_cli/skillscan/__init__.py +37 -0
  22. orithos_cli/skillscan/checks/__init__.py +28 -0
  23. orithos_cli/skillscan/checks/credentials.py +112 -0
  24. orithos_cli/skillscan/checks/iocs.py +95 -0
  25. orithos_cli/skillscan/checks/manifest.py +156 -0
  26. orithos_cli/skillscan/checks/network.py +117 -0
  27. orithos_cli/skillscan/checks/obfuscation.py +130 -0
  28. orithos_cli/skillscan/checks/permissions.py +108 -0
  29. orithos_cli/skillscan/checks/shell.py +140 -0
  30. orithos_cli/skillscan/collect.py +205 -0
  31. orithos_cli/skillscan/model.py +99 -0
  32. orithos_cli/skillscan/report.py +130 -0
  33. orithos_cli/template.py +60 -0
  34. orithos_cli/verify.py +85 -0
  35. orithos_cli/wizard.py +314 -0
  36. orithos_cli-0.1.0.dist-info/METADATA +99 -0
  37. orithos_cli-0.1.0.dist-info/RECORD +40 -0
  38. orithos_cli-0.1.0.dist-info/WHEEL +5 -0
  39. orithos_cli-0.1.0.dist-info/entry_points.txt +2 -0
  40. orithos_cli-0.1.0.dist-info/top_level.txt +1 -0
orithos_cli/skill.py ADDED
@@ -0,0 +1,64 @@
1
+ """Skill / plugin scanner commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from orithos_cli.skillscan import (
8
+ render_json,
9
+ render_sarif,
10
+ render_text,
11
+ scan_artifact,
12
+ severity_at_least,
13
+ )
14
+
15
+
16
+ @click.group(name="skill")
17
+ def skill_group() -> None:
18
+ """Scan AI agent skills and plugins for risky behavior."""
19
+ pass
20
+
21
+
22
+ @skill_group.command("scan")
23
+ @click.argument("artifact")
24
+ @click.option(
25
+ "--format",
26
+ "output_format",
27
+ type=click.Choice(["text", "json", "sarif"]),
28
+ default="text",
29
+ help="Output format. sarif is for CI/code-scanning ingestion.",
30
+ )
31
+ @click.option(
32
+ "--fail-on",
33
+ type=click.Choice(["critical", "high", "medium", "low"]),
34
+ default="high",
35
+ help="Exit 1 when any finding is at or above this severity (default: high).",
36
+ )
37
+ def skill_scan(artifact: str, output_format: str, fail_on: str) -> None:
38
+ """Scan a skill/plugin artifact (local path or URL).
39
+
40
+ \b
41
+ Examples:
42
+ orithos skill scan ./my-claude-skill
43
+ orithos skill scan https://example.com/plugin.zip --format sarif
44
+ orithos skill scan ./plugin --fail-on medium
45
+
46
+ Exit codes: 0 = no findings at/above --fail-on · 1 = findings · 2 = error.
47
+ """
48
+ try:
49
+ result = scan_artifact(artifact)
50
+ except Exception as exc: # noqa: BLE001 — CLI boundary: report any failure as exit 2
51
+ click.echo(f"Error: {exc}", err=True)
52
+ raise SystemExit(2)
53
+
54
+ renderers = {"text": render_text, "json": render_json, "sarif": render_sarif}
55
+ click.echo(renderers[output_format](result))
56
+
57
+ blocking = [f for f in result.findings if severity_at_least(f.severity, fail_on)]
58
+ if blocking:
59
+ if output_format == "text":
60
+ click.echo(
61
+ f"FAIL — {len(blocking)} finding(s) at or above '{fail_on}'.", err=True
62
+ )
63
+ raise SystemExit(1)
64
+ raise SystemExit(0)
@@ -0,0 +1,37 @@
1
+ """Orithos skill scanner — static analysis for agent skills and plugins.
2
+
3
+ Public API used by the CLI and tests:
4
+
5
+ from orithos_cli.skillscan import scan_artifact
6
+ result = scan_artifact("./my-skill")
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from orithos_cli.skillscan.checks import ALL_CHECKS
12
+ from orithos_cli.skillscan.collect import load_artifact, resolve_source
13
+ from orithos_cli.skillscan.model import Finding, ScanResult, severity_at_least
14
+ from orithos_cli.skillscan.report import render_json, render_sarif, render_text
15
+
16
+ __all__ = [
17
+ "Finding",
18
+ "ScanResult",
19
+ "render_json",
20
+ "render_sarif",
21
+ "render_text",
22
+ "scan_artifact",
23
+ "severity_at_least",
24
+ ]
25
+
26
+
27
+ def scan_artifact(source: str) -> ScanResult:
28
+ """Scan a local path or URL and return the aggregated result."""
29
+ path = resolve_source(source)
30
+ files, skipped, errors = load_artifact(path)
31
+
32
+ result = ScanResult(
33
+ source=source, files_scanned=len(files), skipped=skipped, errors=errors
34
+ )
35
+ for _name, check in ALL_CHECKS:
36
+ result.findings.extend(check(files))
37
+ return result
@@ -0,0 +1,28 @@
1
+ """Check registry for the skill scanner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from orithos_cli.skillscan.checks import (
8
+ credentials,
9
+ iocs,
10
+ manifest,
11
+ network,
12
+ obfuscation,
13
+ permissions,
14
+ shell,
15
+ )
16
+ from orithos_cli.skillscan.model import ArtifactFile, Finding
17
+
18
+ CheckFn = Callable[[list[ArtifactFile]], list[Finding]]
19
+
20
+ ALL_CHECKS: list[tuple[str, CheckFn]] = [
21
+ ("manifest", manifest.run),
22
+ ("permissions", permissions.run),
23
+ ("network", network.run),
24
+ ("shell", shell.run),
25
+ ("credentials", credentials.run),
26
+ ("obfuscation", obfuscation.run),
27
+ ("iocs", iocs.run),
28
+ ]
@@ -0,0 +1,112 @@
1
+ """Check 5 — credential and sensitive-data access.
2
+
3
+ Flags code paths that read credential material (SSH/AWS/registry configs,
4
+ .env files, wallets, browser profiles, keychains) or dump the environment.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from orithos_cli.skillscan.model import ArtifactFile, Finding, line_of, snippet
12
+
13
+ # needle -> (severity, label)
14
+ SENSITIVE_PATHS: list[tuple[str, str, str]] = [
15
+ (".ssh/", "high", "SSH directory"),
16
+ ("id_rsa", "high", "SSH private key"),
17
+ ("id_ed25519", "high", "SSH private key"),
18
+ (".aws/credentials", "high", "AWS credentials file"),
19
+ (".aws/config", "medium", "AWS config"),
20
+ (".env", "high", "environment file"),
21
+ (".netrc", "high", "netrc credentials"),
22
+ (".git-credentials", "high", "git credential store"),
23
+ (".npmrc", "high", "npm registry token store"),
24
+ (".pypirc", "high", "PyPI token store"),
25
+ (".docker/config.json", "high", "Docker registry credentials"),
26
+ ("credentials.json", "medium", "service-account credentials file"),
27
+ ("wallet.dat", "high", "crypto wallet"),
28
+ ("keystore", "medium", "keystore"),
29
+ ("Login Data", "medium", "browser password store"),
30
+ ("Cookies", "low", "browser cookie store"),
31
+ ("login.keychain", "high", "macOS keychain"),
32
+ (".kube/config", "high", "Kubernetes credentials"),
33
+ ]
34
+
35
+ _ENV_DUMP_RES: list[re.Pattern[str]] = [
36
+ re.compile(r"os\.environ\b(?![\.\[]?\.get\()"),
37
+ re.compile(r"JSON\.stringify\(\s*process\.env"),
38
+ re.compile(r"Object\.keys\(\s*process\.env\s*\)"),
39
+ re.compile(r"\bprintenv\b"),
40
+ re.compile(r"\benv\s*\|\s*curl\b"),
41
+ ]
42
+
43
+ _CLIPBOARD_RES: list[re.Pattern[str]] = [
44
+ re.compile(r"pyperclip|pbcopy|pbpaste|navigator\.clipboard\.readText|xclip|xsel"),
45
+ ]
46
+
47
+
48
+ def run(files: list[ArtifactFile]) -> list[Finding]:
49
+ out: list[Finding] = []
50
+ for f in files:
51
+ if f.kind != "code":
52
+ continue
53
+
54
+ for needle, sev, label in SENSITIVE_PATHS:
55
+ idx = f.text.find(needle)
56
+ if idx < 0:
57
+ continue
58
+ # "Cookies"/"keystore" style names only matter in string literals.
59
+ if needle in ("Cookies", "keystore", "Login Data") and not re.search(
60
+ rf"['\"][^'\"]*{re.escape(needle)}[^'\"]*['\"]", f.text
61
+ ):
62
+ continue
63
+ out.append(
64
+ Finding(
65
+ check="credentials",
66
+ severity=sev,
67
+ title=f"References {label}",
68
+ detail=(
69
+ f"`{f.path}` references `{needle}` ({label}). Skills have no "
70
+ "legitimate reason to read host credential stores."
71
+ ),
72
+ file=f.path,
73
+ line=line_of(f.text, needle),
74
+ evidence=snippet(f.text, idx),
75
+ )
76
+ )
77
+
78
+ for pattern in _ENV_DUMP_RES:
79
+ m = pattern.search(f.text)
80
+ if m:
81
+ out.append(
82
+ Finding(
83
+ check="credentials",
84
+ severity="high",
85
+ title="Environment variable enumeration",
86
+ detail=(
87
+ f"`{f.path}` reads or dumps the full process environment — "
88
+ "a common credential-harvesting primitive."
89
+ ),
90
+ file=f.path,
91
+ line=line_of(f.text, m.group(0)),
92
+ evidence=snippet(f.text, m.start()),
93
+ )
94
+ )
95
+ break
96
+
97
+ for pattern in _CLIPBOARD_RES:
98
+ m = pattern.search(f.text)
99
+ if m:
100
+ out.append(
101
+ Finding(
102
+ check="credentials",
103
+ severity="medium",
104
+ title="Clipboard access",
105
+ detail=f"`{f.path}` reads the system clipboard, which frequently holds secrets.",
106
+ file=f.path,
107
+ line=line_of(f.text, m.group(0)),
108
+ evidence=snippet(f.text, m.start()),
109
+ )
110
+ )
111
+ break
112
+ return out
@@ -0,0 +1,95 @@
1
+ """Check 7 — known-bad artifact indicators.
2
+
3
+ Static signatures that are hostile regardless of campaign attribution:
4
+ reverse shells, SSH-key injection, persistence via cron, shell-history
5
+ tampering. `IOCS_VERSION` tracks this ruleset; campaign-specific indicators
6
+ (domains, hashes) get added here as research lands.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from orithos_cli.skillscan.model import ArtifactFile, Finding, line_of, snippet
14
+
15
+ IOCS_VERSION = "2026.09.1"
16
+
17
+ # (pattern, severity, title, description)
18
+ _SIGNATURES: list[tuple[re.Pattern[str], str, str, str]] = [
19
+ (
20
+ re.compile(r"bash\s+-i\s+>&?\s*/dev/tcp/"),
21
+ "critical",
22
+ "Reverse shell: /dev/tcp",
23
+ "opens an interactive shell back to a remote host",
24
+ ),
25
+ (
26
+ re.compile(r"\bnc\b[^\n]{0,30}\s-e\s+(?:/bin/)?(?:sh|bash)"),
27
+ "critical",
28
+ "Reverse shell: netcat -e",
29
+ "wires a shell to a network socket via netcat",
30
+ ),
31
+ (
32
+ re.compile(r"\bsocat\b[^\n]*\bexec:"),
33
+ "critical",
34
+ "Reverse shell: socat exec",
35
+ "bridges a socket to a process via socat",
36
+ ),
37
+ (
38
+ re.compile(r">>?\s*[~\/\w.\-]*\.ssh/authorized_keys"),
39
+ "critical",
40
+ "SSH authorized_keys injection",
41
+ "appends an attacker key to the SSH authorized_keys file",
42
+ ),
43
+ (
44
+ re.compile(r"\bcrontab\s+-"),
45
+ "high",
46
+ "Cron persistence",
47
+ "installs a scheduled job — a standard persistence mechanism",
48
+ ),
49
+ (
50
+ re.compile(r"\bunset\s+HISTFILE\b|\bset\s+\+o\s+history\b"),
51
+ "high",
52
+ "Shell history tampering",
53
+ "disables command logging to cover tracks",
54
+ ),
55
+ (
56
+ re.compile(r"\b(?:history\s+-c|shred\s+[^\n]*\.bash_history)\b"),
57
+ "high",
58
+ "Shell history destruction",
59
+ "erases shell history",
60
+ ),
61
+ (
62
+ re.compile(r"chmod\s+(?:777|a\+x)\s+[^\n]*(?:/tmp|\$TMPDIR)"),
63
+ "medium",
64
+ "World-executable drop in temp directory",
65
+ "marks a temp payload executable",
66
+ ),
67
+ (
68
+ re.compile(r"\bmkfifo\b[^\n]*\b(?:nc|socat|sh|bash)\b"),
69
+ "high",
70
+ "FIFO-back shell relay",
71
+ "relays command output through a named pipe — reverse-shell variant",
72
+ ),
73
+ ]
74
+
75
+
76
+ def run(files: list[ArtifactFile]) -> list[Finding]:
77
+ out: list[Finding] = []
78
+ for f in files:
79
+ if f.kind not in ("code", "text", "skill-md", "npm-manifest", "mcp-manifest"):
80
+ continue
81
+ for pattern, sev, title, description in _SIGNATURES:
82
+ m = pattern.search(f.text)
83
+ if m:
84
+ out.append(
85
+ Finding(
86
+ check="iocs",
87
+ severity=sev,
88
+ title=title,
89
+ detail=f"`{f.path}` {description} (signature: {pattern.pattern[:60]}).",
90
+ file=f.path,
91
+ line=line_of(f.text, m.group(0)),
92
+ evidence=snippet(f.text, m.start()),
93
+ )
94
+ )
95
+ return out
@@ -0,0 +1,156 @@
1
+ """Check 1 — manifest hygiene.
2
+
3
+ Signals about what a skill/plugin *declares*: missing descriptions, wildcard
4
+ tool grants, unpinned runtime installs. Code-level behavior is the job of the
5
+ other checks; this module only judges declarations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from orithos_cli.skillscan.model import ArtifactFile, Finding, line_of
13
+
14
+
15
+ def _check_skill_md(f: ArtifactFile, out: list[Finding]) -> None:
16
+ fm = f.frontmatter or {}
17
+ raw_tools = fm.get("allowed-tools") or fm.get("allowed_tools") or fm.get("tools")
18
+ tools: list[str] = []
19
+ if isinstance(raw_tools, str):
20
+ tools = [t.strip() for t in raw_tools.split(",") if t.strip()]
21
+ elif isinstance(raw_tools, list):
22
+ tools = [str(t) for t in raw_tools]
23
+
24
+ for tool in tools:
25
+ if tool in ("*", "all", "any") or tool.endswith(":*"):
26
+ out.append(
27
+ Finding(
28
+ check="manifest",
29
+ severity="high",
30
+ title="Wildcard tool grant in skill manifest",
31
+ detail=(
32
+ f"`{f.path}` grants `{tool}`, which lets the skill invoke any tool "
33
+ "without an explicit, reviewable scope. Grant the minimal tool set."
34
+ ),
35
+ file=f.path,
36
+ line=line_of(f.text, tool),
37
+ evidence=f"allowed-tools: {tool}",
38
+ )
39
+ )
40
+
41
+ if not fm.get("name"):
42
+ out.append(
43
+ Finding(
44
+ check="manifest",
45
+ severity="medium",
46
+ title="Skill manifest missing `name`",
47
+ detail=f"`{f.path}` frontmatter has no `name` field; unnamed skills are hard to attribute or audit.",
48
+ file=f.path,
49
+ line=1,
50
+ evidence="--- frontmatter without name ---",
51
+ )
52
+ )
53
+ if not fm.get("description"):
54
+ out.append(
55
+ Finding(
56
+ check="manifest",
57
+ severity="low",
58
+ title="Skill manifest missing `description`",
59
+ detail=f"`{f.path}` frontmatter has no `description`; consumers cannot judge intent.",
60
+ file=f.path,
61
+ line=1,
62
+ evidence="--- frontmatter without description ---",
63
+ )
64
+ )
65
+
66
+
67
+ def _check_npm(f: ArtifactFile, out: list[Finding]) -> None:
68
+ try:
69
+ data = json.loads(f.text)
70
+ except json.JSONDecodeError:
71
+ out.append(
72
+ Finding(
73
+ check="manifest",
74
+ severity="medium",
75
+ title="Unparseable package.json",
76
+ detail=f"`{f.path}` is not valid JSON; dependency and script analysis was skipped.",
77
+ file=f.path,
78
+ line=1,
79
+ )
80
+ )
81
+ return
82
+ if not isinstance(data, dict):
83
+ return
84
+ for field in ("name", "version"):
85
+ if not data.get(field):
86
+ out.append(
87
+ Finding(
88
+ check="manifest",
89
+ severity="low",
90
+ title=f"package.json missing `{field}`",
91
+ detail=f"`{f.path}` has no `{field}`; provenance is unclear.",
92
+ file=f.path,
93
+ line=1,
94
+ )
95
+ )
96
+
97
+
98
+ def _check_mcp(f: ArtifactFile, out: list[Finding]) -> None:
99
+ try:
100
+ data = json.loads(f.text)
101
+ except json.JSONDecodeError:
102
+ return
103
+ servers = {}
104
+ if isinstance(data, dict):
105
+ for key in ("mcpServers", "mcp_servers", "mcp-servers"):
106
+ if isinstance(data.get(key), dict):
107
+ servers = data[key]
108
+ break
109
+ for name, cfg in servers.items():
110
+ if not isinstance(cfg, dict):
111
+ continue
112
+ args = cfg.get("args") or []
113
+ command = str(cfg.get("command", ""))
114
+ joined = " ".join(str(a) for a in args) if isinstance(args, list) else str(args)
115
+ if (
116
+ command in ("npx", "npx.cmd", "uvx")
117
+ and "-y" in joined
118
+ and "@latest" not in joined
119
+ ):
120
+ out.append(
121
+ Finding(
122
+ check="manifest",
123
+ severity="medium",
124
+ title="MCP server runs an unpinned package",
125
+ detail=(
126
+ f"Server `{name}` in `{f.path}` runs `{command} {joined}` without a "
127
+ "version pin — the executed code can change after review."
128
+ ),
129
+ file=f.path,
130
+ line=line_of(f.text, name),
131
+ evidence=f"{command} {joined}".strip() or command,
132
+ )
133
+ )
134
+ elif not command and "url" not in cfg:
135
+ out.append(
136
+ Finding(
137
+ check="manifest",
138
+ severity="low",
139
+ title="MCP server entry with no command or URL",
140
+ detail=f"Server `{name}` in `{f.path}` declares neither `command` nor `url`.",
141
+ file=f.path,
142
+ line=line_of(f.text, name),
143
+ )
144
+ )
145
+
146
+
147
+ def run(files: list[ArtifactFile]) -> list[Finding]:
148
+ out: list[Finding] = []
149
+ for f in files:
150
+ if f.kind == "skill-md":
151
+ _check_skill_md(f, out)
152
+ elif f.kind == "npm-manifest":
153
+ _check_npm(f, out)
154
+ elif f.kind == "mcp-manifest":
155
+ _check_mcp(f, out)
156
+ return out
@@ -0,0 +1,117 @@
1
+ """Check 3 — outbound network destinations.
2
+
3
+ Flags exfiltration-friendly endpoints (paste sites, request bins, tunnels)
4
+ and public IP literals in code and manifests. Documentation URLs are only
5
+ reported when they match an exfiltration endpoint class.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ import re
12
+
13
+ from orithos_cli.skillscan.model import ArtifactFile, Finding, line_of, snippet
14
+
15
+ # Host pattern -> (severity, reason). These are endpoint *classes* commonly
16
+ # abused for payload staging and exfiltration in agent-skill attacks; the list
17
+ # is intentionally short and versioned here so it can grow with research.
18
+ EXFIL_HOSTS: list[tuple[str, str, str]] = [
19
+ ("webhook.site", "critical", "webhook catch-all service"),
20
+ ("pipedream.net", "critical", "webhook catch-all service"),
21
+ ("requestbin", "critical", "request bin service"),
22
+ ("requestcatcher", "critical", "request bin service"),
23
+ ("transfer.sh", "high", "anonymous file drop"),
24
+ ("file.io", "high", "anonymous file drop"),
25
+ ("0x0.st", "high", "anonymous file drop"),
26
+ ("tmpfiles.org", "high", "anonymous file drop"),
27
+ ("pastebin.com", "high", "paste site"),
28
+ ("hastebin", "high", "paste site"),
29
+ ("ngrok", "high", "tunnel service"),
30
+ ("trycloudflare.com", "high", "tunnel service"),
31
+ ("localtunnel", "high", "tunnel service"),
32
+ ("interact.sh", "critical", "OOB interaction service"),
33
+ ("oast.fun", "critical", "OOB interaction service"),
34
+ ("oastify", "critical", "OOB interaction service"),
35
+ ("burpcollaborator", "critical", "OOB interaction service"),
36
+ ("canarytokens", "high", "token callback service"),
37
+ ]
38
+
39
+ _URL_RE = re.compile(r"https?://([A-Za-z0-9._~-]+)(?::\d+)?(?:[/?#]|\b)")
40
+ _IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
41
+
42
+
43
+ def _severity_for(kind: str, base: str) -> str:
44
+ # Documentation may legitimately reference a service; demote in prose.
45
+ if kind in ("skill-md", "text") and base in ("critical", "high"):
46
+ return "medium"
47
+ return base
48
+
49
+
50
+ def run(files: list[ArtifactFile]) -> list[Finding]:
51
+ out: list[Finding] = []
52
+ for f in files:
53
+ if f.kind not in (
54
+ "code",
55
+ "skill-md",
56
+ "text",
57
+ "npm-manifest",
58
+ "mcp-manifest",
59
+ "python-project",
60
+ ):
61
+ continue
62
+ text_lower = f.text.lower()
63
+ for pattern, base_sev, reason in EXFIL_HOSTS:
64
+ idx = text_lower.find(pattern)
65
+ if idx < 0:
66
+ continue
67
+ out.append(
68
+ Finding(
69
+ check="network",
70
+ severity=_severity_for(f.kind, base_sev),
71
+ title="Reference to exfiltration-prone endpoint",
72
+ detail=(
73
+ f"`{f.path}` references `{pattern}` ({reason}). Agent skills posting "
74
+ "data to catch-all endpoints is a common exfiltration pattern."
75
+ ),
76
+ file=f.path,
77
+ line=line_of(f.text.lower(), pattern),
78
+ evidence=snippet(f.text, idx),
79
+ )
80
+ )
81
+ for m in _URL_RE.finditer(f.text):
82
+ host = m.group(1)
83
+ host_l = host.lower()
84
+ if any(p in host_l for p in [p for p, _, _ in EXFIL_HOSTS]):
85
+ continue # already reported above
86
+ if host_l in ("localhost", "127.0.0.1", "0.0.0.0"):
87
+ continue
88
+ try:
89
+ ip = ipaddress.ip_address(host)
90
+ except ValueError:
91
+ ip = None
92
+ if ip is not None:
93
+ continue # IP literals handled below (URL already implies network use)
94
+ if f.kind in ("skill-md", "text"):
95
+ continue # docs may link anywhere; only endpoint classes matter there
96
+ for m in _IPV4_RE.finditer(f.text):
97
+ try:
98
+ ip = ipaddress.ip_address(m.group(0))
99
+ except ValueError:
100
+ continue
101
+ if ip.is_private or ip.is_loopback or ip.is_multicast or ip.is_reserved:
102
+ continue
103
+ out.append(
104
+ Finding(
105
+ check="network",
106
+ severity="medium",
107
+ title="Public IP literal instead of a hostname",
108
+ detail=(
109
+ f"`{f.path}` references the public IP `{m.group(0)}` directly. IPs "
110
+ "dodge domain-based review and blocklists."
111
+ ),
112
+ file=f.path,
113
+ line=line_of(f.text, m.group(0)),
114
+ evidence=snippet(f.text, m.start()),
115
+ )
116
+ )
117
+ return out