threatcluster-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.
- tc_cli/__init__.py +2 -0
- tc_cli/client.py +225 -0
- tc_cli/commands/__init__.py +0 -0
- tc_cli/commands/auth.py +160 -0
- tc_cli/commands/cluster.py +48 -0
- tc_cli/commands/darkweb.py +67 -0
- tc_cli/commands/entities.py +41 -0
- tc_cli/commands/feeds.py +23 -0
- tc_cli/commands/iocs.py +41 -0
- tc_cli/commands/search.py +61 -0
- tc_cli/commands/threats.py +75 -0
- tc_cli/commands/vulns.py +46 -0
- tc_cli/context.py +16 -0
- tc_cli/credentials.py +102 -0
- tc_cli/main.py +80 -0
- tc_cli/output.py +9 -0
- tc_cli/stdin_helper.py +34 -0
- tc_cli/watch.py +51 -0
- threatcluster_cli-0.1.0.dist-info/METADATA +165 -0
- threatcluster_cli-0.1.0.dist-info/RECORD +22 -0
- threatcluster_cli-0.1.0.dist-info/WHEEL +4 -0
- threatcluster_cli-0.1.0.dist-info/entry_points.txt +2 -0
tc_cli/__init__.py
ADDED
tc_cli/client.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""HTTP client for threatcluster-cli (the `tc` command).
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
- Resolve API base URL; refuse plaintext to non-loopback hosts.
|
|
5
|
+
- Mint a bearer JWT from the refresh credential and cache it in-process
|
|
6
|
+
until ~30s before expiry.
|
|
7
|
+
- Send Authorization: Bearer on data calls.
|
|
8
|
+
- Strip auth headers from any logged request.
|
|
9
|
+
- Reject the --api-key flag (callers must use env / keyring / file).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from . import credentials
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("tc_cli")
|
|
27
|
+
|
|
28
|
+
DEFAULT_BASE = "https://api.threatcluster.io"
|
|
29
|
+
|
|
30
|
+
# Headers we never want to see in logs.
|
|
31
|
+
_REDACT_HEADERS = {"authorization", "x-api-key"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CLIError(Exception):
|
|
35
|
+
"""Surface a clean error message to the user without a traceback."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_loopback(host: str) -> bool:
|
|
39
|
+
return host in {"localhost", "127.0.0.1", "::1"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_base() -> str:
|
|
43
|
+
base = os.environ.get("TC_API_URL", DEFAULT_BASE).rstrip("/")
|
|
44
|
+
parsed = urlparse(base)
|
|
45
|
+
if parsed.scheme == "http" and not _is_loopback(parsed.hostname or ""):
|
|
46
|
+
raise CLIError(
|
|
47
|
+
f"refusing plaintext HTTP to non-loopback host: {base!r}. "
|
|
48
|
+
"Use https:// or set TC_API_URL=http://127.0.0.1:..."
|
|
49
|
+
)
|
|
50
|
+
if parsed.scheme not in {"http", "https"}:
|
|
51
|
+
raise CLIError(f"TC_API_URL must be http(s): {base!r}")
|
|
52
|
+
return base
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _redact_request_for_log(request: httpx.Request) -> dict:
|
|
56
|
+
headers = {
|
|
57
|
+
k: ("<redacted>" if k.lower() in _REDACT_HEADERS else v)
|
|
58
|
+
for k, v in request.headers.items()
|
|
59
|
+
}
|
|
60
|
+
return {"method": request.method, "url": str(request.url), "headers": headers}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _decode_bearer_exp(token: str) -> int:
|
|
64
|
+
"""Pull `exp` out of a JWT without verifying signature. Used only for
|
|
65
|
+
cache TTL — the server is the only authority on validity."""
|
|
66
|
+
try:
|
|
67
|
+
payload_b64 = token.split(".")[1]
|
|
68
|
+
# JWT base64url, no padding
|
|
69
|
+
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
|
|
70
|
+
payload = json.loads(base64.urlsafe_b64decode(padded))
|
|
71
|
+
return int(payload.get("exp", 0))
|
|
72
|
+
except Exception:
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TCClient:
|
|
77
|
+
"""Synchronous httpx wrapper. One per process."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, base: Optional[str] = None) -> None:
|
|
80
|
+
self.base = base or _resolve_base()
|
|
81
|
+
self._refresh: Optional[str] = None
|
|
82
|
+
self._bearer: Optional[str] = None
|
|
83
|
+
self._bearer_exp: int = 0
|
|
84
|
+
# Strict TLS: system trust store. No pinning (plan §6).
|
|
85
|
+
self._http = httpx.Client(timeout=30.0, follow_redirects=False)
|
|
86
|
+
|
|
87
|
+
# -- credential plumbing -------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def _get_refresh(self) -> str:
|
|
90
|
+
if self._refresh:
|
|
91
|
+
return self._refresh
|
|
92
|
+
token = credentials.load()
|
|
93
|
+
if not token:
|
|
94
|
+
raise CLIError(
|
|
95
|
+
"no refresh credential — run `tc auth login` first."
|
|
96
|
+
)
|
|
97
|
+
self._refresh = token
|
|
98
|
+
return token
|
|
99
|
+
|
|
100
|
+
def set_refresh(self, token: str) -> None:
|
|
101
|
+
self._refresh = token
|
|
102
|
+
self._bearer = None
|
|
103
|
+
self._bearer_exp = 0
|
|
104
|
+
|
|
105
|
+
# -- bearer mint ---------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _mint_bearer(self) -> str:
|
|
108
|
+
refresh = self._get_refresh()
|
|
109
|
+
body: dict[str, Any] = {}
|
|
110
|
+
scope_override = os.environ.get("TC_SCOPES")
|
|
111
|
+
if scope_override:
|
|
112
|
+
body["scopes"] = [s.strip() for s in scope_override.split(",") if s.strip()]
|
|
113
|
+
if os.environ.get("TC_SESSION_ID"):
|
|
114
|
+
body["session_id"] = os.environ["TC_SESSION_ID"]
|
|
115
|
+
if os.environ.get("TC_MAX_REQUESTS"):
|
|
116
|
+
try:
|
|
117
|
+
body["max_requests"] = int(os.environ["TC_MAX_REQUESTS"])
|
|
118
|
+
except ValueError:
|
|
119
|
+
raise CLIError("TC_MAX_REQUESTS must be an integer")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
resp = self._http.post(
|
|
123
|
+
f"{self.base}/api/auth/agent/token",
|
|
124
|
+
headers={"X-API-Key": refresh, "Content-Type": "application/json"},
|
|
125
|
+
content=json.dumps(body),
|
|
126
|
+
)
|
|
127
|
+
except httpx.ConnectError as e:
|
|
128
|
+
raise CLIError(
|
|
129
|
+
f"could not reach {self.base}: {e}. "
|
|
130
|
+
"Set TC_API_URL to your backend (e.g. http://localhost:8000)."
|
|
131
|
+
)
|
|
132
|
+
except httpx.TimeoutException as e:
|
|
133
|
+
raise CLIError(f"timed out contacting {self.base}: {e}")
|
|
134
|
+
except httpx.RequestError as e:
|
|
135
|
+
raise CLIError(f"network error contacting {self.base}: {e}")
|
|
136
|
+
if resp.status_code != 200:
|
|
137
|
+
raise CLIError(self._format_error(resp))
|
|
138
|
+
token = resp.json()["access_token"]
|
|
139
|
+
self._bearer = token
|
|
140
|
+
self._bearer_exp = _decode_bearer_exp(token)
|
|
141
|
+
return token
|
|
142
|
+
|
|
143
|
+
def _bearer_is_fresh(self) -> bool:
|
|
144
|
+
if not self._bearer:
|
|
145
|
+
return False
|
|
146
|
+
# Refresh ~30s early so we don't race the server clock.
|
|
147
|
+
return self._bearer_exp - int(time.time()) > 30
|
|
148
|
+
|
|
149
|
+
def _bearer_token(self) -> str:
|
|
150
|
+
if self._bearer_is_fresh():
|
|
151
|
+
return self._bearer # type: ignore[return-value]
|
|
152
|
+
return self._mint_bearer()
|
|
153
|
+
|
|
154
|
+
# -- error formatting ----------------------------------------------------
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _format_error(resp: httpx.Response) -> str:
|
|
158
|
+
try:
|
|
159
|
+
j = resp.json()
|
|
160
|
+
detail = j.get("detail", j)
|
|
161
|
+
if isinstance(detail, dict):
|
|
162
|
+
msg = detail.get("message") or detail.get("error") or str(detail)
|
|
163
|
+
return f"HTTP {resp.status_code}: {msg}"
|
|
164
|
+
return f"HTTP {resp.status_code}: {detail}"
|
|
165
|
+
except Exception:
|
|
166
|
+
return f"HTTP {resp.status_code}: {resp.text[:200]}"
|
|
167
|
+
|
|
168
|
+
# -- request -------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def request(
|
|
171
|
+
self,
|
|
172
|
+
method: str,
|
|
173
|
+
path: str,
|
|
174
|
+
*,
|
|
175
|
+
params: Optional[dict] = None,
|
|
176
|
+
json_body: Optional[dict] = None,
|
|
177
|
+
auth: bool = True,
|
|
178
|
+
) -> Any:
|
|
179
|
+
url = f"{self.base}{path}"
|
|
180
|
+
headers = {"Accept": "application/json"}
|
|
181
|
+
if auth:
|
|
182
|
+
headers["Authorization"] = f"Bearer {self._bearer_token()}"
|
|
183
|
+
if json_body is not None:
|
|
184
|
+
headers["Content-Type"] = "application/json"
|
|
185
|
+
|
|
186
|
+
if os.environ.get("TC_DEBUG") == "1":
|
|
187
|
+
req = self._http.build_request(method, url, params=params, headers=headers,
|
|
188
|
+
content=json.dumps(json_body) if json_body is not None else None)
|
|
189
|
+
print(json.dumps(_redact_request_for_log(req)), file=sys.stderr)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
resp = self._http.request(
|
|
193
|
+
method, url,
|
|
194
|
+
params=params, headers=headers,
|
|
195
|
+
content=json.dumps(json_body) if json_body is not None else None,
|
|
196
|
+
)
|
|
197
|
+
# Bearer expired between cache refresh and request — mint once and retry.
|
|
198
|
+
if resp.status_code == 401 and auth:
|
|
199
|
+
self._bearer = None
|
|
200
|
+
self._bearer_exp = 0
|
|
201
|
+
headers["Authorization"] = f"Bearer {self._bearer_token()}"
|
|
202
|
+
resp = self._http.request(
|
|
203
|
+
method, url,
|
|
204
|
+
params=params, headers=headers,
|
|
205
|
+
content=json.dumps(json_body) if json_body is not None else None,
|
|
206
|
+
)
|
|
207
|
+
except httpx.ConnectError as e:
|
|
208
|
+
raise CLIError(
|
|
209
|
+
f"could not reach {self.base}: {e}. "
|
|
210
|
+
"Set TC_API_URL to your backend (e.g. http://localhost:8000)."
|
|
211
|
+
)
|
|
212
|
+
except httpx.TimeoutException as e:
|
|
213
|
+
raise CLIError(f"timed out contacting {self.base}: {e}")
|
|
214
|
+
except httpx.RequestError as e:
|
|
215
|
+
raise CLIError(f"network error contacting {self.base}: {e}")
|
|
216
|
+
|
|
217
|
+
if resp.status_code >= 400:
|
|
218
|
+
raise CLIError(self._format_error(resp))
|
|
219
|
+
if resp.headers.get("content-type", "").startswith("application/json"):
|
|
220
|
+
return resp.json()
|
|
221
|
+
return resp.text
|
|
222
|
+
|
|
223
|
+
def get(self, path: str, **kw): return self.request("GET", path, **kw)
|
|
224
|
+
def post(self, path: str, **kw): return self.request("POST", path, **kw)
|
|
225
|
+
def delete(self, path: str, **kw): return self.request("DELETE", path, **kw)
|
|
File without changes
|
tc_cli/commands/auth.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""tc auth — login, status, logout.
|
|
2
|
+
|
|
3
|
+
v1 login flow is paste-token: the user mints an agent key in the web UI
|
|
4
|
+
(Settings -> API keys -> "Mint agent key"), copies it once, and pastes it
|
|
5
|
+
here. The CLI stores it in keyring (or a 0600 file), validates it by
|
|
6
|
+
minting one bearer, and is ready to use.
|
|
7
|
+
|
|
8
|
+
We deliberately avoid implementing the Auth0 device-code flow until the
|
|
9
|
+
server-side endpoints for it are wired and tested — see plan §2.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from .. import credentials
|
|
19
|
+
from ..client import CLIError, TCClient
|
|
20
|
+
from ..output import emit
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("login")
|
|
26
|
+
def login(
|
|
27
|
+
paste: bool = typer.Option(
|
|
28
|
+
True,
|
|
29
|
+
"--paste/--no-paste",
|
|
30
|
+
help="Paste a refresh credential minted from the web UI.",
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Store a refresh credential for future commands."""
|
|
34
|
+
if not paste:
|
|
35
|
+
raise typer.BadParameter("--no-paste login is not implemented in v1.")
|
|
36
|
+
|
|
37
|
+
typer.echo(
|
|
38
|
+
"1. Visit https://threatcluster.io/settings?tab=cli\n"
|
|
39
|
+
"2. Click \"Mint key\", choose scopes, copy the key.\n"
|
|
40
|
+
"3. Paste it below (input hidden).",
|
|
41
|
+
err=True,
|
|
42
|
+
)
|
|
43
|
+
token = typer.prompt("refresh token", hide_input=True, err=True)
|
|
44
|
+
token = token.strip()
|
|
45
|
+
if not (token.startswith("tc_agent_") or token.startswith("tc_live_")):
|
|
46
|
+
typer.secho(
|
|
47
|
+
"rejected: token must start with 'tc_agent_' or 'tc_live_'.",
|
|
48
|
+
fg=typer.colors.RED, err=True,
|
|
49
|
+
)
|
|
50
|
+
raise typer.Exit(2)
|
|
51
|
+
|
|
52
|
+
# Validate by minting one bearer.
|
|
53
|
+
client = TCClient()
|
|
54
|
+
client.set_refresh(token)
|
|
55
|
+
try:
|
|
56
|
+
client._mint_bearer() # raises CLIError on failure
|
|
57
|
+
except CLIError as e:
|
|
58
|
+
typer.secho(f"validation failed: {e}", fg=typer.colors.RED, err=True)
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
|
|
61
|
+
where = credentials.save(token)
|
|
62
|
+
typer.secho(f"saved to {where}.", fg=typer.colors.GREEN, err=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _decode_bearer_claims(bearer: str) -> dict:
|
|
66
|
+
import base64, json as _json
|
|
67
|
+
payload_b64 = bearer.split(".")[1]
|
|
68
|
+
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
|
|
69
|
+
return _json.loads(base64.urlsafe_b64decode(padded))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("status")
|
|
73
|
+
def status(
|
|
74
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Include storage backend details and bearer cache state."),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Show current credential status (no secret values printed)."""
|
|
77
|
+
try:
|
|
78
|
+
token = credentials.load()
|
|
79
|
+
except credentials.CredentialError as e:
|
|
80
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
81
|
+
raise typer.Exit(2)
|
|
82
|
+
|
|
83
|
+
if not token:
|
|
84
|
+
emit({"authenticated": False})
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
client = TCClient()
|
|
88
|
+
client.set_refresh(token)
|
|
89
|
+
bearer = client._mint_bearer()
|
|
90
|
+
claims = _decode_bearer_claims(bearer)
|
|
91
|
+
|
|
92
|
+
out = {
|
|
93
|
+
"authenticated": True,
|
|
94
|
+
"storage": "env" if os.environ.get(credentials.ENV_VAR) else "keyring_or_file",
|
|
95
|
+
"token_prefix": token[:12] + "…",
|
|
96
|
+
"key_id": claims.get("kid"),
|
|
97
|
+
"scopes": claims.get("scopes", []),
|
|
98
|
+
"org_id": claims.get("org_id"),
|
|
99
|
+
"org_name": claims.get("org_name"),
|
|
100
|
+
"bearer_expires_in": claims.get("exp", 0) - claims.get("iat", 0),
|
|
101
|
+
"api_url": client.base,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if verbose:
|
|
105
|
+
# Storage detail — best-effort; some keyring backends expose name.
|
|
106
|
+
backend = "env" if os.environ.get(credentials.ENV_VAR) else None
|
|
107
|
+
if backend is None:
|
|
108
|
+
kr = credentials._keyring()
|
|
109
|
+
if kr is not None:
|
|
110
|
+
try:
|
|
111
|
+
backend = f"keyring:{type(kr.get_keyring()).__name__}"
|
|
112
|
+
except Exception:
|
|
113
|
+
backend = "keyring:unknown"
|
|
114
|
+
else:
|
|
115
|
+
backend = f"file:{credentials.CRED_FILE}"
|
|
116
|
+
out["storage_detail"] = backend
|
|
117
|
+
out["bearer_jti"] = claims.get("jti")
|
|
118
|
+
out["bearer_session_id"] = claims.get("session_id")
|
|
119
|
+
out["bearer_max_requests"] = claims.get("max_requests")
|
|
120
|
+
|
|
121
|
+
emit(out)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("logout")
|
|
125
|
+
def logout(
|
|
126
|
+
keep_remote: bool = typer.Option(
|
|
127
|
+
False, "--keep-remote",
|
|
128
|
+
help="Clear local credential only; leave the key valid server-side.",
|
|
129
|
+
),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Revoke the credential server-side and clear local storage.
|
|
132
|
+
|
|
133
|
+
Default: hard logout — the credential dies everywhere. Pass --keep-remote
|
|
134
|
+
if you only want to clear this machine (e.g. you're moving the key).
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
token = credentials.load()
|
|
138
|
+
except credentials.CredentialError:
|
|
139
|
+
token = None
|
|
140
|
+
|
|
141
|
+
revoked = None
|
|
142
|
+
if token and not keep_remote:
|
|
143
|
+
client = TCClient()
|
|
144
|
+
client.set_refresh(token)
|
|
145
|
+
try:
|
|
146
|
+
client.post("/api/auth/agent/self-revoke")
|
|
147
|
+
revoked = True
|
|
148
|
+
except CLIError as e:
|
|
149
|
+
typer.secho(f"server-side revoke failed: {e}", fg=typer.colors.YELLOW, err=True)
|
|
150
|
+
revoked = False
|
|
151
|
+
|
|
152
|
+
credentials.clear()
|
|
153
|
+
msg = "local credential cleared"
|
|
154
|
+
if revoked is True:
|
|
155
|
+
msg += "; server-side revoked"
|
|
156
|
+
elif revoked is False:
|
|
157
|
+
msg += "; server-side revoke FAILED — revoke at https://threatcluster.io/settings?tab=cli"
|
|
158
|
+
elif keep_remote:
|
|
159
|
+
msg += " (--keep-remote: server-side credential still valid)"
|
|
160
|
+
typer.secho(msg, fg=typer.colors.GREEN if revoked is not False else typer.colors.YELLOW, err=True)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""tc cluster — analyst-pivot helpers.
|
|
2
|
+
|
|
3
|
+
Today: `tc cluster open <id>` prints (and optionally opens) the web URL.
|
|
4
|
+
Future: `tc cluster timeline`, `tc cluster compare`, etc.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from ..stdin_helper import expand_id
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _web_base() -> str:
|
|
20
|
+
api = os.environ.get("TC_API_URL", "https://api.threatcluster.io").rstrip("/")
|
|
21
|
+
# Convention: web frontend is the same origin minus an `api.` subdomain.
|
|
22
|
+
if "://api." in api:
|
|
23
|
+
return api.replace("://api.", "://", 1)
|
|
24
|
+
return api # local dev (http://localhost:8000) serves both
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("open")
|
|
28
|
+
def cluster_open(
|
|
29
|
+
identifier: str,
|
|
30
|
+
no_browser: bool = typer.Option(False, "--no-browser", help="Print URL only, don't open."),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Print (and open) the web URL for a cluster. Pass `-` for stdin ids."""
|
|
33
|
+
base = _web_base()
|
|
34
|
+
for ident in expand_id(identifier):
|
|
35
|
+
url = f"{base}/threat/{ident}"
|
|
36
|
+
print(url)
|
|
37
|
+
if no_browser:
|
|
38
|
+
continue
|
|
39
|
+
# Best-effort opener; silent if not available (CI, headless).
|
|
40
|
+
for opener in ("xdg-open", "open"):
|
|
41
|
+
try:
|
|
42
|
+
subprocess.Popen(
|
|
43
|
+
[opener, url],
|
|
44
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
45
|
+
)
|
|
46
|
+
break
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
continue
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""tc darkweb — ransomware, breaches, keyword-hits."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..context import client as _client
|
|
9
|
+
from ..output import emit
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
12
|
+
|
|
13
|
+
ransomware = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
14
|
+
app.add_typer(ransomware, name="ransomware")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@ransomware.command("victims")
|
|
18
|
+
def ransomware_victims(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
group: Optional[str] = typer.Option(None, "--group", help="Filter by ransomware group (e.g. lockbit, akira)."),
|
|
21
|
+
days: int = typer.Option(30, "--days", min=1, max=90),
|
|
22
|
+
limit: int = typer.Option(50, "--limit", "-n", min=1, max=500),
|
|
23
|
+
watch: bool = typer.Option(False, "--watch", help="Poll forever and emit new victims as NDJSON."),
|
|
24
|
+
interval: float = typer.Option(60.0, "--interval"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List ransomware victims. Use --watch for a live feed."""
|
|
27
|
+
params: dict = {"limit": limit, "days": days}
|
|
28
|
+
if group: params["group"] = group
|
|
29
|
+
c = _client(ctx)
|
|
30
|
+
if watch:
|
|
31
|
+
from ..watch import watch_loop
|
|
32
|
+
watch_loop(
|
|
33
|
+
fetch=lambda: c.get("/api/public/v1/darkweb/ransomware/victims", params=params),
|
|
34
|
+
extract=lambda r: r.get("victims") or [],
|
|
35
|
+
dedupe_key="id",
|
|
36
|
+
interval=interval,
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
emit(c.get("/api/public/v1/darkweb/ransomware/victims", params=params))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("breaches")
|
|
43
|
+
def breaches(
|
|
44
|
+
ctx: typer.Context,
|
|
45
|
+
since: Optional[str] = typer.Option(None, "--since"),
|
|
46
|
+
watch: bool = typer.Option(False, "--watch", help="Poll forever and emit new breaches as NDJSON."),
|
|
47
|
+
interval: float = typer.Option(60.0, "--interval"),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""List dark-web breaches. Use --watch for a live feed."""
|
|
50
|
+
params: dict = {}
|
|
51
|
+
if since: params["since"] = since
|
|
52
|
+
c = _client(ctx)
|
|
53
|
+
if watch:
|
|
54
|
+
from ..watch import watch_loop
|
|
55
|
+
watch_loop(
|
|
56
|
+
fetch=lambda: c.get("/api/public/v1/darkweb/breaches", params=params),
|
|
57
|
+
extract=lambda r: r.get("breaches") or [],
|
|
58
|
+
dedupe_key="id",
|
|
59
|
+
interval=interval,
|
|
60
|
+
)
|
|
61
|
+
return
|
|
62
|
+
emit(c.get("/api/public/v1/darkweb/breaches", params=params))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command("keyword-hits")
|
|
66
|
+
def keyword_hits(ctx: typer.Context) -> None:
|
|
67
|
+
emit(_client(ctx).get("/api/public/v1/darkweb/keyword-hits"))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""tc entities — search, get, related, trending."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..context import client as _client
|
|
9
|
+
from ..output import emit
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("search")
|
|
15
|
+
def entities_search(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
query: str,
|
|
18
|
+
type: Optional[str] = typer.Option(None, "--type"),
|
|
19
|
+
) -> None:
|
|
20
|
+
params = {"q": query}
|
|
21
|
+
if type: params["type"] = type
|
|
22
|
+
emit(_client(ctx).get("/api/public/v1/entities/search", params=params))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("get")
|
|
26
|
+
def entities_get(ctx: typer.Context, type: str, value: str) -> None:
|
|
27
|
+
emit(_client(ctx).get(f"/api/public/v1/entities/{type}/{value}"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("related")
|
|
31
|
+
def entities_related(ctx: typer.Context, type: str, value: str) -> None:
|
|
32
|
+
emit(_client(ctx).get(f"/api/public/v1/entities/{type}/{value}/related"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("trending")
|
|
36
|
+
def entities_trending(
|
|
37
|
+
ctx: typer.Context,
|
|
38
|
+
window: str = typer.Option("24h", "--window"),
|
|
39
|
+
) -> None:
|
|
40
|
+
emit(_client(ctx).get("/api/public/v1/entities/trending",
|
|
41
|
+
params={"window": window}))
|
tc_cli/commands/feeds.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""tc feeds — list, get."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from ..context import client as _client
|
|
7
|
+
from ..output import emit
|
|
8
|
+
from ..stdin_helper import expand_id
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("list")
|
|
14
|
+
def feeds_list(ctx: typer.Context) -> None:
|
|
15
|
+
emit(_client(ctx).get("/api/public/v1/feeds"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("get")
|
|
19
|
+
def feeds_get(ctx: typer.Context, feed_id: str) -> None:
|
|
20
|
+
"""Get a feed's entities. Pass `-` to read feed ids from stdin."""
|
|
21
|
+
c = _client(ctx)
|
|
22
|
+
for fid in expand_id(feed_id):
|
|
23
|
+
emit(c.get(f"/api/public/v1/feeds/{fid}/entities"))
|
tc_cli/commands/iocs.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""tc iocs — feed, export."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..context import client as _client
|
|
9
|
+
from ..output import emit
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("feed")
|
|
15
|
+
def ioc_feed(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
type: Optional[str] = typer.Option(None, "--type", help="ip, domain, sha256, url, ..."),
|
|
18
|
+
since: Optional[str] = typer.Option(None, "--since"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Stream IOC feed. Server returns one IOC per line as text/plain."""
|
|
21
|
+
params: dict = {}
|
|
22
|
+
if type: params["type"] = type
|
|
23
|
+
if since: params["since"] = since
|
|
24
|
+
result = _client(ctx).get("/api/public/v1/iocs/feed", params=params)
|
|
25
|
+
# Endpoint returns text/plain (one IOC per line). Print raw, not JSON.
|
|
26
|
+
if isinstance(result, str):
|
|
27
|
+
import sys; sys.stdout.write(result if result.endswith("\n") else result + "\n")
|
|
28
|
+
else:
|
|
29
|
+
emit(result)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("export")
|
|
33
|
+
def ioc_export(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
fmt: str = typer.Option("json", "--format", help="json, csv, stix"),
|
|
36
|
+
) -> None:
|
|
37
|
+
result = _client(ctx).get("/api/public/v1/iocs/export", params={"format": fmt})
|
|
38
|
+
if isinstance(result, str):
|
|
39
|
+
import sys; sys.stdout.write(result if result.endswith("\n") else result + "\n")
|
|
40
|
+
else:
|
|
41
|
+
emit(result)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""tc search — smart router across entities + threats.
|
|
2
|
+
|
|
3
|
+
Single command (no sub-commands) so flags like --limit parse cleanly.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ..context import client as _client
|
|
12
|
+
from ..output import emit
|
|
13
|
+
|
|
14
|
+
# A bare Typer with one default callback is the cleanest "command with args"
|
|
15
|
+
# shape that still composes via add_typer in main.py.
|
|
16
|
+
app = typer.Typer(no_args_is_help=False, add_completion=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def search(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
query: str = typer.Argument(..., help="Search term."),
|
|
22
|
+
limit: int = typer.Option(10, "--limit", "-n", min=1, max=100),
|
|
23
|
+
only: Optional[str] = typer.Option(
|
|
24
|
+
None, "--only",
|
|
25
|
+
help="Restrict to one source: entities | threats",
|
|
26
|
+
),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Search threats and entities, merged. Use `--only entities|threats` to narrow."""
|
|
29
|
+
c = _client(ctx)
|
|
30
|
+
out: list = []
|
|
31
|
+
|
|
32
|
+
if only in (None, "entities"):
|
|
33
|
+
try:
|
|
34
|
+
r = c.get("/api/public/v1/entities/search", params={"q": query})
|
|
35
|
+
for e in (r.get("entities") or r.get("results") or []):
|
|
36
|
+
out.append({
|
|
37
|
+
"kind": "entity",
|
|
38
|
+
"type": e.get("entity_type") or e.get("type") or e.get("category"),
|
|
39
|
+
"value": e.get("entity_value") or e.get("value") or e.get("name"),
|
|
40
|
+
"score": e.get("score") or e.get("relevance") or e.get("article_count"),
|
|
41
|
+
"raw": e,
|
|
42
|
+
})
|
|
43
|
+
except Exception as e:
|
|
44
|
+
out.append({"kind": "error", "source": "entities", "message": str(e)})
|
|
45
|
+
|
|
46
|
+
if only in (None, "threats"):
|
|
47
|
+
try:
|
|
48
|
+
r = c.get("/api/public/v1/threats", params={"keyword": query, "limit": limit})
|
|
49
|
+
for t in (r.get("threats") or []):
|
|
50
|
+
out.append({
|
|
51
|
+
"kind": "threat",
|
|
52
|
+
"id": t.get("cluster_id"),
|
|
53
|
+
"title": t.get("ai_title") or t.get("title"),
|
|
54
|
+
"score": t.get("threat_score"),
|
|
55
|
+
"summary": (t.get("ai_summary") or "")[:200],
|
|
56
|
+
})
|
|
57
|
+
except Exception as e:
|
|
58
|
+
out.append({"kind": "error", "source": "threats", "message": str(e)})
|
|
59
|
+
|
|
60
|
+
out = out[:limit]
|
|
61
|
+
emit({"query": query, "count": len(out), "results": out})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""tc threats — list, get, iocs, stix."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..context import client as _client
|
|
9
|
+
from ..output import emit
|
|
10
|
+
from ..stdin_helper import expand_id
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def list_threats(
|
|
17
|
+
ctx: typer.Context,
|
|
18
|
+
query: Optional[str] = typer.Option(None, "--query", "-q"),
|
|
19
|
+
since: Optional[str] = typer.Option(None, "--since", help="ISO date or relative (e.g. 24h)"),
|
|
20
|
+
limit: int = typer.Option(20, "--limit", "-n", min=1, max=200),
|
|
21
|
+
watch: bool = typer.Option(False, "--watch", help="Poll forever and emit new clusters as NDJSON."),
|
|
22
|
+
interval: float = typer.Option(60.0, "--interval", help="Watch poll interval (seconds)."),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List threat clusters."""
|
|
25
|
+
params: dict = {"limit": limit}
|
|
26
|
+
if query: params["keyword"] = query
|
|
27
|
+
if since: params["since"] = since
|
|
28
|
+
c = _client(ctx)
|
|
29
|
+
if watch:
|
|
30
|
+
from ..watch import watch_loop
|
|
31
|
+
watch_loop(
|
|
32
|
+
fetch=lambda: c.get("/api/public/v1/threats", params=params),
|
|
33
|
+
extract=lambda r: r.get("threats") or [],
|
|
34
|
+
dedupe_key="cluster_id",
|
|
35
|
+
interval=interval,
|
|
36
|
+
)
|
|
37
|
+
return
|
|
38
|
+
emit(c.get("/api/public/v1/threats", params=params))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("get")
|
|
42
|
+
def get_threat(ctx: typer.Context, identifier: str) -> None:
|
|
43
|
+
"""Get one threat cluster by id or short_id. Pass `-` to read ids from stdin."""
|
|
44
|
+
c = _client(ctx)
|
|
45
|
+
for ident in expand_id(identifier):
|
|
46
|
+
emit(c.get(f"/api/public/v1/threats/{ident}"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("iocs")
|
|
50
|
+
def threat_iocs(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
identifier: str,
|
|
53
|
+
types: str = typer.Option("all", "--types"),
|
|
54
|
+
fmt: str = typer.Option("json", "--format"),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""List IOCs for a threat cluster. Pass `-` to read ids from stdin."""
|
|
57
|
+
c = _client(ctx)
|
|
58
|
+
for ident in expand_id(identifier):
|
|
59
|
+
emit(c.get(
|
|
60
|
+
f"/api/public/v1/threats/{ident}/iocs",
|
|
61
|
+
params={"types": types, "format": fmt},
|
|
62
|
+
))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command("stix")
|
|
66
|
+
def threat_stix(ctx: typer.Context, identifier: str) -> None:
|
|
67
|
+
"""STIX 2.1 export. Pass `-` to read ids from stdin."""
|
|
68
|
+
import sys
|
|
69
|
+
c = _client(ctx)
|
|
70
|
+
for ident in expand_id(identifier):
|
|
71
|
+
result = c.get(f"/api/public/v1/threats/{ident}/stix")
|
|
72
|
+
if isinstance(result, str):
|
|
73
|
+
sys.stdout.write(result if result.endswith("\n") else result + "\n")
|
|
74
|
+
else:
|
|
75
|
+
emit(result)
|
tc_cli/commands/vulns.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""tc vulns — list, get."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..context import client as _client
|
|
9
|
+
from ..output import emit
|
|
10
|
+
from ..stdin_helper import expand_id
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def vulns_list(
|
|
17
|
+
ctx: typer.Context,
|
|
18
|
+
severity: Optional[str] = typer.Option(None, "--severity"),
|
|
19
|
+
since: Optional[str] = typer.Option(None, "--since"),
|
|
20
|
+
limit: int = typer.Option(20, "--limit", "-n", min=1, max=200),
|
|
21
|
+
watch: bool = typer.Option(False, "--watch", help="Poll forever and emit new CVEs as NDJSON."),
|
|
22
|
+
interval: float = typer.Option(120.0, "--interval", help="CVE feed updates slowly; default 2 min."),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List CVEs. Use --watch for a live feed."""
|
|
25
|
+
params: dict = {"limit": limit}
|
|
26
|
+
if severity: params["severity"] = severity
|
|
27
|
+
if since: params["since"] = since
|
|
28
|
+
c = _client(ctx)
|
|
29
|
+
if watch:
|
|
30
|
+
from ..watch import watch_loop
|
|
31
|
+
watch_loop(
|
|
32
|
+
fetch=lambda: c.get("/api/public/v1/vulnerabilities", params=params),
|
|
33
|
+
extract=lambda r: r.get("cves") or [],
|
|
34
|
+
dedupe_key="cve_id",
|
|
35
|
+
interval=interval,
|
|
36
|
+
)
|
|
37
|
+
return
|
|
38
|
+
emit(c.get("/api/public/v1/vulnerabilities", params=params))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("get")
|
|
42
|
+
def vulns_get(ctx: typer.Context, cve_id: str) -> None:
|
|
43
|
+
"""Get one CVE. Pass `-` to read CVE ids from stdin."""
|
|
44
|
+
c = _client(ctx)
|
|
45
|
+
for cid in expand_id(cve_id):
|
|
46
|
+
emit(c.get(f"/api/public/v1/vulnerabilities/{cid}"))
|
tc_cli/context.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Helper for command modules to lazily fetch the shared TCClient.
|
|
2
|
+
|
|
3
|
+
Lives in its own module to avoid circular imports between main.py and
|
|
4
|
+
the command modules.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .client import TCClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def client(ctx: typer.Context) -> TCClient:
|
|
14
|
+
if ctx.obj.get("client") is None:
|
|
15
|
+
ctx.obj["client"] = TCClient()
|
|
16
|
+
return ctx.obj["client"]
|
tc_cli/credentials.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Refresh-credential storage.
|
|
2
|
+
|
|
3
|
+
Storage priority on read:
|
|
4
|
+
1. TC_REFRESH_TOKEN env var (intended for CI / one-shot)
|
|
5
|
+
2. OS keyring (default for interactive use)
|
|
6
|
+
3. 0600 file at ~/.config/tc-cli/credentials (fallback when keyring is
|
|
7
|
+
unavailable, e.g. headless Linux without a secret service)
|
|
8
|
+
|
|
9
|
+
Storage on write: keyring if available, otherwise 0600 file.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
KEYRING_SERVICE = "tc-cli"
|
|
19
|
+
KEYRING_USER = "default"
|
|
20
|
+
|
|
21
|
+
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "tc-cli"
|
|
22
|
+
CRED_FILE = CONFIG_DIR / "credentials"
|
|
23
|
+
ENV_VAR = "TC_REFRESH_TOKEN"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CredentialError(Exception):
|
|
27
|
+
"""Raised on storage policy violations (bad file mode, missing keyring)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _keyring():
|
|
31
|
+
try:
|
|
32
|
+
import keyring # type: ignore
|
|
33
|
+
return keyring
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load() -> Optional[str]:
|
|
39
|
+
"""Return the refresh credential, or None if absent. Raises on bad mode."""
|
|
40
|
+
val = os.environ.get(ENV_VAR)
|
|
41
|
+
if val:
|
|
42
|
+
return val
|
|
43
|
+
|
|
44
|
+
kr = _keyring()
|
|
45
|
+
if kr is not None:
|
|
46
|
+
try:
|
|
47
|
+
v = kr.get_password(KEYRING_SERVICE, KEYRING_USER)
|
|
48
|
+
if v:
|
|
49
|
+
return v
|
|
50
|
+
except Exception:
|
|
51
|
+
# keyring backend may be present but unusable; fall through to file
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
if CRED_FILE.exists():
|
|
55
|
+
st = CRED_FILE.stat()
|
|
56
|
+
if (st.st_mode & 0o777) != 0o600:
|
|
57
|
+
raise CredentialError(
|
|
58
|
+
f"{CRED_FILE} has insecure mode {oct(st.st_mode & 0o777)}; "
|
|
59
|
+
"run `chmod 0600` on it or delete and re-login."
|
|
60
|
+
)
|
|
61
|
+
if hasattr(os, "geteuid") and st.st_uid != os.geteuid():
|
|
62
|
+
raise CredentialError(
|
|
63
|
+
f"{CRED_FILE} is not owned by the current user; refusing to load."
|
|
64
|
+
)
|
|
65
|
+
return CRED_FILE.read_text().strip() or None
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save(refresh_token: str) -> str:
|
|
70
|
+
"""Persist the refresh credential. Returns 'keyring' or path of file used."""
|
|
71
|
+
kr = _keyring()
|
|
72
|
+
if kr is not None:
|
|
73
|
+
try:
|
|
74
|
+
kr.set_password(KEYRING_SERVICE, KEYRING_USER, refresh_token)
|
|
75
|
+
return "keyring"
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
# Write atomically with strict mode from the start.
|
|
81
|
+
tmp = CRED_FILE.with_suffix(".tmp")
|
|
82
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
83
|
+
try:
|
|
84
|
+
os.write(fd, refresh_token.encode())
|
|
85
|
+
finally:
|
|
86
|
+
os.close(fd)
|
|
87
|
+
os.replace(str(tmp), str(CRED_FILE))
|
|
88
|
+
return str(CRED_FILE)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def clear() -> None:
|
|
92
|
+
kr = _keyring()
|
|
93
|
+
if kr is not None:
|
|
94
|
+
try:
|
|
95
|
+
kr.delete_password(KEYRING_SERVICE, KEYRING_USER)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
if CRED_FILE.exists():
|
|
99
|
+
try:
|
|
100
|
+
CRED_FILE.unlink()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
tc_cli/main.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""tc — ThreatCluster CLI entrypoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .client import CLIError, TCClient
|
|
10
|
+
from .commands import (
|
|
11
|
+
auth as auth_cmd,
|
|
12
|
+
cluster as cluster_cmd,
|
|
13
|
+
darkweb as darkweb_cmd,
|
|
14
|
+
entities as entities_cmd,
|
|
15
|
+
feeds as feeds_cmd,
|
|
16
|
+
iocs as iocs_cmd,
|
|
17
|
+
search as search_cmd,
|
|
18
|
+
threats as threats_cmd,
|
|
19
|
+
vulns as vulns_cmd,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
add_completion=True, # exposes --install-completion / --show-completion
|
|
25
|
+
pretty_exceptions_enable=False, # we handle our own; tracebacks leak detail
|
|
26
|
+
help="Command-line client for ThreatCluster.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
app.add_typer(auth_cmd.app, name="auth", help="Authentication.")
|
|
30
|
+
app.add_typer(threats_cmd.app, name="threats", help="Threat clusters.")
|
|
31
|
+
app.add_typer(iocs_cmd.app, name="iocs", help="Indicators of Compromise.")
|
|
32
|
+
app.add_typer(entities_cmd.app, name="entities", help="Entities (actors, malware, tools).")
|
|
33
|
+
app.add_typer(vulns_cmd.app, name="vulns", help="Vulnerabilities (CVEs).")
|
|
34
|
+
app.add_typer(darkweb_cmd.app, name="darkweb", help="Dark web (ransomware, breaches, markets).")
|
|
35
|
+
app.add_typer(feeds_cmd.app, name="feeds", help="Custom intel feeds.")
|
|
36
|
+
app.add_typer(cluster_cmd.app, name="cluster", help="Analyst pivots: open cluster URLs, etc.")
|
|
37
|
+
|
|
38
|
+
# `tc search <query>` is a flat command, not a sub-group, so flags parse cleanly.
|
|
39
|
+
app.command("search", help="Smart search across threats + entities.")(search_cmd.search)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.callback()
|
|
43
|
+
def _root(
|
|
44
|
+
ctx: typer.Context,
|
|
45
|
+
api_key: str = typer.Option(
|
|
46
|
+
None,
|
|
47
|
+
"--api-key",
|
|
48
|
+
hidden=True,
|
|
49
|
+
help="REJECTED: pass refresh credential via TC_REFRESH_TOKEN env or `tc auth login`.",
|
|
50
|
+
),
|
|
51
|
+
):
|
|
52
|
+
"""Build a single TCClient and stash on ctx.obj."""
|
|
53
|
+
if api_key is not None:
|
|
54
|
+
# Refuse rather than silently use it — argv leaks via /proc/<pid>/cmdline.
|
|
55
|
+
typer.secho(
|
|
56
|
+
"--api-key is not supported (process-table leak). "
|
|
57
|
+
"Set TC_REFRESH_TOKEN or run `tc auth login`.",
|
|
58
|
+
fg=typer.colors.RED,
|
|
59
|
+
err=True,
|
|
60
|
+
)
|
|
61
|
+
raise typer.Exit(2)
|
|
62
|
+
|
|
63
|
+
# Subprocess hygiene: by default, do not propagate auth env to children.
|
|
64
|
+
if os.environ.get("TC_PROPAGATE_AUTH") != "1":
|
|
65
|
+
for k in ("TC_REFRESH_TOKEN",):
|
|
66
|
+
os.environ.pop(k + "_PROPAGATE", None)
|
|
67
|
+
|
|
68
|
+
ctx.obj = {"client": None} # client is built lazily by commands that need it
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run() -> None:
|
|
72
|
+
try:
|
|
73
|
+
app()
|
|
74
|
+
except CLIError as e:
|
|
75
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
76
|
+
raise SystemExit(1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
_run()
|
tc_cli/output.py
ADDED
tc_cli/stdin_helper.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Stdin helpers for command chaining.
|
|
2
|
+
|
|
3
|
+
Convention: any command that takes a positional `id`/`identifier` accepts the
|
|
4
|
+
literal `-` to mean "read whitespace-separated ids from stdin." Each input is
|
|
5
|
+
yielded one at a time so the command can fan out.
|
|
6
|
+
|
|
7
|
+
Used like:
|
|
8
|
+
tc threats list --json | jq -r '.threats[].cluster_id' | tc threats get -
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def expand_id(arg: str) -> Iterable[str]:
|
|
17
|
+
"""Yield ids: a single literal, or many from stdin if arg == '-'.
|
|
18
|
+
|
|
19
|
+
For multi-id stdin we strip JSON quoting and ignore blanks/comments.
|
|
20
|
+
"""
|
|
21
|
+
if arg != "-":
|
|
22
|
+
yield arg
|
|
23
|
+
return
|
|
24
|
+
if sys.stdin.isatty():
|
|
25
|
+
# Friendlier than waiting silently for input.
|
|
26
|
+
raise SystemExit(
|
|
27
|
+
"error: '-' was given but stdin is a TTY. "
|
|
28
|
+
"Pipe ids in, e.g. `echo cluster-uuid | tc threats get -`."
|
|
29
|
+
)
|
|
30
|
+
for raw in sys.stdin.read().splitlines():
|
|
31
|
+
line = raw.strip().strip('",[] ')
|
|
32
|
+
if not line or line.startswith("#"):
|
|
33
|
+
continue
|
|
34
|
+
yield line
|
tc_cli/watch.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""--watch helper: re-poll a list endpoint and emit only new items.
|
|
2
|
+
|
|
3
|
+
Dedupe key is configurable per command (cluster_id, cve_id, victim_id, ...).
|
|
4
|
+
On Ctrl+C, exits cleanly.
|
|
5
|
+
|
|
6
|
+
Polling cadence default is 60s — high enough to be polite to the server,
|
|
7
|
+
low enough that an analyst keeping a tab open feels live.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Callable, Iterable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def watch_loop(
|
|
18
|
+
*,
|
|
19
|
+
fetch: Callable[[], Any], # returns parsed response
|
|
20
|
+
extract: Callable[[Any], Iterable[dict]], # returns the items list
|
|
21
|
+
dedupe_key: str, # key on each item
|
|
22
|
+
interval: float = 60.0,
|
|
23
|
+
) -> None:
|
|
24
|
+
seen: set = set()
|
|
25
|
+
first = True
|
|
26
|
+
try:
|
|
27
|
+
while True:
|
|
28
|
+
try:
|
|
29
|
+
resp = fetch()
|
|
30
|
+
items = list(extract(resp) or [])
|
|
31
|
+
except Exception as e:
|
|
32
|
+
print(f"watch: fetch failed: {e}", file=sys.stderr)
|
|
33
|
+
time.sleep(interval)
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
new = [it for it in items if it.get(dedupe_key) not in seen]
|
|
37
|
+
for it in items:
|
|
38
|
+
key = it.get(dedupe_key)
|
|
39
|
+
if key is not None:
|
|
40
|
+
seen.add(key)
|
|
41
|
+
|
|
42
|
+
# First tick: emit nothing (just seed). Subsequent: emit new items only.
|
|
43
|
+
if not first:
|
|
44
|
+
for it in new:
|
|
45
|
+
json.dump(it, sys.stdout, default=str)
|
|
46
|
+
sys.stdout.write("\n")
|
|
47
|
+
sys.stdout.flush()
|
|
48
|
+
first = False
|
|
49
|
+
time.sleep(interval)
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
sys.exit(0)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: threatcluster-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for ThreatCluster (`tc`)
|
|
5
|
+
Author: ThreatCluster
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
12
|
+
Requires-Dist: keyring<26.0,>=24.0
|
|
13
|
+
Requires-Dist: pydantic<3.0,>=2.6
|
|
14
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
18
|
+
Requires-Dist: respx>=0.21; extra == 'test'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# threatcluster-cli
|
|
22
|
+
|
|
23
|
+
Command-line client for ThreatCluster. Installs the `tc` command.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Once published to PyPI:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pipx install threatcluster-cli
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Until then, install from source:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
git clone <this-repo>
|
|
37
|
+
pipx install ./tc-testing/cli
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
See `PUBLISHING.md` for the PyPI publish flow.
|
|
41
|
+
|
|
42
|
+
## Authenticate
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
tc auth login
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This runs an Auth0 device-code flow and mints a scoped `tc_agent_*` refresh
|
|
49
|
+
credential, storing it in your OS keyring (Keychain / SecretService /
|
|
50
|
+
Credential Manager). On headless systems it falls back to a 0600 file at
|
|
51
|
+
`~/.config/tc-cli/credentials`.
|
|
52
|
+
|
|
53
|
+
## Use
|
|
54
|
+
|
|
55
|
+
All commands print JSON. Pipe through `jq` for human reading.
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
tc threats list --limit 5 | jq '.threats[].title'
|
|
59
|
+
tc entities search "lazarus" --type apt_group
|
|
60
|
+
tc darkweb ransomware victims --days 7
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Cookbook
|
|
64
|
+
|
|
65
|
+
Worked examples for common analyst / agent flows.
|
|
66
|
+
|
|
67
|
+
### Smart search
|
|
68
|
+
|
|
69
|
+
`tc search` merges entity hits and threat clusters into one shape so you don't
|
|
70
|
+
have to know which sub-command applies.
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
tc search "Volt Typhoon" --limit 5
|
|
74
|
+
tc search lockbit --only entities
|
|
75
|
+
tc search cisco --only threats --limit 20
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Stdin chaining
|
|
79
|
+
|
|
80
|
+
Any command that takes an `id`/`identifier` accepts `-` to read ids from stdin.
|
|
81
|
+
Compose freely:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
# IOCs for the top 5 trending threats
|
|
85
|
+
tc threats list --limit 5 \
|
|
86
|
+
| jq -r '.threats[].cluster_id' \
|
|
87
|
+
| tc threats iocs - \
|
|
88
|
+
| jq -r '.iocs[]'
|
|
89
|
+
|
|
90
|
+
# STIX export of every threat tagged "ransomware"
|
|
91
|
+
tc threats list --query ransomware --limit 50 \
|
|
92
|
+
| jq -r '.threats[].cluster_id' \
|
|
93
|
+
| tc threats stix -
|
|
94
|
+
|
|
95
|
+
# Open every CVE in your browser (xdg-open / open)
|
|
96
|
+
tc vulns list --severity CRITICAL --limit 5 \
|
|
97
|
+
| jq -r '.cves[].cve_id' \
|
|
98
|
+
| xargs -I{} echo "https://nvd.nist.gov/vuln/detail/{}"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Live feeds (`--watch`)
|
|
102
|
+
|
|
103
|
+
Poll an endpoint and emit only new items as NDJSON. Ctrl+C to stop.
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
# New ransomware victims as they're posted
|
|
107
|
+
tc darkweb ransomware victims --watch --interval 60
|
|
108
|
+
|
|
109
|
+
# Pipe live victims into a Slack webhook
|
|
110
|
+
tc darkweb ransomware victims --watch \
|
|
111
|
+
| while read line; do
|
|
112
|
+
echo "$line" | jq -r '"new victim: \(.group): \(.name)"' \
|
|
113
|
+
| curl -X POST -d @- "$SLACK_WEBHOOK_URL"
|
|
114
|
+
done
|
|
115
|
+
|
|
116
|
+
# Watch new threats matching a keyword
|
|
117
|
+
tc threats list --query lockbit --watch --interval 30
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Per-session containment for sub-agents
|
|
121
|
+
|
|
122
|
+
Hand a child process a narrower bearer than your own. The server enforces
|
|
123
|
+
the subset — the child cannot escalate.
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
# Read-only sub-agent with a 50-request budget per session
|
|
127
|
+
TC_SCOPES=threats:read \
|
|
128
|
+
TC_SESSION_ID="$(uuidgen)" \
|
|
129
|
+
TC_MAX_REQUESTS=50 \
|
|
130
|
+
tc threats list --limit 5
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Auth diagnostics
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
tc auth status -v # storage backend, bearer jti, key id
|
|
137
|
+
tc auth logout # hard kill: revokes server-side too
|
|
138
|
+
tc auth logout --keep-remote # local-only clear (you're moving the key)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Shell completion
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
tc --install-completion bash # or zsh / fish
|
|
145
|
+
exec $SHELL # restart your shell
|
|
146
|
+
tc thr<TAB> # completes to `tc threats`
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Environment
|
|
150
|
+
|
|
151
|
+
| Var | Purpose |
|
|
152
|
+
|-----------------------|--------------------------------------------------------------|
|
|
153
|
+
| `TC_API_URL` | Override API base (default `https://api.threatcluster.io`). |
|
|
154
|
+
| `TC_REFRESH_TOKEN` | Refresh credential (overrides keyring + file). For CI only. |
|
|
155
|
+
| `TC_SCOPES` | Comma-separated subset of refresh scopes for the bearer. |
|
|
156
|
+
| `TC_PROPAGATE_AUTH` | Set to `1` to propagate auth env to subprocesses. |
|
|
157
|
+
| `TC_DEBUG` | Set to `1` for verbose stderr (auth headers redacted). |
|
|
158
|
+
|
|
159
|
+
## Security notes
|
|
160
|
+
|
|
161
|
+
- Refresh credential is never sent on argv (`--api-key` flag is rejected).
|
|
162
|
+
- Bearer JWTs (15 min ttl) are minted on demand and cached only in memory.
|
|
163
|
+
- The CLI refuses to talk to plaintext `http://` URLs except `127.0.0.1`/`localhost`.
|
|
164
|
+
- Auth env vars are scrubbed from subprocess environments unless
|
|
165
|
+
`TC_PROPAGATE_AUTH=1` is set.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
tc_cli/__init__.py,sha256=cm1f22oWXay_2lVBv7rce-GxIwLHSbpIxHY9_xnEp_c,47
|
|
2
|
+
tc_cli/client.py,sha256=hz9aWGKZEj1wuLtXRxO84xF8-0xOfZwDjip54HLvoUw,8302
|
|
3
|
+
tc_cli/context.py,sha256=XGlEKS8HHbqMzGhLX__HSDcsTVR5kJDORnIAf8KVdCM,394
|
|
4
|
+
tc_cli/credentials.py,sha256=tLOMawKnNTD33UeaTlOBHkJJQZ1_LNqxdQHy2uOvpz8,2963
|
|
5
|
+
tc_cli/main.py,sha256=zUSG_dK1dXFHdoAMoWO7dGUlux-_C1Z00tSksgerJOY,2673
|
|
6
|
+
tc_cli/output.py,sha256=7j6ATCwXLv8GxyC_qGXw2pf_5SorY5CnOnjYu9WuENw,231
|
|
7
|
+
tc_cli/stdin_helper.py,sha256=uidPp8Rdi2N-LaQG5f7x3GXk3I9Etftaqekoo0d2dis,1081
|
|
8
|
+
tc_cli/watch.py,sha256=jo6vc5K8TlrnrW1D2qMxx17JUnLvvSfluWnRCg2ypJ8,1611
|
|
9
|
+
tc_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
tc_cli/commands/auth.py,sha256=v1qrq0_60lxmx7gAOWRX3b8K8XsA8pUs8BCPr66-W4I,5384
|
|
11
|
+
tc_cli/commands/cluster.py,sha256=YftwUqEP2J3wJhg9hxWo6FL3SPfX62odlDN2NWYdMiE,1479
|
|
12
|
+
tc_cli/commands/darkweb.py,sha256=9xho-fEbroyMmsR7IfCx3x-YcEKTW80Sh_nn6toEhe0,2371
|
|
13
|
+
tc_cli/commands/entities.py,sha256=NCXOi2cQF-_aDxwIW5WUm1SIveLepkukWDMaw3KWLcA,1150
|
|
14
|
+
tc_cli/commands/feeds.py,sha256=ZbMDJqk9rSSAOCcTvIQJlZ0Y-Pdy6DZ0iFRVu1DiISw,630
|
|
15
|
+
tc_cli/commands/iocs.py,sha256=WPYQflMaDadz_5xJo1OuxxZf3G8RIY4w6q6cnAsAcRI,1317
|
|
16
|
+
tc_cli/commands/search.py,sha256=UWd8Wup9XDTQ-OnBccqwC0_oUQRqVXwvZJL_RqB5s9U,2299
|
|
17
|
+
tc_cli/commands/threats.py,sha256=kdAq_iSeYPOdUXJlhJJptNAuLQeN8DQoVx6Ov0mHv5E,2501
|
|
18
|
+
tc_cli/commands/vulns.py,sha256=-P1DVyreBuGTcXzGwzrqZeabHyyr9t1TRarnyQeZnLc,1550
|
|
19
|
+
threatcluster_cli-0.1.0.dist-info/METADATA,sha256=0MkQpXQkOzJtttJabUKW0s7Y7SNPi9eIr2qtHfcAOhM,4695
|
|
20
|
+
threatcluster_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
21
|
+
threatcluster_cli-0.1.0.dist-info/entry_points.txt,sha256=CoNnm1CriTmauH1ywlcTibsVA7k7PbrNfLhasvgNzRE,40
|
|
22
|
+
threatcluster_cli-0.1.0.dist-info/RECORD,,
|