Custom controls¶
Any rule that does not map to a published CIS control can be added as a custom control. The engine and YAML schema are identical — the only difference is the custom: citation block instead of cis:.
Minimal example¶
controls:
- control_id: CUSTOM-NODUAL-PRIVS
title: "No active user may hold both SPECIAL and OPERATIONS simultaneously"
severity: high
custom:
benchmark: "Internal policy"
category: "Privileged access"
reference: "Security Standard SEC-4.2"
data_sources_needed: [irrdbu00]
implementation:
engine: python
dataset: irrdbu00.users
select_columns: [USBD_NAME, USBD_SPECIAL, USBD_OPER]
logic: |
hits = df[
(df['USBD_SPECIAL'] == 'YES') &
(df['USBD_OPER'] == 'YES') &
(df['USBD_REVOKE'] != 'YES')
]
if hits.empty:
status = 'PASS'
detail = "No active user holds both SPECIAL and OPERATIONS"
else:
status = 'FAIL'
detail = f"{len(hits)} user(s) hold both SPECIAL and OPERATIONS"
findings = hits[['USBD_NAME', 'USBD_SPECIAL', 'USBD_OPER']].to_dict('records')
remediation: >
For each identified userid, remove either SPECIAL or OPERATIONS using:
ALTUSER <userid> NOSPECIAL or ALTUSER <userid> NOOPER
custom: citation block¶
custom:
benchmark: "Internal policy" # or a regulation name, e.g. "PCI-DSS v4.0"
category: "Privileged access" # section label shown in the report
reference: "SEC-4.2" # your internal policy reference (optional)
The custom: block replaces the cis: block. All other fields are identical.
pandas_query example¶
When the test is a single filter, pandas_query is simpler:
- control_id: CUSTOM-REVOKED-SPECIALS
title: "Ensure no revoked users hold SPECIAL attribute"
severity: medium
custom:
benchmark: "Internal cleanup"
category: "User management"
data_sources_needed: [irrdbu00]
implementation:
engine: pandas_query
dataset: irrdbu00.users
select_columns: [USBD_NAME, USBD_SPECIAL, USBD_REVOKE]
filter: "USBD_SPECIAL == 'YES' and USBD_REVOKE == 'YES'"
assertion:
type: no_rows
pass_message: "No revoked users hold SPECIAL attribute"
fail_message: "Revoked user(s) still hold SPECIAL attribute"
remediation: >
Remove SPECIAL from revoked accounts: ALTUSER <userid> NOSPECIAL
Naming conventions¶
- Prefix:
CUSTOM-followed by a short descriptive slug, e.g.CUSTOM-NO-DUAL-PRIVS - IDs must be unique across all loaded control files
- Keep IDs stable — they appear in the CSV and report; changing them breaks trend comparisons
Storing custom controls¶
Put custom controls in a separate file (e.g. example_controls.yaml) and pass it alongside the standard libraries:
mfaudit --controls controls.yaml example_controls.yaml --out out/
Real-world example — example_controls.yaml¶
The repository ships with example_controls.yaml containing a ready-to-run shop-floor control. It demonstrates several advanced patterns: cross-DataFrame access analysis, prefix-based filtering, vectorised group-membership joins, sorted findings with a VIA_GROUP column, and a split direct/indirect detail count.
What it checks: Batch service accounts (user IDs starting with B) must never gain access to customer data profiles (dataset profiles starting with C) — either directly (B-user on the access list itself) or indirectly (a group is on the access list and that group contains B-prefixed members). Any such access is a policy violation that needs a business justification.
controls:
- control_id: CUSTOM-AC-1
title: "B-prefixed users must not access C-prefixed dataset profiles (direct or via group)"
severity: high
custom:
benchmark: "Internal Access Control Policy"
category: "Access Control"
reference: "IAC-DS-001" # (1)
data_sources_needed: [irrdbu00]
implementation:
engine: python
dataset: irrdbu00.datasetAccess # (2)
select_columns: [DSACC_NAME, DSACC_AUTH_ID, DSACC_ACCESS, VIA_GROUP]
assertion:
type: python_result
pass_message: "No B-prefixed users access C-prefixed dataset profiles (direct or via group)"
fail_message: "B-user(s) found with access to C-dataset profile(s)"
logic: |
# All access list entries for C-prefixed dataset profiles
c_access = df[df['DSACC_NAME'].str.upper().str.startswith('C')].copy() # (3)
# --- Direct hits: B-prefixed user IDs on the access list ---
direct = c_access[
c_access['DSACC_AUTH_ID'].str.upper().str.startswith('B')
][['DSACC_NAME', 'DSACC_AUTH_ID', 'DSACC_ACCESS']].copy()
direct['VIA_GROUP'] = '' # (4)
# --- Indirect hits: groups on the access list that contain B-prefixed members ---
potential_groups = c_access[
~c_access['DSACC_AUTH_ID'].str.upper().str.startswith('B')
]
connect = irrdbu00.connectData # (5)
b_members = connect[connect['USCON_NAME'].str.upper().str.startswith('B')]
indirect = potential_groups.merge( # (6)
b_members[['USCON_GRP_ID', 'USCON_NAME']],
left_on='DSACC_AUTH_ID',
right_on='USCON_GRP_ID',
how='inner'
)
indirect = indirect[['DSACC_NAME', 'USCON_NAME', 'DSACC_ACCESS', 'DSACC_AUTH_ID']].copy()
indirect.columns = ['DSACC_NAME', 'DSACC_AUTH_ID', 'DSACC_ACCESS', 'VIA_GROUP']
# Combine, sort, emit
all_df = pd.concat([direct, indirect], ignore_index=True) # (7)
all_df = all_df.sort_values(['DSACC_NAME', 'VIA_GROUP', 'DSACC_AUTH_ID'])
count = len(all_df)
direct_count = len(direct)
group_count = len(indirect)
status = 'PASS' if count == 0 else 'FAIL'
detail = (
f"{count} B-user access(es) to C-dataset profiles "
f"({direct_count} direct, {group_count} via group)"
)
findings = all_df.to_dict('records')
remediation: >
For each finding, remove the permit:
PERMIT '<dataset-profile>' ID(<userid-or-group>) DELETE
If the finding is via a group, either remove the B-user from the group
or remove the group from the dataset profile access list.
If access is genuinely required, raise a business justification and
re-assign it to a non-B-prefixed identity.
What each part does¶
(1) custom: citation block with reference
The reference field is optional but useful — it ties the control to your internal policy document. It appears in the report so auditors can trace the finding back to the requirement without leaving the report.
(2) dataset: irrdbu00.datasetAccess
irrdbu00.datasetAccess (type-0410 records) contains one row per access list entry — who has what level of access to which dataset profile. The key columns here are DSACC_NAME (profile name), DSACC_AUTH_ID (the user or group granted access), and DSACC_ACCESS (the access level: READ, UPDATE, ALTER, etc.).
The select_columns list includes VIA_GROUP even though that column does not exist in the raw DataFrame — it is added in the logic block. select_columns controls which columns appear in the report findings table, so listing it here ensures the column is visible to reviewers.
(3) Pre-filter on c_access
The very first step narrows the working set to C-prefixed dataset profiles. Every subsequent operation works on this smaller slice, which keeps the merge in step (6) fast on large RACF databases.
(4) VIA_GROUP sentinel for direct hits
Direct findings have an empty VIA_GROUP column (set to ''). This keeps the schema consistent between the direct and indirect DataFrames so they can be concatenated cleanly. In the report a blank VIA_GROUP cell makes it immediately clear the B-user was on the access list themselves.
(5) irrdbu00.connectData — group membership
irrdbu00.connectData (type-0205 records) contains one row per user-group connection. The two relevant columns are USCON_NAME (the user ID) and USCON_GRP_ID (the group). Filtering this to rows where USCON_NAME starts with B gives every B-prefixed user and the groups they belong to.
The irrdbu00 object is available directly in python engine logic — see the Controls overview for the full list of injected variables.
(6) Vectorised merge instead of a loop
potential_groups.merge(...) performs a pandas inner join: it keeps only those access list entries where the DSACC_AUTH_ID (the group on the access list) matches a group that has at least one B-prefixed member (USCON_GRP_ID). The result has one row per (dataset profile, B-user, access level, group) combination.
After the merge, the columns are renamed so the B-user's ID lands in DSACC_AUTH_ID and the group through which they gained access lands in VIA_GROUP. This makes both sets of findings follow the same column layout.
Merging vectorially is orders of magnitude faster than iterating with a Python for loop, which matters when the RACF database has tens of thousands of users and groups.
(7) pd.concat and split counts
pd.concat([direct, indirect]) stacks both finding sets into one DataFrame. The detail string is built from three separate counts — total, direct, and via-group — so the report summary line tells reviewers at a glance how many of the violations are straightforward permits vs. group-membership paths that may require more remediation steps.