adaptersentry 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. adaptersentry/__init__.py +14 -0
  2. adaptersentry/__main__.py +4 -0
  3. adaptersentry/analyzer.py +554 -0
  4. adaptersentry/cli/__init__.py +1 -0
  5. adaptersentry/cli/batch.py +353 -0
  6. adaptersentry/cli/main.py +68 -0
  7. adaptersentry/cli/scan.py +302 -0
  8. adaptersentry/detectors/__init__.py +27 -0
  9. adaptersentry/detectors/cross_layer.py +112 -0
  10. adaptersentry/detectors/entropy.py +93 -0
  11. adaptersentry/detectors/init_detector.py +111 -0
  12. adaptersentry/detectors/outlier.py +211 -0
  13. adaptersentry/detectors/wasserstein.py +175 -0
  14. adaptersentry/engine/__init__.py +17 -0
  15. adaptersentry/engine/cache.py +270 -0
  16. adaptersentry/engine/config.py +137 -0
  17. adaptersentry/engine/feature_extractor.py +561 -0
  18. adaptersentry/engine/identity.py +144 -0
  19. adaptersentry/engine/manifest.py +293 -0
  20. adaptersentry/engine/orchestrator.py +318 -0
  21. adaptersentry/engine/orchestrator_ray.py +353 -0
  22. adaptersentry/engine/result_sink.py +221 -0
  23. adaptersentry/engine/schemas/__init__.py +27 -0
  24. adaptersentry/engine/schemas/cache.py +69 -0
  25. adaptersentry/engine/schemas/combined_report.py +93 -0
  26. adaptersentry/engine/schemas/identity.py +82 -0
  27. adaptersentry/engine/schemas/requests.py +85 -0
  28. adaptersentry/engine/schemas/scan_result.py +148 -0
  29. adaptersentry/engine/schemas/scoring.py +104 -0
  30. adaptersentry/engine/schemas/signals.py +90 -0
  31. adaptersentry/engine/worker.py +363 -0
  32. adaptersentry/features/__init__.py +6 -0
  33. adaptersentry/features/delta_norm.py +232 -0
  34. adaptersentry/features/distribution.py +234 -0
  35. adaptersentry/features/entropy_compression.py +174 -0
  36. adaptersentry/features/inter_layer_similarity.py +195 -0
  37. adaptersentry/features/layer_stats.py +74 -0
  38. adaptersentry/features/tensor_stats.py +139 -0
  39. adaptersentry/integrations/__init__.py +1 -0
  40. adaptersentry/integrations/github_action.py +97 -0
  41. adaptersentry/parsers/__init__.py +24 -0
  42. adaptersentry/parsers/metadata.py +172 -0
  43. adaptersentry/parsers/safetensors.py +375 -0
  44. adaptersentry/reporters/__init__.py +7 -0
  45. adaptersentry/reporters/json.py +51 -0
  46. adaptersentry/reporters/sarif.py +223 -0
  47. adaptersentry/reporters/text.py +171 -0
  48. adaptersentry/reporting/__init__.py +0 -0
  49. adaptersentry/reporting/human_summary.py +293 -0
  50. adaptersentry/reporting/per_layer.py +198 -0
  51. adaptersentry/schemas/__init__.py +33 -0
  52. adaptersentry/schemas/adapter_metadata.py +78 -0
  53. adaptersentry/schemas/adapter_report.py +188 -0
  54. adaptersentry/schemas/confidence_score.py +146 -0
  55. adaptersentry/schemas/distribution_features.py +110 -0
  56. adaptersentry/schemas/entropy_compression_features.py +117 -0
  57. adaptersentry/schemas/errors.py +154 -0
  58. adaptersentry/schemas/finding.py +252 -0
  59. adaptersentry/schemas/inter_layer_similarity_features.py +80 -0
  60. adaptersentry/schemas/norm_features.py +64 -0
  61. adaptersentry/schemas/per_layer_finding.py +73 -0
  62. adaptersentry/schemas/score_breakdown.py +108 -0
  63. adaptersentry/schemas/scoring_policy.py +99 -0
  64. adaptersentry/schemas/tensor_record.py +125 -0
  65. adaptersentry/scoring/__init__.py +6 -0
  66. adaptersentry/scoring/confidence.py +231 -0
  67. adaptersentry/scoring/ensemble.py +399 -0
  68. adaptersentry/scoring/risk_scorer.py +128 -0
  69. adaptersentry/scoring/score_breakdown.py +558 -0
  70. adaptersentry/version.py +1 -0
  71. adaptersentry-1.0.1.dist-info/METADATA +750 -0
  72. adaptersentry-1.0.1.dist-info/RECORD +83 -0
  73. adaptersentry-1.0.1.dist-info/WHEEL +5 -0
  74. adaptersentry-1.0.1.dist-info/entry_points.txt +4 -0
  75. adaptersentry-1.0.1.dist-info/licenses/LICENSE +202 -0
  76. adaptersentry-1.0.1.dist-info/top_level.txt +2 -0
  77. benchmarks/__init__.py +1 -0
  78. benchmarks/check_regression.py +161 -0
  79. benchmarks/corpus.py +135 -0
  80. benchmarks/harness.py +497 -0
  81. benchmarks/hub_scanner.py +936 -0
  82. benchmarks/report.py +389 -0
  83. benchmarks/run_real.py +107 -0
@@ -0,0 +1,353 @@
1
+ """batch subcommand — scan a corpus of LoRA adapters.
2
+
3
+ Scans multiple .safetensors files with a process-based worker pool,
4
+ incremental caching, and full resumability after crashes.
5
+
6
+ Exit codes:
7
+ 0 — batch completed; all jobs in terminal state
8
+ 1 — batch failed or was interrupted
9
+ 2 — any job produced findings at or above --fail-on threshold
10
+
11
+ Output:
12
+ results/<run_id>/ — per-adapter JSON files (summary-json)
13
+ results/<run_id>/run.jsonl — append-only audit log (all results)
14
+ results/<run_id>/run_summary.json — batch stats and top findings
15
+ [results/<run_id>/*.debug.json] — debug-json per adapter (if --debug)
16
+
17
+ Cache:
18
+ ~/.adaptersentry/cache/ — content-addressed result cache
19
+ ~/.adaptersentry/manifest.sqlite — per-run state machine
20
+
21
+ Resume:
22
+ adaptersentry batch --input-dir ... --run-id <id> --resume
23
+ Resets non-terminal jobs and re-runs from where the batch stopped.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import sys
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _SEVERITIES = ("LOW", "MEDIUM", "HIGH", "CRITICAL")
38
+
39
+
40
+ def build_parser(subparsers: Any) -> None:
41
+ """Register the ``batch`` subcommand."""
42
+ p = subparsers.add_parser(
43
+ "batch",
44
+ help="Scan a corpus of LoRA adapters (batch mode with caching and resume)",
45
+ description=__doc__,
46
+ formatter_class=__import__("argparse").RawDescriptionHelpFormatter,
47
+ )
48
+ source = p.add_mutually_exclusive_group(required=True)
49
+ source.add_argument(
50
+ "--input-dir",
51
+ type=Path,
52
+ metavar="DIR",
53
+ help="Directory to scan recursively for .safetensors files",
54
+ )
55
+ source.add_argument(
56
+ "--input-list",
57
+ type=Path,
58
+ metavar="FILE",
59
+ help="Text file with one adapter path per line",
60
+ )
61
+ p.add_argument(
62
+ "--run-id",
63
+ default=None,
64
+ metavar="ID",
65
+ help=(
66
+ "Batch run identifier. If omitted, auto-generated from timestamp. "
67
+ "Use the same ID with --resume to continue a previous run."
68
+ ),
69
+ )
70
+ p.add_argument(
71
+ "--workers",
72
+ type=int,
73
+ default=4,
74
+ metavar="N",
75
+ help="Number of parallel worker processes (default: 4, minimum: 1)",
76
+ )
77
+ p.add_argument(
78
+ "--output-dir",
79
+ type=Path,
80
+ default=Path("results"),
81
+ metavar="DIR",
82
+ help="Output directory for per-adapter JSON files (default: ./results)",
83
+ )
84
+ p.add_argument(
85
+ "--cache-dir",
86
+ type=Path,
87
+ default=None,
88
+ metavar="DIR",
89
+ help=(
90
+ "Cache store root (default: ~/.adaptersentry/cache). "
91
+ "Set to /dev/null to disable caching."
92
+ ),
93
+ )
94
+ p.add_argument(
95
+ "--no-cache",
96
+ action="store_true",
97
+ help="Disable result caching (always re-scan)",
98
+ )
99
+ p.add_argument(
100
+ "--resume",
101
+ action="store_true",
102
+ help="Resume a previous run: reset non-terminal jobs and continue",
103
+ )
104
+ p.add_argument(
105
+ "--force-rescan",
106
+ action="store_true",
107
+ help="Re-scan all adapters, ignoring existing terminal manifest state",
108
+ )
109
+ p.add_argument(
110
+ "--debug",
111
+ action="store_true",
112
+ help="Write .debug.json files with per-layer statistics alongside summary JSON",
113
+ )
114
+ p.add_argument(
115
+ "--fail-on",
116
+ choices=_SEVERITIES,
117
+ default=None,
118
+ metavar="SEVERITY",
119
+ help="Exit with code 2 if any finding meets or exceeds SEVERITY",
120
+ )
121
+ p.add_argument(
122
+ "--rank",
123
+ type=int,
124
+ default=None,
125
+ metavar="R",
126
+ help="Declared LoRA rank r (applied to all adapters in the batch)",
127
+ )
128
+ p.add_argument(
129
+ "--mode",
130
+ choices=("full", "fast"),
131
+ default="full",
132
+ dest="scan_mode",
133
+ help=(
134
+ "Scan depth: full (default) — all detectors at full depth; "
135
+ "fast — truncated SVD, sampling, no IsolationForest on large tensors. "
136
+ "Use fast for high-throughput corpus screening."
137
+ ),
138
+ )
139
+ p.add_argument(
140
+ "--backend",
141
+ choices=("mp", "ray"),
142
+ default="mp",
143
+ help=(
144
+ "Worker backend: mp (default) — multiprocessing.Pool; "
145
+ "ray — Ray actor pool (requires pip install adaptersentry[ray]). "
146
+ "Ray adds crash isolation, BLAS thread fix, and horizontal scaling."
147
+ ),
148
+ )
149
+ p.add_argument(
150
+ "--ray-address",
151
+ default=None,
152
+ metavar="ADDRESS",
153
+ help=(
154
+ "Ray cluster address (e.g. ray://head-node:10001). "
155
+ "Omit to start a local Ray cluster on this machine. "
156
+ "Only used with --backend ray."
157
+ ),
158
+ )
159
+
160
+
161
+ def run(args: Any) -> int:
162
+ """Execute the batch subcommand."""
163
+ from adaptersentry.engine.config import AnalyzerConfig, ScanMode
164
+ from adaptersentry.engine.manifest import ManifestDB
165
+ from adaptersentry.engine.cache import CacheStore
166
+ from adaptersentry.engine.orchestrator import (
167
+ build_manifest, run_batch, resume_after_failure,
168
+ )
169
+ from adaptersentry.schemas.finding import Severity
170
+
171
+ backend = getattr(args, "backend", "mp")
172
+
173
+ if args.workers < 1:
174
+ print("error: --workers must be >= 1", file=sys.stderr)
175
+ return 1
176
+
177
+ if getattr(args, "ray_address", None) and backend != "ray":
178
+ logger.warning("--ray-address is ignored when --backend is not 'ray'")
179
+
180
+ # ── Run ID ────────────────────────────────────────────────────────────────
181
+ run_id = args.run_id or f"run_{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
182
+ logger.info("Batch run: %s", run_id)
183
+
184
+ # ── Resolve paths ─────────────────────────────────────────────────────────
185
+ input_paths = _collect_paths(args)
186
+ if not input_paths:
187
+ print("error: no .safetensors files found in the specified input", file=sys.stderr)
188
+ return 1
189
+
190
+ logger.info("Found %d adapter path(s)", len(input_paths))
191
+
192
+ # ── Directories ───────────────────────────────────────────────────────────
193
+ output_dir: Path = args.output_dir
194
+ run_dir = output_dir / run_id
195
+ run_dir.mkdir(parents=True, exist_ok=True)
196
+ run_jsonl = run_dir / "run.jsonl"
197
+
198
+ # ── Analyzer config hash ──────────────────────────────────────────────────
199
+ scan_mode = ScanMode(getattr(args, "scan_mode", "full"))
200
+ config = AnalyzerConfig(scan_mode=scan_mode)
201
+ config_hash = config.config_hash()
202
+ logger.info("Analyzer config hash: %s scan_mode=%s", config_hash, scan_mode.value)
203
+
204
+ # ── Manifest ──────────────────────────────────────────────────────────────
205
+ manifest_path = Path.home() / ".adaptersentry" / "manifest.sqlite"
206
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
207
+
208
+ with ManifestDB.open(manifest_path) as manifest_db:
209
+ # ── Resume ────────────────────────────────────────────────────────────
210
+ if args.resume:
211
+ n_reset, n_terminal = resume_after_failure(run_id, manifest_db)
212
+ print(f"Resume: {n_reset} job(s) reset, {n_terminal} already complete.")
213
+
214
+ # ── Build manifest ────────────────────────────────────────────────────
215
+ requests = build_manifest(
216
+ input_paths=input_paths,
217
+ run_id=run_id,
218
+ manifest_db=manifest_db,
219
+ force_rescan=args.force_rescan,
220
+ scan_mode=scan_mode.value,
221
+ )
222
+
223
+ if not requests:
224
+ stats = manifest_db.stats(run_id)
225
+ print(
226
+ f"All {sum(stats.values())} adapter(s) already in terminal state "
227
+ f"for run {run_id}. Use --force-rescan to re-scan."
228
+ )
229
+ return 0
230
+
231
+ print(f"Scanning {len(requests)} adapter(s) with {args.workers} worker(s)...")
232
+
233
+ # ── Cache ─────────────────────────────────────────────────────────────
234
+ cache_root: Path | None = None
235
+ cache_store: CacheStore | None = None
236
+
237
+ if not args.no_cache:
238
+ if args.cache_dir:
239
+ cache_root = args.cache_dir
240
+ else:
241
+ cache_root = Path.home() / ".adaptersentry" / "cache"
242
+ cache_root.mkdir(parents=True, exist_ok=True)
243
+ cache_store = CacheStore.open(cache_root)
244
+
245
+ try:
246
+ # ── Run batch ─────────────────────────────────────────────────────
247
+ if backend == "ray":
248
+ from adaptersentry.engine.orchestrator_ray import run_batch_ray
249
+ stats = run_batch_ray(
250
+ requests=requests,
251
+ manifest_db=manifest_db,
252
+ cache_store=cache_store,
253
+ results_dir=run_dir,
254
+ run_jsonl_path=run_jsonl,
255
+ analyzer_config_hash=config_hash,
256
+ cache_root=cache_root,
257
+ n_workers=args.workers,
258
+ write_debug=args.debug,
259
+ ray_address=getattr(args, "ray_address", None),
260
+ )
261
+ else:
262
+ stats = run_batch(
263
+ requests=requests,
264
+ manifest_db=manifest_db,
265
+ cache_store=cache_store,
266
+ results_dir=run_dir,
267
+ run_jsonl_path=run_jsonl,
268
+ analyzer_config_hash=config_hash,
269
+ cache_root=cache_root,
270
+ n_workers=args.workers,
271
+ write_debug=args.debug,
272
+ )
273
+ finally:
274
+ if cache_store is not None:
275
+ cache_store.close()
276
+
277
+ # ── Summary ───────────────────────────────────────────────────────────────
278
+ total = sum(stats.values())
279
+ print(
280
+ f"\nBatch complete: {total} adapter(s) scanned\n"
281
+ f" ok={stats.get('ok', 0)} degraded={stats.get('degraded', 0)} "
282
+ f"failed={stats.get('failed', 0)} cached={stats.get('cached', 0)}"
283
+ )
284
+ print(f"Results written to: {run_dir}")
285
+ print(f"Audit log: {run_jsonl}")
286
+
287
+ _write_run_summary(run_dir, run_id, stats)
288
+
289
+ # ── fail-on threshold ─────────────────────────────────────────────────────
290
+ if args.fail_on and run_jsonl.exists():
291
+ threshold = _SEVERITIES.index(args.fail_on)
292
+ triggered = _check_fail_on(run_jsonl, threshold)
293
+ if triggered:
294
+ print(f"\nFINDINGS at or above {args.fail_on} detected. Exiting with code 2.")
295
+ return 2
296
+
297
+ if stats.get("failed", 0) > 0:
298
+ return 1
299
+
300
+ return 0
301
+
302
+
303
+ def _collect_paths(args: Any) -> list[Path]:
304
+ """Collect adapter paths from --input-dir or --input-list."""
305
+ paths: list[Path] = []
306
+ if args.input_dir:
307
+ d = Path(args.input_dir)
308
+ if not d.is_dir():
309
+ logger.error("--input-dir is not a directory: %s", d)
310
+ return []
311
+ paths = list(d.rglob("*.safetensors"))
312
+ elif args.input_list:
313
+ lf = Path(args.input_list)
314
+ if not lf.exists():
315
+ logger.error("--input-list file not found: %s", lf)
316
+ return []
317
+ lines = lf.read_text().splitlines()
318
+ paths = list(dict.fromkeys(Path(line.strip()) for line in lines if line.strip()))
319
+ return paths
320
+
321
+
322
+ def _write_run_summary(run_dir: Path, run_id: str, stats: dict) -> None:
323
+ from datetime import datetime, timezone
324
+ summary = {
325
+ "run_id": run_id,
326
+ "completed_at": datetime.now(timezone.utc).isoformat(),
327
+ "stats": stats,
328
+ }
329
+ (run_dir / "run_summary.json").write_text(
330
+ json.dumps(summary, indent=2), encoding="utf-8"
331
+ )
332
+
333
+
334
+ _SEV_ORDER = {s: i for i, s in enumerate(_SEVERITIES)}
335
+
336
+
337
+ def _check_fail_on(run_jsonl: Path, threshold_idx: int) -> bool:
338
+ """Return True if any result in the JSONL has a finding at or above threshold."""
339
+ try:
340
+ for line in run_jsonl.read_text().splitlines():
341
+ if not line.strip():
342
+ continue
343
+ try:
344
+ obj = json.loads(line)
345
+ for finding in obj.get("findings", []):
346
+ sev = finding.get("severity", "LOW")
347
+ if _SEV_ORDER.get(sev, 0) >= threshold_idx:
348
+ return True
349
+ except json.JSONDecodeError:
350
+ continue
351
+ except OSError:
352
+ pass
353
+ return False
@@ -0,0 +1,68 @@
1
+ """Top-level CLI entry point for AdapterSentry.
2
+
3
+ Commands
4
+ --------
5
+ adaptersentry scan ADAPTER [options] — run M1 static analysis
6
+ adaptersentry --version — print version and exit
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import logging
13
+ import sys
14
+
15
+
16
+ def _build_root_parser() -> argparse.ArgumentParser:
17
+ from adaptersentry.version import __version__
18
+
19
+ parser = argparse.ArgumentParser(
20
+ prog="adaptersentry",
21
+ description="AdapterSentry — static security scanner for LoRA adapters",
22
+ formatter_class=argparse.RawDescriptionHelpFormatter,
23
+ epilog=(
24
+ "Examples:\n"
25
+ " adaptersentry scan adapter.safetensors\n"
26
+ " adaptersentry scan adapter.safetensors --format json --output report.json\n"
27
+ " adaptersentry scan adapter.safetensors --format sarif --fail-on HIGH\n"
28
+ " adaptersentry --version\n"
29
+ ),
30
+ )
31
+ parser.add_argument(
32
+ "--version",
33
+ action="version",
34
+ version=f"adaptersentry {__version__}",
35
+ )
36
+ parser.add_argument(
37
+ "--verbose",
38
+ action="store_true",
39
+ help="Enable DEBUG logging",
40
+ )
41
+ return parser
42
+
43
+
44
+ def main() -> None:
45
+ """Primary entry point for ``adaptersentry`` and ``python -m adaptersentry``."""
46
+ from adaptersentry.cli.scan import build_parser as build_scan_parser, run as run_scan
47
+ from adaptersentry.cli.batch import build_parser as build_batch_parser, run as run_batch
48
+
49
+ root = _build_root_parser()
50
+ subparsers = root.add_subparsers(dest="command", metavar="COMMAND")
51
+ build_scan_parser(subparsers)
52
+ build_batch_parser(subparsers)
53
+
54
+ args = root.parse_args()
55
+
56
+ logging.basicConfig(
57
+ level=logging.DEBUG if args.verbose else logging.WARNING,
58
+ format="%(levelname)s %(name)s: %(message)s",
59
+ stream=sys.stderr,
60
+ )
61
+
62
+ if args.command == "scan":
63
+ sys.exit(run_scan(args))
64
+ elif args.command == "batch":
65
+ sys.exit(run_batch(args))
66
+ else:
67
+ root.print_help()
68
+ sys.exit(0)