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.
- picosentry/__init__.py +47 -0
- picosentry/__main__.py +8 -0
- picosentry/_network.py +77 -0
- picosentry/advisory.py +368 -0
- picosentry/audit.py +329 -0
- picosentry/auth.py +617 -0
- picosentry/cache.py +370 -0
- picosentry/cli.py +1802 -0
- picosentry/config.py +525 -0
- picosentry/corpus/advisories/npm-critical-advisories.json +2196 -0
- picosentry/corpus/generate_npm_top.py +311 -0
- picosentry/corpus/ioc/colors_js.json +13 -0
- picosentry/corpus/ioc/crossenv.json +15 -0
- picosentry/corpus/ioc/event_stream_3.3.6.json +24 -0
- picosentry/corpus/ioc/left_pad.json +14 -0
- picosentry/corpus/ioc/nx_typosquat.json +20 -0
- picosentry/corpus/ioc/shai_hulud.json +21 -0
- picosentry/corpus/ioc/ua_parser_js.json +13 -0
- picosentry/corpus/npm_top_packages.json +329 -0
- picosentry/corpus_governance.py +515 -0
- picosentry/corpus_share.py +462 -0
- picosentry/crypto.py +527 -0
- picosentry/daemon.py +784 -0
- picosentry/detection_quality.py +495 -0
- picosentry/docs/rules/L2-ADV-001.md +46 -0
- picosentry/docs/rules/L2-BUND-001.md +46 -0
- picosentry/docs/rules/L2-CRED-001.md +46 -0
- picosentry/docs/rules/L2-DEPC-001.md +37 -0
- picosentry/docs/rules/L2-ENGIN-001.md +55 -0
- picosentry/docs/rules/L2-FORK-001.md +40 -0
- picosentry/docs/rules/L2-IOC-001.md +65 -0
- picosentry/docs/rules/L2-LICENSE-001.md +61 -0
- picosentry/docs/rules/L2-LOCK-001.md +45 -0
- picosentry/docs/rules/L2-MAINT-001.md +50 -0
- picosentry/docs/rules/L2-MANI-001.md +45 -0
- picosentry/docs/rules/L2-MANI-002.md +37 -0
- picosentry/docs/rules/L2-OBFS-001.md +36 -0
- picosentry/docs/rules/L2-OBFS-002.md +35 -0
- picosentry/docs/rules/L2-OBFS-003.md +36 -0
- picosentry/docs/rules/L2-OBFS-004.md +35 -0
- picosentry/docs/rules/L2-PNPM-001.md +52 -0
- picosentry/docs/rules/L2-POST-001.md +47 -0
- picosentry/docs/rules/L2-PROV-001.md +46 -0
- picosentry/docs/rules/L2-SIDELOAD-001.md +59 -0
- picosentry/docs/rules/L2-TYPO-001.md +57 -0
- picosentry/docs/rules/README.md +92 -0
- picosentry/engine.py +412 -0
- picosentry/enterprise.py +178 -0
- picosentry/fleet.py +566 -0
- picosentry/formatters/__init__.py +10 -0
- picosentry/formatters/cyclonedx.py +208 -0
- picosentry/formatters/github.py +98 -0
- picosentry/formatters/json_fmt.py +17 -0
- picosentry/formatters/ml_context.py +18 -0
- picosentry/formatters/sarif.py +116 -0
- picosentry/formatters/table.py +95 -0
- picosentry/guards.py +288 -0
- picosentry/ioc_registry.py +214 -0
- picosentry/logging.py +219 -0
- picosentry/management.py +414 -0
- picosentry/metrics.py +222 -0
- picosentry/models.py +382 -0
- picosentry/policy.py +814 -0
- picosentry/policy_lifecycle.py +387 -0
- picosentry/py.typed +0 -0
- picosentry/rules/__init__.py +196 -0
- picosentry/rules/advisory_check.py +150 -0
- picosentry/rules/bundled_shadow.py +151 -0
- picosentry/rules/credential_read.py +334 -0
- picosentry/rules/dep_confusion.py +166 -0
- picosentry/rules/engine.py +208 -0
- picosentry/rules/fork_drift.py +295 -0
- picosentry/rules/ioc_detection.py +199 -0
- picosentry/rules/license.py +248 -0
- picosentry/rules/lockfile_drift.py +397 -0
- picosentry/rules/maintainer_change.py +287 -0
- picosentry/rules/manifest.py +151 -0
- picosentry/rules/obfuscation.py +218 -0
- picosentry/rules/pnpm_config.py +149 -0
- picosentry/rules/pnpm_lock_parser.py +243 -0
- picosentry/rules/post_install.py +156 -0
- picosentry/rules/provenance.py +188 -0
- picosentry/rules/sideloading.py +134 -0
- picosentry/rules/typosquat.py +331 -0
- picosentry/rules/utils.py +100 -0
- picosentry/tenant.py +433 -0
- picosentry/workspace.py +371 -0
- picosentry-0.16.0.dist-info/METADATA +392 -0
- picosentry-0.16.0.dist-info/RECORD +95 -0
- picosentry-0.16.0.dist-info/WHEEL +5 -0
- picosentry-0.16.0.dist-info/entry_points.txt +2 -0
- picosentry-0.16.0.dist-info/licenses/COMMERCIAL-LICENSE.md +20 -0
- picosentry-0.16.0.dist-info/licenses/LICENSE +91 -0
- picosentry-0.16.0.dist-info/licenses/LICENSE-SUMMARY.md +29 -0
- 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
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"
|