diff --git a/butler.py b/butler.py
index 0713075..5a0728f 100644
--- a/butler.py
+++ b/butler.py
@@ -2,17 +2,19 @@
from src.commands.database.command import CommandDatabase
from src.commands.process.command import CommandProcess
from src.commands.report.command import CommandReport
+from src.commands.secvars.command import CommandSecretsAndVariables
from src.libs.utils import Utils
from src.libs.exceptions import InvalidCommandLine
from src.commands.download.command import CommandDownload
-__VERSION__ = '0.10.2 Beta'
+__VERSION__ = '0.12.0'
commands = {
'download': CommandDownload,
'database': CommandDatabase,
'process': CommandProcess,
'report': CommandReport,
+ 'secvars': CommandSecretsAndVariables
}
parser = argparse.ArgumentParser(prog="butler", description=f"Butler - GitHub Actions Oversight v{__VERSION__}")
diff --git a/src/commands/download/download.py b/src/commands/download/download.py
index 522e1a4..d6b3d61 100644
--- a/src/commands/download/download.py
+++ b/src/commands/download/download.py
@@ -160,44 +160,6 @@ def _collect_repos(self, repos: list[RepoComponent]) -> None:
self.log.trace(f"Writing to database...")
self.database.commit()
- def _save_repo(self, repo: RepoComponent) -> None:
- with self.lock:
- self.log.info(f"Saving repository {repo}")
- if repo.org.id == 0:
- repo.org.id = self._create_org(repo.org).id
-
- if len(repo.ref) > 0:
- repo.poll_status = PollStatus.PENDING
- repo_db = self.database.repos().create(repo)
- return
-
- # At this point, there is no `ref` in the object.
- # Search if the repo is already in the database.
- repo_db = self.database.repos().find(repo.org.id, repo.name, None)
- if self._repo_already_stored(repo_db):
- return
-
- # Either there's no database record, or the stored one also has an empty ref.
- fresh_repo = self._fetch_repo(repo)
- with self.lock:
- if fresh_repo.org.name.lower() == repo.org.name.lower() and fresh_repo.name.lower() == repo.name.lower():
- fresh_repo.org.id = repo.org.id
- fresh_repo.poll_status = PollStatus.SCANNED if repo.status == RepoStatus.MISSING else PollStatus.PENDING
- if repo_db:
- fresh_repo.id = repo_db.id
- self.database.repos().update(fresh_repo)
- else:
- self.database.repos().create(fresh_repo)
- return
-
- # Here, the fetched repo is different to the one passed to the function, this happens when a repo is redirected.
- fresh_repo.org.id = self._create_org(fresh_repo.org).id
- fresh_repo_db = self.database.repos().create(fresh_repo)
-
- repo.redirect_id = fresh_repo_db.id
- repo.status = RepoStatus.REDIRECT
- self.database.repos().create(repo)
-
def _resolve_commits(self) -> None:
while True:
batch = self.database.next_commit_to_resolve(self.threads)
diff --git a/src/commands/download/download_helper.py b/src/commands/download/download_helper.py
index 8f1957f..38ec618 100644
--- a/src/commands/download/download_helper.py
+++ b/src/commands/download/download_helper.py
@@ -1,3 +1,4 @@
+from src.libs.constants import PollStatus
from src.database.models import OrganisationModel, RepositoryModel
from src.github.exceptions import HttpNotFound
from src.libs.components.org import OrgComponent
@@ -167,3 +168,41 @@ def _create_child_workflow_from_workflow(self, uses: str, workflow_instance: Wor
else:
org, repo, workflow = self._create_child_workflow_from_action(uses)
return org, repo, workflow
+
+ def _save_repo(self, repo: RepoComponent) -> None:
+ with self.lock:
+ self.log.info(f"Saving repository {repo}")
+ if repo.org.id == 0:
+ repo.org.id = self._create_org(repo.org).id
+
+ if len(repo.ref) > 0:
+ repo.poll_status = PollStatus.PENDING
+ repo_db = self.database.repos().create(repo)
+ return
+
+ # At this point, there is no `ref` in the object.
+ # Search if the repo is already in the database.
+ repo_db = self.database.repos().find(repo.org.id, repo.name, None)
+ if self._repo_already_stored(repo_db):
+ return
+
+ # Either there's no database record, or the stored one also has an empty ref.
+ fresh_repo = self._fetch_repo(repo)
+ with self.lock:
+ if fresh_repo.org.name.lower() == repo.org.name.lower() and fresh_repo.name.lower() == repo.name.lower():
+ fresh_repo.org.id = repo.org.id
+ fresh_repo.poll_status = PollStatus.SCANNED if repo.status == RepoStatus.MISSING else PollStatus.PENDING
+ if repo_db:
+ fresh_repo.id = repo_db.id
+ self.database.repos().update(fresh_repo)
+ else:
+ self.database.repos().create(fresh_repo)
+ return
+
+ # Here, the fetched repo is different to the one passed to the function, this happens when a repo is redirected.
+ fresh_repo.org.id = self._create_org(fresh_repo.org).id
+ fresh_repo_db = self.database.repos().create(fresh_repo)
+
+ repo.redirect_id = fresh_repo_db.id
+ repo.status = RepoStatus.REDIRECT
+ self.database.repos().create(repo)
diff --git a/src/commands/report/queries/org-secrets.yaml b/src/commands/report/queries/org-secrets.yaml
new file mode 100644
index 0000000..c2b0aa5
--- /dev/null
+++ b/src/commands/report/queries/org-secrets.yaml
@@ -0,0 +1,118 @@
+version: '1.1'
+name: 'Organisation Secrets & Variables'
+description: 'List of all secrets across the organisation and its repos'
+filename: 'all-secrets-and-variables'
+group: 'secrets'
+sql: |
+ SELECT
+ COALESCE(r.name, 'Defined in Organisation') AS repo_name,
+ (CASE
+ WHEN s.category = 1 THEN 'actions'
+ WHEN s.category = 2 THEN 'agents'
+ WHEN s.category = 3 THEN 'codespaces'
+ WHEN s.category = 4 THEN 'dependabot'
+ WHEN s.category = 5 THEN 'environments'
+ ELSE 'unknown'
+ END) AS category,
+ (CASE
+ WHEN s.type = 1 THEN 'secret'
+ WHEN s.type = 2 THEN 'variable'
+ ELSE 'unknown'
+ END) AS type,
+ s.name,
+ s.value,
+ (CASE
+ WHEN s.visibility = 1 THEN 'all'
+ WHEN s.visibility = 2 THEN 'private'
+ WHEN s.visibility = 3 THEN 'selected'
+ ELSE ''
+ END) AS visibility,
+ (CASE WHEN org_secrets.id IS NOT NULL THEN 1 ELSE 0 END) AS overrides_org,
+ COALESCE(selected_repos.repos, '') AS selected_repos,
+ (CASE
+ WHEN r.id IS NOT NULL AND s.category = 1 THEN CONCAT('https://github.com/', o.name, '/', r.name, '/settings/secrets/actions')
+ WHEN r.id IS NOT NULL AND s.category = 2 THEN CONCAT('https://github.com/', o.name, '/', r.name, '/settings/secrets/agents')
+ WHEN r.id IS NOT NULL AND s.category = 3 THEN CONCAT('https://github.com/', o.name, '/', r.name, '/settings/secrets/codespaces')
+ WHEN r.id IS NOT NULL AND s.category = 4 THEN CONCAT('https://github.com/', o.name, '/', r.name, '/settings/secrets/dependabot')
+
+ ELSE ''
+ END) AS category_url,
+ (CASE
+ WHEN r.id IS NOT NULL THEN CONCAT('https://github.com/', o.name, '/', r.name)
+ ELSE ''
+ END) AS repo_url
+ FROM secrets_and_variables s
+ LEFT JOIN repositories r ON r.id = s.repo_id
+ LEFT JOIN organisations o ON o.id = r.org_id
+ LEFT JOIN (
+ SELECT s.id, s.category, s.type, s.name
+ FROM secrets_and_variables s
+ WHERE s.repo_id = 0
+ ) org_secrets ON org_secrets.category = s.category AND org_secrets.type = s.type AND org_secrets.name = s.name AND r.id > 0
+ LEFT JOIN (
+ SELECT s.id, GROUP_CONCAT(r.name, ',') AS repos
+ FROM secrets_and_variables s
+ JOIN secrets_and_variables_repos sr ON sr.secret_variable_id = s.id
+ JOIN repositories r ON r.id = sr.repo_id
+ WHERE s.repo_id = 0 AND s.visibility = 3
+ GROUP BY s.id
+ ) selected_repos ON selected_repos.id = s.id
+ WHERE
+ o.id = :org OR o.id IS NULL
+columns:
+ selected_repos: hide
+ category_url: hide
+ repo_url: hide
+
+ repo_name:
+ label: 'Repository'
+ type: 'link'
+ link: 'repo_url'
+ filters:
+ column_control_alias: 'list'
+ category:
+ label: 'Category'
+ type: 'link'
+ link: 'category_url'
+ filters:
+ column_control_alias: 'list-no-search'
+ value_mapping:
+ actions: 'Actions'
+ agents: 'Agents'
+ codespaces: 'Codespaces'
+ dependabot: 'Dependabot'
+ environments: 'Environments'
+ type:
+ label: 'Type'
+ filters:
+ column_control_alias: 'list-no-search'
+ value_mapping:
+ secret: 'Secret'
+ variable: 'Variable'
+ name:
+ label: 'Name'
+ filters:
+ column_control_alias: 'list'
+ value:
+ label: 'Value'
+ filters:
+ column_control_alias: 'list'
+ visibility:
+ label: 'Visibility'
+ filters:
+ column_control_alias: 'list-no-search'
+ value_mapping:
+ all: 'All Repos'
+ private: 'Private Repos'
+ selected: 'Selected Repos'
+ popup:
+ title: 'Repositories'
+ field: 'selected_repos'
+ overrides_org:
+ label: 'Overrides Org Value'
+ align: 'text-center'
+ filters:
+ column_control_alias: 'list-no-search'
+ type: 'icon'
+ format:
+ style_true: 'text-warning'
diff --git a/src/commands/report/query_processor.py b/src/commands/report/query_processor.py
index dacdaf6..82f4424 100644
--- a/src/commands/report/query_processor.py
+++ b/src/commands/report/query_processor.py
@@ -74,6 +74,8 @@ def _prepare_query(self, sql: str) -> tuple[str, dict]:
params = {}
if '$_TRUSTED_ORGS_$' in sql:
org_ids = self._get_trusted_org_ids()
+ if len(org_ids) == 0:
+ org_ids = [0]
trusted_params = {f'trusted_org_{i}': org_id for i, org_id in enumerate(org_ids)}
params.update(trusted_params)
sql = sql.replace("$_TRUSTED_ORGS_$", ", ".join(f":{k}" for k in trusted_params))
diff --git a/src/commands/report/table_elements.py b/src/commands/report/table_elements.py
index 3f877aa..b461ca2 100644
--- a/src/commands/report/table_elements.py
+++ b/src/commands/report/table_elements.py
@@ -11,9 +11,9 @@ class TableColumnOptions:
_style_false: str = None
_column_control: str = None
_column_control_alias: str = None
-
- def __init__(self, name: str):
- self._name = name
+ _value_mapping: dict = None
+ _popup_field: str = None
+ _popup_title: str = None
@property
def name(self) -> str:
@@ -117,10 +117,35 @@ def column_control_alias(self) -> str:
def column_control_alias(self, value: str):
self._column_control_alias = value
+ @property
+ def value_mapping(self) -> dict:
+ return self._value_mapping or {}
+
+ @value_mapping.setter
+ def value_mapping(self, value: dict):
+ self._value_mapping = value
+
+ @property
+ def popup_field(self) -> str:
+ return self._popup_field or ''
+
+ @popup_field.setter
+ def popup_field(self, value: str):
+ self._popup_field = value
+
+ @property
+ def popup_title(self) -> str:
+ return self._popup_title
+
+ @popup_title.setter
+ def popup_title(self, value: str):
+ self._popup_title = value
+
def __init__(self, name: str):
self.name = name
self.visible = True
self.type = 'text'
+ self.value_mapping = {}
class TableColumn:
_name: str = None
@@ -130,6 +155,8 @@ class TableColumn:
_align: str = None
_icon: str = None
_style: str = None
+ _popup_html: str = None
+ _popup_title: str = None
@property
def name(self) -> str:
@@ -191,6 +218,22 @@ def style(self) -> str:
def style(self, value: str):
self._style = value
+ @property
+ def popup_html(self) -> str:
+ return self._popup_html or ''
+
+ @popup_html.setter
+ def popup_html(self, value: str):
+ self._popup_html = value
+
+ @property
+ def popup_title(self) -> str:
+ return self._popup_title
+
+ @popup_title.setter
+ def popup_title(self, value: str):
+ self._popup_title = value
+
class Table:
columns: dict = None
@@ -220,6 +263,9 @@ def _load_columns(self, options: dict) -> dict:
column.style_false = column_options.get('format', {}).get('style_false', '')
column.column_control = column_options.get('filters', {}).get('column_control', '[]')
column.column_control_alias = column_options.get('filters', {}).get('column_control_alias', '')
+ column.value_mapping = column_options.get('value_mapping', {})
+ column.popup_field = column_options.get('popup', {}).get('field', '')
+ column.popup_title = column_options.get('popup', {}).get('title', '')
all_columns[name] = column
return all_columns
@@ -270,24 +316,53 @@ def load_rows(self, raw_results: list):
continue
column = TableColumn(raw_column)
- column.contents = raw_value
+ column.contents = self._map_value(raw_column, raw_value)
if raw_column in self.columns:
column.align = self.columns[raw_column].align
if self.columns[raw_column].type == 'link':
- column.type = 'link'
- column.url = raw_result[self.columns[raw_column].link] if self.columns[raw_column].link in raw_result else self.columns[raw_column].link
+ column = self._format_link(column, raw_result, raw_column)
elif self.columns[raw_column].type == 'icon':
- column.type = 'icon'
- if raw_value:
- column.icon = self.columns[raw_column].icon_true
- column.style = self.columns[raw_column].style_true
- column.contents = 'yes'
- else:
- column.icon = self.columns[raw_column].icon_false
- column.style = self.columns[raw_column].style_false
- column.contents = 'no'
+ column = self._format_icon(column, raw_column, raw_value)
+ elif len(self.columns[raw_column].popup_field) > 0:
+ column = self._format_popup(column, raw_result, raw_column)
row.append(column)
self.rows.append(row)
+
+ def _map_value(self, raw_column: str, raw_value: str) -> str:
+ if raw_column in self.columns and len(self.columns[raw_column].value_mapping) > 0:
+ if raw_value in self.columns[raw_column].value_mapping:
+ return self.columns[raw_column].value_mapping[raw_value]
+ elif '_' in self.columns[raw_column].value_mapping:
+ return self.columns[raw_column].value_mapping['_']
+ return raw_value
+
+ def _format_link(self, column: TableColumn, raw_result: dict, raw_column: str) -> TableColumn:
+ column.type = 'link'
+ column.url = raw_result[self.columns[raw_column].link] if self.columns[raw_column].link in raw_result else self.columns[raw_column].link
+ return column
+
+ def _format_icon(self, column: TableColumn, raw_column: str, raw_value: str | None) -> TableColumn:
+ column.type = 'icon'
+ if raw_value:
+ column.icon = self.columns[raw_column].icon_true
+ column.style = self.columns[raw_column].style_true
+ column.contents = 'yes'
+ else:
+ column.icon = self.columns[raw_column].icon_false
+ column.style = self.columns[raw_column].style_false
+ column.contents = 'no'
+ return column
+
+ def _format_popup(self, column: TableColumn, raw_result: dict, raw_column: str) -> TableColumn:
+ column.type = 'popup'
+ column.popup_title = self.columns[raw_column].popup_title
+ column.popup_html = ''
+
+ values = [value.strip() for value in raw_result[self.columns[raw_column].popup_field].split(',') if value.strip()]
+ if len(values) > 0:
+ html = "
" + "".join(f"- {item}
" for item in values) + "
"
+ column.popup_html = html
+ return column
diff --git a/src/commands/report/templates/generic.html b/src/commands/report/templates/generic.html
index db8c373..e3f808d 100644
--- a/src/commands/report/templates/generic.html
+++ b/src/commands/report/templates/generic.html
@@ -20,10 +20,18 @@
{% for column in row %}
|
- {% if column.type == 'link' %}
+ {% if column.type == 'link' and column.url|length > 0 %}
{{ column.contents }}
{% elif column.type == 'icon' %}
+ {% elif column.type == 'popup' %}
+ {% if column.popup_html|length > 0 %}
+
+ {{ column.contents }}
+
+ {% else %}
+ {{ column.contents }}
+ {% endif %}
{% else %}
{{ column.contents }}
{% endif %}
@@ -70,4 +78,9 @@
{% endif %}
});
}
+
+ $(document).ready(function() {
+ const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]')
+ const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl))
+ });
diff --git a/src/commands/report/templates/parent.html b/src/commands/report/templates/parent.html
index 4fc6cd8..a9c9996 100644
--- a/src/commands/report/templates/parent.html
+++ b/src/commands/report/templates/parent.html
@@ -14,7 +14,7 @@
-
+
diff --git a/src/commands/secvars/command.py b/src/commands/secvars/command.py
new file mode 100644
index 0000000..b58d669
--- /dev/null
+++ b/src/commands/secvars/command.py
@@ -0,0 +1,51 @@
+import os
+import argparse
+from src.commands.secvars.secvars import ServiceSecretsAndVariables
+from src.commands.command import Command
+from src.database.database import Database
+from src.libs.exceptions import InvalidCommandLine
+from src.github.client import GitHubClient
+
+
+class CommandSecretsAndVariables(Command):
+ @staticmethod
+ def load_command_line(subparsers: any) -> None:
+ subparser = subparsers.add_parser("secvars", help="Download Secrets and Variables from GitHub")
+
+ subparser.add_argument("--org", type=str, help="Organisation to download secrets and variables for")
+ subparser.add_argument("--database", default="database.db", type=str, help="Path to SQLite database to create or connect to")
+ subparser.add_argument("--resume-next", default=True, action="store_true", help="Resume downloads on server errors")
+ subparser.add_argument("--threads", default=1, type=int, help="Enable multithreading")
+
+ Command.define_shared_arguments(subparser)
+
+ def load_arguments(self, arguments: argparse.Namespace) -> dict:
+ return {
+ 'org': '' if arguments.org is None or len(arguments.org.strip()) == 0 else arguments.org.strip(),
+ 'database': '' if arguments.database is None or len(arguments.database.strip()) == 0 else os.path.realpath(arguments.database.strip()),
+ 'resume_next': arguments.resume_next or False,
+ 'threads': int(arguments.threads),
+ }
+
+ def validate_command_arguments(self, arguments: dict) -> None:
+ # Validate database.
+ if len(arguments['database']) == 0:
+ raise InvalidCommandLine(f"--database cannot be empty")
+ elif not arguments['database'].lower().endswith('.sqlite3') and not arguments['database'].lower().endswith('.db'):
+ raise InvalidCommandLine(f"--database {arguments['database']} is not a SQLite database (must end with .sqlite3 or .db)")
+
+ if len(arguments['org']) == 0:
+ raise InvalidCommandLine(f"--org cannot be empty")
+
+ if arguments['threads'] <= 0:
+ arguments['threads'] = 1
+
+ def execute(self, arguments: dict) -> bool:
+ database = Database(arguments['database'], arguments['db_debug'], arguments['db_debug_auto_commit'])
+
+ service = ServiceSecretsAndVariables(self.log, database)
+ service.github_client = GitHubClient(self.tokens, self.log)
+ service.org = arguments['org']
+ service.resume_next = arguments['resume_next']
+ service.threads = arguments['threads']
+ return service.run()
diff --git a/src/commands/secvars/secvars.py b/src/commands/secvars/secvars.py
new file mode 100644
index 0000000..28cdd8f
--- /dev/null
+++ b/src/commands/secvars/secvars.py
@@ -0,0 +1,188 @@
+import threading
+import concurrent.futures
+from contextlib import nullcontext
+from src.database.models import OrganisationModel, RepositoryModel
+from src.libs.constants import SecretVariableCategory, SecretVariableType, SecretVariableVisibility
+from src.libs.components.secvar import SecretVariableComponent
+from src.commands.download.download_helper import DownloadHelper
+from src.commands.service import Service
+from src.github.client import GitHubClient
+from src.github.exceptions import TooManyRequests, ApiRateLimitExceeded, OrgNotFound
+from src.libs.components.org import OrgComponent
+from src.libs.constants import PollStatus, OrgStatus
+from src.libs.exceptions import InvalidCommandLine
+from src.libs.utils import Utils
+
+
+class ServiceSecretsAndVariables(Service, DownloadHelper):
+ org: str = None
+ lock: threading.Lock = None
+ resume_next: bool = None
+ github_client: GitHubClient = None
+ threads: int = None
+
+ _combinations: list = [
+ {'label': 'actions / secrets', 'category': SecretVariableCategory.ACTIONS, 'type': SecretVariableType.SECRET},
+ {'label': 'actions / variables', 'category': SecretVariableCategory.ACTIONS, 'type': SecretVariableType.VARIABLE},
+ {'label': 'agents / secrets', 'category': SecretVariableCategory.AGENTS, 'type': SecretVariableType.SECRET},
+ {'label': 'agents / variables', 'category': SecretVariableCategory.AGENTS, 'type': SecretVariableType.VARIABLE},
+ {'label': 'codespaces / secrets', 'category': SecretVariableCategory.CODESPACES, 'type': SecretVariableType.SECRET},
+ {'label': 'dependabot / secrets', 'category': SecretVariableCategory.DEPENDABOT, 'type': SecretVariableType.SECRET},
+ ]
+
+ def run(self) -> bool:
+ # Thanks SQLite3 :>
+ self.lock = threading.Lock() if self.threads > 1 else nullcontext()
+
+ while True:
+ try:
+ self.log.info(f"Collecting repositories for {self.org}...")
+ self._collect_targets()
+
+ org = self.database.orgs().find(self.org)
+ if not org:
+ raise InvalidCommandLine(f"Organisation {self.org} not found")
+
+ self.log.info(f"Collecting secrets and variables for {org.name}")
+ self._collect_secrets_and_variables(org)
+
+ break
+ except (TooManyRequests, ApiRateLimitExceeded) as e:
+ if self.resume_next:
+ self.github_client.halt_and_continue(5)
+ continue
+ raise
+ except Exception as e:
+ if self.resume_next and 'Server Error' in str(e):
+ self.github_client.halt_and_continue(2)
+ continue
+ raise
+
+ if self.database.debug:
+ self.log.info(f"Total SQL Queries: {self.database.total_queries}")
+
+ self.log.info(f"Total API Calls: {self.github_client._api.total_requests}")
+ return True
+
+ def _collect_targets(self) -> None:
+ orgs, repos = Utils.filter_orgs_and_repos([self.org])
+ self.log.debug(f"Input has {len(orgs)} organisations and {len(repos)} repositories")
+ self._collect_orgs(orgs)
+
+ def _collect_orgs(self, orgs: list[OrgComponent]) -> None:
+ count = 0
+ for org in orgs:
+ count += 1
+ self.log.info(f"Processing {org} ({count}/{len(orgs)})")
+
+ org_db = self._create_org(org)
+ org.id = org_db.id
+
+ if org_db.poll_status == PollStatus.SCANNED:
+ self.log.debug(f"Organisation {org_db.name} already scanned - skipping")
+ continue
+ elif org_db.poll_status == PollStatus.NONE:
+ self.log.info(f"Organisation {org_db.name} is new or was not marked as pending before")
+ self.database.orgs().set_poll_status(org_db.id, PollStatus.PENDING)
+
+ try:
+ for batch in self.github_client.get_org_repos(org.name, True, True):
+ for repo in batch:
+ repo.org.id = org_db.id
+ self._save_repo(repo)
+ except OrgNotFound as e:
+ self.log.error(f"Organisation {org.name} not found")
+ self.database.orgs().set_status(org_db.id, OrgStatus.MISSING)
+
+ self.database.orgs().set_poll_status(org_db.id, PollStatus.SCANNED)
+
+ self.log.trace(f"Writing to database...")
+ self.database.commit()
+
+ self.log.trace(f"Writing to database...")
+ self.database.commit()
+
+ def _collect_secrets_and_variables(self, org: OrganisationModel) -> None:
+ components = []
+ for item in self._combinations:
+ self.log.info(f"Getting organisation {item['label']}")
+ try:
+ results = self.github_client.get_secrets(org.name, item['category'], item['type'], None, None)
+ components.extend(self._create_components(org.id, results, item['category'], item['type']))
+ except Exception as e:
+ self.log.warning(f"Could not get {item['label']}")
+
+ self.log.info(f"Writing to database")
+ for component in components:
+ self.database.secvars().create(0, component)
+
+ self.database.commit()
+
+ self.log.info(f"Getting organisation repos")
+ repos = self.database.repos().all(org.id)
+ self.log.info(f"Got {len(repos)} repos")
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=self.threads) as executor:
+ # Submit all repositories to the executor
+ future_to_repo = {executor.submit(self._fetch_secvar_repo_single, org, repo): repo for repo in repos}
+
+ for future in concurrent.futures.as_completed(future_to_repo):
+ repo = future_to_repo[future]
+ try:
+ # Retrieve the data returned by fetch_repo_data
+ returned_repo, components = future.result()
+
+ # 3. Write to the database safely in the main thread
+ if components:
+ self.log.info(f"Writing {len(components)} components to database for {returned_repo.name}")
+ with self.lock:
+ for component in components:
+ self.database.secvars().create(repo.id, component)
+
+ self.database.commit()
+
+ except Exception as e:
+ self.log.error(f"Unhandled exception processing repo {repo.name}: {e}")
+
+ def _fetch_secvar_repo_single(self, org: OrganisationModel, repo: RepositoryModel) -> tuple[object, list]:
+ components = []
+ self.log.info(f"Getting secrets and variables for {repo.name}")
+ for item in self._combinations:
+ self.log.info(f"Getting data for repo {repo.name} and {item['label']}")
+ try:
+ results = self.github_client.get_secrets(org.name, item['category'], item['type'], None, repo.name)
+ components.extend(self._create_components(org.id, results, item['category'], item['type']))
+ except Exception as e:
+ self.log.warning(f"Could not get {item['label']} for {repo.name}")
+
+ return repo, components
+
+ def _create_components(self, org_id: int, items: list, category: SecretVariableCategory, type: SecretVariableType) -> list[SecretVariableComponent]:
+ components = []
+ for item in items:
+ component = SecretVariableComponent()
+ component.category = category
+ component.type = type
+ component.name = item['name']
+ if component.type == SecretVariableType.VARIABLE:
+ component.value = item['value']
+ component.created_at = item['created_at']
+ component.updated_at = item['updated_at']
+
+ if 'visibility' in item:
+ match item['visibility']:
+ case 'all':
+ component.visibility = SecretVariableVisibility.ALL
+ case 'private':
+ component.visibility = SecretVariableVisibility.PRIVATE
+ case 'selected':
+ component.visibility = SecretVariableVisibility.SELECTED
+
+ repos = []
+ for repo in item['repos']:
+ repo = self.database.repos().find(org_id, repo, None)
+ repos.append(repo)
+ component.repos = repos
+
+ components.append(component)
+ return components
diff --git a/src/database/database.py b/src/database/database.py
index 573a15a..801459e 100644
--- a/src/database/database.py
+++ b/src/database/database.py
@@ -1,4 +1,5 @@
import os
+from src.database.helpers.db_secvars import DBSecretsAndVariables
from src.database.database_helper import DatabaseHelper
from src.database.helpers.db_config import DBConfig
from src.database.helpers.db_jobs import DBJob
@@ -14,7 +15,7 @@
class Database(DatabaseHelper):
- __VERSION__: str = '1.0.1'
+ __VERSION__: str = '1.1.0'
_engine: Engine = None
_sessionmaker: sessionmaker = None
_session = None
@@ -26,6 +27,7 @@ class Database(DatabaseHelper):
_steps: DBStep | None = None
_vars: DBVars | None = None
_config: DBConfig | None = None
+ _secvars: DBSecretsAndVariables | None = None
_total_queries: int = 0
_debug: bool = False
@@ -98,7 +100,6 @@ def _update_views(self) -> None:
"""
self.execute(sql)
-
def orgs(self) -> DBOrg:
if not self._orgs:
self._orgs = DBOrg(self.session, self.auto_commit)
@@ -134,6 +135,11 @@ def config(self) -> DBConfig:
self._config = DBConfig(self.session, self.auto_commit)
return self._config
+ def secvars(self) -> DBSecretsAndVariables:
+ if not self._secvars:
+ self._secvars = DBSecretsAndVariables(self.session, self.auto_commit)
+ return self._secvars
+
def commit(self) -> None:
self.session.commit()
diff --git a/src/database/helpers/db_repos.py b/src/database/helpers/db_repos.py
index 31722ed..53d29d6 100644
--- a/src/database/helpers/db_repos.py
+++ b/src/database/helpers/db_repos.py
@@ -97,3 +97,6 @@ def set_ref_resolved_fields(self, id: int, resolved_ref: str, resolved_ref_type:
def count(self) -> int:
return self.session.query(RepositoryModel).count()
+
+ def all(self, org_id: int) -> any:
+ return self.session.query(RepositoryModel).filter(RepositoryModel.org_id == org_id).all()
diff --git a/src/database/helpers/db_secvars.py b/src/database/helpers/db_secvars.py
new file mode 100644
index 0000000..4ab6017
--- /dev/null
+++ b/src/database/helpers/db_secvars.py
@@ -0,0 +1,37 @@
+from sqlalchemy import func
+from src.libs.constants import SecretVariableVisibility
+from src.libs.components.secvar import SecretVariableComponent
+from src.database.helpers.db_base import DBBase
+from src.database.models import SecretsVariablesModel, SecretsVariablesReposModel
+
+
+class DBSecretsAndVariables(DBBase):
+ def find(self, repo_id: int, category: int, type: int, name: str) -> SecretsVariablesModel | None:
+ return self.session.query(SecretsVariablesModel).filter(
+ SecretsVariablesModel.repo_id == repo_id,
+ SecretsVariablesModel.category == category,
+ SecretsVariablesModel.type == type,
+ func.lower(SecretsVariablesModel.name) == func.lower(name)
+ ).first()
+
+ def create(self, repo_id: int, secvar: SecretVariableComponent) -> SecretsVariablesModel:
+ record = self.find(repo_id, secvar.category, secvar.type, secvar.name)
+ if not record:
+ record = SecretsVariablesModel(repo_id=repo_id)
+ record.category = secvar.category
+ record.type = secvar.type
+ record.name = secvar.name
+ record.value = secvar.value
+ record.visibility = secvar.visibility
+ record.created_at = secvar.created_at
+ record.updated_at = secvar.updated_at
+ self.add(record)
+ self.save()
+
+ if secvar.visibility == SecretVariableVisibility.SELECTED:
+ for repo in secvar.repos:
+ repo_record = SecretsVariablesReposModel(secret_variable_id=record.id, repo_id=repo.id)
+ self.add(repo_record)
+ self.save()
+
+ return record
diff --git a/src/database/models.py b/src/database/models.py
index a0afcc4..7e26396 100644
--- a/src/database/models.py
+++ b/src/database/models.py
@@ -119,3 +119,23 @@ class ConfigModel(Base):
name = Column(String, primary_key=True, index=True)
value = Column(String, default='', index=True)
+
+class SecretsVariablesModel(Base):
+ __tablename__ = 'secrets_and_variables'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ repo_id = Column(Integer, default=0, index=True)
+ category = Column(Integer, default=0, index=True)
+ type = Column(Integer, default=0, index=True)
+ name = Column(String, default='', index=True)
+ value = Column(String, default='', index=True)
+ visibility = Column(Integer, default=0, index=True)
+ created_at = Column(Text, default='', index=True)
+ updated_at = Column(Text, default='', index=True)
+
+class SecretsVariablesReposModel(Base):
+ __tablename__ = 'secrets_and_variables_repos'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ secret_variable_id = Column(Integer, default=0, index=True)
+ repo_id = Column(Integer, default=0, index=True)
diff --git a/src/github/client.py b/src/github/client.py
index 2b2a8d0..9abb1d8 100644
--- a/src/github/client.py
+++ b/src/github/client.py
@@ -2,6 +2,7 @@
import base64
import time
from loguru import logger
+from src.libs.constants import SecretVariableCategory, SecretVariableType
from src.github.api import GitHubApi
from src.github.exceptions import (
AccountNotFound, UnknownAccountType, HttpNotFound, GitHubException, HttpEmptyRepo, HttpAccessBlocked,
@@ -357,3 +358,70 @@ def halt_and_continue(self, minutes: int) -> None:
self.log.info(f"Reached API rate limit - waiting {minutes} minutes")
time.sleep(minutes * 60)
self._api.refresh_tokens()
+
+ def get_secrets(self, org: str, category: SecretVariableCategory, type: SecretVariableType, environment: str | None, repo: str | None) -> list[dict]:
+ params = {'page': 1, 'per_page': 100}
+ url = self._get_secrets_url(org, category, type, environment, repo)
+ all_results = []
+ while True:
+ response_headers = {}
+ try:
+ results = self._api.get(url, params, None, response_headers)
+ except HttpNotFound:
+ raise RepoNotFound(f"{org}/{category}/{type}/{environment}/{repo}")
+
+ name = 'secrets' if 'secrets' in results else 'variables'
+ for item in results[name]:
+ if item.get('visibility', '') == 'selected':
+ item['repos'] = self._get_selected_repositories(item['selected_repositories_url'])
+ all_results.append(item)
+
+ # Invalidate as we'll be using the 'next url' from the headers.
+ params = None
+ url = self._get_next_url(response_headers)
+ if not url:
+ break
+
+ return all_results
+
+ def _get_selected_repositories(self, url: str) -> list:
+ url = url.replace('https://api.github.com', '')
+ try:
+ results = self._api.get(url, {'per_page': 999})
+ except HttpNotFound:
+ raise RepoNotFound(url)
+
+ repos = []
+ for result in results['repositories']:
+ repos.append(result['name'])
+ return repos
+
+ def _get_secrets_url(self, org: str, category: SecretVariableCategory, type: SecretVariableType, environment: str | None, repo: str | None) -> str:
+ category_mapping = {
+ # /orgs/{org}/actions/{secrets,variables}
+ # /repos/{owner}/{repo}/actions/{secrets,variables}
+ SecretVariableCategory.ACTIONS: 'actions',
+
+ # /orgs/{org}/agents/{secrets,variables}
+ SecretVariableCategory.AGENTS: 'agents',
+ # /repos/{owner}/{repo}/codespaces/secrets
+ SecretVariableCategory.CODESPACES: 'codespaces',
+ # /orgs/{org}/dependabot/secrets
+ SecretVariableCategory.DEPENDABOT: 'dependabot',
+ # /repos/{owner}/{repo}/environments/{environment_name}/secrets
+ SecretVariableCategory.ENVIRONMENTS: 'environments',
+ }
+
+ type_mapping = {
+ SecretVariableType.SECRET: 'secrets',
+ SecretVariableType.VARIABLE: 'variables',
+ }
+
+ base_path = f"/repos/{org}/{repo}" if repo else f"/orgs/{org}"
+ components = [
+ base_path,
+ category_mapping[category],
+ environment,
+ type_mapping[type]
+ ]
+ return "/".join(comp for comp in components if comp)
diff --git a/src/libs/components/secvar.py b/src/libs/components/secvar.py
new file mode 100644
index 0000000..45d5766
--- /dev/null
+++ b/src/libs/components/secvar.py
@@ -0,0 +1,90 @@
+from datetime import datetime
+from src.libs.constants import SecretVariableCategory, SecretVariableType, SecretVariableVisibility
+
+
+class SecretVariableComponent:
+ _id: int = None
+ _repos: list = None
+ _category: SecretVariableCategory = None
+ _type: SecretVariableType = None
+ _name: str = None
+ _value: str = None
+ _visibility: SecretVariableVisibility = None
+ _created_at: datetime = None
+ _updated_at: datetime = None
+
+ @property
+ def id(self) -> int:
+ return self._id or 0
+
+ @id.setter
+ def id(self, value: int):
+ self._id = value
+
+ @property
+ def repos(self) -> list:
+ return self._repos
+
+ @repos.setter
+ def repos(self, value: list):
+ self._repos = value
+
+ @property
+ def category(self) -> SecretVariableCategory:
+ return self._category
+
+ @category.setter
+ def category(self, value: SecretVariableCategory):
+ self._category = value
+
+ @property
+ def type(self) -> SecretVariableType:
+ return self._type
+
+ @type.setter
+ def type(self, value: SecretVariableType):
+ self._type = value
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @name.setter
+ def name(self, value: str):
+ self._name = value
+
+ @property
+ def value(self) -> str:
+ return self._value or ''
+
+ @value.setter
+ def value(self, value: str):
+ self._value = value
+
+ @property
+ def visibility(self) -> SecretVariableVisibility:
+ return self._visibility
+
+ @visibility.setter
+ def visibility(self, value: SecretVariableVisibility):
+ self._visibility = value
+
+ @property
+ def created_at(self) -> datetime:
+ return self._created_at
+
+ @created_at.setter
+ def created_at(self, value: datetime | str):
+ if isinstance(value, str):
+ value = datetime.fromisoformat(value)
+ self._created_at = value
+
+ @property
+ def updated_at(self) -> datetime:
+ return self._updated_at
+
+ @updated_at.setter
+ def updated_at(self, value: datetime | str):
+ if isinstance(value, str):
+ value = datetime.fromisoformat(value)
+ self._updated_at = value
diff --git a/src/libs/constants.py b/src/libs/constants.py
index 6061e73..15fe060 100644
--- a/src/libs/constants.py
+++ b/src/libs/constants.py
@@ -60,4 +60,23 @@ class VariableMappingType(IntEnum):
class VariableMappingGroupType(IntEnum):
ENV = 1
# INPUTS = 2
- # OUTPUTS = 3
\ No newline at end of file
+ # OUTPUTS = 3
+
+class SecretVariableCategory(IntEnum):
+ NONE = 0
+ ACTIONS = 1
+ AGENTS = 2
+ CODESPACES = 3
+ DEPENDABOT = 4
+ ENVIRONMENTS = 5
+
+class SecretVariableType(IntEnum):
+ NONE = 0
+ SECRET = 1
+ VARIABLE = 2
+
+class SecretVariableVisibility(IntEnum):
+ NONE = 0
+ ALL = 1
+ PRIVATE = 2
+ SELECTED = 3
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index 9e81811..f1ae9e8 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -47,6 +47,8 @@ def client(logger):
@pytest.fixture
def database():
db_file = os.path.join(tempfile.gettempdir(), 'tests.db')
+ if os.path.isfile(db_file):
+ os.unlink(db_file)
database = Database(db_file)
yield database
|