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.
- diff_sentry/__init__.py +83 -0
- diff_sentry/__main__.py +8 -0
- diff_sentry/assemble.py +87 -0
- diff_sentry/cli.py +376 -0
- diff_sentry/config.py +173 -0
- diff_sentry/deep_classify.py +144 -0
- diff_sentry/detect.py +193 -0
- diff_sentry/emit.py +82 -0
- diff_sentry/fetch_tool.py +94 -0
- diff_sentry/indicators.py +579 -0
- diff_sentry/ingest.py +215 -0
- diff_sentry/normalize.py +171 -0
- diff_sentry/response.py +163 -0
- diff_sentry/rl_export.py +157 -0
- diff_sentry/rubric.py +135 -0
- diff_sentry/schema.py +233 -0
- diff_sentry/skills/ci-injection-patterns.md +36 -0
- diff_sentry/skills/prompt-injection-catalog.md +33 -0
- diff_sentry/skills/triage-a-change.md +34 -0
- diff_sentry-0.4.0.dist-info/METADATA +221 -0
- diff_sentry-0.4.0.dist-info/RECORD +24 -0
- diff_sentry-0.4.0.dist-info/WHEEL +4 -0
- diff_sentry-0.4.0.dist-info/entry_points.txt +2 -0
- diff_sentry-0.4.0.dist-info/licenses/LICENSE +21 -0
diff_sentry/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""diff-sentry — classify a GitHub change for malicious intent, as a traced RLM harness.
|
|
2
|
+
|
|
3
|
+
A downstream *consumer* of rlm-harness (editable path dep) reproducing Datadog's BewAIre defense shape:
|
|
4
|
+
ingest a GitHub PR/issue/push → analyze the diff as UNTRUSTED data in a sandboxed REPL → produce a
|
|
5
|
+
judgement-only structured verdict (benign/suspicious/malicious) → union the deterministic indicator
|
|
6
|
+
evidence on read → emit a SIEM signal host-side → export reward-free trajectories.
|
|
7
|
+
|
|
8
|
+
Public surface::
|
|
9
|
+
|
|
10
|
+
from diff_sentry import DetectConfig, run, detect_from_event # drive a run
|
|
11
|
+
from diff_sentry import ChangeVerdict, AssembledVerdict, DetectionResponse # shapes
|
|
12
|
+
from diff_sentry import scan_indicators, assemble_verdict, build_response, emit_signal
|
|
13
|
+
from diff_sentry import normalize_event, event_from_payload, export_dataset
|
|
14
|
+
|
|
15
|
+
`config`, `schema`, `normalize`, `indicators`, `assemble`, `response`, `emit`, `ingest`, `rl_export`
|
|
16
|
+
import NO dspy at module top (unit-testable in isolation). `ClassifyChange` / `setup` / `run` /
|
|
17
|
+
`detect_from_event` pull in dspy lazily (via RLMTask).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .assemble import assemble_verdict, verdict_from_events
|
|
23
|
+
from .config import DetectConfig
|
|
24
|
+
from .emit import EmitResult, emit_signal, signal_payload
|
|
25
|
+
from .indicators import hits_from_events, scan_indicators
|
|
26
|
+
from .ingest import event_from_payload, issue_event, pr_event
|
|
27
|
+
from .normalize import event_metadata, normalize_event
|
|
28
|
+
from .response import build_failed_response, build_response
|
|
29
|
+
from .rl_export import export_dataset, run_labels, run_metrics
|
|
30
|
+
from .schema import (
|
|
31
|
+
AssembledVerdict,
|
|
32
|
+
ChangeVerdict,
|
|
33
|
+
DetectionResponse,
|
|
34
|
+
IndicatorHit,
|
|
35
|
+
ProcessInfo,
|
|
36
|
+
RefusalInfo,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DetectConfig",
|
|
41
|
+
"ChangeVerdict",
|
|
42
|
+
"AssembledVerdict",
|
|
43
|
+
"IndicatorHit",
|
|
44
|
+
"DetectionResponse",
|
|
45
|
+
"RefusalInfo",
|
|
46
|
+
"ProcessInfo",
|
|
47
|
+
"scan_indicators",
|
|
48
|
+
"hits_from_events",
|
|
49
|
+
"normalize_event",
|
|
50
|
+
"event_metadata",
|
|
51
|
+
"event_from_payload",
|
|
52
|
+
"pr_event",
|
|
53
|
+
"issue_event",
|
|
54
|
+
"assemble_verdict",
|
|
55
|
+
"verdict_from_events",
|
|
56
|
+
"build_response",
|
|
57
|
+
"build_failed_response",
|
|
58
|
+
"emit_signal",
|
|
59
|
+
"signal_payload",
|
|
60
|
+
"EmitResult",
|
|
61
|
+
"export_dataset",
|
|
62
|
+
"run_labels",
|
|
63
|
+
"run_metrics",
|
|
64
|
+
# dspy-bearing (lazy):
|
|
65
|
+
"ClassifyChange",
|
|
66
|
+
"setup",
|
|
67
|
+
"run",
|
|
68
|
+
"detect_from_event",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
__version__ = "0.4.0"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def __getattr__(name: str): # PEP 562 — defer the dspy import to first use
|
|
75
|
+
if name in ("ClassifyChange", "setup"):
|
|
76
|
+
from . import detect
|
|
77
|
+
|
|
78
|
+
return getattr(detect, name)
|
|
79
|
+
if name in ("run", "detect_from_event"):
|
|
80
|
+
from . import cli
|
|
81
|
+
|
|
82
|
+
return getattr(cli, name)
|
|
83
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
diff_sentry/__main__.py
ADDED
diff_sentry/assemble.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Assemble the canonical verdict from the trace — the read-time step that makes the EVIDENCE a fact.
|
|
2
|
+
|
|
3
|
+
`assemble_verdict(verdict, events)` attaches the deterministic fields the SYSTEM owns to the planner's
|
|
4
|
+
JUDGEMENT (`ChangeVerdict`, no hits): the UNION of ALL indicator hits from the trace (baseline ∪ every
|
|
5
|
+
`scan_indicators` tool call — MF3), the derived max severity, whether to SIGNAL, and any indicator ids
|
|
6
|
+
the planner cited that match no recorded hit (a fabrication tell).
|
|
7
|
+
|
|
8
|
+
The `signal` decision is DETERMINISTIC and partly independent of the planner: it fires if the planner's
|
|
9
|
+
verdict is in `emit_on` OR a high/critical deterministic indicator fired. So a false-benign self-report
|
|
10
|
+
cannot suppress hard evidence from reaching the SIEM — the planner can skew the verdict, never hide the
|
|
11
|
+
indicators. (This is deterministic reduction over facts, NOT a model judgement smuggled into the read
|
|
12
|
+
path — the verdict tier stays the planner's; only the evidence-driven floor is ours.)
|
|
13
|
+
|
|
14
|
+
This runs everywhere the result is consumed — the live path (cli), re-render, and rl_export — so labels
|
|
15
|
+
read facts too. Old traces heal: pydantic ignores legacy keys; hits re-source from the trace.
|
|
16
|
+
|
|
17
|
+
Pure stdlib + pydantic; no dspy.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from rlm_harness.trace import EVENT_RESULT, EVENT_RUN_START
|
|
23
|
+
|
|
24
|
+
from .indicators import hits_from_events
|
|
25
|
+
from .schema import (
|
|
26
|
+
SIGNAL_SEVERITY_FLOOR,
|
|
27
|
+
AssembledVerdict,
|
|
28
|
+
ChangeVerdict,
|
|
29
|
+
max_severity,
|
|
30
|
+
severity_rank,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_DEFAULT_EMIT_ON = ("suspicious", "malicious")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _emit_on_from_meta(events: list[dict]) -> tuple[str, ...]:
|
|
37
|
+
"""The `emit_on` the run actually used, recorded in run_start meta (so an OFFLINE re-render/export
|
|
38
|
+
re-derives the SAME `signal` the live run emitted). Falls back to the default for an old trace."""
|
|
39
|
+
for e in events:
|
|
40
|
+
if e.get("type") == EVENT_RUN_START:
|
|
41
|
+
eo = ((e.get("payload") or {}).get("meta") or {}).get("emit_on")
|
|
42
|
+
if isinstance(eo, (list, tuple)) and eo:
|
|
43
|
+
return tuple(eo)
|
|
44
|
+
return _DEFAULT_EMIT_ON
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def assemble_verdict(
|
|
48
|
+
verdict: ChangeVerdict, events: list[dict], *, emit_on: tuple[str, ...] = _DEFAULT_EMIT_ON
|
|
49
|
+
) -> AssembledVerdict:
|
|
50
|
+
"""Attach the deterministic evidence + derived signal to the planner's judgement."""
|
|
51
|
+
hits = hits_from_events(events)
|
|
52
|
+
known_ids = {h.id for h in hits}
|
|
53
|
+
cited_unknown = [cid for cid in (verdict.indicator_ids or []) if cid not in known_ids]
|
|
54
|
+
top_sev = max_severity([h.severity for h in hits])
|
|
55
|
+
|
|
56
|
+
verdict_signals = (verdict.verdict or "").strip().lower() in {v.lower() for v in emit_on}
|
|
57
|
+
evidence_signals = severity_rank(top_sev) >= severity_rank(SIGNAL_SEVERITY_FLOOR)
|
|
58
|
+
signal = bool(verdict_signals or evidence_signals)
|
|
59
|
+
|
|
60
|
+
return AssembledVerdict(
|
|
61
|
+
verdict=verdict.verdict,
|
|
62
|
+
confidence=verdict.confidence,
|
|
63
|
+
summary=verdict.summary,
|
|
64
|
+
rationale=verdict.rationale,
|
|
65
|
+
techniques=list(verdict.techniques or []),
|
|
66
|
+
suspect_files=list(verdict.suspect_files or []),
|
|
67
|
+
recommended_action=verdict.recommended_action,
|
|
68
|
+
indicators=hits,
|
|
69
|
+
max_indicator_severity=top_sev,
|
|
70
|
+
signal=signal,
|
|
71
|
+
cited_unknown_ids=cited_unknown,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def verdict_from_events(events: list[dict], *, emit_on: tuple[str, ...] | None = None
|
|
76
|
+
) -> AssembledVerdict | None:
|
|
77
|
+
"""Reconstruct the assembled verdict from a saved trace's result event, or None if the run produced
|
|
78
|
+
no result (never finalized). `emit_on` defaults to the value recorded in the run's meta, so an
|
|
79
|
+
offline re-render/export re-derives the SAME `signal` the live run emitted (finding 5)."""
|
|
80
|
+
results = [e for e in events if e["type"] == EVENT_RESULT]
|
|
81
|
+
if not results:
|
|
82
|
+
return None
|
|
83
|
+
out = results[-1]["payload"].get("output")
|
|
84
|
+
if not isinstance(out, dict):
|
|
85
|
+
return None
|
|
86
|
+
resolved = emit_on if emit_on is not None else _emit_on_from_meta(events)
|
|
87
|
+
return assemble_verdict(ChangeVerdict.from_payload(out), events, emit_on=resolved)
|
diff_sentry/cli.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""THE entry point: a GitHub change in → a structured malicious-intent verdict out, signal emitted.
|
|
2
|
+
|
|
3
|
+
python -m diff_sentry pr owner/repo 7 # classify a PR (needs gh + DS_* creds + Deno)
|
|
4
|
+
python -m diff_sentry issue owner/repo 12 # classify an issue
|
|
5
|
+
python -m diff_sentry classify event.json # classify a payload you already hold
|
|
6
|
+
python -m diff_sentry render traces/pr-7.jsonl pr-7 # re-render the response from a trace (offline)
|
|
7
|
+
python -m diff_sentry export "traces/*.jsonl" ds.json # reward-free dataset export (offline)
|
|
8
|
+
git diff main...HEAD | python -m diff_sentry scan - # indicators only, exit 1 at/above the floor
|
|
9
|
+
|
|
10
|
+
`run()` is the programmatic entry. It records `traces/{run_id}.jsonl`, writes `responses/{run_id}.json`,
|
|
11
|
+
and — host-side, AFTER the run — emits a SIEM signal when the assembled verdict warrants it. A host-side
|
|
12
|
+
BASELINE indicator scan runs at ingest and rides in the run_start meta, so the deterministic evidence is
|
|
13
|
+
in the trace before the planner takes a turn (MF3). `scan`/`render`/`export` work offline;
|
|
14
|
+
`pr`/`issue`/`classify` need model creds (DS_* env) + a Deno sandbox (`classify` only *ingests* offline).
|
|
15
|
+
`scan` exposes that deterministic baseline on its own — the half we can run in our own CI on an untrusted
|
|
16
|
+
fork PR, where no creds exist and none may be handed over (see `_cmd_scan`).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import asyncio
|
|
23
|
+
import glob
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from . import __version__
|
|
31
|
+
from .config import DetectConfig
|
|
32
|
+
from .schema import SEVERITIES, SIGNAL_SEVERITY_FLOOR, AssembledVerdict
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def detect_from_event(
|
|
36
|
+
event: dict,
|
|
37
|
+
config: DetectConfig,
|
|
38
|
+
*,
|
|
39
|
+
trace_path: str | None = None,
|
|
40
|
+
run_id: str = "change",
|
|
41
|
+
on_event: Callable[[dict], None] | None = None,
|
|
42
|
+
extra_tools=(),
|
|
43
|
+
):
|
|
44
|
+
"""Run the classifier on one change event and return the planner's JUDGEMENT (`ChangeVerdict`).
|
|
45
|
+
Call `assemble.assemble_verdict(verdict, events)` to attach the deterministic indicators + signal."""
|
|
46
|
+
import rlm_harness
|
|
47
|
+
|
|
48
|
+
from .detect import ClassifyChange, setup
|
|
49
|
+
from .normalize import event_metadata, normalize_event
|
|
50
|
+
from .rubric import default_rubric, rubric_to_meta
|
|
51
|
+
|
|
52
|
+
setup(config)
|
|
53
|
+
task = ClassifyChange(config=config, extra_tools=extra_tools)
|
|
54
|
+
event_str = normalize_event(event)
|
|
55
|
+
meta_source = event_metadata(event)
|
|
56
|
+
# Host-side BASELINE scan — deterministic evidence in the trace before the planner runs (MF3).
|
|
57
|
+
baseline = _baseline_indicators(event)
|
|
58
|
+
|
|
59
|
+
async def _run():
|
|
60
|
+
return await task.arun(event=event_str)
|
|
61
|
+
|
|
62
|
+
if trace_path:
|
|
63
|
+
with rlm_harness.TraceRecorder(trace_path, run_id=run_id, on_event=on_event, meta={
|
|
64
|
+
# The run's INITIAL STATE (the untrusted event is a REPL variable, not a chat turn) + prompt.
|
|
65
|
+
"event": event_str,
|
|
66
|
+
"instructions": task.instructions,
|
|
67
|
+
# The change under review — echoed into the response envelope + the SIEM signal.
|
|
68
|
+
"source": {k: meta_source.get(k) for k in ("repo", "kind", "number", "author", "title",
|
|
69
|
+
"file_count", "content_sha256")},
|
|
70
|
+
# The deterministic baseline — assemble unions this with every scan_indicators tool call.
|
|
71
|
+
"baseline_indicators": baseline,
|
|
72
|
+
# The emit threshold THIS run used, so an offline re-render/export re-derives the SAME signal.
|
|
73
|
+
"emit_on": list(config.emit_on),
|
|
74
|
+
# The models actually used this run (roles → concrete names) — self-describing trace.
|
|
75
|
+
"planner": config.main_model,
|
|
76
|
+
"analyst": config.sub_model,
|
|
77
|
+
"classifier": config.classifier_model,
|
|
78
|
+
# The budget THIS run ran under, so an offline reader computes hit_iteration_cap correctly.
|
|
79
|
+
"max_iterations": config.max_iterations,
|
|
80
|
+
"max_llm_calls": config.max_llm_calls,
|
|
81
|
+
# The ATLAS rubric decomposition carried as LABELS (structure only — the trainer scores dᵢ). A
|
|
82
|
+
# FIXED skeleton (default_rubric): diff-sentry's task is constant, so only the per-criterion
|
|
83
|
+
# FACTS vary per run (re-sourced from the trace on read; see rubric.criteria_facts).
|
|
84
|
+
"rubric": rubric_to_meta(default_rubric()),
|
|
85
|
+
}):
|
|
86
|
+
return await _run()
|
|
87
|
+
return await _run()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _baseline_indicators(event: dict) -> list[dict]:
|
|
91
|
+
"""The host-side deterministic evidence recorded in run_start meta BEFORE the planner runs: the text
|
|
92
|
+
detectors over the raw content ∪ the provenance detectors over the ingest facts. Both re-source into the
|
|
93
|
+
evidence union on read (MF3), so a false-benign SUBMIT can skew the verdict but never suppress these."""
|
|
94
|
+
from .indicators import scan_indicators, scan_provenance
|
|
95
|
+
from .normalize import raw_content
|
|
96
|
+
|
|
97
|
+
return ([h.model_dump() for h in scan_indicators(raw_content(event))]
|
|
98
|
+
+ [h.model_dump() for h in scan_provenance(event.get("provenance") or {})])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _write(path_parts: tuple[str, str], content: str) -> str:
|
|
102
|
+
d, name = path_parts
|
|
103
|
+
os.makedirs(d, exist_ok=True)
|
|
104
|
+
path = os.path.join(d, name)
|
|
105
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
106
|
+
fh.write(content)
|
|
107
|
+
return path
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _reset_trace(trace_path: str) -> None:
|
|
111
|
+
"""Drop any stale trace for this run_id before the run (TraceRecorder appends; one trace per run)."""
|
|
112
|
+
if os.path.exists(trace_path):
|
|
113
|
+
os.remove(trace_path)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class RunArtifacts:
|
|
118
|
+
assembled: AssembledVerdict | None
|
|
119
|
+
events: list
|
|
120
|
+
run_id: str
|
|
121
|
+
trace_path: str
|
|
122
|
+
response_path: str
|
|
123
|
+
emit: object = None # an emit.EmitResult when the SIEM emitter ran, else None
|
|
124
|
+
status: str = "classified"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run(
|
|
128
|
+
event: dict,
|
|
129
|
+
*,
|
|
130
|
+
run_id: str = "change",
|
|
131
|
+
outdir: str = "./output",
|
|
132
|
+
config: DetectConfig | None = None,
|
|
133
|
+
on_event: Callable[[dict], None] | None = None,
|
|
134
|
+
extra_tools=(),
|
|
135
|
+
emit: bool = True,
|
|
136
|
+
poster=None,
|
|
137
|
+
) -> RunArtifacts:
|
|
138
|
+
"""THE programmatic entry: classify one change, write the response, and (host-side, after the run)
|
|
139
|
+
emit a SIEM signal when warranted. Never raises on a run failure — a failed run still writes an
|
|
140
|
+
informative response and returns result-less RunArtifacts."""
|
|
141
|
+
from rlm_harness.trace import load_events
|
|
142
|
+
|
|
143
|
+
from .assemble import assemble_verdict
|
|
144
|
+
from .emit import emit_signal
|
|
145
|
+
from .response import build_failed_response, build_response
|
|
146
|
+
|
|
147
|
+
config = config or DetectConfig.from_env()
|
|
148
|
+
trace_path = os.path.join(outdir, "traces", f"{run_id}.jsonl")
|
|
149
|
+
_reset_trace(trace_path)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
verdict = asyncio.run(detect_from_event(event, config, trace_path=trace_path, run_id=run_id,
|
|
153
|
+
on_event=on_event, extra_tools=extra_tools))
|
|
154
|
+
except Exception as exc: # noqa: BLE001 — a failed run is still self-contained + navigable
|
|
155
|
+
events = load_events(trace_path, run_id) if os.path.exists(trace_path) else []
|
|
156
|
+
resp = build_failed_response(run_id, events, f"{type(exc).__name__}: {exc}")
|
|
157
|
+
response_path = _write((os.path.join(outdir, "responses"), f"{run_id}.json"),
|
|
158
|
+
resp.model_dump_json(indent=2) + "\n")
|
|
159
|
+
emit_result = emit_signal(resp, config, poster=poster) if emit else None
|
|
160
|
+
return RunArtifacts(None, events, run_id, trace_path, response_path, emit_result, status="failed")
|
|
161
|
+
|
|
162
|
+
events = load_events(trace_path, run_id)
|
|
163
|
+
assembled = assemble_verdict(verdict, events, emit_on=config.emit_on)
|
|
164
|
+
resp = build_response(assembled, events, run_id)
|
|
165
|
+
response_path = _write((os.path.join(outdir, "responses"), f"{run_id}.json"),
|
|
166
|
+
resp.model_dump_json(indent=2) + "\n")
|
|
167
|
+
emit_result = emit_signal(resp, config, poster=poster) if emit else None
|
|
168
|
+
return RunArtifacts(assembled, events, run_id, trace_path, response_path, emit_result, status=resp.status)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---- subcommands -------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def _print_run(arts: RunArtifacts) -> None:
|
|
174
|
+
a = arts.assembled
|
|
175
|
+
if a is None:
|
|
176
|
+
print(f" ✗ {arts.run_id} failed → {arts.response_path}")
|
|
177
|
+
return
|
|
178
|
+
sig = "SIGNAL" if a.signal else "no-signal"
|
|
179
|
+
emitted = ""
|
|
180
|
+
if arts.emit is not None:
|
|
181
|
+
e = arts.emit
|
|
182
|
+
emitted = (f" · emitted→{e.status_code}" if e.emitted
|
|
183
|
+
else f" · not-emitted({e.skipped_reason or e.error})")
|
|
184
|
+
print(f" ✔ {arts.run_id} [{a.verdict}/{sig}/sev={a.max_indicator_severity}] "
|
|
185
|
+
f"{len(a.indicators)} indicator(s) → {arts.response_path}{emitted}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _cmd_pr(args) -> int:
|
|
189
|
+
from .ingest import pr_event
|
|
190
|
+
|
|
191
|
+
config = DetectConfig.from_env()
|
|
192
|
+
event = pr_event(args.repo, args.number)
|
|
193
|
+
arts = run(event, run_id=f"{args.repo.replace('/', '-')}-pr-{args.number}", outdir=args.out,
|
|
194
|
+
config=config, emit=not args.no_emit)
|
|
195
|
+
_print_run(arts)
|
|
196
|
+
return 0 if arts.assembled is not None else 1
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _cmd_issue(args) -> int:
|
|
200
|
+
from .ingest import issue_event
|
|
201
|
+
|
|
202
|
+
config = DetectConfig.from_env()
|
|
203
|
+
event = issue_event(args.repo, args.number)
|
|
204
|
+
arts = run(event, run_id=f"{args.repo.replace('/', '-')}-issue-{args.number}", outdir=args.out,
|
|
205
|
+
config=config, emit=not args.no_emit)
|
|
206
|
+
_print_run(arts)
|
|
207
|
+
return 0 if arts.assembled is not None else 1
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _cmd_classify(args) -> int:
|
|
211
|
+
from .ingest import event_from_payload
|
|
212
|
+
|
|
213
|
+
config = DetectConfig.from_env()
|
|
214
|
+
with open(args.payload, "r", encoding="utf-8") as fh:
|
|
215
|
+
payload = json.load(fh)
|
|
216
|
+
event = event_from_payload(payload)
|
|
217
|
+
run_id = args.run_id or (payload.get("repo", "change").replace("/", "-") + f"-{payload.get('number', 0)}")
|
|
218
|
+
arts = run(event, run_id=run_id, outdir=args.out, config=config, emit=not args.no_emit)
|
|
219
|
+
_print_run(arts)
|
|
220
|
+
return 0 if arts.assembled is not None else 1
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def drop_deleted_lines(text: str) -> str:
|
|
224
|
+
"""Strip a unified diff's `-` deletion lines, keeping additions and context.
|
|
225
|
+
|
|
226
|
+
A diff carries the code being REMOVED as well as the code being added, and the detectors read plain
|
|
227
|
+
text — so scanning a raw diff flags a change for a payload it is DELETING. That turns every
|
|
228
|
+
remediation commit red, which is the worst possible failure mode for a security gate: it penalises
|
|
229
|
+
exactly the change you wanted. `---` (the file header) is kept; only true `-` deletion lines go.
|
|
230
|
+
|
|
231
|
+
Non-diff input is returned untouched — the check is per line, so a plain source file has no `-`-
|
|
232
|
+
prefixed lines to lose. `scan --include-deletions` opts back in when you want to audit what a change
|
|
233
|
+
removed (a payload can also be hidden by deleting a check)."""
|
|
234
|
+
if not text:
|
|
235
|
+
return text
|
|
236
|
+
return "\n".join(ln for ln in text.split("\n")
|
|
237
|
+
if not (ln.startswith("-") and not ln.startswith("---")))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _cmd_scan(args) -> int:
|
|
241
|
+
"""The deterministic HALF of the pipeline, standalone: run the indicator suite over text and exit
|
|
242
|
+
non-zero on a hit at/above the floor. No LLM, no network, no creds, no Deno — just `indicators`.
|
|
243
|
+
|
|
244
|
+
This is what makes the detector safe to dogfood in our OWN CI. The full `pr`/`classify` path needs
|
|
245
|
+
`DS_*` creds, and the only way to hand creds to a fork PR's run is `pull_request_target` — the exact
|
|
246
|
+
misconfiguration that opened the AsyncAPI "Miasma" supply-chain compromise. `scan` needs nothing, so
|
|
247
|
+
it runs under the same read-only `contents: read` token an untrusted fork PR already gets."""
|
|
248
|
+
from .indicators import scan_indicators
|
|
249
|
+
from .schema import severity_rank
|
|
250
|
+
|
|
251
|
+
threshold = severity_rank(args.fail_on)
|
|
252
|
+
hits = []
|
|
253
|
+
for path in args.path:
|
|
254
|
+
if path == "-":
|
|
255
|
+
text, label = sys.stdin.read(), "<stdin>"
|
|
256
|
+
else:
|
|
257
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
258
|
+
text = fh.read()
|
|
259
|
+
label = path
|
|
260
|
+
if not args.include_deletions:
|
|
261
|
+
text = drop_deleted_lines(text)
|
|
262
|
+
hits.extend(scan_indicators(text, location=label))
|
|
263
|
+
|
|
264
|
+
worst = max((severity_rank(h.severity) for h in hits), default=-1)
|
|
265
|
+
failed = worst >= threshold
|
|
266
|
+
|
|
267
|
+
if args.json:
|
|
268
|
+
print(json.dumps({"hits": [h.model_dump() for h in hits], "count": len(hits),
|
|
269
|
+
"max_severity": SEVERITIES[worst] if worst >= 0 else None,
|
|
270
|
+
"fail_on": args.fail_on, "failed": failed}, indent=2))
|
|
271
|
+
return 1 if failed else 0
|
|
272
|
+
|
|
273
|
+
if not hits:
|
|
274
|
+
print("scan: no indicators fired.")
|
|
275
|
+
return 0
|
|
276
|
+
print(f"scan: {len(hits)} indicator(s), max severity={SEVERITIES[worst]} (fail-on={args.fail_on})")
|
|
277
|
+
for h in sorted(hits, key=lambda h: -severity_rank(h.severity)):
|
|
278
|
+
where = f" ({h.location})" if h.location else ""
|
|
279
|
+
print(f" [{h.severity:8}] {h.rule}{where}: {h.title}")
|
|
280
|
+
print(f" evidence: {h.evidence[:160]}")
|
|
281
|
+
print(f"FAIL — a hit at/above `{args.fail_on}`." if failed
|
|
282
|
+
else f"OK — every hit is below `{args.fail_on}`.")
|
|
283
|
+
return 1 if failed else 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _cmd_render(args) -> int:
|
|
287
|
+
from rlm_harness.trace import load_events
|
|
288
|
+
|
|
289
|
+
from .assemble import verdict_from_events
|
|
290
|
+
from .response import build_failed_response, build_response
|
|
291
|
+
|
|
292
|
+
events = load_events(args.trace, args.run_id)
|
|
293
|
+
assembled = verdict_from_events(events)
|
|
294
|
+
resp = (build_response(assembled, events, args.run_id) if assembled is not None
|
|
295
|
+
else build_failed_response(args.run_id, events, "trace has no result event"))
|
|
296
|
+
out = resp.model_dump_json(indent=2)
|
|
297
|
+
if args.out:
|
|
298
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
299
|
+
fh.write(out + "\n")
|
|
300
|
+
print(f"wrote {args.out}")
|
|
301
|
+
else:
|
|
302
|
+
print(out)
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _cmd_export(args) -> int:
|
|
307
|
+
from .rl_export import export_dataset, load_runs
|
|
308
|
+
|
|
309
|
+
paths = [p for g in args.trace for p in glob.glob(g)]
|
|
310
|
+
runs = load_runs(*paths)
|
|
311
|
+
bundle = export_dataset(runs)
|
|
312
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
313
|
+
json.dump(bundle, fh, ensure_ascii=False, indent=2, default=str)
|
|
314
|
+
signals = sum(1 for lab in bundle["labels"].values() if lab["signal"])
|
|
315
|
+
print(f"runs={len(runs)} ({signals} signalling) | sft_turns={len(bundle['sft_turns'])} | "
|
|
316
|
+
f"classifier={len(bundle['classifier'])} | reward-free → {args.out}")
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
321
|
+
p = argparse.ArgumentParser(prog="diff_sentry", description=__doc__)
|
|
322
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
323
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
324
|
+
|
|
325
|
+
pr = sub.add_parser("pr", help="classify a GitHub pull request")
|
|
326
|
+
pr.add_argument("repo", help="owner/name")
|
|
327
|
+
pr.add_argument("number", type=int)
|
|
328
|
+
pr.add_argument("--out", default="./output")
|
|
329
|
+
pr.add_argument("--no-emit", action="store_true", help="do not emit a SIEM signal")
|
|
330
|
+
pr.set_defaults(func=_cmd_pr)
|
|
331
|
+
|
|
332
|
+
iss = sub.add_parser("issue", help="classify a GitHub issue")
|
|
333
|
+
iss.add_argument("repo", help="owner/name")
|
|
334
|
+
iss.add_argument("number", type=int)
|
|
335
|
+
iss.add_argument("--out", default="./output")
|
|
336
|
+
iss.add_argument("--no-emit", action="store_true", help="do not emit a SIEM signal")
|
|
337
|
+
iss.set_defaults(func=_cmd_issue)
|
|
338
|
+
|
|
339
|
+
cl = sub.add_parser("classify", help="classify a change payload you already hold (offline ingest)")
|
|
340
|
+
cl.add_argument("payload", help="path to a JSON payload {repo,kind,number,author,title,body,files}")
|
|
341
|
+
cl.add_argument("--run-id", default=None)
|
|
342
|
+
cl.add_argument("--out", default="./output")
|
|
343
|
+
cl.add_argument("--no-emit", action="store_true", help="do not emit a SIEM signal")
|
|
344
|
+
cl.set_defaults(func=_cmd_classify)
|
|
345
|
+
|
|
346
|
+
sc = sub.add_parser("scan", help="deterministic indicator scan over text/diff (offline, no creds)")
|
|
347
|
+
sc.add_argument("path", nargs="+", help="file(s) to scan; `-` reads stdin")
|
|
348
|
+
sc.add_argument("--fail-on", default=SIGNAL_SEVERITY_FLOOR, choices=SEVERITIES,
|
|
349
|
+
help=f"exit 1 on a hit at/above this severity (default: {SIGNAL_SEVERITY_FLOOR}, "
|
|
350
|
+
"the SIEM signal floor)")
|
|
351
|
+
sc.add_argument("--json", action="store_true", help="machine-readable output")
|
|
352
|
+
sc.add_argument("--include-deletions", action="store_true",
|
|
353
|
+
help="also scan a diff's `-` lines (default: skip them, so removing a payload "
|
|
354
|
+
"does not flag the change that removes it)")
|
|
355
|
+
sc.set_defaults(func=_cmd_scan)
|
|
356
|
+
|
|
357
|
+
r = sub.add_parser("render", help="re-render the response from a trace (offline)")
|
|
358
|
+
r.add_argument("trace")
|
|
359
|
+
r.add_argument("run_id")
|
|
360
|
+
r.add_argument("--out", default=None)
|
|
361
|
+
r.set_defaults(func=_cmd_render)
|
|
362
|
+
|
|
363
|
+
e = sub.add_parser("export", help="export reward-free SFT/RL datasets from traces (offline)")
|
|
364
|
+
e.add_argument("trace", nargs="+", help="trace file glob(s)")
|
|
365
|
+
e.add_argument("out", help="output json path")
|
|
366
|
+
e.set_defaults(func=_cmd_export)
|
|
367
|
+
return p
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main(argv=None) -> int:
|
|
371
|
+
args = build_parser().parse_args(argv)
|
|
372
|
+
return args.func(args)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
raise SystemExit(main())
|