oc_connect
Loading...
Searching...
No Matches
egress.py
Go to the documentation of this file.
1"""Plain-text outbound scanner derived from OCT's secret-scanner heuristics.
2
3Provenance: the pattern vocabulary and Shannon-entropy formula are ported from
4``oct/oct/tools/secret_scanner.py``. OCT's scanner itself is intentionally not
5imported: it walks Python AST nodes and cannot certify ordinary message prose.
6This module scans content tokens and well-known credential shapes instead.
7
8Dependencies
9------------
10- oc_diagnostics (_dbg structured logging of text length and finding count per scan, and of each block by finding count only)
11"""
12from __future__ import annotations
13
14import math
15import re
16from collections import Counter
17from dataclasses import dataclass
18
19from oc_diagnostics import _dbg
20
21_MIN_ENTROPY_LENGTH = 24
22_ENTROPY_THRESHOLD = 4.5
23
24_GITHUB_SHAPE = re.compile("gh" + "[pousr]_" + "[A-Za-z0-9]" + "{20,}")
25_AWS_SHAPE = re.compile("AKIA" + "[0-9A-Z]" + "{16}")
26_OPENAI_SHAPE = re.compile("sk-" + "[A-Za-z0-9]" + "{20,}")
27_TELEGRAM_SHAPE = re.compile("[0-9]" + "{6,12}:" + "[A-Za-z0-9_-]" + "{30,}")
28_TOKEN_SHAPE = re.compile("[A-Za-z0-9_" + "+/=-]" + "{24,}")
29
30_LABELS = (
31 "api[ _-]?key",
32 "access[ _-]?token",
33 "auth[ _-]?token",
34 "client[ _-]?secret",
35 "password",
36 "private[ _-]?key",
37 "refresh[ _-]?token",
38 "secret",
39 "token",
40)
41_NAMED_VALUE_SHAPE = re.compile(
42 "\\b(?:"
43 + "|".join(_LABELS)
44 + ")\\b"
45 + "\\s*[:=]\\s*"
46 + "[\"']?"
47 + "([A-Za-z0-9_"
48 + "+/=.:-]{8,})",
49 re.IGNORECASE,
50)
51
52THREE_OPTIONS_ESCALATION = (
53 "Outbound message blocked: a possible credential was detected and nothing was sent.\n"
54 "1. Remove or redact the sensitive value, then retry.\n"
55 "2. Ask the operator to review a possible false positive through the scoped waiver workflow.\n"
56 "3. Cancel the send and keep the message local."
57)
58
59
60@dataclass(frozen=True)
62 """A non-sensitive description of one blocked span."""
63
64 kind: str
65 start: int
66 end: int
67 entropy: float | None = None
68
69
70class EgressBlockedError(PermissionError):
71 """Outbound content failed the mandatory edge scan."""
72
73 def __init__(self, findings: tuple[EgressFinding, ...]) -> None:
74 self.findings = findings
75 kinds = ", ".join(sorted({finding.kind for finding in findings}))
76 super().__init__(f"{THREE_OPTIONS_ESCALATION}\nFinding classes: {kinds}.")
77
78
79def shannon_entropy(value: str) -> float:
80 """Return Shannon entropy in bits per character."""
81 if not value:
82 return 0.0
83 counts = Counter(value)
84 length = len(value)
85 return -sum(
86 (count / length) * math.log2(count / length)
87 for count in counts.values()
88 )
89
90
91def scan_text(text: str) -> tuple[EgressFinding, ...]:
92 """Return content-oriented findings without returning matched content."""
93 if not isinstance(text, str):
94 raise TypeError("outbound content must be text")
95 findings: list[EgressFinding] = []
96 for kind, shape in (
97 ("github-token-shape", _GITHUB_SHAPE),
98 ("aws-key-shape", _AWS_SHAPE),
99 ("openai-key-shape", _OPENAI_SHAPE),
100 ("telegram-token-shape", _TELEGRAM_SHAPE),
101 ):
102 findings.extend(
103 EgressFinding(kind=kind, start=match.start(), end=match.end())
104 for match in shape.finditer(text)
105 )
106 findings.extend(
108 kind="named-sensitive-value",
109 start=match.start(1),
110 end=match.end(1),
111 )
112 for match in _NAMED_VALUE_SHAPE.finditer(text)
113 )
114
115 covered = {(finding.start, finding.end) for finding in findings}
116 for match in _TOKEN_SHAPE.finditer(text):
117 span = (match.start(), match.end())
118 if span in covered or any(
119 start <= span[0] and span[1] <= end for start, end in covered
120 ):
121 continue
122 candidate = match.group(0)
123 if len(candidate) < _MIN_ENTROPY_LENGTH:
124 continue
125 entropy = shannon_entropy(candidate)
126 if entropy >= _ENTROPY_THRESHOLD:
127 findings.append(
129 kind="high-entropy-token",
130 start=span[0],
131 end=span[1],
132 entropy=entropy,
133 )
134 )
135
136 unique = {
137 (finding.kind, finding.start, finding.end): finding for finding in findings
138 }
139 result = tuple(unique[key] for key in sorted(unique))
140 _dbg(
141 "OC_CONNECT.EGRESS",
142 "scan_text",
143 f"scanned outbound text length={len(text)} findings={len(result)}",
144 3,
145 )
146 return result
147
148
149def require_safe(text: str) -> None:
150 """Return only for clean text; otherwise block with three operator options."""
151 findings = scan_text(text)
152 if findings:
153 _dbg(
154 "OC_CONNECT.EGRESS",
155 "require_safe",
156 # OCT-LINT: disable=no_hardcoded_secrets reason="Security diagnostic prose reports only a finding count; no credential content is present"
157 f"SECURITY_ERROR: blocked outbound text findings={len(findings)}",
158 1,
159 override=True,
160 )
161 raise EgressBlockedError(findings)
162
163
164__all__ = [
165 "EgressBlockedError",
166 "EgressFinding",
167 "THREE_OPTIONS_ESCALATION",
168 "require_safe",
169 "scan_text",
170 "shannon_entropy",
171]
None __init__(self, tuple[EgressFinding,...] findings)
Definition egress.py:73
tuple[EgressFinding,...] scan_text(str text)
Definition egress.py:91
None require_safe(str text)
Definition egress.py:149
float shannon_entropy(str value)
Definition egress.py:79