-
Notifications
You must be signed in to change notification settings - Fork 28
Module Development Guide
This guide reflects the current package layout and helper APIs.
gcpwn/modules/ is split into four top-level areas:
-
gcpwn/modules/gcp/<service>/...— the GCP service modules (one dir per service:cloudscheduler,cloudworkflows,spanner,alloydb,orgpolicy,assetinventory,eventarc,workstations,billing,cloudshell,logging,memorystore, etc.). -
gcpwn/modules/workspace/...— Google Workspace modules (organized by API:cloud_identity/,directory/, with shared infra inworkspace/common.py). -
gcpwn/modules/everything/...— cross-cutting modules that span services:enum_all,enum_gcp,enum_google_workspace,enum_gcp_policy_bindings,exploit_gcp_setiampolicy,process_gcp_iam_bindings,analyze_vulns. -
gcpwn/modules/opengraph/...— the BloodHound OpenGraph pipeline.
Within a service dir:
- Runtime modules:
gcpwn/modules/gcp/<service>/<category>/... - Service helpers:
gcpwn/modules/gcp/<service>/utilities/helpers.py - Static service data:
gcpwn/modules/gcp/<service>/utilities/data/* - Module index metadata:
gcpwn/mappings/module_mappings.json
Namespace packages. Modules are now namespace packages — there is only a single
__init__.pyatgcpwn/__init__.py. Do not add empty__init__.pyfiles undermodules/. Refer to modules by their dotted path, e.g.gcpwn.modules.gcp.cloudscheduler.enumeration.enum_cloudscheduler.
The authoritative module list is gcpwn/mappings/module_mappings.json.
- Add module file with
run_module(user_args, session)entrypoint under the correct area (gcp/<service>/enumeration/...for a new service). - Use the declarative
run_componentspattern (below) for a standard list/get/testIamPermissions enumerator; fall back to in-module argparse (allow_abbrev=False) only for genuinely bespoke modules. - Use shared helpers from
gcpwn/core/utils/service_runtime.pywhen possible. - Put the real API logic in
utilities/helpers.pyas aGcpListResourcesubclass; keep the module file a thin CLI wrapper. - Store discovered rows via the resource's
save()(which callssession.insert_data(...)); record permissions/actions via the shared IAM/action helpers used byrun_components. - Register the module in
gcpwn/mappings/module_mappings.jsonand its table(s) ingcpwn/mappings/database_info.json. - If it should run under
enum_all, wire it intogcpwn/modules/everything/enumeration/enum_all.py(see below). - Add tests (at least focused unit coverage where feasible).
A standard service enumerator is now two small pieces:
-
A thin
enum_<svc>.pythat declares a list ofComponents and callsrun_components. -
A
GcpListResourcesubclass inutilities/helpers.pythat declares the table/columns/permissions and implements a couple of SDK hooks.
GcpListResource (gcpwn/core/resource.py) implements the shared
list / get / test_iam_permissions / save bodies once. A subclass just
declares config as class attributes and implements _build_client. Use
gcp/cloudscheduler as the canonical template.
from google.cloud import scheduler_v1
from gcpwn.core.resource import GcpListResource
from gcpwn.core.utils.module_helpers import (
extract_path_segment,
resolve_regions_from_module_data,
)
def resolve_locations(session, args) -> list[str]:
return resolve_regions_from_module_data(
session, args, service="cloudscheduler", discovery=("cloudscheduler", "v1")
)
class CloudSchedulerJobsResource(GcpListResource):
SERVICE_LABEL = "Cloud Scheduler" # used in "API disabled" messages
TABLE_NAME = "cloudscheduler_jobs" # workspace-scoped DB table
COLUMNS = ["location", "job_id", "name", "state", "schedule",
"time_zone", "target_type", "target_uri", "target_sa_email"]
ACTION_RESOURCE_TYPE = "jobs"
LIST_PERMISSION = "cloudscheduler.jobs.list" # recorded as direct_api evidence on success
GET_PERMISSION = "cloudscheduler.jobs.get"
ID_FIELD = "job_id" # column for the short path tail
def _build_client(self, session):
return scheduler_v1.CloudSchedulerClient(credentials=session.credentials)
def _list_items(self, parent, **_):
return self.client.list_jobs(request=scheduler_v1.ListJobsRequest(parent=parent))
def _get_item(self, resource_id, **_):
return self.client.get_job(request=scheduler_v1.GetJobRequest(name=resource_id))
def _extra_save_fields(self, raw):
# derive extra columns from the raw resource (the offensively interesting bits)
return {"job_id": extract_path_segment(str(raw.get("name", "") or ""), "jobs"), ...}Key config attributes (full set documented in the GcpListResource docstring):
-
SERVICE_LABEL,TABLE_NAME,COLUMNS,ACTION_RESOURCE_TYPE,ID_FIELD. -
LIST_PERMISSION/GET_PERMISSION— recorded asdirect_apievidence on a successful list/get. -
TEST_IAM_API_NAME/TEST_IAM_PERMISSIONS— thetestIamPermissionsprobe (recorded astest_iam_permissionsevidence — distinct provenance). -
Parent-build mode:
PARENT_FROM_PROJECT_LOCATION(default →projects/<p>/locations/<loc>),PARENT_FROM_PROJECT(→projects/<p>, no location, e.g. pubsub/bigtable), or neither set (caller passesparent=).
Hooks you typically override:
-
_build_client(session)— required; build the SDK client fromsession.credentials. -
_list_items(parent, **kwargs)/_get_item(resource_id, **kwargs)— override for clients that takerequest={...}/Request(...)objects or wrap responses (the default uses plainparent=/name=kwargs). -
_normalize_row(row)— post-process eachresource_to_dictrow. -
_extra_save_fields(raw)— derive extra columns to write alongsideID_FIELD(this is where you surface the offensively interesting fields, e.g. the SA a scheduler job impersonates).
DB writes are main-thread only.
save()writes to the single-threadedDataController. Never call it from aparallel_mapworker —run_componentsalready collects on the main thread.
from gcpwn.core.utils.enum_framework import (
REGION, Component, build_extra_args, component_args, run_components,
)
from gcpwn.core.utils.service_runtime import parse_component_args
from gcpwn.modules.gcp.cloudscheduler.utilities.helpers import (
CloudSchedulerJobsResource, resolve_locations,
)
COMPONENTS = [
Component("jobs", CloudSchedulerJobsResource, "Cloud Scheduler Jobs", "Jobs",
help_text="Enumerate Cloud Scheduler jobs", scope=REGION,
supports_iam=False,
manual_id_arg="job_ids",
manual_template=("projects", "{project_id}", "locations", 0, "jobs", 1),
manual_help="Job IDs as LOCATION/JOB_ID or full projects/.../jobs/... names."),
]
def run_module(user_args, session):
args = _parse_args(user_args)
run_components(
session, args, components=COMPONENTS,
column_name="cloudscheduler_actions_allowed",
region_resolver=resolve_locations, module_name="enum_cloudscheduler",
)
return 1Each Component (gcpwn/core/utils/enum_framework.py) maps a CLI flag
(--<key>) to a GcpListResource subclass plus knobs:
-
scope:REGION(fan outlistacross regions),PROJECT(single global list), orNESTED(list under every parent component's rows — e.g. Spanner databases under instances; setparent_key). -
supports_iam=False— service has no per-resourcetestIamPermissions. -
persist=False— list/summarize only, neversave()(resource has no table). -
summarize=False— module renders its own summary. -
manual_id_arg— enables--<arg>/--<arg>-fileto target specific resources by name (hydrated viaget(), so requires--get).
run_components handles the project/region fan-out, --get, --iam,
per-row enrich_fn, saving, the summary, and flushing the three action
accumulators once. Spanner (gcp/spanner) is a good --iam + NESTED
(instances → databases) reference.
For modules that don't fit the list/get pattern, the contract is unchanged:
def run_module(user_args, session):
parser = argparse.ArgumentParser(allow_abbrev=False, ...)
# add flags
args = parser.parse_args(user_args)
# enumerate/list/get/testIamPermissions/download
# write rows via session.insert_data(...)
return 0 # truthy/0 == success; -1 == failureThree registries (plus optional enum_all wiring) must agree:
-
gcpwn/mappings/module_mappings.json— add the service block with itsEnumerationcategory entry:module_name,info_blurb, and the dottedlocation(gcpwn.modules.gcp.<svc>.enumeration.enum_<svc>). Without this,modules list/info/runbehavior is incomplete. -
gcpwn/mappings/database_info.json— add atable_nameentry under the database spec'stablesarray withcolumns(matching your resource'sCOLUMNSplusproject_id,raw_json, and any derived fields) andprimary_keys(typically["project_id", "name"]). Aworkspace_idcolumn + aworkspaces(id)FK (ON DELETE CASCADE) and a(workspace_id, …)index are added automatically at table-build time — don't declare them yourself. -
Tests — the contract tests (
tests/module_contracts/) assert every mappedlocationexists and definesrun_module.
gcpwn/modules/everything/enumeration/enum_all.py drives both a parallel
orchestrator and a sequential path off one declarative _SERVICES
ServiceSpec table. Adding a service touches four spots — all must get the
new --flag:
-
_SERVICES— add aServiceSpecrow:ServiceSpec(("cloud_scheduler",), "gcpwn.modules.gcp.cloudscheduler.enumeration.enum_cloudscheduler", threads=True, regions=True),
-
gate_flags— theargsattribute(s) that select this service (the--flagwith dashes→underscores). -
threads/regions/zones/iam/get— which standard args are forwarded. Useget=Falsefor non-region custom modules that have no--get(e.g.cloud_billing,cloud_shell,cloud_logging). -
opt_in=True— the service never runs under a bareenum_all; it runs only when its gate flag is passed. Cloud Asset Inventory (--asset-inventory) is the example: bareenum_allskips it,enum_all --asset-inventoryruns everything plus it (additive).
-
-
_PARALLEL_SERVICES— add("cloud_scheduler", "--cloud-scheduler")(the gate-key attr → its canonical flag name). This is now the single source of truth: the sequential parser'sset_defaults,_ALL_GATE_KEYS, the derivedevery_flag_missing, the--modulestoken map, and the parallel orchestrator all read from it. You do not add aparser.add_argumentor edit a hand-maintainedevery_flag_missinglist anymore — a service added here runs under a bareenum_alland is selectable as--modules cloud-schedulerautomatically. (Setopt_in=Trueon itsServiceSpecto keep it OUT of the bare-run sweep, like Cloud Asset Inventory.) -
(Optional)
_MODULE_TOKEN_ALIASES— add a friendly short token (e.g."scheduler": "cloud_scheduler") if the fullcloud-schedulername is clunky.
Also keep the human-label map (e.g. "enum_cloudscheduler": "Cloud Scheduler")
in sync so progress output and --list-modules are readable.
- Prefer existing helper utilities over one-off implementations.
- Keep service API error handling consistent (
403 denied,404, API disabled, generic errors) viahandle_service_error/handle_discovery_error— theGcpListResourcebodies already route through these. - Keep CLI output concise and table-aware; go through
gcpwn.core.console.UtilityToolsrather than hand-rolling ANSI. - Respect workspace/project context and project selectors.
- Surface the offensively interesting field (the SA a job/trigger/workflow
impersonates, a writer SA, an authorized SSH key) in
_extra_save_fields.
Interactive module listing and info use:
gcpwn/mappings/module_mappings.json
If you add a module but do not add this mapping, modules list/info/run
behavior will be incomplete.
Run current suites:
python -m pytest -q -ra tests/unit tests/module_contractsRelevant areas:
tests/unit/core/tests/unit/opengraph/tests/unit/everything/tests/module_contracts/
If adding new dangerous-path logic:
- Update rule definitions (
og_privilege_escalation_paths.json) - Update permission map coverage (
og_permission_to_roles_map.json) - Add/adjust OpenGraph unit tests:
- rule variants
- combo path emission
- coverage warnings and skip logic
- Authentication Reference
- Workspace Instructions
- Google Workspace Enumeration
- CLI Module Reference
- Enumeration Module Reference
- Exploit Module Reference
- Downloads to Disk
- Logging & Verbosity
- Data View/Export
- IAM Enumeration and Analysis Workflow
- Troubleshooting and FAQ