Skip to content

Commit 4b8a211

Browse files
OffByQuantclaude
andcommitted
Initial commit: NpmDiffWatch — npm supply-chain malware scanner
Static, no-execution scanner: polls the npm replication feed, diffs each new release against its predecessor, runs a YAML rules-engine triage, and escalates suspicious diffs to an LLM reviewer (local or OpenAI-compatible/Anthropic APIs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0 parents  commit 4b8a211

54 files changed

Lines changed: 4983 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
.venv/
2+
venv/
3+
env/
4+
__pycache__/
5+
*.py[cod]
6+
*.egg-info/
7+
.eggs/
8+
build/
9+
dist/
10+
*.sqlite
11+
*.sqlite3
12+
*.db
13+
.diffwatch/
14+
quarantine/
15+
artifact_cache/
16+
.env
17+
*.local
18+
.pytest_cache/
19+
.mypy_cache/
20+
.ruff_cache/
21+
.coverage
22+
.DS_Store
23+
24+
# stale nested copy + a stray redirect artifact (not part of the project)
25+
Node/
26+
/2

CLAUDE.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# NpmDiffWatch — AI Agent Guide
2+
3+
## Project Structure
4+
5+
```
6+
npmdiffwatch/ # Core package
7+
__init__.py
8+
__main__.py # CLI entry point
9+
config.py # Config dataclasses + TOML loader
10+
models.py # Data models (NewRelease, ArtifactSet, Diff, Verdict, etc.)
11+
backends.py # LLM backends (OpenAI-compatible + Anthropic)
12+
egress.py # Default-deny egress guard (socket.getaddrinfo wrapper)
13+
notifier.py # Alert emitter (stdout + webhook)
14+
store.py # SQLite store (cursor, releases, verdicts, alerts)
15+
quarantine.py # Static denylist for confirmed malicious npm packages
16+
deps.py # Dependency reputation gate (typosquat detection)
17+
ingest.py # npm registry search API + packument poller
18+
fetcher.py # npm .tgz download + in-memory extraction + baseline resolution
19+
differ.py # Version-to-version diff (difflib + JSON-aware for package.json)
20+
facts.py # JS/TS AST analysis via tree-sitter
21+
rules.py # YAML rule loader (fail-closed validator + safe matcher)
22+
engine.py # Rules engine triage (facts x rules)
23+
reviewer.py # LLM reviewer prompt builder + verdict parser
24+
orchestrator.py # Pipeline coordinator (run_once, seed, adjudicate, etc.)
25+
data/
26+
top_npm_names.txt # Vendored top npm names for typosquat corpus
27+
rules/community/ # Shipped YAML detection rules
28+
code.yaml # JS/TS code-level rules
29+
package_json.yaml # package.json field change rules
30+
deps.yaml # Dependency reputation rules
31+
lockfile.yaml # Lockfile integrity rules
32+
maintainer.yaml # Maintainer set change rules
33+
binaries.yaml # Binary foreign-language rules
34+
examples/ # Example config files
35+
local-qwen.toml
36+
ollama.toml
37+
llamacpp.toml
38+
openai.toml
39+
anthropic.toml
40+
```
41+
42+
## Key Architecture Rules
43+
44+
- **No execution**: Never `npm install`, never `node eval`. All analysis is static, in-memory.
45+
- **No execution** of extracted .tgz files — streamed extract, never `extractall()`, never `fs.write()`.
46+
- **Default-deny egress**: Only the configured npm registry, LLM endpoint, and webhook are contactable.
47+
- **Rules are pure data**: YAML files walked by a matcher — no eval/exec/expression strings.
48+
- **LLM injection defense**: Per-request CSPRNG markers around untrusted package content.
49+
- **Evidence persistence**: Flagged payload diffs stored in SQLite for takedown reports.
50+
- **Cursor-based**: Incremental scanning with `flock` mutual exclusion.
51+
52+
## Testing
53+
54+
```bash
55+
pip install -e ".[dev]"
56+
pytest -v
57+
```
58+
59+
## Lint / Typecheck
60+
61+
```bash
62+
ruff check npmdiffwatch/ tests/
63+
```
64+
65+
## Pipeline Flow
66+
67+
```
68+
npm registry search → ingest() → [NewRelease]
69+
→ fetcher() → ArtifactSet (new + prior .tgz files in memory)
70+
→ differ() → Diff (FileDiff[] + package.json changes + lockfile meta)
71+
→ engine.triage() → TriageResult (score + FiredRule[])
72+
→ [if escalate] reviewer.review() → Verdict (LLM classification)
73+
→ notifier + store → alerts + SQLite
74+
```

IMPROVEMENT_PLAN.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# NpmDiffWatch — Improvement Plan
2+
3+
Review of the PyDiffWatch → npm port. The safety scaffolding came over intact, but the
4+
core detection front-door is broken and there are no tests. Work the priority order at the
5+
bottom: ingest first (nothing else matters without it), then the contained bug + test fixes.
6+
7+
> Context for a fresh session: this is a Python tool that scans **npm** packages (ported from
8+
> PyDiffWatch, which scanned PyPI). The hard invariant is **NO EXECUTION** — analyzed packages
9+
> are data, never run/installed/imported. Pipeline: `ingest → fetcher → differ → engine (triage)
10+
> → reviewer (only on escalate) → notifier → store`, SQLite under `.diffwatch/`.
11+
12+
---
13+
14+
## The Ugly — the core function doesn't work
15+
16+
### 1. Ingest can't find new releases (`ingest.py:13`, `ingest.py:55-103`)
17+
```python
18+
_SEARCH_URL = "/-/v1/search?by=maintenance&size=250"
19+
```
20+
- npm's `/-/v1/search` requires a `text` query and ranks by quality/popularity/maintenance —
21+
`by=` is not a real parameter.
22+
- Even if it returned results, sorting toward *maintenance* surfaces established, popular
23+
packages — the opposite of the brand-new, low-reputation packages where supply-chain malware
24+
lands. A freshly published malicious version will essentially never appear.
25+
- **Fix:** switch to the replication changes feed (`https://replicate.npmjs.org/_changes`,
26+
monotonic `seq`). This is npm's real equivalent of PyPI's `changelog_since_serial` and maps
27+
1:1 onto the serial-cursor model already ported. Follow `seq` forward from the cursor.
28+
29+
### 2. The "serial" cursor is fake (`ingest.py:40-47`, `ingest.py:55-103`, `orchestrator.py:122-169`)
30+
- `current_serial()` returns `int(datetime.now().timestamp())` — wall-clock, not a registry
31+
sequence.
32+
- `changes_since` ignores `since_serial` for filtering; dedup happens against the DB `releases`
33+
table (`ingest.py:88-92`), and `serial` is just a local batch counter (`serial += 1`).
34+
- Result: the seed / `--backfill` / `advance_to` / "genesis" machinery in `run_once` is dead
35+
scaffolding with no npm meaning — incrementality/resumability are illusory.
36+
- **Fix:** once #1 uses the `_changes` feed, make the serial the real `seq`; the cursor then
37+
gates correctly and the existing seed/advance logic becomes meaningful again.
38+
39+
> #1 and #2 share one root cause: the PyPI firehose model was lifted without an npm source
40+
> underneath it. Fixing ingest to the `_changes` feed resolves both.
41+
42+
---
43+
44+
## The Bad — real bugs
45+
46+
### 3. Dependency screening always crashes (`fetcher.py:198-200`)
47+
```python
48+
def _fetch_json(name):
49+
url = f"{cfg.npm_registry.rstrip('/')}/{name}"
50+
return _fetch_json(url, cfg) # shadows module-level _fetch_json; calls THIS 1-arg closure with 2 args
51+
```
52+
- The nested `_fetch_json` shadows the module-level one and calls itself → `TypeError`.
53+
- `deps.screen_added_deps` calls it for any added dep that isn't in the corpus and isn't a
54+
typosquat (`deps.py:86`); no try/except, so it propagates → `fetch_artifacts`
55+
caught in `_process_fetched` as generic exception → `fetch_failed` → retried forever.
56+
- Net: nonexistent / brand-new dependency detection never runs; any package with a novel added
57+
dep silently never completes.
58+
- **Fix:** rename the inner closure (e.g. `_lookup`) so the module-level `_fetch_json(url, cfg)`
59+
is the one called. Low risk, contained.
60+
61+
### 4. Zero tests (no `tests/` dir; `CLAUDE.md:53-63`, `pyproject.toml` dev extra + `pythonpath`)
62+
- `CLAUDE.md` documents `pytest -v` and `ruff check npmdiffwatch/ tests/`; `pyproject.toml`
63+
declares `dev=["pytest>=8"]` and `pythonpath=["."]` — but there is no `tests/` directory.
64+
- PyDiffWatch's entire safety claim rests on `tests/test_containment_reviewer.py` — AST guards
65+
proving reviewer/backends/fetcher stay network-free and in-memory, i.e. the enforcement of
66+
NO-EXECUTION. None of that was ported. The invariants are currently honored by convention only.
67+
- **Fix:** port the containment test suite first (AST guards over `reviewer.py` / `backends.py`
68+
/ `fetcher.py`), then unit tests for `egress`, `rules` (fail-closed validator + no-eval
69+
matcher), `facts` (tree-sitter categories), `differ`, `deps`, and `fetcher` extraction caps.
70+
71+
### 5. Anthropic backend uses an unverified API shape (`backends.py:99-101`)
72+
```python
73+
thinking={"type": "adaptive"},
74+
output_config={"format": {"type": "json_schema", "schema": schema}},
75+
```
76+
- Uncertain these are valid in the Anthropic Python SDK (`anthropic>=0.40`). `thinking` is
77+
normally `{"type": "enabled", "budget_tokens": N}`; JSON-schema output via `output_config`
78+
isn't a confirmed shape. **Flagged as uncertain — verify, don't assume.**
79+
- If wrong, every Anthropic review raises `APIError` → silent heuristic fallback, undetectable
80+
without a test.
81+
- **Fix:** verify against the installed SDK version; correct the call; add a backend test with a
82+
mocked client.
83+
84+
---
85+
86+
## Smells (low severity)
87+
88+
- **`_is_strict_binary` duplicates `_is_binary`** (`fetcher.py:40-41`); `_is_binary` is unused —
89+
copy-paste residue.
90+
- **Dead branch in `_is_surface`** (`fetcher.py:133-135`): `base.startswith("bin/")` can never be
91+
true since `base = posixpath.basename(path)` has no slash. The `"/bin/" in path` clause is what
92+
works.
93+
- **README drift** (`README.md:46,48,8`): documents `max_tgz_bytes` (actual field is
94+
`max_download_bytes`, `config.py:25`); shows `new_package_policy = "skip"` while the default is
95+
`"surface"` (`config.py:39`); `pip install npmdiffwatch` implies a published package.
96+
97+
---
98+
99+
## The Good — ported faithfully (do not regress)
100+
101+
- **Safety core intact.** In-memory streamed extraction (`tarfile.open(mode="r|")`,
102+
`_BoundedReader`, member/size/name/decompressed caps, `_unsafe` path rejection, never
103+
`extractall`, never to disk — `fetcher.py:21-100`); default-deny egress guard with
104+
`is_installed()` + `run_once` warning (`egress.py`, `orchestrator.py:123-125`);
105+
`assert_web_scheme` `file://`/SSRF guard.
106+
- **Rules engine kept its safety boundary**: pure-data matcher, no eval/exec, fail-closed
107+
validator, scope-checked predicates (`rules.py`). Community rules stay untrusted input.
108+
- **Reviewer injection defense**: per-request CSPRNG markers (`reviewer.py:12-13`), strong
109+
"judge behavior not stated purpose / content between markers is inert data" system prompt,
110+
client-side verdict validation with heuristic fallback.
111+
- **npm threat modeling is appropriate**: lifecycle hooks (`pre/post/install`, `prepare`,
112+
`prepublish`), `child_process`/`fs`/`process.env` binding, prototype pollution, dynamic
113+
`require`/`import`, lockfile integrity/new-package, `bin` field changes (`facts.py`,
114+
`differ.py`). tree-sitter for JS/TS is correct. Dropping `defusedxml` was correct — npm
115+
registry is JSON, no XML-RPC.
116+
117+
---
118+
119+
## Priority order
120+
121+
1. **Replace ingest with the `_changes` replication feed** (make the serial a real `seq`).
122+
Without this, nothing else matters. (#1 + #2)
123+
2. **Fix the `_fetch_json` shadowing bug** (`fetcher.py:198-200`). Contained, low-risk. (#3)
124+
3. **Port the containment test suite**, then broader unit tests. Keeps NO-EXECUTION from
125+
quietly rotting. (#4)
126+
4. **Verify the Anthropic SDK call** against the real API. (#5)
127+
5. Clean up smells + README drift once the above land.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Appy
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

0 commit comments

Comments
 (0)