Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ A fine-grained token can only see the private repositories of its one resource o

- For the `traffic_*` streams, [you will need write access to the repository](https://docs.github.com/en/rest/metrics/traffic?apiVersion=2022-11-28). You can enable extraction for these streams by [selecting them in the catalog](https://hub.meltano.com/singer/spec/#metadata).
- GitHub is [limiting the stargazer endpoints](https://docs.github.com/en/rest/activity/starring#list-stargazers) to repository admins and collaborators.
- The `dependabot_alerts` stream requires a token with read access to Dependabot alerts. For classic PATs, use the `security_events` scope (or `public_repo` when extracting only public repositories). For fine-grained PATs and GitHub Apps, grant the repository's **Dependabot alerts: Read** permission.

### Executing the Tap Directly

Expand Down
19 changes: 10 additions & 9 deletions tap_github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,12 +339,19 @@ def validate_response(self, response: requests.Response) -> None:
https://docs.python-requests.org/en/latest/api/#requests.Response
"""
full_path = urlparse(response.url).path
response_content = str(response.content).lower()
is_primary_rate_limit = (
response.status_code == 403 and "rate limit exceeded" in response_content
)
is_secondary_rate_limit = (
response.status_code == 403 and "secondary rate limit" in response_content
)
if response.status_code in (
[
*self.tolerated_http_errors,
EMPTY_REPO_ERROR_STATUS,
]
):
) and not (is_primary_rate_limit or is_secondary_rate_limit):
msg = (
f"{response.status_code} Tolerated Status Code "
f"(Reason: {response.reason}) for path: {full_path}"
Expand All @@ -362,20 +369,14 @@ def validate_response(self, response: requests.Response) -> None:
f"[GitHub-Request-Id: {github_request_id}]"
)
# Retry on rate limiting
if (
response.status_code == 403
and "rate limit exceeded" in str(response.content).lower()
):
if is_primary_rate_limit:
# Update token
self.authenticator.get_next_auth_token()
# Raise an error to force a retry with the new token.
raise RetriableAPIError(msg, response)

# Retry on secondary rate limit
if (
response.status_code == 403
and "secondary rate limit" in str(response.content).lower()
):
if is_secondary_rate_limit:
# Wait about a minute and retry
time.sleep(60 + 30 * random.random())
raise RetriableAPIError(msg, response)
Expand Down
170 changes: 170 additions & 0 deletions tap_github/repository_streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,176 @@ class CommunityProfileStream(GitHubRestStream):
).to_dict()


class DependabotAlertsStream(GitHubRestStream):
"""Extract Dependabot vulnerability alerts for a repository.

https://docs.github.com/en/rest/dependabot/alerts#list-dependabot-alerts-for-a-repository
"""

name = "dependabot_alerts"
path = "/repos/{org}/{repo}/dependabot/alerts"
primary_keys: Sequence[str] = ["number", "repo", "org"]
replication_key = "updated_at"
parent_stream_type = RepositoryStream
ignore_parent_replication_key = True
state_partitioning_keys: Sequence[str] | None = ["repo", "org"]
# The endpoint supports sorting by `updated`, but no `since` parameter.
# Fetch newest alerts first so the paginator can stop at the bookmark.
use_fake_since_parameter = True
# Dependabot alerts use Link-header cursors instead of page numbers.
use_cursor_pagination = True
tolerated_http_errors: ClassVar[list[int]] = [404, 403]
# Dependabot alerts require an explicit repository permission, so users

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Dependabot alerts require an explicit repository permission, so users
tolerated_http_errors: ClassVar[list[int]] = [404, 403]
# Dependabot alerts require an explicit repository permission, so users

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 343a0f7. The stream now tolerates 404/403 responses and the regression test verifies that a missing Dependabot-alerts permission completes without records.\n\nValidated with uv run pytest tests/test_dependabot_alerts.py, Ruff check/format, mypy, and ty.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Actually... could this swallow a 403 that is in fact a rate-limit, which should be retried?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 34d74c7. The REST client now lets primary and secondary rate-limit 403s reach the existing retry logic before considering a status tolerated; non-rate-limit 403s and 404s remain tolerated for this stream.\n\nAdded regression coverage for both rate-limit responses plus the missing-permission case. Validated with uv run pytest tests/test_dependabot_alerts.py, Ruff check/format, mypy, and ty.

# must select this stream after supplying a suitably scoped token.
selected_by_default = False

schema = th.PropertiesList(
# Parent keys
th.Property("repo", th.StringType),
th.Property("org", th.StringType),
th.Property("repo_id", th.IntegerType),
# Alert keys
th.Property("number", th.IntegerType),
th.Property("state", th.StringType),
th.Property("url", th.StringType),
th.Property("html_url", th.StringType),
th.Property("created_at", th.DateTimeType),
th.Property("updated_at", th.DateTimeType),
th.Property("dismissed_at", th.DateTimeType),
th.Property("dismissed_by", user_object),
th.Property("dismissed_reason", th.StringType),
th.Property("dismissed_comment", th.StringType),
th.Property("fixed_at", th.DateTimeType),
th.Property("auto_dismissed_at", th.DateTimeType),
th.Property("assignees", th.ArrayType(user_object)),
th.Property(
"dependency",
th.ObjectType(
th.Property(
"package",
th.ObjectType(
th.Property("ecosystem", th.StringType),
th.Property("name", th.StringType),
),
),
th.Property("manifest_path", th.StringType),
th.Property("scope", th.StringType),
th.Property("relationship", th.StringType),
),
),
th.Property(
"security_vulnerability",
th.ObjectType(
th.Property(
"package",
th.ObjectType(
th.Property("ecosystem", th.StringType),
th.Property("name", th.StringType),
),
),
th.Property("severity", th.StringType),
th.Property("vulnerable_version_range", th.StringType),
th.Property(
"first_patched_version",
th.ObjectType(th.Property("identifier", th.StringType)),
),
),
),
th.Property(
"security_advisory",
th.ObjectType(
th.Property("ghsa_id", th.StringType),
th.Property("cve_id", th.StringType),
th.Property("url", th.StringType),
th.Property("html_url", th.StringType),
th.Property("summary", th.StringType),
th.Property("description", th.StringType),
th.Property("severity", th.StringType),
th.Property("published_at", th.DateTimeType),
th.Property("updated_at", th.DateTimeType),
th.Property("withdrawn_at", th.DateTimeType),
th.Property(
"cvss",
th.ObjectType(
th.Property("vector_string", th.StringType),
th.Property("score", th.NumberType),
),
),
th.Property(
"cvss_severities",
th.ObjectType(
th.Property(
"cvss_v3",
th.ObjectType(
th.Property("vector_string", th.StringType),
th.Property("score", th.NumberType),
),
),
th.Property(
"cvss_v4",
th.ObjectType(
th.Property("vector_string", th.StringType),
th.Property("score", th.NumberType),
),
),
),
),
th.Property(
"epss",
th.ObjectType(
th.Property("percentage", th.NumberType),
th.Property("percentile", th.StringType),
),
),
th.Property(
"identifiers",
th.ArrayType(
th.ObjectType(
th.Property("type", th.StringType),
th.Property("value", th.StringType),
),
),
),
th.Property(
"references",
th.ArrayType(th.ObjectType(th.Property("url", th.StringType))),
),
th.Property(
"cwes",
th.ArrayType(
th.ObjectType(
th.Property("cwe_id", th.StringType),
th.Property("name", th.StringType),
),
),
),
th.Property(
"vulnerabilities",
th.ArrayType(
th.ObjectType(
th.Property(
"package",
th.ObjectType(
th.Property("ecosystem", th.StringType),
th.Property("name", th.StringType),
),
),
th.Property("severity", th.StringType),
th.Property("vulnerable_version_range", th.StringType),
th.Property(
"first_patched_version",
th.ObjectType(
th.Property("identifier", th.StringType),
),
),
),
),
),
),
),
).to_dict()


class EventsStream(GitHubRestStream):
"""
Defines 'Events' stream.
Expand Down
2 changes: 2 additions & 0 deletions tap_github/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
CommunityProfileStream,
ContributorsStream,
CustomPropertiesStream,
DependabotAlertsStream,
DependenciesStream,
DependentsStream,
DeploymentsStream,
Expand Down Expand Up @@ -93,6 +94,7 @@ def __init__(self, valid_queries: set[str], streams: list[type[Stream]]) -> None
CommitDiffsStream,
CommunityProfileStream,
ContributorsStream,
DependabotAlertsStream,
DependenciesStream,
DependentsStream,
DeploymentsStream,
Expand Down
98 changes: 98 additions & 0 deletions tests/test_dependabot_alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Tests for the Dependabot alerts stream."""

from unittest.mock import patch

import pytest
from requests import Response
from singer_sdk.exceptions import RetriableAPIError

from tap_github.repository_streams import DependabotAlertsStream
from tap_github.tap import TapGitHub

from .fixtures import repo_list_config # noqa: F401


def test_dependabot_alerts_stream_is_incremental_and_cursor_paginated(repo_list_config): # noqa: F811
"""Dependabot alerts use the repository endpoint and updated-time state."""
stream = DependabotAlertsStream(TapGitHub(config=repo_list_config))

assert stream.path == "/repos/{org}/{repo}/dependabot/alerts"
assert stream.replication_key == "updated_at"
assert stream.use_fake_since_parameter is True
assert stream.use_cursor_pagination is True
assert stream.selected_by_default is False
assert stream.parent_stream_type is not None
assert stream.parent_stream_type.__name__ == "RepositoryStream"
assert "dependency" in stream.schema["properties"]
assert "security_advisory" in stream.schema["properties"]
assert "security_vulnerability" in stream.schema["properties"]
assert "dependabot_alerts" in TapGitHub(config=repo_list_config).streams


def test_dependabot_alerts_stream_tolerates_unavailable_alerts_permission(
repo_list_config, # noqa: F811
):
"""Do not fail a sync when the token cannot access Dependabot alerts."""
stream = DependabotAlertsStream(TapGitHub(config=repo_list_config))
response = Response()
response.status_code = 403
response.url = (
"https://api.github.com/repos/MeltanoLabs/tap-github/dependabot/alerts"
)
response.reason = "Forbidden"
response._content = b'{"message": "Resource not accessible"}'

stream.validate_response(response)

assert stream.tolerated_http_errors == [404, 403]
assert list(stream.parse_response(response)) == []


@pytest.mark.parametrize(
("message", "is_primary_rate_limit"),
[
("API rate limit exceeded", True),
("You have exceeded a secondary rate limit", False),
],
)
def test_dependabot_alerts_stream_retries_rate_limits(
repo_list_config, # noqa: F811
message,
is_primary_rate_limit,
):
"""Retry rate-limit 403s even though permission-denied 403s are tolerated."""
stream = DependabotAlertsStream(TapGitHub(config=repo_list_config))
response = Response()
response.status_code = 403
response.url = (
"https://api.github.com/repos/MeltanoLabs/tap-github/dependabot/alerts"
)
response.reason = "Forbidden"
response._content = f'{{"message": "{message}"}}'.encode()

with (
patch.object(stream.authenticator, "get_next_auth_token") as rotate_token,
patch("tap_github.client.time.sleep") as sleep,
pytest.raises(RetriableAPIError, match="403 Client Error"),
):
stream.validate_response(response)

if is_primary_rate_limit:
rotate_token.assert_called_once_with()
sleep.assert_not_called()
else:
rotate_token.assert_not_called()
sleep.assert_called_once()


def test_dependabot_alerts_stream_requests_updated_alerts_descending(repo_list_config): # noqa: F811
"""Ensure pagination can stop after the saved incremental bookmark."""
stream = DependabotAlertsStream(TapGitHub(config=repo_list_config))

params = stream.get_url_params(
{"org": "MeltanoLabs", "repo": "tap-github", "repo_id": 365087920},
None,
)

assert params["sort"] == "updated"
assert params["direction"] == "desc"