adaptersentry 2.0.0__tar.gz → 2.0.2__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.
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/PKG-INFO +29 -19
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/README.md +28 -18
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/PKG-INFO +29 -19
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/hub_scanner.py +2 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/pyproject.toml +1 -1
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/detectors/intra_adapter.py +57 -11
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/result_sink.py +1 -1
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/combined_report.py +1 -1
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/worker.py +1 -1
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/names.py +11 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporters/json.py +1 -1
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporters/sarif.py +1 -1
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporters/text.py +4 -2
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/scanner.py +49 -12
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/schemas/result.py +8 -1
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/scoring/verdict.py +14 -7
- adaptersentry-2.0.2/src/adaptersentry/version.py +1 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/tests/test_bench.py +1 -1
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/tests/test_scanner.py +12 -1
- adaptersentry-2.0.0/src/adaptersentry/version.py +0 -1
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/LICENSE +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/SOURCES.txt +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/dependency_links.txt +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/entry_points.txt +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/requires.txt +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/adaptersentry.egg-info/top_level.txt +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/check_regression.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/corpus.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/harness.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/report.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/benchmarks/run_real.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/setup.cfg +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/__main__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/cli/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/cli/batch.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/cli/main.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/cli/scan.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/detectors/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/detectors/structural.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/cache.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/identity.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/manifest.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/orchestrator.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/orchestrator_ray.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/cache.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/identity.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/requests.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/features/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/features/lowrank_core.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/features/spectral.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/adapter_config.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/adapter_file.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/parsers/safetensors.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporters/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporting/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/reporting/sanitize.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/schemas/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/schemas/errors.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/schemas/severity.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/scoring/__init__.py +0 -0
- {adaptersentry-2.0.0 → adaptersentry-2.0.2}/tests/test_harness.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: adaptersentry
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Static security scanner for LoRA adapters — detects anomalous weight patterns in .safetensors files
|
|
5
5
|
License:
|
|
6
6
|
Apache License
|
|
@@ -246,7 +246,7 @@ Dynamic: license-file
|
|
|
246
246
|
|
|
247
247
|

|
|
248
248
|

|
|
249
|
-

|
|
250
250
|
|
|
251
251
|
AdapterSentry is a static security scanner for LoRA adapters distributed as `.safetensors`
|
|
252
252
|
files. A LoRA adapter is a small file that changes how a large language model behaves; anyone
|
|
@@ -257,7 +257,9 @@ loaded, without running the model and without knowing the trigger.
|
|
|
257
257
|
**v2.0.0** replaces the analysis engine: exact statistics of the effective update
|
|
258
258
|
ΔW = s·B·A (no sampling, no proxies), streaming analysis that scales to 70B–405B and MoE
|
|
259
259
|
models in tens of megabytes of memory, a comparison of every module with its own family inside
|
|
260
|
-
the adapter, structural findings, and a new result contract (`ScanResult 2.
|
|
260
|
+
the adapter, structural findings, and a new result contract (`ScanResult 2.x`).
|
|
261
|
+
**v2.0.1** recalibrates the intra-adapter verdict rule on 393 real adapters from the Hugging
|
|
262
|
+
Face Hub (see [Limits](#limits--read-before-relying-on-a-verdict)).
|
|
261
263
|
See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
|
|
262
264
|
|
|
263
265
|
---
|
|
@@ -266,18 +268,18 @@ See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
|
|
|
266
268
|
|
|
267
269
|
```text
|
|
268
270
|
$ adaptersentry scan adapter_model.safetensors
|
|
269
|
-
AdapterSentry 2.0.
|
|
271
|
+
AdapterSentry 2.0.1 · adapter_model.safetensors
|
|
270
272
|
================================================================
|
|
271
273
|
Verdict: REVIEW (MEDIUM) · confidence medium · behavioural check recommended
|
|
272
274
|
Status: ok · policy default · mode full
|
|
273
275
|
Why:
|
|
274
276
|
- [LOW] NO_REFERENCE_PROFILE: No calibrated reference for this model family; verdict relies on intra-adapter comparison
|
|
275
|
-
- [MEDIUM] INTRA_OUTLIER:
|
|
277
|
+
- [MEDIUM] INTRA_OUTLIER: Update concentrated on a few outputs, unlike the rest of its family (down_proj layer 47): concentration z = 21.3 ≥ 14.0 (threshold set at 5 % alerts on popular Hub adapters)
|
|
276
278
|
Adapter: peft_lora, family llama (config), 80 layers, r=64 (declared 64), α=128, scaling alpha_over_r, training trained
|
|
277
279
|
Coverage: 560/560 modules analysed; 0 tensor(s) not analysed
|
|
278
|
-
Intra: max
|
|
280
|
+
Intra: concentration z 21.3 · max single-feature z 64.0 · 9 outlier module(s) · energy Gini 0.41
|
|
279
281
|
Findings (1):
|
|
280
|
-
[MEDIUM]
|
|
282
|
+
[MEDIUM] INTRA_CONCENTRATION_SPIKE — Update concentrated on a few outputs, unlike its family (layer 47, down_proj)
|
|
281
283
|
```
|
|
282
284
|
*(Illustrative output.)*
|
|
283
285
|
|
|
@@ -292,7 +294,7 @@ level and what limits it. CI gates read `verdict.action`.
|
|
|
292
294
|
pip install adaptersentry
|
|
293
295
|
|
|
294
296
|
adaptersentry scan adapter_model.safetensors # text summary
|
|
295
|
-
adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.
|
|
297
|
+
adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.1.0
|
|
296
298
|
adaptersentry scan adapter_model.safetensors --format sarif \
|
|
297
299
|
--output adaptersentry.sarif --fail-on review # GitHub code scanning + CI gate
|
|
298
300
|
adaptersentry batch --input-dir adapters/ --workers 8 --fail-on review
|
|
@@ -349,10 +351,13 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
|
|
|
349
351
|
spiky rows are not missed), and how concentrated the update is on a few output rows or
|
|
350
352
|
input columns — for `lm_head` and embeddings, on a few tokens.
|
|
351
353
|
4. **Compare within the adapter.** Each module is compared with its family (all `q_proj`, all
|
|
352
|
-
`down_proj`, …): the
|
|
353
|
-
z-scores (median/MAD)
|
|
354
|
-
|
|
355
|
-
|
|
354
|
+
`down_proj`, …): the trend with depth is removed with a Theil–Sen line, then robust
|
|
355
|
+
z-scores (median/MAD) are computed for every feature. Real adapters differ a lot between
|
|
356
|
+
modules of one family, so a single unusual feature is reported but does not change the
|
|
357
|
+
verdict. The verdict looks for one pattern: a module whose update is **concentrated on a
|
|
358
|
+
few outputs** — entry kurtosis, top-8 row share and row Hoyer all rise against the family
|
|
359
|
+
(*concentration z*). This needs no reference data and is insensitive to the adapter's
|
|
360
|
+
overall strength. It runs for families of at least 16 modules.
|
|
356
361
|
5. **Structural findings** that numbers cannot show: complete `lm_head` / embedding / router
|
|
357
362
|
matrices shipped in the adapter, LoRA on a mixture-of-experts router, ranks that differ from
|
|
358
363
|
the declared `r` (typical of merged adapters), code/pickle/archive files next to the adapter.
|
|
@@ -361,7 +366,7 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
|
|
|
361
366
|
| Situation | Action |
|
|
362
367
|
|---|---|
|
|
363
368
|
| File could not be parsed, or part of it was not analysed | at least `review` |
|
|
364
|
-
| A module
|
|
369
|
+
| A module's update is concentrated on a few outputs, unlike its family (concentration z ≥ 14) | `review` |
|
|
365
370
|
| Structural red flag | `review`; `block` with `--policy strict` |
|
|
366
371
|
| Reference profile for the model family says p ≤ α | `review` / `block` *(reference profiles: next release)* |
|
|
367
372
|
| Otherwise | `allow` |
|
|
@@ -386,10 +391,15 @@ needed ~3.3 GB just to load the same file.
|
|
|
386
391
|
random seeds can exceed the signal of a backdoor. A low-risk verdict is not evidence that an
|
|
387
392
|
adapter is safe; behavioural verification in an isolated environment remains necessary for
|
|
388
393
|
adapters you do not trust.
|
|
389
|
-
- **
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
394
|
+
- **Calibration is partial.** The intra-adapter threshold (concentration z ≥ 14) was set on
|
|
395
|
+
393 popular PEFT adapters from the Hugging Face Hub (October 2026; mostly Qwen and Llama
|
|
396
|
+
up to 14B), presumed benign but not verified: about **5 % of them get `review`** from this
|
|
397
|
+
rule. Larger models and other families may behave differently. Detection was measured only
|
|
398
|
+
on a toy payload inserted into those adapters (one LoRA direction redirected into 4 outputs
|
|
399
|
+
of a mid-depth `down_proj`): **71 %** found when the payload is strong, **≤ 3 %** when it is
|
|
400
|
+
as strong as an ordinary direction. Real backdoors are not toy payloads; a measured
|
|
401
|
+
detection rate on labelled clean/poisoned adapters and reference profiles per model family
|
|
402
|
+
are the next steps. Until then the confidence of a verdict is at most `medium`.
|
|
393
403
|
- **An attacker who knows the detector** can try to spread a backdoor across many directions
|
|
394
404
|
and modules, or pad the update with inert components. Static analysis without base-model
|
|
395
405
|
information cannot fully rule this out.
|
|
@@ -401,7 +411,7 @@ needed ~3.3 GB just to load the same file.
|
|
|
401
411
|
|
|
402
412
|
---
|
|
403
413
|
|
|
404
|
-
## Output: ScanResult 2.
|
|
414
|
+
## Output: ScanResult 2.1.0
|
|
405
415
|
|
|
406
416
|
`--format json` produces one document per adapter: `scan` (who/when/how), `artifact` (hashes,
|
|
407
417
|
provenance — file name only unless `--full-paths`), `adapter` (format, family, rank, α,
|
|
@@ -410,7 +420,7 @@ scaling, modules), `status` + `coverage` (what was and was not analysed), `verdi
|
|
|
410
420
|
optional `modules`, and `errors`.
|
|
411
421
|
|
|
412
422
|
- Field reference: [docs/output-schema/scan-result.md](docs/output-schema/scan-result.md)
|
|
413
|
-
- JSON Schema: [docs/output-schema/scan-result-2.
|
|
423
|
+
- JSON Schema: [docs/output-schema/scan-result-2.1.0.schema.json](docs/output-schema/scan-result-2.1.0.schema.json)
|
|
414
424
|
- `adaptersentry.load_scan_result()` validates a document and rejects other major versions.
|
|
415
425
|
|
|
416
426
|
---
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|

|
|
5
|
-

|
|
6
6
|
|
|
7
7
|
AdapterSentry is a static security scanner for LoRA adapters distributed as `.safetensors`
|
|
8
8
|
files. A LoRA adapter is a small file that changes how a large language model behaves; anyone
|
|
@@ -13,7 +13,9 @@ loaded, without running the model and without knowing the trigger.
|
|
|
13
13
|
**v2.0.0** replaces the analysis engine: exact statistics of the effective update
|
|
14
14
|
ΔW = s·B·A (no sampling, no proxies), streaming analysis that scales to 70B–405B and MoE
|
|
15
15
|
models in tens of megabytes of memory, a comparison of every module with its own family inside
|
|
16
|
-
the adapter, structural findings, and a new result contract (`ScanResult 2.
|
|
16
|
+
the adapter, structural findings, and a new result contract (`ScanResult 2.x`).
|
|
17
|
+
**v2.0.1** recalibrates the intra-adapter verdict rule on 393 real adapters from the Hugging
|
|
18
|
+
Face Hub (see [Limits](#limits--read-before-relying-on-a-verdict)).
|
|
17
19
|
See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
|
|
18
20
|
|
|
19
21
|
---
|
|
@@ -22,18 +24,18 @@ See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
|
|
|
22
24
|
|
|
23
25
|
```text
|
|
24
26
|
$ adaptersentry scan adapter_model.safetensors
|
|
25
|
-
AdapterSentry 2.0.
|
|
27
|
+
AdapterSentry 2.0.1 · adapter_model.safetensors
|
|
26
28
|
================================================================
|
|
27
29
|
Verdict: REVIEW (MEDIUM) · confidence medium · behavioural check recommended
|
|
28
30
|
Status: ok · policy default · mode full
|
|
29
31
|
Why:
|
|
30
32
|
- [LOW] NO_REFERENCE_PROFILE: No calibrated reference for this model family; verdict relies on intra-adapter comparison
|
|
31
|
-
- [MEDIUM] INTRA_OUTLIER:
|
|
33
|
+
- [MEDIUM] INTRA_OUTLIER: Update concentrated on a few outputs, unlike the rest of its family (down_proj layer 47): concentration z = 21.3 ≥ 14.0 (threshold set at 5 % alerts on popular Hub adapters)
|
|
32
34
|
Adapter: peft_lora, family llama (config), 80 layers, r=64 (declared 64), α=128, scaling alpha_over_r, training trained
|
|
33
35
|
Coverage: 560/560 modules analysed; 0 tensor(s) not analysed
|
|
34
|
-
Intra: max
|
|
36
|
+
Intra: concentration z 21.3 · max single-feature z 64.0 · 9 outlier module(s) · energy Gini 0.41
|
|
35
37
|
Findings (1):
|
|
36
|
-
[MEDIUM]
|
|
38
|
+
[MEDIUM] INTRA_CONCENTRATION_SPIKE — Update concentrated on a few outputs, unlike its family (layer 47, down_proj)
|
|
37
39
|
```
|
|
38
40
|
*(Illustrative output.)*
|
|
39
41
|
|
|
@@ -48,7 +50,7 @@ level and what limits it. CI gates read `verdict.action`.
|
|
|
48
50
|
pip install adaptersentry
|
|
49
51
|
|
|
50
52
|
adaptersentry scan adapter_model.safetensors # text summary
|
|
51
|
-
adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.
|
|
53
|
+
adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.1.0
|
|
52
54
|
adaptersentry scan adapter_model.safetensors --format sarif \
|
|
53
55
|
--output adaptersentry.sarif --fail-on review # GitHub code scanning + CI gate
|
|
54
56
|
adaptersentry batch --input-dir adapters/ --workers 8 --fail-on review
|
|
@@ -105,10 +107,13 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
|
|
|
105
107
|
spiky rows are not missed), and how concentrated the update is on a few output rows or
|
|
106
108
|
input columns — for `lm_head` and embeddings, on a few tokens.
|
|
107
109
|
4. **Compare within the adapter.** Each module is compared with its family (all `q_proj`, all
|
|
108
|
-
`down_proj`, …): the
|
|
109
|
-
z-scores (median/MAD)
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
`down_proj`, …): the trend with depth is removed with a Theil–Sen line, then robust
|
|
111
|
+
z-scores (median/MAD) are computed for every feature. Real adapters differ a lot between
|
|
112
|
+
modules of one family, so a single unusual feature is reported but does not change the
|
|
113
|
+
verdict. The verdict looks for one pattern: a module whose update is **concentrated on a
|
|
114
|
+
few outputs** — entry kurtosis, top-8 row share and row Hoyer all rise against the family
|
|
115
|
+
(*concentration z*). This needs no reference data and is insensitive to the adapter's
|
|
116
|
+
overall strength. It runs for families of at least 16 modules.
|
|
112
117
|
5. **Structural findings** that numbers cannot show: complete `lm_head` / embedding / router
|
|
113
118
|
matrices shipped in the adapter, LoRA on a mixture-of-experts router, ranks that differ from
|
|
114
119
|
the declared `r` (typical of merged adapters), code/pickle/archive files next to the adapter.
|
|
@@ -117,7 +122,7 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
|
|
|
117
122
|
| Situation | Action |
|
|
118
123
|
|---|---|
|
|
119
124
|
| File could not be parsed, or part of it was not analysed | at least `review` |
|
|
120
|
-
| A module
|
|
125
|
+
| A module's update is concentrated on a few outputs, unlike its family (concentration z ≥ 14) | `review` |
|
|
121
126
|
| Structural red flag | `review`; `block` with `--policy strict` |
|
|
122
127
|
| Reference profile for the model family says p ≤ α | `review` / `block` *(reference profiles: next release)* |
|
|
123
128
|
| Otherwise | `allow` |
|
|
@@ -142,10 +147,15 @@ needed ~3.3 GB just to load the same file.
|
|
|
142
147
|
random seeds can exceed the signal of a backdoor. A low-risk verdict is not evidence that an
|
|
143
148
|
adapter is safe; behavioural verification in an isolated environment remains necessary for
|
|
144
149
|
adapters you do not trust.
|
|
145
|
-
- **
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
- **Calibration is partial.** The intra-adapter threshold (concentration z ≥ 14) was set on
|
|
151
|
+
393 popular PEFT adapters from the Hugging Face Hub (October 2026; mostly Qwen and Llama
|
|
152
|
+
up to 14B), presumed benign but not verified: about **5 % of them get `review`** from this
|
|
153
|
+
rule. Larger models and other families may behave differently. Detection was measured only
|
|
154
|
+
on a toy payload inserted into those adapters (one LoRA direction redirected into 4 outputs
|
|
155
|
+
of a mid-depth `down_proj`): **71 %** found when the payload is strong, **≤ 3 %** when it is
|
|
156
|
+
as strong as an ordinary direction. Real backdoors are not toy payloads; a measured
|
|
157
|
+
detection rate on labelled clean/poisoned adapters and reference profiles per model family
|
|
158
|
+
are the next steps. Until then the confidence of a verdict is at most `medium`.
|
|
149
159
|
- **An attacker who knows the detector** can try to spread a backdoor across many directions
|
|
150
160
|
and modules, or pad the update with inert components. Static analysis without base-model
|
|
151
161
|
information cannot fully rule this out.
|
|
@@ -157,7 +167,7 @@ needed ~3.3 GB just to load the same file.
|
|
|
157
167
|
|
|
158
168
|
---
|
|
159
169
|
|
|
160
|
-
## Output: ScanResult 2.
|
|
170
|
+
## Output: ScanResult 2.1.0
|
|
161
171
|
|
|
162
172
|
`--format json` produces one document per adapter: `scan` (who/when/how), `artifact` (hashes,
|
|
163
173
|
provenance — file name only unless `--full-paths`), `adapter` (format, family, rank, α,
|
|
@@ -166,7 +176,7 @@ scaling, modules), `status` + `coverage` (what was and was not analysed), `verdi
|
|
|
166
176
|
optional `modules`, and `errors`.
|
|
167
177
|
|
|
168
178
|
- Field reference: [docs/output-schema/scan-result.md](docs/output-schema/scan-result.md)
|
|
169
|
-
- JSON Schema: [docs/output-schema/scan-result-2.
|
|
179
|
+
- JSON Schema: [docs/output-schema/scan-result-2.1.0.schema.json](docs/output-schema/scan-result-2.1.0.schema.json)
|
|
170
180
|
- `adaptersentry.load_scan_result()` validates a document and rejects other major versions.
|
|
171
181
|
|
|
172
182
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: adaptersentry
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Summary: Static security scanner for LoRA adapters — detects anomalous weight patterns in .safetensors files
|
|
5
5
|
License:
|
|
6
6
|
Apache License
|
|
@@ -246,7 +246,7 @@ Dynamic: license-file
|
|
|
246
246
|
|
|
247
247
|

|
|
248
248
|

|
|
249
|
-

|
|
250
250
|
|
|
251
251
|
AdapterSentry is a static security scanner for LoRA adapters distributed as `.safetensors`
|
|
252
252
|
files. A LoRA adapter is a small file that changes how a large language model behaves; anyone
|
|
@@ -257,7 +257,9 @@ loaded, without running the model and without knowing the trigger.
|
|
|
257
257
|
**v2.0.0** replaces the analysis engine: exact statistics of the effective update
|
|
258
258
|
ΔW = s·B·A (no sampling, no proxies), streaming analysis that scales to 70B–405B and MoE
|
|
259
259
|
models in tens of megabytes of memory, a comparison of every module with its own family inside
|
|
260
|
-
the adapter, structural findings, and a new result contract (`ScanResult 2.
|
|
260
|
+
the adapter, structural findings, and a new result contract (`ScanResult 2.x`).
|
|
261
|
+
**v2.0.1** recalibrates the intra-adapter verdict rule on 393 real adapters from the Hugging
|
|
262
|
+
Face Hub (see [Limits](#limits--read-before-relying-on-a-verdict)).
|
|
261
263
|
See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
|
|
262
264
|
|
|
263
265
|
---
|
|
@@ -266,18 +268,18 @@ See [CHANGELOG.md](CHANGELOG.md) and [Migrating from 1.x](#migrating-from-1x).
|
|
|
266
268
|
|
|
267
269
|
```text
|
|
268
270
|
$ adaptersentry scan adapter_model.safetensors
|
|
269
|
-
AdapterSentry 2.0.
|
|
271
|
+
AdapterSentry 2.0.1 · adapter_model.safetensors
|
|
270
272
|
================================================================
|
|
271
273
|
Verdict: REVIEW (MEDIUM) · confidence medium · behavioural check recommended
|
|
272
274
|
Status: ok · policy default · mode full
|
|
273
275
|
Why:
|
|
274
276
|
- [LOW] NO_REFERENCE_PROFILE: No calibrated reference for this model family; verdict relies on intra-adapter comparison
|
|
275
|
-
- [MEDIUM] INTRA_OUTLIER:
|
|
277
|
+
- [MEDIUM] INTRA_OUTLIER: Update concentrated on a few outputs, unlike the rest of its family (down_proj layer 47): concentration z = 21.3 ≥ 14.0 (threshold set at 5 % alerts on popular Hub adapters)
|
|
276
278
|
Adapter: peft_lora, family llama (config), 80 layers, r=64 (declared 64), α=128, scaling alpha_over_r, training trained
|
|
277
279
|
Coverage: 560/560 modules analysed; 0 tensor(s) not analysed
|
|
278
|
-
Intra: max
|
|
280
|
+
Intra: concentration z 21.3 · max single-feature z 64.0 · 9 outlier module(s) · energy Gini 0.41
|
|
279
281
|
Findings (1):
|
|
280
|
-
[MEDIUM]
|
|
282
|
+
[MEDIUM] INTRA_CONCENTRATION_SPIKE — Update concentrated on a few outputs, unlike its family (layer 47, down_proj)
|
|
281
283
|
```
|
|
282
284
|
*(Illustrative output.)*
|
|
283
285
|
|
|
@@ -292,7 +294,7 @@ level and what limits it. CI gates read `verdict.action`.
|
|
|
292
294
|
pip install adaptersentry
|
|
293
295
|
|
|
294
296
|
adaptersentry scan adapter_model.safetensors # text summary
|
|
295
|
-
adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.
|
|
297
|
+
adaptersentry scan adapter_model.safetensors --format json # ScanResult 2.1.0
|
|
296
298
|
adaptersentry scan adapter_model.safetensors --format sarif \
|
|
297
299
|
--output adaptersentry.sarif --fail-on review # GitHub code scanning + CI gate
|
|
298
300
|
adaptersentry batch --input-dir adapters/ --workers 8 --fail-on review
|
|
@@ -349,10 +351,13 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
|
|
|
349
351
|
spiky rows are not missed), and how concentrated the update is on a few output rows or
|
|
350
352
|
input columns — for `lm_head` and embeddings, on a few tokens.
|
|
351
353
|
4. **Compare within the adapter.** Each module is compared with its family (all `q_proj`, all
|
|
352
|
-
`down_proj`, …): the
|
|
353
|
-
z-scores (median/MAD)
|
|
354
|
-
|
|
355
|
-
|
|
354
|
+
`down_proj`, …): the trend with depth is removed with a Theil–Sen line, then robust
|
|
355
|
+
z-scores (median/MAD) are computed for every feature. Real adapters differ a lot between
|
|
356
|
+
modules of one family, so a single unusual feature is reported but does not change the
|
|
357
|
+
verdict. The verdict looks for one pattern: a module whose update is **concentrated on a
|
|
358
|
+
few outputs** — entry kurtosis, top-8 row share and row Hoyer all rise against the family
|
|
359
|
+
(*concentration z*). This needs no reference data and is insensitive to the adapter's
|
|
360
|
+
overall strength. It runs for families of at least 16 modules.
|
|
356
361
|
5. **Structural findings** that numbers cannot show: complete `lm_head` / embedding / router
|
|
357
362
|
matrices shipped in the adapter, LoRA on a mixture-of-experts router, ranks that differ from
|
|
358
363
|
the declared `r` (typical of merged adapters), code/pickle/archive files next to the adapter.
|
|
@@ -361,7 +366,7 @@ file ─► validate ─► inventory ─► per module (streamed) ─► compar
|
|
|
361
366
|
| Situation | Action |
|
|
362
367
|
|---|---|
|
|
363
368
|
| File could not be parsed, or part of it was not analysed | at least `review` |
|
|
364
|
-
| A module
|
|
369
|
+
| A module's update is concentrated on a few outputs, unlike its family (concentration z ≥ 14) | `review` |
|
|
365
370
|
| Structural red flag | `review`; `block` with `--policy strict` |
|
|
366
371
|
| Reference profile for the model family says p ≤ α | `review` / `block` *(reference profiles: next release)* |
|
|
367
372
|
| Otherwise | `allow` |
|
|
@@ -386,10 +391,15 @@ needed ~3.3 GB just to load the same file.
|
|
|
386
391
|
random seeds can exceed the signal of a backdoor. A low-risk verdict is not evidence that an
|
|
387
392
|
adapter is safe; behavioural verification in an isolated environment remains necessary for
|
|
388
393
|
adapters you do not trust.
|
|
389
|
-
- **
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
394
|
+
- **Calibration is partial.** The intra-adapter threshold (concentration z ≥ 14) was set on
|
|
395
|
+
393 popular PEFT adapters from the Hugging Face Hub (October 2026; mostly Qwen and Llama
|
|
396
|
+
up to 14B), presumed benign but not verified: about **5 % of them get `review`** from this
|
|
397
|
+
rule. Larger models and other families may behave differently. Detection was measured only
|
|
398
|
+
on a toy payload inserted into those adapters (one LoRA direction redirected into 4 outputs
|
|
399
|
+
of a mid-depth `down_proj`): **71 %** found when the payload is strong, **≤ 3 %** when it is
|
|
400
|
+
as strong as an ordinary direction. Real backdoors are not toy payloads; a measured
|
|
401
|
+
detection rate on labelled clean/poisoned adapters and reference profiles per model family
|
|
402
|
+
are the next steps. Until then the confidence of a verdict is at most `medium`.
|
|
393
403
|
- **An attacker who knows the detector** can try to spread a backdoor across many directions
|
|
394
404
|
and modules, or pad the update with inert components. Static analysis without base-model
|
|
395
405
|
information cannot fully rule this out.
|
|
@@ -401,7 +411,7 @@ needed ~3.3 GB just to load the same file.
|
|
|
401
411
|
|
|
402
412
|
---
|
|
403
413
|
|
|
404
|
-
## Output: ScanResult 2.
|
|
414
|
+
## Output: ScanResult 2.1.0
|
|
405
415
|
|
|
406
416
|
`--format json` produces one document per adapter: `scan` (who/when/how), `artifact` (hashes,
|
|
407
417
|
provenance — file name only unless `--full-paths`), `adapter` (format, family, rank, α,
|
|
@@ -410,7 +420,7 @@ scaling, modules), `status` + `coverage` (what was and was not analysed), `verdi
|
|
|
410
420
|
optional `modules`, and `errors`.
|
|
411
421
|
|
|
412
422
|
- Field reference: [docs/output-schema/scan-result.md](docs/output-schema/scan-result.md)
|
|
413
|
-
- JSON Schema: [docs/output-schema/scan-result-2.
|
|
423
|
+
- JSON Schema: [docs/output-schema/scan-result-2.1.0.schema.json](docs/output-schema/scan-result-2.1.0.schema.json)
|
|
414
424
|
- `adaptersentry.load_scan_result()` validates a document and rejects other major versions.
|
|
415
425
|
|
|
416
426
|
---
|
|
@@ -122,6 +122,7 @@ class ScanResult:
|
|
|
122
122
|
level: str | None = None # verdict.level: LOW | MEDIUM | HIGH | CRITICAL
|
|
123
123
|
training_state: str | None = None # adapter.training_state
|
|
124
124
|
max_robust_z: float | None = None # anomaly.intra.max_robust_z (None when not computed)
|
|
125
|
+
max_concentration_z: float | None = None # anomaly.intra.max_concentration_z (drives the verdict)
|
|
125
126
|
n_outlier_modules: int | None = None # anomaly.intra.n_outlier_modules
|
|
126
127
|
reason_codes: list[str] = field(default_factory=list) # verdict.reasons[].code
|
|
127
128
|
n_findings: int | None = None
|
|
@@ -621,6 +622,7 @@ def _extract_result(
|
|
|
621
622
|
level=m1_result.verdict.level.value,
|
|
622
623
|
training_state=m1_result.adapter.training_state,
|
|
623
624
|
max_robust_z=intra.max_robust_z if intra is not None else None,
|
|
625
|
+
max_concentration_z=intra.max_concentration_z if intra is not None else None,
|
|
624
626
|
n_outlier_modules=intra.n_outlier_modules if intra is not None else None,
|
|
625
627
|
reason_codes=[r.code for r in m1_result.verdict.reasons],
|
|
626
628
|
n_findings=len(m1_result.findings),
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "adaptersentry"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.2"
|
|
8
8
|
description = "Static security scanner for LoRA adapters — detects anomalous weight patterns in .safetensors files"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {file = "LICENSE"}
|
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
"""Intra-adapter comparison: which modules differ from their own family.
|
|
2
2
|
|
|
3
3
|
No reference profile is needed. A 70B adapter has ~80 modules of each kind
|
|
4
|
-
(q_proj, down_proj, …);
|
|
5
|
-
depth. A module that departs from its family is suspicious.
|
|
4
|
+
(q_proj, down_proj, …); each module is compared with the rest of its family.
|
|
6
5
|
|
|
7
|
-
Per family (same module name) and per feature:
|
|
6
|
+
Per family (same module name in the same layer stack) and per feature:
|
|
8
7
|
1. if layer indices are known, remove the depth trend with a Theil–Sen line
|
|
9
8
|
(median of pairwise slopes; insensitive to the outliers we look for);
|
|
10
9
|
2. robust z of the residuals: (x − median) / (1.4826 · MAD), with the
|
|
11
10
|
mean-absolute-deviation fallback when MAD = 0.
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
Verdict rule — joint concentration (calibrated on real adapters):
|
|
13
|
+
A module whose update is concentrated on a few outputs moves three features
|
|
14
|
+
up together: entry kurtosis, top-8 row share and row Hoyer. Its
|
|
15
|
+
concentration z is the smallest of those three signed z-values; the verdict
|
|
16
|
+
is raised when it reaches CONCENTRATION_REVIEW_Z.
|
|
17
|
+
|
|
18
|
+
Real adapters are far more heterogeneous within a family than synthetic
|
|
19
|
+
ones: on 393 popular PEFT adapters from the Hugging Face Hub (2026-10,
|
|
20
|
+
mostly Qwen/Llama up to 14B) the largest single-feature |z| had a median of
|
|
21
|
+
14 and exceeded the former threshold of 8 in 83 % of them. The joint rule at
|
|
22
|
+
14.0 flagged 5 % of those (presumed benign) adapters, and a threshold fitted
|
|
23
|
+
on one random half gave 4.8–5.9 % on the other half.
|
|
24
|
+
|
|
25
|
+
Single-feature z-values above OUTLIER_Z are still reported, for information only.
|
|
18
26
|
|
|
19
27
|
Security Notes:
|
|
20
28
|
- Pure numpy/scipy on feature values; no I/O.
|
|
@@ -40,7 +48,13 @@ from adaptersentry.schemas.result import (
|
|
|
40
48
|
)
|
|
41
49
|
|
|
42
50
|
OUTLIER_Z = 3.5
|
|
43
|
-
|
|
51
|
+
CONCENTRATION_REVIEW_Z = 14.0
|
|
52
|
+
CONCENTRATION_FEATURES: tuple[str, ...] = ("kurt_dw", "row_top8", "row_hoyer")
|
|
53
|
+
# On Hub adapters 28 of 32 concentration alerts sat in the first 3 or last 2 layers of
|
|
54
|
+
# the model, which work differently from the middle. Such alerts keep the verdict but
|
|
55
|
+
# say so; a per-family threshold for edge layers needs reference profiles.
|
|
56
|
+
EDGE_FIRST_LAYERS = 3
|
|
57
|
+
EDGE_LAST_LAYERS = 2
|
|
44
58
|
# Robust z from small families is unreliable: MAD from a handful of points is
|
|
45
59
|
# noisy, and detrending consumes degrees of freedom. On synthetic clean adapters
|
|
46
60
|
# the maximum |z| reached 12-70 with 6-12 modules per family, but stayed ≤ 6.6
|
|
@@ -76,6 +90,7 @@ class ModuleEntry:
|
|
|
76
90
|
expert: int | None
|
|
77
91
|
features: ModuleFeatures
|
|
78
92
|
energy: float # ‖ΔW‖²_F, for concentration across modules
|
|
93
|
+
stack: str = "" # layer stack (language model, vision tower, …); part of the family
|
|
79
94
|
|
|
80
95
|
|
|
81
96
|
@dataclass
|
|
@@ -112,6 +127,22 @@ def detrend(layers: np.ndarray, x: np.ndarray) -> np.ndarray:
|
|
|
112
127
|
return x - (intercept + slope * layers)
|
|
113
128
|
|
|
114
129
|
|
|
130
|
+
def is_edge_layer(layer: int | None, n_layers: int | None) -> bool:
|
|
131
|
+
"""True for the first EDGE_FIRST_LAYERS and last EDGE_LAST_LAYERS layers of a stack."""
|
|
132
|
+
if layer is None or n_layers is None:
|
|
133
|
+
return False
|
|
134
|
+
return layer < EDGE_FIRST_LAYERS or layer >= n_layers - EDGE_LAST_LAYERS
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def concentration_z(zs: dict[str, float]) -> float | None:
|
|
138
|
+
"""Joint concentration z of one module: the smallest of the signed z-values of
|
|
139
|
+
CONCENTRATION_FEATURES, so it is high only when all three rise together.
|
|
140
|
+
None when any of them is missing."""
|
|
141
|
+
if any(f not in zs for f in CONCENTRATION_FEATURES):
|
|
142
|
+
return None
|
|
143
|
+
return min(zs[f] for f in CONCENTRATION_FEATURES)
|
|
144
|
+
|
|
145
|
+
|
|
115
146
|
def _transform(value: float | None, kind: str) -> float | None:
|
|
116
147
|
if value is None or not math.isfinite(value):
|
|
117
148
|
return None
|
|
@@ -136,9 +167,14 @@ def compare_within_adapter(entries: list[ModuleEntry], *, top_k: int = 10) -> In
|
|
|
136
167
|
Returns:
|
|
137
168
|
IntraResult, or None if no family has at least MIN_FAMILY modules.
|
|
138
169
|
"""
|
|
170
|
+
# A family is one module name within one layer stack: a vision tower's q_proj is
|
|
171
|
+
# not compared with the language model's q_proj. Names are qualified with the
|
|
172
|
+
# stack only when the adapter covers more than one.
|
|
173
|
+
qualify = len({e.stack for e in entries}) > 1
|
|
174
|
+
family_of = {e.index: (f"{e.stack}/{e.module}" if qualify and e.stack else e.module) for e in entries}
|
|
139
175
|
families: dict[str, list[ModuleEntry]] = defaultdict(list)
|
|
140
176
|
for e in entries:
|
|
141
|
-
families[e.
|
|
177
|
+
families[family_of[e.index]].append(e)
|
|
142
178
|
|
|
143
179
|
z_by_module: dict[int, dict[str, float]] = defaultdict(dict)
|
|
144
180
|
contributions: list[tuple[float, Contributor]] = []
|
|
@@ -180,13 +216,21 @@ def compare_within_adapter(entries: list[ModuleEntry], *, top_k: int = 10) -> In
|
|
|
180
216
|
per_module_max: dict[int, float] = {
|
|
181
217
|
idx: max(abs(v) for v in zs.values()) for idx, zs in z_by_module.items() if zs
|
|
182
218
|
}
|
|
219
|
+
concentration: dict[int, float] = {
|
|
220
|
+
idx: concentration_z(zs) for idx, zs in z_by_module.items() if concentration_z(zs) is not None
|
|
221
|
+
}
|
|
183
222
|
by_index = {e.index: e for e in entries}
|
|
223
|
+
conc_spikes = [
|
|
224
|
+
DepthSpike(module_type=family_of[idx], layer=by_index[idx].layer, z=v)
|
|
225
|
+
for idx, v in sorted(concentration.items(), key=lambda kv: -kv[1])
|
|
226
|
+
if v >= CONCENTRATION_REVIEW_Z and by_index[idx].layer is not None
|
|
227
|
+
][:top_k]
|
|
184
228
|
n_outliers = sum(1 for v in per_module_max.values() if v > OUTLIER_Z)
|
|
185
229
|
for idx, v in sorted(per_module_max.items(), key=lambda kv: -kv[1]):
|
|
186
230
|
e = by_index[idx]
|
|
187
231
|
if v > OUTLIER_Z and e.layer is not None and len(spikes) < top_k:
|
|
188
232
|
signed = max(z_by_module[idx].values(), key=abs)
|
|
189
|
-
spikes.append(DepthSpike(module_type=
|
|
233
|
+
spikes.append(DepthSpike(module_type=family_of[idx], layer=e.layer, z=float(signed)))
|
|
190
234
|
|
|
191
235
|
energies = np.array([e.energy for e in entries if e.energy > 0.0], dtype=np.float64)
|
|
192
236
|
attn = [e.features.log_rms for e in entries if e.kind == "attention" and e.features.log_rms is not None]
|
|
@@ -197,6 +241,8 @@ def compare_within_adapter(entries: list[ModuleEntry], *, top_k: int = 10) -> In
|
|
|
197
241
|
max_robust_z=max(per_module_max.values(), default=0.0),
|
|
198
242
|
n_outlier_modules=n_outliers,
|
|
199
243
|
depth_spikes=spikes,
|
|
244
|
+
max_concentration_z=max(concentration.values(), default=None),
|
|
245
|
+
concentration_spikes=conc_spikes,
|
|
200
246
|
energy_gini=_gini(energies),
|
|
201
247
|
logrms_dispersion=dispersion,
|
|
202
248
|
attn_mlp_ratio=float(np.median(attn) - np.median(mlp)) if attn and mlp else None,
|
|
@@ -100,7 +100,7 @@ class ResultSink:
|
|
|
100
100
|
"""Persist result; update cache and manifest on success.
|
|
101
101
|
|
|
102
102
|
Args:
|
|
103
|
-
result: ScanResult 2.
|
|
103
|
+
result: ScanResult 2.x.
|
|
104
104
|
manifest_db: Manifest to update on completion.
|
|
105
105
|
cache_store: Cache store to write the entry (None = cache disabled).
|
|
106
106
|
request_id: Manifest row to update.
|
{adaptersentry-2.0.0 → adaptersentry-2.0.2}/src/adaptersentry/engine/schemas/combined_report.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""CombinedReport — M1 + M2 unified verdict.
|
|
2
2
|
|
|
3
|
-
schema_version = "2.0.0" — embeds ScanResult 2.
|
|
3
|
+
schema_version = "2.0.0" — embeds ScanResult 2.x (m1). BehavioralResult /
|
|
4
4
|
ProbeResult keep their own 1.0.0 wire format.
|
|
5
5
|
|
|
6
6
|
M1-only scans populate the m1 field only; the m2 field defaults to
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Batch worker: one adapter → one ScanResult 2.
|
|
1
|
+
"""Batch worker: one adapter → one ScanResult 2.x.
|
|
2
2
|
|
|
3
3
|
``worker_main`` runs in a worker process (multiprocessing or Ray). It checks the
|
|
4
4
|
content-addressed cache first and otherwise calls ``adaptersentry.scanner.scan``.
|
|
@@ -20,6 +20,7 @@ _EMB_B = re.compile(r"^(?P<base>.+)\.lora_embedding_B(?:\.[A-Za-z0-9_\-]+)?$")
|
|
|
20
20
|
_DORA = re.compile(r"^(?P<base>.+)\.lora_magnitude_vector(?:\.[A-Za-z0-9_\-]+)?(?:\.weight)?$")
|
|
21
21
|
_TRAINABLE_TOKENS = re.compile(r"^(?P<base>.+)\.trainable_tokens_(?:delta|original)(?:\.[A-Za-z0-9_\-]+)?$")
|
|
22
22
|
|
|
23
|
+
_PEFT_PREFIX = "base_model.model."
|
|
23
24
|
_LAYER = re.compile(r"(?:^|\.)(?:layers|h|blocks|layer)\.(\d+)(?:\.|$)")
|
|
24
25
|
_EXPERT = re.compile(r"(?:^|\.)experts\.(\d+)(?:\.|$)")
|
|
25
26
|
|
|
@@ -57,6 +58,10 @@ class ModuleLocation:
|
|
|
57
58
|
expert: int | None
|
|
58
59
|
module: str
|
|
59
60
|
kind: ModuleKind
|
|
61
|
+
# Path of the layer stack the module belongs to, e.g. "model.language_model" or
|
|
62
|
+
# "model.vision_tower.vision_model.encoder"; "" when the path has no layer index.
|
|
63
|
+
# Multimodal models reuse module names (q_proj, up_proj) across stacks.
|
|
64
|
+
stack: str = ""
|
|
60
65
|
|
|
61
66
|
|
|
62
67
|
def parse_key(key: str) -> ParsedKey:
|
|
@@ -98,9 +103,15 @@ def locate(base: str) -> ModuleLocation:
|
|
|
98
103
|
kind = "router"
|
|
99
104
|
else:
|
|
100
105
|
kind = "other"
|
|
106
|
+
stack = ""
|
|
107
|
+
if layer_m:
|
|
108
|
+
stack = base[:layer_m.start()]
|
|
109
|
+
if stack.startswith(_PEFT_PREFIX):
|
|
110
|
+
stack = stack[len(_PEFT_PREFIX):]
|
|
101
111
|
return ModuleLocation(
|
|
102
112
|
layer=int(layer_m.group(1)) if layer_m else None,
|
|
103
113
|
expert=int(expert_m.group(1)) if expert_m else None,
|
|
104
114
|
module=name,
|
|
105
115
|
kind=kind,
|
|
116
|
+
stack=stack,
|
|
106
117
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""SARIF 2.1.0 report for a ScanResult 2.
|
|
1
|
+
"""SARIF 2.1.0 report for a ScanResult 2.x (GitHub code scanning compatible).
|
|
2
2
|
|
|
3
3
|
- One rule descriptor per rule id; ``security-severity`` maps to GitHub's buckets.
|
|
4
4
|
- Every finding is a result; its locations point at the adapter file and, as
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Human-readable text report for a ScanResult 2.
|
|
1
|
+
"""Human-readable text report for a ScanResult 2.x.
|
|
2
2
|
|
|
3
3
|
Every string that originates in the adapter file (paths, tensor keys, module
|
|
4
4
|
names, messages built from them) goes through ``safe_text`` so control or bidi
|
|
@@ -68,7 +68,9 @@ def render(result: ScanResult, *, no_color: bool = False, max_findings: int = 15
|
|
|
68
68
|
intra = result.anomaly.intra
|
|
69
69
|
if intra is not None:
|
|
70
70
|
gini = f" · energy Gini {intra.energy_gini:.2f}" if intra.energy_gini is not None else ""
|
|
71
|
-
|
|
71
|
+
conc = (f"concentration z {intra.max_concentration_z:.1f} · "
|
|
72
|
+
if intra.max_concentration_z is not None else "")
|
|
73
|
+
lines.append(f"Intra: {conc}max single-feature z {intra.max_robust_z:.1f} · "
|
|
72
74
|
f"{intra.n_outlier_modules} outlier module(s){gini}")
|
|
73
75
|
if result.anomaly.top_contributors:
|
|
74
76
|
lines.append("Top contributors:")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""AdapterSentry M1 scan pipeline — produces a ScanResult 2.
|
|
1
|
+
"""AdapterSentry M1 scan pipeline — produces a ScanResult 2.x.
|
|
2
2
|
|
|
3
3
|
scan(path) → ScanResult
|
|
4
4
|
|
|
@@ -35,7 +35,15 @@ from typing import Literal
|
|
|
35
35
|
|
|
36
36
|
import numpy as np
|
|
37
37
|
|
|
38
|
-
from adaptersentry.detectors.intra_adapter import
|
|
38
|
+
from adaptersentry.detectors.intra_adapter import (
|
|
39
|
+
CONCENTRATION_FEATURES,
|
|
40
|
+
CONCENTRATION_REVIEW_Z,
|
|
41
|
+
OUTLIER_Z,
|
|
42
|
+
ModuleEntry,
|
|
43
|
+
compare_within_adapter,
|
|
44
|
+
concentration_z,
|
|
45
|
+
is_edge_layer,
|
|
46
|
+
)
|
|
39
47
|
from adaptersentry.detectors.structural import find_siblings, structural_findings
|
|
40
48
|
from adaptersentry.features.spectral import head_features, module_features
|
|
41
49
|
from adaptersentry.parsers.adapter_config import read_adapter_config, resolve_scale
|
|
@@ -77,7 +85,7 @@ def config_hash(mode: Mode, policy: Policy) -> str:
|
|
|
77
85
|
"""Hash of every setting that changes the result (cache key component)."""
|
|
78
86
|
settings = {
|
|
79
87
|
"schema": SCHEMA_VERSION, "mode": mode, "policy": policy,
|
|
80
|
-
"outlier_z": OUTLIER_Z, "
|
|
88
|
+
"outlier_z": OUTLIER_Z, "concentration_review_z": CONCENTRATION_REVIEW_Z,
|
|
81
89
|
}
|
|
82
90
|
blob = json.dumps(settings, sort_keys=True).encode()
|
|
83
91
|
return "sha256:" + hashlib.sha256(blob).hexdigest()
|
|
@@ -192,7 +200,7 @@ def scan(
|
|
|
192
200
|
hf_repo_id: str | None = None,
|
|
193
201
|
hf_revision: str | None = None,
|
|
194
202
|
) -> ScanResult:
|
|
195
|
-
"""Scan one LoRA adapter file and return a ScanResult 2.
|
|
203
|
+
"""Scan one LoRA adapter file and return a ScanResult 2.x. Never raises.
|
|
196
204
|
|
|
197
205
|
Args:
|
|
198
206
|
path: .safetensors adapter file.
|
|
@@ -315,7 +323,8 @@ def _scan_inventory(path, inv: AdapterInventory, cfg_read, siblings, artifact, s
|
|
|
315
323
|
idx = len(records)
|
|
316
324
|
records.append(ModuleRecord(**base_record, scale=res.scale, status="ok", features=feats, heads=heads))
|
|
317
325
|
entries.append(ModuleEntry(index=idx, module=loc.module, kind=loc.kind, layer=loc.layer,
|
|
318
|
-
expert=loc.expert, features=feats, energy=core.fro ** 2
|
|
326
|
+
expert=loc.expert, features=feats, energy=core.fro ** 2,
|
|
327
|
+
stack=loc.stack))
|
|
319
328
|
del pd
|
|
320
329
|
|
|
321
330
|
intra = compare_within_adapter(entries)
|
|
@@ -328,25 +337,52 @@ def _scan_inventory(path, inv: AdapterInventory, cfg_read, siblings, artifact, s
|
|
|
328
337
|
struct = structural_findings(inv, config, cfg_read.problems, siblings,
|
|
329
338
|
unresolved_scale_pattern=unresolved_pattern)
|
|
330
339
|
findings = list(struct.findings)
|
|
340
|
+
top_spike_edge = False
|
|
331
341
|
if intra is not None:
|
|
332
342
|
pair_by_index = {i: inv.pairs[i] for i in range(len(inv.pairs))}
|
|
343
|
+
stack_depth: dict[str, int] = {}
|
|
344
|
+
for p in inv.pairs:
|
|
345
|
+
if p.location.layer is not None:
|
|
346
|
+
s = p.location.stack
|
|
347
|
+
stack_depth[s] = max(stack_depth.get(s, 0), p.location.layer + 1)
|
|
348
|
+
conc = {idx: c for idx, zs in intra.z_by_module.items()
|
|
349
|
+
if (c := concentration_z(zs)) is not None and c >= CONCENTRATION_REVIEW_Z}
|
|
350
|
+
for rank, (idx, c) in enumerate(sorted(conc.items(), key=lambda kv: -kv[1])[:10]):
|
|
351
|
+
rec, pair = records[idx], pair_by_index[idx]
|
|
352
|
+
zs = intra.z_by_module[idx]
|
|
353
|
+
edge = is_edge_layer(rec.layer, stack_depth.get(pair.location.stack))
|
|
354
|
+
if rank == 0:
|
|
355
|
+
top_spike_edge = edge
|
|
356
|
+
note = ("; first/last layers often differ like this in benign adapters" if edge else "")
|
|
357
|
+
findings.append(Finding(
|
|
358
|
+
rule_id="INTRA_CONCENTRATION_SPIKE", severity=Severity.MEDIUM,
|
|
359
|
+
title="Update concentrated on a few outputs, unlike its family",
|
|
360
|
+
message=(f"{rec.module} in layer {rec.layer}: kurtosis, top-8 row share and row Hoyer "
|
|
361
|
+
f"all rise against the family (concentration z = {c:.1f} ≥ {CONCENTRATION_REVIEW_Z})"
|
|
362
|
+
f"{note}"),
|
|
363
|
+
evidence={"concentration_z": round(c, 3), "edge_layer": edge,
|
|
364
|
+
**{f"z_{f}": round(zs[f], 3) for f in CONCENTRATION_FEATURES}},
|
|
365
|
+
locations=[Location(layer=rec.layer, module=rec.module, expert=rec.expert,
|
|
366
|
+
tensor_key=pair.b.key)],
|
|
367
|
+
remediation="Behavioural check of this adapter",
|
|
368
|
+
))
|
|
369
|
+
# Single-feature outliers: information only. Real adapters have many (see intra_adapter).
|
|
333
370
|
ranked = sorted(intra.z_by_module.items(), key=lambda kv: -max(abs(v) for v in kv[1].values()))
|
|
334
|
-
for idx, zs in ranked[:
|
|
371
|
+
for idx, zs in ranked[:5]:
|
|
372
|
+
if idx in conc:
|
|
373
|
+
continue
|
|
335
374
|
feat, zval = max(zs.items(), key=lambda kv: abs(kv[1]))
|
|
336
375
|
if abs(zval) <= OUTLIER_Z:
|
|
337
376
|
break
|
|
338
377
|
rec, pair = records[idx], pair_by_index[idx]
|
|
339
|
-
strong = abs(zval) >= REVIEW_Z
|
|
340
378
|
findings.append(Finding(
|
|
341
|
-
rule_id="
|
|
342
|
-
|
|
343
|
-
title="Module departs from its family" if strong else "Mild outlier within its family",
|
|
379
|
+
rule_id="INTRA_OUTLIER", severity=Severity.LOW,
|
|
380
|
+
title="Module differs from its family in one feature",
|
|
344
381
|
message=(f"{rec.module} in layer {rec.layer}: {feat} robust z = {zval:.1f} "
|
|
345
|
-
f"(
|
|
382
|
+
f"(information only; common in benign adapters)"),
|
|
346
383
|
evidence={"feature": feat, "z": round(zval, 3), "value": getattr(rec.features, feat)},
|
|
347
384
|
locations=[Location(layer=rec.layer, module=rec.module, expert=rec.expert,
|
|
348
385
|
tensor_key=pair.b.key)],
|
|
349
|
-
remediation="Behavioural check of this adapter" if strong else None,
|
|
350
386
|
))
|
|
351
387
|
for tc in token_conc:
|
|
352
388
|
findings.append(Finding(
|
|
@@ -430,6 +466,7 @@ def _scan_inventory(path, inv: AdapterInventory, cfg_read, siblings, artifact, s
|
|
|
430
466
|
status=status, policy=policy, intra=intra.summary if intra else None, reference=None,
|
|
431
467
|
red_flags=struct.red_flags, metadata_present=bool(meta) or config is not None,
|
|
432
468
|
confidence_limits=struct.confidence_limits, n_not_analyzed=n_not,
|
|
469
|
+
top_spike_edge=top_spike_edge,
|
|
433
470
|
)
|
|
434
471
|
anomaly = Anomaly(
|
|
435
472
|
intra=intra.summary if intra else None,
|
|
@@ -39,7 +39,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
39
39
|
from adaptersentry.schemas.errors import ScanError
|
|
40
40
|
from adaptersentry.schemas.severity import Severity
|
|
41
41
|
|
|
42
|
-
SCHEMA_VERSION = "2.
|
|
42
|
+
SCHEMA_VERSION = "2.1.0"
|
|
43
43
|
|
|
44
44
|
# Upper bound on coverage.not_analyzed entries; the rest are counted only.
|
|
45
45
|
MAX_LISTED_NOT_ANALYZED = 50
|
|
@@ -228,6 +228,13 @@ class IntraAnomaly(_Model):
|
|
|
228
228
|
max_robust_z: float = Field(ge=0.0)
|
|
229
229
|
n_outlier_modules: int = Field(ge=0)
|
|
230
230
|
depth_spikes: list[DepthSpike] = Field(default_factory=list)
|
|
231
|
+
max_concentration_z: float | None = Field(
|
|
232
|
+
default=None,
|
|
233
|
+
description="Largest joint concentration z (min of the kurt_dw, row_top8, row_hoyer z-values) "
|
|
234
|
+
"over the modules; drives the verdict. Added in 2.0.1.")
|
|
235
|
+
concentration_spikes: list[DepthSpike] = Field(
|
|
236
|
+
default_factory=list,
|
|
237
|
+
description="Modules whose joint concentration z reaches the review threshold. Added in 2.0.1.")
|
|
231
238
|
energy_gini: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
232
239
|
logrms_dispersion: dict[str, float] = Field(default_factory=dict)
|
|
233
240
|
attn_mlp_ratio: float | None = None
|
|
@@ -15,7 +15,7 @@ bug here cannot produce an "allow" for a degraded scan.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
-
from adaptersentry.detectors.intra_adapter import
|
|
18
|
+
from adaptersentry.detectors.intra_adapter import CONCENTRATION_REVIEW_Z
|
|
19
19
|
from adaptersentry.schemas.severity import Severity
|
|
20
20
|
from adaptersentry.schemas.result import (
|
|
21
21
|
Confidence,
|
|
@@ -40,6 +40,7 @@ def derive_verdict(
|
|
|
40
40
|
metadata_present: bool,
|
|
41
41
|
confidence_limits: list[str],
|
|
42
42
|
n_not_analyzed: int,
|
|
43
|
+
top_spike_edge: bool = False,
|
|
43
44
|
) -> Verdict:
|
|
44
45
|
"""Derive action, level, reasons, behavioural recommendation and confidence."""
|
|
45
46
|
action = "allow"
|
|
@@ -75,13 +76,19 @@ def derive_verdict(
|
|
|
75
76
|
message="No calibrated reference for this model family; "
|
|
76
77
|
"verdict relies on intra-adapter comparison"))
|
|
77
78
|
|
|
78
|
-
if intra is not None and intra.
|
|
79
|
+
if (intra is not None and intra.max_concentration_z is not None
|
|
80
|
+
and intra.max_concentration_z >= CONCENTRATION_REVIEW_Z):
|
|
79
81
|
where = ""
|
|
80
|
-
if intra.
|
|
81
|
-
top = intra.
|
|
82
|
-
where = f"
|
|
83
|
-
reasons.append(Reason(
|
|
84
|
-
|
|
82
|
+
if intra.concentration_spikes:
|
|
83
|
+
top = intra.concentration_spikes[0]
|
|
84
|
+
where = f" ({top.module_type} layer {top.layer})"
|
|
85
|
+
reasons.append(Reason(
|
|
86
|
+
code="INTRA_OUTLIER", severity=Severity.MEDIUM,
|
|
87
|
+
message=f"Update concentrated on a few outputs, unlike the rest of its family{where}: "
|
|
88
|
+
f"concentration z = {intra.max_concentration_z:.1f} ≥ {CONCENTRATION_REVIEW_Z} "
|
|
89
|
+
f"(threshold set at 5 % alerts on popular Hub adapters)"
|
|
90
|
+
+ ("; it is a first/last layer, where such differences are common in benign adapters"
|
|
91
|
+
if top_spike_edge else "")))
|
|
85
92
|
raise_to("review")
|
|
86
93
|
|
|
87
94
|
for flag in red_flags:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.2"
|
|
@@ -540,7 +540,7 @@ class TestAnalysisFailedClassification:
|
|
|
540
540
|
fake = SimpleNamespace(
|
|
541
541
|
status="ok",
|
|
542
542
|
errors=[],
|
|
543
|
-
anomaly=SimpleNamespace(intra=SimpleNamespace(max_robust_z=6.5, n_outlier_modules=2)),
|
|
543
|
+
anomaly=SimpleNamespace(intra=SimpleNamespace(max_robust_z=6.5, max_concentration_z=15.0, n_outlier_modules=2)),
|
|
544
544
|
verdict=SimpleNamespace(action="review", level=SimpleNamespace(value="MEDIUM"),
|
|
545
545
|
reasons=[SimpleNamespace(code="INTRA_OUTLIER")]),
|
|
546
546
|
adapter=SimpleNamespace(training_state="trained", rank_declared=16),
|
|
@@ -59,8 +59,17 @@ class TestDetection:
|
|
|
59
59
|
result = scan(_write_adapter(tmp_path / "inj", inject_layer=7))
|
|
60
60
|
assert result.verdict.action == "review"
|
|
61
61
|
assert "INTRA_OUTLIER" in {r.code for r in result.verdict.reasons}
|
|
62
|
-
spike = next(f for f in result.findings if f.rule_id == "
|
|
62
|
+
spike = next(f for f in result.findings if f.rule_id == "INTRA_CONCENTRATION_SPIKE")
|
|
63
63
|
assert spike.locations[0].layer == 7 and spike.locations[0].module == "down_proj"
|
|
64
|
+
assert spike.evidence["concentration_z"] >= 14.0
|
|
65
|
+
assert spike.evidence["edge_layer"] is False
|
|
66
|
+
|
|
67
|
+
def test_edge_layer_alert_is_marked(self, tmp_path: Path) -> None:
|
|
68
|
+
result = scan(_write_adapter(tmp_path / "edge", inject_layer=15)) # last of 16 layers
|
|
69
|
+
spike = next(f for f in result.findings if f.rule_id == "INTRA_CONCENTRATION_SPIKE")
|
|
70
|
+
assert result.verdict.action == "review"
|
|
71
|
+
assert spike.locations[0].layer == 15 and spike.evidence["edge_layer"] is True
|
|
72
|
+
assert "benign adapters" in spike.message
|
|
64
73
|
|
|
65
74
|
def test_rescale_attack_does_not_hide_the_update(self, tmp_path: Path) -> None:
|
|
66
75
|
# B shrunk by 1e4 and lora_alpha raised by 1e4: effective ΔW is unchanged.
|
|
@@ -71,6 +80,8 @@ class TestDetection:
|
|
|
71
80
|
))
|
|
72
81
|
assert attack.verdict.action == "review"
|
|
73
82
|
assert attack.anomaly.intra.max_robust_z == pytest.approx(normal.anomaly.intra.max_robust_z, rel=1e-3)
|
|
83
|
+
assert attack.anomaly.intra.max_concentration_z == pytest.approx(
|
|
84
|
+
normal.anomaly.intra.max_concentration_z, rel=1e-3)
|
|
74
85
|
|
|
75
86
|
def test_init_only_adapter(self, tmp_path: Path) -> None:
|
|
76
87
|
result = scan(_write_adapter(tmp_path / "init", b_scale=0.0))
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.0.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|