diff-sentry 0.4.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.
@@ -0,0 +1,579 @@
1
+ """Deterministic indicator detectors — pure-Python, in-loop-safe, NO subprocess.
2
+
3
+ `scan_indicators(text)` runs a suite of pattern detectors over a change (or a decoded region) and
4
+ returns structured `IndicatorHit`s — FACTS, never a model opinion. It is used TWO ways:
5
+
6
+ 1. **Host-side BASELINE** at ingest (`cli`/`normalize`), stashed in the run_start meta, so the
7
+ deterministic evidence is in the trace even if the planner never scans — the planner cannot suppress
8
+ a hit by omission (assemble unions baseline ∪ tool hits).
9
+ 2. **An RLM TOOL** (`make_indicator_tool`) the planner calls on a specific region it decoded or wants
10
+ double-checked; each call records a `tool_call` carrying the FULL structured hits.
11
+
12
+ Why pure-Python and no subprocess: a subprocess spawned from inside the live dspy.RLM/asyncio process
13
+ reliably hangs (a hard-won rlm-harness-consumer lesson). Any heavier external scanner belongs host-side,
14
+ post-run — never as an in-loop tool.
15
+
16
+ No dspy import; the tool wrapper imports only `rlm_harness.trace.record_tool_call`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import binascii
23
+ import hashlib
24
+ import math
25
+ import re
26
+ from collections.abc import Callable
27
+
28
+ from .schema import IndicatorHit
29
+
30
+ _MAX_EVIDENCE = 240 # bounded snippet — never leak the whole diff into a hit
31
+
32
+
33
+ def _snip(text: str, start: int, end: int, *, pad: int = 40) -> str:
34
+ """A bounded, whitespace-collapsed window around [start, end) for an IndicatorHit's evidence."""
35
+ lo = max(0, start - pad)
36
+ hi = min(len(text), end + pad)
37
+ window = re.sub(r"\s+", " ", text[lo:hi]).strip()
38
+ return window[:_MAX_EVIDENCE]
39
+
40
+
41
+ def mint_id(rule: str, evidence: str) -> str:
42
+ """A DETERMINISTIC hit id — stable across the host-side baseline scan and an in-loop tool scan of
43
+ the same content, so the two de-duplicate to one union member. No Date/random (both unavailable and
44
+ would break replay determinism)."""
45
+ digest = hashlib.sha1(f"{rule}\x00{evidence}".encode("utf-8", "replace")).hexdigest()[:8]
46
+ return f"ind-{rule}-{digest}"
47
+
48
+
49
+ def _hit(rule: str, severity: str, title: str, evidence: str, *, location: str = "",
50
+ decoded: str | None = None) -> IndicatorHit:
51
+ return IndicatorHit(id=mint_id(rule, evidence), rule=rule, severity=severity, title=title,
52
+ evidence=evidence[:_MAX_EVIDENCE], location=location, decoded=decoded)
53
+
54
+
55
+ def _shannon_entropy(s: str) -> float:
56
+ if not s:
57
+ return 0.0
58
+ counts: dict[str, int] = {}
59
+ for ch in s:
60
+ counts[ch] = counts.get(ch, 0) + 1
61
+ n = len(s)
62
+ return -sum((c / n) * math.log2(c / n) for c in counts.values())
63
+
64
+
65
+ # ── individual detectors: each takes the text and yields IndicatorHit(s) ──────────────────────────
66
+
67
+ _IFS_RE = re.compile(r"\$\{?IFS\}?")
68
+ _CMD_SUBST_RE = re.compile(r"\$\([^)]{1,200}\)|`[^`]{1,200}`")
69
+ _CURL_PIPE_RE = re.compile(r"\b(?:curl|wget)\b[^\n|]{0,300}\|\s*(?:ba)?sh\b", re.IGNORECASE)
70
+ _CHMOD_EXEC_RE = re.compile(r"chmod\s+\+x[^\n]{0,120}", re.IGNORECASE)
71
+ _B64_RE = re.compile(r"[A-Za-z0-9+/]{24,}={0,2}")
72
+ # Exfiltration: environment/secret READS and a secret NAME piped to a network sink. Deliberately NOT the
73
+ # bare token name alone (a legit workflow uses `${{ secrets.GITHUB_TOKEN }}`) and NOT `export X=$Y`
74
+ # (`export PATH=$PATH:…` is a normal idiom) — the secret must actually reach an exfil verb to fire.
75
+ # `/proc/<pid>/mem` (and maps) reading ANOTHER process is the runner-memory-dump technique from the
76
+ # tj-actions supply-chain incident; the secret-name alternation covers the CI token families that class of
77
+ # attack harvests. `self/maps` is deliberately EXCLUDED — parsing your own memory map is textbook legit
78
+ # native tooling (crash reporters, profilers, sanitizers); the cross-process (`\d+`) reads are the tell.
79
+ _EXFIL_RE = re.compile(
80
+ r"printenv|env\s*\||/proc/(?:self/(?:environ|mem)|\d+/(?:environ|mem|maps))|~/\.aws/credentials|"
81
+ r"(?:AWS_SECRET_ACCESS_KEY|GITHUB_TOKEN|NPM_TOKEN|NODE_AUTH_TOKEN|PYPI_TOKEN|ACTIONS_RUNTIME_TOKEN|"
82
+ r"ACTIONS_ID_TOKEN_REQUEST_TOKEN|CARGO_REGISTRY_TOKEN|TWINE_PASSWORD|NUGET_API_KEY)\b"
83
+ r"[^\n]{0,80}?(?:curl|wget|nc\b|https?://|\|\s*(?:ba)?sh)",
84
+ re.IGNORECASE)
85
+ _CI_PATH_RE = re.compile(r"(?:^|[\s\"'/])(\.github/workflows/[^\s\"']+|CODEOWNERS)\b")
86
+ _RAW_IP_URL_RE = re.compile(r"https?://\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?/\S*")
87
+
88
+ # Exfil sinks come in TWO precision tiers. The enrichment-fetch allowlist (MF2) owns "org-owned vs
89
+ # external"; THIS is the detection side — a NAMED list, not "any external host".
90
+ # Tier 1 — OAST / interaction / request-bin services whose ONLY purpose is receiving out-of-band callbacks
91
+ # (the tj-actions gist/webhook-callback shape). A committed reference has NO legitimate use → `high`.
92
+ _OAST_DOMAINS = (
93
+ "webhook.site", "requestbin.com", "requestbin.net", "burpcollaborator.net",
94
+ "oast.pro", "oast.live", "oast.site", "oast.online", "oast.fun", "oast.me",
95
+ "interactsh.com", "dnslog.cn", "hookbin.com",
96
+ )
97
+ _OAST_DOMAIN_RE = re.compile(r"\b(?:" + "|".join(re.escape(d) for d in _OAST_DOMAINS) + r")\b", re.IGNORECASE)
98
+ # Tier 2 — dev tunnels / API-mock services. Heavily ABUSED as exfil sinks, BUT with mainstream legitimate
99
+ # use (smee.io is GitHub's own Probot webhook proxy; ngrok/beeceptor appear in ordinary dev READMEs). So a
100
+ # corroborating `medium` — BELOW the signal floor — never a standalone signal (the FP-safe tier).
101
+ _TUNNEL_DOMAINS = (
102
+ "ngrok.io", "ngrok-free.app", "ngrok.app", "ngrok.dev", "smee.io",
103
+ "beeceptor.com", "pipedream.net", "trycloudflare.com", "serveo.net",
104
+ )
105
+ _TUNNEL_DOMAIN_RE = re.compile(r"\b(?:" + "|".join(re.escape(d) for d in _TUNNEL_DOMAINS) + r")\b", re.IGNORECASE)
106
+
107
+ # CI-bypass markers: a commit/PR that skips the CI checks a malicious change would otherwise trip. Legit
108
+ # uses exist (a docs-only commit), so `medium` — BELOW the signal floor: a corroborating tell, never a
109
+ # standalone smoking gun (mirrors `workflow-tamper`'s tuning; a real payload fires its own high rule).
110
+ # GitHub honors [skip ci]/[ci skip]/[no ci]/[skip actions]/[actions skip] and the skip-checks trailer.
111
+ _CI_BYPASS_RE = re.compile(
112
+ r"\[\s*(?:skip[\s_-]*ci|ci[\s_-]*skip|no[\s_-]*ci|skip[\s_-]*actions|actions[\s_-]*skip)\s*\]"
113
+ r"|skip-checks\s*:\s*true", re.IGNORECASE)
114
+
115
+ # Workflow permission escalation: an over-broad `permissions: write-all` grant — the "permission model
116
+ # change" the malicious-PR research flags. Rare and high-signal, like a CODEOWNERS reassignment → `high`.
117
+ # LINE-ANCHORED (MULTILINE) on the YAML shorthand and NOT on a diff `-` deletion line, so REMOVING a
118
+ # write-all (the recommended hardening after tj-actions) and prose mentions do NOT fire.
119
+ _WRITE_ALL_RE = re.compile(r"^[+ ]?\s*permissions\s*:\s*write-all\b", re.IGNORECASE | re.MULTILINE)
120
+
121
+ # ── Workflow-CONFIGURATION escalation (distinct from `workflow-tamper`, which only asks "was a workflow
122
+ # file touched?"). This asks what the workflow BECOMES. `pull_request_target`/`workflow_run` run a fork
123
+ # PR's workflow with the BASE repo's token and secrets; on their own that is a legitimate pattern (label
124
+ # or comment bots that never touch the PR's code), so alone they are a sub-floor `medium`, consistent
125
+ # with `workflow-tamper`'s tuning. The lethal pairing is that trigger PLUS an explicit checkout of the PR
126
+ # HEAD — attacker-authored code executing inside the privileged context. That is the ROOT CAUSE of the
127
+ # AsyncAPI "Miasma" supply-chain compromise (a fork PR harvested the org-admin token out of the runner),
128
+ # and it has no safe reading → `critical`.
129
+ _PRIV_TRIGGER_RE = re.compile(r"^[+ ]?\s*(?:pull_request_target|workflow_run)\s*:", re.MULTILINE)
130
+ _PR_HEAD_REF_RE = re.compile(
131
+ r"github\.event\.pull_request\.head\.(?:sha|ref)|github\.event\.workflow_run\.head_(?:sha|branch)|"
132
+ r"refs/pull/[^\s\"']*/(?:head|merge)", re.IGNORECASE)
133
+
134
+ # Diff-PRESENTATION evasion — attacks aimed at the human reading the diff, not at the runtime. Miasma
135
+ # prepended ~700 spaces to shove its payload off the right edge of a standard diff viewer. No source
136
+ # formatter indents past ~60 columns, so a 200-space run before real content is unambiguous.
137
+ # NOT anchored to the line start. Miasma PREPENDED its ~700 spaces, but a run of that size sitting
138
+ # mid-line shoves the tail of the line off the viewport just as effectively, and the concealment is the
139
+ # thing being detected — not where it happens to sit. No formatter indents or aligns past ~60 columns.
140
+ _WS_SHOVE_RE = re.compile(r"[ \t]{200,}(?=\S)")
141
+ # Trojan Source: bidi overrides/isolates reorder rendered text away from what the compiler sees; ZWSP
142
+ # hides content outright. Deliberately EXCLUDES U+200E/U+200F (LRM/RLM) and U+200C/U+200D (ZWNJ/ZWJ),
143
+ # named here as CODEPOINTS rather than pasted literally — those carry real linguistic and emoji-
144
+ # sequence use and would fire on ordinary prose, and a comment full of invisible characters is the
145
+ # very thing this rule exists to catch (ruff PLE2502 rejects it, correctly).
146
+ # those carry real linguistic and emoji-sequence use, and would fire on ordinary prose.
147
+ _INVISIBLE_RE = re.compile("[\u202a-\u202e\u2066-\u2069\u200b]") # escaped on purpose: literal
148
+ # invisible characters in this file would be unreadable, un-reviewable, and would trip this very rule.
149
+
150
+ # Node/npm execution primitives. Miasma's payload was JS end-to-end and our suite was shell/YAML-shaped,
151
+ # so every stage of it ran silently past the deterministic floor. Tuned rather than uniformly paranoid:
152
+ # `child_process` and `eval` appear in legitimate tooling constantly, so only the DETACHED spawn (a
153
+ # process deliberately outliving the parent — the stage-1 loader shape) and inline `node -e` execution
154
+ # reach the floor; a bare dynamic eval is a sub-floor corroborator.
155
+ _JS_CHILD_PROC_RE = re.compile(
156
+ r"child_process|\b(?:execSync|spawnSync|execFileSync|execFile|spawn|fork)\s*\(", re.IGNORECASE)
157
+ _JS_DETACHED_RE = re.compile(r"detached\s*:\s*true|\.unref\s*\(\s*\)")
158
+ _NODE_INLINE_EXEC_RE = re.compile(
159
+ r"\bnode\s+-e\b|process\.execPath[^\n]{0,120}['\"]-e['\"]|['\"]-e['\"][^\n]{0,120}process\.execPath")
160
+ _DYNAMIC_EVAL_RE = re.compile(r"\beval\s*\(|new\s+Function\s*\(")
161
+ # The dropper shape: pull bytes off the network AND commit them to disk. Either half alone is ordinary
162
+ # (every HTTP client fetches; every build writes files); together in one change they are a stage-2 loader.
163
+ _JS_NET_FETCH_RE = re.compile(
164
+ r"\bfetch\s*\(|\baxios\b|require\s*\(\s*['\"]https?['\"]\s*\)|\bhttps?\.(?:get|request)\s*\(",
165
+ re.IGNORECASE)
166
+ _FILE_WRITE_RE = re.compile(r"writeFileSync|createWriteStream|\bfs\.write", re.IGNORECASE)
167
+
168
+ # Tier 3 — content-addressed / permaweb gateways. Miasma staged its second-stage binary on IPFS. Same
169
+ # FP-safe reasoning as the dev tunnels: these have real mainstream use (web3 apps, NFT metadata), so a
170
+ # reference alone is a sub-floor `medium`. When the change actually FETCHES from one and writes the
171
+ # result to disk, `remote-fetch-to-disk` fires on its own and carries the signal.
172
+ _CONTENT_HOST_DOMAINS = (
173
+ "ipfs.io", "dweb.link", "cloudflare-ipfs.com", "gateway.pinata.cloud", "ipfs.infura.io",
174
+ "nftstorage.link", "w3s.link", "arweave.net", "ipfs.dweb.link",
175
+ )
176
+ _CONTENT_HOST_RE = re.compile(
177
+ r"\b(?:" + "|".join(re.escape(d) for d in _CONTENT_HOST_DOMAINS) + r")\b", re.IGNORECASE)
178
+
179
+ # javascript-obfuscator's signature identifier mangling (`_0x1dd48b`). Terser/uglify emit SHORT names
180
+ # (`a`, `t`), never this form, so it is a strong obfuscation tell that base64 detection cannot see —
181
+ # Miasma's 15k-char `validator.js` payload used exactly this and fired nothing in our suite.
182
+ _HEX_IDENT_RE = re.compile(r"_0x[0-9a-fA-F]{4,}")
183
+
184
+ # Prompt-injection phrases — the class of payload hackerbot-claw aimed at a claude-code-action workflow.
185
+ _INJECTION_PHRASES = (
186
+ "ignore previous instructions", "ignore all previous", "disregard the above",
187
+ "disregard previous", "system prompt", "you are now", "new instructions:",
188
+ "override your instructions", "bulk-label", "label all issues", "as an ai",
189
+ "do not follow your instructions", "forget your instructions",
190
+ )
191
+ _INJECTION_RE = re.compile("|".join(re.escape(p) for p in _INJECTION_PHRASES), re.IGNORECASE)
192
+
193
+
194
+ def _scan_shell(text: str) -> list[IndicatorHit]:
195
+ hits: list[IndicatorHit] = []
196
+ for m in _IFS_RE.finditer(text):
197
+ hits.append(_hit("ci-shell-injection", "high",
198
+ "`${IFS}` shell field-separator trick (space-evasion in a command)",
199
+ _snip(text, m.start(), m.end())))
200
+ for m in _CURL_PIPE_RE.finditer(text):
201
+ hits.append(_hit("curl-pipe-shell", "critical",
202
+ "pipe-to-shell download-and-execute (`curl … | bash`)",
203
+ _snip(text, m.start(), m.end())))
204
+ for m in _CHMOD_EXEC_RE.finditer(text):
205
+ hits.append(_hit("make-executable", "medium", "makes a file executable (`chmod +x`)",
206
+ _snip(text, m.start(), m.end())))
207
+ subs = list(_CMD_SUBST_RE.finditer(text))
208
+ for m in subs[:8]: # cap: a diff can have many; the first few are enough to flag
209
+ hits.append(_hit("command-substitution", "medium", "shell command substitution (`$(…)` / backticks)",
210
+ _snip(text, m.start(), m.end())))
211
+ return hits
212
+
213
+
214
+ def _scan_obfuscation(text: str) -> list[IndicatorHit]:
215
+ """Decode base64 blobs and RE-SCAN the decoded bytes for shell/exfil payloads (de-obfuscation)."""
216
+ hits: list[IndicatorHit] = []
217
+ seen: set[str] = set()
218
+ for m in _B64_RE.finditer(text):
219
+ blob = m.group(0)
220
+ if blob in seen:
221
+ continue
222
+ seen.add(blob)
223
+ try:
224
+ decoded = base64.b64decode(blob + "=" * (-len(blob) % 4), validate=True).decode("utf-8", "replace")
225
+ except (binascii.Error, ValueError):
226
+ continue
227
+ if not decoded.isprintable() and "\n" not in decoded:
228
+ continue
229
+ # Only flag base64 that DECODES to something suspicious — a plain data blob isn't an indicator.
230
+ # (Rescan shell/exfil/URL/callback-domain payloads; CI-skip and permission markers aren't usefully
231
+ # base64-wrapped, so they're not in the inner set.)
232
+ inner = (_scan_shell(decoded) + _scan_urls(decoded) + _scan_exfil(decoded)
233
+ + _scan_exfil_domains(decoded) + _scan_js_exec(decoded))
234
+ if inner:
235
+ worst = max((h.severity for h in inner), key=lambda s: _sev_rank(s))
236
+ hits.append(_hit("obfuscated-payload", worst,
237
+ "base64 blob decodes to a shell/exfil payload",
238
+ _snip(text, m.start(), m.end()), decoded=decoded[:_MAX_EVIDENCE]))
239
+ return hits
240
+
241
+
242
+ def _scan_exfil(text: str) -> list[IndicatorHit]:
243
+ return [_hit("data-exfiltration", "high", "reads secrets/credentials/environment",
244
+ _snip(text, m.start(), m.end()))
245
+ for m in _EXFIL_RE.finditer(text)]
246
+
247
+
248
+ def _scan_urls(text: str) -> list[IndicatorHit]:
249
+ return [_hit("raw-ip-url", "medium", "hardcoded raw-IP URL (possible C2 / non-standard host)",
250
+ _snip(text, m.start(), m.end()))
251
+ for m in _RAW_IP_URL_RE.finditer(text)]
252
+
253
+
254
+ def _scan_exfil_domains(text: str) -> list[IndicatorHit]:
255
+ hits = [_hit("exfil-infrastructure", "high",
256
+ "references a known exfil / OAST callback service (no legitimate use in a change)",
257
+ _snip(text, m.start(), m.end()))
258
+ for m in _OAST_DOMAIN_RE.finditer(text)]
259
+ hits += [_hit("dev-tunnel-endpoint", "medium",
260
+ "references a dev-tunnel / API-mock service (abused as an exfil sink)",
261
+ _snip(text, m.start(), m.end()))
262
+ for m in _TUNNEL_DOMAIN_RE.finditer(text)]
263
+ hits += [_hit("content-addressed-host", "medium",
264
+ "references an IPFS / permaweb gateway (abused to host a stage-2 payload)",
265
+ _snip(text, m.start(), m.end()))
266
+ for m in _CONTENT_HOST_RE.finditer(text)]
267
+ return hits
268
+
269
+
270
+ def _scan_ci_bypass(text: str) -> list[IndicatorHit]:
271
+ return [_hit("ci-bypass", "medium",
272
+ "CI-skip marker (evades the checks a malicious change would trip)",
273
+ _snip(text, m.start(), m.end()))
274
+ for m in _CI_BYPASS_RE.finditer(text)]
275
+
276
+
277
+ def _scan_workflow_perms(text: str) -> list[IndicatorHit]:
278
+ return [_hit("workflow-permission-escalation", "high",
279
+ "over-broad workflow permission grant (`write-all`)",
280
+ _snip(text, m.start(), m.end()))
281
+ for m in _WRITE_ALL_RE.finditer(text)]
282
+
283
+
284
+ def _scan_workflow_config(text: str) -> list[IndicatorHit]:
285
+ """What a workflow BECOMES, not merely that one was touched (`workflow-tamper`'s job)."""
286
+ trigger = _PRIV_TRIGGER_RE.search(text)
287
+ if not trigger:
288
+ return []
289
+ head = _PR_HEAD_REF_RE.search(text)
290
+ if head:
291
+ return [_hit("pwn-request", "critical",
292
+ "privileged fork trigger checks out the PR HEAD — untrusted code with the base "
293
+ "repo's token/secrets",
294
+ _snip(text, head.start(), head.end()))]
295
+ return [_hit("privileged-fork-trigger", "medium",
296
+ "`pull_request_target`/`workflow_run` — a fork PR runs in a privileged context",
297
+ _snip(text, trigger.start(), trigger.end()))]
298
+
299
+
300
+ def _scan_diff_evasion(text: str) -> list[IndicatorHit]:
301
+ """Payloads aimed at the human READING the diff rather than at the runtime."""
302
+ hits: list[IndicatorHit] = []
303
+ m = _WS_SHOVE_RE.search(text)
304
+ if m:
305
+ hits.append(_hit("diff-viewport-evasion", "high",
306
+ "content shoved past the diff viewport by a long whitespace run",
307
+ f"{m.end() - m.start()} leading whitespace chars before content: "
308
+ f"{_snip(text, m.end(), m.end(), pad=80)}"))
309
+ inv = _INVISIBLE_RE.search(text)
310
+ if inv:
311
+ hits.append(_hit("invisible-unicode", "high",
312
+ "bidi-override / zero-width characters (rendered text differs from real code)",
313
+ f"U+{ord(inv.group(0)):04X} at offset {inv.start()}: "
314
+ f"{_snip(text, inv.start(), inv.end())}"))
315
+ return hits
316
+
317
+
318
+ def _scan_js_exec(text: str) -> list[IndicatorHit]:
319
+ """Node/npm execution primitives — the ecosystem our shell/YAML-shaped rules were blind to."""
320
+ hits: list[IndicatorHit] = []
321
+ detached = _JS_DETACHED_RE.search(text)
322
+ if detached and _JS_CHILD_PROC_RE.search(text):
323
+ hits.append(_hit("detached-process-spawn", "high",
324
+ "spawns a DETACHED child process (outlives the parent — stage-1 loader shape)",
325
+ _snip(text, detached.start(), detached.end())))
326
+ for m in _NODE_INLINE_EXEC_RE.finditer(text):
327
+ hits.append(_hit("inline-code-exec", "high",
328
+ "executes code passed inline to node (`node -e`)",
329
+ _snip(text, m.start(), m.end())))
330
+ break
331
+ fetch = _JS_NET_FETCH_RE.search(text)
332
+ write = _FILE_WRITE_RE.search(text)
333
+ if fetch and write:
334
+ hits.append(_hit("remote-fetch-to-disk", "high",
335
+ "fetches from the network AND writes the result to disk (dropper shape)",
336
+ _snip(text, fetch.start(), fetch.end())))
337
+ ev = _DYNAMIC_EVAL_RE.search(text)
338
+ if ev:
339
+ hits.append(_hit("dynamic-code-eval", "medium",
340
+ "dynamic code evaluation (`eval` / `new Function`)",
341
+ _snip(text, ev.start(), ev.end())))
342
+ return hits
343
+
344
+
345
+ def _scan_hex_identifiers(text: str) -> list[IndicatorHit]:
346
+ """javascript-obfuscator's `_0x…` mangling — obfuscation that base64 detection cannot see."""
347
+ found = _HEX_IDENT_RE.findall(text)
348
+ if len(set(found)) >= 3 and len(found) >= 8:
349
+ return [_hit("obfuscated-identifiers", "high",
350
+ "hex-mangled identifiers (`_0x…`) — machine-obfuscated JavaScript",
351
+ f"{len(set(found))} distinct over {len(found)} uses: "
352
+ f"{', '.join(sorted(set(found))[:6])}")]
353
+ return []
354
+
355
+
356
+ def _scan_ci_paths(text: str) -> list[IndicatorHit]:
357
+ hits: list[IndicatorHit] = []
358
+ for m in _CI_PATH_RE.finditer(text):
359
+ path = m.group(1)
360
+ is_co = path == "CODEOWNERS"
361
+ # CODEOWNERS reassignment is a rare, high-signal governance takeover (exactly hackerbot-claw's
362
+ # move) → `high`. A plain workflow-file EDIT is common and legitimate → `medium` (below the
363
+ # signal floor), so a benign workflow PR does not force a SIEM signal; a real payload inside the
364
+ # workflow still fires the high/critical shell/obfuscation rules and forces the signal itself.
365
+ rule = "codeowners-tamper" if is_co else "workflow-tamper"
366
+ sev = "high" if is_co else "medium"
367
+ title = ("reassigns code ownership (`CODEOWNERS`)" if is_co
368
+ else f"edits a CI workflow file (`{path}`)")
369
+ hits.append(_hit(rule, sev, title, _snip(text, m.start(), m.end()), location=path))
370
+ return hits
371
+
372
+
373
+ def _scan_injection(text: str) -> list[IndicatorHit]:
374
+ return [_hit("prompt-injection", "high",
375
+ "prompt-injection phrasing aimed at an LLM reviewer/workflow",
376
+ _snip(text, m.start(), m.end()))
377
+ for m in _INJECTION_RE.finditer(text)]
378
+
379
+
380
+ def _scan_entropy(text: str) -> list[IndicatorHit]:
381
+ """A single flag for a very-high-entropy long token (packed/encrypted blob), capped to one hit."""
382
+ for tok in re.findall(r"\S{60,}", text):
383
+ if _shannon_entropy(tok) >= 4.5:
384
+ return [_hit("high-entropy-blob", "low", "very high-entropy long token (packed/encoded data)",
385
+ tok[:_MAX_EVIDENCE])]
386
+ return []
387
+
388
+
389
+ def _sev_rank(sev: str) -> int:
390
+ from .schema import severity_rank
391
+ return severity_rank(sev)
392
+
393
+
394
+ def scan_indicators(text: str, *, location: str = "") -> list[IndicatorHit]:
395
+ """Run every detector over `text` and return the DEDUPED union of hits (by deterministic id).
396
+
397
+ `location` labels hits that don't carry their own (a filename/region tag). Deterministic and
398
+ side-effect-free — safe to run host-side (baseline) and in-loop (tool)."""
399
+ text = text or ""
400
+ all_hits = (
401
+ _scan_shell(text) + _scan_obfuscation(text) + _scan_exfil(text) + _scan_urls(text)
402
+ + _scan_exfil_domains(text) + _scan_ci_paths(text) + _scan_ci_bypass(text)
403
+ + _scan_workflow_perms(text) + _scan_injection(text) + _scan_entropy(text)
404
+ + _scan_workflow_config(text) + _scan_diff_evasion(text) + _scan_js_exec(text)
405
+ + _scan_hex_identifiers(text)
406
+ )
407
+ deduped: dict[str, IndicatorHit] = {}
408
+ for h in all_hits:
409
+ if location and not h.location:
410
+ h = h.model_copy(update={"location": location})
411
+ deduped.setdefault(h.id, h)
412
+ return list(deduped.values())
413
+
414
+
415
+ # ── provenance detectors: FACTS from host-side `gh api` ingest enrichment, NOT untrusted text ─────────
416
+ # These take the structured `provenance` dict (author type/association, account age, commit signatures)
417
+ # ingest.py attaches — the "source/provenance" signals from the malicious-PR research. Host-side ONLY: not
418
+ # an RLM tool (there is nothing for the planner to decode), recorded into the run_start baseline so they
419
+ # join the evidence union (MF3) and re-derive on read.
420
+
421
+ # Base names of first-party automation bots. A REAL bot commits as a `Bot`-type account
422
+ # (`renovate[bot]`); we scrutinise a `User`-type account wearing a bot identity. github.com forbids `[`/`]`
423
+ # in a username, so a `[bot]`-suffixed User login is STRUCTURALLY impossible for a real account → forged.
424
+ # A bot-like NAME (exact `renovate`, or a lookalike `renovate-bot`/`dependabot-fix`) is ambiguous: a legit
425
+ # HOSTED machine account (Mend's `renovate-bot`, `snyk-bot`) has one too, so it only rises to a signal when
426
+ # the account is ALSO an outsider (new / first-time) — an established machine account never trips it.
427
+ _BOT_BASES = ("dependabot", "renovate", "github-actions", "mergify", "snyk", "greenkeeper",
428
+ "depfu", "imgbot", "pre-commit-ci", "allcontributors")
429
+ # author_association values that mean "not an established contributor to THIS repo".
430
+ _OUTSIDER_ASSOCIATIONS = frozenset({"FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE", "MANNEQUIN"})
431
+ _YOUNG_ACCOUNT_DAYS = 30 # younger than this is a weak newness tell (sub-floor)
432
+
433
+
434
+ def _resembles_bot(login_lower: str) -> bool:
435
+ stripped = login_lower.replace("[bot]", "")
436
+ return any(base in stripped for base in _BOT_BASES)
437
+
438
+
439
+ def scan_provenance(provenance: dict) -> list[IndicatorHit]:
440
+ """Deterministic signals from host-side ingest enrichment (author/commit facts from `gh api`), not
441
+ from the untrusted text. Tuned like the text rules: a new/first-time author and an unsigned commit are
442
+ COMMON → sub-floor `low` corroborators (never force a signal alone). Bot identity: a `[bot]`-suffixed
443
+ User login is a FORGED identity (impossible on real github.com) → `high`; a bot-like NAME only rises to
444
+ a `medium` corroborator when the account is ALSO an outsider, so a legit hosted `renovate-bot` machine
445
+ account never forces a signal. Deterministic + side-effect-free (safe in baseline)."""
446
+ prov = provenance or {}
447
+ hits: list[IndicatorHit] = []
448
+
449
+ login = str(prov.get("author_login") or "")
450
+ login_l = login.strip().lower()
451
+ atype = str(prov.get("author_type") or "")
452
+ is_user = bool(atype) and atype.lower() != "bot" # KNOWN User (unknown/empty type stays silent)
453
+
454
+ assoc = str(prov.get("author_association") or "").upper()
455
+ age = prov.get("author_account_age_days")
456
+ young = isinstance(age, (int, float)) and not isinstance(age, bool) and age < _YOUNG_ACCOUNT_DAYS
457
+ outsider = assoc in _OUTSIDER_ASSOCIATIONS or young
458
+
459
+ if is_user and login_l.endswith("[bot]"):
460
+ hits.append(_hit("bot-impersonation", "high",
461
+ "a `[bot]`-suffixed login on a User account — a forged automation identity",
462
+ f"login={login[:80]} type={atype[:20]}"))
463
+ elif is_user and outsider and _resembles_bot(login_l):
464
+ hits.append(_hit("bot-like-author", "medium",
465
+ "a new/unestablished User account whose login mimics an automation bot",
466
+ f"login={login[:80]} type={atype[:20]} association={assoc[:40] or 'n/a'}"))
467
+
468
+ # Display-name spoof — a bot-like PROFILE NAME (not login) worn by a new/unestablished User. Same
469
+ # outsider gate as bot-like-author, so an established `renovate-bot` ("Renovate Bot") stays silent.
470
+ display = str(prov.get("author_display_name") or "")
471
+ if is_user and outsider and display and (_resembles_bot(display.lower())
472
+ or display.strip().lower().endswith("[bot]")):
473
+ hits.append(_hit("bot-like-display-name", "medium",
474
+ "a new/unestablished User whose DISPLAY NAME mimics an automation bot",
475
+ f"name={display[:80]} login={login[:80]}"))
476
+
477
+ # Author account does not resolve — a positively-confirmed 404, or GitHub's `ghost` deletion
478
+ # attribution. A real post-incident tell, but benign deletions happen too → medium (sub-floor).
479
+ if prov.get("author_not_found") is True or login_l == "ghost":
480
+ hits.append(_hit("author-unresolvable", "medium",
481
+ "the author account does not resolve (deleted / not found)",
482
+ f"login={login[:80] or 'n/a'} "
483
+ f"resolved={'404' if prov.get('author_not_found') is True else 'ghost-attribution'}"))
484
+
485
+ unverified = prov.get("commits_unverified")
486
+ if isinstance(unverified, int) and not isinstance(unverified, bool) and unverified > 0:
487
+ total = prov.get("commits_total")
488
+ ev = f"unverified={unverified}" + (f" of {total}" if isinstance(total, int) else "")
489
+ hits.append(_hit("unsigned-commits", "low", "one or more commits are unsigned / unverified", ev))
490
+
491
+ # A signature was attached but does NOT bind to the committer's GitHub identity (bad_email / unknown_key
492
+ # / no_user / unverified_email) — spoofing-shaped, distinct from plain unsigned. Low: misconfigured-but-
493
+ # honest signers produce these constantly, so a corroborator, never a signal driver.
494
+ mismatch = prov.get("commits_sig_identity_mismatch")
495
+ if isinstance(mismatch, int) and not isinstance(mismatch, bool) and mismatch > 0:
496
+ hits.append(_hit("signature-identity-mismatch", "low",
497
+ "a commit signature does not bind to the committer's GitHub identity",
498
+ f"identity_mismatch={mismatch}"))
499
+
500
+ # A forged bot-like git AUTHOR name on an UNVERIFIED commit (real API-created bot commits VERIFY) — the
501
+ # commit-level spoof shape. Same outsider gate as the login/display rules: an established machine account
502
+ # (self-hosted Renovate without platformCommit, github-actions committing via git) legitimately produces
503
+ # unverified bot-named commits, and a Bot-type author authoring bot-named commits is normal — so this
504
+ # only rises to a corroborator for an OUTSIDER User, and a name matching the author's OWN login (a
505
+ # self-consistent identity) is not a spoof. Medium; the git author string is pure attacker input.
506
+ authors = prov.get("unverified_commit_authors")
507
+ if is_user and outsider and isinstance(authors, list):
508
+ spoofed = next(
509
+ (n for n in authors
510
+ if isinstance(n, str) and n.strip().lower() != login_l
511
+ and (_resembles_bot(n.strip().lower()) or n.strip().lower().endswith("[bot]"))),
512
+ None)
513
+ if spoofed:
514
+ hits.append(_hit("spoofed-commit-author", "medium",
515
+ "an unsigned commit's git author name mimics an automation bot",
516
+ f"git_author={spoofed[:80]} login={login[:80]}"))
517
+
518
+ reasons: list[str] = []
519
+ if assoc in _OUTSIDER_ASSOCIATIONS:
520
+ reasons.append(f"association={assoc}")
521
+ if young:
522
+ reasons.append("account_age<30d") # BUCKETED, so re-ingesting a day later keeps the same hit id
523
+ if reasons:
524
+ hits.append(_hit("unknown-contributor", "low",
525
+ "author is not an established contributor (new / first-time)",
526
+ "; ".join(reasons)[:_MAX_EVIDENCE]))
527
+ return hits
528
+
529
+
530
+ def make_indicator_tool() -> Callable[[str], str]:
531
+ """Build the sync `scan_indicators` RLM tool. It scans a region the planner passes and RECORDS the
532
+ full structured hits into the trace (so the evidence is a fact, re-sourced on read), then returns a
533
+ compact text summary + the hit ids the planner may cite. Sync — dspy.RLM invokes tools synchronously."""
534
+ from rlm_harness.trace import record_tool_call
535
+
536
+ def scan_indicators_tool(region: str) -> str:
537
+ """Scan a snippet of the change (or a value you decoded) for malicious indicators — shell
538
+ injection, obfuscated payloads, secret exfiltration, known exfil/OAST callback services,
539
+ CI/CODEOWNERS tampering, workflow permission escalation, CI-skip bypass, prompt injection. Returns
540
+ the hits found (id, rule, severity, title). Cite an id in your final `indicator_ids`."""
541
+ hits = scan_indicators(region or "")
542
+ record_tool_call("scan_indicators", args={"region": (region or "")[:200]}, ok=True,
543
+ hits=[h.model_dump() for h in hits], n=len(hits))
544
+ if not hits:
545
+ return "No indicators fired on this region."
546
+ lines = [f"- {h.id} [{h.severity}] {h.rule}: {h.title}" for h in hits]
547
+ return f"{len(hits)} indicator(s):\n" + "\n".join(lines)
548
+
549
+ # dspy registers a tool under its __name__ and the planner calls it by that name; the prompt
550
+ # (detect.INSTRUCTIONS) says `scan_indicators(region)`, so the tool MUST register under exactly that
551
+ # name — otherwise the sandbox call is a NameError. The inner def can't literally BE `scan_indicators`
552
+ # without shadowing the module-level detector it calls, so rename the callable here. (The trace
553
+ # `tool_call` already records the "scan_indicators" name via record_tool_call above.)
554
+ scan_indicators_tool.__name__ = "scan_indicators"
555
+ scan_indicators_tool.__qualname__ = "scan_indicators"
556
+ return scan_indicators_tool
557
+
558
+
559
+ def hits_from_events(events: list[dict]) -> list[IndicatorHit]:
560
+ """Re-source the UNION of every deterministic hit from a trace: the run_start meta BASELINE plus
561
+ every `scan_indicators` tool_call's recorded hits, deduped by id. This is the evidence a signal is
562
+ built on — never the planner's self-report (MF3)."""
563
+ deduped: dict[str, IndicatorHit] = {}
564
+
565
+ def _absorb(raw_hits) -> None:
566
+ for h in raw_hits or []:
567
+ try:
568
+ hit = IndicatorHit(**h) if isinstance(h, dict) else h
569
+ except (TypeError, ValueError):
570
+ continue
571
+ deduped.setdefault(hit.id, hit)
572
+
573
+ for e in events:
574
+ p = e.get("payload", {})
575
+ if e.get("type") == "run_start":
576
+ _absorb((p.get("meta") or {}).get("baseline_indicators"))
577
+ elif e.get("type") == "tool_call" and p.get("tool") == "scan_indicators":
578
+ _absorb(p.get("hits"))
579
+ return list(deduped.values())