diff --git a/README.md b/README.md index 154a34f..b8c54d9 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ This script retrieves code scanning alerts from GitHub repositories, organizatio ```text usage: list_code_scanning_alerts.py [-h] [--scope {ent,org,repo}] [--state {open,resolved}] [--since SINCE] [--json] - [--raw] [--quote-all] [--hostname HOSTNAME] [--debug] + [--raw] [--quote-all] [--hostname HOSTNAME] [--ca-cert-bundle CA_CERT_BUNDLE] + [--no-verify-tls] [--debug] name List code scanning alerts for a GitHub repository, organization or Enterprise. @@ -97,6 +98,9 @@ options: --raw, -r Output raw JSON data from the API --quote-all, -q Quote all fields in CSV output --hostname HOSTNAME GitHub Enterprise hostname (defaults to github.com) + --ca-cert-bundle CA_CERT_BUNDLE, -C CA_CERT_BUNDLE + Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates) + --no-verify-tls Do not verify TLS connection certificates (warning: insecure) --debug, -d Enable debug logging ``` @@ -106,7 +110,8 @@ This script replays or restores the status of code scanning alerts based on a pr ```text usage: replay_code_scanning_alert_status.py [-h] [--scope {ent,org,repo}] [--state {open,resolved}] [--since SINCE] - [--json] [--quote-all] [--hostname HOSTNAME] [--debug] + [--json] [--quote-all] [--hostname HOSTNAME] + [--ca-cert-bundle CA_CERT_BUNDLE] [--no-verify-tls] [--debug] name Replay code scanning alert status for a GitHub repository, organization or Enterprise, based on a provide file of @@ -127,6 +132,9 @@ options: --json Output in JSON format (otherwise CSV) --quote-all, -q Quote all fields in CSV output --hostname HOSTNAME GitHub Enterprise hostname (defaults to github.com) + --ca-cert-bundle CA_CERT_BUNDLE, -C CA_CERT_BUNDLE + Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates) + --no-verify-tls Do not verify TLS connection certificates (warning: insecure) --debug, -d Enable debug logging ``` @@ -136,7 +144,8 @@ This script replays or restores the status of secret scanning alerts based on a ```text usage: replay_secret_scanning_result_status.py [-h] [--scope {ent,org,repo}] [--state {open,resolved}] [--since SINCE] - [--json] [--quote-all] [--hostname HOSTNAME] [--debug] + [--json] [--quote-all] [--hostname HOSTNAME] + [--ca-cert-bundle CA_CERT_BUNDLE] [--no-verify-tls] [--debug] name Replay secret scanning alert status for a GitHub repository, organization or Enterprise, based on a provided file of @@ -159,6 +168,9 @@ options: --json Output in JSON format (otherwise CSV) --quote-all, -q Quote all fields in CSV output --hostname HOSTNAME GitHub Enterprise hostname (defaults to github.com) + --ca-cert-bundle CA_CERT_BUNDLE, -C CA_CERT_BUNDLE + Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates) + --no-verify-tls Do not verify TLS connection certificates (warning: insecure) --debug, -d Enable debug logging ``` @@ -207,7 +219,8 @@ This script identifies and resolves duplicate secret scanning alerts that occur ```text usage: resolve_duplicate_secret_scanning_alerts.py [-h] [--scope {ent,org,repo}] [--state {open,resolved}] - [--since SINCE] [--hostname HOSTNAME] [--debug] + [--since SINCE] [--hostname HOSTNAME] + [--ca-cert-bundle CA_CERT_BUNDLE] [--no-verify-tls] [--debug] [--add-matching-secret OLD_TYPE NEW_TYPE] name @@ -226,6 +239,9 @@ options: Only show alerts created after this date/time - ISO 8601 format, e.g. 2024-10-08 or 2024-10-08T12:00; or Nd format, e.g. 7d for 7 days ago --hostname HOSTNAME GitHub Enterprise hostname (defaults to github.com) + --ca-cert-bundle CA_CERT_BUNDLE, -C CA_CERT_BUNDLE + Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates) + --no-verify-tls Do not verify TLS connection certificates (warning: insecure) --debug, -d Enable debug logging --add-matching-secret OLD_TYPE NEW_TYPE, -a OLD_TYPE NEW_TYPE Add a new pair of matched secret types @@ -236,7 +252,8 @@ options: This script bulk-closes all open code scanning alerts for a specified repository. It's useful for cleanup operations, such as dismissing false positives or marking alerts as "won't fix" across an entire repository. The script supports dry-run mode to preview changes before applying them. ```text -usage: close_code_scanning_alerts.py [-h] [--resolution {false positive,won't fix,used in tests}] [--dry-run] [-d] +usage: close_code_scanning_alerts.py [-h] [--resolution {false positive,won't fix,used in tests}] [--dry-run] + [--hostname HOSTNAME] [--ca-cert-bundle CA_CERT_BUNDLE] [--no-verify-tls] [-d] repo_name Close all open code scanning alerts for a repository. @@ -249,6 +266,10 @@ options: --resolution {false positive,won't fix,used in tests} The resolution of the alert. --dry-run Print the alerts that would be closed, but don't actually close them. + --hostname HOSTNAME GitHub Enterprise hostname (defaults to github.com) + --ca-cert-bundle CA_CERT_BUNDLE, -C CA_CERT_BUNDLE + Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates) + --no-verify-tls Do not verify TLS connection certificates (warning: insecure) -d, --debug Print debug messages to the console. ``` diff --git a/close_code_scanning_alerts.py b/close_code_scanning_alerts.py index aa843bc..1586445 100755 --- a/close_code_scanning_alerts.py +++ b/close_code_scanning_alerts.py @@ -91,6 +91,25 @@ def add_args(parser: argparse.ArgumentParser) -> None: action="store_true", help="Print the alerts that would be closed, but don't actually close them.", ) + parser.add_argument( + "--hostname", + type=str, + default="github.com", + required=False, + help="GitHub Enterprise hostname (defaults to github.com)", + ) + parser.add_argument( + "--ca-cert-bundle", + "-C", + type=str, + required=False, + help="Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates)" + ) + parser.add_argument( + "--no-verify-tls", + action="store_true", + help="Do not verify TLS connection certificates (warning: insecure)" + ) parser.add_argument( "-d", "--debug", @@ -108,7 +127,18 @@ def main() -> None: logging.basicConfig(level=logging.INFO if not args.debug else logging.DEBUG) - github = GitHub() + verify = True + if args.ca_cert_bundle: + verify = args.ca_cert_bundle + + if args.no_verify_tls: + verify = False + LOG = logging.getLogger(__name__) + LOG.warning("Disabling TLS verification. This is insecure and should not be used in production") + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + github = GitHub(hostname=args.hostname, verify=verify) try: owner, repo = args.repo_name.split("/") diff --git a/list_code_scanning_alerts.py b/list_code_scanning_alerts.py index 7fd3c4f..3a537b6 100755 --- a/list_code_scanning_alerts.py +++ b/list_code_scanning_alerts.py @@ -124,8 +124,8 @@ def output_csv(results: list[dict], quote_all: bool) -> None: writer.writerow(to_list(result)) -def list_code_scanning_alerts(name: str, scope: str, hostname: str, state: str|None=None, since: datetime.datetime|None=None, raw: bool=False) -> Generator[dict, None, None]: - g = GitHub(hostname=hostname) +def list_code_scanning_alerts(name: str, scope: str, hostname: str, state: str|None=None, since: datetime.datetime|None=None, raw: bool=False, verify: bool | str = True) -> Generator[dict, None, None]: + g = GitHub(hostname=hostname, verify=verify) alerts = g.list_code_scanning_alerts(name, state=state, since=since, scope=scope) if raw: return alerts @@ -178,6 +178,18 @@ def add_args(parser: argparse.ArgumentParser) -> None: required=False, help="GitHub Enterprise hostname (defaults to github.com)", ) + parser.add_argument( + "--ca-cert-bundle", + "-C", + type=str, + required=False, + help="Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates)" + ) + parser.add_argument( + "--no-verify-tls", + action="store_true", + help="Do not verify TLS connection certificates (warning: insecure)" + ) parser.add_argument( "--debug", "-d", action="store_true", help="Enable debug logging" ) @@ -202,11 +214,21 @@ def main() -> None: name = args.name state = args.state hostname = args.hostname + verify = True + + if args.ca_cert_bundle: + verify = args.ca_cert_bundle + + if args.no_verify_tls: + verify = False + LOG.warning("Disabling TLS verification. This is insecure and should not be used in production") + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) if not GitHub.check_name(name, scope): raise ValueError("Invalid name: %s for %s", name, scope) - results = list_code_scanning_alerts(name, scope, hostname, state=state, since=since, raw=args.raw) + results = list_code_scanning_alerts(name, scope, hostname, state=state, since=since, raw=args.raw, verify=verify) if args.json: print(json.dumps(list(results), indent=2)) diff --git a/list_secret_scanning_alerts.py b/list_secret_scanning_alerts.py index 2d27d12..d188e6d 100755 --- a/list_secret_scanning_alerts.py +++ b/list_secret_scanning_alerts.py @@ -375,7 +375,7 @@ def main() -> None: verify = True if args.ca_cert_bundle: - verify = ca_cert_bundle + verify = args.ca_cert_bundle if args.no_verify_tls: verify = False diff --git a/replay_code_scanning_alert_status.py b/replay_code_scanning_alert_status.py index 4f59bfd..cba7558 100755 --- a/replay_code_scanning_alert_status.py +++ b/replay_code_scanning_alert_status.py @@ -50,9 +50,9 @@ def existing_results_by_location(reader: csv.DictReader) -> dict: return existing_results -def change_state(hostname, result: dict, res: dict) -> None: +def change_state(hostname, result: dict, res: dict, verify: bool | str = True) -> None: """Change the state of the alert to match the existing result using the GitHub API to update the alert.""" - g = GitHub(hostname=hostname) + g = GitHub(hostname=hostname, verify=verify) repo_name = result["repo"] @@ -77,7 +77,7 @@ def change_state(hostname, result: dict, res: dict) -> None: return -def update_states(hostname: str, results: Iterable[dict], existing_results: dict) -> None: +def update_states(hostname: str, results: Iterable[dict], existing_results: dict, verify: bool | str = True) -> None: """Update the state of matching alerts to match the existing results.""" for result in results: repo = result["repo"] @@ -101,7 +101,7 @@ def update_states(hostname: str, results: Iterable[dict], existing_results: dict if res["state"] != result["state"]: LOG.warning(f"State mismatch: {res['state']} != {result['state']}") - change_state(hostname, result, res) + change_state(hostname, result, res, verify=verify) def add_args(parser: argparse.ArgumentParser) -> None: @@ -145,6 +145,18 @@ def add_args(parser: argparse.ArgumentParser) -> None: required=False, help="GitHub Enterprise hostname (defaults to github.com)", ) + parser.add_argument( + "--ca-cert-bundle", + "-C", + type=str, + required=False, + help="Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates)" + ) + parser.add_argument( + "--no-verify-tls", + action="store_true", + help="Do not verify TLS connection certificates (warning: insecure)" + ) parser.add_argument( "--debug", "-d", action="store_true", help="Enable debug logging" ) @@ -166,6 +178,16 @@ def main() -> None: name = args.name state = args.state hostname = args.hostname + verify = True + + if args.ca_cert_bundle: + verify = args.ca_cert_bundle + + if args.no_verify_tls: + verify = False + LOG.warning("Disabling TLS verification. This is insecure and should not be used in production") + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) if not GitHub.check_name(args.name, scope): raise ValueError("Invalid name: %s for %s", args.name, scope) @@ -179,9 +201,9 @@ def main() -> None: LOG.debug(existing_results) - results = list_code_scanning_alerts(name, scope, hostname, state=state, since=since) + results = list_code_scanning_alerts(name, scope, hostname, state=state, since=since, verify=verify) - update_states(hostname, results, existing_results) + update_states(hostname, results, existing_results, verify=verify) if __name__ == "__main__": diff --git a/replay_secret_scanning_result_status.py b/replay_secret_scanning_result_status.py index 204a9da..a055dc4 100755 --- a/replay_secret_scanning_result_status.py +++ b/replay_secret_scanning_result_status.py @@ -44,9 +44,9 @@ def existing_results_by_secret(reader: csv.DictReader) -> dict: return existing_results -def change_state(hostname, result: dict, res: dict) -> None: +def change_state(hostname, result: dict, res: dict, verify: bool | str = True) -> None: """Change the state of the alert to match the existing result using the GitHub API to update the alert.""" - g = GitHub(hostname=hostname) + g = GitHub(hostname=hostname, verify=verify) repo_name = result["repo"] @@ -112,6 +112,18 @@ def add_args(parser: argparse.ArgumentParser) -> None: required=False, help="GitHub Enterprise hostname (defaults to github.com)", ) + parser.add_argument( + "--ca-cert-bundle", + "-C", + type=str, + required=False, + help="Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates)" + ) + parser.add_argument( + "--no-verify-tls", + action="store_true", + help="Do not verify TLS connection certificates (warning: insecure)" + ) parser.add_argument( "--debug", "-d", action="store_true", help="Enable debug logging" ) @@ -133,6 +145,16 @@ def main() -> None: name = args.name state = args.state hostname = args.hostname + verify = True + + if args.ca_cert_bundle: + verify = args.ca_cert_bundle + + if args.no_verify_tls: + verify = False + LOG.warning("Disabling TLS verification. This is insecure and should not be used in production") + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) if not GitHub.check_name(args.name, scope): raise ValueError("Invalid name: %s for %s", args.name, scope) @@ -146,7 +168,7 @@ def main() -> None: LOG.debug(existing_results) - results = list_secret_scanning_alerts(name, scope, hostname, state=state, since=since) + results = list_secret_scanning_alerts(name, scope, hostname, state=state, since=since, verify=verify) for result in results: repo = result["repo"] @@ -165,7 +187,7 @@ def main() -> None: LOG.warning(f"State mismatch: {res['state']} != {result['state']}") if result["state"] != "pattern_edited": - change_state(hostname, result, res) + change_state(hostname, result, res, verify=verify) if __name__ == "__main__": diff --git a/resolve_duplicate_secret_scanning_alerts.py b/resolve_duplicate_secret_scanning_alerts.py index c5e4793..4fb2057 100755 --- a/resolve_duplicate_secret_scanning_alerts.py +++ b/resolve_duplicate_secret_scanning_alerts.py @@ -51,9 +51,9 @@ def index_results_by_secret(results: Iterable[dict]) -> dict: return indexed_results -def change_state(hostname, old_result: dict, new_result: dict) -> None: +def change_state(hostname, old_result: dict, new_result: dict, verify: bool | str = True) -> None: """Change the state of the alert to match the existing result using the GitHub API to update the alert.""" - g = GitHub(hostname=hostname) + g = GitHub(hostname=hostname, verify=verify) repo_name = new_result["repo"] @@ -83,7 +83,7 @@ def change_state(hostname, old_result: dict, new_result: dict) -> None: def resolve_duplicates( - indexed_results: dict, matching_secrets_lookup: dict, hostname: str + indexed_results: dict, matching_secrets_lookup: dict, hostname: str, verify: bool | str = True ) -> None: """Resolve duplicates by matching on a new secret type and updating the state of the alert to match the existing result.""" for repo, repo_results in indexed_results.items(): @@ -107,7 +107,7 @@ def resolve_duplicates( LOG.info(f"State mismatch, updating state: {new_result['state']} != {old_result['state']}") if old_result["state"] != "pattern_edited": - change_state(hostname, old_result, new_result) + change_state(hostname, old_result, new_result, verify=verify) def add_args(parser: argparse.ArgumentParser) -> None: @@ -145,6 +145,18 @@ def add_args(parser: argparse.ArgumentParser) -> None: required=False, help="GitHub Enterprise hostname (defaults to github.com)", ) + parser.add_argument( + "--ca-cert-bundle", + "-C", + type=str, + required=False, + help="Path to CA certificate bundle in PEM format (e.g. for self-signed server certificates)" + ) + parser.add_argument( + "--no-verify-tls", + action="store_true", + help="Do not verify TLS connection certificates (warning: insecure)" + ) parser.add_argument( "--debug", "-d", action="store_true", help="Enable debug logging" ) @@ -174,6 +186,16 @@ def main() -> None: name = args.name state = args.state hostname = args.hostname + verify = True + + if args.ca_cert_bundle: + verify = args.ca_cert_bundle + + if args.no_verify_tls: + verify = False + LOG.warning("Disabling TLS verification. This is insecure and should not be used in production") + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) if not GitHub.check_name(args.name, scope): raise ValueError("Invalid name: %s for %s", args.name, scope) @@ -187,7 +209,7 @@ def main() -> None: matching_secrets_lookup = {k: v for k, v in matching_secrets} # find secret scanning alerts - results = list_secret_scanning_alerts(name, scope, hostname, state=state, since=since, include_secret=True) + results = list_secret_scanning_alerts(name, scope, hostname, state=state, since=since, include_secret=True, verify=verify) if not results: LOG.info("No secret scanning alerts found") return @@ -195,7 +217,7 @@ def main() -> None: # index results by secret and type for easy lookup indexed_results = index_results_by_secret(results) - resolve_duplicates(indexed_results, matching_secrets_lookup, hostname) + resolve_duplicates(indexed_results, matching_secrets_lookup, hostname, verify=verify) if __name__ == "__main__": diff --git a/test_resolve_duplicate_secret_scanning_alerts.py b/test_resolve_duplicate_secret_scanning_alerts.py index 61dc303..2d0669e 100644 --- a/test_resolve_duplicate_secret_scanning_alerts.py +++ b/test_resolve_duplicate_secret_scanning_alerts.py @@ -19,7 +19,9 @@ def mock_args(): since="2024-10-08", hostname="github.com", debug=True, - add_matching_secret=[("old_type", "new_type")] + add_matching_secret=[("old_type", "new_type")], + ca_cert_bundle=None, + no_verify_tls=False ) yield mock_parse_args diff --git a/test_tls_cert_support.py b/test_tls_cert_support.py new file mode 100644 index 0000000..835c589 --- /dev/null +++ b/test_tls_cert_support.py @@ -0,0 +1,71 @@ +"""Test TLS certificate bundle support in githubapi module.""" + +import pytest +from unittest.mock import patch, MagicMock +import os + + +def test_github_verify_default(): + """Test that GitHub class defaults to verify=True.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "test_token"}): + with patch("githubapi.requests.Session") as mock_session_class: + mock_session = MagicMock() + mock_session_class.return_value = mock_session + + from githubapi import GitHub + + gh = GitHub() + + # Verify that session.verify is set to True by default + assert mock_session.verify == True + + +def test_github_verify_false(): + """Test that GitHub class accepts verify=False.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "test_token"}): + with patch("githubapi.requests.Session") as mock_session_class: + mock_session = MagicMock() + mock_session_class.return_value = mock_session + + from githubapi import GitHub + + gh = GitHub(verify=False) + + # Verify that session.verify is set to False + assert mock_session.verify == False + + +def test_github_verify_cert_bundle(): + """Test that GitHub class accepts verify with certificate bundle path.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "test_token"}): + with patch("githubapi.requests.Session") as mock_session_class: + mock_session = MagicMock() + mock_session_class.return_value = mock_session + + from githubapi import GitHub + + cert_path = "/path/to/cert.pem" + gh = GitHub(verify=cert_path) + + # Verify that session.verify is set to the certificate path + assert mock_session.verify == cert_path + + +def test_github_token_required(): + """Test that GitHub class requires a token.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="GITHUB_TOKEN environment variable must be set"): + from githubapi import GitHub + gh = GitHub() + + +def test_github_hostname_validation(): + """Test that GitHub class validates hostname.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "test_token"}): + with pytest.raises(ValueError, match="Invalid server hostname"): + from githubapi import GitHub + gh = GitHub(hostname="invalid hostname with spaces") + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])