cyfendo 2.0.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.
- cyfendo-2.0.0/PKG-INFO +59 -0
- cyfendo-2.0.0/README.md +34 -0
- cyfendo-2.0.0/cyfendo/__init__.py +7 -0
- cyfendo-2.0.0/cyfendo/client.py +218 -0
- cyfendo-2.0.0/cyfendo/events.py +81 -0
- cyfendo-2.0.0/cyfendo/main.py +610 -0
- cyfendo-2.0.0/cyfendo/packager.py +173 -0
- cyfendo-2.0.0/cyfendo/patcher.py +203 -0
- cyfendo-2.0.0/cyfendo/terminal.py +155 -0
- cyfendo-2.0.0/cyfendo.egg-info/PKG-INFO +59 -0
- cyfendo-2.0.0/cyfendo.egg-info/SOURCES.txt +16 -0
- cyfendo-2.0.0/cyfendo.egg-info/dependency_links.txt +1 -0
- cyfendo-2.0.0/cyfendo.egg-info/entry_points.txt +2 -0
- cyfendo-2.0.0/cyfendo.egg-info/top_level.txt +1 -0
- cyfendo-2.0.0/pyproject.toml +34 -0
- cyfendo-2.0.0/setup.cfg +4 -0
- cyfendo-2.0.0/setup.py +16 -0
- cyfendo-2.0.0/tests/test_cli.py +120 -0
cyfendo-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cyfendo
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Lightweight, zero-dependency CLI scanner for the Cyfendo Autonomous Security Platform
|
|
5
|
+
Author: Cyfendo Security Team
|
|
6
|
+
Author-email: Cyfendo Security Team <contact@cyfendo.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://cyfendo.com
|
|
9
|
+
Project-URL: Documentation, https://cyfendo.com/docs
|
|
10
|
+
Project-URL: Developer Settings, https://cyfendo.com/settings/developer
|
|
11
|
+
Keywords: security,sast,vulnerability-scanner,appsec,cyfendo,patching
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Dynamic: author
|
|
24
|
+
Dynamic: requires-python
|
|
25
|
+
|
|
26
|
+
# Cyfendo CLI
|
|
27
|
+
|
|
28
|
+
> **Fast, zero-dependency command-line interface for Cyfendo Autonomous Security Platform.**
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Global pip install
|
|
34
|
+
pip install cyfendo
|
|
35
|
+
|
|
36
|
+
# Or install in editable/development mode
|
|
37
|
+
pip install -e ./cli
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# 1. Authenticate with your Cyfendo API Key
|
|
44
|
+
cyfendo login --key cy_live_xxxxxxxxxxxxxxxx
|
|
45
|
+
|
|
46
|
+
# 2. Run a vulnerability scan in the current directory
|
|
47
|
+
cyfendo scan .
|
|
48
|
+
|
|
49
|
+
# 3. Target an existing protected project
|
|
50
|
+
cyfendo scan . --project "payment-gateway"
|
|
51
|
+
|
|
52
|
+
# 4. View and export individual AI remediation patches
|
|
53
|
+
cyfendo patches
|
|
54
|
+
cyfendo patch fnd_8b22 --show
|
|
55
|
+
cyfendo patch fnd_8b22 --apply
|
|
56
|
+
|
|
57
|
+
# 5. CI/CD automation with SARIF export and threshold gate
|
|
58
|
+
cyfendo scan . --fail-on=high --sarif=results.sarif --no-interactive
|
|
59
|
+
```
|
cyfendo-2.0.0/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Cyfendo CLI
|
|
2
|
+
|
|
3
|
+
> **Fast, zero-dependency command-line interface for Cyfendo Autonomous Security Platform.**
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Global pip install
|
|
9
|
+
pip install cyfendo
|
|
10
|
+
|
|
11
|
+
# Or install in editable/development mode
|
|
12
|
+
pip install -e ./cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 1. Authenticate with your Cyfendo API Key
|
|
19
|
+
cyfendo login --key cy_live_xxxxxxxxxxxxxxxx
|
|
20
|
+
|
|
21
|
+
# 2. Run a vulnerability scan in the current directory
|
|
22
|
+
cyfendo scan .
|
|
23
|
+
|
|
24
|
+
# 3. Target an existing protected project
|
|
25
|
+
cyfendo scan . --project "payment-gateway"
|
|
26
|
+
|
|
27
|
+
# 4. View and export individual AI remediation patches
|
|
28
|
+
cyfendo patches
|
|
29
|
+
cyfendo patch fnd_8b22 --show
|
|
30
|
+
cyfendo patch fnd_8b22 --apply
|
|
31
|
+
|
|
32
|
+
# 5. CI/CD automation with SARIF export and threshold gate
|
|
33
|
+
cyfendo scan . --fail-on=high --sarif=results.sarif --no-interactive
|
|
34
|
+
```
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cyfendo API Client Module
|
|
3
|
+
Lightweight HTTP client built entirely with Python standard libraries (urllib).
|
|
4
|
+
Handles API Key authentication, multipart file uploads, and streaming endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import uuid
|
|
10
|
+
import urllib.request
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.error
|
|
13
|
+
import ssl
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Dict, Any, Tuple
|
|
16
|
+
try:
|
|
17
|
+
from . import __version__
|
|
18
|
+
except ImportError:
|
|
19
|
+
from cyfendo import __version__
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_SERVER_URL = os.environ.get("CYFENDO_SERVER_URL", "https://cyfendo.com")
|
|
23
|
+
CONFIG_DIR = Path.home() / ".cyfendo"
|
|
24
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CyfendoClientError(Exception):
|
|
28
|
+
"""Base exception for Cyfendo API client errors."""
|
|
29
|
+
def __init__(self, message: str, status_code: Optional[int] = None, response_data: Optional[dict] = None):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.response_data = response_data or {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CyfendoClient:
|
|
36
|
+
def __init__(self, api_key: Optional[str] = None, server_url: Optional[str] = None):
|
|
37
|
+
self.config = self.load_config()
|
|
38
|
+
self.api_key = api_key or os.environ.get("CYFENDO_API_KEY") or self.config.get("api_key")
|
|
39
|
+
self.server_url = (server_url or os.environ.get("CYFENDO_SERVER_URL") or self.config.get("server_url") or DEFAULT_SERVER_URL).rstrip("/")
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def load_config() -> Dict[str, Any]:
|
|
43
|
+
"""Loads credentials and settings from ~/.cyfendo/config.json."""
|
|
44
|
+
if CONFIG_FILE.is_file():
|
|
45
|
+
try:
|
|
46
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
47
|
+
return json.load(f)
|
|
48
|
+
except Exception:
|
|
49
|
+
return {}
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def save_config(cls, api_key: str, server_url: Optional[str] = None) -> None:
|
|
54
|
+
"""Saves authentication credentials to ~/.cyfendo/config.json."""
|
|
55
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
config_data = cls.load_config()
|
|
57
|
+
config_data["api_key"] = api_key.strip()
|
|
58
|
+
if server_url:
|
|
59
|
+
config_data["server_url"] = server_url.rstrip("/")
|
|
60
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
61
|
+
json.dump(config_data, f, indent=2)
|
|
62
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def clear_config(cls) -> None:
|
|
66
|
+
"""Clears local credentials."""
|
|
67
|
+
if CONFIG_FILE.is_file():
|
|
68
|
+
try:
|
|
69
|
+
os.remove(CONFIG_FILE)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def _get_headers(self, content_type: Optional[str] = None) -> Dict[str, str]:
|
|
74
|
+
"""Builds default request headers with Bearer API Key."""
|
|
75
|
+
headers = {
|
|
76
|
+
"User-Agent": f"Cyfendo-CLI/{__version__}",
|
|
77
|
+
"Accept": "application/json"
|
|
78
|
+
}
|
|
79
|
+
if self.api_key:
|
|
80
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
81
|
+
headers["X-Cyfendo-Api-Key"] = self.api_key
|
|
82
|
+
if content_type:
|
|
83
|
+
headers["Content-Type"] = content_type
|
|
84
|
+
return headers
|
|
85
|
+
|
|
86
|
+
def _make_request(
|
|
87
|
+
self,
|
|
88
|
+
path: str,
|
|
89
|
+
method: str = "GET",
|
|
90
|
+
data: Optional[bytes] = None,
|
|
91
|
+
headers: Optional[Dict[str, str]] = None,
|
|
92
|
+
timeout: int = 60
|
|
93
|
+
) -> Tuple[int, Any]:
|
|
94
|
+
"""Executes HTTP request via standard library urllib."""
|
|
95
|
+
url = f"{self.server_url}{path}" if path.startswith("/") else f"{self.server_url}/{path}"
|
|
96
|
+
req_headers = self._get_headers()
|
|
97
|
+
if headers:
|
|
98
|
+
req_headers.update(headers)
|
|
99
|
+
|
|
100
|
+
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
|
101
|
+
ctx = ssl.create_default_context()
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as response:
|
|
105
|
+
status_code = response.getcode()
|
|
106
|
+
content_type = response.headers.get("Content-Type", "")
|
|
107
|
+
raw_bytes = response.read()
|
|
108
|
+
|
|
109
|
+
if "application/json" in content_type:
|
|
110
|
+
return status_code, json.loads(raw_bytes.decode("utf-8"))
|
|
111
|
+
return status_code, raw_bytes
|
|
112
|
+
except urllib.error.HTTPError as e:
|
|
113
|
+
err_body = e.read().decode("utf-8", errors="replace")
|
|
114
|
+
err_json = {}
|
|
115
|
+
try:
|
|
116
|
+
err_json = json.loads(err_body)
|
|
117
|
+
err_msg = err_json.get("error") or err_json.get("message") or str(e)
|
|
118
|
+
except Exception:
|
|
119
|
+
err_msg = err_body[:200] if err_body else str(e)
|
|
120
|
+
raise CyfendoClientError(err_msg, status_code=e.code, response_data=err_json)
|
|
121
|
+
except urllib.error.URLError as e:
|
|
122
|
+
raise CyfendoClientError(f"Connection failed to {self.server_url}: {e.reason}")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
raise CyfendoClientError(f"Unexpected network error: {str(e)}")
|
|
125
|
+
|
|
126
|
+
def whoami(self) -> Dict[str, Any]:
|
|
127
|
+
"""Retrieves authenticated user identity and workspace status."""
|
|
128
|
+
_, data = self._make_request("/api/v1/cli/whoami")
|
|
129
|
+
return data
|
|
130
|
+
|
|
131
|
+
def list_projects(self) -> Dict[str, Any]:
|
|
132
|
+
"""Retrieves list of active protected projects in workspace."""
|
|
133
|
+
_, data = self._make_request("/api/v1/cli/status")
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
def submit_scan(
|
|
137
|
+
self,
|
|
138
|
+
zip_bytes: bytes,
|
|
139
|
+
project_name: str,
|
|
140
|
+
project_id: Optional[str] = None,
|
|
141
|
+
scan_profile: str = "deep_agentic",
|
|
142
|
+
confirm_separate: bool = False,
|
|
143
|
+
generate_patches: bool = False,
|
|
144
|
+
dedup_scope: str = "root_cause",
|
|
145
|
+
benchmark_mode: bool = False
|
|
146
|
+
) -> Dict[str, Any]:
|
|
147
|
+
"""
|
|
148
|
+
Submits workspace archive to the single-shot CLI scan endpoint.
|
|
149
|
+
Uses pure standard library multipart/form-data encoding.
|
|
150
|
+
"""
|
|
151
|
+
boundary = f"----CyfendoBoundary{uuid.uuid4().hex}"
|
|
152
|
+
body_parts = []
|
|
153
|
+
|
|
154
|
+
# Form fields
|
|
155
|
+
fields = {
|
|
156
|
+
"repo_name": project_name,
|
|
157
|
+
"scan_profile": scan_profile,
|
|
158
|
+
"confirm_separate": "true" if confirm_separate else "false",
|
|
159
|
+
"generate_patches": "true" if generate_patches else "false",
|
|
160
|
+
"dedup_scope": str(dedup_scope or "root_cause"),
|
|
161
|
+
"benchmark_mode": "true" if benchmark_mode else "false"
|
|
162
|
+
}
|
|
163
|
+
if project_id:
|
|
164
|
+
fields["project_id"] = project_id
|
|
165
|
+
|
|
166
|
+
for k, v in fields.items():
|
|
167
|
+
body_parts.append(f"--{boundary}\r\n".encode("utf-8"))
|
|
168
|
+
body_parts.append(f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode("utf-8"))
|
|
169
|
+
body_parts.append(f"{v}\r\n".encode("utf-8"))
|
|
170
|
+
|
|
171
|
+
# File part
|
|
172
|
+
body_parts.append(f"--{boundary}\r\n".encode("utf-8"))
|
|
173
|
+
body_parts.append(f'Content-Disposition: form-data; name="file"; filename="source.zip"\r\n'.encode("utf-8"))
|
|
174
|
+
body_parts.append(b"Content-Type: application/zip\r\n\r\n")
|
|
175
|
+
body_parts.append(zip_bytes)
|
|
176
|
+
body_parts.append(b"\r\n")
|
|
177
|
+
body_parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
|
178
|
+
|
|
179
|
+
payload = b"".join(body_parts)
|
|
180
|
+
content_type = f"multipart/form-data; boundary={boundary}"
|
|
181
|
+
|
|
182
|
+
_, data = self._make_request(
|
|
183
|
+
"/api/v1/cli/scan",
|
|
184
|
+
method="POST",
|
|
185
|
+
data=payload,
|
|
186
|
+
headers={"Content-Type": content_type},
|
|
187
|
+
timeout=120
|
|
188
|
+
)
|
|
189
|
+
return data
|
|
190
|
+
|
|
191
|
+
def get_scan(self, scan_id: str) -> Dict[str, Any]:
|
|
192
|
+
"""Retrieves scan details, summary, and findings."""
|
|
193
|
+
_, data = self._make_request(f"/api/v1/scans/{scan_id}")
|
|
194
|
+
return data
|
|
195
|
+
|
|
196
|
+
def get_patches(self, scan_id: str) -> Dict[str, Any]:
|
|
197
|
+
"""Retrieves all finding patches for scan."""
|
|
198
|
+
_, data = self._make_request(f"/api/v1/scans/{scan_id}/patches")
|
|
199
|
+
return data
|
|
200
|
+
|
|
201
|
+
def get_sarif(self, scan_id: str) -> Dict[str, Any]:
|
|
202
|
+
"""Retrieves standard SARIF 2.1.0 report."""
|
|
203
|
+
_, data = self._make_request(f"/api/v1/scans/{scan_id}/export/sarif")
|
|
204
|
+
return data
|
|
205
|
+
|
|
206
|
+
def get_markdown_report(self, scan_id: str) -> str:
|
|
207
|
+
"""Retrieves full Markdown audit report text."""
|
|
208
|
+
_, data = self._make_request(f"/api/v1/scans/{scan_id}/export/markdown")
|
|
209
|
+
if isinstance(data, bytes):
|
|
210
|
+
return data.decode("utf-8", errors="replace")
|
|
211
|
+
return str(data)
|
|
212
|
+
|
|
213
|
+
def get_unified_patch(self, scan_id: str) -> str:
|
|
214
|
+
"""Retrieves aggregated unified diff patch file."""
|
|
215
|
+
_, data = self._make_request(f"/api/v1/scans/{scan_id}/export/patch")
|
|
216
|
+
if isinstance(data, bytes):
|
|
217
|
+
return data.decode("utf-8", errors="replace")
|
|
218
|
+
return str(data)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live Telemetry & SSE Streaming Module
|
|
3
|
+
Connects to Cyfendo Server-Sent Events (SSE) endpoint to stream live multi-agent scanning progress.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import urllib.request
|
|
9
|
+
import ssl
|
|
10
|
+
from typing import Callable, Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SSEStreamError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def stream_scan_events(
|
|
18
|
+
server_url: str,
|
|
19
|
+
scan_id: str,
|
|
20
|
+
api_key: Optional[str] = None,
|
|
21
|
+
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
22
|
+
timeout: int = 300
|
|
23
|
+
) -> Dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
Subscribes to /api/v1/scans/<scan_id>/events and yields live telemetry events.
|
|
26
|
+
Returns the final completion event payload.
|
|
27
|
+
"""
|
|
28
|
+
url = f"{server_url.rstrip('/')}/api/v1/scans/{scan_id}/events"
|
|
29
|
+
headers = {
|
|
30
|
+
"Accept": "text/event-stream",
|
|
31
|
+
"Cache-Control": "no-cache",
|
|
32
|
+
"User-Agent": "Cyfendo-CLI/2.0.0"
|
|
33
|
+
}
|
|
34
|
+
if api_key:
|
|
35
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
36
|
+
headers["X-Cyfendo-Api-Key"] = api_key
|
|
37
|
+
|
|
38
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
39
|
+
ctx = ssl.create_default_context()
|
|
40
|
+
|
|
41
|
+
start_time = time.time()
|
|
42
|
+
last_event: Dict[str, Any] = {"stage": "running", "progress_percentage": 0}
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as response:
|
|
46
|
+
buffer = ""
|
|
47
|
+
for raw_chunk in response:
|
|
48
|
+
if time.time() - start_time > timeout:
|
|
49
|
+
raise SSEStreamError(f"Scan telemetry stream timed out after {timeout} seconds.")
|
|
50
|
+
|
|
51
|
+
chunk = raw_chunk.decode("utf-8", errors="replace")
|
|
52
|
+
buffer += chunk
|
|
53
|
+
|
|
54
|
+
while "\n\n" in buffer:
|
|
55
|
+
message_block, buffer = buffer.split("\n\n", 1)
|
|
56
|
+
lines = message_block.split("\n")
|
|
57
|
+
|
|
58
|
+
event_data = None
|
|
59
|
+
for line in lines:
|
|
60
|
+
line = line.strip()
|
|
61
|
+
if line.startswith("data:"):
|
|
62
|
+
raw_data = line[5:].strip()
|
|
63
|
+
try:
|
|
64
|
+
event_data = json.loads(raw_data)
|
|
65
|
+
except Exception:
|
|
66
|
+
event_data = {"message": raw_data}
|
|
67
|
+
|
|
68
|
+
if event_data:
|
|
69
|
+
last_event = event_data
|
|
70
|
+
if on_event:
|
|
71
|
+
on_event(event_data)
|
|
72
|
+
|
|
73
|
+
stage = event_data.get("stage", "")
|
|
74
|
+
if stage in ("completed", "failed", "cancelled"):
|
|
75
|
+
return last_event
|
|
76
|
+
|
|
77
|
+
except (urllib.error.URLError, OSError) as e:
|
|
78
|
+
# If SSE stream drops, return last observed state
|
|
79
|
+
return last_event
|
|
80
|
+
|
|
81
|
+
return last_event
|