libidentifier 0.2.0rc1__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.
- libidentifier/__init__.py +36 -0
- libidentifier/api.py +90 -0
- libidentifier/archive.py +133 -0
- libidentifier/cli.py +340 -0
- libidentifier/cli_resources.py +36 -0
- libidentifier/cli_setup.py +177 -0
- libidentifier/config.py +54 -0
- libidentifier/detect.py +30 -0
- libidentifier/errors.py +30 -0
- libidentifier/extractors/__init__.py +2 -0
- libidentifier/extractors/dex.py +121 -0
- libidentifier/extractors/dex_tables.py +137 -0
- libidentifier/extractors/elf.py +66 -0
- libidentifier/extractors/instructions.py +77 -0
- libidentifier/extractors/metadata.py +99 -0
- libidentifier/extractors/version_metadata.py +72 -0
- libidentifier/fingerprint/__init__.py +2 -0
- libidentifier/fingerprint/dex_v2.py +97 -0
- libidentifier/fingerprint/elf_v1.py +16 -0
- libidentifier/grouping.py +74 -0
- libidentifier/inventory.py +260 -0
- libidentifier/matcher.py +381 -0
- libidentifier/models.py +423 -0
- libidentifier/profiles/__init__.py +2 -0
- libidentifier/profiles/builder.py +182 -0
- libidentifier/profiles/catalog.py +135 -0
- libidentifier/profiles/distribution.py +303 -0
- libidentifier/profiles/fetcher.py +141 -0
- libidentifier/profiles/pom.py +66 -0
- libidentifier/profiles/schema.py +137 -0
- libidentifier/profiles/store.py +212 -0
- libidentifier/purl.py +36 -0
- libidentifier/relationships.py +168 -0
- libidentifier/sbom.py +62 -0
- libidentifier/serialization.py +53 -0
- libidentifier/table.py +254 -0
- libidentifier/version.py +2 -0
- libidentifier/vulnerabilities/__init__.py +2 -0
- libidentifier/vulnerabilities/correlate.py +164 -0
- libidentifier/vulnerabilities/database.py +213 -0
- libidentifier/vulnerabilities/models.py +155 -0
- libidentifier/vulnerabilities/runner.py +148 -0
- libidentifier/vulnerabilities/setup.py +74 -0
- libidentifier-0.2.0rc1.dist-info/METADATA +156 -0
- libidentifier-0.2.0rc1.dist-info/RECORD +50 -0
- libidentifier-0.2.0rc1.dist-info/WHEEL +5 -0
- libidentifier-0.2.0rc1.dist-info/entry_points.txt +2 -0
- libidentifier-0.2.0rc1.dist-info/licenses/LICENSE +62 -0
- libidentifier-0.2.0rc1.dist-info/licenses/THIRD_PARTY_NOTICES.md +15 -0
- libidentifier-0.2.0rc1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""SQLite schema and compatibility validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from ..config import FINGERPRINT_ALGORITHM, DATABASE_SCHEMA_VERSION
|
|
9
|
+
from ..errors import ProfileError
|
|
10
|
+
from ..purl import reference_coordinates
|
|
11
|
+
|
|
12
|
+
DDL = """
|
|
13
|
+
CREATE TABLE IF NOT EXISTS db_meta (
|
|
14
|
+
key TEXT PRIMARY KEY,
|
|
15
|
+
value TEXT NOT NULL
|
|
16
|
+
);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS library_family (
|
|
18
|
+
family_id TEXT PRIMARY KEY,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
ecosystem TEXT,
|
|
21
|
+
package_name TEXT
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS library_release (
|
|
24
|
+
release_id TEXT PRIMARY KEY,
|
|
25
|
+
family_id TEXT NOT NULL REFERENCES library_family(family_id),
|
|
26
|
+
version TEXT NOT NULL,
|
|
27
|
+
purl TEXT NOT NULL,
|
|
28
|
+
fingerprint_algorithm TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS reference_artifact (
|
|
31
|
+
artifact_id TEXT PRIMARY KEY,
|
|
32
|
+
release_id TEXT NOT NULL REFERENCES library_release(release_id),
|
|
33
|
+
sha256 TEXT NOT NULL,
|
|
34
|
+
format TEXT NOT NULL,
|
|
35
|
+
provenance_json TEXT NOT NULL,
|
|
36
|
+
UNIQUE(release_id,sha256)
|
|
37
|
+
);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS build_variant (
|
|
39
|
+
variant_id TEXT PRIMARY KEY,
|
|
40
|
+
artifact_id TEXT NOT NULL REFERENCES reference_artifact(artifact_id),
|
|
41
|
+
output_sha256 TEXT NOT NULL,
|
|
42
|
+
configuration_json TEXT NOT NULL,
|
|
43
|
+
is_primary INTEGER NOT NULL CHECK(is_primary IN (0,1))
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS fingerprint (
|
|
46
|
+
fingerprint_id TEXT PRIMARY KEY,
|
|
47
|
+
algorithm TEXT NOT NULL,
|
|
48
|
+
digest TEXT NOT NULL,
|
|
49
|
+
UNIQUE(algorithm,digest)
|
|
50
|
+
);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS fingerprint_occurrence (
|
|
52
|
+
variant_id TEXT NOT NULL REFERENCES build_variant(variant_id),
|
|
53
|
+
fingerprint_id TEXT NOT NULL REFERENCES fingerprint(fingerprint_id),
|
|
54
|
+
locator TEXT NOT NULL,
|
|
55
|
+
multiplicity INTEGER NOT NULL CHECK(multiplicity>0),
|
|
56
|
+
PRIMARY KEY(variant_id,fingerprint_id,locator)
|
|
57
|
+
);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_fingerprint_digest ON fingerprint(digest);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_occurrence_fingerprint ON fingerprint_occurrence(fingerprint_id);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_artifact_release ON reference_artifact(release_id);
|
|
61
|
+
CREATE VIEW IF NOT EXISTS dex_class AS
|
|
62
|
+
SELECT a.release_id, f.digest, o.locator AS descriptor, o.multiplicity
|
|
63
|
+
FROM fingerprint_occurrence o JOIN fingerprint f USING(fingerprint_id)
|
|
64
|
+
JOIN build_variant v USING(variant_id) JOIN reference_artifact a USING(artifact_id)
|
|
65
|
+
WHERE f.algorithm='dex-shape-v2' AND v.is_primary=1;
|
|
66
|
+
CREATE VIEW IF NOT EXISTS dex_shape AS
|
|
67
|
+
SELECT release_id,digest,SUM(multiplicity) AS multiplicity FROM dex_class
|
|
68
|
+
GROUP BY release_id,digest;
|
|
69
|
+
CREATE TABLE IF NOT EXISTS elf_identity (
|
|
70
|
+
release_id TEXT NOT NULL REFERENCES library_release(release_id),
|
|
71
|
+
abi TEXT NOT NULL,
|
|
72
|
+
sha256 TEXT NOT NULL,
|
|
73
|
+
build_id TEXT,
|
|
74
|
+
soname TEXT,
|
|
75
|
+
symbol_digest TEXT,
|
|
76
|
+
PRIMARY KEY (release_id, abi, sha256)
|
|
77
|
+
);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_elf_sha256 ON elf_identity(sha256);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS published_metadata (
|
|
80
|
+
release_id TEXT NOT NULL REFERENCES library_release(release_id),
|
|
81
|
+
source_sha256 TEXT NOT NULL,
|
|
82
|
+
body_json TEXT NOT NULL,
|
|
83
|
+
PRIMARY KEY(release_id,source_sha256)
|
|
84
|
+
);
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def initialize(conn: sqlite3.Connection) -> None:
|
|
89
|
+
if conn.execute("SELECT 1 FROM sqlite_master WHERE name='db_meta'").fetchone():
|
|
90
|
+
version = conn.execute("SELECT value FROM db_meta WHERE key='schema_version'").fetchone()
|
|
91
|
+
if not version or version[0] != str(DATABASE_SCHEMA_VERSION):
|
|
92
|
+
raise ProfileError('profile database schema is incompatible; rebuild with profiles rebuild')
|
|
93
|
+
conn.executescript(DDL)
|
|
94
|
+
meta = {
|
|
95
|
+
"schema_version": str(DATABASE_SCHEMA_VERSION),
|
|
96
|
+
"fingerprint_algorithm": FINGERPRINT_ALGORITHM,
|
|
97
|
+
"database_id": "local",
|
|
98
|
+
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
|
99
|
+
}
|
|
100
|
+
for key, value in meta.items():
|
|
101
|
+
conn.execute("INSERT OR IGNORE INTO db_meta(key, value) VALUES (?, ?)", (key, value))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validate(conn: sqlite3.Connection) -> None:
|
|
105
|
+
rows = dict(conn.execute("SELECT key, value FROM db_meta"))
|
|
106
|
+
if rows.get("schema_version") != str(DATABASE_SCHEMA_VERSION):
|
|
107
|
+
raise ProfileError("profile database schema version is incompatible; rebuild with profiles rebuild")
|
|
108
|
+
if rows.get("fingerprint_algorithm") != FINGERPRINT_ALGORITHM:
|
|
109
|
+
raise ProfileError("profile database fingerprint algorithm is incompatible")
|
|
110
|
+
required = {
|
|
111
|
+
"db_meta",
|
|
112
|
+
"library_family",
|
|
113
|
+
"library_release",
|
|
114
|
+
"dex_shape",
|
|
115
|
+
"dex_class",
|
|
116
|
+
"elf_identity",
|
|
117
|
+
"reference_artifact", "build_variant", "fingerprint", "fingerprint_occurrence", "published_metadata",
|
|
118
|
+
}
|
|
119
|
+
actual = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type IN ('table','view')")}
|
|
120
|
+
missing = required - actual
|
|
121
|
+
if missing:
|
|
122
|
+
raise ProfileError(f"profile database is missing tables: {', '.join(sorted(missing))}")
|
|
123
|
+
incompatible = conn.execute(
|
|
124
|
+
"SELECT COUNT(*) FROM library_release WHERE fingerprint_algorithm != ?",
|
|
125
|
+
(FINGERPRINT_ALGORITHM,),
|
|
126
|
+
).fetchone()[0]
|
|
127
|
+
if incompatible:
|
|
128
|
+
raise ProfileError("profile database contains incompatible release fingerprints; rebuild it")
|
|
129
|
+
for purl, version, name, namespace in conn.execute(
|
|
130
|
+
"SELECT r.purl, r.version, f.name, f.package_name FROM library_release r JOIN library_family f USING(family_id)"
|
|
131
|
+
):
|
|
132
|
+
try:
|
|
133
|
+
_, expected_namespace, expected_name, expected_version, _ = reference_coordinates(purl)
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
raise ProfileError(f"profile database has an invalid reference PURL: {exc}") from exc
|
|
136
|
+
if (version, name, namespace) != (expected_version, expected_name, expected_namespace):
|
|
137
|
+
raise ProfileError("profile database identity disagrees with reference PURL; rebuild it")
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Transactional profile storage and read-only matching index queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from ..config import FINGERPRINT_ALGORITHM
|
|
13
|
+
from ..errors import ProfileError
|
|
14
|
+
from ..models import ProfileDatabaseRecord
|
|
15
|
+
from ..purl import reference_coordinates
|
|
16
|
+
from .schema import initialize, validate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sha256_file(path: str | Path) -> str:
|
|
20
|
+
h = hashlib.sha256()
|
|
21
|
+
with Path(path).open("rb") as fh:
|
|
22
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
23
|
+
h.update(chunk)
|
|
24
|
+
return h.hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def connect_write(path: str | Path) -> sqlite3.Connection:
|
|
28
|
+
conn = sqlite3.connect(Path(path))
|
|
29
|
+
try:
|
|
30
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
31
|
+
initialize(conn)
|
|
32
|
+
validate(conn)
|
|
33
|
+
return conn
|
|
34
|
+
except Exception:
|
|
35
|
+
conn.close()
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def connect_readonly(path: str | Path) -> sqlite3.Connection:
|
|
40
|
+
p = Path(path)
|
|
41
|
+
if not p.is_file():
|
|
42
|
+
raise ProfileError(f"profile database does not exist or is not a file: {p}")
|
|
43
|
+
uri = f"file:{quote(str(p.resolve()).replace(chr(92), '/'))}?mode=ro"
|
|
44
|
+
conn = None
|
|
45
|
+
try:
|
|
46
|
+
conn = sqlite3.connect(uri, uri=True)
|
|
47
|
+
validate(conn)
|
|
48
|
+
return conn
|
|
49
|
+
except (sqlite3.Error, ProfileError) as exc:
|
|
50
|
+
if conn is not None:
|
|
51
|
+
conn.close()
|
|
52
|
+
raise ProfileError(f"cannot use profile database {p}: {exc}") from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def database_record(path: str | Path | None) -> ProfileDatabaseRecord:
|
|
56
|
+
if path is None:
|
|
57
|
+
return ProfileDatabaseRecord()
|
|
58
|
+
p = Path(path)
|
|
59
|
+
conn = connect_readonly(p)
|
|
60
|
+
try:
|
|
61
|
+
rows = dict(conn.execute("SELECT key, value FROM db_meta"))
|
|
62
|
+
count = conn.execute("SELECT COUNT(*) FROM library_release").fetchone()[0]
|
|
63
|
+
except sqlite3.Error as exc:
|
|
64
|
+
raise ProfileError(f"cannot read profile database metadata {p}: {exc}") from exc
|
|
65
|
+
finally:
|
|
66
|
+
conn.close()
|
|
67
|
+
try:
|
|
68
|
+
digest = sha256_file(p)
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
raise ProfileError(f"cannot read profile database {p}: {exc}") from exc
|
|
71
|
+
return ProfileDatabaseRecord(
|
|
72
|
+
id=rows.get("database_id"),
|
|
73
|
+
version=rows.get("schema_version"),
|
|
74
|
+
sha256=digest,
|
|
75
|
+
generated_at=rows.get("generated_at"),
|
|
76
|
+
profile_count=int(count),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def family_id_from_purl(purl: str) -> tuple[str, str, str | None, str]:
|
|
81
|
+
ecosystem, package_name, name, version, _ = reference_coordinates(purl)
|
|
82
|
+
family_id = hashlib.sha256(f"{ecosystem}:{package_name or ''}:{name}".encode()).hexdigest()[:16]
|
|
83
|
+
return family_id, name, package_name, version
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def release_id(family_id: str, version: str, source_sha256: str) -> str:
|
|
87
|
+
# Kept as an internal call shape; release identity no longer depends on bytes.
|
|
88
|
+
return hashlib.sha256(f"{family_id}:{version}".encode()).hexdigest()[:24]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def upsert_release(
|
|
92
|
+
conn: sqlite3.Connection,
|
|
93
|
+
*,
|
|
94
|
+
purl: str,
|
|
95
|
+
source_sha256: str,
|
|
96
|
+
source_format: str,
|
|
97
|
+
provenance: dict,
|
|
98
|
+
dex_shapes: Counter[str] | None = None,
|
|
99
|
+
dex_classes: Counter[tuple[str, str]] | None = None,
|
|
100
|
+
elf_identity: dict | None = None,
|
|
101
|
+
build_configuration: dict | None = None,
|
|
102
|
+
output_sha256: str | None = None,
|
|
103
|
+
published_metadata: dict | None = None,
|
|
104
|
+
) -> tuple[str, str, str]:
|
|
105
|
+
family_id, name, package_name, version = family_id_from_purl(purl)
|
|
106
|
+
rid = release_id(family_id, version, source_sha256)
|
|
107
|
+
ecosystem = "Maven" if purl.startswith("pkg:maven/") else (purl[4:].split("/", 1)[0] if purl.startswith("pkg:") else None)
|
|
108
|
+
with conn:
|
|
109
|
+
conn.execute(
|
|
110
|
+
"INSERT OR IGNORE INTO library_family(family_id, name, ecosystem, package_name) VALUES (?, ?, ?, ?)",
|
|
111
|
+
(family_id, name, ecosystem, package_name),
|
|
112
|
+
)
|
|
113
|
+
conn.execute(
|
|
114
|
+
"""
|
|
115
|
+
INSERT OR IGNORE INTO library_release(
|
|
116
|
+
release_id, family_id, version, purl, fingerprint_algorithm
|
|
117
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
118
|
+
""",
|
|
119
|
+
(
|
|
120
|
+
rid,
|
|
121
|
+
family_id,
|
|
122
|
+
version,
|
|
123
|
+
purl,
|
|
124
|
+
FINGERPRINT_ALGORITHM,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
aid = hashlib.sha256(f'{rid}:{source_sha256}'.encode()).hexdigest()[:24]
|
|
128
|
+
configuration = json.dumps(build_configuration or {'kind': 'original'}, sort_keys=True, separators=(',', ':'))
|
|
129
|
+
output_sha = output_sha256 or source_sha256
|
|
130
|
+
vid = hashlib.sha256(f'{aid}:{output_sha}:{configuration}'.encode()).hexdigest()[:24]
|
|
131
|
+
primary = not conn.execute("SELECT 1 FROM build_variant v JOIN reference_artifact a USING(artifact_id) WHERE a.release_id=? AND v.is_primary=1 AND ((a.format='elf')=?)", (rid, int(source_format == 'elf'))).fetchone()
|
|
132
|
+
conn.execute('INSERT OR IGNORE INTO reference_artifact VALUES (?,?,?,?,?)',
|
|
133
|
+
(aid, rid, source_sha256, source_format, json.dumps(provenance, sort_keys=True)))
|
|
134
|
+
conn.execute('INSERT OR IGNORE INTO build_variant VALUES (?,?,?,?,?)', (vid, aid, output_sha, configuration, int(primary)))
|
|
135
|
+
occurrences = dex_classes or Counter({(d, ''): n for d, n in (dex_shapes or {}).items()})
|
|
136
|
+
for (digest, descriptor), count in sorted(occurrences.items()):
|
|
137
|
+
fid = hashlib.sha256(f'dex-shape-v2:{digest}'.encode()).hexdigest()
|
|
138
|
+
conn.execute('INSERT OR IGNORE INTO fingerprint VALUES (?,?,?)', (fid, 'dex-shape-v2', digest))
|
|
139
|
+
conn.execute('INSERT OR REPLACE INTO fingerprint_occurrence VALUES (?,?,?,?)', (vid, fid, descriptor, count))
|
|
140
|
+
if published_metadata is not None:
|
|
141
|
+
conn.execute('INSERT OR REPLACE INTO published_metadata VALUES (?,?,?)',
|
|
142
|
+
(rid, published_metadata['sha256'], json.dumps(published_metadata, sort_keys=True)))
|
|
143
|
+
if elf_identity:
|
|
144
|
+
fid = hashlib.sha256(f"elf-identity-v1:{elf_identity['sha256']}".encode()).hexdigest()
|
|
145
|
+
conn.execute('INSERT OR IGNORE INTO fingerprint VALUES (?,?,?)', (fid, 'elf-identity-v1', elf_identity['sha256']))
|
|
146
|
+
conn.execute('INSERT OR IGNORE INTO fingerprint_occurrence VALUES (?,?,?,1)', (vid, fid, elf_identity['abi']))
|
|
147
|
+
conn.execute(
|
|
148
|
+
"""
|
|
149
|
+
INSERT OR REPLACE INTO elf_identity(release_id, abi, sha256, build_id, soname, symbol_digest)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
151
|
+
""",
|
|
152
|
+
(
|
|
153
|
+
rid,
|
|
154
|
+
elf_identity["abi"],
|
|
155
|
+
elf_identity["sha256"],
|
|
156
|
+
elf_identity.get("build_id"),
|
|
157
|
+
elf_identity.get("soname"),
|
|
158
|
+
elf_identity.get("symbol_digest"),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
conn.execute(
|
|
162
|
+
"INSERT OR REPLACE INTO db_meta(key, value) VALUES ('generated_at', datetime('now'))"
|
|
163
|
+
)
|
|
164
|
+
return rid, family_id, version
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def dex_class_rows(conn: sqlite3.Connection, release_id: str, digests: set[str]) -> dict[str, Counter[str]]:
|
|
168
|
+
if not digests:
|
|
169
|
+
return {}
|
|
170
|
+
placeholders = ",".join("?" for _ in digests)
|
|
171
|
+
rows: dict[str, Counter[str]] = {}
|
|
172
|
+
for digest, descriptor, multiplicity in conn.execute(
|
|
173
|
+
f"""
|
|
174
|
+
SELECT digest, descriptor, multiplicity
|
|
175
|
+
FROM dex_class
|
|
176
|
+
WHERE release_id=? AND digest IN ({placeholders})
|
|
177
|
+
""",
|
|
178
|
+
(release_id, *tuple(sorted(digests))),
|
|
179
|
+
):
|
|
180
|
+
rows.setdefault(digest, Counter())[descriptor] += int(multiplicity)
|
|
181
|
+
return rows
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def release_rows(conn: sqlite3.Connection, release_ids: set[str]) -> dict[str, dict]:
|
|
185
|
+
if not release_ids:
|
|
186
|
+
return {}
|
|
187
|
+
placeholders = ",".join("?" for _ in release_ids)
|
|
188
|
+
rows = {}
|
|
189
|
+
for row in conn.execute(
|
|
190
|
+
f"""
|
|
191
|
+
SELECT r.release_id, r.family_id, r.version, r.purl,
|
|
192
|
+
(SELECT sha256 FROM reference_artifact WHERE release_id=r.release_id ORDER BY artifact_id LIMIT 1),
|
|
193
|
+
(SELECT format FROM reference_artifact WHERE release_id=r.release_id ORDER BY artifact_id LIMIT 1),
|
|
194
|
+
f.name, f.ecosystem, f.package_name
|
|
195
|
+
FROM library_release r JOIN library_family f USING(family_id)
|
|
196
|
+
WHERE r.release_id IN ({placeholders})
|
|
197
|
+
""",
|
|
198
|
+
tuple(sorted(release_ids)),
|
|
199
|
+
):
|
|
200
|
+
rows[row[0]] = {
|
|
201
|
+
"release_id": row[0],
|
|
202
|
+
"family_id": row[1],
|
|
203
|
+
"version": row[2],
|
|
204
|
+
"purl": row[3],
|
|
205
|
+
"source_sha256": row[4],
|
|
206
|
+
"source_format": row[5],
|
|
207
|
+
"name": row[6],
|
|
208
|
+
"ecosystem": row[7],
|
|
209
|
+
"package_name": row[8],
|
|
210
|
+
"source_hashes": [x[0] for x in conn.execute('SELECT sha256 FROM reference_artifact WHERE release_id=?', (row[0],))],
|
|
211
|
+
}
|
|
212
|
+
return rows
|
libidentifier/purl.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Small PURL coordinate reader for trusted reference identities.
|
|
2
|
+
|
|
3
|
+
This does not resolve packages or validate ecosystem-specific release names.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from urllib.parse import unquote
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def reference_coordinates(purl: str) -> tuple[str, str | None, str, str, str]:
|
|
13
|
+
"""Return type, namespace, name, decoded version and versionless PURL."""
|
|
14
|
+
if not isinstance(purl, str) or not purl.startswith("pkg:"):
|
|
15
|
+
raise ValueError("reference PURL must start with pkg:")
|
|
16
|
+
if re.search(r"\s|%(?![0-9a-fA-F]{2})", purl):
|
|
17
|
+
raise ValueError("reference PURL has invalid escaping or whitespace")
|
|
18
|
+
coordinate = re.split(r"[?#]", purl, maxsplit=1)[0]
|
|
19
|
+
suffix = purl[len(coordinate):]
|
|
20
|
+
if coordinate.count("@") != 1:
|
|
21
|
+
raise ValueError("reference PURL must include one explicit version")
|
|
22
|
+
base, encoded_version = coordinate.rsplit("@", 1)
|
|
23
|
+
parts = base[4:].split("/")
|
|
24
|
+
if len(parts) < 2 or not re.fullmatch(r"[a-z][a-z0-9.+-]*", parts[0]):
|
|
25
|
+
raise ValueError("reference PURL has invalid package type")
|
|
26
|
+
decoded = [unquote(part, errors="strict") for part in parts[1:]]
|
|
27
|
+
version = unquote(encoded_version, errors="strict")
|
|
28
|
+
if not version or any(not part or part in {".", ".."} or "/" in part for part in decoded):
|
|
29
|
+
raise ValueError("reference PURL needs a package name and non-empty version")
|
|
30
|
+
if parts[0] == "maven" and len(decoded) != 2:
|
|
31
|
+
raise ValueError("Maven PURL must include group and artifact")
|
|
32
|
+
return parts[0], "/".join(decoded[:-1]) or None, decoded[-1], version, base + suffix
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def family_purl(purl: str) -> str:
|
|
36
|
+
return reference_coordinates(purl)[4]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Evidence-backed structural/call relationships and component aggregation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
import json
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from .extractors.instructions import decode_invocations
|
|
10
|
+
from .grouping import locator_key, stable_id, group_unknown
|
|
11
|
+
from .models import (AnalysisRun, Producer, EvidenceRecord, EntityRef, Relationship,
|
|
12
|
+
OwnershipAssertion, AnalysisError)
|
|
13
|
+
from .purl import family_purl
|
|
14
|
+
|
|
15
|
+
PRODUCER = Producer('dex-relationships', '1')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def enrich(result, inv, options, conn=None):
|
|
19
|
+
"""Run once against the complete internal inventory, before output capping."""
|
|
20
|
+
evidence, edges, runs = list(result.evidence_records), [], list(result.analysis_runs)
|
|
21
|
+
definitions = defaultdict(list)
|
|
22
|
+
methods = defaultdict(list)
|
|
23
|
+
for cls in inv.dex_classes:
|
|
24
|
+
definitions[cls.descriptor].append(cls.locator)
|
|
25
|
+
for method in cls.methods:
|
|
26
|
+
if method.descriptor:
|
|
27
|
+
methods[method.descriptor].append(replace(cls.locator, method_descriptor=method.descriptor))
|
|
28
|
+
assertions = inv.ownership_assertions or [OwnershipAssertion(cls.locator,
|
|
29
|
+
'ambiguous' if len(definitions[cls.descriptor]) > 1 else 'unattributed', []) for cls in inv.dex_classes]
|
|
30
|
+
resolved_assertions = []
|
|
31
|
+
for assertion in assertions:
|
|
32
|
+
ids = []
|
|
33
|
+
if assertion.component_ids:
|
|
34
|
+
eid = stable_id('evidence', ['ownership', assertion.to_dict()])
|
|
35
|
+
evidence.append(EvidenceRecord(eid, 'component_match', Producer('component-matcher', '2'),
|
|
36
|
+
assertion.location, details={'component_ids': assertion.component_ids, 'status': assertion.status},
|
|
37
|
+
provenance={'profile_database_sha256': result.profile_database.sha256}, limitations=assertion.limitations))
|
|
38
|
+
ids.append(eid)
|
|
39
|
+
resolved_assertions.append(replace(assertion, evidence_ids=ids))
|
|
40
|
+
incomplete = inv.coverage.dex == 'partial'
|
|
41
|
+
runs.append(AnalysisRun('ownership', 'not_requested' if conn is None else ('partial' if incomplete or any(a.status == 'ambiguous' for a in assertions) else 'complete'),
|
|
42
|
+
Producer('component-matcher', '2'), {'definitions': len(assertions), 'ambiguous': sum(a.status == 'ambiguous' for a in assertions)},
|
|
43
|
+
['Ownership is a supported attribution, not proof of original authorship']))
|
|
44
|
+
|
|
45
|
+
def entity(kind, descriptor, locations):
|
|
46
|
+
location = locations[0] if len(locations) == 1 else None
|
|
47
|
+
identity = [kind, locator_key(location), descriptor] if location else [kind, descriptor, 'ambiguous' if locations else 'external']
|
|
48
|
+
return EntityRef(kind, stable_id(kind, identity), location)
|
|
49
|
+
|
|
50
|
+
def emit(kind, source, target, location, details, qualifiers=None):
|
|
51
|
+
eid = stable_id('evidence', [kind, location.to_dict(), details])
|
|
52
|
+
evidence.append(EvidenceRecord(eid, kind, PRODUCER, location, details))
|
|
53
|
+
rid = stable_id('relationship', [kind, source.id, target.id, eid])
|
|
54
|
+
edges.append(Relationship(rid, kind, source, target, [eid], PRODUCER,
|
|
55
|
+
qualifiers=qualifiers or {}, limitations=['Static reference does not establish runtime execution']))
|
|
56
|
+
|
|
57
|
+
structural_count = 0
|
|
58
|
+
calls_examined = calls_skipped = 0
|
|
59
|
+
call_errors = []
|
|
60
|
+
if options.extract_relationships:
|
|
61
|
+
for cls in inv.dex_classes:
|
|
62
|
+
source = entity('class', cls.descriptor, [cls.locator])
|
|
63
|
+
refs = [('inheritance', cls.superclass, cls.locator, {'role': 'superclass'})]
|
|
64
|
+
refs += [('interface_implementation', desc, cls.locator, {'role': 'interface'}) for desc in cls.interfaces]
|
|
65
|
+
refs += [('class_reference', f.type_descriptor, cls.locator, {'role': 'field_type', 'field': f.name}) for f in cls.fields]
|
|
66
|
+
for method in cls.methods:
|
|
67
|
+
loc = replace(cls.locator, method_descriptor=method.descriptor)
|
|
68
|
+
refs.append(('class_reference', method.return_type, loc, {'role': 'return_type'}))
|
|
69
|
+
refs += [('class_reference', desc, loc, {'role': 'parameter_type', 'parameter_index': i}) for i,desc in enumerate(method.parameter_types)]
|
|
70
|
+
for kind, desc, loc, details in refs:
|
|
71
|
+
if not desc or not desc.lstrip('[').startswith('L'):
|
|
72
|
+
continue
|
|
73
|
+
target_desc = desc.lstrip('[')
|
|
74
|
+
target = entity('class', target_desc, definitions.get(target_desc, []))
|
|
75
|
+
emit(kind, source, target, loc, {**details, 'target_descriptor': target_desc},
|
|
76
|
+
{'target_resolution': 'unique' if target.location else ('ambiguous' if definitions.get(target_desc) else 'external')})
|
|
77
|
+
structural_count += 1
|
|
78
|
+
runs.append(AnalysisRun('relationships.structural', ('partial' if incomplete else 'complete') if options.extract_relationships else 'not_requested',
|
|
79
|
+
PRODUCER, {'relationships': structural_count}, ['Only declaration/signature type references are extracted']))
|
|
80
|
+
|
|
81
|
+
if options.extract_calls:
|
|
82
|
+
for cls in inv.dex_classes:
|
|
83
|
+
tables = inv.dex_tables.get((cls.locator.container, cls.locator.member))
|
|
84
|
+
for method in cls.methods:
|
|
85
|
+
if method.code_offset is None:
|
|
86
|
+
continue
|
|
87
|
+
calls_examined += 1
|
|
88
|
+
loc = replace(cls.locator, method_descriptor=method.descriptor)
|
|
89
|
+
if tables is None:
|
|
90
|
+
calls_skipped += 1
|
|
91
|
+
call_errors.append(AnalysisError('call_tables_missing', loc.member, 'Method reference tables unavailable'))
|
|
92
|
+
continue
|
|
93
|
+
calls, issues = decode_invocations(method.bytecode, method_count=len(tables.methods), dex_version=int(tables.data[4:7]))
|
|
94
|
+
if issues:
|
|
95
|
+
calls_skipped += 1
|
|
96
|
+
call_errors.extend(AnalysisError('call_extraction_partial', loc.member,
|
|
97
|
+
f'{method.descriptor}: {issue}') for issue in issues)
|
|
98
|
+
source = entity('method', method.descriptor, [loc])
|
|
99
|
+
for offset, kind, index in calls:
|
|
100
|
+
target_desc = tables.method_descriptor(index)
|
|
101
|
+
target = entity('method', target_desc, methods.get(target_desc, []))
|
|
102
|
+
emit('static_call_reference', source, target,
|
|
103
|
+
replace(loc, byte_offset=method.code_offset + offset),
|
|
104
|
+
{'declared_target': target_desc, 'instruction_byte_offset': offset, 'method_index': index},
|
|
105
|
+
{'invocation_kind': kind, 'target_resolution': 'unique' if target.location else ('ambiguous' if methods.get(target_desc) else 'external')})
|
|
106
|
+
runs.append(AnalysisRun('relationships.calls', ('partial' if incomplete or call_errors else 'complete') if options.extract_calls else 'not_requested',
|
|
107
|
+
PRODUCER, {'methods_examined': calls_examined, 'methods_partial': calls_skipped},
|
|
108
|
+
['Declared ordinary invocation targets only; no runtime dispatch or reachability'], call_errors))
|
|
109
|
+
|
|
110
|
+
ownership = {locator_key(a.location): a.component_ids[0] for a in resolved_assertions if a.status == 'attributed' and len(a.component_ids) == 1}
|
|
111
|
+
aggregates = defaultdict(list)
|
|
112
|
+
for edge in edges:
|
|
113
|
+
if edge.source.location and edge.target.location:
|
|
114
|
+
src, dst = ownership.get(locator_key(edge.source.location)), ownership.get(locator_key(edge.target.location))
|
|
115
|
+
if src and dst and src != dst:
|
|
116
|
+
aggregates[(edge.type, src, dst)].append(edge)
|
|
117
|
+
for (kind, src, dst), supporting in sorted(aggregates.items()):
|
|
118
|
+
edges.append(Relationship(stable_id('relationship', [kind, src, dst]), kind,
|
|
119
|
+
EntityRef('component', src), EntityRef('component', dst),
|
|
120
|
+
sorted({eid for edge in supporting for eid in edge.evidence_ids}), PRODUCER,
|
|
121
|
+
strength='attributed', supporting_relationship_ids=sorted(e.id for e in supporting),
|
|
122
|
+
limitations=['Component edge depends on code ownership; does not prove build introduction or runtime execution']))
|
|
123
|
+
runs.append(AnalysisRun('relationships.components', 'not_requested' if conn is None or not (options.extract_relationships or options.extract_calls) else ('partial' if incomplete or any(a.status == 'ambiguous' for a in assertions) or call_errors else 'complete'),
|
|
124
|
+
PRODUCER, {'relationships': len(aggregates)}, ['Only uniquely attributed embedded endpoints are aggregated']))
|
|
125
|
+
|
|
126
|
+
published_count, missing_metadata = 0, 0
|
|
127
|
+
if conn is not None and options.extract_relationships:
|
|
128
|
+
metadata_rows = list(conn.execute('SELECT r.purl,m.body_json FROM published_metadata m JOIN library_release r USING(release_id) ORDER BY r.purl,m.source_sha256'))
|
|
129
|
+
for component in result.components:
|
|
130
|
+
supported = {v for v,details in component.score_details.get('version_candidate_scores',{}).items() if details.get('supported')}
|
|
131
|
+
versions = {component.version} if component.version else supported | set(component.version_candidates)
|
|
132
|
+
selected = [(purl, json.loads(body)) for purl,body in metadata_rows
|
|
133
|
+
if family_purl(purl) == component.family_purl and purl_version(purl) in versions]
|
|
134
|
+
if not selected:
|
|
135
|
+
missing_metadata += 1
|
|
136
|
+
for purl, metadata in selected:
|
|
137
|
+
if metadata['status'] != 'complete':
|
|
138
|
+
missing_metadata += 1
|
|
139
|
+
for index, declaration in enumerate(metadata['declarations']):
|
|
140
|
+
values = declaration['interpreted']
|
|
141
|
+
target_purl = ('pkg:maven/' + quote(values['groupId'], safe='.') + '/' + quote(values['artifactId'], safe='')
|
|
142
|
+
if values['groupId'] and values['artifactId'] else None)
|
|
143
|
+
eid = stable_id('evidence', [purl, metadata['sha256'], index])
|
|
144
|
+
evidence.append(EvidenceRecord(eid, 'pom_declaration', Producer('pom-direct', '1'),
|
|
145
|
+
details=declaration, provenance={'source':metadata['source'], 'sha256':metadata['sha256'], 'release_purl':purl}, limitations=metadata['limitations']))
|
|
146
|
+
edges.append(Relationship(stable_id('relationship', [component.component_id, eid]), 'published_dependency',
|
|
147
|
+
EntityRef('reference_release', purl, purl=purl), EntityRef('package', target_purl or stable_id('unresolved', declaration), purl=target_purl),
|
|
148
|
+
[eid], Producer('pom-direct','1'), strength='declared',
|
|
149
|
+
qualifiers={'component_id':component.component_id, 'release_association':'exact' if component.version else 'candidate', **declaration},
|
|
150
|
+
limitations=['Published reference declaration does not prove APK dependency inclusion or selected version']))
|
|
151
|
+
published_count += 1
|
|
152
|
+
runs.append(AnalysisRun('relationships.published', 'not_requested' if conn is None or not options.extract_relationships else ('partial' if missing_metadata else 'complete'),
|
|
153
|
+
Producer('pom-direct','1'), {'declarations':published_count, 'incomplete_components':missing_metadata},
|
|
154
|
+
['Only supplied reference metadata is evaluated; candidate release declarations are conditional']))
|
|
155
|
+
|
|
156
|
+
assigned = {locator_key(a.location) for a in resolved_assertions if a.status == 'attributed'}
|
|
157
|
+
unknown = [c.locator for c in inv.dex_classes if locator_key(c.locator) not in assigned]
|
|
158
|
+
clusters = group_unknown(inv.dex_classes, unknown, edges) if options.group_unknown_classes else []
|
|
159
|
+
runs.append(AnalysisRun('grouping', ('partial' if incomplete else 'complete') if options.group_unknown_classes else 'not_requested',
|
|
160
|
+
Producer('namespace-structural','1'), {'classes_total':len(unknown), 'classes_emitted':sum(c.class_count for c in clusters), 'clusters':len(clusters)},
|
|
161
|
+
['Grouping covers successfully extracted unmatched definitions only']))
|
|
162
|
+
return replace(result, ownership_assertions=resolved_assertions, evidence_records=sorted({e.id:e for e in evidence}.values(),key=lambda e:e.id),
|
|
163
|
+
relationships=sorted(edges,key=lambda e:e.id), analysis_runs=runs, unattributed_clusters=clusters)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def purl_version(purl):
|
|
167
|
+
from .purl import reference_coordinates
|
|
168
|
+
return reference_coordinates(purl)[3]
|
libidentifier/sbom.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""CycloneDX 1.5 JSON export for exact, high-confidence components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .models import ScanResult
|
|
9
|
+
from .purl import reference_coordinates
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def export_cyclonedx(result: ScanResult, path) -> None:
|
|
13
|
+
components = []
|
|
14
|
+
excluded = []
|
|
15
|
+
for component in result.components:
|
|
16
|
+
if not component.purl or not component.version:
|
|
17
|
+
excluded.append({"component_id": component.component_id, "reason": "exact version or PURL unavailable"})
|
|
18
|
+
continue
|
|
19
|
+
if component.identity_confidence != "high" or component.version_confidence != "high":
|
|
20
|
+
excluded.append({"component_id": component.component_id, "reason": "identity and version confidence must both be high"})
|
|
21
|
+
continue
|
|
22
|
+
try:
|
|
23
|
+
_, _, name, version, _ = reference_coordinates(component.purl)
|
|
24
|
+
except ValueError:
|
|
25
|
+
excluded.append({"component_id": component.component_id, "reason": "invalid reference PURL"})
|
|
26
|
+
continue
|
|
27
|
+
if version != component.version or name != component.name:
|
|
28
|
+
excluded.append({"component_id": component.component_id, "reason": "PURL disagrees with component identity/version"})
|
|
29
|
+
continue
|
|
30
|
+
components.append(
|
|
31
|
+
{
|
|
32
|
+
"type": "library",
|
|
33
|
+
"bom-ref": component.component_id,
|
|
34
|
+
"name": component.name,
|
|
35
|
+
"version": component.version,
|
|
36
|
+
"purl": component.purl,
|
|
37
|
+
"properties": [{"name": "libidentifier:matched_code", "value": json.dumps(
|
|
38
|
+
[loc.to_dict() for loc in component.matched_code], sort_keys=True, separators=(",", ":"))}],
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
bom = {
|
|
42
|
+
"bomFormat": "CycloneDX",
|
|
43
|
+
"specVersion": "1.5",
|
|
44
|
+
"version": 1,
|
|
45
|
+
"metadata": {
|
|
46
|
+
"tools": [{"name": result.tool_name, "version": result.tool_version}],
|
|
47
|
+
"properties": [
|
|
48
|
+
{"name": "libidentifier:inventory_scope", "value": "Only high-confidence exact-version matches; not a complete software inventory"},
|
|
49
|
+
{"name": "libidentifier:input", "value": json.dumps(result.input.to_dict(), sort_keys=True)},
|
|
50
|
+
{"name": "libidentifier:profile_database", "value": json.dumps(result.profile_database.to_dict(), sort_keys=True)},
|
|
51
|
+
{"name": "libidentifier:fingerprint_algorithm", "value": result.fingerprint_algorithm},
|
|
52
|
+
{"name": "libidentifier:scan_schema_version", "value": str(result.schema_version)},
|
|
53
|
+
{"name": "libidentifier:excluded_components", "value": json.dumps(excluded, sort_keys=True)},
|
|
54
|
+
{"name": "libidentifier:coverage", "value": json.dumps(result.coverage.to_dict(), sort_keys=True)},
|
|
55
|
+
{"name": "libidentifier:errors", "value": json.dumps([e.to_dict() for e in result.errors], sort_keys=True)},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
"compositions": [{"aggregate": "incomplete"}],
|
|
59
|
+
"components": sorted(components, key=lambda c: c["bom-ref"]),
|
|
60
|
+
}
|
|
61
|
+
Path(path).write_text(json.dumps(bom, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8")
|
|
62
|
+
|