-
Notifications
You must be signed in to change notification settings - Fork 2
Module Development Guide
This guide reflects the current package layout and helper APIs. It captures the
module contract and the shared runtime you should build on. For the canonical,
in-repo version of these invariants see
CLAUDE.md and
CONTRIBUTING.md.
The installable package is nested at ocinferno/ocinferno/ (the repo root holds
packaging/docs; the inner directory is the ocinferno package). Module code lives
under ocinferno/modules/ in three areas:
-
<service>/{enumeration,exploit,utilities}/— the per-service modules (one directory per OCI service, e.g.dns/,objectstorage/,identityclient/). Categories areEnumeration(live reads),Process(operate on saved data), andExploit(offensive write actions, currently underidentityclient/exploit/). The enumeration file is a thin CLI wrapper; the real OCI API logic lives inutilities/helpers.pyas Resource classes. -
everything/— cross-cutting orchestrators:enum_all(cross-service enumeration) andprocess_config_check(the saved-data configuration audit engine, backed byutilities/config_audit.py). -
opengraph/— the OpenGraph / BloodHound generator (process_oracle_cloud_hound_data). This is OCI-specific — do not force gcpwn parity here.
Modules are referred to by their dotted path following the folder layout, e.g.
ocinferno.modules.dns.enumeration.enum_dns. The authoritative module list lives in
ocinferno/mappings/module_mappings.json; the service-table schema lives in
ocinferno/mappings/database_info.json.
Every runnable module exposes a single entrypoint:
def run_module(user_args, session):
...
return {"ok": True, "components": [ ... ]}-
user_argsis alist[str](argparse-style). Parse it with the helpers incore/utils/service_runtime.py(parse_wrapper_args,resolve_selected_components) rather than hand-rolling a newArgumentParser. -
sessionis theSessionUtility. Its data API is:-
session.save_resources(rows, table_name)— persist a list of row dicts. This is a single bulkexecutemanywith one commit for the whole batch (unioning columns across rows so a field present on only some rows isn't dropped), so prefer passing the full list once over calling it row-by-row. Inside aDataController.transaction(...)it defers the commit to the enclosing transaction. -
session.get_resource_fields(table, where_conditions=..., columns=...)— read saved rows back. -
session.compartment_id— the compartment the current invocation is scoped to.
-
- The dispatcher wraps success/failure in an
OperationResult, so the shape of the return value is not strictly enforced — but return the{"ok": ..., "components": [...]}dict for consistency.
Runner vs. module flags. The runner-level flags —
--cids,--current-cid,--all-cids,--proxy,--get,--download,-v/--debug— are consumed incli/module_actions.pyand not forwarded verbatim; everything else on the line is module passthrough.--get/--downloadare re-surfaced to modules that opt into them. There is no--save/--no-saveflag — enumeration always persists to the DB as it runs.
A standard service enumerator is two small pieces:
-
A thin
enum_<service>.pythat declares itsCOMPONENTS/CACHE_TABLESand drives them through the shared runtime. -
Resource classes in
utilities/helpers.pythat carryTABLE_NAME,COLUMNS, andlist()/get()/save()(and optionallydownload()).
Build the OCI client with the shared _init_client (it applies the workspace
signer, proxy, and session behavior consistently), then implement list/get/save:
import oci
from ocinferno.core.utils.service_runtime import _init_client, ResourceBase
def build_dns_client(session, region=None):
client = _init_client(oci.dns.DnsClient, session=session, service_name="DNS")
target_region = region or getattr(session, "region", None)
if target_region:
client.base_client.set_region(target_region)
return client
class DnsZonesResource(ResourceBase):
TABLE_NAME = "dns_zones" # table name, registered in mappings/database_info.json
COLUMNS = ["id", "name", "scope", "zone_type", "lifecycle_state", "time_created"]
def __init__(self, session, region=None):
self.session = session
self.client = build_dns_client(session=session, region=region)
def list(self, *, compartment_id):
resp = oci.pagination.list_call_get_all_results(
self.client.list_zones, compartment_id=compartment_id, scope="GLOBAL"
)
return oci.util.to_dict(resp.data) or []
def get(self, *, resource_id):
return oci.util.to_dict(self.client.get_zone(zone_name_or_id=resource_id).data) or {}
# save() is inherited from ResourceBase (self.session.save_resources(rows, self.TABLE_NAME))
# -- only override it if this resource needs custom save behavior.Use oci.pagination.list_call_get_all_results for LIST calls and
oci.util.to_dict(...) to normalize SDK models into plain dicts before saving.
Extend ResourceBase (core/utils/service_runtime.py) rather than hand-rolling
save() yourself -- it's the one-line save_resources(rows, TABLE_NAME) call every
Resource class needs, plus a default no-op download() that services with binary
artifacts override.
Declare COMPONENTS as (key, method_suffix, help_text) tuples and a
CACHE_TABLES map, parse with parse_wrapper_args, resolve which components were
selected with resolve_selected_components, then run each selected component:
from ocinferno.core.console import UtilityTools
from ocinferno.core.utils.module_helpers import fill_missing_fields
from ocinferno.core.utils.service_runtime import (
append_cached_component_counts, parse_wrapper_args, resolve_selected_components,
)
from ocinferno.modules.dns.utilities.helpers import DnsZonesResource
COMPONENTS = [
("zones", "zones", "Enumerate zones"),
]
CACHE_TABLES = {
"zones": ("dns_zones", "compartment_id"),
}
def run_module(user_args, session):
args, _ = parse_wrapper_args(
user_args=user_args, description="Enumerate DNS resources", components=COMPONENTS,
)
component_order = [key for key, _s, _h in COMPONENTS]
selected = resolve_selected_components(args, component_order)
compartment_id = session.compartment_id
results = []
zones = DnsZonesResource(session=session)
if selected.get("zones"):
rows = [r for r in (zones.list(compartment_id=compartment_id) or []) if isinstance(r, dict)]
for row in rows:
row.setdefault("compartment_id", compartment_id)
if args.get:
for row in rows:
fill_missing_fields(row, zones.get(resource_id=row["id"]) or {})
if rows:
UtilityTools.print_limited_table(rows, zones.COLUMNS)
zones.save(rows) # enumeration always persists -- there is no --save opt-in flag
results.append({"ok": True, "zones": len(rows), "saved": True})
append_cached_component_counts(
results=results, session=session, selected=selected,
component_order=component_order, cache_tables=CACHE_TABLES,
)
return {"ok": True, "components": results}Notes on the shared runtime (core/utils/service_runtime.py):
-
parse_wrapper_args(...)builds a--<component>flag per component plus the standard--get(and--download/--download-timeoutwheninclude_download=True), a--wideflag (print all columns instead of the concise default subset), and returns(args, remainder). There is no--save/--no-saveflag: enumeration always persists to the DB as it runs (gcpwn parity, no opt-in step). -
resolve_selected_components(args, keys)returns{key: bool}— if the user named no component flags, all components run; otherwise only the named ones. -
append_cached_component_counts(...)reports counts for components the user did not select from their cached tables, and hints the exactmodules run ...command to backfill missing prerequisite data. -
run_standard_enum_component(...)/ServiceEnumOpsBasepackage the list→get→print→save flow so a thin dispatcher can delegate instead of repeating the loop. ~18 modules now use it (grepServiceEnumOpsBaseundermodules/for current examples). The remaining hand-rolled modules mostly do so for a reason (content downloads, custom multi-scope listing, non-standard--getshape); migrating one is a deliberate, test-validated refactor, not a drop-in. - A newer, more declarative alternative also exists:
core/utils/enum_framework.py'sComponent/run_components/component_arg_specs(seemodules/iot/enumeration/enum_iot.pyormodules/lockbox/enumeration/enum_lockbox.pyfor real examples). It expresses each component as aComponent(key, resource_cls, help_text=..., cache_table=...)entry and letsrun_components(...)drive the whole list→get→print→save loop, including nested child-resource components (e.g. one resource fanned out per parent ID). Prefer this pattern for new modules with several related components; the hand-rolledCOMPONENTStuple style shown above is still fine for a single- or two-component module.
Two registries must agree, plus module policy:
-
ocinferno/mappings/module_mappings.json— add a module entry:Without this,{ "module_name": "enum_dns", "module_category": "Enumeration", "info_blurb": "Enumerate DNS resources", "author": "Your Name (@handle)", "location": "ocinferno.modules.dns.enumeration.enum_dns", "service": "DNS" }modules list/search/runbehavior is incomplete. -
ocinferno/mappings/database_info.json— add each new resource table under the top-leveltablesarray withtable_name,columns(matching your Resource'sCOLUMNSpluscompartment_id, tag fields, and any derived columns), andprimary_keys(typically["id"]). Aworkspace_idcolumn and the workspace-scoping index are added automatically by the schema builder — do not declare them yourself. -
Module policy — behavior (run-once vs per-compartment, auth requirements) is
decided by
MODULE_POLICY_REGISTRYincli/module_actions.py. The default (DEFAULT_MODULE_POLICY) isExecMode.PER_TARGET(the module runs once per selected compartment), which is what a standard service enumerator wants. Only add an entry for a module that must beExecMode.ONCE— e.g.enum_allandprocess_oracle_cloud_hound_datarun a single time with a chosen context compartment rather than fanning out.
-
One unified SQLite DB, workspace-scoped. All tables (workspaces / credentials /
user-permissions plus every enumerated resource table, built from
database_info.json) live in a single file,ocinferno/databases/ocinferno.db.metadata_db/service_dbare just aliases to that one file, kept for API back-compat. Every service table carries aworkspace_idFK (added automatically) — never query service tables without it. The session data API handles this for you. -
Schema and registry are JSON. Tables come from
database_info.json; modules frommodule_mappings.json. There is no YAML config — PyYAML is not a runtime dependency (the only YAML parsing is a lazy, optional fallback for OpenAPI specs in the API Gateway module). -
Minimal required dependencies. Required runtime deps are
oci,requests,prettytable, andoci-lexer-parser. The Excel-export stack (pandas,xlsxwriter) is the optional[excel]extra and is lazily imported — keep it that way so a base install stays lean. -
Errors go through the result contract. Use
OperationResult.from_exception/UtilityTools.dlog(seecore/contracts.pyandcore/console.py) for normalized error handling rather than re-implementing per-module status/code extraction. -
OpenGraph is OCI-specific. The
modules/opengraph/logic (policy lexing viaoci-lexer-parser, dynamic-group matching, identity-domain graph building) is intentionally different from GCPwn's OpenGraph. Leave the graph-building logic alone unless explicitly asked.
- Output goes through
core.console.UtilityTools(colors,print_limited_table,dlog, OCID condensing). Don't hand-roll ANSI codes. - Normalize SDK models to dicts with
oci.util.to_dict(...)before saving. - Surface the offensively interesting field (a policy statement, an attached dynamic group, a writable bucket, an authorized key) in the saved columns.
- Keep non-OCI runtime dependencies minimal (eases enterprise install approval).
python -m pytest -q -ra tests/unit # primary suite (run in CI)
ruff check . # lint (BLOCKING in CI)
mypy # type-check core (informational)Install dev tooling with pip install .[dev]. Excel-related tests skip unless the
optional extra is installed (pip install .[excel]). Additional suites live under
tests/integration/ and tests/enum_modules/.
Home | Getting Started | Auth | Workspace | Orchestration Modules | Downloads to Disk | Data View/Export | Operator Runbook | Troubleshooting and FAQ | OpenGraph - Node/Edge Tables | OpenGraph - Default Priv Escalation Mode | OpenGraph - IAM Conditionals | OpenGraph - Inheritance & IncludeAll | ConfigChecker - Static Config Checks | Module Development Guide
- Authentication Reference
- Workspace Instructions
- Orchestration Module Reference
- Enumeration Module Reference
- Exploit Module Reference
- Downloads to Disk
- Data View/Export
- Troubleshooting and FAQ