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 ADDED
@@ -0,0 +1,2 @@
1
+ """ThreatCluster CLI."""
2
+ __version__ = "0.1.0"
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
@@ -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}))
@@ -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"))
@@ -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)
@@ -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
@@ -0,0 +1,9 @@
1
+ """JSON output. No table mode in v1 (plan §6 cuts)."""
2
+ import json
3
+ import sys
4
+ from typing import Any
5
+
6
+
7
+ def emit(data: Any) -> None:
8
+ json.dump(data, sys.stdout, default=str, indent=2, sort_keys=True)
9
+ sys.stdout.write("\n")
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tc = tc_cli.main:_run