Skip to content

Commit 6f38184

Browse files
michaelkedartymzd
authored andcommitted
feat: make the workers use the gitter for enumeration (google#5109)
yay the worker should be so much faster. The tests had to be updated because the gitter is more thorough than the legacy stuff (also, the gitter wasn't being set up correctly in the tests anyway - fixed) With enough arm-twisting, in a follow-up PR I can mock the gitter for these tests, and add the test cases into the gitter tests directly.
1 parent 667b095 commit 6f38184

7 files changed

Lines changed: 433 additions & 13 deletions

File tree

gcp/workers/worker/testdata/UpdateTest_last_affected_git.txt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
{ 'affected': [ { 'ranges': [ { 'events': [ { 'introduced': 'eefe8ec3f1f90d0e684890e810f3f21e8500a4cd'},
2-
{ 'last_affected': '8d8242f545e9cec3e6d0d2e3f5bde8be1c659735'}],
2+
{ 'last_affected': '8d8242f545e9cec3e6d0d2e3f5bde8be1c659735'},
3+
{ 'introduced': 'febfac1940086bc1f6d3dc33fda0a1d1ba336209'}],
34
'repo': 'https://osv-test/repo/url',
45
'type': 'GIT'}],
5-
'versions': ['v0.1.1', 'v0.2']}],
6+
'versions': ['branch-v0.1.1',
7+
'branch-v0.1.1-with-fix',
8+
'branch_1_cherrypick_regress',
9+
'v0.1.1',
10+
'v0.2']}],
611
'details': 'Blah blah blah\nBlah\n',
712
'id': 'OSV-TEST-last-affected-01',
813
'modified': '3000-01-01T00:00:00Z',

gcp/workers/worker/worker_test.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ def setUp(self):
185185
# Initialise fake source_repo.
186186
self.tmp_dir = tempfile.TemporaryDirectory()
187187
self.addCleanup(self.tmp_dir.cleanup)
188-
188+
osv.repos._GIT_MIRRORS[
189+
'https://osv-test/repo/url'] = 'https://github.com/oliverchang/osv-test.git'
189190
self.mock_repo = tests.mock_repository(self)
190191
self.remote_source_repo_path = self.mock_repo.path
191192
self.mock_repo.add_file(
@@ -320,6 +321,7 @@ def test_update_limit(self):
320321

321322
self.assertCountEqual(
322323
[
324+
b'4c155795426727ea05575bd5904321def23c03f4', # from cherrypicked limit event
323325
b'a2ba949290915d445d34d0e8e9de2e7ce38198fc',
324326
b'b1c95a196f22d06fcf80df8c6691cd113d8fefff',
325327
b'e1b045257bc5ca2a11d0476474f45ef77a0366c7',
@@ -329,10 +331,10 @@ def test_update_limit(self):
329331
)
330332

331333
def test_update_no_introduced(self):
332-
"""Test update vulnerability with no introduced commit."""
334+
"""Test update vulnerability with introduced '0' commit."""
333335
task_runner = worker.TaskRunner(ndb_client, None, self.tmp_dir.name, None,
334336
None)
335-
337+
self.source_repo.detect_cherrypicks = True
336338
message = mock.Mock()
337339
message.attributes = {
338340
'source': 'source',
@@ -349,7 +351,6 @@ def test_update_no_introduced(self):
349351
affected_commits = list(osv.AffectedCommits.query())
350352
self.assertEqual(1, len(affected_commits))
351353
affected_commits = affected_commits[0]
352-
353354
self.assertCountEqual(
354355
[
355356
b'00514d6f244f696e750a37083163992c6a50cfd3',
@@ -477,7 +478,9 @@ def test_update_pypi(self):
477478

478479
self.assertCountEqual(
479480
[
481+
b'4c155795426727ea05575bd5904321def23c03f4',
480482
b'b1c95a196f22d06fcf80df8c6691cd113d8fefff',
483+
b'b9b3fd4732695b83c3068b7b6a14bb372ec31f98', # cherrypicked fix commit
481484
b'eefe8ec3f1f90d0e684890e810f3f21e8500a4cd',
482485
],
483486
[codecs.encode(commit, 'hex') for commit in affected_commits.commits],
@@ -528,7 +531,9 @@ def test_normalize_pypi(self):
528531

529532
self.assertCountEqual(
530533
[
534+
b'4c155795426727ea05575bd5904321def23c03f4',
531535
b'b1c95a196f22d06fcf80df8c6691cd113d8fefff',
536+
b'b9b3fd4732695b83c3068b7b6a14bb372ec31f98',
532537
b'eefe8ec3f1f90d0e684890e810f3f21e8500a4cd',
533538
],
534539
[codecs.encode(commit, 'hex') for commit in affected_commits.commits],
@@ -890,9 +895,13 @@ def test_last_affected_git(self):
890895

891896
self.assertCountEqual(
892897
[
898+
b'4c155795426727ea05575bd5904321def23c03f4',
893899
b'b1c95a196f22d06fcf80df8c6691cd113d8fefff',
900+
b'b9b3fd4732695b83c3068b7b6a14bb372ec31f98',
894901
b'eefe8ec3f1f90d0e684890e810f3f21e8500a4cd',
895902
b'8d8242f545e9cec3e6d0d2e3f5bde8be1c659735',
903+
b'febfac1940086bc1f6d3dc33fda0a1d1ba336209',
904+
b'ff8cc32ba60ad9cbb3b23f0a82aad96ebe9ff76b',
896905
],
897906
[codecs.encode(commit, 'hex') for commit in affected_commits.commits],
898907
)

osv/gitter_client.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Functions for interacting with Gitter."""
15+
16+
import requests
17+
18+
from . import repository_pb2
19+
from .vulnerability_pb2 import Range
20+
from .repos import gitter_host, RepoInaccessibleError, _git_mirror
21+
22+
# To regenerate the gitter repository python bindings:
23+
# pylint: disable=line-too-long
24+
# poetry run python3 -m grpc_tools.protoc --proto_path=../go/cmd/gitter/pb/repository/ --python_out=. --mypy_out=. ../go/cmd/gitter/pb/repository/repository.proto
25+
26+
27+
def get_affected_commits(
28+
affected_range: Range,
29+
detect_cherrypicks=True
30+
) -> tuple[list[str], list[bytes], list[repository_pb2.Event]]:
31+
"""
32+
Get affected commits and tags from Gitter.
33+
34+
Args:
35+
affected_range: The range to get affected commits for.
36+
detect_cherrypicks: Whether to detect cherrypicks.
37+
38+
Returns:
39+
A tuple of (versions, cherry_picked_events).
40+
Raises:
41+
RuntimeError: If GITTER_HOST is not set.
42+
ValueError: If affected_range.type is not GIT or repo is not set.
43+
RepoInaccessibleError: If Gitter reports that the repo is inaccessible.
44+
"""
45+
if not gitter_host():
46+
raise RuntimeError('GITTER_HOST not set')
47+
if affected_range.type != Range.GIT:
48+
raise ValueError('Range type must be GIT')
49+
if not affected_range.repo:
50+
raise ValueError('No repo in range')
51+
52+
gitter_request = repository_pb2.AffectedCommitsRequest(
53+
url=_git_mirror(affected_range.repo),
54+
detect_cherrypicks_fixed=detect_cherrypicks,
55+
detect_cherrypicks_introduced=detect_cherrypicks,
56+
detect_cherrypicks_limit=detect_cherrypicks,
57+
)
58+
for event in affected_range.events:
59+
if event.introduced:
60+
gitter_request.events.append(
61+
repository_pb2.Event(
62+
event_type=repository_pb2.EventType.INTRODUCED,
63+
hash=event.introduced,
64+
))
65+
if event.fixed:
66+
gitter_request.events.append(
67+
repository_pb2.Event(
68+
event_type=repository_pb2.EventType.FIXED,
69+
hash=event.fixed,
70+
))
71+
if event.limit:
72+
gitter_request.events.append(
73+
repository_pb2.Event(
74+
event_type=repository_pb2.EventType.LIMIT,
75+
hash=event.limit,
76+
))
77+
if event.last_affected:
78+
gitter_request.events.append(
79+
repository_pb2.Event(
80+
event_type=repository_pb2.EventType.LAST_AFFECTED,
81+
hash=event.last_affected,
82+
))
83+
84+
response = requests.post(
85+
f'{gitter_host()}/affected-commits',
86+
data=gitter_request.SerializeToString(),
87+
headers={'Content-Type': 'application/x-protobuf'},
88+
timeout=3600,
89+
)
90+
if response.status_code == 403:
91+
raise RepoInaccessibleError(f'Gitter returned error: {response.text}')
92+
if response.status_code != 200:
93+
raise RuntimeError(f'Gitter returned error: {response.text}')
94+
95+
gitter_response = repository_pb2.AffectedCommitsResponse()
96+
gitter_response.ParseFromString(response.content)
97+
versions: list[str] = []
98+
for tag in gitter_response.tags:
99+
versions.append(tag.label)
100+
commits: list[bytes] = []
101+
for commit in gitter_response.commits:
102+
commits.append(commit.hash)
103+
104+
return versions, commits, gitter_response.cherry_picked_events

osv/impact.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@
2929
import pygit2.enums
3030

3131
from . import ecosystems
32+
from . import gitter_client
3233
from . import repos
3334
from . import models
3435
from . import vulnerability_pb2
36+
from . import repository_pb2
3537

3638
TAG_PREFIX = 'refs/tags/'
3739
BRANCH_PREFIX = 'refs/remotes/'
@@ -539,7 +541,8 @@ def update_affected_commits(bug_id, commits, public):
539541
public=public,
540542
page=num_pages)
541543
affected_commits.commits = [
542-
codecs.decode(commit, 'hex') for commit in batch
544+
commit if isinstance(commit, bytes) else codecs.decode(commit, 'hex')
545+
for commit in batch
543546
]
544547
to_put.append(affected_commits)
545548
num_pages += 1
@@ -651,13 +654,39 @@ def _analyze_git_ranges(repo_analyzer: RepoAnalyzer, checkout_path: str,
651654
Returns:
652655
A tuple of the set of new_versions and commits
653656
"""
654-
package_repo = None
655-
657+
if repos.gitter_host():
658+
try:
659+
versions, new_commits, cherries = gitter_client.get_affected_commits(
660+
affected_range, detect_cherrypicks=repo_analyzer.detect_cherrypicks)
661+
new_versions.update(versions)
662+
commits.update(new_commits)
663+
for cherry in cherries:
664+
if cherry.event_type == repository_pb2.EventType.INTRODUCED:
665+
new_introduced.add(cherry.hash)
666+
elif cherry.event_type == repository_pb2.EventType.FIXED:
667+
new_fixed.add(cherry.hash)
668+
return new_versions, commits
669+
except repos.RepoInaccessibleError:
670+
# expected error, handled by the caller
671+
raise
672+
except Exception as e:
673+
# logging an error and raising may be noisy.
674+
logging.error(
675+
'Failed to get affected commits from gitter: %s',
676+
e,
677+
extra={'json_fields': {
678+
'repo_url': affected_range.repo
679+
}},
680+
)
681+
raise
682+
683+
logging.warning('GITTER_HOST not set, falling back to legacy enumeration.')
656684
with tempfile.TemporaryDirectory() as package_repo_dir:
657685
# We'd prefer to only download the commit history, but detect_cherrypicks
658686
# requires having the blobs available to function correctly.
659687
blobless = not repo_analyzer.detect_cherrypicks
660688
repo_name = os.path.basename(affected_range.repo.rstrip('/')).rstrip('.git')
689+
package_repo = None
661690
if checkout_path:
662691
# Repo name is <base-name>-<sha256(affected_range.repo)>.
663692
repo_hash = hashlib.sha256(affected_range.repo.encode()).hexdigest()

osv/repos.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@
2323
import pygit2
2424
import pygit2.enums
2525

26+
# We need to get GITTER_HOST from the environment, but we can't do it in the
27+
# global scope because it's not set in the test environment until after the
28+
# module is imported.
29+
_gitter_host = None
30+
31+
32+
def gitter_host():
33+
global _gitter_host
34+
if _gitter_host is None:
35+
_gitter_host = os.getenv('GITTER_HOST', '')
36+
return _gitter_host
37+
38+
2639
CLONE_TRIES = int(os.getenv('CLONE_TRIES', '3'))
2740
RETRY_SLEEP_SECONDS = 5
2841

@@ -37,8 +50,6 @@
3750
FETCH_CACHE: dict[tuple, datetime.datetime] = {}
3851
FETCH_CACHE_SECONDS = 5 * 60 # 5 minutes
3952

40-
GITTER_HOST = os.getenv('GITTER_HOST', '')
41-
4253

4354
class GitRemoteCallback(pygit2.RemoteCallbacks):
4455
"""Authentication callbacks."""
@@ -131,7 +142,7 @@ def clone(git_url,
131142
"""Perform a clone."""
132143
# Don't user Gitter for oss-fuzz-vulns repo because it requires auth
133144
logging.info('Cloning %s to %s.', git_url, checkout_dir)
134-
if GITTER_HOST and git_url != 'ssh://github.com/google/oss-fuzz-vulns':
145+
if gitter_host() and git_url != 'ssh://github.com/google/oss-fuzz-vulns':
135146
try:
136147
os.makedirs(checkout_dir, exist_ok=True)
137148
params = {'url': _git_mirror(git_url)}
@@ -140,7 +151,7 @@ def clone(git_url,
140151

141152
# Long timeout duration (1hr) because it could be cloning a large repo
142153
resp = requests.get(
143-
f'{GITTER_HOST}/git',
154+
f'{gitter_host()}/git',
144155
params=params,
145156
stream=True,
146157
timeout=3600,

osv/repository_pb2.py

Lines changed: 53 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)