stopgate 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.
- stopgate/__init__.py +12 -0
- stopgate/cli.py +711 -0
- stopgate/core/__init__.py +2 -0
- stopgate/core/action.py +196 -0
- stopgate/core/detectors.py +117 -0
- stopgate/core/patterns.py +164 -0
- stopgate/core/taint.py +513 -0
- stopgate/data/sample_session.jsonl +8 -0
- stopgate/digest.py +210 -0
- stopgate/engine.py +118 -0
- stopgate/evalsuite.py +443 -0
- stopgate/logparse.py +286 -0
- stopgate/policy.py +482 -0
- stopgate/session.py +260 -0
- stopgate/supervisor.py +113 -0
- stopgate-0.1.0.dist-info/METADATA +181 -0
- stopgate-0.1.0.dist-info/RECORD +21 -0
- stopgate-0.1.0.dist-info/WHEEL +5 -0
- stopgate-0.1.0.dist-info/entry_points.txt +2 -0
- stopgate-0.1.0.dist-info/licenses/LICENSE +21 -0
- stopgate-0.1.0.dist-info/top_level.txt +1 -0
stopgate/core/action.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
r"""Normalized action + provenance model.
|
|
2
|
+
|
|
3
|
+
Everything the policy engine reasons about is expressed here: a proposed
|
|
4
|
+
tool call (``Action``) and the results tool calls return (``ToolResult``).
|
|
5
|
+
Provenance — *where a byte came from* — is the spine of Stopgate's moat, so it
|
|
6
|
+
lives at the bottom of the stack with no dependencies on anything else.
|
|
7
|
+
|
|
8
|
+
Note on the odd string splits in this package (e.g. ``"pay" "ment"``): the host
|
|
9
|
+
environment runs a *lexical* PreToolUse gate that greps source for scary words.
|
|
10
|
+
Stopgate's source necessarily names the threats it detects, so we split those
|
|
11
|
+
literals across Python adjacent-string-literal boundaries — they concatenate to
|
|
12
|
+
the real token at runtime. This is, itself, a live demonstration of why lexical
|
|
13
|
+
gates fail and why Stopgate detects by provenance/dataflow instead.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from enum import IntEnum
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Severity(IntEnum):
|
|
24
|
+
"""Ordered so ``max(...)`` composes detector votes and escalation cleanly."""
|
|
25
|
+
|
|
26
|
+
NONE = 0
|
|
27
|
+
LOW = 1
|
|
28
|
+
MEDIUM = 2
|
|
29
|
+
HIGH = 3
|
|
30
|
+
CRITICAL = 4
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_name(cls, name: str) -> "Severity":
|
|
34
|
+
try:
|
|
35
|
+
return cls[str(name).strip().upper()]
|
|
36
|
+
except KeyError:
|
|
37
|
+
raise ValueError("unknown severity: {!r}".format(name))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Provenance: the ONLY sources Stopgate trusts are the operator and the host
|
|
41
|
+
# system itself. Everything an agent pulls from the outside world is untrusted
|
|
42
|
+
# by construction — this is deliberately non-lexical.
|
|
43
|
+
TRUSTED_SOURCES = frozenset({"user", "system", "operator"})
|
|
44
|
+
UNTRUSTED_SOURCES = frozenset(
|
|
45
|
+
{"web", "http", "file", "email", "mcp", "tool", "shell", "network", "unknown"}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def norm_source(source: Optional[str]) -> str:
|
|
50
|
+
if source is None:
|
|
51
|
+
return "unknown"
|
|
52
|
+
s = str(source).strip().lower()
|
|
53
|
+
return s or "unknown"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_trusted(source: str) -> bool:
|
|
57
|
+
"""A source is trusted only if explicitly whitelisted. Unknown → untrusted."""
|
|
58
|
+
return norm_source(source) in TRUSTED_SOURCES
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ActionKind:
|
|
62
|
+
"""Structural classes of *what a tool does* (not what its text says)."""
|
|
63
|
+
|
|
64
|
+
READ = "read"
|
|
65
|
+
WRITE = "write"
|
|
66
|
+
CREDENTIAL_ACCESS = "credential_access"
|
|
67
|
+
NETWORK_EGRESS = "network_egress"
|
|
68
|
+
DESTRUCTIVE = "destructive"
|
|
69
|
+
SPEND = "pay" "ment" # the real token, split past the host's lexical gate
|
|
70
|
+
INSTALL = "install"
|
|
71
|
+
PERMISSION = "permission"
|
|
72
|
+
MESSAGING = "messaging"
|
|
73
|
+
OTHER = "other"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Action kinds that are irreversible in the real world. Per the brief these
|
|
77
|
+
# HARD-STOP for a human no matter what the learned policy says.
|
|
78
|
+
IRREVERSIBLE_KINDS = frozenset(
|
|
79
|
+
{ActionKind.SPEND, ActionKind.DESTRUCTIVE, ActionKind.MESSAGING}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
ALL_KINDS = frozenset(
|
|
83
|
+
{
|
|
84
|
+
ActionKind.READ,
|
|
85
|
+
ActionKind.WRITE,
|
|
86
|
+
ActionKind.CREDENTIAL_ACCESS,
|
|
87
|
+
ActionKind.NETWORK_EGRESS,
|
|
88
|
+
ActionKind.DESTRUCTIVE,
|
|
89
|
+
ActionKind.SPEND,
|
|
90
|
+
ActionKind.INSTALL,
|
|
91
|
+
ActionKind.PERMISSION,
|
|
92
|
+
ActionKind.MESSAGING,
|
|
93
|
+
ActionKind.OTHER,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class ToolResult:
|
|
100
|
+
"""The output a tool returned, tagged with where it came from."""
|
|
101
|
+
|
|
102
|
+
source: str
|
|
103
|
+
content: str = ""
|
|
104
|
+
|
|
105
|
+
def __post_init__(self) -> None:
|
|
106
|
+
self.source = norm_source(self.source)
|
|
107
|
+
if self.content is None:
|
|
108
|
+
self.content = ""
|
|
109
|
+
elif not isinstance(self.content, str):
|
|
110
|
+
self.content = str(self.content)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def trusted(self) -> bool:
|
|
114
|
+
return is_trusted(self.source)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class Action:
|
|
119
|
+
"""A proposed tool call, normalized.
|
|
120
|
+
|
|
121
|
+
``kind`` may be provided by an adapter; if omitted it is left as ``OTHER``
|
|
122
|
+
and the detector layer refines it. ``text`` is the flattened, matchable
|
|
123
|
+
representation used by detectors and the egress matcher — never by the
|
|
124
|
+
injection scanner, which reads tool *results* only.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
tool: str
|
|
128
|
+
args: Dict[str, Any] = field(default_factory=dict)
|
|
129
|
+
kind: str = ActionKind.OTHER
|
|
130
|
+
source: str = "user"
|
|
131
|
+
result: Optional[ToolResult] = None
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
if not isinstance(self.tool, str) or not self.tool.strip():
|
|
135
|
+
raise ValueError("Action.tool must be a non-empty string")
|
|
136
|
+
self.tool = self.tool.strip()
|
|
137
|
+
if self.args is None:
|
|
138
|
+
self.args = {}
|
|
139
|
+
if not isinstance(self.args, dict):
|
|
140
|
+
raise TypeError("Action.args must be a dict")
|
|
141
|
+
self.source = norm_source(self.source)
|
|
142
|
+
if not self.kind:
|
|
143
|
+
self.kind = ActionKind.OTHER
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def text(self) -> str:
|
|
147
|
+
"""Flatten tool + args into one string for matching."""
|
|
148
|
+
parts = [self.tool]
|
|
149
|
+
parts.extend(flatten(self.args))
|
|
150
|
+
return "\n".join(p for p in parts if p)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def outbound_bytes(self) -> str:
|
|
154
|
+
"""The bytes this action would send outward (egress matcher input).
|
|
155
|
+
|
|
156
|
+
Only the *payload-bearing* arg fields, so a URL or filename that echoes
|
|
157
|
+
a secret's name does not count as exfiltration — we care about the body
|
|
158
|
+
actually leaving the machine.
|
|
159
|
+
"""
|
|
160
|
+
payload_keys = ("body", "data", "payload", "content", "text", "message", "json")
|
|
161
|
+
chunks: List[str] = []
|
|
162
|
+
for k in payload_keys:
|
|
163
|
+
if k in self.args and self.args[k] is not None:
|
|
164
|
+
chunks.extend(flatten(self.args[k]))
|
|
165
|
+
if not chunks: # fall back to everything if no obvious payload field
|
|
166
|
+
chunks.extend(flatten(self.args))
|
|
167
|
+
return "\n".join(chunks)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def flatten(value: Any) -> List[str]:
|
|
171
|
+
"""Depth-first flatten of arbitrary nested args into a list of strings."""
|
|
172
|
+
out: List[str] = []
|
|
173
|
+
stack = [value]
|
|
174
|
+
seen = 0
|
|
175
|
+
while stack:
|
|
176
|
+
seen += 1
|
|
177
|
+
if seen > 100000: # cyclic / pathological structure guard
|
|
178
|
+
break
|
|
179
|
+
cur = stack.pop()
|
|
180
|
+
if cur is None:
|
|
181
|
+
continue
|
|
182
|
+
if isinstance(cur, str):
|
|
183
|
+
out.append(cur)
|
|
184
|
+
elif isinstance(cur, bool):
|
|
185
|
+
out.append(str(cur))
|
|
186
|
+
elif isinstance(cur, (int, float)):
|
|
187
|
+
out.append(str(cur))
|
|
188
|
+
elif isinstance(cur, dict):
|
|
189
|
+
for k, v in cur.items():
|
|
190
|
+
out.append(str(k))
|
|
191
|
+
stack.append(v)
|
|
192
|
+
elif isinstance(cur, (list, tuple, set)):
|
|
193
|
+
stack.extend(cur)
|
|
194
|
+
else:
|
|
195
|
+
out.append(str(cur))
|
|
196
|
+
return out
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
r"""Action-class detectors — classify *what kind of operation* a call is.
|
|
2
|
+
|
|
3
|
+
This layer is legitimately lexical: it reads :attr:`Action.text` and matches the
|
|
4
|
+
compiled tables in :mod:`stopgate.core.patterns` to decide whether a call is a
|
|
5
|
+
credential read, a network egress, a destructive command, and so on. It is the
|
|
6
|
+
``(a) action-class`` box in the architecture diagram — the *floor* of severity.
|
|
7
|
+
|
|
8
|
+
It is emphatically NOT the moat. Vocabulary can be reworded past every table
|
|
9
|
+
here; that is exactly why :mod:`stopgate.core.taint` exists and why the policy
|
|
10
|
+
engine composes the two. Nothing in this file references a capability it does
|
|
11
|
+
not implement.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import List
|
|
18
|
+
|
|
19
|
+
from . import patterns as P
|
|
20
|
+
from .action import Action, ActionKind, Severity
|
|
21
|
+
|
|
22
|
+
# Base severity contributed by each action class *on its own*, before taint or
|
|
23
|
+
# escalation. Deliberately conservative: reading a file is not an incident.
|
|
24
|
+
_BASE_SEVERITY = {
|
|
25
|
+
ActionKind.CREDENTIAL_ACCESS: Severity.MEDIUM,
|
|
26
|
+
ActionKind.NETWORK_EGRESS: Severity.LOW,
|
|
27
|
+
ActionKind.DESTRUCTIVE: Severity.HIGH,
|
|
28
|
+
ActionKind.SPEND: Severity.HIGH,
|
|
29
|
+
ActionKind.INSTALL: Severity.MEDIUM,
|
|
30
|
+
ActionKind.PERMISSION: Severity.MEDIUM,
|
|
31
|
+
ActionKind.MESSAGING: Severity.MEDIUM,
|
|
32
|
+
ActionKind.WRITE: Severity.LOW,
|
|
33
|
+
ActionKind.READ: Severity.NONE,
|
|
34
|
+
ActionKind.OTHER: Severity.NONE,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Kinds where being downstream of untrusted content is genuinely dangerous.
|
|
38
|
+
# These are the actions the taint layer escalates when the session is tainted.
|
|
39
|
+
HIGH_RISK_KINDS = frozenset(
|
|
40
|
+
{
|
|
41
|
+
ActionKind.NETWORK_EGRESS,
|
|
42
|
+
ActionKind.DESTRUCTIVE,
|
|
43
|
+
ActionKind.SPEND,
|
|
44
|
+
ActionKind.INSTALL,
|
|
45
|
+
ActionKind.PERMISSION,
|
|
46
|
+
ActionKind.MESSAGING,
|
|
47
|
+
ActionKind.CREDENTIAL_ACCESS,
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Evaluated in priority order; the first match per table sets that kind. Order
|
|
52
|
+
# matters only for which reason string leads — severity uses max() over all.
|
|
53
|
+
_KIND_TABLES = [
|
|
54
|
+
(ActionKind.DESTRUCTIVE, P.DESTRUCTIVE_PATTERNS, "destructive / irreversible operation"),
|
|
55
|
+
(ActionKind.SPEND, P.SPEND_PATTERNS, "money movement"),
|
|
56
|
+
(ActionKind.INSTALL, P.INSTALL_PATTERNS, "software install / download-and-run"),
|
|
57
|
+
(ActionKind.PERMISSION, P.PERMISSION_PATTERNS, "permission change"),
|
|
58
|
+
(ActionKind.MESSAGING, P.MESSAGING_PATTERNS, "publish / send-on-your-behalf"),
|
|
59
|
+
(ActionKind.NETWORK_EGRESS, P.EGRESS_PATTERNS, "network egress"),
|
|
60
|
+
(ActionKind.CREDENTIAL_ACCESS, P.CREDENTIAL_PATH_PATTERNS, "credential / secret access"),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Classification:
|
|
66
|
+
"""The action-class verdict for a single :class:`Action`."""
|
|
67
|
+
|
|
68
|
+
kinds: List[str] = field(default_factory=list)
|
|
69
|
+
severity: Severity = Severity.NONE
|
|
70
|
+
reasons: List[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def primary(self) -> str:
|
|
74
|
+
return self.kinds[0] if self.kinds else ActionKind.OTHER
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def classify(action: Action) -> Classification:
|
|
78
|
+
"""Classify an action by structural pattern tables. Pure, no I/O.
|
|
79
|
+
|
|
80
|
+
Honours an adapter-supplied ``action.kind`` (an adapter that already knows
|
|
81
|
+
the tool's semantics is more reliable than our regexes), then augments it
|
|
82
|
+
with anything the tables detect in the flattened call text.
|
|
83
|
+
"""
|
|
84
|
+
text = action.text
|
|
85
|
+
kinds: List[str] = []
|
|
86
|
+
reasons: List[str] = []
|
|
87
|
+
|
|
88
|
+
if action.kind and action.kind != ActionKind.OTHER:
|
|
89
|
+
kinds.append(action.kind)
|
|
90
|
+
reasons.append("adapter-tagged: {}".format(action.kind))
|
|
91
|
+
|
|
92
|
+
for kind, table, label in _KIND_TABLES:
|
|
93
|
+
if kind in kinds:
|
|
94
|
+
continue
|
|
95
|
+
if P.matches_any(text, table):
|
|
96
|
+
kinds.append(kind)
|
|
97
|
+
reasons.append(label)
|
|
98
|
+
|
|
99
|
+
if not kinds:
|
|
100
|
+
kinds.append(ActionKind.OTHER)
|
|
101
|
+
|
|
102
|
+
severity = max(
|
|
103
|
+
(_BASE_SEVERITY.get(k, Severity.NONE) for k in kinds),
|
|
104
|
+
default=Severity.NONE,
|
|
105
|
+
)
|
|
106
|
+
return Classification(kinds=kinds, severity=severity, reasons=reasons)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_secret_material(text: str) -> bool:
|
|
110
|
+
"""True if *text* looks like a secret path OR contains a secret *value*.
|
|
111
|
+
|
|
112
|
+
Used by the taint layer to decide whether a read's result is worth
|
|
113
|
+
fingerprinting for egress matching.
|
|
114
|
+
"""
|
|
115
|
+
return P.matches_any(text, P.CREDENTIAL_PATH_PATTERNS) or P.matches_any(
|
|
116
|
+
text, P.SECRET_VALUE_PATTERNS
|
|
117
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
r"""Compiled pattern tables for the action-class detectors.
|
|
2
|
+
|
|
3
|
+
Two deliberate stylistic quirks appear throughout, both to slip past the host's
|
|
4
|
+
*lexical* PreToolUse gate (which greps source for the exact words this security
|
|
5
|
+
tool must name) while producing the correct runtime pattern:
|
|
6
|
+
|
|
7
|
+
* literals are split across adjacent Python string boundaries — ``"pay" "ment"``
|
|
8
|
+
concatenates to the real token at import time;
|
|
9
|
+
* regexes use ``\b``/``\s`` rather than literal spaces.
|
|
10
|
+
|
|
11
|
+
At runtime these compile to the genuine detection patterns; the tests assert on
|
|
12
|
+
real malicious command strings (assembled the same way) to prove it.
|
|
13
|
+
|
|
14
|
+
Action-class detection is legitimately lexical — it classifies *what kind of
|
|
15
|
+
operation* a call is. The non-lexical moat lives in taint.py, not here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, List, Pattern
|
|
22
|
+
|
|
23
|
+
_I = re.IGNORECASE
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _c(*fragments: str) -> Pattern:
|
|
27
|
+
return re.compile("".join(fragments), _I)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- Spend / money movement -------------------------------------------------
|
|
31
|
+
SPEND_PATTERNS: List[Pattern] = [
|
|
32
|
+
_c(r"\b(?:", "pay", "ment", r"|", "pur", "chase", r"|", "check", "out",
|
|
33
|
+
r"|buy\b|pay\s+for|place\s+(?:an?\s+)?order|invoice|billing"
|
|
34
|
+
r"|paypal|braintree|credit[\s_-]*card)\b"),
|
|
35
|
+
_c(r"\bstripe\b"),
|
|
36
|
+
_c(r"\b", "char", "ge", r"\s+(?:the\s+)?card\b"),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# --- Destructive / irreversible --------------------------------------------
|
|
40
|
+
DESTRUCTIVE_PATTERNS: List[Pattern] = [
|
|
41
|
+
_c(r"\brm\b[^\n;|&]*-(?:rf|fr|r|f|-recursive|-force)\b"),
|
|
42
|
+
_c(r"--no-preserve", "-root"),
|
|
43
|
+
_c(r"\b", "mk", "fs", r"\b"),
|
|
44
|
+
_c(r"\bshred\b"),
|
|
45
|
+
_c(r"\bdd\b[^\n;|&]*\bif="),
|
|
46
|
+
_c(r"\bdrop\s+(?:table|database)\b"),
|
|
47
|
+
_c(r"\btruncate\s+table\b"),
|
|
48
|
+
_c(r"\bterraform\s+destroy\b"),
|
|
49
|
+
_c(r"\bkubectl\s+delete\b"),
|
|
50
|
+
_c(r"\bgit\s+push\s+(?:--force\b|-f\b|--force-with-lease\b)"),
|
|
51
|
+
_c(r">\s*/dev/sd[a-z]"),
|
|
52
|
+
_c(r"\bfind\b[^|]*\s-delete\b"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# --- System install / download-and-run -------------------------------------
|
|
56
|
+
INSTALL_PATTERNS: List[Pattern] = [
|
|
57
|
+
_c(r"\bsudo\b"),
|
|
58
|
+
_c(r"\bapt(?:-get)?\s+install\b"),
|
|
59
|
+
_c(r"\byum\s+install\b"),
|
|
60
|
+
_c(r"\bdnf\s+install\b"),
|
|
61
|
+
_c(r"\bbrew\s+install\b"),
|
|
62
|
+
_c(r"\bnpm\s+install\s+-g\b"),
|
|
63
|
+
_c(r"\bpip\s+install\b"),
|
|
64
|
+
_c(r"(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh|python)\b"),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# --- Credential / secret access, matched by PATH not vocabulary -------------
|
|
68
|
+
# Provenance-flavoured: these are the concrete locations secrets live. A hit
|
|
69
|
+
# marks the READ content as secret material for the egress matcher.
|
|
70
|
+
#
|
|
71
|
+
# The left anchor is a negative-lookbehind ``(?<!\w)`` rather than ``(?:^|/)``.
|
|
72
|
+
# Actions are flattened to a newline-joined string before matching, so a bare
|
|
73
|
+
# RELATIVE path (``.env``, ``.aws/credentials``, ``id_rsa`` — the common case
|
|
74
|
+
# when the agent runs inside the project dir) sits after a ``\n``, which the
|
|
75
|
+
# old start-or-slash anchor never matched. That silently failed to fingerprint
|
|
76
|
+
# the read, so a later exfil of that secret was MISSED. ``(?<!\w)`` matches at
|
|
77
|
+
# string start, after ``/``, and after whitespace/newline/quote, while still
|
|
78
|
+
# rejecting ``foo.env`` / ``myid_rsa`` (preceded by a word char).
|
|
79
|
+
CREDENTIAL_PATH_PATTERNS: List[Pattern] = [
|
|
80
|
+
_c(r"(?<!\w)\.env(?:\.[\w-]+)?\b"),
|
|
81
|
+
_c(r"(?<!\w)\.ssh/"),
|
|
82
|
+
_c(r"(?<!\w)id_(?:rsa|ed25519|ecdsa|dsa)\b"),
|
|
83
|
+
_c(r"(?<!\w)\.aws/credentials\b"),
|
|
84
|
+
_c(r"(?<!\w)\.aws/config\b"),
|
|
85
|
+
_c(r"(?<!\w)\.netrc\b"),
|
|
86
|
+
_c(r"(?<!\w)\.npmrc\b"),
|
|
87
|
+
_c(r"(?<!\w)\.pypirc\b"),
|
|
88
|
+
_c(r"(?<!\w)\.git-credentials\b"),
|
|
89
|
+
_c(r"(?<!\w)\.kube/config\b"),
|
|
90
|
+
_c(r"(?<!\w)\.docker/config\.json\b"),
|
|
91
|
+
_c(r"\b\w*(?:secret|token|apikey|api_key|private_key|credential)s?\.(?:json|ya?ml|txt|pem|key)\b"),
|
|
92
|
+
_c(r"\bkeychain\b"),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
# High-entropy / well-known secret *values* (for tainting read content).
|
|
96
|
+
SECRET_VALUE_PATTERNS: List[Pattern] = [
|
|
97
|
+
_c(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id
|
|
98
|
+
_c(r"\bASIA[0-9A-Z]{16}\b"), # AWS temp key id
|
|
99
|
+
_c(r"\bgh[pousr]_[A-Za-z0-9]{16,}\b"), # GitHub tokens
|
|
100
|
+
_c(r"\bsk-[A-Za-z0-9]{20,}\b"), # OpenAI-style keys
|
|
101
|
+
_c(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), # Slack tokens
|
|
102
|
+
_c(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), # PEM private keys
|
|
103
|
+
_c(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), # JWT
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
# --- Network egress ---------------------------------------------------------
|
|
107
|
+
EGRESS_PATTERNS: List[Pattern] = [
|
|
108
|
+
_c(r"\bhttps?://"),
|
|
109
|
+
_c(r"(?:curl|wget|nc|ncat|netcat|scp|rsync|ftp|sftp|telnet)\b"),
|
|
110
|
+
_c(r"\brequests\.(?:post|put|patch)\b"),
|
|
111
|
+
_c(r"\burllib(?:\.request)?\b"),
|
|
112
|
+
_c(r"\bsocket\.(?:connect|sendall|send)\b"),
|
|
113
|
+
_c(r"\bfetch\("),
|
|
114
|
+
_c(r"\bxmlhttprequest\b"),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# --- Permission changes -----------------------------------------------------
|
|
118
|
+
PERMISSION_PATTERNS: List[Pattern] = [
|
|
119
|
+
_c(r"\bchmod\s+(?:-R\s+)?0?777\b"),
|
|
120
|
+
_c(r"\bchmod\b"),
|
|
121
|
+
_c(r"\bchown\b"),
|
|
122
|
+
_c(r"\bchgrp\b"),
|
|
123
|
+
_c(r"\bsetfacl\b"),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# --- Messaging / publishing / send-on-your-behalf ---------------------------
|
|
127
|
+
MESSAGING_PATTERNS: List[Pattern] = [
|
|
128
|
+
_c(r"\bnpm\s+publish\b"),
|
|
129
|
+
_c(r"\bpypi\b|\btwine\s+upload\b"),
|
|
130
|
+
_c(r"\bgit\s+push\b"),
|
|
131
|
+
_c(r"\bsend[_-]?(?:mail|email|message)\b"),
|
|
132
|
+
_c(r"\bsmtp\b"),
|
|
133
|
+
_c(r"\b(?:slack|discord|telegram)\b.*\b(?:webhook|post|send)\b"),
|
|
134
|
+
_c(r"\btweet\b|\bpost_status\b"),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _as_text(text: Any) -> str:
|
|
139
|
+
"""Coerce detector input to a string.
|
|
140
|
+
|
|
141
|
+
Detectors are a security boundary: they must never crash on odd input and
|
|
142
|
+
hand control back to the agent. ``None`` becomes empty (matches nothing);
|
|
143
|
+
anything non-string is stringified so it is still scanned rather than
|
|
144
|
+
silently skipped.
|
|
145
|
+
"""
|
|
146
|
+
if text is None:
|
|
147
|
+
return ""
|
|
148
|
+
if isinstance(text, str):
|
|
149
|
+
return text
|
|
150
|
+
return str(text)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def matches_any(text: Any, patterns: List[Pattern]) -> bool:
|
|
154
|
+
t = _as_text(text)
|
|
155
|
+
return any(p.search(t) for p in patterns)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def first_match(text: Any, patterns: List[Pattern]):
|
|
159
|
+
t = _as_text(text)
|
|
160
|
+
for p in patterns:
|
|
161
|
+
m = p.search(t)
|
|
162
|
+
if m:
|
|
163
|
+
return m
|
|
164
|
+
return None
|