# Copyright (C) 2025-2026 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
import asyncio
import dataclasses
import itertools
import json
import os
from pathlib import Path
import sqlite3
from typing import Iterable
import aiohttp
from google.protobuf.field_mask_pb2 import FieldMask
import grpc.aio
import grpc.experimental
import tqdm.asyncio
import swh.graph.grpc.swhgraph_pb2 as swhgraph
import swh.graph.grpc.swhgraph_pb2_grpc as swhgraph_grpc
from .map_origins import OriginDenormalizer
from .utils import Cache, concurrent_async_map
GRAPH_GRPC_URL = "localhost:50091"
# GRAPH_GRPC_URL = "localhost:12345"
[docs]
def init_db(conn: sqlite3.Connection) -> None:
cur = conn.cursor()
for statement in [
"""
CREATE TABLE IF NOT EXISTS unknown_swhids (
swhid TEXT PRIMARY KEY
)
""",
# """
# CREATE TABLE IF NOT EXISTS swhids (
# id INT PRIMARY KEY,
# swhid TEXT
# )
# """,
# """
# CREATE UNIQUE INDEX IF NOT EXISTS swhids_swhid
# ON swhids (swhid)
# """,
# """
# CREATE TABLE IF NOT EXISTS vulnerability_swhids (
# vulnerability_filename TEXT,
# swhid_id INT
# )
# """,
# """
# CREATE INDEX IF NOT EXISTS vulnerability_swhids_vulnerability_filename
# ON vulnerability_swhids (vulnerability_filename)
# """,
# """
# CREATE INDEX IF NOT EXISTS vulnerability_swhids_swhid_id
# ON vulnerability_swhids (swhid_id)
# """,
]:
cur.execute(statement)
[docs]
@dataclasses.dataclass
class DocProcessor:
stub: swhgraph_grpc.TraversalServiceStub
session: aiohttp.ClientSession
conn: sqlite3.Connection
origin_denormalizer: OriginDenormalizer
root_revisions_cache: Cache[dict[tuple[str, ...], list[str]]]
total_invalid_commit_ids = 0
total_unknown_commits = 0
total_skipped_due_to_invalid_commit = 0
total_skipped_due_to_unknown_commit = 0
total_skipped_due_to_unimplemented_event_types = 0
total_vulnerable = 0
total_successes = 0
[docs]
def add_unknown_swhid(self, swhid) -> None:
assert swhid.startswith("swh:1:rev:"), swhid
return
self.conn.execute(
"""
INSERT INTO unknown_swhids(swhid)
VALUES(?)
ON CONFLICT (swhid) DO NOTHING
""",
(swhid,),
)
[docs]
def get_unknown_swhids(self, swhids: Iterable[str]) -> set[str]:
"""Given an iterable of SWHIDs, returns which ones are known to be unknown"""
swhids = tuple(swhids)
cur = self.conn.cursor()
cur.execute(
f"""
SELECT swhid
FROM unknown_swhids
WHERE swhid IN ({','.join('?' for _ in swhids)})
""",
swhids,
)
return {swhid for (swhid,) in cur}
[docs]
async def process_docs(self, arg):
filename, doc = arg
doc = json.loads(doc)
for affected in doc.get("affected", []):
for range_ in affected.get("ranges", []):
if range_["type"] == "GIT":
commits_by_event_type = await self.expand_git_range(range_)
if commits_by_event_type is not None:
await self.process_git_range(commits_by_event_type)
[docs]
async def expand_git_range(self, range_: dict) -> dict[str, set[str]] | None:
root_revisions = None
commits_by_event_type: dict[str, set[str]] = {}
for event in range_["events"]:
for event_type, commit_id in event.items():
if commit_id == "0":
if event_type != "introduced":
raise ValueError(
"Pseudo-version '0' is only allowed in 'introduced' events"
)
commits_by_event_type.setdefault(event_type, set()).add(commit_id)
# forbidden by the spec
if commits_by_event_type.keys() >= {"last_affected", "fixed"}:
raise ValueError(
"'events' array contains both 'last_affected' and 'fixed' events"
)
commit_ids = set(itertools.chain.from_iterable(commits_by_event_type.values()))
if "0" in commit_ids:
commit_ids.remove("0")
# "introduced allows a version of the value "0" to represent a version
# that sorts before any other version."
root_revisions = set()
for commit_id in commit_ids:
commit_swhid = "swh:1:rev:" + commit_id
if commit_id not in self.root_revisions_cache:
try:
revs = self.stub.Traverse(
swhgraph.TraversalRequest(
src=[commit_swhid],
edges="rev:rev",
direction=swhgraph.GraphDirection.FORWARD,
mask=FieldMask(paths=["swhid"]),
return_nodes=swhgraph.NodeFilter(
max_traversal_successors=0, # return only leaves
),
)
)
commit_root_revisions = [rev.swhid async for rev in revs]
except grpc.aio.AioRpcError as e:
if e.code() == grpc.StatusCode.NOT_FOUND:
self.add_unknown_swhid(commit_swhid)
self.total_unknown_commits += 1
continue
elif e.code() == grpc.StatusCode.INVALID_ARGUMENT:
self.total_invalid_commit_ids += 1
self.total_skipped_due_to_invalid_commit += 1
return None
else:
raise
else:
self.root_revisions_cache[commit_id] = commit_root_revisions
await self.root_revisions_cache.save()
else:
commit_root_revisions = self.root_revisions_cache[commit_id]
root_revisions.update(commit_root_revisions)
# replace "0" with the set of root commits
commit_swhids_by_event_type = {}
for event_type, commit_set in commits_by_event_type.items():
if "0" in commit_set:
commit_set.clear()
commit_swhids_by_event_type[event_type] = {
f"swh:1:rev:{commit_id}"
for commit_id in commit_set
if commit_id != "0"
} | root_revisions
else:
commit_swhids_by_event_type[event_type] = {
f"swh:1:rev:{commit_id}" for commit_id in commit_set
}
return commit_swhids_by_event_type
[docs]
async def process_git_range(
self, commits_by_event_type: dict[str, set[str]]
) -> None:
unknown_swhids = self.get_unknown_swhids(
set(itertools.chain.from_iterable(commits_by_event_type.values()))
)
self.total_unknown_commits += len(
commits_by_event_type["introduced"] & unknown_swhids
)
introduction_commits = commits_by_event_type["introduced"] - unknown_swhids
if not introduction_commits:
# not worth running this
self.total_skipped_due_to_unknown_commit += 1
return
assert "swh:1:rev:0" not in introduction_commits
while introduction_commits - unknown_swhids:
# keep trying in case some of the commits are missing from the graph
try:
reachable_by_introduction = {
rev.swhid.removeprefix("swh:1:rev:")
async for rev in self.stub.Traverse(
swhgraph.TraversalRequest(
src=introduction_commits,
edges="rev:rev",
direction=swhgraph.GraphDirection.BACKWARD,
mask=FieldMask(paths=["swhid"]),
ignore_node=(
commits_by_event_type.get("fixed", set())
| commits_by_event_type.get("last_affected", set())
| commits_by_event_type.get("limit", set())
)
- unknown_swhids,
)
)
}
except grpc.aio.AioRpcError as e:
if e.code() == grpc.StatusCode.NOT_FOUND:
# one of the commits is missing from the graph. remove it and try again.
# this is correct because if an introduction/fix commit is missing then
# so are its children, so we don't mislabel them.
details = e.details()
assert details is not None
unknown_swhid = details.removeprefix("Unknown SWHID: ")
unknown_swhids.add(unknown_swhid)
self.add_unknown_swhid(unknown_swhid)
else:
raise
else:
break
else:
self.total_skipped_due_to_unknown_commit += 1
return
event_types = set(commits_by_event_type)
if event_types == {"introduced", "fixed"} or event_types == {"introduced"}:
if "fixed" in commits_by_event_type:
self.total_unknown_commits += len(
commits_by_event_type["fixed"] & unknown_swhids
)
fix_commits = commits_by_event_type["fixed"] - unknown_swhids
else:
fix_commits = set()
assert "swh:1:rev:0" not in fix_commits
if fix_commits:
reachable_by_fix = {
rev.swhid.removeprefix("swh:1:rev:")
async for rev in self.stub.Traverse(
swhgraph.TraversalRequest(
src=fix_commits,
edges="rev:rev",
direction=swhgraph.GraphDirection.BACKWARD,
mask=FieldMask(paths=["swhid"]),
ignore_node=introduction_commits,
)
)
}
else:
reachable_by_fix = set()
vulnerable = reachable_by_introduction - reachable_by_fix
self.total_successes += 1
self.total_vulnerable += len(vulnerable)
# self.vulnerable_commits.update(vulnerable)
elif event_types == {"introduced", "last_affected"}:
self.total_unknown_commits += len(
commits_by_event_type["last_affected"] & unknown_swhids
)
last_affected_commits = (
commits_by_event_type["last_affected"] - unknown_swhids
)
assert "swh:1:rev:0" not in last_affected_commits
if last_affected_commits:
reachable_by_last_affected = {
rev.swhid.removeprefix("swh:1:rev:")
async for rev in self.stub.Traverse(
swhgraph.TraversalRequest(
src=last_affected_commits,
edges="rev:rev",
direction=swhgraph.GraphDirection.BACKWARD,
mask=FieldMask(paths=["swhid"]),
ignore_node=introduction_commits,
)
)
}
else:
reachable_by_last_affected = set()
vulnerable = (
reachable_by_introduction - reachable_by_last_affected
) | last_affected_commits
self.total_successes += 1
self.total_vulnerable += len(vulnerable)
# self.vulnerable_commits.update(vulnerable)
elif event_types == {"introduced", "limit"}:
self.total_unknown_commits += len(
commits_by_event_type["limit"] & unknown_swhids
)
limit_commits = commits_by_event_type["limit"] - unknown_swhids
assert "swh:1:rev:0" not in limit_commits
if limit_commits:
coreachable_by_limit = {
rev.swhid.removeprefix("swh:1:rev:")
async for rev in self.stub.Traverse(
swhgraph.TraversalRequest(
src=limit_commits,
edges="rev:rev",
direction=swhgraph.GraphDirection.FORWARD,
mask=FieldMask(paths=["swhid"]),
ignore_node=introduction_commits,
)
)
}
else:
reachable_by_last_affected = set()
vulnerable = reachable_by_introduction & coreachable_by_limit
self.total_successes += 1
self.total_vulnerable += len(vulnerable)
# self.vulnerable_commits.update(vulnerable)
else:
self.total_skipped_due_to_unimplemented_event_types += 1
raise NotImplementedError(f"Set of event types: {event_types}")
[docs]
async def main(conn: sqlite3.Connection) -> None:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM vulnerabilities")
((row_count,),) = cur
headers = {
"User-Agent": "osv/map_git.py +https://gitlab.softwareheritage.org/vlorentz/osv"
}
if "SWH_BEARER_TOKEN" in os.environ:
headers["Authorization"] = f"Bearer {os.environ['SWH_BEARER_TOKEN']}"
init_db(conn)
options = [
# makes the overall script over 5 times faster
(grpc.experimental.ChannelOptions.SingleThreadedUnaryStream, 1),
]
async with (
grpc.aio.insecure_channel(GRAPH_GRPC_URL, options=options) as channel,
aiohttp.ClientSession(headers=headers) as session,
):
stub = swhgraph_grpc.TraversalServiceStub(channel)
origin_denormalizer = OriginDenormalizer(stub=stub, session=session)
await origin_denormalizer.load()
root_revisions_cache = Cache(
Path("./root_revisions.json"),
dict[tuple[str, ...], list[str]](),
json.dumps,
json.loads,
)
await root_revisions_cache.load()
proc = DocProcessor(
origin_denormalizer=origin_denormalizer,
stub=stub,
conn=conn,
session=session,
root_revisions_cache=root_revisions_cache,
)
cur.execute(
"SELECT filename, doc FROM vulnerabilities WHERE random() % 100 == 0"
)
async for x in tqdm.asyncio.tqdm(
concurrent_async_map(
proc.process_docs, cur, max_concurrency=500, buffer=10000
),
total=row_count,
desc="Reading",
):
pass
cur.execute("SELECT COUNT(*) FROM unknown_swhids")
((unknown_swhids_count,),) = cur
print(f"Total unknown commits: {proc.total_unknown_commits}")
print(f"Unique unknown commits: {unknown_swhids_count}")
print(f"Total vulnerable commits×vulnerabilities: {proc.total_vulnerable}")
# print(f"Total vulnerable commits: {len(proc.vulnerable_commits)}")
print(f"Total successes: {proc.total_successes}")
print(
f"Total ranges skipped due to unimplemented event types: {proc.total_skipped_due_to_unimplemented_event_types}"
)
print(
f"Total ranges skipped due to unknown commits: {proc.total_skipped_due_to_unknown_commit}"
)
print(
f"Total ranges skipped due to invalid commits: {proc.total_skipped_due_to_invalid_commit}"
)
if __name__ == "__main__":
with sqlite3.connect("all.sqlite") as conn:
asyncio.run(main(conn))