classifyre-cli 0.4.45__py3-none-any.whl → 0.4.46__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.45.dist-info → classifyre_cli-0.4.46.dist-info}/METADATA +1 -1
- {classifyre_cli-0.4.45.dist-info → classifyre_cli-0.4.46.dist-info}/RECORD +7 -5
- src/models/generated_input.py +84 -3
- src/sources/local_folder/__init__.py +3 -0
- src/sources/local_folder/source.py +146 -0
- {classifyre_cli-0.4.45.dist-info → classifyre_cli-0.4.46.dist-info}/WHEEL +0 -0
- {classifyre_cli-0.4.45.dist-info → classifyre_cli-0.4.46.dist-info}/entry_points.txt +0 -0
|
@@ -31,7 +31,7 @@ src/detectors/threat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hS
|
|
|
31
31
|
src/detectors/threat/code_security_detector.py,sha256=SdjhvTSU10wHVxP2EwyzoTGMq8-J8CtwypayHVHdMUs,7049
|
|
32
32
|
src/detectors/threat/yara_detector.py,sha256=VuJ6Tu3o3wMhv2bskSx2YaB7sTILuQaZlK0uWEiLqe0,6549
|
|
33
33
|
src/models/generated_detectors.py,sha256=bDTV8bBG2IJhC37lYu32t_-VTEf-pTPqB_Q-yTMclIo,47709
|
|
34
|
-
src/models/generated_input.py,sha256=
|
|
34
|
+
src/models/generated_input.py,sha256=XA81Wk9H5vFhtRIcExXrPP3WNENDjkeRILzImGIODcQ,115008
|
|
35
35
|
src/models/generated_single_asset_scan_results.py,sha256=BRHBLb9wAKghAF60KPJEYPio2i2tklmbt5kMTLLt2qY,7761
|
|
36
36
|
src/outputs/__init__.py,sha256=41SESY2nC1y4zF0CTWn1dnTh9AK7qMdRxmRSN_GuWss,126
|
|
37
37
|
src/outputs/base.py,sha256=mRFTwqkaWLqyS55OkdbO49yBnSHXlaIGd_bzLyNGj9E,1556
|
|
@@ -79,6 +79,8 @@ src/sources/jira/__init__.py,sha256=p3G96i94D6ZrdHKE6bzgVtYvO8h2-Aet4zERRYdJAyA,
|
|
|
79
79
|
src/sources/jira/source.py,sha256=OApvZQTQC6sV4qKN3jZMpaA2gTdC0U-ky2VTgL2G3ts,24559
|
|
80
80
|
src/sources/kafka/__init__.py,sha256=BJoJs6vgviCpcyJXuUf70i46XqRnBrqcT9dJJZtHC5o,59
|
|
81
81
|
src/sources/kafka/source.py,sha256=3WAJGobP85HHSozH2noaSnsJmEtTkD22AcBrmvaYfy4,17610
|
|
82
|
+
src/sources/local_folder/__init__.py,sha256=WYo83Wg7ONugPZzisjwPkaWe0TADD7AetTpSXs2k908,71
|
|
83
|
+
src/sources/local_folder/source.py,sha256=ed6DdgqIt-w8thchYs6bQVp89lr8sa9dyg6ms5hI3Vc,5472
|
|
82
84
|
src/sources/meilisearch/__init__.py,sha256=2ZESbCcM68x8B7qF_bRUt0Aah32pdzje_YndSHizAjM,71
|
|
83
85
|
src/sources/meilisearch/source.py,sha256=2Gm3WA_1llxmmdrRkmDyOZfMg_Oo8I16sbrGXM8AXyk,14487
|
|
84
86
|
src/sources/mongodb/__init__.py,sha256=IcSTuVMB3JOXAjYpwFcFvRuc2a7m6xgZ1VG5AfM4eJU,63
|
|
@@ -130,7 +132,7 @@ src/utils/resources.py,sha256=nE3YBKNBCFMnFErQcO9DKkatHPFQfFt-OsXCpoEz6sA,2125
|
|
|
130
132
|
src/utils/transcription.py,sha256=_wr0DuPnzBb8tR4IgmMcS0PDSgJ5YbvIBZSx0_iXgIs,14067
|
|
131
133
|
src/utils/uv_sync.py,sha256=VZ0TZ7t3_pSjxOb20BxUs2XLVjigXnVqa5y69XWlnE8,9307
|
|
132
134
|
src/utils/validation.py,sha256=ab6OzB-GeZBMr413U10jG2FDiiWXCil-_e70T0BXwFE,1694
|
|
133
|
-
classifyre_cli-0.4.
|
|
134
|
-
classifyre_cli-0.4.
|
|
135
|
-
classifyre_cli-0.4.
|
|
136
|
-
classifyre_cli-0.4.
|
|
135
|
+
classifyre_cli-0.4.46.dist-info/METADATA,sha256=jWJZvJ0w4g_B0KYyTJiG9iMkpU74DBiQceAJItsWmHU,4222
|
|
136
|
+
classifyre_cli-0.4.46.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
137
|
+
classifyre_cli-0.4.46.dist-info/entry_points.txt,sha256=edxllyMiIFfajgCZ3dTRACXYGC34ZX1AYTezMSR5E_8,45
|
|
138
|
+
classifyre_cli-0.4.46.dist-info/RECORD,,
|
src/models/generated_input.py
CHANGED
|
@@ -51,6 +51,7 @@ class AssetType(StrEnum):
|
|
|
51
51
|
ELASTICSEARCH = 'ELASTICSEARCH'
|
|
52
52
|
OPENSEARCH = 'OPENSEARCH'
|
|
53
53
|
MEILISEARCH = 'MEILISEARCH'
|
|
54
|
+
LOCAL_FOLDER = 'LOCAL_FOLDER'
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
class DetectorType(StrEnum):
|
|
@@ -355,6 +356,7 @@ class Type(StrEnum):
|
|
|
355
356
|
ELASTICSEARCH = 'ELASTICSEARCH'
|
|
356
357
|
OPENSEARCH = 'OPENSEARCH'
|
|
357
358
|
MEILISEARCH = 'MEILISEARCH'
|
|
359
|
+
LOCAL_FOLDER = 'LOCAL_FOLDER'
|
|
358
360
|
|
|
359
361
|
|
|
360
362
|
class YouTubeRequired(BaseModel):
|
|
@@ -644,6 +646,64 @@ class S3CompatibleStorageOptional(BaseModel):
|
|
|
644
646
|
scope: ObjectStorageOptionalScope | None = None
|
|
645
647
|
|
|
646
648
|
|
|
649
|
+
class LocalFolderRequired(BaseModel):
|
|
650
|
+
model_config = ConfigDict(
|
|
651
|
+
extra='forbid',
|
|
652
|
+
)
|
|
653
|
+
path: str = Field(
|
|
654
|
+
...,
|
|
655
|
+
description='Absolute path of the local folder to scan (for example, /Users/jane/Documents)',
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class LocalFolderMasked(BaseModel):
|
|
660
|
+
"""
|
|
661
|
+
Local folder scans run with the desktop application's filesystem permissions; no credentials are required.
|
|
662
|
+
"""
|
|
663
|
+
|
|
664
|
+
model_config = ConfigDict(
|
|
665
|
+
extra='forbid',
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class LocalFolderOptionalTraversal(BaseModel):
|
|
670
|
+
"""
|
|
671
|
+
Directory traversal controls.
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
model_config = ConfigDict(
|
|
675
|
+
extra='forbid',
|
|
676
|
+
)
|
|
677
|
+
follow_symlinks: bool | None = Field(
|
|
678
|
+
False,
|
|
679
|
+
description='Follow symbolic links while traversing (loops are not detected; leave off unless needed)',
|
|
680
|
+
)
|
|
681
|
+
include_hidden: bool | None = Field(
|
|
682
|
+
False,
|
|
683
|
+
description='Include hidden files and dot-directories (names starting with a dot)',
|
|
684
|
+
)
|
|
685
|
+
max_depth: int | None = Field(
|
|
686
|
+
None,
|
|
687
|
+
description='Maximum directory depth below the selected folder (unset scans all levels)',
|
|
688
|
+
ge=1,
|
|
689
|
+
le=64,
|
|
690
|
+
)
|
|
691
|
+
max_file_bytes: int | None = Field(
|
|
692
|
+
10485760,
|
|
693
|
+
description='Maximum bytes read per file for MIME detection and text extraction',
|
|
694
|
+
ge=1024,
|
|
695
|
+
le=104857600,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
class LocalFolderOptional(BaseModel):
|
|
700
|
+
model_config = ConfigDict(
|
|
701
|
+
extra='forbid',
|
|
702
|
+
)
|
|
703
|
+
traversal: LocalFolderOptionalTraversal | None = None
|
|
704
|
+
scope: ObjectStorageOptionalScope | None = None
|
|
705
|
+
|
|
706
|
+
|
|
647
707
|
class AzureBlobStorageRequired(BaseModel):
|
|
648
708
|
model_config = ConfigDict(
|
|
649
709
|
extra='forbid',
|
|
@@ -2100,6 +2160,24 @@ class S3CompatibleStorageInput(CoreInput):
|
|
|
2100
2160
|
resources: ResourceOverrides | None = None
|
|
2101
2161
|
|
|
2102
2162
|
|
|
2163
|
+
class LocalFolderInput(CoreInput):
|
|
2164
|
+
type: Literal['LOCAL_FOLDER'] | None = Field(
|
|
2165
|
+
None, description='Type of the asset or source'
|
|
2166
|
+
)
|
|
2167
|
+
required: LocalFolderRequired
|
|
2168
|
+
masked: LocalFolderMasked | None = None
|
|
2169
|
+
optional: LocalFolderOptional | None = None
|
|
2170
|
+
detectors: list[Detector] | None = Field(
|
|
2171
|
+
None, description='Detectors to run on ingested content'
|
|
2172
|
+
)
|
|
2173
|
+
custom_detectors: list[CustomDetectorSelection] | None = Field(
|
|
2174
|
+
None,
|
|
2175
|
+
description='Reusable custom detector IDs selected from the custom detector catalog.',
|
|
2176
|
+
)
|
|
2177
|
+
sampling: SamplingConfig
|
|
2178
|
+
resources: ResourceOverrides | None = None
|
|
2179
|
+
|
|
2180
|
+
|
|
2103
2181
|
class AzureBlobStorageInput(CoreInput):
|
|
2104
2182
|
type: Literal['AZURE_BLOB_STORAGE'] | None = Field(
|
|
2105
2183
|
None, description='Type of the asset or source'
|
|
@@ -2531,7 +2609,7 @@ class ConfluenceOptionalConnection(BaseModel):
|
|
|
2531
2609
|
)
|
|
2532
2610
|
|
|
2533
2611
|
|
|
2534
|
-
class
|
|
2612
|
+
class Type19(StrEnum):
|
|
2535
2613
|
"""
|
|
2536
2614
|
Filter spaces by space type
|
|
2537
2615
|
"""
|
|
@@ -2568,7 +2646,7 @@ class ConfluenceOptionalScopeSpaces(BaseModel):
|
|
|
2568
2646
|
keys: list[str] | None = Field(
|
|
2569
2647
|
None, description='Filter spaces by keys (up to 250)', max_length=250
|
|
2570
2648
|
)
|
|
2571
|
-
type:
|
|
2649
|
+
type: Type19 | None = Field(None, description='Filter spaces by space type')
|
|
2572
2650
|
status: Status | None = Field(None, description='Filter spaces by status')
|
|
2573
2651
|
labels: list[str] | None = Field(
|
|
2574
2652
|
None,
|
|
@@ -2834,7 +2912,7 @@ class ServiceDeskOptional(BaseModel):
|
|
|
2834
2912
|
content: ServiceDeskOptionalContent | None = None
|
|
2835
2913
|
|
|
2836
2914
|
|
|
2837
|
-
class
|
|
2915
|
+
class Type20(StrEnum):
|
|
2838
2916
|
"""
|
|
2839
2917
|
Type of the asset or source
|
|
2840
2918
|
"""
|
|
@@ -2868,6 +2946,7 @@ class Type19(StrEnum):
|
|
|
2868
2946
|
ELASTICSEARCH = 'ELASTICSEARCH'
|
|
2869
2947
|
OPENSEARCH = 'OPENSEARCH'
|
|
2870
2948
|
MEILISEARCH = 'MEILISEARCH'
|
|
2949
|
+
LOCAL_FOLDER = 'LOCAL_FOLDER'
|
|
2871
2950
|
|
|
2872
2951
|
|
|
2873
2952
|
class ConfluenceInput(CoreInput):
|
|
@@ -3682,6 +3761,7 @@ class SourceInput(
|
|
|
3682
3761
|
RootModel[
|
|
3683
3762
|
SlackInput
|
|
3684
3763
|
| S3CompatibleStorageInput
|
|
3764
|
+
| LocalFolderInput
|
|
3685
3765
|
| AzureBlobStorageInput
|
|
3686
3766
|
| GoogleCloudStorageInput
|
|
3687
3767
|
| PostgreSQLInput
|
|
@@ -3714,6 +3794,7 @@ class SourceInput(
|
|
|
3714
3794
|
root: (
|
|
3715
3795
|
SlackInput
|
|
3716
3796
|
| S3CompatibleStorageInput
|
|
3797
|
+
| LocalFolderInput
|
|
3717
3798
|
| AzureBlobStorageInput
|
|
3718
3799
|
| GoogleCloudStorageInput
|
|
3719
3800
|
| PostgreSQLInput
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from ...models.generated_input import LocalFolderInput
|
|
12
|
+
from ..object_storage.base import ObjectRef, ObjectStorageSourceBase
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocalFolderSource(ObjectStorageSourceBase):
|
|
18
|
+
"""Scans a folder on the local filesystem (desktop application deployments).
|
|
19
|
+
|
|
20
|
+
Reuses the object-storage pipeline: each file becomes an ObjectRef whose key
|
|
21
|
+
is the path relative to the scanned root, so sampling, MIME resolution, text
|
|
22
|
+
extraction, file metadata, and embedded-image child assets all come from
|
|
23
|
+
ObjectStorageSourceBase.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
source_type = "local_folder"
|
|
27
|
+
provider_label = "LOCAL_FOLDER"
|
|
28
|
+
input_model = LocalFolderInput
|
|
29
|
+
|
|
30
|
+
DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
_resolved_root: Path | None = None
|
|
33
|
+
|
|
34
|
+
def _root(self) -> Path:
|
|
35
|
+
if self._resolved_root is not None:
|
|
36
|
+
return self._resolved_root
|
|
37
|
+
raw = str(self.config.required.path).strip()
|
|
38
|
+
if not raw:
|
|
39
|
+
raise ValueError("required.path must be set")
|
|
40
|
+
root = Path(raw).expanduser()
|
|
41
|
+
if not root.is_absolute():
|
|
42
|
+
raise ValueError(f"required.path must be an absolute path, got: {raw}")
|
|
43
|
+
if not root.exists():
|
|
44
|
+
raise ValueError(f"Folder does not exist: {root}")
|
|
45
|
+
if not root.is_dir():
|
|
46
|
+
raise ValueError(f"Path is not a folder: {root}")
|
|
47
|
+
self._resolved_root = root.resolve()
|
|
48
|
+
return self._resolved_root
|
|
49
|
+
|
|
50
|
+
def _traversal_option(self, key: str, default: Any = None) -> Any:
|
|
51
|
+
optional = self.config.optional
|
|
52
|
+
traversal = getattr(optional, "traversal", None) if optional else None
|
|
53
|
+
if traversal is not None:
|
|
54
|
+
value = getattr(traversal, key, None)
|
|
55
|
+
if value is not None:
|
|
56
|
+
return value
|
|
57
|
+
return default
|
|
58
|
+
|
|
59
|
+
def _follow_symlinks(self) -> bool:
|
|
60
|
+
return bool(self._traversal_option("follow_symlinks", False))
|
|
61
|
+
|
|
62
|
+
def _include_hidden(self) -> bool:
|
|
63
|
+
return bool(self._traversal_option("include_hidden", False))
|
|
64
|
+
|
|
65
|
+
def _max_depth(self) -> int | None:
|
|
66
|
+
value = self._traversal_option("max_depth")
|
|
67
|
+
try:
|
|
68
|
+
return int(value) if value is not None else None
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def _max_file_bytes(self) -> int:
|
|
73
|
+
value = self._traversal_option("max_file_bytes", self.DEFAULT_MAX_FILE_BYTES)
|
|
74
|
+
try:
|
|
75
|
+
parsed = int(value)
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return self.DEFAULT_MAX_FILE_BYTES
|
|
78
|
+
return max(parsed, 1024)
|
|
79
|
+
|
|
80
|
+
def _is_hidden(self, name: str) -> bool:
|
|
81
|
+
return name.startswith(".")
|
|
82
|
+
|
|
83
|
+
def _walk(self, directory: Path, depth: int) -> Iterator[ObjectRef]:
|
|
84
|
+
max_depth = self._max_depth()
|
|
85
|
+
follow_symlinks = self._follow_symlinks()
|
|
86
|
+
include_hidden = self._include_hidden()
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
entries = sorted(os.scandir(directory), key=lambda entry: entry.name)
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
logger.warning("Skipping unreadable directory %s: %s", directory, exc)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
for entry in entries:
|
|
95
|
+
if not include_hidden and self._is_hidden(entry.name):
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
if entry.is_dir(follow_symlinks=follow_symlinks):
|
|
99
|
+
if max_depth is None or depth < max_depth:
|
|
100
|
+
yield from self._walk(Path(entry.path), depth + 1)
|
|
101
|
+
continue
|
|
102
|
+
if not entry.is_file(follow_symlinks=follow_symlinks):
|
|
103
|
+
continue
|
|
104
|
+
stat = entry.stat(follow_symlinks=follow_symlinks)
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
logger.warning("Skipping unreadable entry %s: %s", entry.path, exc)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
key = Path(entry.path).relative_to(self._root()).as_posix()
|
|
110
|
+
if not self._object_matches_extension_filters(key):
|
|
111
|
+
continue
|
|
112
|
+
prefix = self._prefix()
|
|
113
|
+
if prefix and not key.startswith(prefix):
|
|
114
|
+
continue
|
|
115
|
+
if stat.st_size == 0 and not self._include_empty_objects():
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
yield ObjectRef(
|
|
119
|
+
key=key,
|
|
120
|
+
size=stat.st_size,
|
|
121
|
+
last_modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
|
|
122
|
+
# Change signature: mtime+size, so edits produce a new checksum
|
|
123
|
+
# without hashing file bytes during discovery.
|
|
124
|
+
etag=f"{stat.st_mtime_ns:x}-{stat.st_size:x}",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _list_objects(self) -> Iterator[ObjectRef]:
|
|
128
|
+
yield from self._walk(self._root(), depth=0)
|
|
129
|
+
|
|
130
|
+
def _download_object(self, ref: ObjectRef) -> tuple[bytes, str | None]:
|
|
131
|
+
file_path = self._root() / ref.key
|
|
132
|
+
max_bytes = self._max_file_bytes()
|
|
133
|
+
with open(file_path, "rb") as handle:
|
|
134
|
+
file_bytes = handle.read(max_bytes)
|
|
135
|
+
if ref.size > max_bytes:
|
|
136
|
+
logger.info(
|
|
137
|
+
"Truncated %s to %d of %d bytes for content extraction",
|
|
138
|
+
file_path,
|
|
139
|
+
max_bytes,
|
|
140
|
+
ref.size,
|
|
141
|
+
)
|
|
142
|
+
return file_bytes, None
|
|
143
|
+
|
|
144
|
+
def _external_url(self, key: str) -> str:
|
|
145
|
+
absolute = (self._root() / key).as_posix()
|
|
146
|
+
return f"file://{quote(absolute)}"
|
|
File without changes
|
|
File without changes
|