classifyre-cli 0.4.2__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.
- classifyre_cli-0.4.2.dist-info/METADATA +167 -0
- classifyre_cli-0.4.2.dist-info/RECORD +101 -0
- classifyre_cli-0.4.2.dist-info/WHEEL +4 -0
- classifyre_cli-0.4.2.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/detectors/__init__.py +105 -0
- src/detectors/base.py +97 -0
- src/detectors/broken_links/__init__.py +3 -0
- src/detectors/broken_links/detector.py +280 -0
- src/detectors/config.py +59 -0
- src/detectors/content/__init__.py +0 -0
- src/detectors/custom/__init__.py +13 -0
- src/detectors/custom/detector.py +45 -0
- src/detectors/custom/runners/__init__.py +56 -0
- src/detectors/custom/runners/_base.py +177 -0
- src/detectors/custom/runners/_factory.py +51 -0
- src/detectors/custom/runners/_feature_extraction.py +138 -0
- src/detectors/custom/runners/_gliner2.py +324 -0
- src/detectors/custom/runners/_image_classification.py +98 -0
- src/detectors/custom/runners/_llm.py +22 -0
- src/detectors/custom/runners/_object_detection.py +107 -0
- src/detectors/custom/runners/_regex.py +147 -0
- src/detectors/custom/runners/_text_classification.py +109 -0
- src/detectors/custom/trainer.py +293 -0
- src/detectors/dependencies.py +109 -0
- src/detectors/pii/__init__.py +0 -0
- src/detectors/pii/detector.py +883 -0
- src/detectors/secrets/__init__.py +0 -0
- src/detectors/secrets/detector.py +399 -0
- src/detectors/threat/__init__.py +0 -0
- src/detectors/threat/code_security_detector.py +206 -0
- src/detectors/threat/yara_detector.py +177 -0
- src/main.py +608 -0
- src/models/generated_detectors.py +1296 -0
- src/models/generated_input.py +2732 -0
- src/models/generated_single_asset_scan_results.py +240 -0
- src/outputs/__init__.py +3 -0
- src/outputs/base.py +69 -0
- src/outputs/console.py +62 -0
- src/outputs/factory.py +156 -0
- src/outputs/file.py +83 -0
- src/outputs/rest.py +258 -0
- src/pipeline/__init__.py +7 -0
- src/pipeline/content_provider.py +26 -0
- src/pipeline/detector_pipeline.py +742 -0
- src/pipeline/parsed_content_provider.py +59 -0
- src/sandbox/__init__.py +5 -0
- src/sandbox/runner.py +145 -0
- src/sources/__init__.py +95 -0
- src/sources/atlassian_common.py +389 -0
- src/sources/azure_blob_storage/__init__.py +3 -0
- src/sources/azure_blob_storage/source.py +130 -0
- src/sources/base.py +296 -0
- src/sources/confluence/__init__.py +3 -0
- src/sources/confluence/source.py +733 -0
- src/sources/databricks/__init__.py +3 -0
- src/sources/databricks/source.py +1279 -0
- src/sources/dependencies.py +81 -0
- src/sources/google_cloud_storage/__init__.py +3 -0
- src/sources/google_cloud_storage/source.py +114 -0
- src/sources/hive/__init__.py +3 -0
- src/sources/hive/source.py +709 -0
- src/sources/jira/__init__.py +3 -0
- src/sources/jira/source.py +605 -0
- src/sources/mongodb/__init__.py +3 -0
- src/sources/mongodb/source.py +550 -0
- src/sources/mssql/__init__.py +3 -0
- src/sources/mssql/source.py +1034 -0
- src/sources/mysql/__init__.py +3 -0
- src/sources/mysql/source.py +797 -0
- src/sources/neo4j/__init__.py +0 -0
- src/sources/neo4j/source.py +523 -0
- src/sources/object_storage/base.py +679 -0
- src/sources/oracle/__init__.py +3 -0
- src/sources/oracle/source.py +982 -0
- src/sources/postgresql/__init__.py +3 -0
- src/sources/postgresql/source.py +774 -0
- src/sources/powerbi/__init__.py +3 -0
- src/sources/powerbi/source.py +774 -0
- src/sources/recipe_normalizer.py +179 -0
- src/sources/s3_compatible_storage/README.md +66 -0
- src/sources/s3_compatible_storage/__init__.py +3 -0
- src/sources/s3_compatible_storage/source.py +150 -0
- src/sources/servicedesk/__init__.py +3 -0
- src/sources/servicedesk/source.py +620 -0
- src/sources/slack/__init__.py +3 -0
- src/sources/slack/source.py +534 -0
- src/sources/snowflake/__init__.py +3 -0
- src/sources/snowflake/source.py +912 -0
- src/sources/tableau/__init__.py +3 -0
- src/sources/tableau/source.py +799 -0
- src/sources/tabular_utils.py +165 -0
- src/sources/wordpress/__init__.py +3 -0
- src/sources/wordpress/source.py +590 -0
- src/telemetry.py +96 -0
- src/utils/__init__.py +1 -0
- src/utils/content_extraction.py +108 -0
- src/utils/file_parser.py +777 -0
- src/utils/hashing.py +82 -0
- src/utils/uv_sync.py +79 -0
- src/utils/validation.py +56 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import AsyncGenerator, Iterable
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from ...models.generated_input import (
|
|
10
|
+
SamplingStrategy,
|
|
11
|
+
SlackInput,
|
|
12
|
+
SlackMaskedBotToken,
|
|
13
|
+
SlackMaskedToken,
|
|
14
|
+
SlackMaskedUserToken,
|
|
15
|
+
SlackOptionalChannels,
|
|
16
|
+
SlackOptionalIngestion,
|
|
17
|
+
SlackOptionalTimeRange,
|
|
18
|
+
)
|
|
19
|
+
from ...models.generated_single_asset_scan_results import (
|
|
20
|
+
AssetType as OutputAssetType,
|
|
21
|
+
)
|
|
22
|
+
from ...models.generated_single_asset_scan_results import (
|
|
23
|
+
DetectionResult,
|
|
24
|
+
Location,
|
|
25
|
+
SingleAssetScanResults,
|
|
26
|
+
)
|
|
27
|
+
from ...utils.hashing import hash_id, unhash_id
|
|
28
|
+
from ..base import BaseSource
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SlackSource(BaseSource):
|
|
34
|
+
source_type = "slack"
|
|
35
|
+
API_BASE = "https://slack.com/api"
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
recipe: dict[str, Any],
|
|
40
|
+
source_id: str | None = None,
|
|
41
|
+
runner_id: str | None = None,
|
|
42
|
+
):
|
|
43
|
+
super().__init__(recipe, source_id, runner_id)
|
|
44
|
+
self.config = SlackInput.model_validate(recipe)
|
|
45
|
+
required = self.config.required
|
|
46
|
+
self.workspace = (
|
|
47
|
+
self._normalize_workspace(required.workspace)
|
|
48
|
+
if required and required.workspace
|
|
49
|
+
else None
|
|
50
|
+
)
|
|
51
|
+
self.team_id: str | None = None
|
|
52
|
+
|
|
53
|
+
self.session = requests.Session()
|
|
54
|
+
self.session.headers.update({"Authorization": f"Bearer {self._get_token()}"})
|
|
55
|
+
self.rate_limit_delay = float(self._ingestion_options().rate_limit_delay_seconds or 0)
|
|
56
|
+
|
|
57
|
+
def _channels_options(self) -> SlackOptionalChannels:
|
|
58
|
+
optional = self.config.optional
|
|
59
|
+
if optional and optional.channels:
|
|
60
|
+
return optional.channels
|
|
61
|
+
return SlackOptionalChannels()
|
|
62
|
+
|
|
63
|
+
def _time_range_options(self) -> SlackOptionalTimeRange:
|
|
64
|
+
optional = self.config.optional
|
|
65
|
+
if optional and optional.time_range:
|
|
66
|
+
return optional.time_range
|
|
67
|
+
return SlackOptionalTimeRange()
|
|
68
|
+
|
|
69
|
+
def _ingestion_options(self) -> SlackOptionalIngestion:
|
|
70
|
+
optional = self.config.optional
|
|
71
|
+
if optional and optional.ingestion:
|
|
72
|
+
return optional.ingestion
|
|
73
|
+
return SlackOptionalIngestion()
|
|
74
|
+
|
|
75
|
+
def _get_token(self) -> str:
|
|
76
|
+
masked = self.config.masked
|
|
77
|
+
if isinstance(masked, SlackMaskedBotToken):
|
|
78
|
+
return masked.bot_token
|
|
79
|
+
if isinstance(masked, SlackMaskedUserToken):
|
|
80
|
+
return masked.user_token
|
|
81
|
+
if isinstance(masked, SlackMaskedToken):
|
|
82
|
+
return masked.token
|
|
83
|
+
raise ValueError("Slack token is required in masked configuration")
|
|
84
|
+
|
|
85
|
+
def _normalize_workspace(self, workspace: str) -> str:
|
|
86
|
+
cleaned = workspace.strip()
|
|
87
|
+
for prefix in ("https://", "http://"):
|
|
88
|
+
if cleaned.startswith(prefix):
|
|
89
|
+
cleaned = cleaned[len(prefix) :]
|
|
90
|
+
return cleaned.strip("/").replace(".slack.com", "")
|
|
91
|
+
|
|
92
|
+
def _request(
|
|
93
|
+
self,
|
|
94
|
+
method: str,
|
|
95
|
+
endpoint: str,
|
|
96
|
+
*,
|
|
97
|
+
params: dict[str, Any] | None = None,
|
|
98
|
+
data: dict[str, Any] | None = None,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
url = f"{self.API_BASE}/{endpoint}"
|
|
101
|
+
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
response = self.session.request(
|
|
105
|
+
method,
|
|
106
|
+
url,
|
|
107
|
+
params=params,
|
|
108
|
+
data=data,
|
|
109
|
+
timeout=30,
|
|
110
|
+
)
|
|
111
|
+
except requests.RequestException as exc:
|
|
112
|
+
raise RuntimeError(f"Slack API request failed: {exc}") from exc
|
|
113
|
+
|
|
114
|
+
if response.status_code == 429:
|
|
115
|
+
retry_after = int(response.headers.get("Retry-After", "1"))
|
|
116
|
+
logger.warning(
|
|
117
|
+
"Slack rate limit hit. Retrying after %s seconds...",
|
|
118
|
+
retry_after,
|
|
119
|
+
)
|
|
120
|
+
time.sleep(retry_after)
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
payload = response.json()
|
|
125
|
+
except ValueError as exc:
|
|
126
|
+
raise RuntimeError("Slack API returned invalid JSON") from exc
|
|
127
|
+
|
|
128
|
+
if payload.get("ok"):
|
|
129
|
+
if self.rate_limit_delay > 0:
|
|
130
|
+
time.sleep(self.rate_limit_delay)
|
|
131
|
+
return payload
|
|
132
|
+
|
|
133
|
+
if payload.get("error") == "ratelimited":
|
|
134
|
+
retry_after = int(response.headers.get("Retry-After", "1"))
|
|
135
|
+
logger.warning(
|
|
136
|
+
"Slack rate limit hit. Retrying after %s seconds...",
|
|
137
|
+
retry_after,
|
|
138
|
+
)
|
|
139
|
+
time.sleep(retry_after)
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
raise RuntimeError(f"{endpoint} error: {payload.get('error')}")
|
|
143
|
+
|
|
144
|
+
def test_connection(self) -> dict[str, Any]:
|
|
145
|
+
logger.info("Testing connection to Slack API...")
|
|
146
|
+
result = {
|
|
147
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
148
|
+
"source_type": self.recipe.get("type"),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
payload = self._request("get", "auth.test")
|
|
153
|
+
self.team_id = payload.get("team_id")
|
|
154
|
+
team_name = payload.get("team")
|
|
155
|
+
result["status"] = "SUCCESS"
|
|
156
|
+
result["message"] = (
|
|
157
|
+
f"Successfully connected to Slack workspace {team_name}."
|
|
158
|
+
if team_name
|
|
159
|
+
else "Successfully connected to Slack."
|
|
160
|
+
)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
result["status"] = "FAILURE"
|
|
163
|
+
result["message"] = str(exc)
|
|
164
|
+
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
def _ensure_team_id(self) -> None:
|
|
168
|
+
if self.team_id:
|
|
169
|
+
return
|
|
170
|
+
try:
|
|
171
|
+
payload = self._request("get", "auth.test")
|
|
172
|
+
except Exception:
|
|
173
|
+
logger.debug("Slack auth.test failed while resolving team_id", exc_info=True)
|
|
174
|
+
return
|
|
175
|
+
team_id = payload.get("team_id")
|
|
176
|
+
if isinstance(team_id, str) and team_id.strip():
|
|
177
|
+
self.team_id = team_id.strip()
|
|
178
|
+
|
|
179
|
+
def _normalize_ts(self, value: str | None) -> str | None:
|
|
180
|
+
if not value:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
float(value)
|
|
185
|
+
return value
|
|
186
|
+
except ValueError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
cleaned = value.replace("Z", "+00:00")
|
|
191
|
+
parsed = datetime.fromisoformat(cleaned)
|
|
192
|
+
except ValueError:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
if parsed.tzinfo is None:
|
|
196
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
197
|
+
return f"{parsed.timestamp():.6f}"
|
|
198
|
+
|
|
199
|
+
def _list_channels(self) -> list[dict[str, Any]]:
|
|
200
|
+
channel_options = self._channels_options()
|
|
201
|
+
channel_types = channel_options.channel_types or ["public_channel"]
|
|
202
|
+
types_param = ",".join(channel_types)
|
|
203
|
+
exclude_archived = channel_options.exclude_archived is not False
|
|
204
|
+
channels: list[dict[str, Any]] = []
|
|
205
|
+
cursor: str | None = None
|
|
206
|
+
|
|
207
|
+
while True:
|
|
208
|
+
params: dict[str, Any] = {
|
|
209
|
+
"types": types_param,
|
|
210
|
+
"limit": 200,
|
|
211
|
+
"exclude_archived": str(exclude_archived).lower(),
|
|
212
|
+
}
|
|
213
|
+
if cursor:
|
|
214
|
+
params["cursor"] = cursor
|
|
215
|
+
|
|
216
|
+
payload = self._request("get", "conversations.list", params=params)
|
|
217
|
+
channels.extend(payload.get("channels", []))
|
|
218
|
+
cursor = payload.get("response_metadata", {}).get("next_cursor")
|
|
219
|
+
if not cursor:
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
return channels
|
|
223
|
+
|
|
224
|
+
def discover(self) -> dict[str, Any]:
|
|
225
|
+
logger.info("Discovering Slack channels...")
|
|
226
|
+
channels = self._list_channels()
|
|
227
|
+
results = []
|
|
228
|
+
for channel in channels:
|
|
229
|
+
results.append(
|
|
230
|
+
{
|
|
231
|
+
"id": channel.get("id"),
|
|
232
|
+
"name": channel.get("name"),
|
|
233
|
+
"is_private": channel.get("is_private"),
|
|
234
|
+
"type": "SLACK_CHANNEL",
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
return {"channels": results}
|
|
238
|
+
|
|
239
|
+
STREAM_DETECTIONS = True
|
|
240
|
+
|
|
241
|
+
async def extract_raw(self) -> AsyncGenerator[list[SingleAssetScanResults], None]:
|
|
242
|
+
if self._aborted:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
logger.info("Extracting Slack messages...")
|
|
246
|
+
self._ensure_team_id()
|
|
247
|
+
|
|
248
|
+
channel_ids, channel_lookup = self._resolve_channels()
|
|
249
|
+
|
|
250
|
+
if not channel_ids:
|
|
251
|
+
logger.warning("No Slack channels found to extract.")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
for channel_id in channel_ids:
|
|
255
|
+
if self._aborted:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
channel_name = channel_lookup.get(channel_id, channel_id)
|
|
259
|
+
async for batch in self._stream_channel_batches(
|
|
260
|
+
channel_id,
|
|
261
|
+
channel_name,
|
|
262
|
+
):
|
|
263
|
+
yield batch
|
|
264
|
+
|
|
265
|
+
def _resolve_channels(self) -> tuple[list[str], dict[str, str]]:
|
|
266
|
+
channel_ids = self._channels_options().channel_ids or []
|
|
267
|
+
channel_lookup: dict[str, str] = {}
|
|
268
|
+
|
|
269
|
+
if channel_ids:
|
|
270
|
+
return list(channel_ids), channel_lookup
|
|
271
|
+
|
|
272
|
+
channels = self._list_channels()
|
|
273
|
+
channel_lookup = {
|
|
274
|
+
channel.get("id"): channel.get("name", "") for channel in channels if channel.get("id")
|
|
275
|
+
}
|
|
276
|
+
return list(channel_lookup.keys()), channel_lookup
|
|
277
|
+
|
|
278
|
+
async def _stream_channel_batches(
|
|
279
|
+
self,
|
|
280
|
+
channel_id: str,
|
|
281
|
+
channel_name: str,
|
|
282
|
+
) -> AsyncGenerator[list[SingleAssetScanResults], None]:
|
|
283
|
+
assets = self._iter_channel_assets(channel_id, channel_name)
|
|
284
|
+
async for batch in self._yield_batches(assets):
|
|
285
|
+
yield batch
|
|
286
|
+
|
|
287
|
+
def _iter_channel_assets(
|
|
288
|
+
self,
|
|
289
|
+
channel_id: str,
|
|
290
|
+
channel_name: str,
|
|
291
|
+
) -> Iterable[SingleAssetScanResults]:
|
|
292
|
+
for message in self._iter_channel_messages(channel_id):
|
|
293
|
+
if self._aborted:
|
|
294
|
+
return
|
|
295
|
+
yield self._message_to_asset(message, channel_id, channel_name)
|
|
296
|
+
|
|
297
|
+
async def _yield_batches(
|
|
298
|
+
self,
|
|
299
|
+
assets: Iterable[SingleAssetScanResults],
|
|
300
|
+
) -> AsyncGenerator[list[SingleAssetScanResults], None]:
|
|
301
|
+
batch: list[SingleAssetScanResults] = []
|
|
302
|
+
|
|
303
|
+
for asset in assets:
|
|
304
|
+
if self._aborted:
|
|
305
|
+
return
|
|
306
|
+
batch.append(asset)
|
|
307
|
+
|
|
308
|
+
if len(batch) >= self.BATCH_SIZE:
|
|
309
|
+
yield batch
|
|
310
|
+
batch = []
|
|
311
|
+
|
|
312
|
+
if batch:
|
|
313
|
+
yield batch
|
|
314
|
+
|
|
315
|
+
def _iter_channel_messages(self, channel_id: str) -> Iterable[dict[str, Any]]:
|
|
316
|
+
cursor: str | None = None
|
|
317
|
+
fetched = 0
|
|
318
|
+
ingestion_options = self._ingestion_options()
|
|
319
|
+
time_range_options = self._time_range_options()
|
|
320
|
+
sampling = self.config.sampling
|
|
321
|
+
max_total: int | None = None if sampling.strategy == SamplingStrategy.ALL else 100
|
|
322
|
+
oldest = self._normalize_ts(time_range_options.oldest)
|
|
323
|
+
latest = self._normalize_ts(time_range_options.latest)
|
|
324
|
+
batch_size = min(int(ingestion_options.batch_size or 200), 200)
|
|
325
|
+
|
|
326
|
+
while True:
|
|
327
|
+
if self._aborted:
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
payload_data: dict[str, Any] = {
|
|
331
|
+
"channel": channel_id,
|
|
332
|
+
"limit": batch_size,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if cursor:
|
|
336
|
+
payload_data["cursor"] = cursor
|
|
337
|
+
if oldest:
|
|
338
|
+
payload_data["oldest"] = oldest
|
|
339
|
+
if latest:
|
|
340
|
+
payload_data["latest"] = latest
|
|
341
|
+
|
|
342
|
+
payload = self._request("post", "conversations.history", data=payload_data)
|
|
343
|
+
messages = payload.get("messages", [])
|
|
344
|
+
|
|
345
|
+
if not messages:
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
for message in messages:
|
|
349
|
+
yield message
|
|
350
|
+
fetched += 1
|
|
351
|
+
if max_total and fetched >= max_total:
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
cursor = payload.get("response_metadata", {}).get("next_cursor")
|
|
355
|
+
has_more = payload.get("has_more", False)
|
|
356
|
+
if not has_more or not cursor:
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
def _message_to_asset(
|
|
360
|
+
self,
|
|
361
|
+
message: dict[str, Any],
|
|
362
|
+
channel_id: str,
|
|
363
|
+
channel_name: str,
|
|
364
|
+
) -> SingleAssetScanResults:
|
|
365
|
+
ts = str(message.get("ts", ""))
|
|
366
|
+
thread_ts = message.get("thread_ts")
|
|
367
|
+
edited_ts = message.get("edited", {}).get("ts")
|
|
368
|
+
text = message.get("text", "") or ""
|
|
369
|
+
user = message.get("user") or message.get("bot_id") or message.get("username")
|
|
370
|
+
|
|
371
|
+
created_at = self._parse_ts(ts)
|
|
372
|
+
updated_at = self._parse_ts(edited_ts) if edited_ts else created_at
|
|
373
|
+
|
|
374
|
+
snippet = self._message_snippet(text)
|
|
375
|
+
display_channel = f"#{channel_name}" if channel_name else channel_id
|
|
376
|
+
name = f"{display_channel}: {snippet}" if snippet else f"{display_channel} message {ts}"
|
|
377
|
+
|
|
378
|
+
metadata = {
|
|
379
|
+
"channel_id": channel_id,
|
|
380
|
+
"ts": ts,
|
|
381
|
+
"thread_ts": thread_ts,
|
|
382
|
+
"edited_ts": edited_ts,
|
|
383
|
+
"user": user,
|
|
384
|
+
"text": text,
|
|
385
|
+
"subtype": message.get("subtype"),
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
raw_id = f"{channel_id}_#_{ts}"
|
|
389
|
+
hashed_id = self.generate_hash_id(raw_id)
|
|
390
|
+
|
|
391
|
+
external_url = self.ensure_location(
|
|
392
|
+
self._message_permalink(channel_id, ts),
|
|
393
|
+
fallback=f"slack://channel?channel={channel_id}&message={ts}",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
return SingleAssetScanResults(
|
|
397
|
+
hash=hashed_id,
|
|
398
|
+
checksum=self.calculate_checksum(metadata),
|
|
399
|
+
name=name,
|
|
400
|
+
external_url=external_url,
|
|
401
|
+
links=[],
|
|
402
|
+
asset_type=OutputAssetType.TXT,
|
|
403
|
+
created_at=created_at,
|
|
404
|
+
updated_at=updated_at,
|
|
405
|
+
source_id=self.source_id,
|
|
406
|
+
runner_id=self.runner_id,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def _message_snippet(self, text: str, max_length: int = 120) -> str:
|
|
410
|
+
cleaned = " ".join(text.strip().split())
|
|
411
|
+
if not cleaned:
|
|
412
|
+
return ""
|
|
413
|
+
if len(cleaned) <= max_length:
|
|
414
|
+
return cleaned
|
|
415
|
+
return f"{cleaned[:max_length].rstrip()}..."
|
|
416
|
+
|
|
417
|
+
def _parse_ts(self, ts: str | None) -> datetime:
|
|
418
|
+
if not ts:
|
|
419
|
+
return datetime.now(UTC)
|
|
420
|
+
try:
|
|
421
|
+
return datetime.fromtimestamp(float(ts), tz=UTC)
|
|
422
|
+
except (TypeError, ValueError):
|
|
423
|
+
return datetime.now(UTC)
|
|
424
|
+
|
|
425
|
+
def _message_permalink(self, channel_id: str, ts: str) -> str:
|
|
426
|
+
if self.workspace:
|
|
427
|
+
ts_compact = ts.replace(".", "")
|
|
428
|
+
return f"https://{self.workspace}.slack.com/archives/{channel_id}/p{ts_compact}"
|
|
429
|
+
return f"slack://channel?channel={channel_id}&message={ts}"
|
|
430
|
+
|
|
431
|
+
def generate_hash_id(self, asset_id: str) -> str:
|
|
432
|
+
identity = self.workspace or self.team_id or "slack"
|
|
433
|
+
raw_id = f"{identity}_#_{asset_id}"
|
|
434
|
+
type_value = (
|
|
435
|
+
self.config.type.value if hasattr(self.config.type, "value") else str(self.config.type)
|
|
436
|
+
)
|
|
437
|
+
return hash_id(type_value, raw_id)
|
|
438
|
+
|
|
439
|
+
async def fetch_content(self, asset_id: str) -> tuple[str, str] | None:
|
|
440
|
+
try:
|
|
441
|
+
decoded = asset_id
|
|
442
|
+
if "_#_" not in asset_id:
|
|
443
|
+
try:
|
|
444
|
+
decoded = unhash_id(asset_id)
|
|
445
|
+
except Exception:
|
|
446
|
+
decoded = asset_id
|
|
447
|
+
|
|
448
|
+
# Format: SLACK_#_{workspace}_#_{channel_id}_#_{ts}
|
|
449
|
+
parts = decoded.split("_#_")
|
|
450
|
+
if len(parts) < 4:
|
|
451
|
+
return None
|
|
452
|
+
channel_id = parts[-2]
|
|
453
|
+
ts = parts[-1]
|
|
454
|
+
|
|
455
|
+
message = self._fetch_message(channel_id, ts)
|
|
456
|
+
if not message:
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
text = message.get("text", "") or ""
|
|
460
|
+
combined_text = text
|
|
461
|
+
|
|
462
|
+
if self._ingestion_options().include_thread_replies:
|
|
463
|
+
thread_ts = message.get("thread_ts") or ts
|
|
464
|
+
replies = self._fetch_thread_replies(channel_id, thread_ts)
|
|
465
|
+
if replies:
|
|
466
|
+
replies_text = "\n".join(self._format_reply(reply) for reply in replies)
|
|
467
|
+
combined_text = f"{text}\n{replies_text}".strip()
|
|
468
|
+
|
|
469
|
+
return combined_text, combined_text
|
|
470
|
+
except Exception as exc:
|
|
471
|
+
logger.error("Failed to fetch Slack message content: %s", exc)
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
def _fetch_message(self, channel_id: str, ts: str) -> dict[str, Any] | None:
|
|
475
|
+
payload = self._request(
|
|
476
|
+
"post",
|
|
477
|
+
"conversations.history",
|
|
478
|
+
data={
|
|
479
|
+
"channel": channel_id,
|
|
480
|
+
"oldest": ts,
|
|
481
|
+
"latest": ts,
|
|
482
|
+
"inclusive": "true",
|
|
483
|
+
"limit": 1,
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
messages = payload.get("messages", [])
|
|
487
|
+
return messages[0] if messages else None
|
|
488
|
+
|
|
489
|
+
def _fetch_thread_replies(self, channel_id: str, thread_ts: str) -> list[dict[str, Any]]:
|
|
490
|
+
replies: list[dict[str, Any]] = []
|
|
491
|
+
cursor: str | None = None
|
|
492
|
+
|
|
493
|
+
while True:
|
|
494
|
+
payload_data: dict[str, Any] = {
|
|
495
|
+
"channel": channel_id,
|
|
496
|
+
"ts": thread_ts,
|
|
497
|
+
"limit": 200,
|
|
498
|
+
}
|
|
499
|
+
if cursor:
|
|
500
|
+
payload_data["cursor"] = cursor
|
|
501
|
+
|
|
502
|
+
payload = self._request("post", "conversations.replies", data=payload_data)
|
|
503
|
+
messages = payload.get("messages", [])
|
|
504
|
+
if messages:
|
|
505
|
+
replies.extend(messages[1:] if cursor is None else messages)
|
|
506
|
+
|
|
507
|
+
cursor = payload.get("response_metadata", {}).get("next_cursor")
|
|
508
|
+
if not payload.get("has_more") or not cursor:
|
|
509
|
+
break
|
|
510
|
+
|
|
511
|
+
return replies
|
|
512
|
+
|
|
513
|
+
def _format_reply(self, reply: dict[str, Any]) -> str:
|
|
514
|
+
user = reply.get("user") or reply.get("bot_id") or "unknown"
|
|
515
|
+
text = reply.get("text", "") or ""
|
|
516
|
+
return f"{user}: {text}".strip()
|
|
517
|
+
|
|
518
|
+
def enrich_finding_location(
|
|
519
|
+
self,
|
|
520
|
+
finding: DetectionResult,
|
|
521
|
+
asset: SingleAssetScanResults,
|
|
522
|
+
text_content: str,
|
|
523
|
+
) -> None:
|
|
524
|
+
finding.location = Location(path=asset.external_url)
|
|
525
|
+
|
|
526
|
+
def abort(self) -> None:
|
|
527
|
+
logger.info("Aborting Slack extraction...")
|
|
528
|
+
super().abort()
|
|
529
|
+
if hasattr(self, "session"):
|
|
530
|
+
self.session.close()
|
|
531
|
+
|
|
532
|
+
def cleanup(self) -> None:
|
|
533
|
+
if hasattr(self, "session"):
|
|
534
|
+
self.session.close()
|