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.
- orithos_cli/__init__.py +6 -0
- orithos_cli/agent.py +78 -0
- orithos_cli/agent_wizard.py +255 -0
- orithos_cli/auth.py +21 -0
- orithos_cli/cli.py +82 -0
- orithos_cli/compliance.py +133 -0
- orithos_cli/config.py +114 -0
- orithos_cli/configure.py +103 -0
- orithos_cli/connection.py +69 -0
- orithos_cli/connection_wizard.py +129 -0
- orithos_cli/discovery.py +78 -0
- orithos_cli/graph.py +102 -0
- orithos_cli/guardrail.py +131 -0
- orithos_cli/mcp.py +170 -0
- orithos_cli/output.py +178 -0
- orithos_cli/probes.py +50 -0
- orithos_cli/remediation.py +111 -0
- orithos_cli/runtime.py +100 -0
- orithos_cli/scan.py +758 -0
- orithos_cli/skill.py +64 -0
- orithos_cli/skillscan/__init__.py +37 -0
- orithos_cli/skillscan/checks/__init__.py +28 -0
- orithos_cli/skillscan/checks/credentials.py +112 -0
- orithos_cli/skillscan/checks/iocs.py +95 -0
- orithos_cli/skillscan/checks/manifest.py +156 -0
- orithos_cli/skillscan/checks/network.py +117 -0
- orithos_cli/skillscan/checks/obfuscation.py +130 -0
- orithos_cli/skillscan/checks/permissions.py +108 -0
- orithos_cli/skillscan/checks/shell.py +140 -0
- orithos_cli/skillscan/collect.py +205 -0
- orithos_cli/skillscan/model.py +99 -0
- orithos_cli/skillscan/report.py +130 -0
- orithos_cli/template.py +60 -0
- orithos_cli/verify.py +85 -0
- orithos_cli/wizard.py +314 -0
- orithos_cli-0.1.0.dist-info/METADATA +99 -0
- orithos_cli-0.1.0.dist-info/RECORD +40 -0
- orithos_cli-0.1.0.dist-info/WHEEL +5 -0
- orithos_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|