provide-uterm-annotation 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- provide_uterm_annotation-0.5.1/PKG-INFO +16 -0
- provide_uterm_annotation-0.5.1/VERSION +1 -0
- provide_uterm_annotation-0.5.1/pyproject.toml +96 -0
- provide_uterm_annotation-0.5.1/setup.cfg +4 -0
- provide_uterm_annotation-0.5.1/src/provide/uterm/annotation/__init__.py +22 -0
- provide_uterm_annotation-0.5.1/src/provide/uterm/annotation/_detector.py +86 -0
- provide_uterm_annotation-0.5.1/src/provide/uterm/annotation/_models.py +60 -0
- provide_uterm_annotation-0.5.1/src/provide/uterm/annotation/_rules.py +216 -0
- provide_uterm_annotation-0.5.1/src/provide/uterm/annotation/_streaming.py +63 -0
- provide_uterm_annotation-0.5.1/src/provide/uterm/annotation/py.typed +0 -0
- provide_uterm_annotation-0.5.1/src/provide_uterm_annotation.egg-info/PKG-INFO +16 -0
- provide_uterm_annotation-0.5.1/src/provide_uterm_annotation.egg-info/SOURCES.txt +18 -0
- provide_uterm_annotation-0.5.1/src/provide_uterm_annotation.egg-info/dependency_links.txt +1 -0
- provide_uterm_annotation-0.5.1/src/provide_uterm_annotation.egg-info/requires.txt +11 -0
- provide_uterm_annotation-0.5.1/src/provide_uterm_annotation.egg-info/top_level.txt +1 -0
- provide_uterm_annotation-0.5.1/tests/test_annotation_detector.py +218 -0
- provide_uterm_annotation-0.5.1/tests/test_annotation_models.py +189 -0
- provide_uterm_annotation-0.5.1/tests/test_annotation_rules.py +245 -0
- provide_uterm_annotation-0.5.1/tests/test_detector_exception_path.py +86 -0
- provide_uterm_annotation-0.5.1/tests/test_streaming_detector.py +85 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provide-uterm-annotation
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Session-annotation models, pattern detector, and built-in rules for provide-uterm
|
|
5
|
+
License-Expression: AGPL-3.0-or-later
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: provide-uterm>=0.5.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-asyncio>=1.3; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-cov>=7.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-timeout>=2.4.0; extra == "dev"
|
|
13
|
+
Requires-Dist: mypy>=1.19; extra == "dev"
|
|
14
|
+
Requires-Dist: ruff>=0.15; extra == "dev"
|
|
15
|
+
Requires-Dist: bandit>=1.9; extra == "dev"
|
|
16
|
+
Requires-Dist: provide-uterm>=0.5.0; extra == "dev"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.5.1
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
[build-system]
|
|
5
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
6
|
+
build-backend = "setuptools.build_meta"
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "provide-uterm-annotation"
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
description = "Session-annotation models, pattern detector, and built-in rules for provide-uterm"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
license = "AGPL-3.0-or-later"
|
|
14
|
+
dependencies = ["provide-uterm>=0.5.0"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=9.0",
|
|
19
|
+
"pytest-asyncio>=1.3",
|
|
20
|
+
"pytest-cov>=7.0",
|
|
21
|
+
"pytest-timeout>=2.4.0",
|
|
22
|
+
"mypy>=1.19",
|
|
23
|
+
"ruff>=0.15",
|
|
24
|
+
"bandit>=1.9",
|
|
25
|
+
"provide-uterm>=0.5.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.dynamic]
|
|
29
|
+
version = {file = "VERSION"}
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.package-data]
|
|
35
|
+
"provide.uterm" = ["py.typed"]
|
|
36
|
+
"provide.uterm.annotation" = ["py.typed"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
consider_namespace_packages = true
|
|
40
|
+
asyncio_mode = "auto"
|
|
41
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
addopts = [
|
|
44
|
+
"--import-mode=importlib",
|
|
45
|
+
"-v",
|
|
46
|
+
"--strict-markers",
|
|
47
|
+
"--strict-config",
|
|
48
|
+
"--tb=short",
|
|
49
|
+
"-ra",
|
|
50
|
+
"--cov=provide.uterm.annotation",
|
|
51
|
+
"--cov-branch",
|
|
52
|
+
"--cov-report=term-missing",
|
|
53
|
+
"--cov-fail-under=100",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.coverage.run]
|
|
57
|
+
source = ["provide.uterm.annotation"]
|
|
58
|
+
omit = ["tests/*"]
|
|
59
|
+
branch = true
|
|
60
|
+
|
|
61
|
+
[tool.coverage.report]
|
|
62
|
+
fail_under = 100
|
|
63
|
+
show_missing = true
|
|
64
|
+
skip_covered = false
|
|
65
|
+
|
|
66
|
+
[tool.ruff]
|
|
67
|
+
target-version = "py311"
|
|
68
|
+
line-length = 120
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
select = [
|
|
72
|
+
"E", "W", "F", "I", "N", "UP", "B", "C4", "SIM", "TCH", "PTH",
|
|
73
|
+
"DTZ", "ICN", "ISC", "PERF", "PIE", "RET", "RSE", "TID", "S", "ARG", "RUF",
|
|
74
|
+
]
|
|
75
|
+
# Aligned with the workspace-root ruff config in ../../pyproject.toml.
|
|
76
|
+
ignore = [
|
|
77
|
+
"E501", "S101", "S104", "S108", "S110", "S112", "S311", "S603", "S607",
|
|
78
|
+
"S604", "F811", "N806", "N814", "B017", "SIM105", "SIM117", "ARG001",
|
|
79
|
+
"ARG002", "PTH108", "SIM108", "PERF401",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint.per-file-ignores]
|
|
83
|
+
"__init__.py" = ["F401", "RUF022"]
|
|
84
|
+
"tests/**/*.py" = ["E402", "B905", "S101", "S105", "S106", "S110", "ARG", "TCH", "RUF003", "RUF012", "RUF015", "RUF043", "RUF059"]
|
|
85
|
+
|
|
86
|
+
[tool.mypy]
|
|
87
|
+
python_version = "3.11"
|
|
88
|
+
strict = true
|
|
89
|
+
namespace_packages = true
|
|
90
|
+
explicit_package_bases = true
|
|
91
|
+
mypy_path = ["src"]
|
|
92
|
+
warn_return_any = true
|
|
93
|
+
warn_unused_configs = true
|
|
94
|
+
|
|
95
|
+
[tool.uv.sources]
|
|
96
|
+
provide-uterm = { workspace = true }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
"""Annotation feature: mark and detect interesting moments in terminal session recordings."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from provide.uterm.annotation._detector import PatternDetector
|
|
11
|
+
from provide.uterm.annotation._models import Annotation, AnnotationSpan, DetectionRule
|
|
12
|
+
from provide.uterm.annotation._rules import BUILTIN_RULES
|
|
13
|
+
from provide.uterm.annotation._streaming import StreamingDetector
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Annotation",
|
|
17
|
+
"AnnotationSpan",
|
|
18
|
+
"BUILTIN_RULES",
|
|
19
|
+
"DetectionRule",
|
|
20
|
+
"PatternDetector",
|
|
21
|
+
"StreamingDetector",
|
|
22
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
"""PatternDetector — hot-path scanner that matches terminal event text against DetectionRules."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from provide.uterm.annotation._models import Annotation, AnnotationSpan, DetectionRule
|
|
10
|
+
from provide.uterm.annotation._rules import BUILTIN_RULES
|
|
11
|
+
|
|
12
|
+
_DESCRIPTION_TRUNCATE = 80
|
|
13
|
+
# Placeholder used in the fallback description so the raw match (a potential
|
|
14
|
+
# secret) is never embedded when a description_template fails to format.
|
|
15
|
+
_FALLBACK_PLACEHOLDER = "<unavailable>"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PatternDetector:
|
|
19
|
+
"""Scan terminal event text against a set of :class:`DetectionRule` objects.
|
|
20
|
+
|
|
21
|
+
Designed for the hot path: returns an empty list immediately when *text* is
|
|
22
|
+
empty and performs no allocations beyond the returned list when no rules
|
|
23
|
+
match.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, rules: list[DetectionRule] | None = None) -> None:
|
|
27
|
+
"""Initialise with *rules*.
|
|
28
|
+
|
|
29
|
+
If *rules* is ``None`` the built-in rule set is used.
|
|
30
|
+
"""
|
|
31
|
+
self._rules: list[DetectionRule] = BUILTIN_RULES if rules is None else rules
|
|
32
|
+
|
|
33
|
+
def detect(self, event_type: str, text: str, seq: int) -> list[Annotation]:
|
|
34
|
+
"""Scan *text* against all rules that apply to *event_type*.
|
|
35
|
+
|
|
36
|
+
Returns a (possibly empty) list of :class:`Annotation` objects. At
|
|
37
|
+
most one annotation is returned per *category* — the first rule whose
|
|
38
|
+
pattern matches wins and later rules in that category are skipped.
|
|
39
|
+
"""
|
|
40
|
+
return self.scan(event_type, text, seq)[0]
|
|
41
|
+
|
|
42
|
+
def scan(self, event_type: str, text: str, seq: int) -> tuple[list[Annotation], int]:
|
|
43
|
+
"""Like :meth:`detect`, but also return the end offset of the furthest
|
|
44
|
+
match in *text* (0 when nothing matches).
|
|
45
|
+
|
|
46
|
+
:class:`StreamingDetector` uses the offset to carry only the window tail
|
|
47
|
+
*after* the matched region — bridging a second secret that straddles the
|
|
48
|
+
boundary without re-reporting a match that already completed.
|
|
49
|
+
"""
|
|
50
|
+
if not text:
|
|
51
|
+
return [], 0
|
|
52
|
+
|
|
53
|
+
results: list[Annotation] = []
|
|
54
|
+
seen_categories: set[str] = set()
|
|
55
|
+
max_end = 0
|
|
56
|
+
|
|
57
|
+
for rule in self._rules:
|
|
58
|
+
if rule.category in seen_categories:
|
|
59
|
+
continue
|
|
60
|
+
if event_type not in rule.event_types:
|
|
61
|
+
continue
|
|
62
|
+
m = rule.pattern.search(text)
|
|
63
|
+
if m is None:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
seen_categories.add(rule.category)
|
|
67
|
+
max_end = max(max_end, m.end())
|
|
68
|
+
match_text = m.group(0)[:_DESCRIPTION_TRUNCATE]
|
|
69
|
+
try:
|
|
70
|
+
description = rule.description_template.format(match=match_text, event_type=event_type)
|
|
71
|
+
except (KeyError, IndexError):
|
|
72
|
+
# A malformed template must not leak the raw match (a potential
|
|
73
|
+
# secret) into the description, which flows to telemetry/logs.
|
|
74
|
+
description = f"{rule.label}: {_FALLBACK_PLACEHOLDER}"
|
|
75
|
+
results.append(
|
|
76
|
+
Annotation(
|
|
77
|
+
label=rule.label,
|
|
78
|
+
description=description,
|
|
79
|
+
severity=rule.severity,
|
|
80
|
+
source="detector",
|
|
81
|
+
principal="system",
|
|
82
|
+
span=AnnotationSpan(from_seq=seq, to_seq=seq),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return results, max_end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
"""Data models for session annotation — marking interesting moments in terminal recordings."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class AnnotationSpan:
|
|
18
|
+
"""A contiguous range of recording event sequence numbers."""
|
|
19
|
+
|
|
20
|
+
from_seq: int
|
|
21
|
+
to_seq: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class Annotation:
|
|
26
|
+
"""A single annotation marking an interesting moment (or range) in a session recording."""
|
|
27
|
+
|
|
28
|
+
label: str
|
|
29
|
+
description: str
|
|
30
|
+
severity: str
|
|
31
|
+
source: str
|
|
32
|
+
principal: str
|
|
33
|
+
span: AnnotationSpan | None = None
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
"""Serialise to a plain :class:`dict`, with ``span`` as a nested dict if present."""
|
|
37
|
+
result: dict[str, Any] = {
|
|
38
|
+
"label": self.label,
|
|
39
|
+
"description": self.description,
|
|
40
|
+
"severity": self.severity,
|
|
41
|
+
"source": self.source,
|
|
42
|
+
"principal": self.principal,
|
|
43
|
+
"span": None,
|
|
44
|
+
}
|
|
45
|
+
if self.span is not None:
|
|
46
|
+
result["span"] = {"from_seq": self.span.from_seq, "to_seq": self.span.to_seq}
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class DetectionRule:
|
|
52
|
+
"""A compiled regex rule used to detect annotation-worthy events in terminal output."""
|
|
53
|
+
|
|
54
|
+
rule_id: str
|
|
55
|
+
label: str
|
|
56
|
+
pattern: re.Pattern[str]
|
|
57
|
+
severity: str
|
|
58
|
+
description_template: str
|
|
59
|
+
event_types: frozenset[str]
|
|
60
|
+
category: str
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
"""Built-in detection rules for session annotation."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from provide.uterm.annotation._models import DetectionRule
|
|
12
|
+
|
|
13
|
+
# All built-in rules apply to both inbound and outbound event streams.
|
|
14
|
+
_BOTH: frozenset[str] = frozenset({"read", "send"})
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Built-in rules — ordered most-specific first within each category.
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
BUILTIN_RULES: list[DetectionRule] = [
|
|
21
|
+
# -------------------------------------------------------------------------
|
|
22
|
+
# Credentials — high severity
|
|
23
|
+
# -------------------------------------------------------------------------
|
|
24
|
+
DetectionRule(
|
|
25
|
+
rule_id="cred.aws_access_key",
|
|
26
|
+
label="credential_exposure",
|
|
27
|
+
pattern=re.compile(r"AKIA[0-9A-Z]{12}"),
|
|
28
|
+
severity="high",
|
|
29
|
+
description_template="AWS access key detected in {event_type}",
|
|
30
|
+
event_types=_BOTH,
|
|
31
|
+
category="credentials",
|
|
32
|
+
),
|
|
33
|
+
DetectionRule(
|
|
34
|
+
rule_id="cred.github_token",
|
|
35
|
+
label="credential_exposure",
|
|
36
|
+
pattern=re.compile(r"gh[psourx]_[A-Za-z0-9_]{8}"),
|
|
37
|
+
severity="high",
|
|
38
|
+
description_template="GitHub token detected in {event_type}",
|
|
39
|
+
event_types=_BOTH,
|
|
40
|
+
category="credentials",
|
|
41
|
+
),
|
|
42
|
+
DetectionRule(
|
|
43
|
+
rule_id="cred.generic_secret",
|
|
44
|
+
label="credential_exposure",
|
|
45
|
+
pattern=re.compile(r"(?i)(password|secret|token|api_key)\s*[=:]"),
|
|
46
|
+
severity="high",
|
|
47
|
+
description_template="Secret assignment detected in {event_type}",
|
|
48
|
+
event_types=_BOTH,
|
|
49
|
+
category="credentials",
|
|
50
|
+
),
|
|
51
|
+
DetectionRule(
|
|
52
|
+
rule_id="cred.bearer_token",
|
|
53
|
+
label="credential_exposure",
|
|
54
|
+
pattern=re.compile(r"Bearer\s+\S{8}"),
|
|
55
|
+
severity="high",
|
|
56
|
+
description_template="Bearer token detected in {event_type}",
|
|
57
|
+
event_types=_BOTH,
|
|
58
|
+
category="credentials",
|
|
59
|
+
),
|
|
60
|
+
DetectionRule(
|
|
61
|
+
rule_id="cred.private_key_header",
|
|
62
|
+
label="credential_exposure",
|
|
63
|
+
pattern=re.compile(r"-----BEGIN\s+(RSA|EC|OPENSSH)\s+PRIVATE KEY-----"),
|
|
64
|
+
severity="high",
|
|
65
|
+
description_template="Private key header detected: {match}",
|
|
66
|
+
event_types=_BOTH,
|
|
67
|
+
category="credentials",
|
|
68
|
+
),
|
|
69
|
+
# -------------------------------------------------------------------------
|
|
70
|
+
# Privilege escalation — high severity
|
|
71
|
+
# -------------------------------------------------------------------------
|
|
72
|
+
DetectionRule(
|
|
73
|
+
rule_id="esc.sudo",
|
|
74
|
+
label="privilege_escalation",
|
|
75
|
+
pattern=re.compile(r"\bsudo\b"),
|
|
76
|
+
severity="high",
|
|
77
|
+
description_template="sudo command detected: {match}",
|
|
78
|
+
event_types=_BOTH,
|
|
79
|
+
category="escalation",
|
|
80
|
+
),
|
|
81
|
+
DetectionRule(
|
|
82
|
+
rule_id="esc.su_dash",
|
|
83
|
+
label="privilege_escalation",
|
|
84
|
+
pattern=re.compile(r"\bsu\s+-"),
|
|
85
|
+
severity="high",
|
|
86
|
+
description_template="su - (switch to root) detected: {match}",
|
|
87
|
+
event_types=_BOTH,
|
|
88
|
+
category="escalation",
|
|
89
|
+
),
|
|
90
|
+
DetectionRule(
|
|
91
|
+
rule_id="esc.pkexec",
|
|
92
|
+
label="privilege_escalation",
|
|
93
|
+
pattern=re.compile(r"\bpkexec\b"),
|
|
94
|
+
severity="high",
|
|
95
|
+
description_template="pkexec privilege escalation detected: {match}",
|
|
96
|
+
event_types=_BOTH,
|
|
97
|
+
category="escalation",
|
|
98
|
+
),
|
|
99
|
+
# -------------------------------------------------------------------------
|
|
100
|
+
# Destructive commands — critical severity
|
|
101
|
+
# -------------------------------------------------------------------------
|
|
102
|
+
DetectionRule(
|
|
103
|
+
rule_id="dest.rm_rf",
|
|
104
|
+
label="destructive_command",
|
|
105
|
+
pattern=re.compile(r"\brm\s+(-[rRf]{2,}|-[rR]\s+-f|-f\s+-[rR])"),
|
|
106
|
+
severity="critical",
|
|
107
|
+
description_template="Recursive force-remove detected: {match}",
|
|
108
|
+
event_types=_BOTH,
|
|
109
|
+
category="destructive",
|
|
110
|
+
),
|
|
111
|
+
DetectionRule(
|
|
112
|
+
rule_id="dest.drop_table",
|
|
113
|
+
label="destructive_command",
|
|
114
|
+
pattern=re.compile(r"(?i)\bDROP\s+(TABLE|DATABASE)\b"),
|
|
115
|
+
severity="critical",
|
|
116
|
+
description_template="SQL DROP statement detected: {match}",
|
|
117
|
+
event_types=_BOTH,
|
|
118
|
+
category="destructive",
|
|
119
|
+
),
|
|
120
|
+
DetectionRule(
|
|
121
|
+
rule_id="dest.kubectl_delete",
|
|
122
|
+
label="destructive_command",
|
|
123
|
+
pattern=re.compile(r"\bkubectl\s+delete\b"),
|
|
124
|
+
severity="critical",
|
|
125
|
+
description_template="kubectl delete command detected: {match}",
|
|
126
|
+
event_types=_BOTH,
|
|
127
|
+
category="destructive",
|
|
128
|
+
),
|
|
129
|
+
DetectionRule(
|
|
130
|
+
rule_id="dest.dd_if",
|
|
131
|
+
label="destructive_command",
|
|
132
|
+
pattern=re.compile(r"\bdd\s+if="),
|
|
133
|
+
severity="critical",
|
|
134
|
+
description_template="dd disk-copy command detected: {match}",
|
|
135
|
+
event_types=_BOTH,
|
|
136
|
+
category="destructive",
|
|
137
|
+
),
|
|
138
|
+
DetectionRule(
|
|
139
|
+
rule_id="dest.mkfs",
|
|
140
|
+
label="destructive_command",
|
|
141
|
+
pattern=re.compile(r"\bmkfs\."),
|
|
142
|
+
severity="critical",
|
|
143
|
+
description_template="mkfs (format filesystem) detected: {match}",
|
|
144
|
+
event_types=_BOTH,
|
|
145
|
+
category="destructive",
|
|
146
|
+
),
|
|
147
|
+
# -------------------------------------------------------------------------
|
|
148
|
+
# Outbound connections — info severity
|
|
149
|
+
# -------------------------------------------------------------------------
|
|
150
|
+
DetectionRule(
|
|
151
|
+
rule_id="conn.ssh",
|
|
152
|
+
label="outbound_connection",
|
|
153
|
+
pattern=re.compile(r"\bssh\s+[\w.\-]+@"),
|
|
154
|
+
severity="info",
|
|
155
|
+
description_template="SSH connection detected: {match}",
|
|
156
|
+
event_types=_BOTH,
|
|
157
|
+
category="connections",
|
|
158
|
+
),
|
|
159
|
+
DetectionRule(
|
|
160
|
+
rule_id="conn.curl",
|
|
161
|
+
label="outbound_connection",
|
|
162
|
+
pattern=re.compile(r"\bcurl\b.*https?://"),
|
|
163
|
+
severity="info",
|
|
164
|
+
description_template="curl HTTP request detected: {match}",
|
|
165
|
+
event_types=_BOTH,
|
|
166
|
+
category="connections",
|
|
167
|
+
),
|
|
168
|
+
DetectionRule(
|
|
169
|
+
rule_id="conn.wget",
|
|
170
|
+
label="outbound_connection",
|
|
171
|
+
pattern=re.compile(r"\bwget\b.*https?://"),
|
|
172
|
+
severity="info",
|
|
173
|
+
description_template="wget HTTP request detected: {match}",
|
|
174
|
+
event_types=_BOTH,
|
|
175
|
+
category="connections",
|
|
176
|
+
),
|
|
177
|
+
DetectionRule(
|
|
178
|
+
rule_id="conn.scp",
|
|
179
|
+
label="outbound_connection",
|
|
180
|
+
pattern=re.compile(r"\bscp\b"),
|
|
181
|
+
severity="info",
|
|
182
|
+
description_template="scp file transfer detected: {match}",
|
|
183
|
+
event_types=_BOTH,
|
|
184
|
+
category="connections",
|
|
185
|
+
),
|
|
186
|
+
# -------------------------------------------------------------------------
|
|
187
|
+
# Session lifecycle — info severity
|
|
188
|
+
# -------------------------------------------------------------------------
|
|
189
|
+
DetectionRule(
|
|
190
|
+
rule_id="life.exit",
|
|
191
|
+
label="session_lifecycle",
|
|
192
|
+
pattern=re.compile(r"\bexit\b"),
|
|
193
|
+
severity="info",
|
|
194
|
+
description_template="exit command detected: {match}",
|
|
195
|
+
event_types=_BOTH,
|
|
196
|
+
category="lifecycle",
|
|
197
|
+
),
|
|
198
|
+
DetectionRule(
|
|
199
|
+
rule_id="life.shutdown",
|
|
200
|
+
label="session_lifecycle",
|
|
201
|
+
pattern=re.compile(r"\bshutdown\b"),
|
|
202
|
+
severity="info",
|
|
203
|
+
description_template="shutdown command detected: {match}",
|
|
204
|
+
event_types=_BOTH,
|
|
205
|
+
category="lifecycle",
|
|
206
|
+
),
|
|
207
|
+
DetectionRule(
|
|
208
|
+
rule_id="life.reboot",
|
|
209
|
+
label="session_lifecycle",
|
|
210
|
+
pattern=re.compile(r"\breboot\b"),
|
|
211
|
+
severity="info",
|
|
212
|
+
description_template="reboot command detected: {match}",
|
|
213
|
+
event_types=_BOTH,
|
|
214
|
+
category="lifecycle",
|
|
215
|
+
),
|
|
216
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
"""StreamingDetector — catch patterns split across consecutive detect() calls.
|
|
6
|
+
|
|
7
|
+
:class:`PatternDetector` is stateless: it scans one chunk at a time, so a
|
|
8
|
+
multi-character pattern (an AWS key, a URL) that happens to straddle two
|
|
9
|
+
``detect()`` chunks is silently missed. This wrapper carries a small bounded
|
|
10
|
+
tail of the previous chunk and prepends it to the next one, so a boundary-split
|
|
11
|
+
match is still found.
|
|
12
|
+
|
|
13
|
+
It is **stateful** — use one instance per logical stream (one per session, and
|
|
14
|
+
not shared across event types whose text must not be concatenated). The wrapped
|
|
15
|
+
``PatternDetector`` stays stateless and may be shared.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from provide.uterm.annotation._detector import PatternDetector
|
|
24
|
+
from provide.uterm.annotation._models import Annotation
|
|
25
|
+
|
|
26
|
+
# Longest fixed-shape secret we expect to bridge a boundary. Bounds how much of
|
|
27
|
+
# the previous chunk is retained (and re-scanned), capping memory and CPU.
|
|
28
|
+
_DEFAULT_MAX_CARRY = 512
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StreamingDetector:
|
|
32
|
+
"""Stateful per-stream wrapper that bridges chunk boundaries for a detector."""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("_carry", "_detector", "_max_carry")
|
|
35
|
+
|
|
36
|
+
def __init__(self, detector: PatternDetector, *, max_carry: int = _DEFAULT_MAX_CARRY) -> None:
|
|
37
|
+
self._detector = detector
|
|
38
|
+
self._max_carry = max_carry
|
|
39
|
+
self._carry = ""
|
|
40
|
+
|
|
41
|
+
def detect(self, event_type: str, text: str, seq: int) -> list[Annotation]:
|
|
42
|
+
"""Scan *text* (joined with the carried tail) and return any matches.
|
|
43
|
+
|
|
44
|
+
A match is owned by the chunk in which it *completes* — the returned
|
|
45
|
+
annotation's span carries the *seq* passed for that chunk. The carried
|
|
46
|
+
tail is the bounded window suffix *after* the furthest match: it bridges
|
|
47
|
+
a secret straddling the next boundary (including a second one that begins
|
|
48
|
+
right after a completed match) without re-reporting a match that already
|
|
49
|
+
finished.
|
|
50
|
+
"""
|
|
51
|
+
if not text:
|
|
52
|
+
return []
|
|
53
|
+
window = self._carry + text if self._carry else text
|
|
54
|
+
annotations, match_end = self._detector.scan(event_type, window, seq)
|
|
55
|
+
self._carry = window[match_end:][-self._max_carry :]
|
|
56
|
+
return annotations
|
|
57
|
+
|
|
58
|
+
def reset(self) -> None:
|
|
59
|
+
"""Forget the carried tail (e.g. on screen clear / session resync)."""
|
|
60
|
+
self._carry = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["StreamingDetector"]
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provide-uterm-annotation
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Session-annotation models, pattern detector, and built-in rules for provide-uterm
|
|
5
|
+
License-Expression: AGPL-3.0-or-later
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: provide-uterm>=0.5.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-asyncio>=1.3; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-cov>=7.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-timeout>=2.4.0; extra == "dev"
|
|
13
|
+
Requires-Dist: mypy>=1.19; extra == "dev"
|
|
14
|
+
Requires-Dist: ruff>=0.15; extra == "dev"
|
|
15
|
+
Requires-Dist: bandit>=1.9; extra == "dev"
|
|
16
|
+
Requires-Dist: provide-uterm>=0.5.0; extra == "dev"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
VERSION
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/provide/uterm/annotation/__init__.py
|
|
4
|
+
src/provide/uterm/annotation/_detector.py
|
|
5
|
+
src/provide/uterm/annotation/_models.py
|
|
6
|
+
src/provide/uterm/annotation/_rules.py
|
|
7
|
+
src/provide/uterm/annotation/_streaming.py
|
|
8
|
+
src/provide/uterm/annotation/py.typed
|
|
9
|
+
src/provide_uterm_annotation.egg-info/PKG-INFO
|
|
10
|
+
src/provide_uterm_annotation.egg-info/SOURCES.txt
|
|
11
|
+
src/provide_uterm_annotation.egg-info/dependency_links.txt
|
|
12
|
+
src/provide_uterm_annotation.egg-info/requires.txt
|
|
13
|
+
src/provide_uterm_annotation.egg-info/top_level.txt
|
|
14
|
+
tests/test_annotation_detector.py
|
|
15
|
+
tests/test_annotation_models.py
|
|
16
|
+
tests/test_annotation_rules.py
|
|
17
|
+
tests/test_detector_exception_path.py
|
|
18
|
+
tests/test_streaming_detector.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
provide
|