diff-sentry 0.5.0__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/indicators.md +281 -0
- diff_sentry-0.6.0/.claude/rules/monitoring.md +106 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.claude/skills/release/SKILL.md +9 -4
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.env.example +12 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/AGENTS.md +44 -19
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/CHANGELOG.md +62 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/PKG-INFO +121 -6
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/README.md +120 -5
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/action.yml +1 -1
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/__init__.py +16 -6
- diff_sentry-0.6.0/diff_sentry/actors.py +544 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/assemble.py +2 -3
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/cli.py +287 -13
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/config.py +12 -0
- diff_sentry-0.6.0/diff_sentry/dbutil.py +42 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/detect.py +3 -1
- {diff_sentry-0.5.0 → 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.5.0 → diff_sentry-0.6.0}/diff_sentry/normalize.py +90 -6
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/response.py +25 -5
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/schema.py +26 -2
- diff_sentry-0.6.0/diff_sentry/shelllex.py +743 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/skills/ci-injection-patterns.md +12 -0
- {diff_sentry-0.5.0 → 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.5.0 → diff_sentry-0.6.0}/pyproject.toml +1 -1
- 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.6.0/tests/test_campaign_replay.py +150 -0
- diff_sentry-0.6.0/tests/test_cli_watch.py +443 -0
- 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.6.0/tests/test_gate_edges.py +373 -0
- diff_sentry-0.6.0/tests/test_hosts.py +195 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_indicators.py +10 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_ingest.py +9 -8
- 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.6.0/tests/test_oracle.py +241 -0
- diff_sentry-0.6.0/tests/test_oracle_harness.py +45 -0
- 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.6.0/tests/test_watch.py +1340 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/uv.lock +1 -1
- diff_sentry-0.5.0/.claude/rules/indicators.md +0 -42
- diff_sentry-0.5.0/diff_sentry/indicators.py +0 -712
- diff_sentry-0.5.0/diff_sentry/ingest.py +0 -213
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.claude/rules/detect-setup.md +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.claude/rules/handoff.md +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.claude/rules/rubric-and-eval.md +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.github/workflows/ci.yml +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.github/workflows/release.yml +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.gitignore +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/.python-version +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/LICENSE +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/VENDOR.md +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/__main__.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/deep_classify.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/fetch_tool.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/rl_export.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/rubric.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/diff_sentry/skills/prompt-injection-catalog.md +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/README.md +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/__init__.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/__main__.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/cli.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/judge.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/schema.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/score.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/diff_sentry_eval/taskset.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/pyproject.toml +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/taskset.example.json +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/conftest.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_boundary.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_cli.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_judge.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_schema.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_score.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/eval/tests/test_taskset.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/DESIGN.md +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/README.md +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/__init__.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/app.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/iterations.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/live.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/diff_sentry_studio/mapper.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/pyproject.toml +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/app.js +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/index.html +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/replay-core.js +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/run-core.js +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/style.css +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/trajectory.js +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/vendor/fonts/jetbrains-mono-400.woff2 +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/static/vendor/fonts/jetbrains-mono-700.woff2 +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/replay-core.test.js +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/run-core.test.js +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/static-contract.test.js +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/test_app.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/test_iterations.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/test_live.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/test_mapper.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/studio/tests/trajectory.test.js +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/__init__.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/conftest.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/corpus/change_corpus.json +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/corpus/hackerbot_claw_incident.json +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/fixtures/known-malicious.diff +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_assemble.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_cli.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_config.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_deep_classify.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_detect.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_detection_quality.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_emit.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_export.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_fetch_tool.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_forward_offline.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_incident_hackerbot_claw.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_injection_offline.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_normalize.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_provenance.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_response.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_rubric.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_schema.py +0 -0
- {diff_sentry-0.5.0 → diff_sentry-0.6.0}/tests/test_subscription.py +0 -0
|
@@ -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.
|
|
@@ -26,7 +26,12 @@ check it by hand.
|
|
|
26
26
|
breaking changes and required actions explicit, no debugging story.
|
|
27
27
|
3. Bump sites 1–4, run `uv lock`, then verify: `uv run pytest`, the eval suite
|
|
28
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.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
@@ -53,3 +53,15 @@ DS_API_KEY=sk-...
|
|
|
53
53
|
# DS_SIEM_WEBHOOK_URL=https://your-siem/intake
|
|
54
54
|
# DS_SIEM_TOKEN=...
|
|
55
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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# diff-sentry
|
|
1
|
+
# diff-sentry: agent guide
|
|
2
2
|
|
|
3
3
|
A BewAIre-style malicious-change detector built on `rlm-harness`. It classifies ONE GitHub change
|
|
4
4
|
(PR / issue / push) for malicious intent: the change is UNTRUSTED DATA held in a sandboxed REPL, the
|
|
@@ -11,18 +11,24 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
|
|
|
11
11
|
|
|
12
12
|
## Verify
|
|
13
13
|
|
|
14
|
-
- `uv run pytest
|
|
14
|
+
- `uv run pytest`: the full offline suite (`uv sync --group dev` first). No live LLM, network, or Deno:
|
|
15
15
|
dspy paths use DummyLM / rlm-harness's `ScriptedInterpreter`, transports are injected fakes, and
|
|
16
16
|
`tests/corpus/` pins the indicator suite's hit/miss behavior.
|
|
17
|
-
- `uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests
|
|
17
|
+
- `uv run --package diff-sentry-eval --extra dev python -m pytest eval/tests`: the eval member's own suite
|
|
18
18
|
(the `--package` is required; a root `uv run` does not install the member). Run it when you touch
|
|
19
19
|
`assemble.py`, `rl_export.py`, `rubric.py`, `schema.py`, or the trace payloads it scores.
|
|
20
|
-
- `
|
|
20
|
+
- `DS_ORACLE=1 uv run pytest tests/test_oracle.py`: the shell oracle (opt-in; needs Docker and the `python:3.11-slim`
|
|
21
|
+
image): candidate download-and-execute texts are run under a real `bash` and `dash` in a no-network, cap-dropped
|
|
22
|
+
container with a stub `curl`, and the rules are held to what actually ran (`DS_ORACLE_N` / `DS_ORACLE_SEED` scale
|
|
23
|
+
it). Run it when you touch the `curl-pipe-shell` patterns, the prose softening in `indicators.py` (`_soften_prose`
|
|
24
|
+
and the helpers it calls, the shape gate, the rendered view), `hosts.py`, `linewide.py`, or `shelllex.py` (the
|
|
25
|
+
lexer behind the enclosure check, held to what `bash -n` and `dash -n` say about generated fragments).
|
|
26
|
+
- `uvx ruff check .`: lint (line-length 110). CI runs it as its own job, next to both suites and the
|
|
21
27
|
node tests (`.github/workflows/ci.yml`).
|
|
22
28
|
- A live run needs role creds (`DS_*`, see `.env.example`), Deno (`brew install deno`), and `gh` for
|
|
23
29
|
`pr`/`issue` ingest. `render`/`export` are offline; `classify` ingests offline but classifies live.
|
|
24
30
|
|
|
25
|
-
## Running
|
|
31
|
+
## Running: always through the CLI
|
|
26
32
|
|
|
27
33
|
- Run via `cli` (`pr` / `issue` / `classify`), never an ad-hoc script. `cli.run(event, …)` is the
|
|
28
34
|
programmatic entry: it records `<out>/traces/{run_id}.jsonl` (dropping a stale one first, since
|
|
@@ -30,9 +36,12 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
|
|
|
30
36
|
after the run. It never raises: a crash still writes a `status=failed` response that can signal off the
|
|
31
37
|
evidence floor. Extend `cli.py` rather than driving `detect_from_event` / `build_response` directly.
|
|
32
38
|
- Offline: `python -m diff_sentry render <trace> <run_id>` re-renders a response;
|
|
33
|
-
`python -m diff_sentry export "output/traces/*.jsonl" ds.json` exports the reward-free dataset
|
|
39
|
+
`python -m diff_sentry export "output/traces/*.jsonl" ds.json` exports the reward-free dataset;
|
|
40
|
+
`actor <login>` prints an account's dossier and `archive verify` re-hashes the snapshot archive.
|
|
41
|
+
- Continuous collection goes through `watch`, which calls `cli.run` (model) or `cli.screen`
|
|
42
|
+
(deterministic, no creds) and never `detect_from_event` directly. `--screen-only` needs no model roles.
|
|
34
43
|
|
|
35
|
-
## Hard invariants
|
|
44
|
+
## Hard invariants: do not break
|
|
36
45
|
|
|
37
46
|
- **The change is DATA: read, never execute.** It is a REPL variable under the default `pyodide`
|
|
38
47
|
interpreter; nothing in it is run, built, or fetched-and-run. An embedded instruction is a
|
|
@@ -63,20 +72,33 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
|
|
|
63
72
|
localhost/metadata.
|
|
64
73
|
- **Indicators are deterministic, pure Python, and in-loop safe.** Rule-level invariants (severity
|
|
65
74
|
tuning, per-file pairing, `pwn-request`, the tool name) live in `.claude/rules/indicators.md`.
|
|
75
|
+
- **A documented installer is enclosed when the shell lexer OR the line-wide floor says so.** `linewide.py` is
|
|
76
|
+
the line-wide rule that preceded the lexer (a line with an odd number of apostrophes or double quotes, or a `<<`,
|
|
77
|
+
and a word that could run what follows, opens the sixty lines after it), kept unchanged. The lexer
|
|
78
|
+
(`shelllex.py`) reads quoting exactly in the raw text, but cannot see a quote that a YAML scalar, a string literal,
|
|
79
|
+
a `printf`, a variable or Markdown rendering turns into the one a shell is handed. The union makes the line-wide rule's
|
|
80
|
+
`critical` a lower bound, except for the removed lines, hunk headers (not their section heading) and `\ No newline` notes of a patch in
|
|
81
|
+
its rendered view (which is of the new file) and a NUL (the text is read without it, as the shells read it). Never drop or narrow the floor, never relax
|
|
82
|
+
the lexer's answer on top of it, and test the lexer's relaxations through quote-free openers
|
|
83
|
+
(`ssh evil.example $(`). See `.claude/rules/indicators.md`.
|
|
66
84
|
- **Models are ROLES configured by env**: `DS_ROOT_LM` planner, `DS_SUB_LM` analyst, `DS_CLASSIFIER_LM`
|
|
67
85
|
classifier (defaults to the analyst). Refer to them by role; no hardcoded model name.
|
|
68
|
-
- **The budget is hard: `max_retries=1`, no whole-RLM retry.** One change = one trajectory, so the
|
|
69
|
-
stays valid training data.
|
|
86
|
+
- **The budget is hard: `max_retries=1`, no whole-RLM retry inside a run.** One change = one trajectory, so the
|
|
87
|
+
trace stays valid training data. (`watch` may run a change again, but only when the previous attempt produced
|
|
88
|
+
no result at all, an infrastructure failure; `cli.run` resets that run id's trace first, so exactly one
|
|
89
|
+
trajectory per run id survives.) A failed run is infra before it is a schema bug; see
|
|
70
90
|
`.claude/rules/detect-setup.md` for how the failure names itself.
|
|
71
91
|
- **A second model-judgement is a TOOL, never the sub-LM.** `deep_classify` (on rlm-harness's
|
|
72
92
|
`make_model_tool`) is the swappable second-stage seam the planner chooses to call, so the decision is a
|
|
73
93
|
`tool_call` in the trajectory. The analyst intercept (`intercept_sub_lm`) is tracing-only, zero
|
|
74
94
|
transforms. Swapping the backend touches only `classify_backend` / `_selfclassify_chat`. The planner
|
|
75
95
|
passes distilled findings, never the whole diff.
|
|
76
|
-
- **GitHub ingest and SIEM emission are host-side plumbing, never planner tools.** `ingest.py`
|
|
77
|
-
to `gh` host-side (transport injectable); `emit.emit_signal` POSTs
|
|
78
|
-
|
|
79
|
-
plumbing.
|
|
96
|
+
- **GitHub ingest, monitoring, and SIEM emission are host-side plumbing, never planner tools.** `ingest.py`
|
|
97
|
+
shells out to `gh` host-side (transport injectable); `emit.emit_signal` POSTs and never raises; `watch`,
|
|
98
|
+
`actors` and `snapshot` poll, remember, and archive outside the trajectory. Only choices the policy makes
|
|
99
|
+
are tools; delivering a finished result is plumbing. What the store learns reaches the signal only as
|
|
100
|
+
provenance facts through `scan_provenance`, never as a second derivation (see
|
|
101
|
+
`.claude/rules/monitoring.md`).
|
|
80
102
|
- **This is a ROLLOUT source: trajectories, not reward.** `rl_export` passes `reward=None`; labels read
|
|
81
103
|
the assembled verdict and metrics are objective effort counters. `deep_classify` tool_calls train the
|
|
82
104
|
classifier; every other action is the planner's. A metric keeps ONE meaning across rlm-harness
|
|
@@ -88,10 +110,10 @@ rlm-harness is an exact PyPI pin (`rlm-harness==X.Y.Z` in `pyproject.toml`, lock
|
|
|
88
110
|
convention by editing or adding a skill; `detect.INSTRUCTIONS` holds only identity, the MISSION frame,
|
|
89
111
|
the tool cost model, the triage loop, and output-gating rules. Skills ship via
|
|
90
112
|
`packages = ["diff_sentry"]`; a force-include duplicates them and breaks the build.
|
|
91
|
-
- **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `assemble`,
|
|
92
|
-
`response`, `emit`, `ingest`, `
|
|
93
|
-
`import diff_sentry` must not import dspy (`ClassifyChange` / `setup` /
|
|
94
|
-
lazy PEP 562 re-exports).
|
|
113
|
+
- **Keep the dspy-free modules dspy-free.** `config`, `schema`, `normalize`, `indicators`, `shelllex`, `linewide`, `assemble`,
|
|
114
|
+
`response`, `emit`, `ingest`, `hosts`, `dbutil`, `actors`, `snapshot`, `watch`, `rl_export`, and `rubric`
|
|
115
|
+
must not import dspy at module top, and `import diff_sentry` must not import dspy (`ClassifyChange` / `setup` /
|
|
116
|
+
`run` / `screen` / `detect_from_event` are lazy PEP 562 re-exports).
|
|
95
117
|
- **The trace self-describes the run.** `run_start` meta carries the normalized event, instructions,
|
|
96
118
|
source echo, `baseline_indicators`, `emit_on`, role→model names, budgets, and the rubric skeleton, so
|
|
97
119
|
an offline re-render/export re-derives the same `signal` (reading `emit_on` from meta, never current
|
|
@@ -107,6 +129,7 @@ file before working in that area.
|
|
|
107
129
|
| Indicator rules, severities, corpus | `.claude/rules/indicators.md` |
|
|
108
130
|
| Model roles, subscription auth, run failures | `.claude/rules/detect-setup.md` |
|
|
109
131
|
| ATLAS rubric labels, eval member, studio rubric card | `.claude/rules/rubric-and-eval.md` |
|
|
132
|
+
| Watcher, actor store, snapshot archive, ingest enrichment | `.claude/rules/monitoring.md` |
|
|
110
133
|
| Cutting a release (five version sites) | `.claude/skills/release/SKILL.md` |
|
|
111
134
|
| What to keep across context compaction | `.claude/rules/handoff.md` |
|
|
112
135
|
|
|
@@ -118,8 +141,10 @@ file before working in that area.
|
|
|
118
141
|
- When this consumer needs a workaround, fix the reusable gap in rlm-harness generically; never
|
|
119
142
|
special-case diff-sentry there. Consumer values (`DS_*` roles, the verdict schema, indicator rules, the
|
|
120
143
|
SIEM payload shape) stay here.
|
|
121
|
-
-
|
|
122
|
-
|
|
144
|
+
- The deterministic tier ships as `cli.screen` and `watch` (host-side, in front of `cli.run`). Next
|
|
145
|
+
increments: an optional small single-shot model call inside that tier; a GH Archive backfill for account
|
|
146
|
+
history older than the Events API's 30 days; per-repository workflow awareness (which repos run
|
|
147
|
+
`issue_comment` or `pull_request_target` workflows) to weight the slash-command rule.
|
|
123
148
|
- Two workspace members, never in the `diff_sentry` wheel: `studio/` (`diff-sentry-studio`, the detection
|
|
124
149
|
console, behind its `live` extra) and `eval/` (`diff-sentry-eval`, the reward-free scorecard). Both read
|
|
125
150
|
the trace / `DetectionResponse` contract through the public surface only. Keep the root wheel at
|