diff-sentry 0.4.3__tar.gz → 0.6.0__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.
- diff_sentry-0.6.0/.claude/rules/detect-setup.md +23 -0
- diff_sentry-0.6.0/.claude/rules/handoff.md +45 -0
- diff_sentry-0.6.0/.claude/rules/indicators.md +281 -0
- diff_sentry-0.6.0/.claude/rules/monitoring.md +106 -0
- diff_sentry-0.6.0/.claude/rules/rubric-and-eval.md +42 -0
- diff_sentry-0.6.0/.claude/skills/release/SKILL.md +37 -0
- diff_sentry-0.6.0/.env.example +67 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/.github/workflows/ci.yml +17 -13
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/.github/workflows/release.yml +4 -5
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/.gitignore +1 -1
- diff_sentry-0.6.0/AGENTS.md +151 -0
- diff_sentry-0.6.0/CHANGELOG.md +233 -0
- diff_sentry-0.6.0/PKG-INFO +335 -0
- diff_sentry-0.6.0/README.md +314 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/VENDOR.md +5 -6
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/action.yml +4 -4
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/__init__.py +18 -8
- diff_sentry-0.6.0/diff_sentry/actors.py +544 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/assemble.py +6 -6
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/cli.py +287 -13
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/config.py +21 -12
- diff_sentry-0.6.0/diff_sentry/dbutil.py +42 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/deep_classify.py +7 -8
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/detect.py +5 -3
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/emit.py +21 -6
- diff_sentry-0.6.0/diff_sentry/hosts.py +316 -0
- diff_sentry-0.6.0/diff_sentry/indicators.py +3381 -0
- diff_sentry-0.6.0/diff_sentry/ingest.py +568 -0
- diff_sentry-0.6.0/diff_sentry/linewide.py +192 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/normalize.py +94 -11
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/response.py +33 -10
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/rl_export.py +37 -8
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/rubric.py +3 -5
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/schema.py +34 -8
- diff_sentry-0.6.0/diff_sentry/shelllex.py +743 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/skills/ci-injection-patterns.md +12 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/skills/triage-a-change.md +10 -0
- diff_sentry-0.6.0/diff_sentry/snapshot.py +186 -0
- diff_sentry-0.6.0/diff_sentry/watch.py +1025 -0
- diff_sentry-0.6.0/eval/README.md +113 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/cli.py +3 -2
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/judge.py +1 -1
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/schema.py +1 -1
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/score.py +14 -16
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/pyproject.toml +2 -3
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/conftest.py +5 -5
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_score.py +4 -4
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/pyproject.toml +17 -20
- diff_sentry-0.6.0/studio/DESIGN.md +227 -0
- diff_sentry-0.6.0/studio/README.md +209 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/app.py +34 -13
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/iterations.py +10 -12
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/live.py +4 -6
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/mapper.py +5 -5
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/pyproject.toml +1 -2
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/app.js +6 -3
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/replay-core.js +1 -1
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/run-core.js +14 -4
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/style.css +3 -3
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/replay-core.test.js +3 -3
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/run-core.test.js +18 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/static-contract.test.js +3 -3
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/test_app.py +42 -2
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/test_iterations.py +3 -3
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/test_mapper.py +9 -0
- diff_sentry-0.6.0/tests/corpus/hackerbot_claw_campaign.json +165 -0
- diff_sentry-0.6.0/tests/oracle/__init__.py +11 -0
- diff_sentry-0.6.0/tests/oracle/grammar.py +252 -0
- diff_sentry-0.6.0/tests/oracle/harness.py +171 -0
- diff_sentry-0.6.0/tests/oracle/lexrunner.py +27 -0
- diff_sentry-0.6.0/tests/oracle/programs.py +134 -0
- diff_sentry-0.6.0/tests/oracle/runner.py +114 -0
- diff_sentry-0.6.0/tests/test_actors.py +742 -0
- diff_sentry-0.6.0/tests/test_adversarial_input.py +367 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_assemble.py +2 -2
- diff_sentry-0.6.0/tests/test_campaign_replay.py +150 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_cli.py +3 -4
- diff_sentry-0.6.0/tests/test_cli_watch.py +443 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_deep_classify.py +3 -3
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_detect.py +1 -2
- diff_sentry-0.6.0/tests/test_dogfood_gate.py +59 -0
- diff_sentry-0.6.0/tests/test_enclosure.py +944 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_export.py +21 -2
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_forward_offline.py +5 -5
- diff_sentry-0.6.0/tests/test_gate_edges.py +373 -0
- diff_sentry-0.6.0/tests/test_hosts.py +195 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_incident_hackerbot_claw.py +8 -8
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_indicators.py +19 -9
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_ingest.py +11 -10
- diff_sentry-0.6.0/tests/test_ingest_metadata.py +361 -0
- diff_sentry-0.6.0/tests/test_linewide.py +1042 -0
- diff_sentry-0.6.0/tests/test_metadata_rules.py +5448 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_normalize.py +1 -1
- diff_sentry-0.6.0/tests/test_oracle.py +241 -0
- diff_sentry-0.6.0/tests/test_oracle_harness.py +45 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_response.py +2 -2
- diff_sentry-0.6.0/tests/test_shelllex.py +1514 -0
- diff_sentry-0.6.0/tests/test_snapshot.py +178 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_subscription.py +1 -1
- diff_sentry-0.6.0/tests/test_watch.py +1340 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/uv.lock +9 -9
- diff_sentry-0.4.3/.claude/rules/handoff.md +0 -56
- diff_sentry-0.4.3/.env.example +0 -50
- diff_sentry-0.4.3/CHANGELOG.md +0 -296
- diff_sentry-0.4.3/CLAUDE.md +0 -226
- diff_sentry-0.4.3/PKG-INFO +0 -220
- diff_sentry-0.4.3/README.md +0 -199
- diff_sentry-0.4.3/diff_sentry/indicators.py +0 -723
- diff_sentry-0.4.3/diff_sentry/ingest.py +0 -215
- diff_sentry-0.4.3/eval/README.md +0 -105
- diff_sentry-0.4.3/studio/DESIGN.md +0 -206
- diff_sentry-0.4.3/studio/README.md +0 -208
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/.python-version +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/LICENSE +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/taskset.example.json +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/index.html +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/__init__.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/conftest.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_config.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_detection_quality.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_emit.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_provenance.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_rubric.py +0 -0
- {diff_sentry-0.4.3 → diff_sentry-0.6.0}/tests/test_schema.py +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "diff_sentry/detect.py"
|
|
4
|
+
- "diff_sentry/config.py"
|
|
5
|
+
- "diff_sentry/cli.py"
|
|
6
|
+
- "diff_sentry/deep_classify.py"
|
|
7
|
+
- "diff_sentry/__init__.py"
|
|
8
|
+
- ".env.example"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Model roles, auth, and run failures
|
|
12
|
+
|
|
13
|
+
- **Failures name themselves.** With `max_retries=1` (set in `detect.setup`), a non-retryable LM error
|
|
14
|
+
(auth / billing / configuration / unsupported model) escapes after one attempt as the raw
|
|
15
|
+
`dspy.LMError` subclass. An `RLMTaskError` means the single attempt ended in an adapter parse failure,
|
|
16
|
+
an invalid result, or a retryable endpoint fault (timeout / 5xx / transport). `cli.run` turns both into
|
|
17
|
+
a `status=failed` response. Check the endpoint before suspecting the schema.
|
|
18
|
+
- **Subscription auth is a lazy, opt-in branch.** A role whose model is `claude-agent-sdk/<id>` runs on
|
|
19
|
+
rlm-harness's `ClaudeAgentLM`, imported only inside `detect._maybe_subscription_lm` and never from
|
|
20
|
+
`__init__.py`, so `import diff_sentry` stays dspy-free and a proxy-only install never touches the SDK.
|
|
21
|
+
- **The classifier never runs on the subscription.** It is a separate OpenAI-compatible client
|
|
22
|
+
(`deep_classify._selfclassify_chat`), so `config.from_env` rejects a `claude-agent-sdk/` classifier
|
|
23
|
+
model, whether set explicitly or inherited from `DS_SUB_LM`. Mixed auth across roles is by design.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Context preservation (read before auto-compacting)
|
|
2
|
+
|
|
3
|
+
Durable knowledge belongs in tracked files; a handoff summary carries only the in-flight session state
|
|
4
|
+
they do not hold yet.
|
|
5
|
+
|
|
6
|
+
- **Stable invariants** → `AGENTS.md` **Hard invariants**, or the topic rule under `.claude/rules/`.
|
|
7
|
+
- **Shipped changes** → the commit message, and a `CHANGELOG.md` entry at release.
|
|
8
|
+
- **Open / proposed work** → the issue tracker, or `AGENTS.md` **Layout** (next increments).
|
|
9
|
+
|
|
10
|
+
A handoff summary covers, in order:
|
|
11
|
+
|
|
12
|
+
1. **Decisions agreed this session** that are not yet in those files, each with its reason. Promote
|
|
13
|
+
durable ones into `AGENTS.md` or a topic rule before they fade.
|
|
14
|
+
2. **Files / symbols changed**, as `path:symbol — final shape` one-liners (e.g.
|
|
15
|
+
`assemble.py:assemble_verdict — signal = verdict∈emit_on OR severity≥floor`). No diffs, no
|
|
16
|
+
intermediate revisions.
|
|
17
|
+
3. **Current status**: what passes (`uv run pytest`, the eval suite, `uvx ruff check .`) with counts,
|
|
18
|
+
what is broken, the last command and its result. One paragraph.
|
|
19
|
+
4. **Open items** not yet tracked, each marked `proposed`, `accepted-not-done`, or `rejected`.
|
|
20
|
+
5. **Seams touched**: `classify_backend` (`"self"` or a dedicated backend in progress), the host-side
|
|
21
|
+
pre-filter / live GitHub+SIEM wiring, and the rlm-harness pin. A resumed session must not re-open a
|
|
22
|
+
seam that moved.
|
|
23
|
+
6. **The user's in-flight intent and acceptance criteria.**
|
|
24
|
+
|
|
25
|
+
Do not preserve anything already in `AGENTS.md`, `README.md`, `.env.example`, or `pyproject.toml`; tool
|
|
26
|
+
output, file listings, or file contents readable from disk; or exploration narration that led to no
|
|
27
|
+
decision.
|
|
28
|
+
|
|
29
|
+
Format (keep it under ~40 lines):
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
## Session state
|
|
33
|
+
- Goal: <one sentence>
|
|
34
|
+
- Status: <what passes, what doesn't, last command + result>
|
|
35
|
+
- Seams: <only the ones touched, one line each>
|
|
36
|
+
|
|
37
|
+
## Decisions
|
|
38
|
+
- <decision> — <why> (→ AGENTS.md / topic rule / commit message)
|
|
39
|
+
|
|
40
|
+
## Changed
|
|
41
|
+
- <path:symbol> — <what & why>
|
|
42
|
+
|
|
43
|
+
## Open
|
|
44
|
+
- [proposed|accepted-not-done|rejected] <item>
|
|
45
|
+
```
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "diff_sentry/indicators.py"
|
|
4
|
+
- "diff_sentry/normalize.py"
|
|
5
|
+
- "tests/corpus/**"
|
|
6
|
+
- "tests/test_indicators.py"
|
|
7
|
+
- "diff_sentry/hosts.py"
|
|
8
|
+
- "tests/oracle/**"
|
|
9
|
+
- "tests/test_oracle.py"
|
|
10
|
+
- "tests/test_oracle_harness.py"
|
|
11
|
+
- "diff_sentry/shelllex.py"
|
|
12
|
+
- "diff_sentry/linewide.py"
|
|
13
|
+
- "tests/test_shelllex.py"
|
|
14
|
+
- "tests/test_enclosure.py"
|
|
15
|
+
- "tests/test_linewide.py"
|
|
16
|
+
- "tests/test_gate_edges.py"
|
|
17
|
+
- "tests/test_metadata_rules.py"
|
|
18
|
+
- "tests/test_adversarial_input.py"
|
|
19
|
+
- "tests/test_detection_quality.py"
|
|
20
|
+
- "tests/test_incident_*.py"
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# Indicator rules
|
|
24
|
+
|
|
25
|
+
## Rules that hold for every indicator
|
|
26
|
+
|
|
27
|
+
- **Deterministic, pure Python, no subprocess.** A subprocess spawned inside the live dspy.RLM/asyncio process
|
|
28
|
+
hangs; a heavier scanner belongs host-side, after the run. `mint_id` is a sha1 over rule and evidence (no time,
|
|
29
|
+
no randomness), so a baseline hit and an in-loop re-scan of the same content dedupe to one union member.
|
|
30
|
+
Evidence is a bounded snippet (`_MAX_EVIDENCE`), never the whole diff.
|
|
31
|
+
- **Severities are tuned, not paranoid.** A plain workflow-file edit is `medium`, below the signal floor on
|
|
32
|
+
purpose: a benign workflow PR must not force a SIEM signal, and a real payload inside the workflow fires the
|
|
33
|
+
high/critical shell and obfuscation rules itself. A CODEOWNERS reassignment is `high`. A bare
|
|
34
|
+
`pull_request_target` (the ordinary label-bot shape) is `medium`; the same trigger checking out the PR head is
|
|
35
|
+
`pwn-request` / `critical`. `dev-tunnel-endpoint`, `content-addressed-host` (IPFS) and `dynamic-code-eval` are
|
|
36
|
+
sub-floor corroborators; pairs with no innocent reading (`child_process` + `detached`, network-fetch +
|
|
37
|
+
file-write) are `high`. Requiring both halves is how a common primitive becomes evidence without becoming noise.
|
|
38
|
+
- **Every rule ships with its negative case**, and `tests/corpus/` pins the suite's hit/miss behavior.
|
|
39
|
+
- **`pwn-request` has three checkout forms, each requiring the privileged trigger:** the head expression on a
|
|
40
|
+
`ref:` (directly or one binding hop away), a hand-rolled `git fetch` / `gh pr checkout`, and
|
|
41
|
+
`allow-unsafe-pr-checkout: true` (the actions/checkout v5.1+ opt-in; the line states the intent, so no dataflow
|
|
42
|
+
is needed). Each form has its own title. A privileged trigger is recognised in the common YAML spellings (map
|
|
43
|
+
key, `on: name`, flow list or map, block-list item, quotes, anchor, trailing comment). `issue_comment` counts
|
|
44
|
+
beside `pull_request_target` and `workflow_run` (it runs with the base repository's secrets), but that trigger
|
|
45
|
+
alone is not a finding; `pull_request_review` and `pull_request_review_comment` are not privileged.
|
|
46
|
+
- **A paired rule only pairs within one file.** `scan_diff` splits a unified diff on its file headers and
|
|
47
|
+
`scan_content` does the same for an event. `normalize.content_segments` is the single definition both
|
|
48
|
+
`raw_content` and the baseline build on. Per-file scoping stops two halves from different files composing into a
|
|
49
|
+
`critical` no file contains, and keeps a hit id byte-stable between a whole-diff and a single-file scan, which is
|
|
50
|
+
what lets the baseline and an in-loop scan dedupe. Non-diff input falls through to a whole-text scan.
|
|
51
|
+
- **The in-loop tool registers as exactly `scan_indicators`.** dspy registers a tool under its `__name__` and the
|
|
52
|
+
prompt calls `scan_indicators(region)`; the inner callable is renamed on purpose (`make_indicator_tool`).
|
|
53
|
+
- **Hits are capped at 25 per (rule, place, title)** (`_MAX_HITS_PER_RULE`), most severe first, so decoys cannot
|
|
54
|
+
evict the critical that follows them. The title is part of the key because `curl-pipe-shell` has three forms. The
|
|
55
|
+
SIEM payload lists the 100 most severe and says how many there were (`indicator_count`).
|
|
56
|
+
- **A detector that raises is a finding, not a silence.** `scan_indicators` runs each detector in `_DETECTORS`
|
|
57
|
+
under a guard; one that raises records a `high` `scanner-error` hit and the rest still run.
|
|
58
|
+
`tests/test_metadata_rules.py` feeds thousands of random hostile strings through the scanners and requires that
|
|
59
|
+
none raises. The prose softening and the rendered view run under the same kind of guard (`_or_closed`): a crash
|
|
60
|
+
keeps the installer as strict as the detector found it and adds a `scanner-error` hit.
|
|
61
|
+
- **Every regex is linear on hostile input.** The watcher runs these rules on any user's text (up to 65,536
|
|
62
|
+
characters in a body) on one poller thread, and a regex that backtracks does not return. Habits: `[ \t]*` after a
|
|
63
|
+
line anchor, never `\s*`; no two adjacent quantifiers that can take the same character; alternatives disjoint
|
|
64
|
+
on their first character; every unbounded `[^\n]*` in front of another pattern gets a `{0,N}` window; host
|
|
65
|
+
patterns anchored and label-bounded. `tests/test_adversarial_input.py` times named vectors and, in a subprocess,
|
|
66
|
+
runs 65,536-character inputs built from the literals of every module-level regex in the package. A regex written
|
|
67
|
+
inside a function is not picked up: compile it at module level.
|
|
68
|
+
- **The change text is scanned once per change in the poller.** `cli._baseline_indicators(event, content_hits=...)`
|
|
69
|
+
and `cli.screen(..., baseline=...)` take the result of the one `scan_change`.
|
|
70
|
+
- **Command words are bounded by ASCII alphanumerics, never `\b` or `\w`**, which know Unicode: `__curl ... | sh__`
|
|
71
|
+
(Markdown emphasis) and `執行curl ...` (CJK prose has no spaces) must still be found.
|
|
72
|
+
- **`invisible-unicode`** covers bidi overrides, embeddings and isolates; the word joiner and invisible math
|
|
73
|
+
operators; the Hangul fillers; a ZWSP anywhere but right after `@` (Dependabot renders `@user`); a BOM
|
|
74
|
+
anywhere but the start of a line; Unicode tag characters (except the three subdivision flags); the
|
|
75
|
+
variation-selector supplement; and three or more plain variation selectors in a row.
|
|
76
|
+
- **`branch-name-injection` is `high` unless the author is an OWNER, MEMBER or COLLABORATOR** (then `medium`): a
|
|
77
|
+
maintainer can name a branch `fix/handle_${HOME}_expansion`.
|
|
78
|
+
|
|
79
|
+
## `curl-pipe-shell`
|
|
80
|
+
|
|
81
|
+
A download piped into a shell or an interpreter is `critical`. It has three forms that are judged separately: the
|
|
82
|
+
pipe, the command substitution and PowerShell's. In prose (a `title`, `body` or `comment`, or a documentation file:
|
|
83
|
+
`.md .mdx .markdown .rst .adoc .ipynb .html .htm`) a documented installer is `medium`. Code files (`.sh`, `.go`,
|
|
84
|
+
`.yml`, a Dockerfile, `requirements.txt`) and branches are never softened. The segment kind decides, never a label.
|
|
85
|
+
The planner's `scan_indicators` applies the same softening to the region it is given.
|
|
86
|
+
|
|
87
|
+
### What it matches
|
|
88
|
+
|
|
89
|
+
- **The pipe form.** The shell is `sh bash zsh dash ksh ash fish csh tcsh` by name or path (quotes or backslashes
|
|
90
|
+
threaded through the letters are fine), `$SHELL`, or one of them behind `env`, `busybox`, `command`, `exec`,
|
|
91
|
+
`nohup`, `nice`, `timeout`, `stdbuf`, `sudo`, `doas`, `pkexec` or `su -c`. An interpreter that reads its program from the pipe
|
|
92
|
+
(`python3 -`, `perl`, `ruby`, `node`, `php`) counts too, but not `python -m`, a script file or `-c` / `-e`. The first
|
|
93
|
+
pipe into a shell is the sink; up to three stages in between; the download word may itself be quoted or split by a
|
|
94
|
+
backslash-newline; up to 300 characters of arguments sit between it and the pipe, after the padding that would
|
|
95
|
+
push a real command out of that window has been folded. The window reads quoted strings (which may run over a line),
|
|
96
|
+
`$'...'` as an ANSI-C string, and a backslash outside a string as bash does (`\'`, `\|` and `\$` are characters of a word,
|
|
97
|
+
`curl -H 'a<newline>b' URL \' | sh`). A second window reads `$` as a plain character (after `\$` or `$$`, and in dash,
|
|
98
|
+
`$'` starts no ANSI-C string), and a third ignores quotes altogether (it finds a Markdown table cell's `\|`). A match of
|
|
99
|
+
any of the three counts.
|
|
100
|
+
- **The command substitution form:** `bash -c "$(curl ...)"`, `bash <(curl ...)`, `source <(curl ...)`,
|
|
101
|
+
`eval "$(curl ...)"`, `python3 <(curl ...)`, here-strings, with the usual prefixes.
|
|
102
|
+
- **The PowerShell form:** `iwr ... | iex` (`curl` and `wget` are aliases of `iwr` there), `iex (irm ...)`,
|
|
103
|
+
`iex ((New-Object Net.WebClient).DownloadString(...))`, `[scriptblock]::Create(...)`.
|
|
104
|
+
- A backslash-continued command and a pipe at the end of a line are joined before matching (`_join_lines`), in the
|
|
105
|
+
scan and in the softening alike. A line that looks like a table row but is not one keeps its lines, and its
|
|
106
|
+
pipeline is appended at the end of the text; the appended lines are matched as a text of their own (`_matches`). Prose and HTML files are also scanned as rendered (`_rendered`, below), and the
|
|
107
|
+
hits only that view has are added.
|
|
108
|
+
|
|
109
|
+
### When prose makes it `medium`
|
|
110
|
+
|
|
111
|
+
Every download of the form in the segment must meet all of these; one failure keeps the whole form `critical`.
|
|
112
|
+
The form is part of the hit id, and two hits that share an id keep the more severe one (`keep_worse`).
|
|
113
|
+
|
|
114
|
+
1. **Known installers only** (`hosts.KNOWN_INSTALLERS`: exact hosts, and exact path patterns for shared hosts such
|
|
115
|
+
as `raw.githubusercontent.com/nvm-sh/nvm/...`). `hosts.installer_only` is strict: every flag is on an allowlist
|
|
116
|
+
(`-sSfL`, `-o-`, `--proto '=https'`, `--retry N`; `--proxy`, `--config`, `--next` and the like fail), every
|
|
117
|
+
argument is a clean URL, and a stand-in (`...`, `<url>`) names no download. `powershell_installer_only` applies the same
|
|
118
|
+
test to PowerShell. Never add a whole platform (github.com, raw.githubusercontent.com) to the list.
|
|
119
|
+
2. **A plain shell side** (pipe form). A bare shell name or one under `/bin`, `/usr/bin`, `/usr/local/bin`, plain
|
|
120
|
+
flags, no `-c`, no script operand (`sh ./evil.sh`), no `=` or URL; only decode or copy filters between the
|
|
121
|
+
download and the shell (`base64 gunzip gzip zcat xz tee cat`); an interpreter only for a known installer and only
|
|
122
|
+
with the switches it documents.
|
|
123
|
+
3. **It stands on its own logical line.** Nothing on the line feeds it (`|`, `=`, `&`, `;`, `$`, `xargs`, `-exec`;
|
|
124
|
+
only `NONINTERACTIVE=`, `CI=` and `DEBIAN_FRONTEND=` may precede it), and no program runs it elsewhere or in its
|
|
125
|
+
place (`_wrapped` reads every word in front of the command, after markup, labels, list markers and redirects are
|
|
126
|
+
dropped). The command starts within `_MAX_PROSE_LEAD` characters of its line or inside an inline code span, and
|
|
127
|
+
the rest of the line is prose.
|
|
128
|
+
4. **Nothing earlier in the segment changes what runs** (`_HIJACK_RE`): a curl config, an alias or function named
|
|
129
|
+
`curl` or a shell, an assignment to `PATH`, `HOME`, `LD_*`, `BASH_ENV`, `SSL_CERT_*`, a URL assigned to any
|
|
130
|
+
variable, a proxy variable, a write to `/etc/hosts`, PowerShell's `Set-Alias`. This is a brake on false positives,
|
|
131
|
+
not a proof: what defeats it is still recorded at `medium` and handed to the planner as evidence.
|
|
132
|
+
5. **The installer is not enclosed** by anything a wrapper opened above it, and the text has no shape the
|
|
133
|
+
softening does not read (both below).
|
|
134
|
+
|
|
135
|
+
### Is the installer enclosed?
|
|
136
|
+
|
|
137
|
+
`ssh evil.example 'NEWLINE curl ... | sh NEWLINE '` runs the installer inside ssh's argument, so a documented installer
|
|
138
|
+
stays `critical` when something above it opens a wrapper's quote, here-document or substitution. `enclosed` in
|
|
139
|
+
`_soften_prose` asks two readings of the folded join; **either one** keeps the installer `critical`.
|
|
140
|
+
|
|
141
|
+
- **The line-wide floor (`linewide.py`)** is the line-wide rule that preceded the lexer, kept unchanged. A line
|
|
142
|
+
opens something when its last 300 characters (minus quote, list and prompt markers) hold an odd number of
|
|
143
|
+
apostrophes (one between two letters does not count), an odd number of double quotes or a `<<`, and a word in it
|
|
144
|
+
could run what follows (`linewide.wrapped`: a wrapper, an interpreter, a path, a name glued to the command). The
|
|
145
|
+
60 lines after an opening line are enclosed. The floor also asks `wrapped` of the text in front of the command on
|
|
146
|
+
its own line. It counts the quotes the text spells, so it sees the ones a YAML scalar, a string literal, `printf`, a
|
|
147
|
+
variable or Markdown rendering turns into the quote a shell is handed, which the lexer cannot. It knows no fence,
|
|
148
|
+
removed line, closed string or syntax error. A text with a NUL is read with and without it.
|
|
149
|
+
- **Guarantee:** what the line-wide rule rates `critical` through its opening-line rule or lead check is never
|
|
150
|
+
softened. Exceptions: the removed lines, hunk headers (not the section heading after the second `@@`, which is a line of the
|
|
151
|
+
new file) and `\ No newline` notes of a patch (the rendered view is of the new file, so a line break it makes
|
|
152
|
+
carries the marker of its line), and a NUL anywhere in the text (it is read without the NUL, as the shells read it).
|
|
153
|
+
- **Do not narrow the floor or relax the lexer's answer on top of it.** Test the lexer's relaxations through
|
|
154
|
+
quote-free openers (`ssh evil.example $(`).
|
|
155
|
+
- **The lexer (`shelllex.py`)** is a one-pass quoting lexer with a stack of open constructs (quotes, `$'...'`,
|
|
156
|
+
backticks, `$(...)`, `<(...)`, `(...)`, `$((...))`, `${...}`) and a queue of pending here-documents, held to
|
|
157
|
+
`bash -n` and `dash -n` by the oracle. What the two shells read differently, or what needs a parser, is `unknown`
|
|
158
|
+
(the docstring lists them) and counts as enclosed on the lines after the first one on which a command may begin like
|
|
159
|
+
a wrapper's. A construct counts only when the command that holds it is a wrapper and does not read as prose
|
|
160
|
+
(`_begins_with_wrapper`, `_wrapped`): `ssh evil.example '`, `docker run x sh -c "`, `ssh evil.example <<'EOF'`. A
|
|
161
|
+
capitalised first word, an apostrophe, a URL, or a word such as `the a an is it to of use run` makes prose, and a
|
|
162
|
+
plain-word host (`ssh host '`) reads as prose.
|
|
163
|
+
- The diff's `+` and a blockquote's `>` are removed, and the text is read in each stretch between code fences. A
|
|
164
|
+
fence is a line of three or more backticks or tildes, at most three blanks in, with nothing in front of it. A
|
|
165
|
+
fence behind a quote or list marker cuts nothing. The lines of an HTML block are text. A string opened in one
|
|
166
|
+
block is not open in the next. CommonMark's container rules are not modelled further.
|
|
167
|
+
- Every reading is also tried from each wrapper word as a fresh start, because prose or a table cell can sit in
|
|
168
|
+
front of a command (`Run:ssh`, `| docker |`). The 60 lines after it are read as a script of its own.
|
|
169
|
+
- `eval` runs the script its first string holds, and a string piped into a shell or an interpreter is a script
|
|
170
|
+
too; both are read as such to a depth of three.
|
|
171
|
+
- A wrapper-led line whose comment holds a quote is read a second time as a zsh in a terminal reads it, where `#` is no comment.
|
|
172
|
+
- The enclosure reads are held to a budget of 12 units per character plus 2,000, and a command of more than 600
|
|
173
|
+
characters is judged from its head and tail. The budget fails closed.
|
|
174
|
+
|
|
175
|
+
### Shapes the softening does not read, and the rendered view
|
|
176
|
+
|
|
177
|
+
- **The gate (`_unread_shape`)** is fail-closed: a text with a shape that the softening does not read is not softened
|
|
178
|
+
at all, and the extras of its rendered view are not either. The shapes are a lone CR, a diff block in a
|
|
179
|
+
document (a line of the new file that starts with `+` and a non-blank), a notebook array, the strings of a list that
|
|
180
|
+
hold a download and a program, a multi-line array assignment, a YAML single-quoted scalar that goes on, and a
|
|
181
|
+
template placeholder that spells a quote. A crash of the check is a shape.
|
|
182
|
+
- **The rendered view (`_rendered`)** reads a text as a reader copies it: entities decoded, comments and tags
|
|
183
|
+
gone, a block tag a line break, links reduced to their text, emphasis markers removed, a word in backticks reduced to the
|
|
184
|
+
word, a code span of a quote read as that quote. Its stricter hit stands (stricter wins). `_shield_html_fences` blanks
|
|
185
|
+
fence markers inside HTML blocks first. A second view without those last two readings is read as well, because a
|
|
186
|
+
backtick they erase still counts in front of a command (`ssh host <code> ' </code> curl ... | sh`). `.rst`, `.adoc`,
|
|
187
|
+
`.html`, `.htm` and `.ipynb` are not Markdown, so no fence or HTML block cuts there.
|
|
188
|
+
|
|
189
|
+
### Accepted over-alerts and known gaps
|
|
190
|
+
|
|
191
|
+
The first four bullets are over-alerts, not misses: for the examples they give, the installer stays `critical`.
|
|
192
|
+
|
|
193
|
+
- A letter-apostrophe-letter (`change's`) after a path-like first word makes the rest of that stretch of prose
|
|
194
|
+
enclosed (`/format the change's notes`, `bin/evil doesn't work.`). Markup hides a relative path from the raw
|
|
195
|
+
reading, but an absolute path (`/usr/bin/x`, `./x`, `~/x`) or a typographic quote hides nothing, and the rendered
|
|
196
|
+
view reads a code span or emphasis away, so `` `src/main.py` doesn't read the config. `` before an installer is
|
|
197
|
+
`critical`. A shell would read the apostrophe as an unterminated string.
|
|
198
|
+
- A line led by a wrapper that runs what follows whose comment holds a quote (`uv run pytest # run the project's tests`;
|
|
199
|
+
not `npm test` or a plain-word host), and prose with an unmatched quote or backtick next to a bare path or a wrapper head.
|
|
200
|
+
- A table row with a quote, a closer or a stray `$(` or backtick opener followed by a paragraph joins that paragraph; a cell
|
|
201
|
+
after a command written as plain text or `<code>` is a stage of its pipeline; a `+` row inside a fenced `diff` block reads
|
|
202
|
+
as shell text.
|
|
203
|
+
- A tilde fence (the rendered view erases `~~~`), and any text with a gate shape.
|
|
204
|
+
- Known gaps: a hunk or text that starts inside a construct opened above what is visible; indented code read as
|
|
205
|
+
prose; commands that are not known wrappers (`awk`, `parallel`, `aws ssm`, `csh`, `lua -e`);
|
|
206
|
+
a patch whose removed lines or hunk headers sit between `curl U |` and `sh`; single-line sink forms such as
|
|
207
|
+
`| >/dev/null sh`, `| { sh; }`, `| setsid sh`, `| xargs -0 sh -c`; a comment word followed by a backslash; a
|
|
208
|
+
here-document on a multi-line compound command; a redirect after a here-document delimiter; more than about 300
|
|
209
|
+
characters of distinct words between `curl` and the pipe; a quoted-space argument (`' '`) that the padding fold turns into
|
|
210
|
+
a lone quote (`" "` and a string that ends in a blank are folded the same way), together with a quoted argument that runs
|
|
211
|
+
over a line; an argument that is a multi-line `$(...)`, backtick span, `${...}` or `<(...)` in front of the pipe; a quoted
|
|
212
|
+
`|#` among the arguments (the rest of the line reads as a comment); a `$'` that is an ANSI-C string in one place and `$$` followed by a quote in another. The quote-aware window reads a
|
|
213
|
+
backslash as a shell does and must keep doing so: reading `\'` as a plain character would catch some of these by
|
|
214
|
+
accident, but it closes strings a shell never opens, and the window built that way backtracked exponentially on
|
|
215
|
+
`$'a'` chains and cost hundreds of microseconds per character on `\"` chains.
|
|
216
|
+
- A table cell spells its pipe `\|`, and that is read as a pipe only in a table row that starts with a pipe; elsewhere `\|`
|
|
217
|
+
is an argument of the command (`curl \| URL | sh` is `critical`). So an installer written with `\|` in a table without
|
|
218
|
+
leading pipes, in a paragraph or in an HTML cell is `critical` too, although Markdown renders the `\|` as a pipe there. A
|
|
219
|
+
line that starts with a pipe inside a multi-line quoted argument is read as a table row, and its `\|` as a cell's pipe.
|
|
220
|
+
- A lone CR is a character of a word to a shell. The shell rules read the text once more with each lone CR as a letter
|
|
221
|
+
(`curl URL<CR>| sh` runs; `\<CR>` is an escaped CR), also for the planner tool and `diff_sentry scan`, which read a
|
|
222
|
+
file or stdin with the CR kept.
|
|
223
|
+
|
|
224
|
+
### Measured
|
|
225
|
+
|
|
226
|
+
- **Over-alert rate.** The uv installer inserted as its own paragraph into 2,400 real Markdown documents (7,200
|
|
227
|
+
insertions): about 2.5% read `critical` where the line-wide rule alone said `medium`, and none read `medium` where it
|
|
228
|
+
said `critical`. About a quarter of those come from a gate shape (under 1% of documents hold one). Without the
|
|
229
|
+
floor, about 3% of the insertions would have been softened that the line-wide rule kept `critical`. An installer
|
|
230
|
+
whose own line carries a quote (the rustup one) over-alerts more, roughly one insertion in eight on a similar corpus,
|
|
231
|
+
because a plain apostrophe in the prose above pairs with that quote.
|
|
232
|
+
- **Cost is linear.** The worst shape (`curl(` repeated) costs about 10 µs per character per channel (0.7 s for a 65 KB body,
|
|
233
|
+
about 20 s for a 2 MB patch); the hostile enclosure and gate shapes cost 2 to 17 µs per character; plain prose is
|
|
234
|
+
about 2 µs and a README 8 to 14. Tests hold the cost per character flat from 16 KiB to 128 KiB.
|
|
235
|
+
- **The lexer agrees with real shells.** The oracle (`DS_ORACLE=1`) has found no case of the lexer calling a construct closed
|
|
236
|
+
that `bash -n` or `dash -n` reports open since the last fix.
|
|
237
|
+
|
|
238
|
+
### How it is verified
|
|
239
|
+
|
|
240
|
+
- **The oracle** (`tests/test_oracle.py`, opt-in, Docker): seeded candidates run under the container's real `bash`
|
|
241
|
+
and `dash` with stub `curl`/`wget` and stub programs that only log, no network, no capabilities, a read-only root,
|
|
242
|
+
and a canary download before and after each batch. Four properties: recall in code files, recall in prose
|
|
243
|
+
(whatever a shell really runs stays `critical`), the lexer against `bash -n` / `dash -n`, and soundness of the
|
|
244
|
+
softening (whatever is reduced to `medium` fetches nothing but a known installer and calls nothing else). A new
|
|
245
|
+
spelling goes into `tests/oracle/grammar.py` first. The oracle sees under-detection and unsafe softening, never extra
|
|
246
|
+
alerts, and does not cover PowerShell, zsh, or effects only the real installer would show; guards that only add
|
|
247
|
+
alerts are pinned by unit tests instead.
|
|
248
|
+
- **Unit pins.** `tests/test_linewide.py` pins the floor against its source rule; `tests/test_gate_edges.py` pins the
|
|
249
|
+
gate, rendered view and shield patterns by the shortest texts on which a mutant of each differs;
|
|
250
|
+
`tests/test_enclosure.py`, `tests/test_shelllex.py` and `tests/test_metadata_rules.py` pin the rest end to end.
|
|
251
|
+
A mutant that no text tells from the code is an equivalent one (an alternative another covers, a possessive
|
|
252
|
+
quantifier, a class member under `re.IGNORECASE`, a cost bound).
|
|
253
|
+
|
|
254
|
+
## Other prose softening
|
|
255
|
+
|
|
256
|
+
All in `indicators._soften_prose`; a code file and a branch are never softened.
|
|
257
|
+
|
|
258
|
+
- **`prompt-injection`** whose only trigger is the bare phrase "system prompt" is `medium`: ordinary in a thread
|
|
259
|
+
about an LLM product. It is `high` when:
|
|
260
|
+
- a real override phrase is present (`ignore previous instructions`, `your system prompt`, `print the system
|
|
261
|
+
prompt`, `new instructions:`, and the previous / prior / earlier / the above family), which must name what is
|
|
262
|
+
dropped (instructions, rules, guidance, context) or ask for an action (approve, merge, lgtm, reply, print,
|
|
263
|
+
reveal, send, exfiltrate, grant, delete, execute). "Ignore all previous commits" and a bare "ignore the above"
|
|
264
|
+
are ordinary sentences;
|
|
265
|
+
- an imperative starts a sentence aimed at the phrase (`Ignore the system prompt ...`, `New system prompt: ...`):
|
|
266
|
+
the verb follows the start of the text or a line, punctuation, a quote, a markdown or diff marker, a table pipe,
|
|
267
|
+
a bullet, a vocative, or `could you` / `can you` / `you need to` / `you will`; never a hyphen, an apostrophe or a
|
|
268
|
+
word;
|
|
269
|
+
- it is addressed to a reviewer or a model: `please`, `you must/should`, or a name (`agents`, `Claude`, `the AI`,
|
|
270
|
+
`reviewers`) with a directive modal, then the verb. A name without a modal describes them. Not through a negation.
|
|
271
|
+
|
|
272
|
+
It stays `medium` when the verb starts a library title with a subordinate clause (`Ignore system prompt when
|
|
273
|
+
counting tokens`), when the phrase starts a longer name (`system prompt caching`) or is a whole table cell, and
|
|
274
|
+
when negated, except that a clause about the review itself (`when reviewing this PR`, `and merge`) makes it an
|
|
275
|
+
instruction. A variable (`SYSTEM_PROMPT`), a flag or a path is not the phrase.
|
|
276
|
+
- **`pwn-request`** is `medium`: a workflow shown in a body, a comment or a documentation file is not one that runs.
|
|
277
|
+
The same text in a workflow file stays `critical`.
|
|
278
|
+
- **`diff-viewport-evasion`** is `medium` in a comment only (a padded markdown table). In a file, including a
|
|
279
|
+
markdown file, it stays `high`.
|
|
280
|
+
- A planner re-scan of text that is not byte-identical to the baseline's window mints another id (a title is scanned
|
|
281
|
+
with the author appended by the baseline), and the stricter baseline hit for a code file always stays in the union.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "diff_sentry/watch.py"
|
|
4
|
+
- "diff_sentry/actors.py"
|
|
5
|
+
- "diff_sentry/snapshot.py"
|
|
6
|
+
- "diff_sentry/ingest.py"
|
|
7
|
+
- "diff_sentry/hosts.py"
|
|
8
|
+
- "diff_sentry/dbutil.py"
|
|
9
|
+
- "tests/test_watch.py"
|
|
10
|
+
- "tests/test_actors.py"
|
|
11
|
+
- "tests/test_snapshot.py"
|
|
12
|
+
- "tests/test_hosts.py"
|
|
13
|
+
- "tests/test_ingest_metadata.py"
|
|
14
|
+
- "tests/test_metadata_rules.py"
|
|
15
|
+
- "tests/test_campaign_replay.py"
|
|
16
|
+
- "tests/test_cli_watch.py"
|
|
17
|
+
- "tests/corpus/hackerbot_claw_campaign.json"
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Monitoring: the watcher, the actor store, the archive
|
|
21
|
+
|
|
22
|
+
- **All of it is host-side.** `watch`, `actors`, `snapshot` and the ingest enrichment run outside the
|
|
23
|
+
trajectory. The planner never queries the store, the queue, or GitHub. The one thing it receives is the
|
|
24
|
+
`actor_history` summary (repositories, outcomes, rule names, hours, and which profile FIELDS changed, never
|
|
25
|
+
the profile text), rendered as an untrusted section because the logins and refs in it came from users.
|
|
26
|
+
- **Cross-event facts are provenance, not a second signal.** `ActorStore.correlate` is read-only and runs
|
|
27
|
+
BEFORE `record`. Its output lands in `event["provenance"]` under plain keys (`actor_*`, `ioc_reuse`,
|
|
28
|
+
`content_revisions`, `evidence_scrubbed`) and `indicators.scan_provenance` turns them into baseline hits.
|
|
29
|
+
Never add a hits or signal path from the store. A change scanned on its own carries none of those keys
|
|
30
|
+
and fires none of those rules.
|
|
31
|
+
- **A deleted account is not an actor.** GitHub's `ghost` is one identity for every deleted user, so `actor_key`
|
|
32
|
+
keys a `ghost` change on the change itself: two unrelated deleted authors never share a history.
|
|
33
|
+
- **Examine oldest first.** Correlation reads what came before a change, so the listing (newest first) is
|
|
34
|
+
sorted ascending before it is examined. A burst examined newest-first would show its last member nothing.
|
|
35
|
+
- **Attribution follows the author of the text, decided structurally.** `IndicatorHit.via` names the commenter
|
|
36
|
+
and is set from the segment KIND in `normalize.content_segments_ex`; it is never parsed out of `location`,
|
|
37
|
+
which is a free-form file path an author can name `comment:alice`. A hit whose `via` is another account is
|
|
38
|
+
recorded against that commenter (`channel='comment'`, only when it reached the floor or fired a slash
|
|
39
|
+
command), and `correlate` reads only the author's own hits, so a drive-by's payload host does not link the
|
|
40
|
+
maintainer's next change and a moderator deleting the comment is not the author scrubbing evidence. A verdict
|
|
41
|
+
on a change whose floor-level evidence is only a third party's goes to that party (`finalize`).
|
|
42
|
+
- **A verdict clears an inference, never evidence.** (`actor-known-bad` is the one model-derived fact that
|
|
43
|
+
enters the baseline: it needs a stored `malicious` verdict, expires after 30 days, and does not apply to
|
|
44
|
+
`Bot` accounts.) A `benign` verdict clears the account-level flag
|
|
45
|
+
(`actor-prior-flag`, an inference about a person), except over a prompt injection, where the verdict may be
|
|
46
|
+
the injection working (`ActorStore._stands`, the only implementation). It never clears `ioc-reuse`, the
|
|
47
|
+
payload-host link: a local model has a real false-benign rate, and letting its verdict erase evidence would
|
|
48
|
+
let one missed payload launder a campaign. `actor-known-bad` needs a `malicious` verdict. The account-level
|
|
49
|
+
rules do not apply to `Bot` accounts; the change-level ones do.
|
|
50
|
+
- **The first copy is kept.** `SnapshotStore.put` runs before anything else touches a change; objects are never
|
|
51
|
+
rewritten and the index is append-only. An edit is a new revision with its own run id (`-r1`), so its trace never
|
|
52
|
+
overwrites the original's, and `prior_indicators` carry the earlier hits forward so a removed payload cannot
|
|
53
|
+
remove the evidence. `evidence-scrubbed` fires when a floor-level hit of an earlier revision is gone (matched by
|
|
54
|
+
rule and location, not id; a payload rewritten to read as prose counts as gone) and names the vanished hit ids,
|
|
55
|
+
so a later edit that still lacks them is the same finding and does not page again. Snapshot the event as
|
|
56
|
+
ingested, before correlation attaches facts, so the archive holds what GitHub said and not what we derived.
|
|
57
|
+
- **Time is the cost, not money.** With a local model every change reaches the model by default; the screen
|
|
58
|
+
sets the order (`_priority`), not eligibility. The caps (`deep_min_severity`, `max_queue`,
|
|
59
|
+
`max_deep_per_hour`) default off. A shed change keeps its `screened/` record and a `shed` state.
|
|
60
|
+
- **The screen is not a trajectory.** `cli.screen` writes `screened/{run_id}.json`, never a trace, so the
|
|
61
|
+
rollout corpus stays trajectories. Its early alert is `stage=screen`; the run's own is `stage=final`.
|
|
62
|
+
- **One model worker.** dspy's global configuration is bound to the thread that first set it up. The poller
|
|
63
|
+
is a second thread and touches no model. Do not add a worker pool without proving `setup` is safe there.
|
|
64
|
+
- **A failed change must not block the ones behind it, and must be retried.** A failure holds the watermark below it
|
|
65
|
+
and withholds the page-1 ETag (otherwise the next listing answers 304 and it is never retried), backs off
|
|
66
|
+
exponentially, and after `_MAX_FAILS` is parked as `error` so it stops holding the listing. The others are skipped
|
|
67
|
+
by their stored `updated_at`. A listing deeper than `_MAX_PAGES` is reported as truncated, never advanced past
|
|
68
|
+
silently. A rate-limit refusal (`429`, or `403` saying so) is not a failure of the change: wait for the reset, and
|
|
69
|
+
`_ingest` subtracts the calls it made from the budget the list responses report.
|
|
70
|
+
- **A read that failed is not a change.** `_comments` returns None on failure and the event carries
|
|
71
|
+
`comments_incomplete`; the watcher then keeps what it has instead of judging the empty thread as an edit.
|
|
72
|
+
Anything that feeds the digest must distinguish "none" from "could not read".
|
|
73
|
+
- **Run ids are unambiguous.** `owner__name-pr-7` (`-r1` per revision). Owners cannot contain `_`, so two
|
|
74
|
+
repositories never collide; the one-off `pr`/`issue` commands keep their older `owner-name-pr-7` scheme, which can.
|
|
75
|
+
- **A failed model run is retried, and pages nothing.** It backs off up to `_MAX_ATTEMPTS` (about four hours, so a
|
|
76
|
+
local model restarting does not fail the queue) with `cli.run` told `emit_failed=False`, then is buried
|
|
77
|
+
(`claimed=2`, event kept) for `--requeue-failed`. Claiming counts as an attempt, so an input that kills the process
|
|
78
|
+
cannot loop across restarts. The queue row stays claimed until the run completes, and `recover()` (called by
|
|
79
|
+
`serve` and `--once`, never by a read-only look) requeues it under a per-directory `flock`. Neither thread may die
|
|
80
|
+
on an unexpected exception.
|
|
81
|
+
- **Undelivered alerts are retried a few at a time.** `_retry_alerts` re-sends at most `_ALERT_RETRIES_PER_POLL`
|
|
82
|
+
(5) per poll, so a SIEM that hangs (a POST is bounded by 15 s) cannot stall polling for a whole backlog.
|
|
83
|
+
- **An alert counts once delivered.** `changes.alerted` gains ids only after the SIEM answered 2xx
|
|
84
|
+
(`EmitResult.ok`); a refusal or outage leaves `alert_pending`, re-sent from `screened/{run_id}.json` on later
|
|
85
|
+
polls. `emitted` alone is also True for a 5xx.
|
|
86
|
+
- **State files are versioned.** `dbutil.open_versioned` stamps `PRAGMA user_version` and refuses a file with
|
|
87
|
+
tables but another stamp; there is no migration, and the error says to move the directory aside.
|
|
88
|
+
- **Account age is measured as of the event.** `_age_days(created_at, as_of)` takes the event's own time;
|
|
89
|
+
the wall clock is only the fallback for a payload with none. A replay of a historical incident must
|
|
90
|
+
carry `created_at`, or every old attacker looks established (`test_campaign_replay` pins this).
|
|
91
|
+
- **Attacker-authored strings stay out of the MF1 sandwich.** Profile fields, branch names, and comments
|
|
92
|
+
are bounded at ingest and rendered in the untrusted section only. Comments are text-scanned only when
|
|
93
|
+
the change's author or an outsider wrote them and the commenter is not a `Bot`: maintainers quote
|
|
94
|
+
install commands and release notes constantly.
|
|
95
|
+
- **Data minimization.** The `email` fields of a profile and of commits are reduced to a domain in the event
|
|
96
|
+
and masked in the archive (`snapshot.scrub_emails`). Free text is archived as captured: it is evidence.
|
|
97
|
+
Nothing prunes the archive, the store, or queued events.
|
|
98
|
+
- **Adding a fact:** capture it in `ingest.py` (best-effort: a failed call omits the key, unless the value feeds
|
|
99
|
+
the digest, in which case mark the event incomplete instead of reporting "none"), put it in
|
|
100
|
+
`provenance` (or a bounded event field), read it in `scan_provenance` / `scan_metadata` with a negative
|
|
101
|
+
case, and keep it sub-floor unless it is standalone evidence. Replay the incident corpus.
|
|
102
|
+
- **Read-only means read-only.** `--stats` and `actor` open the state `mode=ro` (`open_versioned(read_only=True)`)
|
|
103
|
+
and neither create, stamp, nor requeue anything. Only `serve` and `--once` call `recover()`, under the lock.
|
|
104
|
+
- **Terminal output is sanitized.** `format_dossier` replaces control characters; `_json_out` escapes the C1 range
|
|
105
|
+
`json.dumps` leaves raw; URL and token parsing cut at the first control character. A string an attacker wrote
|
|
106
|
+
must not be able to drive the operator's terminal.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "diff_sentry/rubric.py"
|
|
4
|
+
- "diff_sentry/rl_export.py"
|
|
5
|
+
- "diff_sentry/response.py"
|
|
6
|
+
- "diff_sentry/assemble.py"
|
|
7
|
+
- "eval/**"
|
|
8
|
+
- "studio/**"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# ATLAS rubric labels and the eval member
|
|
12
|
+
|
|
13
|
+
## The rubric is a reward-free label surface
|
|
14
|
+
|
|
15
|
+
- `rl_export.rubric_signal` attaches the ATLAS 4-category (TF/TA/TG/PA) decomposition as LABELS: a fixed,
|
|
16
|
+
no-LLM skeleton in `run_start` meta (`rubric_to_meta(default_rubric())`, one criterion per category,
|
|
17
|
+
since the task is constant; there is no `generate_rubric` path) plus deterministic per-criterion
|
|
18
|
+
`criteria_facts`.
|
|
19
|
+
- The facts are a re-lens over `run_labels` / `run_metrics`, not a second derivation: `rubric.trace_facts`
|
|
20
|
+
reuses them, so a criterion's `observed` cannot drift from what a trainer reads. `_CATEGORY_LENS` maps
|
|
21
|
+
TF↔(verdict / signal / hit_iteration_cap), TA↔(scan / deep_classify / analyst / fetch / skill counts +
|
|
22
|
+
circuit-breaks + analyst failures), TG↔(indicator_count / max_indicator_severity / signal /
|
|
23
|
+
cited_unknown), PA↔(verdict / cited_unknown).
|
|
24
|
+
- `CriterionFact` has no score/met field; the trainer scores. Never add a reward/score/met field to any
|
|
25
|
+
rubric type.
|
|
26
|
+
- `rubric.py` is a dspy-free leaf: it imports only `.schema` at top, and its `rl_export` reuse is a
|
|
27
|
+
function-level import (that path is dspy-free too), so `response.py` can import it at top.
|
|
28
|
+
- `response._rubric` surfaces it as `DetectionResponse.rubric` (`RubricReport`); the studio shows it as a
|
|
29
|
+
"labels — not a score" card that adds no judgement. Keep the card legacy-guarded: a response without a
|
|
30
|
+
`rubric` renders nothing.
|
|
31
|
+
|
|
32
|
+
## The eval member measures; it never rewards
|
|
33
|
+
|
|
34
|
+
- `eval/` (`diff-sentry-eval`) scores a recorded run's assembled verdict with a fixed external ATLAS
|
|
35
|
+
LLM-as-judge: TF/TA/TG/PA on 0–10, TF primary, per-category means only, no composite, no threshold.
|
|
36
|
+
- It is a one-way reader: it reaches diff-sentry only through `verdict_from_events` / `run_labels` /
|
|
37
|
+
`run_metrics` / `AssembledVerdict`, and `diff_sentry` never imports `diff_sentry_eval`
|
|
38
|
+
(`eval/tests/test_boundary.py` enforces it).
|
|
39
|
+
- The judge is rubric-free (it never reads `rubric_signal`, which would bias the measure) and holds
|
|
40
|
+
read-never-execute: it assesses the classification statically and treats the change as untrusted.
|
|
41
|
+
- A run it cannot score (never finalized, no usable verdict, judge failed) is `unscored`, never a fake 0.
|
|
42
|
+
See `eval/README.md`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: release
|
|
3
|
+
description: Cut a diff-sentry release — bump the five version sites together, write the CHANGELOG entry, and publish the GitHub Release that ships PyPI + the Marketplace Action. Use when asked to release, bump the version, or tag vX.Y.Z.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cut a diff-sentry release
|
|
7
|
+
|
|
8
|
+
A release moves FIVE version sites together, plus a `CHANGELOG.md` entry:
|
|
9
|
+
|
|
10
|
+
1. `pyproject.toml` `[project].version`
|
|
11
|
+
2. `diff_sentry/__init__.py` `__version__`
|
|
12
|
+
3. `action.yml` `inputs.version.default` (decides which PyPI release a consumer's `uses: …@vX.Y.Z` installs)
|
|
13
|
+
4. `README.md` quickstart `uses: qazbnm456/diff-sentry@vX.Y.Z`
|
|
14
|
+
5. `uv.lock`, via `uv lock`
|
|
15
|
+
|
|
16
|
+
`release.yml` fails the build unless the tag, the built wheel's version, the installed `__version__`, and
|
|
17
|
+
the `action.yml` default all agree, and it checks that the installed console script flags a known-malicious
|
|
18
|
+
diff. Nothing gates the README `uses:` line: a stale one sends new adopters to the previous release, so
|
|
19
|
+
check it by hand.
|
|
20
|
+
|
|
21
|
+
## Steps
|
|
22
|
+
|
|
23
|
+
1. Freeze the boundary: `git log v<last>..HEAD`. Every `feat:` / `fix:` / `deps:` commit inside it is a
|
|
24
|
+
CHANGELOG candidate.
|
|
25
|
+
2. Write the `CHANGELOG.md` entry in the file's existing style: one sentence per user-visible outcome,
|
|
26
|
+
breaking changes and required actions explicit, no debugging story.
|
|
27
|
+
3. Bump sites 1–4, run `uv lock`, then verify: `uv run pytest`, the eval suite
|
|
28
|
+
(`uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests`), `uvx ruff check .`.
|
|
29
|
+
4. Commit (`chore: release X.Y.Z`) and push to `main` once the user approves. CI's `consumer install
|
|
30
|
+
path (PyPI)` job then goes RED on this commit, by design: it installs `action.yml`'s default version
|
|
31
|
+
from PyPI, which does not exist until step 5. Every other job must be green before you continue.
|
|
32
|
+
5. Create a DRAFT release (`gh release create vX.Y.Z --draft --target <release sha> --title … --notes-file
|
|
33
|
+
…`, notes = the CHANGELOG section). The user publishes it in the web UI with "Publish this Action to
|
|
34
|
+
the GitHub Marketplace" ticked (the CLI cannot tick it). Publishing is what builds, verifies, and
|
|
35
|
+
uploads to PyPI via Trusted Publishing, and the upload is irreversible.
|
|
36
|
+
6. After `release.yml` succeeds, re-run the failed consumer-install job
|
|
37
|
+
(`gh run rerun <ci run id> --failed`); green proves the published package installs and scans.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# diff-sentry: model roles and runtime knobs. Copy to .env; a live run needs these plus a Deno sandbox.
|
|
2
|
+
|
|
3
|
+
# --- Model roles (required for a live `pr`/`issue`/`classify` run) ---
|
|
4
|
+
# planner: a cheap, injection-resistant orchestrator that holds the diff in the REPL and triages.
|
|
5
|
+
DS_ROOT_LM=your-planner-model
|
|
6
|
+
# analyst: an expensive model reached via llm_query for a subtle source->sink (short questions only).
|
|
7
|
+
DS_SUB_LM=your-analyst-model
|
|
8
|
+
# One OpenAI-compatible proxy serves all roles. When DS_BASE_URL is set the model names are the plain
|
|
9
|
+
# ids your endpoint serves (e.g. qwen/qwen3-next), not litellm-prefixed.
|
|
10
|
+
DS_BASE_URL=https://your-openai-compatible-endpoint/v1
|
|
11
|
+
DS_API_KEY=sk-...
|
|
12
|
+
|
|
13
|
+
# --- OR: run planner + analyst on a Claude Pro/Max subscription (no API key) ---
|
|
14
|
+
# A role with the `claude-agent-sdk/<id>` prefix runs on your Claude subscription through the Claude
|
|
15
|
+
# Agent SDK (`rlm_harness.ClaudeAgentLM`, the `subscription` extra). Each call is a pure completion:
|
|
16
|
+
# no tools, no filesystem, no settings leakage. Prerequisites:
|
|
17
|
+
# - `uv sync --extra subscription`
|
|
18
|
+
# - the Claude Code CLI installed and logged in (`claude` then /login, or `claude setup-token` and
|
|
19
|
+
# export CLAUDE_CODE_OAUTH_TOKEN)
|
|
20
|
+
# - `unset ANTHROPIC_API_KEY`: the CLI prefers it over the subscription and would bill API credit,
|
|
21
|
+
# so the adapter refuses to start while it is set
|
|
22
|
+
# Prefer full model ids over aliases, which drift.
|
|
23
|
+
# DS_ROOT_LM=claude-agent-sdk/claude-sonnet-5 # planner on Sonnet 5
|
|
24
|
+
# DS_SUB_LM=claude-agent-sdk/claude-fable-5 # analyst on Fable 5
|
|
25
|
+
# DS_BASE_URL/DS_API_KEY then serve only the classifier, which always needs an OpenAI-compatible
|
|
26
|
+
# endpoint (a subscription-only run is not supported). Set DS_CLASSIFIER_LM to a real model id (plus
|
|
27
|
+
# DS_CLASSIFIER_BASE_URL/DS_CLASSIFIER_API_KEY if it is a separate box). Left unset, it inherits the
|
|
28
|
+
# `claude-agent-sdk/…` DS_SUB_LM and config loading fails with an actionable error.
|
|
29
|
+
# Budgets use the same env vars as proxy mode. Each call adds ~2-5s of CLI-spawn overhead, so keep
|
|
30
|
+
# these runs interactive, not batch. DS_PLANNER_MAX_TOKENS has no effect here: the Agent SDK exposes
|
|
31
|
+
# no token or sampling controls.
|
|
32
|
+
|
|
33
|
+
# --- Second-stage classifier (the swappable seam) ---
|
|
34
|
+
# Defaults to the analyst model on DS_BASE_URL/DS_API_KEY. Required when the analyst runs on the
|
|
35
|
+
# subscription (see above).
|
|
36
|
+
# DS_CLASSIFIER_LM=your-strong-classifier
|
|
37
|
+
# DS_CLASSIFIER_BASE_URL=https://your-classifier-endpoint/v1
|
|
38
|
+
# DS_CLASSIFIER_API_KEY=sk-...
|
|
39
|
+
# DS_CLASSIFIER_CIRCUIT_BREAK=4
|
|
40
|
+
|
|
41
|
+
# --- RLM runtime ---
|
|
42
|
+
# DS_INTERPRETER=pyodide # read-not-execute: the sandbox never runs the change. `deno` is an alias.
|
|
43
|
+
# DS_MAX_ITERATIONS=25
|
|
44
|
+
# DS_MAX_LLM_CALLS=8 # caps only analyst (llm_query) escalations
|
|
45
|
+
# DS_ADAPTER=json # portable structured output (OpenAI + vLLM/NIM)
|
|
46
|
+
# DS_PLANNER_MAX_TOKENS=16384 # generous, so a reasoning planner isn't truncated to empty content
|
|
47
|
+
|
|
48
|
+
# --- Enrichment fetch (off by default: an exfil surface on attacker-authored input; GitHub-allowlisted) ---
|
|
49
|
+
# DS_ENABLE_FETCH=false
|
|
50
|
+
# DS_FETCH_ALLOW_CIDRS=198.18.0.0/16 # only if behind a fake-IP proxy / split-DNS VPN
|
|
51
|
+
|
|
52
|
+
# --- Host-side SIEM emitter (opt-in; deterministic POST after the run) ---
|
|
53
|
+
# DS_SIEM_WEBHOOK_URL=https://your-siem/intake
|
|
54
|
+
# DS_SIEM_TOKEN=...
|
|
55
|
+
# DS_EMIT_ON=suspicious,malicious
|
|
56
|
+
|
|
57
|
+
# --- Watching a watchlist (`diff-sentry watch`) ---
|
|
58
|
+
# Time is the only cost with a local model, so the caps are OFF unless set.
|
|
59
|
+
# DS_WATCH_REPOS=owner/name,other/name # or --repos / --repos-file
|
|
60
|
+
# DS_WATCH_STATE_DIR=./output/state # watch.db, actors.db, snapshots/
|
|
61
|
+
# DS_WATCH_INTERVAL=60 # seconds between polls (raised if GitHub sends X-Poll-Interval)
|
|
62
|
+
# DS_WATCH_BACKFILL_HOURS=0 # on first sight of a repo, also examine changes this recent
|
|
63
|
+
# DS_WATCH_RECHECK_HOURS=24 # shorten the re-check schedule (15 min, 1 h, 6 h, 24 h); it cannot extend it
|
|
64
|
+
# DS_WATCH_DEEP_MIN_SEVERITY=info # info = every change goes to the model; raise it to gate on the screen
|
|
65
|
+
# DS_WATCH_MAX_QUEUE=0 # 0 = unbounded; otherwise the lowest priority is shed and recorded
|
|
66
|
+
# DS_WATCH_MAX_DEEP_PER_HOUR=0 # 0 = unlimited model runs per hour
|
|
67
|
+
# DS_WATCH_RATE_RESERVE=200 # pause polling when the API's remaining budget falls below this
|