diff --git a/.gitignore b/.gitignore index b6e4761..c53ee44 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + +ghas-cli-complete.sh +checksums.sha512 +checksums.sha512.asc diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f1d589d --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.DEFAULT_GOAL=help +.PHONY=help + +ZIP = zip +PIP3 = python3 -m pip +PYTHON3 = python3 +POETRY = poetry + + +clean: ## clean existing builds + rm -rf ./dist || true + rm -rf ./src/ghas-cli/ghas-cli.egg-info || true + rm checksums.sha512 || true + rm checksums.sha512.asc || true + +release: ## Build a wheel + $(POETRY) build + cd dist && sha512sum * > ../checksums.sha512 + gpg --detach-sign --armor checksums.sha512 + +shell: ## Generate the shell autocompletion + _GHAS_CLI_COMPLETE=source_bash ghas-cli > ghas-cli-complete.sh || true + +deps: ## Fetch or update dependencies + $(POETRY) update --no-dev + +help: + @awk -F ':|##' '/^[^\t].+?:.*?##/ { printf "\033[36m%-30s\033[0m %s\n", $$1, $$NF }' $(MAKEFILE_LIST) | sort + + +.PHONY: help diff --git a/README.md b/README.md index 21d38e9..bc2178c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,44 @@ # Security-ghas-cli -CLI utility to interact with GHAS + +CLI utility to interact with GHAS. + + +## Installation + +Builds are available in the [`Releases`](https://github.com/Malwarebytes/Security-ghas-cli/releases) tab. + +```bash +python -m pip install /full/path/to/ghas-cli-xxx.whl + +# e.g: python3 -m pip install Downloads/ghas-cli-0.5.0-none-any.whl +``` + +## Development + +### Build + +[Install Poetry](https://python-poetry.org/docs/#installation) first, then: + +```bash +make release +``` + +### Bump the version number + +* Update the `version` field in `pyproject.toml`. +* Update the `__version__` field in `src/cli.py`. + +### Publish a new version + +1. Bump the version number as described above +2. `make deps` to update the dependencies +3. `make release` to build the packages +4. `git commit -a -S Bump to version 1.1.2` and `git tag -s v1.1.2 -m "1.1.2"` +5. Upload `dist/*`, `checksums.sha512` and `checksums.sha512.asc` to a new release in Github. + +# Resources + +Please reach Jérôme Boursier for any issues or question: + +* jboursier@malwarebytes.com +* `jboursier` on Slack diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ff19022 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[tool.poetry] +name = "ghas-cli" +version = "0.0.1" +description = "Python3 command line interface to interact with Github Advanced Security." +authors = ["jboursier "] +license = "MIT" +readme = "README.md" +homepage = "https://malwarebytes.com" +repository = "https://github.com/Malwarebytes/Security-ghash-cli" +keywords = ["security", "cli", "github", "utility"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Topic :: Utilities" +] +include = ["src/cli.py"] + +[tool.poetry.dependencies] +python = ">=3.7" +click = ">=8" +requests = "*" +colorama = "*" +configparser = "*" +python-magic = "*" + +[tool.poetry.dev-dependencies] + +[tool.poetry.scripts] +ghas-cli = 'src.cli:main' + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..317becf --- /dev/null +++ b/src/cli.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +#!/usr/bin/env python3 + +__author__ = "jboursier" +__copyright__ = "Copyright 2022, Malwarebytes" +__version__ = "0.0.1" +__maintainer__ = "jboursier" +__email__ = "jboursier@malwarebytes.com" +__status__ = "Development" + +try: + import click + import requests + import json + from typing import List, Dict, Any + from datetime import datetime +except ImportError: + import sys + + print("Missing dependencies. Please reach @jboursier if needed.") + sys.exit(255) + +from click.exceptions import ClickException +from requests.exceptions import Timeout + +from ghas_cli.utils import repositories, network, vulns + + +def main() -> None: + try: + cli() + except Exception as e: + click.echo(e) + + +@click.group() +def cli() -> None: + """ghas-cli is a Python3 utility to interact with Github Advanced Security. + + Get help: `@jboursier` on Slack + """ + + +@cli.group() +def vuln_alerts() -> None: + """Manage vulnerability alerts""" + pass + + +@vuln_alerts.command("list") +@click.option( + "-s", + "--status", + prompt="Alert status", + type=click.Choice(["open", "closed", ""], case_sensitive=False), + default="open", +) +@click.option( + "-r", + "--repos", + prompt="Repositories name. Use `all` to retrieve alerts for all repos.", + type=str, + multiple=True, +) +@click.option("-o", "--organization", prompt="Organization name", type=str) +@click.option( + "-t", + "--token", + prompt=False, + type=str, + default=None, + hide_input=True, + confirmation_prompt=False, + show_envvar=True, +) +def vulns_alerts_list(repos: str, organization: str, status: str, token: str) -> Dict: + """Get CodeQL alerts for one or several repositories""" + + repositories_alerts = {} + if repos == ("all",): + repos = repositories.get_org_repositories( + status="all", organization=organization, token=token + ) + + repositories_alerts = vulns.get_codeql_alerts_repo( + repos, organization, status, token + ) + click.echo(repositories_alerts) + return repositories_alerts + + +@cli.group() +def secret_alerts() -> None: + """Manage Secret Scanner alerts""" + pass + + +@cli.group() +def dependabot_alerts() -> None: + """Manage Dependabot alerts""" + pass + + +if __name__ == "__main__": + main() diff --git a/src/ghas_cli/__init__.py b/src/ghas_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ghas_cli/utils/__init__.py b/src/ghas_cli/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ghas_cli/utils/export.py b/src/ghas_cli/utils/export.py new file mode 100644 index 0000000..1f75553 --- /dev/null +++ b/src/ghas_cli/utils/export.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +#!/usr/bin/env python3 + +from typing import List, Any +import requests +import json + + +def output_to_csv(alerts_per_repos: Dict, location: str) -> bool: + try: + with open(location, "w") as log_file: + log_file.write(json.dumps(alerts_per_repos)) + except Exception as e: + print(str(e)) + print(f"Failure to write the output to {location}") + return False + return True diff --git a/src/ghas_cli/utils/network.py b/src/ghas_cli/utils/network.py new file mode 100644 index 0000000..da609c8 --- /dev/null +++ b/src/ghas_cli/utils/network.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +#!/usr/bin/env python3 + +from typing import List, Any +import requests +import json + + +def check_rate_limit(response: Any) -> bool: + if "0" == response.headers["x-ratelimit-remaining"]: + reset_time = datetime.fromtimestamp(int(response.headers["x-ratelimit-reset"])) + print( + f"Rate limit reached: {response.headers['x-ratelimit-remaining']}/{response.headers['x-ratelimit-limit']} - {reset_time}" + ) + return True + else: + return False diff --git a/src/ghas_cli/utils/repositories.py b/src/ghas_cli/utils/repositories.py new file mode 100644 index 0000000..31006d1 --- /dev/null +++ b/src/ghas_cli/utils/repositories.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +#!/usr/bin/env python3 + +from typing import List +import requests +import json + +from . import network + + +def get_org_repositories(status: str, organization: str, token: str) -> List: + repositories = [] + page = 1 + + headers = { + "accept": "application/vnd.github+json", + "authorization": f"Bearer {token}", + "User-Agent": "jboursier-mwb/fetch_org_ghas_metrics", + } + while True: + params = { + "type": f"{status}", + "sort": "full_name", + "per_page": 100, + "page": page, + } + repos = requests.get( + url=f"https://api.github.com/orgs/{organization}/repos", + params=params, + headers=headers, + ) + if network.check_rate_limit(repos): + break + + if repos.status_code != 200: + break + for r in repos.json(): + repositories.append(r["name"]) + + if [] == repos.json(): + break + page += 1 + + return repositories diff --git a/src/ghas_cli/utils/vulns.py b/src/ghas_cli/utils/vulns.py new file mode 100644 index 0000000..7b9202b --- /dev/null +++ b/src/ghas_cli/utils/vulns.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +#!/usr/bin/env python3 + +from typing import List, Any, Dict +import requests +import json + +from . import network + + +def get_codeql_alerts_repo( + repos: str, organization: str, status: str, token: str +) -> Dict: + """Get CodeQL alerts for one or several repositories""" + + headers = { + "accept": "application/vnd.github+json", + "authorization": f"Bearer {token}", + "User-Agent": "jboursier-mwb/fetch_org_ghas_metrics", + } + + repositories_alerts = {} + + for repo in repos: + + alerts_repo = [] + page = 1 + + while True: + + params = {"state": "open", "per_page": 100, "page": page} + alerts = requests.get( + url=f"https://api.github.com/repos/{organization}/{repo}/code-scanning/alerts", + params=params, + headers=headers, + ) + if network.check_rate_limit(alerts): + break + + if alerts.status_code != 200: + break + + for a in alerts.json(): + if not a: + continue + + alert_summary = {} + alert_summary["number"] = a["number"] + alert_summary["created_at"] = a["created_at"] + alert_summary["state"] = a["state"] + alert_summary["severity"] = a["rule"]["severity"] + alerts_repo.append(alert_summary) + + if not alerts.json(): + break + + page += 1 + + repositories_alerts[repo] = alerts_repo + + return repositories_alerts