picosentry 0.16.0__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 (95) hide show
  1. picosentry/__init__.py +47 -0
  2. picosentry/__main__.py +8 -0
  3. picosentry/_network.py +77 -0
  4. picosentry/advisory.py +368 -0
  5. picosentry/audit.py +329 -0
  6. picosentry/auth.py +617 -0
  7. picosentry/cache.py +370 -0
  8. picosentry/cli.py +1802 -0
  9. picosentry/config.py +525 -0
  10. picosentry/corpus/advisories/npm-critical-advisories.json +2196 -0
  11. picosentry/corpus/generate_npm_top.py +311 -0
  12. picosentry/corpus/ioc/colors_js.json +13 -0
  13. picosentry/corpus/ioc/crossenv.json +15 -0
  14. picosentry/corpus/ioc/event_stream_3.3.6.json +24 -0
  15. picosentry/corpus/ioc/left_pad.json +14 -0
  16. picosentry/corpus/ioc/nx_typosquat.json +20 -0
  17. picosentry/corpus/ioc/shai_hulud.json +21 -0
  18. picosentry/corpus/ioc/ua_parser_js.json +13 -0
  19. picosentry/corpus/npm_top_packages.json +329 -0
  20. picosentry/corpus_governance.py +515 -0
  21. picosentry/corpus_share.py +462 -0
  22. picosentry/crypto.py +527 -0
  23. picosentry/daemon.py +784 -0
  24. picosentry/detection_quality.py +495 -0
  25. picosentry/docs/rules/L2-ADV-001.md +46 -0
  26. picosentry/docs/rules/L2-BUND-001.md +46 -0
  27. picosentry/docs/rules/L2-CRED-001.md +46 -0
  28. picosentry/docs/rules/L2-DEPC-001.md +37 -0
  29. picosentry/docs/rules/L2-ENGIN-001.md +55 -0
  30. picosentry/docs/rules/L2-FORK-001.md +40 -0
  31. picosentry/docs/rules/L2-IOC-001.md +65 -0
  32. picosentry/docs/rules/L2-LICENSE-001.md +61 -0
  33. picosentry/docs/rules/L2-LOCK-001.md +45 -0
  34. picosentry/docs/rules/L2-MAINT-001.md +50 -0
  35. picosentry/docs/rules/L2-MANI-001.md +45 -0
  36. picosentry/docs/rules/L2-MANI-002.md +37 -0
  37. picosentry/docs/rules/L2-OBFS-001.md +36 -0
  38. picosentry/docs/rules/L2-OBFS-002.md +35 -0
  39. picosentry/docs/rules/L2-OBFS-003.md +36 -0
  40. picosentry/docs/rules/L2-OBFS-004.md +35 -0
  41. picosentry/docs/rules/L2-PNPM-001.md +52 -0
  42. picosentry/docs/rules/L2-POST-001.md +47 -0
  43. picosentry/docs/rules/L2-PROV-001.md +46 -0
  44. picosentry/docs/rules/L2-SIDELOAD-001.md +59 -0
  45. picosentry/docs/rules/L2-TYPO-001.md +57 -0
  46. picosentry/docs/rules/README.md +92 -0
  47. picosentry/engine.py +412 -0
  48. picosentry/enterprise.py +178 -0
  49. picosentry/fleet.py +566 -0
  50. picosentry/formatters/__init__.py +10 -0
  51. picosentry/formatters/cyclonedx.py +208 -0
  52. picosentry/formatters/github.py +98 -0
  53. picosentry/formatters/json_fmt.py +17 -0
  54. picosentry/formatters/ml_context.py +18 -0
  55. picosentry/formatters/sarif.py +116 -0
  56. picosentry/formatters/table.py +95 -0
  57. picosentry/guards.py +288 -0
  58. picosentry/ioc_registry.py +214 -0
  59. picosentry/logging.py +219 -0
  60. picosentry/management.py +414 -0
  61. picosentry/metrics.py +222 -0
  62. picosentry/models.py +382 -0
  63. picosentry/policy.py +814 -0
  64. picosentry/policy_lifecycle.py +387 -0
  65. picosentry/py.typed +0 -0
  66. picosentry/rules/__init__.py +196 -0
  67. picosentry/rules/advisory_check.py +150 -0
  68. picosentry/rules/bundled_shadow.py +151 -0
  69. picosentry/rules/credential_read.py +334 -0
  70. picosentry/rules/dep_confusion.py +166 -0
  71. picosentry/rules/engine.py +208 -0
  72. picosentry/rules/fork_drift.py +295 -0
  73. picosentry/rules/ioc_detection.py +199 -0
  74. picosentry/rules/license.py +248 -0
  75. picosentry/rules/lockfile_drift.py +397 -0
  76. picosentry/rules/maintainer_change.py +287 -0
  77. picosentry/rules/manifest.py +151 -0
  78. picosentry/rules/obfuscation.py +218 -0
  79. picosentry/rules/pnpm_config.py +149 -0
  80. picosentry/rules/pnpm_lock_parser.py +243 -0
  81. picosentry/rules/post_install.py +156 -0
  82. picosentry/rules/provenance.py +188 -0
  83. picosentry/rules/sideloading.py +134 -0
  84. picosentry/rules/typosquat.py +331 -0
  85. picosentry/rules/utils.py +100 -0
  86. picosentry/tenant.py +433 -0
  87. picosentry/workspace.py +371 -0
  88. picosentry-0.16.0.dist-info/METADATA +392 -0
  89. picosentry-0.16.0.dist-info/RECORD +95 -0
  90. picosentry-0.16.0.dist-info/WHEEL +5 -0
  91. picosentry-0.16.0.dist-info/entry_points.txt +2 -0
  92. picosentry-0.16.0.dist-info/licenses/COMMERCIAL-LICENSE.md +20 -0
  93. picosentry-0.16.0.dist-info/licenses/LICENSE +91 -0
  94. picosentry-0.16.0.dist-info/licenses/LICENSE-SUMMARY.md +29 -0
  95. picosentry-0.16.0.dist-info/top_level.txt +1 -0
picosentry/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ PicoSentry — deterministic supply-chain scanner for npm/pnpm.
3
+
4
+ Same inputs + same corpus version = same findings and scan fingerprint.
5
+ No HTTP at scan time. No probabilistic heuristics. No narrative in findings.
6
+
7
+ Usage:
8
+ from picosentry import ScanEngine, create_default_engine
9
+ result = create_default_engine().scan("./my-project")
10
+ print(result.to_json())
11
+
12
+ Deterministic guard stack:
13
+ from picosentry.guards import (
14
+ DeterministicGuard, DeterminismViolation,
15
+ deterministic_hash, fingerprint_scan,
16
+ verify_determinism, diff_scans,
17
+ )
18
+ """
19
+
20
+ from .engine import ScanEngine, create_default_engine, user_corpus_dir
21
+ from .models import (
22
+ BaselineResult,
23
+ Confidence,
24
+ Finding,
25
+ RuleExecution,
26
+ ScanResult,
27
+ ScanStats,
28
+ Severity,
29
+ apply_baseline,
30
+ load_baseline,
31
+ )
32
+
33
+ __version__ = "0.16.0"
34
+ __all__ = [
35
+ "ScanEngine",
36
+ "create_default_engine",
37
+ "user_corpus_dir",
38
+ "Finding",
39
+ "ScanResult",
40
+ "ScanStats",
41
+ "Severity",
42
+ "Confidence",
43
+ "BaselineResult",
44
+ "RuleExecution",
45
+ "load_baseline",
46
+ "apply_baseline",
47
+ ]
picosentry/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow running PicoSentry CLI as: python -m picosentry"""
2
+
3
+ from picosentry.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ import sys
7
+
8
+ sys.exit(main())
picosentry/_network.py ADDED
@@ -0,0 +1,77 @@
1
+ """Network helpers with TLS enforcement and size limits for PicoSentry.
2
+
3
+ All outbound HTTP in PicoSentry must go through safe_urlopen to:
4
+ - Reject non-HTTPS URLs (MITM protection)
5
+ - Cap response body size (OOM / disk-exhaustion protection)
6
+
7
+ The scanner engine itself is offline; this module is only used by
8
+ management, auth (JWKS), and the CLI update command.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import urllib.error
15
+ import urllib.request
16
+ from http.client import HTTPResponse
17
+
18
+ logger = logging.getLogger("picosentry._network")
19
+
20
+ # Default maximum response body size (10 MB)
21
+ DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
22
+
23
+
24
+ class InsecureURLError(ValueError):
25
+ """Raised when a non-HTTPS URL is passed to safe_urlopen."""
26
+
27
+
28
+ class ResponseTooLargeError(ValueError):
29
+ """Raised when a response body exceeds the configured size limit."""
30
+
31
+
32
+ def safe_urlopen(
33
+ url: str | urllib.request.Request,
34
+ *,
35
+ timeout: int = 30,
36
+ max_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
37
+ allow_http: bool = False,
38
+ ) -> tuple[HTTPResponse, bytes]:
39
+ """Open a URL with TLS enforcement and response size capping.
40
+
41
+ Args:
42
+ url: URL string or urllib Request object.
43
+ timeout: Request timeout in seconds.
44
+ max_bytes: Maximum allowed response body size.
45
+ allow_http: If True, allow http:// URLs (for local dev only).
46
+
47
+ Returns:
48
+ Tuple of (response_object, body_bytes).
49
+
50
+ Raises:
51
+ InsecureURLError: If the URL scheme is not HTTPS.
52
+ ResponseTooLargeError: If the response body exceeds max_bytes.
53
+ urllib.error.URLError: If the request fails.
54
+ """
55
+ # Extract URL string for scheme check
56
+ url_str = url.full_url if isinstance(url, urllib.request.Request) else url
57
+
58
+ if not allow_http and not url_str.startswith("https://"):
59
+ raise InsecureURLError(
60
+ f"Refusing non-HTTPS URL (MITM risk): {url_str}. Set allow_http=True only for local development."
61
+ )
62
+
63
+ try:
64
+ resp = urllib.request.urlopen(url, timeout=timeout)
65
+ except urllib.error.URLError:
66
+ raise
67
+
68
+ # Read with size cap
69
+ body = resp.read(max_bytes + 1)
70
+ if len(body) > max_bytes:
71
+ resp.close()
72
+ raise ResponseTooLargeError(
73
+ f"Response from {url_str} exceeded {max_bytes // (1024 * 1024)}MB limit. "
74
+ "Possible network issue or MITM attack."
75
+ )
76
+
77
+ return resp, body
picosentry/advisory.py ADDED
@@ -0,0 +1,368 @@
1
+ """
2
+ Advisory database integration for PicoSentry.
3
+
4
+ Loads OSV-format vulnerability data from a local directory and matches
5
+ installed packages against known CVEs, GHSA advisories, and npm advisories.
6
+
7
+ Enterprise teams can mirror the OSV database locally for air-gapped scanning:
8
+ gsutil cp gs://osv-vulnerabilities/npm/all.zip .
9
+ unzip all.zip -d advisories/
10
+ picosentry scan . --advisory-db advisories/
11
+
12
+ Offline-only. No network calls at scan time.
13
+ Supports: OSV JSON format, GitHub Advisory Database (GHSA), npm advisory format.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ logger = logging.getLogger("picosentry.advisory")
25
+
26
+ # Semver parsing: extract major.minor.patch from a version string
27
+ _SEMVER_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
28
+
29
+
30
+ @dataclass
31
+ class Advisory:
32
+ """A single security advisory from an OSV-format database."""
33
+
34
+ id: str = "" # CVE-2024-xxxx, GHSA-xxxx-xxxx, etc.
35
+ package_name: str = "" # npm package name
36
+ summary: str = ""
37
+ severity: str = "MEDIUM" # CRITICAL, HIGH, MEDIUM, LOW
38
+ fixed_version: str = "" # First patched version
39
+ affected_versions: list[str] = field(default_factory=list)
40
+ cwe_ids: list[str] = field(default_factory=list)
41
+ references: list[str] = field(default_factory=list)
42
+ published: str = ""
43
+ database_specific: dict = field(default_factory=dict)
44
+ affected_ranges: list[tuple[str, str, bool]] = field(default_factory=list)
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "id": self.id,
49
+ "package_name": self.package_name,
50
+ "summary": self.summary,
51
+ "severity": self.severity,
52
+ "fixed_version": self.fixed_version,
53
+ "affected_versions": self.affected_versions,
54
+ "cwe_ids": self.cwe_ids,
55
+ "references": self.references,
56
+ "published": self.published,
57
+ "affected_ranges": self.affected_ranges,
58
+ }
59
+
60
+ @staticmethod
61
+ def from_osv(data: dict) -> Advisory | None:
62
+ """Parse an OSV-format advisory entry.
63
+
64
+ OSV schema: https://ossf.github.io/osv-schema/
65
+ """
66
+ adv_id = data.get("id", "")
67
+ summary = data.get("summary", "")
68
+ details = data.get("details", "")
69
+ if not summary and details:
70
+ summary = details[:200]
71
+
72
+ # Extract package name from "affected" array
73
+ pkg_name = ""
74
+ affected_versions: list[str] = []
75
+ affected_ranges: list[tuple[str, str, bool]] = []
76
+ for affected in data.get("affected", []):
77
+ pkg = affected.get("package", {})
78
+ ecosystem = pkg.get("ecosystem", "")
79
+ if ecosystem.lower() == "npm":
80
+ pkg_name = pkg.get("name", "")
81
+ for r in affected.get("ranges", []):
82
+ introduced = ""
83
+ fixed = ""
84
+ last_affected = ""
85
+ for event in r.get("events", []):
86
+ if "introduced" in event:
87
+ introduced = event["introduced"]
88
+ if "fixed" in event:
89
+ fixed = event["fixed"]
90
+ if "last_affected" in event:
91
+ last_affected = event["last_affected"]
92
+ if introduced:
93
+ if fixed:
94
+ # fixed is exclusive upper bound (< fixed)
95
+ affected_ranges.append((introduced, fixed, False))
96
+ elif last_affected:
97
+ # last_affected is inclusive upper bound (<= last_affected)
98
+ affected_ranges.append((introduced, last_affected, True))
99
+ else:
100
+ # No upper bound — all versions >= introduced are affected
101
+ affected_ranges.append((introduced, "", False))
102
+ for ver in affected.get("versions", []):
103
+ if ver not in affected_versions:
104
+ affected_versions.append(ver)
105
+
106
+ if not pkg_name:
107
+ return None
108
+
109
+ # Determine severity from database_specific or aliases
110
+ severity = "MEDIUM"
111
+ db_specific = data.get("database_specific", {})
112
+ if isinstance(db_specific, dict):
113
+ sev = db_specific.get("severity", "").upper()
114
+ if sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
115
+ severity = sev
116
+
117
+ # Extract fixed version
118
+ fixed_version = ""
119
+ for affected in data.get("affected", []):
120
+ for r in affected.get("ranges", []):
121
+ for event in r.get("events", []):
122
+ if "fixed" in event:
123
+ fixed_version = event["fixed"]
124
+
125
+ return Advisory(
126
+ id=adv_id,
127
+ package_name=pkg_name,
128
+ summary=summary,
129
+ severity=severity,
130
+ fixed_version=fixed_version,
131
+ affected_versions=affected_versions,
132
+ affected_ranges=affected_ranges,
133
+ cwe_ids=data.get("database_specific", {}).get("cwe_ids", [])
134
+ if isinstance(data.get("database_specific"), dict)
135
+ else [],
136
+ references=[ref.get("url", "") for ref in data.get("references", [])],
137
+ published=data.get("published", ""),
138
+ database_specific=db_specific if isinstance(db_specific, dict) else {},
139
+ )
140
+
141
+ @staticmethod
142
+ def from_ghsa(data: dict) -> Advisory | None:
143
+ """Parse a GitHub Advisory Database (GHSA) entry."""
144
+ adv_id = data.get("ghsa_id", data.get("id", ""))
145
+ return Advisory(
146
+ id=adv_id,
147
+ package_name=data.get("package", {}).get("name", ""),
148
+ summary=data.get("summary", ""),
149
+ severity=data.get("severity", "MEDIUM").upper(),
150
+ fixed_version=data.get("first_patched_version", {}).get("identifier", ""),
151
+ affected_versions=[data.get("vulnerable_version_range", "")],
152
+ cwe_ids=[c.get("cwe_id", "") for c in data.get("cwes", [])],
153
+ references=data.get("references", []),
154
+ published=data.get("published_at", ""),
155
+ )
156
+
157
+
158
+ class AdvisoryDB:
159
+ """Offline advisory database loaded from local OSV-format files.
160
+
161
+ Directory structure expected:
162
+ advisories/
163
+ npm/
164
+ CVE-2024-xxxx.json
165
+ GHSA-xxxx-xxxx.json
166
+ or flat .json files
167
+
168
+ Each file is a single OSV-format advisory entry.
169
+ """
170
+
171
+ def __init__(self, db_dir: Path | None = None) -> None:
172
+ self._advisories: dict[str, list[Advisory]] = {} # pkg_name → advisories
173
+ self._loaded = False
174
+ self._db_dir = db_dir
175
+ if db_dir and db_dir.is_dir():
176
+ self.load(db_dir)
177
+
178
+ def load(self, db_dir: Path) -> int:
179
+ """Load all advisory files from a directory.
180
+
181
+ Returns number of advisories loaded.
182
+ """
183
+ count = 0
184
+ for json_file in sorted(db_dir.rglob("*.json")):
185
+ if json_file.is_symlink():
186
+ continue
187
+ try:
188
+ data = json.loads(json_file.read_text(encoding="utf-8"))
189
+ except (json.JSONDecodeError, OSError):
190
+ logger.debug("Failed to read advisory file: %s", json_file)
191
+ continue
192
+
193
+ # Support both single advisory and array of advisories
194
+ entries = data if isinstance(data, list) else [data]
195
+
196
+ for entry in entries:
197
+ adv = Advisory.from_osv(entry)
198
+ if adv is None:
199
+ continue
200
+ self._advisories.setdefault(adv.package_name, []).append(adv)
201
+ count += 1
202
+
203
+ self._loaded = True
204
+ logger.info("Loaded %d advisories for %d packages", count, len(self._advisories))
205
+ return count
206
+
207
+ def check(self, pkg_name: str, pkg_version: str) -> list[Advisory]:
208
+ """Check a package against known advisories.
209
+
210
+ Returns list of advisories affecting this package.
211
+ Simple version matching: checks if version is in affected range
212
+ or below the fixed version.
213
+ """
214
+ advisories = self._advisories.get(pkg_name, [])
215
+ if not advisories:
216
+ return []
217
+
218
+ results: list[Advisory] = []
219
+ for adv in advisories:
220
+ if self._version_affected(pkg_version, adv):
221
+ results.append(adv)
222
+
223
+ return results
224
+
225
+ def _version_affected(self, version: str, adv: Advisory) -> bool:
226
+ """Check if a version is affected by an advisory.
227
+
228
+ Checks structured range intervals first (with AND logic within each
229
+ range), then falls back to fixed_version heuristic and explicit
230
+ affected_versions list for backward compatibility.
231
+ """
232
+ v_tuple = self._parse_version(version)
233
+ if v_tuple is None:
234
+ return False # Can't parse, assume not affected (conservative)
235
+
236
+ # Check structured range intervals (AND logic within each range)
237
+ for introduced, upper, upper_inclusive in adv.affected_ranges:
238
+ iv = self._parse_version(introduced)
239
+ if iv is None:
240
+ continue
241
+ if v_tuple < iv:
242
+ continue
243
+ if upper:
244
+ uv = self._parse_version(upper)
245
+ if uv is not None:
246
+ if upper_inclusive:
247
+ if v_tuple > uv:
248
+ continue
249
+ else:
250
+ if v_tuple >= uv:
251
+ continue
252
+ return True
253
+
254
+ # Fallback: if fixed version is set and version < fixed_version,
255
+ # the package is affected (used by GHSA and other sources without ranges).
256
+ # Skip this heuristic when structured ranges are available, since
257
+ # ranges encode both lower and upper bounds correctly.
258
+ if not adv.affected_ranges:
259
+ fv_tuple = self._parse_version(adv.fixed_version)
260
+ if fv_tuple and v_tuple < fv_tuple:
261
+ return True
262
+
263
+ # Check explicit affected version matches
264
+ return any(self._version_in_range(v_tuple, av) for av in adv.affected_versions)
265
+
266
+ @staticmethod
267
+ def _parse_version(version_str: str) -> tuple | None:
268
+ """Parse a semver-ish string into (major, minor, patch) tuple."""
269
+ if not version_str:
270
+ return None
271
+ m = _SEMVER_RE.search(version_str)
272
+ if m:
273
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
274
+ return None
275
+
276
+ @staticmethod
277
+ def _version_in_range(v_tuple: tuple, range_str: str) -> bool:
278
+ """Check if version falls within a simple range like '>=1.0.0' or '<2.0.0'."""
279
+ range_str = range_str.strip()
280
+ if range_str.startswith(">="):
281
+ rv = AdvisoryDB._parse_version(range_str[2:])
282
+ return rv is not None and v_tuple >= rv
283
+ if range_str.startswith("<="):
284
+ rv = AdvisoryDB._parse_version(range_str[2:])
285
+ return rv is not None and v_tuple <= rv
286
+ if range_str.startswith(">"):
287
+ rv = AdvisoryDB._parse_version(range_str[1:])
288
+ return rv is not None and v_tuple > rv
289
+ if range_str.startswith("<"):
290
+ rv = AdvisoryDB._parse_version(range_str[1:])
291
+ return rv is not None and v_tuple < rv
292
+ # Exact version match
293
+ rv = AdvisoryDB._parse_version(range_str)
294
+ return rv is not None and v_tuple == rv
295
+
296
+ @property
297
+ def package_count(self) -> int:
298
+ return len(self._advisories)
299
+
300
+ @property
301
+ def advisory_count(self) -> int:
302
+ return sum(len(v) for v in self._advisories.values())
303
+
304
+ @property
305
+ def is_loaded(self) -> bool:
306
+ return self._loaded
307
+
308
+
309
+ # ── Bundled advisory snapshot ─────────────────────────────────────────
310
+
311
+
312
+ def load_bundled_advisories() -> AdvisoryDB:
313
+ """Load the bundled advisory snapshot that ships with PicoSentry.
314
+
315
+ The snapshot contains a curated set of critical/high severity
316
+ npm advisories for air-gapped and offline environments.
317
+ For the full advisory database, use `picosentry advisories fetch`
318
+ or run `scripts/download-advisories.sh`.
319
+
320
+ Returns:
321
+ AdvisoryDB loaded with bundled advisories.
322
+ """
323
+ bundled_path = Path(__file__).parent / "corpus" / "advisories" / "npm-critical-advisories.json"
324
+ db = AdvisoryDB()
325
+ if not bundled_path.is_file():
326
+ logger.warning("Bundled advisory file not found: %s", bundled_path)
327
+ return db
328
+
329
+ try:
330
+ data = json.loads(bundled_path.read_text(encoding="utf-8"))
331
+ advisory_list = data.get("advisories", [])
332
+ if not advisory_list:
333
+ logger.info("Bundled advisory snapshot is empty — run scripts/bundle-advisories.py to populate")
334
+ return db
335
+
336
+ for entry in advisory_list:
337
+ adv = Advisory.from_osv(entry)
338
+ if adv is None:
339
+ continue
340
+ db._advisories.setdefault(adv.package_name, []).append(adv)
341
+
342
+ db._loaded = True
343
+ meta = data.get("metadata", {})
344
+ logger.info(
345
+ "Loaded %d bundled advisories for %d packages (source: %s)",
346
+ len(advisory_list),
347
+ len(db._advisories),
348
+ meta.get("source", "unknown"),
349
+ )
350
+ except (json.JSONDecodeError, OSError) as e:
351
+ logger.warning("Failed to load bundled advisories: %s", e)
352
+
353
+ return db
354
+
355
+
356
+ def default_advisory_dir() -> Path:
357
+ """Return default advisory database directory path.
358
+
359
+ Preference order:
360
+ 1. $PICOADVISORY_DIR env var
361
+ 2. ~/.local/share/picosentry/advisories/
362
+ """
363
+ import os
364
+
365
+ explicit = os.environ.get("PICOADVISORY_DIR")
366
+ if explicit:
367
+ return Path(explicit)
368
+ return Path.home() / ".local" / "share" / "picosentry" / "advisories"