skillplus 0.1.0__tar.gz
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.
- skillplus-0.1.0/.gitignore +40 -0
- skillplus-0.1.0/LICENSE +21 -0
- skillplus-0.1.0/PKG-INFO +102 -0
- skillplus-0.1.0/README.md +84 -0
- skillplus-0.1.0/pyproject.toml +38 -0
- skillplus-0.1.0/src/skillplus/__init__.py +49 -0
- skillplus-0.1.0/src/skillplus/client.py +378 -0
- skillplus-0.1.0/src/skillplus/errors.py +17 -0
- skillplus-0.1.0/src/skillplus/py.typed +0 -0
- skillplus-0.1.0/src/skillplus/types.py +176 -0
- skillplus-0.1.0/tests/test_client.py +223 -0
- skillplus-0.1.0/tests/test_v3_features.py +185 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Backend
|
|
2
|
+
backend/.venv/
|
|
3
|
+
backend/.env
|
|
4
|
+
backend/__pycache__/
|
|
5
|
+
backend/**/__pycache__/
|
|
6
|
+
backend/.pytest_cache/
|
|
7
|
+
backend/.ruff_cache/
|
|
8
|
+
*.pyc
|
|
9
|
+
|
|
10
|
+
# Frontend
|
|
11
|
+
frontend/node_modules/
|
|
12
|
+
frontend/.next/
|
|
13
|
+
frontend/out/
|
|
14
|
+
|
|
15
|
+
# Admin
|
|
16
|
+
skillplus-admin/node_modules/
|
|
17
|
+
skillplus-admin/.next/
|
|
18
|
+
skillplus-admin/.env
|
|
19
|
+
|
|
20
|
+
# SDK
|
|
21
|
+
sdk/node_modules/
|
|
22
|
+
sdk/dist/
|
|
23
|
+
python-sdk/dist/
|
|
24
|
+
python-sdk/.pytest_cache/
|
|
25
|
+
python-sdk/**/__pycache__/
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
skills
|
|
30
|
+
agent-browser/
|
|
31
|
+
|
|
32
|
+
# Sensitive files (contain server passwords)
|
|
33
|
+
Full_Scan_Version_Management.md
|
|
34
|
+
skillplus_project_summary.md
|
|
35
|
+
.claude/skillplus-rules.md
|
|
36
|
+
Optimizing SkillPlus Database Performance.md
|
|
37
|
+
|
|
38
|
+
# Credential vault — plaintext secrets, NEVER commit/push. Local only.
|
|
39
|
+
*.local.md
|
|
40
|
+
docs/prod/CREDENTIALS.local.md
|
skillplus-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SkillPlus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
skillplus-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skillplus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for SkillPlus
|
|
5
|
+
Project-URL: Homepage, https://skillplus.xyz
|
|
6
|
+
Project-URL: Documentation, https://docs.skillplus.xyz
|
|
7
|
+
Author: SkillPlus
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,sdk,security,skillplus
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx>=0.28
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: respx>=0.22; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# SkillPlus Python SDK
|
|
20
|
+
|
|
21
|
+
Official Python SDK for the hosted SkillPlus API.
|
|
22
|
+
|
|
23
|
+
SkillPlus provides security intelligence for AI skills and agent-facing software, helping developers, teams, and platforms scan, understand, and trust AI skills before installation, approval, or integration.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install skillplus
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Basic usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from skillplus import SkillPlus
|
|
35
|
+
|
|
36
|
+
client = SkillPlus(api_key="skp_...")
|
|
37
|
+
|
|
38
|
+
result = client.query("https://github.com/owner/repo")
|
|
39
|
+
|
|
40
|
+
if result.status == "found" and result.report:
|
|
41
|
+
print(result.report.rating) # "safe" | "medium" | "high"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Advanced options
|
|
45
|
+
|
|
46
|
+
For repositories containing many skills, or to queue a scan automatically when
|
|
47
|
+
no report exists yet:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
result = client.query(
|
|
51
|
+
"https://github.com/owner/repo",
|
|
52
|
+
skill_path="skills/example", # pick one skill in a multi-skill repo
|
|
53
|
+
scan_if_missing=True, # queue a scan if no report exists
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The SDK talks to the official SkillPlus service at `https://skillplus.xyz`.
|
|
58
|
+
|
|
59
|
+
## Context manager
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from skillplus import SkillPlus
|
|
63
|
+
|
|
64
|
+
with SkillPlus(api_key="skp_...") as client:
|
|
65
|
+
result = client.scan(
|
|
66
|
+
"https://github.com/owner/repo",
|
|
67
|
+
skill_path="skills/example",
|
|
68
|
+
)
|
|
69
|
+
print(result.status)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Methods
|
|
73
|
+
|
|
74
|
+
| Method | Use case |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `query(...)` | Check whether SkillPlus already has a report for a skill. |
|
|
77
|
+
| `scan(...)` | Request a scan when a report is missing or when a fresh check is needed. |
|
|
78
|
+
| `get_report(scan_id)` | Retrieve structured report data for dashboards, registries, or automation. |
|
|
79
|
+
| `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
|
|
80
|
+
| `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
|
|
81
|
+
|
|
82
|
+
## Error handling
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from skillplus import SkillPlus, SkillPlusError
|
|
86
|
+
|
|
87
|
+
client = SkillPlus(api_key="skp_...")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
result = client.query("https://github.com/owner/repo")
|
|
91
|
+
except SkillPlusError as error:
|
|
92
|
+
print(error.status_code)
|
|
93
|
+
print(error.message)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv sync --extra dev
|
|
100
|
+
uv run pytest
|
|
101
|
+
uv run python -m build
|
|
102
|
+
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# SkillPlus Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the hosted SkillPlus API.
|
|
4
|
+
|
|
5
|
+
SkillPlus provides security intelligence for AI skills and agent-facing software, helping developers, teams, and platforms scan, understand, and trust AI skills before installation, approval, or integration.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install skillplus
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Basic usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from skillplus import SkillPlus
|
|
17
|
+
|
|
18
|
+
client = SkillPlus(api_key="skp_...")
|
|
19
|
+
|
|
20
|
+
result = client.query("https://github.com/owner/repo")
|
|
21
|
+
|
|
22
|
+
if result.status == "found" and result.report:
|
|
23
|
+
print(result.report.rating) # "safe" | "medium" | "high"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Advanced options
|
|
27
|
+
|
|
28
|
+
For repositories containing many skills, or to queue a scan automatically when
|
|
29
|
+
no report exists yet:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
result = client.query(
|
|
33
|
+
"https://github.com/owner/repo",
|
|
34
|
+
skill_path="skills/example", # pick one skill in a multi-skill repo
|
|
35
|
+
scan_if_missing=True, # queue a scan if no report exists
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The SDK talks to the official SkillPlus service at `https://skillplus.xyz`.
|
|
40
|
+
|
|
41
|
+
## Context manager
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from skillplus import SkillPlus
|
|
45
|
+
|
|
46
|
+
with SkillPlus(api_key="skp_...") as client:
|
|
47
|
+
result = client.scan(
|
|
48
|
+
"https://github.com/owner/repo",
|
|
49
|
+
skill_path="skills/example",
|
|
50
|
+
)
|
|
51
|
+
print(result.status)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Methods
|
|
55
|
+
|
|
56
|
+
| Method | Use case |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `query(...)` | Check whether SkillPlus already has a report for a skill. |
|
|
59
|
+
| `scan(...)` | Request a scan when a report is missing or when a fresh check is needed. |
|
|
60
|
+
| `get_report(scan_id)` | Retrieve structured report data for dashboards, registries, or automation. |
|
|
61
|
+
| `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
|
|
62
|
+
| `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
|
|
63
|
+
|
|
64
|
+
## Error handling
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from skillplus import SkillPlus, SkillPlusError
|
|
68
|
+
|
|
69
|
+
client = SkillPlus(api_key="skp_...")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = client.query("https://github.com/owner/repo")
|
|
73
|
+
except SkillPlusError as error:
|
|
74
|
+
print(error.status_code)
|
|
75
|
+
print(error.message)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv sync --extra dev
|
|
82
|
+
uv run pytest
|
|
83
|
+
uv run python -m build
|
|
84
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "skillplus"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for SkillPlus"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "SkillPlus" }]
|
|
13
|
+
keywords = ["skillplus", "ai", "security", "sdk"]
|
|
14
|
+
dependencies = ["httpx>=0.28"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://skillplus.xyz"
|
|
18
|
+
Documentation = "https://docs.skillplus.xyz"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"build>=1.2",
|
|
23
|
+
"pytest>=8",
|
|
24
|
+
"respx>=0.22",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/skillplus"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.sdist]
|
|
31
|
+
include = ["src/skillplus", "tests", "README.md", "pyproject.toml"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
line-length = 100
|
|
38
|
+
target-version = "py310"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from .client import SkillPlus
|
|
2
|
+
from .errors import SkillPlusError
|
|
3
|
+
from .types import (
|
|
4
|
+
AgentRun,
|
|
5
|
+
AiCategoryResult,
|
|
6
|
+
AiFalsePositive,
|
|
7
|
+
AiReport,
|
|
8
|
+
AiReportStatus,
|
|
9
|
+
IssueCategory,
|
|
10
|
+
PlatformListing,
|
|
11
|
+
QueryResult,
|
|
12
|
+
QueryStatus,
|
|
13
|
+
Rating,
|
|
14
|
+
ScanIssue,
|
|
15
|
+
ScanReport,
|
|
16
|
+
ScanResult,
|
|
17
|
+
ScanStatus,
|
|
18
|
+
ScanSummary,
|
|
19
|
+
Severity,
|
|
20
|
+
SeverityNormalized,
|
|
21
|
+
SupplyChainHit,
|
|
22
|
+
SupplyChainReport,
|
|
23
|
+
Verdict,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AgentRun",
|
|
28
|
+
"AiCategoryResult",
|
|
29
|
+
"AiFalsePositive",
|
|
30
|
+
"AiReport",
|
|
31
|
+
"AiReportStatus",
|
|
32
|
+
"IssueCategory",
|
|
33
|
+
"PlatformListing",
|
|
34
|
+
"QueryResult",
|
|
35
|
+
"QueryStatus",
|
|
36
|
+
"Rating",
|
|
37
|
+
"ScanIssue",
|
|
38
|
+
"ScanReport",
|
|
39
|
+
"ScanResult",
|
|
40
|
+
"ScanStatus",
|
|
41
|
+
"ScanSummary",
|
|
42
|
+
"Severity",
|
|
43
|
+
"SeverityNormalized",
|
|
44
|
+
"SupplyChainHit",
|
|
45
|
+
"SupplyChainReport",
|
|
46
|
+
"SkillPlus",
|
|
47
|
+
"SkillPlusError",
|
|
48
|
+
"Verdict",
|
|
49
|
+
]
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .errors import SkillPlusError
|
|
9
|
+
from .types import (
|
|
10
|
+
AgentRun,
|
|
11
|
+
AiCategoryResult,
|
|
12
|
+
AiFalsePositive,
|
|
13
|
+
AiReport,
|
|
14
|
+
PlatformListing,
|
|
15
|
+
QueryResult,
|
|
16
|
+
ScanIssue,
|
|
17
|
+
ScanReport,
|
|
18
|
+
ScanResult,
|
|
19
|
+
ScanSummary,
|
|
20
|
+
SupplyChainHit,
|
|
21
|
+
SupplyChainReport,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://skillplus.xyz"
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0
|
|
26
|
+
DEFAULT_MAX_RETRIES = 2
|
|
27
|
+
_RETRY_STATUSES = frozenset({429, 502, 503})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SkillPlus:
|
|
31
|
+
"""Client for the hosted SkillPlus API."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str,
|
|
36
|
+
*,
|
|
37
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
38
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
39
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
40
|
+
) -> None:
|
|
41
|
+
if not api_key:
|
|
42
|
+
raise ValueError("api_key is required")
|
|
43
|
+
|
|
44
|
+
self._api_key = api_key
|
|
45
|
+
self._base_url = base_url.rstrip("/")
|
|
46
|
+
self._max_retries = max(0, max_retries)
|
|
47
|
+
self._client = httpx.Client(
|
|
48
|
+
base_url=self._base_url,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
headers={
|
|
51
|
+
"Authorization": f"Bearer {api_key}",
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
"User-Agent": "skillplus-python/0.1.0",
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def __enter__(self) -> SkillPlus:
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
61
|
+
self.close()
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
"""Close the underlying HTTP client."""
|
|
65
|
+
|
|
66
|
+
self._client.close()
|
|
67
|
+
|
|
68
|
+
def query(
|
|
69
|
+
self,
|
|
70
|
+
repo_url: str,
|
|
71
|
+
*,
|
|
72
|
+
skill_path: str | None = None,
|
|
73
|
+
scan_if_missing: bool = False,
|
|
74
|
+
preferred_provider: str | None = None,
|
|
75
|
+
) -> QueryResult:
|
|
76
|
+
"""Query an existing report, optionally queueing a scan if no report exists."""
|
|
77
|
+
|
|
78
|
+
payload = self._request_json(
|
|
79
|
+
"POST",
|
|
80
|
+
"/api/sdk/query",
|
|
81
|
+
json={
|
|
82
|
+
"repo_url": repo_url,
|
|
83
|
+
"skill_path": skill_path,
|
|
84
|
+
"scan_if_missing": scan_if_missing,
|
|
85
|
+
"preferred_provider": preferred_provider,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
return _parse_query_result(payload)
|
|
89
|
+
|
|
90
|
+
def scan(
|
|
91
|
+
self,
|
|
92
|
+
repo_url: str,
|
|
93
|
+
*,
|
|
94
|
+
skill_path: str | None = None,
|
|
95
|
+
preferred_provider: str | None = None,
|
|
96
|
+
force: bool = False,
|
|
97
|
+
) -> ScanResult:
|
|
98
|
+
"""Request a scan for a skill."""
|
|
99
|
+
|
|
100
|
+
payload = self._request_json(
|
|
101
|
+
"POST",
|
|
102
|
+
"/api/sdk/scan",
|
|
103
|
+
json={
|
|
104
|
+
"repo_url": repo_url,
|
|
105
|
+
"skill_path": skill_path,
|
|
106
|
+
"preferred_provider": preferred_provider,
|
|
107
|
+
"force": force,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
return _parse_scan_result(payload)
|
|
111
|
+
|
|
112
|
+
def wait_for_report(
|
|
113
|
+
self,
|
|
114
|
+
repo_url: str,
|
|
115
|
+
*,
|
|
116
|
+
skill_path: str | None = None,
|
|
117
|
+
preferred_provider: str | None = None,
|
|
118
|
+
interval: float = 5.0,
|
|
119
|
+
timeout: float = 600.0,
|
|
120
|
+
) -> ScanReport:
|
|
121
|
+
"""Query-and-wait: poll until a completed report exists (scanning if
|
|
122
|
+
missing). This is the reliable path after a ``queued`` result, which
|
|
123
|
+
may carry no scan_id to poll."""
|
|
124
|
+
|
|
125
|
+
deadline = time.monotonic() + timeout
|
|
126
|
+
while True:
|
|
127
|
+
result = self.query(
|
|
128
|
+
repo_url,
|
|
129
|
+
skill_path=skill_path,
|
|
130
|
+
scan_if_missing=True,
|
|
131
|
+
preferred_provider=preferred_provider,
|
|
132
|
+
)
|
|
133
|
+
if result.status == "found" and result.report and result.report.status == "completed":
|
|
134
|
+
return result.report
|
|
135
|
+
if result.status in ("failed", "not_found"):
|
|
136
|
+
raise SkillPlusError(f"Scan {result.status}: {result.message}", detail=result.status)
|
|
137
|
+
if time.monotonic() + interval > deadline:
|
|
138
|
+
raise SkillPlusError(
|
|
139
|
+
f"Timed out waiting for report (last status: {result.status})",
|
|
140
|
+
detail=result.status,
|
|
141
|
+
)
|
|
142
|
+
time.sleep(interval)
|
|
143
|
+
|
|
144
|
+
def get_report(self, scan_id: str) -> ScanReport:
|
|
145
|
+
"""Retrieve structured report data by scan ID."""
|
|
146
|
+
|
|
147
|
+
payload = self._request_json("GET", f"/api/report/{scan_id}")
|
|
148
|
+
return _parse_report(payload)
|
|
149
|
+
|
|
150
|
+
def get_badge(self, scan_id: str) -> str:
|
|
151
|
+
"""Fetch the badge SVG for a report."""
|
|
152
|
+
|
|
153
|
+
return self._request_text("GET", f"/api/report/{scan_id}/badge.svg")
|
|
154
|
+
|
|
155
|
+
def get_badge_url(self, scan_id: str) -> str:
|
|
156
|
+
"""Return the public badge URL for a report."""
|
|
157
|
+
|
|
158
|
+
return f"{self._base_url}/api/report/{scan_id}/badge.svg"
|
|
159
|
+
|
|
160
|
+
def _request_json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
161
|
+
response = self._request(method, path, **kwargs)
|
|
162
|
+
try:
|
|
163
|
+
payload = response.json()
|
|
164
|
+
except ValueError as exc:
|
|
165
|
+
raise SkillPlusError("SkillPlus returned invalid JSON", status_code=response.status_code) from exc
|
|
166
|
+
|
|
167
|
+
if not isinstance(payload, dict):
|
|
168
|
+
raise SkillPlusError(
|
|
169
|
+
"SkillPlus returned an unexpected response",
|
|
170
|
+
status_code=response.status_code,
|
|
171
|
+
detail=payload,
|
|
172
|
+
)
|
|
173
|
+
return payload
|
|
174
|
+
|
|
175
|
+
def _request_text(self, method: str, path: str, **kwargs: Any) -> str:
|
|
176
|
+
return self._request(method, path, **kwargs).text
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _retry_delay(response: httpx.Response | None, attempt: int) -> float:
|
|
180
|
+
if response is not None:
|
|
181
|
+
retry_after = response.headers.get("Retry-After")
|
|
182
|
+
if retry_after:
|
|
183
|
+
try:
|
|
184
|
+
return min(max(float(retry_after), 0.0), 60.0)
|
|
185
|
+
except ValueError:
|
|
186
|
+
pass
|
|
187
|
+
return min(2.0**attempt, 10.0)
|
|
188
|
+
|
|
189
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
190
|
+
response: httpx.Response | None = None
|
|
191
|
+
for attempt in range(self._max_retries + 1):
|
|
192
|
+
try:
|
|
193
|
+
response = self._client.request(method, path, **kwargs)
|
|
194
|
+
except httpx.RequestError as exc:
|
|
195
|
+
if attempt < self._max_retries:
|
|
196
|
+
time.sleep(self._retry_delay(None, attempt))
|
|
197
|
+
continue
|
|
198
|
+
raise SkillPlusError(str(exc), detail=exc) from exc
|
|
199
|
+
if response.status_code in _RETRY_STATUSES and attempt < self._max_retries:
|
|
200
|
+
time.sleep(self._retry_delay(response, attempt))
|
|
201
|
+
continue
|
|
202
|
+
break
|
|
203
|
+
assert response is not None
|
|
204
|
+
|
|
205
|
+
if response.is_error:
|
|
206
|
+
detail: object | None = None
|
|
207
|
+
message = response.reason_phrase
|
|
208
|
+
try:
|
|
209
|
+
detail = response.json()
|
|
210
|
+
if isinstance(detail, dict):
|
|
211
|
+
message = str(detail.get("detail") or detail.get("message") or message)
|
|
212
|
+
except ValueError:
|
|
213
|
+
detail = response.text or None
|
|
214
|
+
if response.text:
|
|
215
|
+
message = response.text
|
|
216
|
+
|
|
217
|
+
raise SkillPlusError(message, status_code=response.status_code, detail=detail)
|
|
218
|
+
|
|
219
|
+
return response
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _parse_query_result(payload: dict[str, Any]) -> QueryResult:
|
|
223
|
+
return QueryResult(
|
|
224
|
+
status=payload["status"],
|
|
225
|
+
message=payload["message"],
|
|
226
|
+
scan_id=payload.get("scan_id"),
|
|
227
|
+
poll_url=payload.get("poll_url"),
|
|
228
|
+
report=_parse_report(payload["report"]) if payload.get("report") else None,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_scan_result(payload: dict[str, Any]) -> ScanResult:
|
|
233
|
+
return ScanResult(
|
|
234
|
+
status=payload["status"],
|
|
235
|
+
message=payload["message"],
|
|
236
|
+
scan_id=payload.get("scan_id"),
|
|
237
|
+
poll_url=payload.get("poll_url"),
|
|
238
|
+
report=_parse_report(payload["report"]) if payload.get("report") else None,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_report(payload: dict[str, Any]) -> ScanReport:
|
|
243
|
+
return ScanReport(
|
|
244
|
+
scan_id=payload["scan_id"],
|
|
245
|
+
skill_name=payload["skill_name"],
|
|
246
|
+
source=payload["source"],
|
|
247
|
+
skill_path=payload["skill_path"],
|
|
248
|
+
tree_sha=payload["tree_sha"],
|
|
249
|
+
rule_version=payload["rule_version"],
|
|
250
|
+
valid_skill=payload["valid_skill"],
|
|
251
|
+
rating=payload["rating"],
|
|
252
|
+
verdict=payload.get("verdict")
|
|
253
|
+
or {"low": "safe", "critical": "high"}.get(payload["rating"], payload["rating"]),
|
|
254
|
+
scanned_at=payload["scanned_at"],
|
|
255
|
+
cached=payload["cached"],
|
|
256
|
+
scan_duration_ms=payload["scan_duration_ms"],
|
|
257
|
+
summary=_parse_summary(payload["summary"]),
|
|
258
|
+
issues=[_parse_issue(issue) for issue in payload.get("issues", [])],
|
|
259
|
+
report_url=payload["report_url"],
|
|
260
|
+
badge_url=payload["badge_url"],
|
|
261
|
+
blacklisted=bool(payload.get("blacklisted")),
|
|
262
|
+
whitelisted=bool(payload.get("whitelisted")),
|
|
263
|
+
status=payload["status"],
|
|
264
|
+
ai_report=_parse_ai_report(payload["ai_report"]) if payload.get("ai_report") else None,
|
|
265
|
+
external_links=payload.get("external_links") or {},
|
|
266
|
+
platform_listings=[
|
|
267
|
+
PlatformListing(
|
|
268
|
+
platform=listing["platform"],
|
|
269
|
+
url=listing["url"],
|
|
270
|
+
installs=listing["installs"],
|
|
271
|
+
)
|
|
272
|
+
for listing in payload.get("platform_listings", [])
|
|
273
|
+
],
|
|
274
|
+
supply_chain=_parse_supply_chain(payload.get("supply_chain")),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_supply_chain(payload: dict[str, Any] | None) -> SupplyChainReport | None:
|
|
279
|
+
if not payload:
|
|
280
|
+
return None
|
|
281
|
+
return SupplyChainReport(
|
|
282
|
+
dependency_count=payload.get("dependency_count") or 0,
|
|
283
|
+
endpoint_count=payload.get("endpoint_count") or 0,
|
|
284
|
+
blacklist_hits=[
|
|
285
|
+
SupplyChainHit(
|
|
286
|
+
kind=hit["kind"],
|
|
287
|
+
ecosystem=hit.get("ecosystem"),
|
|
288
|
+
name=hit["name"],
|
|
289
|
+
version_spec=hit.get("version_spec") or {},
|
|
290
|
+
reason=hit["reason"],
|
|
291
|
+
severity=hit["severity"],
|
|
292
|
+
reference_url=hit.get("reference_url"),
|
|
293
|
+
match_strength=hit["match_strength"],
|
|
294
|
+
matched_version=hit.get("matched_version"),
|
|
295
|
+
)
|
|
296
|
+
for hit in payload.get("blacklist_hits", [])
|
|
297
|
+
],
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _parse_summary(payload: dict[str, Any]) -> ScanSummary:
|
|
302
|
+
return ScanSummary(
|
|
303
|
+
total_issues=payload["total_issues"],
|
|
304
|
+
danger=payload["danger"],
|
|
305
|
+
warning=payload["warning"],
|
|
306
|
+
info=payload["info"],
|
|
307
|
+
files_scanned=payload["files_scanned"],
|
|
308
|
+
by_category=payload.get("by_category") or {},
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _parse_issue(payload: dict[str, Any]) -> ScanIssue:
|
|
313
|
+
return ScanIssue(
|
|
314
|
+
rule_id=payload["rule_id"],
|
|
315
|
+
severity=payload["severity"],
|
|
316
|
+
severity_normalized=payload.get("severity_normalized")
|
|
317
|
+
or {"danger": "high", "warning": "medium", "info": "low"}.get(
|
|
318
|
+
payload["severity"], payload["severity"]
|
|
319
|
+
),
|
|
320
|
+
category=payload["category"],
|
|
321
|
+
message=payload["message"],
|
|
322
|
+
file=payload.get("file"),
|
|
323
|
+
line=payload.get("line"),
|
|
324
|
+
snippet=payload.get("snippet"),
|
|
325
|
+
domain=payload.get("domain"),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _parse_ai_report(payload: dict[str, Any]) -> AiReport:
|
|
330
|
+
return AiReport(
|
|
331
|
+
status=payload["status"],
|
|
332
|
+
risk_level=payload.get("risk_level"),
|
|
333
|
+
refined_rating=payload.get("refined_rating"),
|
|
334
|
+
false_positives=[
|
|
335
|
+
AiFalsePositive(
|
|
336
|
+
rule_id=false_positive["rule_id"],
|
|
337
|
+
file=false_positive["file"],
|
|
338
|
+
reason=false_positive["reason"],
|
|
339
|
+
)
|
|
340
|
+
for false_positive in payload.get("false_positives", [])
|
|
341
|
+
],
|
|
342
|
+
analysis=[_parse_ai_category(item) for item in payload.get("analysis", [])],
|
|
343
|
+
summary=payload.get("summary"),
|
|
344
|
+
categories_checked=[
|
|
345
|
+
_parse_ai_category(item) for item in payload.get("categories_checked", [])
|
|
346
|
+
],
|
|
347
|
+
tokens_used=payload.get("tokens_used"),
|
|
348
|
+
duration_ms=payload.get("duration_ms"),
|
|
349
|
+
error_message=payload.get("error_message"),
|
|
350
|
+
agents=(
|
|
351
|
+
[
|
|
352
|
+
AgentRun(
|
|
353
|
+
agent=run["agent"],
|
|
354
|
+
status=run["status"],
|
|
355
|
+
findings=run.get("findings") or [],
|
|
356
|
+
summary=run.get("summary"),
|
|
357
|
+
tokens_used=run.get("tokens_used"),
|
|
358
|
+
duration_ms=run.get("duration_ms"),
|
|
359
|
+
error_message=run.get("error_message"),
|
|
360
|
+
)
|
|
361
|
+
for run in payload["agents"]
|
|
362
|
+
]
|
|
363
|
+
if payload.get("agents") is not None
|
|
364
|
+
else None
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _parse_ai_category(payload: dict[str, Any]) -> AiCategoryResult:
|
|
370
|
+
return AiCategoryResult(
|
|
371
|
+
category=payload["category"],
|
|
372
|
+
status=payload["status"],
|
|
373
|
+
severity=payload["severity"],
|
|
374
|
+
title=payload["title"],
|
|
375
|
+
description=payload["description"],
|
|
376
|
+
evidence=payload.get("evidence") or [],
|
|
377
|
+
confidence=payload.get("confidence") or 0.0,
|
|
378
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SkillPlusError(Exception):
|
|
5
|
+
"""Error raised by the SkillPlus SDK."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status_code: int | None = None,
|
|
12
|
+
detail: object | None = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.detail = detail
|
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Literal, TypeAlias
|
|
5
|
+
|
|
6
|
+
Rating: TypeAlias = Literal["safe", "low", "medium", "high", "unknown"]
|
|
7
|
+
# Rating system v3: three-tier verdict (low→safe, critical→high). Prefer over Rating.
|
|
8
|
+
Verdict: TypeAlias = Literal["safe", "medium", "high", "unknown"]
|
|
9
|
+
# Raw rule-issue severity. v3 scans emit high/medium/low; reports scanned
|
|
10
|
+
# before v3 may still carry danger/warning/info. Prefer SeverityNormalized.
|
|
11
|
+
Severity: TypeAlias = Literal[
|
|
12
|
+
"high", "medium", "low", "critical", "danger", "warning", "info", "safe"
|
|
13
|
+
]
|
|
14
|
+
# Rating system v3: unified finding severity. Prefer over Severity.
|
|
15
|
+
SeverityNormalized: TypeAlias = Literal["high", "medium", "low", "safe"]
|
|
16
|
+
IssueCategory: TypeAlias = Literal[
|
|
17
|
+
"url",
|
|
18
|
+
"code",
|
|
19
|
+
"prompt",
|
|
20
|
+
"structure",
|
|
21
|
+
"persistence",
|
|
22
|
+
"credential",
|
|
23
|
+
"supply_chain",
|
|
24
|
+
"propagation",
|
|
25
|
+
]
|
|
26
|
+
QueryStatus: TypeAlias = Literal["found", "queued", "running", "not_found", "failed"]
|
|
27
|
+
ScanStatus: TypeAlias = Literal["found", "queued", "running", "failed"]
|
|
28
|
+
AiReportStatus: TypeAlias = Literal["pending", "processing", "completed", "failed"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class ScanIssue:
|
|
33
|
+
rule_id: str
|
|
34
|
+
severity: Severity # deprecated: prefer severity_normalized
|
|
35
|
+
severity_normalized: SeverityNormalized
|
|
36
|
+
category: IssueCategory
|
|
37
|
+
message: str
|
|
38
|
+
file: str | None = None
|
|
39
|
+
line: int | None = None
|
|
40
|
+
snippet: str | None = None
|
|
41
|
+
domain: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class ScanSummary:
|
|
46
|
+
total_issues: int
|
|
47
|
+
danger: int
|
|
48
|
+
warning: int
|
|
49
|
+
info: int
|
|
50
|
+
files_scanned: int
|
|
51
|
+
by_category: dict[str, int] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class AiFalsePositive:
|
|
56
|
+
rule_id: str
|
|
57
|
+
file: str
|
|
58
|
+
reason: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class AiCategoryResult:
|
|
63
|
+
category: str
|
|
64
|
+
status: str
|
|
65
|
+
severity: str
|
|
66
|
+
title: str
|
|
67
|
+
description: str
|
|
68
|
+
evidence: list[str] = field(default_factory=list)
|
|
69
|
+
confidence: float = 0.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class AgentRun:
|
|
74
|
+
"""One agent's run in the v2 multi-agent pipeline (present on get_report)."""
|
|
75
|
+
|
|
76
|
+
agent: str
|
|
77
|
+
status: str
|
|
78
|
+
findings: list[dict] = field(default_factory=list)
|
|
79
|
+
summary: str | None = None
|
|
80
|
+
tokens_used: int | None = None
|
|
81
|
+
duration_ms: int | None = None
|
|
82
|
+
error_message: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(slots=True)
|
|
86
|
+
class AiReport:
|
|
87
|
+
status: AiReportStatus
|
|
88
|
+
risk_level: str | None
|
|
89
|
+
refined_rating: Rating | None
|
|
90
|
+
false_positives: list[AiFalsePositive] = field(default_factory=list)
|
|
91
|
+
analysis: list[AiCategoryResult] = field(default_factory=list)
|
|
92
|
+
summary: str | None = None
|
|
93
|
+
categories_checked: list[AiCategoryResult] = field(default_factory=list)
|
|
94
|
+
tokens_used: int | None = None
|
|
95
|
+
duration_ms: int | None = None
|
|
96
|
+
error_message: str | None = None
|
|
97
|
+
# Per-agent detail (multi-agent pipeline); None when not exposed.
|
|
98
|
+
agents: list[AgentRun] | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class SupplyChainHit:
|
|
103
|
+
"""One supply-chain blacklist hit (a dependency/domain known to be poisoned)."""
|
|
104
|
+
|
|
105
|
+
kind: str
|
|
106
|
+
ecosystem: str | None
|
|
107
|
+
name: str
|
|
108
|
+
version_spec: dict
|
|
109
|
+
reason: str
|
|
110
|
+
severity: str
|
|
111
|
+
reference_url: str | None
|
|
112
|
+
match_strength: str
|
|
113
|
+
matched_version: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(slots=True)
|
|
117
|
+
class SupplyChainReport:
|
|
118
|
+
"""Supply-chain section of a report. Any blacklist hit floors the verdict
|
|
119
|
+
to high unless the skill is admin-whitelisted."""
|
|
120
|
+
|
|
121
|
+
dependency_count: int = 0
|
|
122
|
+
endpoint_count: int = 0
|
|
123
|
+
blacklist_hits: list[SupplyChainHit] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(slots=True)
|
|
127
|
+
class PlatformListing:
|
|
128
|
+
platform: str
|
|
129
|
+
url: str
|
|
130
|
+
installs: int
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(slots=True)
|
|
134
|
+
class ScanReport:
|
|
135
|
+
scan_id: str
|
|
136
|
+
skill_name: str
|
|
137
|
+
source: str
|
|
138
|
+
skill_path: str
|
|
139
|
+
tree_sha: str
|
|
140
|
+
rule_version: str
|
|
141
|
+
valid_skill: bool
|
|
142
|
+
rating: Rating # deprecated: prefer verdict
|
|
143
|
+
verdict: Verdict
|
|
144
|
+
scanned_at: str
|
|
145
|
+
cached: bool
|
|
146
|
+
scan_duration_ms: int
|
|
147
|
+
summary: ScanSummary
|
|
148
|
+
issues: list[ScanIssue]
|
|
149
|
+
report_url: str
|
|
150
|
+
badge_url: str
|
|
151
|
+
blacklisted: bool
|
|
152
|
+
whitelisted: bool
|
|
153
|
+
status: str
|
|
154
|
+
ai_report: AiReport | None = None
|
|
155
|
+
external_links: dict[str, str] = field(default_factory=dict)
|
|
156
|
+
platform_listings: list[PlatformListing] = field(default_factory=list)
|
|
157
|
+
# Supply-chain snapshot; None when the section is unavailable.
|
|
158
|
+
supply_chain: SupplyChainReport | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(slots=True)
|
|
162
|
+
class QueryResult:
|
|
163
|
+
status: QueryStatus
|
|
164
|
+
message: str
|
|
165
|
+
scan_id: str | None = None
|
|
166
|
+
poll_url: str | None = None
|
|
167
|
+
report: ScanReport | None = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(slots=True)
|
|
171
|
+
class ScanResult:
|
|
172
|
+
status: ScanStatus
|
|
173
|
+
message: str
|
|
174
|
+
scan_id: str | None = None
|
|
175
|
+
poll_url: str | None = None
|
|
176
|
+
report: ScanReport | None = None
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import respx
|
|
7
|
+
from httpx import Response
|
|
8
|
+
|
|
9
|
+
from skillplus import SkillPlus, SkillPlusError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
REPORT_PAYLOAD = {
|
|
13
|
+
"scan_id": "scan_123",
|
|
14
|
+
"skill_name": "example",
|
|
15
|
+
"source": "github:owner/repo",
|
|
16
|
+
"skill_path": "skills/example",
|
|
17
|
+
"tree_sha": "abc123",
|
|
18
|
+
"rule_version": "rules-v1",
|
|
19
|
+
"valid_skill": True,
|
|
20
|
+
"rating": "safe",
|
|
21
|
+
"scanned_at": "2026-06-10T00:00:00Z",
|
|
22
|
+
"cached": True,
|
|
23
|
+
"scan_duration_ms": 1200,
|
|
24
|
+
"summary": {
|
|
25
|
+
"total_issues": 1,
|
|
26
|
+
"danger": 0,
|
|
27
|
+
"warning": 1,
|
|
28
|
+
"info": 0,
|
|
29
|
+
"files_scanned": 4,
|
|
30
|
+
"by_category": {"url": 1},
|
|
31
|
+
},
|
|
32
|
+
"issues": [
|
|
33
|
+
{
|
|
34
|
+
"rule_id": "external-url",
|
|
35
|
+
"severity": "warning",
|
|
36
|
+
"category": "url",
|
|
37
|
+
"message": "External URL found",
|
|
38
|
+
"file": "SKILL.md",
|
|
39
|
+
"line": 12,
|
|
40
|
+
"snippet": "https://example.com",
|
|
41
|
+
"domain": "example.com",
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"report_url": "https://skillplus.xyz/report/scan_123",
|
|
45
|
+
"badge_url": "https://skillplus.xyz/api/report/scan_123/badge.svg",
|
|
46
|
+
"blacklisted": False,
|
|
47
|
+
"whitelisted": False,
|
|
48
|
+
"status": "completed",
|
|
49
|
+
"ai_report": {
|
|
50
|
+
"status": "completed",
|
|
51
|
+
"risk_level": "low",
|
|
52
|
+
"refined_rating": "safe",
|
|
53
|
+
"false_positives": [
|
|
54
|
+
{"rule_id": "external-url", "file": "SKILL.md", "reason": "Documentation link"}
|
|
55
|
+
],
|
|
56
|
+
"analysis": [
|
|
57
|
+
{
|
|
58
|
+
"category": "Command Execution",
|
|
59
|
+
"status": "pass",
|
|
60
|
+
"severity": "safe",
|
|
61
|
+
"title": "No command execution",
|
|
62
|
+
"description": "No risky command execution was found.",
|
|
63
|
+
"evidence": ["No shell usage"],
|
|
64
|
+
"confidence": 0.98,
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"summary": "No meaningful risk found.",
|
|
68
|
+
"categories_checked": [],
|
|
69
|
+
"tokens_used": 500,
|
|
70
|
+
"duration_ms": 800,
|
|
71
|
+
"error_message": None,
|
|
72
|
+
},
|
|
73
|
+
"external_links": {"website": "https://example.com"},
|
|
74
|
+
"platform_listings": [
|
|
75
|
+
{"platform": "skills.sh", "url": "https://skills.sh/owner/repo/example", "installs": 10}
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@respx.mock
|
|
81
|
+
def test_query_sends_auth_and_request_body() -> None:
|
|
82
|
+
route = respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
83
|
+
return_value=Response(
|
|
84
|
+
200,
|
|
85
|
+
json={
|
|
86
|
+
"status": "found",
|
|
87
|
+
"message": "Report found",
|
|
88
|
+
"scan_id": "scan_123",
|
|
89
|
+
"poll_url": "/api/scan/status/scan_123",
|
|
90
|
+
"report": REPORT_PAYLOAD,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
96
|
+
result = client.query(
|
|
97
|
+
"https://github.com/owner/repo",
|
|
98
|
+
skill_path="skills/example",
|
|
99
|
+
scan_if_missing=True,
|
|
100
|
+
preferred_provider="github",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
request = route.calls.last.request
|
|
104
|
+
assert request.headers["Authorization"] == "Bearer skp_test"
|
|
105
|
+
assert request.headers["Content-Type"] == "application/json"
|
|
106
|
+
assert json.loads(request.read()) == {
|
|
107
|
+
"repo_url": "https://github.com/owner/repo",
|
|
108
|
+
"skill_path": "skills/example",
|
|
109
|
+
"scan_if_missing": True,
|
|
110
|
+
"preferred_provider": "github",
|
|
111
|
+
}
|
|
112
|
+
assert result.status == "found"
|
|
113
|
+
assert result.scan_id == "scan_123"
|
|
114
|
+
assert result.report is not None
|
|
115
|
+
assert result.report.rating == "safe"
|
|
116
|
+
assert result.report.summary.warning == 1
|
|
117
|
+
assert result.report.issues[0].rule_id == "external-url"
|
|
118
|
+
assert result.report.ai_report is not None
|
|
119
|
+
assert result.report.ai_report.false_positives[0].reason == "Documentation link"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@respx.mock
|
|
123
|
+
def test_query_without_report_returns_status_envelope() -> None:
|
|
124
|
+
respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
125
|
+
return_value=Response(
|
|
126
|
+
200,
|
|
127
|
+
json={
|
|
128
|
+
"status": "queued",
|
|
129
|
+
"message": "Scan queued",
|
|
130
|
+
"scan_id": "scan_queued",
|
|
131
|
+
"poll_url": "/api/scan/status/scan_queued",
|
|
132
|
+
"report": None,
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
client = SkillPlus(api_key="skp_test")
|
|
138
|
+
try:
|
|
139
|
+
result = client.query("https://github.com/owner/repo", scan_if_missing=True)
|
|
140
|
+
finally:
|
|
141
|
+
client.close()
|
|
142
|
+
|
|
143
|
+
assert result.status == "queued"
|
|
144
|
+
assert result.scan_id == "scan_queued"
|
|
145
|
+
assert result.report is None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@respx.mock
|
|
149
|
+
def test_scan_sends_force_flag() -> None:
|
|
150
|
+
route = respx.post("https://skillplus.xyz/api/sdk/scan").mock(
|
|
151
|
+
return_value=Response(
|
|
152
|
+
200,
|
|
153
|
+
json={
|
|
154
|
+
"status": "running",
|
|
155
|
+
"message": "Scan already running",
|
|
156
|
+
"scan_id": "scan_running",
|
|
157
|
+
"poll_url": "/api/scan/status/scan_running",
|
|
158
|
+
"report": None,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
164
|
+
result = client.scan("https://github.com/owner/repo", skill_path="skills/example", force=True)
|
|
165
|
+
|
|
166
|
+
assert json.loads(route.calls.last.request.read()) == {
|
|
167
|
+
"repo_url": "https://github.com/owner/repo",
|
|
168
|
+
"skill_path": "skills/example",
|
|
169
|
+
"preferred_provider": None,
|
|
170
|
+
"force": True,
|
|
171
|
+
}
|
|
172
|
+
assert result.status == "running"
|
|
173
|
+
assert result.report is None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@respx.mock
|
|
177
|
+
def test_get_report_parses_report() -> None:
|
|
178
|
+
respx.get("https://skillplus.xyz/api/report/scan_123").mock(
|
|
179
|
+
return_value=Response(200, json=REPORT_PAYLOAD)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
183
|
+
report = client.get_report("scan_123")
|
|
184
|
+
|
|
185
|
+
assert report.scan_id == "scan_123"
|
|
186
|
+
assert report.platform_listings[0].platform == "skills.sh"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@respx.mock
|
|
190
|
+
def test_get_badge_returns_svg_text() -> None:
|
|
191
|
+
respx.get("https://skillplus.xyz/api/report/scan_123/badge.svg").mock(
|
|
192
|
+
return_value=Response(200, text="<svg></svg>")
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
196
|
+
badge = client.get_badge("scan_123")
|
|
197
|
+
|
|
198
|
+
assert badge == "<svg></svg>"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_get_badge_url_uses_hosted_service() -> None:
|
|
202
|
+
client = SkillPlus(api_key="skp_test")
|
|
203
|
+
try:
|
|
204
|
+
assert (
|
|
205
|
+
client.get_badge_url("scan_123")
|
|
206
|
+
== "https://skillplus.xyz/api/report/scan_123/badge.svg"
|
|
207
|
+
)
|
|
208
|
+
finally:
|
|
209
|
+
client.close()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@respx.mock
|
|
213
|
+
def test_non_ok_response_raises_skillplus_error() -> None:
|
|
214
|
+
respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
215
|
+
return_value=Response(401, json={"detail": "Invalid API key"})
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
with SkillPlus(api_key="bad_key") as client:
|
|
219
|
+
with pytest.raises(SkillPlusError) as exc_info:
|
|
220
|
+
client.query("https://github.com/owner/repo")
|
|
221
|
+
|
|
222
|
+
assert exc_info.value.status_code == 401
|
|
223
|
+
assert exc_info.value.message == "Invalid API key"
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import respx
|
|
7
|
+
from httpx import Response
|
|
8
|
+
|
|
9
|
+
from skillplus import SkillPlus, SkillPlusError
|
|
10
|
+
|
|
11
|
+
from test_client import REPORT_PAYLOAD
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _report(**overrides):
|
|
15
|
+
payload = copy.deepcopy(REPORT_PAYLOAD)
|
|
16
|
+
payload.update(overrides)
|
|
17
|
+
return payload
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SUPPLY_CHAIN = {
|
|
21
|
+
"dependency_count": 12,
|
|
22
|
+
"endpoint_count": 3,
|
|
23
|
+
"blacklist_hits": [
|
|
24
|
+
{
|
|
25
|
+
"kind": "package",
|
|
26
|
+
"ecosystem": "pypi",
|
|
27
|
+
"name": "litellm",
|
|
28
|
+
"version_spec": {"exact": ["1.82.7", "1.82.8"]},
|
|
29
|
+
"reason": "known malicious release",
|
|
30
|
+
"severity": "critical",
|
|
31
|
+
"reference_url": "https://example.com/advisory",
|
|
32
|
+
"match_strength": "exact",
|
|
33
|
+
"matched_version": "1.82.7",
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@respx.mock
|
|
40
|
+
def test_supply_chain_parsed() -> None:
|
|
41
|
+
respx.get("https://skillplus.xyz/api/report/scan_123").mock(
|
|
42
|
+
return_value=Response(200, json=_report(supply_chain=SUPPLY_CHAIN))
|
|
43
|
+
)
|
|
44
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
45
|
+
report = client.get_report("scan_123")
|
|
46
|
+
|
|
47
|
+
assert report.supply_chain is not None
|
|
48
|
+
assert report.supply_chain.dependency_count == 12
|
|
49
|
+
assert report.supply_chain.endpoint_count == 3
|
|
50
|
+
hit = report.supply_chain.blacklist_hits[0]
|
|
51
|
+
assert hit.name == "litellm"
|
|
52
|
+
assert hit.ecosystem == "pypi"
|
|
53
|
+
assert hit.matched_version == "1.82.7"
|
|
54
|
+
assert hit.version_spec == {"exact": ["1.82.7", "1.82.8"]}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@respx.mock
|
|
58
|
+
def test_supply_chain_absent_is_none() -> None:
|
|
59
|
+
respx.get("https://skillplus.xyz/api/report/scan_123").mock(
|
|
60
|
+
return_value=Response(200, json=_report())
|
|
61
|
+
)
|
|
62
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
63
|
+
report = client.get_report("scan_123")
|
|
64
|
+
assert report.supply_chain is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@respx.mock
|
|
68
|
+
def test_agent_runs_parsed() -> None:
|
|
69
|
+
payload = _report()
|
|
70
|
+
payload["ai_report"]["agents"] = [
|
|
71
|
+
{
|
|
72
|
+
"agent": "supply_chain",
|
|
73
|
+
"status": "completed",
|
|
74
|
+
"findings": [{"category": "SUPPLY_CHAIN", "severity": "high"}],
|
|
75
|
+
"summary": "one poisoned dependency",
|
|
76
|
+
"tokens_used": 1234,
|
|
77
|
+
"duration_ms": 4000,
|
|
78
|
+
"error_message": None,
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
respx.get("https://skillplus.xyz/api/report/scan_123").mock(
|
|
82
|
+
return_value=Response(200, json=payload)
|
|
83
|
+
)
|
|
84
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
85
|
+
report = client.get_report("scan_123")
|
|
86
|
+
|
|
87
|
+
assert report.ai_report is not None
|
|
88
|
+
assert report.ai_report.agents is not None
|
|
89
|
+
assert report.ai_report.agents[0].agent == "supply_chain"
|
|
90
|
+
assert report.ai_report.agents[0].findings[0]["severity"] == "high"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@respx.mock
|
|
94
|
+
def test_v3_severity_passthrough() -> None:
|
|
95
|
+
payload = _report()
|
|
96
|
+
payload["issues"][0]["severity"] = "high"
|
|
97
|
+
respx.get("https://skillplus.xyz/api/report/scan_123").mock(
|
|
98
|
+
return_value=Response(200, json=payload)
|
|
99
|
+
)
|
|
100
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
101
|
+
report = client.get_report("scan_123")
|
|
102
|
+
assert report.issues[0].severity == "high"
|
|
103
|
+
assert report.issues[0].severity_normalized == "high"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@respx.mock
|
|
107
|
+
def test_base_url_override() -> None:
|
|
108
|
+
respx.get("https://staging.example.com/api/report/scan_123").mock(
|
|
109
|
+
return_value=Response(200, json=_report())
|
|
110
|
+
)
|
|
111
|
+
with SkillPlus(api_key="skp_test", base_url="https://staging.example.com/") as client:
|
|
112
|
+
report = client.get_report("scan_123")
|
|
113
|
+
assert client.get_badge_url("scan_123") == (
|
|
114
|
+
"https://staging.example.com/api/report/scan_123/badge.svg"
|
|
115
|
+
)
|
|
116
|
+
assert report.scan_id == "scan_123"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@respx.mock
|
|
120
|
+
def test_retry_on_429_with_retry_after() -> None:
|
|
121
|
+
route = respx.get("https://skillplus.xyz/api/report/scan_123")
|
|
122
|
+
route.side_effect = [
|
|
123
|
+
Response(429, headers={"Retry-After": "0"}, json={"detail": "Rate limit exceeded"}),
|
|
124
|
+
Response(200, json=_report()),
|
|
125
|
+
]
|
|
126
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
127
|
+
report = client.get_report("scan_123")
|
|
128
|
+
assert report.scan_id == "scan_123"
|
|
129
|
+
assert route.call_count == 2
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@respx.mock
|
|
133
|
+
def test_no_retry_when_disabled() -> None:
|
|
134
|
+
respx.get("https://skillplus.xyz/api/report/scan_123").mock(
|
|
135
|
+
return_value=Response(429, headers={"Retry-After": "0"}, json={"detail": "Rate limit exceeded"})
|
|
136
|
+
)
|
|
137
|
+
with SkillPlus(api_key="skp_test", max_retries=0) as client:
|
|
138
|
+
with pytest.raises(SkillPlusError) as excinfo:
|
|
139
|
+
client.get_report("scan_123")
|
|
140
|
+
assert excinfo.value.status_code == 429
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@respx.mock
|
|
144
|
+
def test_wait_for_report_polls_until_completed() -> None:
|
|
145
|
+
queued = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
|
|
146
|
+
found = {
|
|
147
|
+
"status": "found",
|
|
148
|
+
"message": "Report found",
|
|
149
|
+
"scan_id": "scan_123",
|
|
150
|
+
"poll_url": "/api/scan/status/scan_123",
|
|
151
|
+
"report": _report(),
|
|
152
|
+
}
|
|
153
|
+
route = respx.post("https://skillplus.xyz/api/sdk/query")
|
|
154
|
+
route.side_effect = [Response(200, json=queued), Response(200, json=found)]
|
|
155
|
+
|
|
156
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
157
|
+
report = client.wait_for_report(
|
|
158
|
+
"https://github.com/owner/repo", interval=0.01, timeout=5.0
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert report.scan_id == "scan_123"
|
|
162
|
+
assert route.call_count == 2
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@respx.mock
|
|
166
|
+
def test_wait_for_report_times_out() -> None:
|
|
167
|
+
queued = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
|
|
168
|
+
respx.post("https://skillplus.xyz/api/sdk/query").mock(
|
|
169
|
+
return_value=Response(200, json=queued)
|
|
170
|
+
)
|
|
171
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
172
|
+
with pytest.raises(SkillPlusError) as excinfo:
|
|
173
|
+
client.wait_for_report("https://github.com/owner/repo", interval=0.01, timeout=0.02)
|
|
174
|
+
assert "Timed out" in str(excinfo.value)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@respx.mock
|
|
178
|
+
def test_verdict_fallback_from_legacy_rating() -> None:
|
|
179
|
+
respx.get("https://skillplus.xyz/api/report/scan_123").mock(
|
|
180
|
+
return_value=Response(200, json=_report(rating="low"))
|
|
181
|
+
)
|
|
182
|
+
with SkillPlus(api_key="skp_test") as client:
|
|
183
|
+
report = client.get_report("scan_123")
|
|
184
|
+
assert report.rating == "low"
|
|
185
|
+
assert report.verdict == "safe"
|