Skip to content

Commit 3048744

Browse files
authored
Merge pull request #32 from spark1security/github-scan-rebased
Added support to scan for leaked secrets in GitHub
2 parents 68bd8e0 + edab660 commit 3048744

File tree

8 files changed

+318
-11
lines changed

8 files changed

+318
-11
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212

1313
# n0s1 - Secret Scanner
14-
n0s1 ([pronunciation](https://en.wiktionary.org/wiki/nosy#Pronunciation)) is a secret scanner for Slack, Jira, Confluence, Asana, Wrike, Linear and Zendesk. It scans all channels/tickets/items/issues within the chosen platform in search of any leaked secrets in the titles, bodies, messages and comments. It is open-source and it can be easily extended to support scanning many others ticketing and messaging platforms.
14+
n0s1 ([pronunciation](https://en.wiktionary.org/wiki/nosy#Pronunciation)) is a secret scanner for Slack, Jira, Confluence, Asana, Wrike, Linear, Zendesk and GitHub. It scans all channels/tickets/items/issues within the chosen platform in search of any leaked secrets in the titles, bodies, messages and comments. It is open-source and it can be easily extended to support scanning many others ticketing and messaging platforms.
1515

1616
These secrets are identified by comparing them against an adaptable configuration file named [regex.yaml](https://github.com/spark1security/n0s1/blob/main/src/n0s1/config/regex.yaml). Alternative TOML format is also supported: [regex.toml](https://github.com/spark1security/n0s1/blob/main/src/n0s1/config/regex.toml). The scanner specifically looks for sensitive information, which includes:
1717
* Github Personal Access Tokens
@@ -30,6 +30,7 @@ These secrets are identified by comparing them against an adaptable configuratio
3030
* [Wrike](https://www.wrike.com)
3131
* [Linear](https://linear.app/)
3232
* [Zendesk](https://www.zendesk.com/)
33+
* [GitHub](https://github.com/)
3334

3435
### Install
3536
```bash

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pyyaml
55
atlassian-python-api
66
asana==3.2.2
77
zenpy
8+
PyGithub
89
WrikePy
910
BeautifulSoup4
1011
slack_sdk

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def get_version():
2727
setup(
2828
name="n0s1",
2929
version=get_version(),
30-
description="Secret Scanner for Slack, Jira, Confluence, Asana, Wrike, Linear and Zendesk. Prevent credential leaks with n0s1.",
30+
description="Secret Scanner for Slack, Jira, Confluence, Asana, Wrike, Linear, Zendesk and GitHub. Prevent credential leaks with n0s1.",
3131
long_description=long_description,
3232
long_description_content_type="text/markdown",
3333
url="https://spark1.us/n0s1",
@@ -48,7 +48,7 @@ def get_version():
4848
"Programming Language :: Python :: 3.12",
4949
"Programming Language :: Python :: 3.13",
5050
], # Classifiers help users find your project by categorizing it https://pypi.org/classifiers/
51-
keywords="security, cybersecurity, scanner, secret scanner, secret leak, data leak, Slack, Jira, Confluence, Asana, Wrike, Linear, Zendesk, security scanner, data loss prevention",
51+
keywords="security, cybersecurity, scanner, secret scanner, secret leak, data leak, Slack, Jira, Confluence, Asana, Wrike, Linear, Zendesk, GitHub, security scanner, data loss prevention",
5252
package_dir={"": "src"},
5353
packages=find_packages(where="src"),
5454
python_requires=">=3.9, <4",

src/n0s1/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.0.25"
1+
__version__ = "1.0.26"

src/n0s1/controllers/asana_controller.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def get_mapping(self, levels=-1, limit=None):
131131
"name": t.get("name", ""),
132132
"stories": {}
133133
}
134-
if len(project_gid) > 0:
134+
if len(task_gid) > 0:
135135
map_data["workspaces"][workspace_gid]["projects"][project_gid]["tasks"][task_gid] = t_item
136136
if levels > 0 and levels <= 3:
137137
continue
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import logging
2+
3+
4+
try:
5+
from . import hollow_controller as hollow_controller
6+
except Exception:
7+
import n0s1.controllers.hollow_controller as hollow_controller
8+
9+
10+
class GitHubController(hollow_controller.HollowController):
11+
def __init__(self):
12+
super().__init__()
13+
self._client = None
14+
15+
def set_config(self, config=None):
16+
super().set_config(config)
17+
from github import Github
18+
TOKEN = config.get("token", "")
19+
self._client = Github(TOKEN)
20+
self._owner = config.get("owner", "")
21+
self._repo = config.get("repo", "")
22+
return self.is_connected()
23+
24+
def get_name(self):
25+
return "GitHub"
26+
27+
def is_connected(self):
28+
if self._client:
29+
if user := self._client.get_user():
30+
self.log_message(f"Logged to {self.get_name()} as {user}")
31+
return True
32+
else:
33+
self.log_message(f"Unable to connect to {self.get_name()}. Check your credentials.", logging.ERROR)
34+
return False
35+
return False
36+
37+
def _get_repo_fullname(self, repo_name):
38+
owner = self._owner
39+
repo_fullname = ""
40+
41+
git_suffix = ".git"
42+
suffix_len = len(git_suffix)
43+
repo_name_len = len(repo_name)
44+
git_url_suffix = repo_name.lower()[repo_name_len-suffix_len:]
45+
if git_url_suffix == git_suffix:
46+
repo_fullname = repo_name.lower().replace("git://github.com/", "")
47+
repo_fullname = repo_fullname[:-suffix_len]
48+
return repo_fullname
49+
50+
if not owner or len(owner) <= 0:
51+
owner = self._client.get_user().login
52+
repo_fullname = owner + "/" + repo_name
53+
return repo_fullname
54+
55+
def _get_repo_obj(self, repo_gid=None):
56+
if self._repo and len(self._repo) > 0:
57+
repo_name = self._get_repo_fullname(self._repo)
58+
return self._client.get_repo(repo_name)
59+
full_name = self._get_repo_fullname(repo_gid)
60+
return self._client.get_repo(full_name)
61+
62+
def _get_repos(self):
63+
owner = {}
64+
repos = []
65+
66+
if self._scan_scope:
67+
owner = self._scan_scope.get("owner", {})
68+
for key in self._scan_scope.get("repos", {}):
69+
repos.append(key)
70+
if len(repos) > 0:
71+
return repos, owner
72+
self.connect()
73+
if self._owner and len(self._owner) > 0:
74+
from github import UnknownObjectException
75+
try:
76+
org = self._client.get_organization(self._owner)
77+
repos = org.get_repos()
78+
owner = {"type": "org", "name": self._owner}
79+
except UnknownObjectException as e:
80+
try:
81+
message = f"Unable to get ORG {self._owner} as owner: {e}"
82+
self.log_message(message, logging.WARNING)
83+
message = f"Trying to get user {self._owner} as owner..."
84+
self.log_message(message, logging.WARNING)
85+
repos = self._client.get_user(self._owner).get_repos()
86+
owner = {"type": "user", "name": self._owner}
87+
except Exception as e:
88+
message = f"Unable to get repos from {self._owner}: {e}"
89+
self.log_message(message, logging.ERROR)
90+
else:
91+
user = self._client.get_user()
92+
repos = user.get_repos()
93+
owner = {"type": "authenticated_user", "name": user.login}
94+
95+
if self._repo and len(self._repo) > 0:
96+
for r in repos:
97+
if r.name.lower() == self._repo.lower():
98+
return [r], owner
99+
return [], owner
100+
101+
return repos, owner
102+
103+
def _get_branches(self, repo_gid, limit=None):
104+
branches = []
105+
if self._scan_scope:
106+
branches = self._scan_scope.get("repos", {}).get(repo_gid, {}).get("branches", {})
107+
if len(branches) > 0:
108+
return branches
109+
self.connect()
110+
repo_obj = self._get_repo_obj(repo_gid)
111+
if repo_obj:
112+
branches = repo_obj.get_branches()
113+
return branches
114+
115+
def _get_files(self, repo_gid, branch_gid, limit=None):
116+
files = []
117+
files_content = []
118+
if self._scan_scope:
119+
files = self._scan_scope.get("repos", {}).get(repo_gid, {}).get("branches", {}).get(branch_gid, {}).get("files", [])
120+
if len(files) > 0:
121+
return files, files_content
122+
self.connect()
123+
repo_obj = self._get_repo_obj(repo_gid)
124+
if repo_obj:
125+
files = []
126+
try:
127+
contents = repo_obj.get_contents("", ref=branch_gid)
128+
while contents:
129+
file_content = contents.pop(0)
130+
if file_content.type == "dir":
131+
contents.extend(repo_obj.get_contents(file_content.path, ref=branch_gid))
132+
else:
133+
files.append(file_content.path)
134+
files_content.append(file_content)
135+
except Exception as e:
136+
message = f"Error listing files from branch {branch_gid}: {e}"
137+
self.log_message(message, logging.ERROR)
138+
return files, files_content
139+
140+
def get_mapping(self, levels=-1, limit=None):
141+
if not self._client:
142+
return {}
143+
repos, owner = self._get_repos()
144+
map_data = {"owner": owner, "repos": {}}
145+
if repos:
146+
for repo in repos:
147+
repo_gid = repo.git_url
148+
message = f"Searching in repository: {repo.html_url}"
149+
self.log_message(message, logging.INFO)
150+
if len(repo_gid) > 0:
151+
r_item = {
152+
"gid": repo_gid,
153+
"name": repo.name,
154+
"branches": {}
155+
}
156+
map_data["repos"][repo_gid] = r_item
157+
if levels > 0 and levels <= 1:
158+
continue
159+
if branches := self._get_branches(repo_gid, limit):
160+
for branch in branches:
161+
message = f"Searching in branch: {branch.name}"
162+
self.log_message(message, logging.INFO)
163+
branch_gid = branch.name
164+
b_item = {
165+
"gid": branch.commit.sha,
166+
"name": branch.name,
167+
"files": {}
168+
}
169+
if len(branch_gid) > 0:
170+
map_data["repos"][repo_gid]["branches"][branch_gid] = b_item
171+
if levels > 0 and levels <= 2:
172+
continue
173+
files, files_content = self._get_files(repo_gid, branch_gid)
174+
map_data["repos"][repo_gid]["branches"][branch_gid]["files"] = files
175+
if levels > 0 and levels <= 3:
176+
continue
177+
return map_data
178+
179+
def get_data(self, include_comments=False, limit=None):
180+
if not self._client:
181+
return {}
182+
183+
repos, owner = self._get_repos()
184+
if repos:
185+
for repo in repos:
186+
if isinstance(repo, str):
187+
repo = self._get_repo_obj(repo)
188+
repo_gid = repo.git_url
189+
repo_html_url = repo.html_url
190+
message = f"Searching in repository: {repo_html_url}"
191+
self.log_message(message, logging.INFO)
192+
193+
# Iterate through each branch
194+
for branch in self._get_branches(repo_gid):
195+
branch_gid = ""
196+
if isinstance(branch, str):
197+
branch_gid = branch
198+
else:
199+
branch_gid = branch.name
200+
message = f"Searching in branch: {branch_gid}"
201+
self.log_message(message, logging.INFO)
202+
203+
# Iterate through each file in the branch
204+
try:
205+
files, files_content = self._get_files(repo_gid, branch_gid)
206+
use_preloaded_content = False
207+
if len(files_content) > 0:
208+
use_preloaded_content = True
209+
files = files_content
210+
for f in files:
211+
try:
212+
if use_preloaded_content:
213+
file_data = f.decoded_content.decode(errors='ignore')
214+
f = f.path
215+
else:
216+
# Fetch file content
217+
file_data = repo.get_contents(f, ref=branch_gid).decoded_content.decode(errors='ignore')
218+
url = repo.html_url + f"/blob/{branch_gid}/{f}"
219+
file = self.pack_data(file_data, url)
220+
yield file
221+
except Exception as e:
222+
message = f"Error accessing file {f} from branch {branch_gid}: {e}"
223+
self.log_message(message, logging.ERROR)
224+
except Exception as e:
225+
message = f"Error accessing branch {branch_gid}: {e}"
226+
self.log_message(message, logging.ERROR)
227+
228+
def post_comment(self, issue, comment):
229+
if not self._client:
230+
return False
231+
message = f"Unable to post comment to {issue}!"
232+
self.log_message(message, logging.ERROR)
233+
return False
234+
235+
def pack_data(self, file_data, url):
236+
ticket_data = {
237+
"ticket": {
238+
"file": {
239+
"name": "file",
240+
"data": file_data,
241+
"data_type": "str"
242+
},
243+
},
244+
"url": url,
245+
"issue_id": url
246+
}
247+
return ticket_data

src/n0s1/controllers/platform_controller.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def get_platform(self, platform):
2626
from . import linear_controller as linear_controller
2727
from . import asana_controller as asana_controller
2828
from . import zendesk_controller as zendesk_controller
29+
from . import github_controller as github_controller
2930
from . import wrike_controller as wrike_controller
3031
from . import slack_controller as slack_controller
3132
except Exception:
@@ -34,6 +35,7 @@ def get_platform(self, platform):
3435
import n0s1.controllers.linear_controller as linear_controller
3536
import n0s1.controllers.asana_controller as asana_controller
3637
import n0s1.controllers.zendesk_controller as zendesk_controller
38+
import n0s1.controllers.github_controller as github_controller
3739
import n0s1.controllers.wrike_controller as wrike_controller
3840
import n0s1.controllers.slack_controller as slack_controller
3941

@@ -48,6 +50,8 @@ def get_platform(self, platform):
4850
factory.register_platform("asana_scan", asana_controller.AsanaController)
4951
factory.register_platform("zendesk", zendesk_controller.ZendeskController)
5052
factory.register_platform("zendesk_scan", zendesk_controller.ZendeskController)
53+
factory.register_platform("github", github_controller.GitHubController)
54+
factory.register_platform("github_scan", github_controller.GitHubController)
5155
factory.register_platform("wrike", wrike_controller.WrikeController)
5256
factory.register_platform("wrike_scan", wrike_controller.WrikeController)
5357
factory.register_platform("slack", slack_controller.SlackController)

0 commit comments

Comments
 (0)