Hard examples — python engine

Use the python engine when the check cannot be expressed as a single filter:

  • The verdict depends on data from more than one DataFrame
  • You need conditional branching (if X then check Y, else check Z)
  • Some findings require a human review rather than an automatic PASS/FAIL
  • A data source might not be present and the control should SKIP gracefully

How the python engine works

Your YAML has a logic: key containing a block of Python source code. The engine runs that code in a controlled environment with a set of pre-loaded variables (DataFrames, the full IRRDBU00/SETROPTS objects, and the pandas module). When the code finishes, the engine reads three output variables you must set:

Variable Required What it does
status Yes The verdict: 'PASS', 'FAIL', 'REVIEW', or 'SKIP'
detail No A one-line summary shown under the verdict in the report
findings No A list of dicts — each dict becomes a row in the findings table

If logic exits without setting status, the control is marked ERROR and the traceback appears on the console.

The assertion: block with type: python_result is documentation-only. The engine ignores it at runtime. It exists so someone reading the YAML can understand the intent without running the code. Always include it — it makes the YAML self-describing.

assertion:
  type: python_result
  pass_message: "What PASS means in plain English"
  fail_message: "What FAIL means in plain English"

Example 1 — All STARTED profiles must assign PROTECTED userids

What it checks: Every profile in the RACF STARTED class assigns a userid to a started task. If that userid is not defined with NOPASSWORD (the PROTECTED flag), it could theoretically be logged into interactively — a serious security gap.

This check requires two DataFrames: irrdbu00.generalSTDATA (the STARTED profiles) and irrdbu00.users (to look up the NOPASSWORD flag for each assigned userid). That cross-DataFrame join is why pandas_query cannot do it.

controls:
  - control_id: CUSTOM-STC-PROTECTED
    title: "All STARTED class profiles must assign PROTECTED userids"
    severity: high
    custom:
      benchmark: "Internal policy"
      category:  "Started tasks"
    data_sources_needed: [irrdbu00]
    implementation:
      engine: python
      dataset: "irrdbu00.generalSTDATA + irrdbu00.users"    # (1)
      select_columns:
        - GRST_CLASS_NAME
        - GRST_NAME
        - GRST_USER_ID
      assertion:                                             # (2)
        type: python_result
        pass_message: "All STARTED profiles assign PROTECTED userids"
        fail_message:  "One or more STARTED profiles assign non-PROTECTED userids"
      logic: |
        # (3) Narrow to STARTED class profiles only
        started = df[df['GRST_CLASS_NAME'] == 'STARTED']    # (4)

        bad = []
        for _, row in started.iterrows():                    # (5)
            uid = row['GRST_USER_ID']

            # (6) Skip special values that are not real userids
            if str(uid).strip().upper() in ('', 'NAN', '**MEMBER**'):
                continue

            # (7) Look up the userid in the users DataFrame
            user_row = users_df[users_df['USBD_NAME'] == uid]

            if user_row.empty:
                # Userid referenced in STARTED profile does not exist in RACF
                bad.append({
                    'GRST_NAME':    row['GRST_NAME'],
                    'GRST_USER_ID': uid,
                    'ISSUE':        'Userid not defined in RACF',
                })
            elif user_row['USBD_NOPWD'].values[0] != 'YES':   # (8)
                bad.append({
                    'GRST_NAME':    row['GRST_NAME'],
                    'GRST_USER_ID': uid,
                    'ISSUE':        'Not PROTECTED (NOPASSWORD not set)',
                })

        if bad:
            status  = 'FAIL'
            detail  = f"{len(bad)} STARTED profile(s) assign non-PROTECTED userid(s)"
            findings = bad                                     # (9)
        else:
            status = 'PASS'
            detail = "All STARTED profiles assign PROTECTED userids"
    remediation: >
      For each listed userid, issue:
        ALTUSER <userid> NOPASSWORD
      If the userid does not exist, define it and assign it NOPASSWORD:
        ADDUSER <userid> NOPASSWORD
      Then verify the STARTED profile still points to the correct userid.

Line-by-line explanation

(1) dataset: "irrdbu00.generalSTDATA + irrdbu00.users" The + syntax tells the engine to load two DataFrames. Each component gets a fixed variable name in logic:

  • irrdbu00.generalSTDATAdf (because it's not one of the named shortcuts)
  • irrdbu00.usersusers_df

See the combined dataset reference for the full variable-name table.

(2) assertion: type: python_result Pure documentation. The engine reads status from your logic block — these messages are never executed. Write them so a colleague can understand the control without reading the Python.

(3) Narrowing to STARTED profiles irrdbu00.generalSTDATA contains STDATA segments for ALL general resource classes, not just STARTED. Always filter to the class you care about before iterating.

(4) df[df['GRST_CLASS_NAME'] == 'STARTED'] Standard pandas boolean indexing. The result is a new DataFrame containing only rows where the class name is 'STARTED'. The original df is not modified.

(5) for _, row in started.iterrows() Iterates row by row. The _ discards the integer index (we don't need it). row is a pandas Series — access columns with row['COLUMN_NAME'].

When to iterate vs. vectorise

For a check like this — where you need to look up each row's value in a second DataFrame — row-by-row iteration is the clearest approach. Vectorised merges are faster but harder to read and debug. For audit controls, clarity beats speed.

(6) Skipping non-userid values STARTED profiles can have special userid values that are not real RACF userids (e.g. blank, **MEMBER**). Always guard against these to avoid false positives. str(uid).strip().upper() normalises the value before comparison; the .upper() is defensive since casing should be consistent but may vary in edge cases.

(7) users_df[users_df['USBD_NAME'] == uid] Looks up the userid in the users DataFrame. The result is a (possibly empty) DataFrame. We check .empty before accessing .values[0] to avoid an IndexError.

(8) user_row['USBD_NOPWD'].values[0] .values extracts the underlying NumPy array; [0] takes the first element. This is the standard idiom for reading a single value from a one-row DataFrame. The column USBD_NOPWD is 'YES' when the userid has NOPASSWORD set (i.e. is PROTECTED).

(9) findings = bad findings must be a list of dicts. Each dict becomes one row in the report's findings table. The keys become column headers. Build a custom dict (as we do here) rather than dumping the whole row with row.to_dict() when you want clean, readable column names or want to add an ISSUE column.


Example 2 — No UID=0 user unless they are PROTECTED

What it checks: Unix System Services (USS) UID 0 is the superuser equivalent on z/OS. Any RACF userid with UID 0 must also have NOPASSWORD set — otherwise it could be used to log in interactively as root.

  - control_id: CUSTOM-UID0-PROTECTED
    title: "All UID=0 accounts must be PROTECTED (NOPASSWORD)"
    severity: high
    custom:
      benchmark: "Internal policy"
      category:  "Unix System Services"
    data_sources_needed: [irrdbu00]
    implementation:
      engine: python
      dataset: "irrdbu00.userOMVS + irrdbu00.users"
      select_columns: [USOMVS_NAME, USOMVS_UID, USBD_NOPWD]
      assertion:
        type: python_result
        pass_message: "All UID=0 accounts are PROTECTED"
        fail_message:  "One or more UID=0 accounts are not PROTECTED"
      logic: |
        # (1) All userids that have a USS OMVS segment with UID=0
        uid0 = omvs_df[omvs_df['USOMVS_UID'] == 0]     # (2)

        if uid0.empty:
            status = 'PASS'
            detail = "No UID=0 accounts defined"
        else:
            bad = []
            for _, row in uid0.iterrows():
                name = row['USOMVS_NAME']
                user = users_df[users_df['USBD_NAME'] == name]
                if user.empty:
                    bad.append({
                        'USOMVS_NAME': name,
                        'USOMVS_UID':  0,
                        'ISSUE':       'Base userid not found in RACF',
                    })
                elif user['USBD_NOPWD'].values[0] != 'YES':
                    bad.append({
                        'USOMVS_NAME': name,
                        'USOMVS_UID':  0,
                        'USBD_NOPWD':  user['USBD_NOPWD'].values[0],
                        'ISSUE':       'Not PROTECTED',
                    })

            if bad:
                status   = 'FAIL'
                detail   = f"{len(bad)} UID=0 account(s) are not PROTECTED"
                findings = bad
            else:
                status = 'PASS'
                detail = f"All {len(uid0)} UID=0 account(s) are PROTECTED"
    remediation: >
      For each listed userid:
        ALTUSER <userid> NOPASSWORD

Key points

(1) dataset: "irrdbu00.userOMVS + irrdbu00.users" irrdbu00.userOMVSomvs_df; irrdbu00.usersusers_df. The naming convention is described in the python engine docs.

(2) omvs_df[omvs_df['USOMVS_UID'] == 0] USOMVS_UID is an integer column in mfpandas, so we compare with 0 not '0'. If you are unsure of a column's type, check with print(df.dtypes) in a test script before writing the control.

Notice the early exit: if there are no UID=0 accounts at all, we return PASS immediately and skip the loop entirely. This is good practice — don't iterate over an empty DataFrame.


Example 3 — Critical classes must be both ACTIVE and RACLIST'd (with REVIEW)

What it checks: Certain RACF resource classes must be not just activated (CLASSACT) but also memory-resident (RACLIST) for performance and security reasons. A class that is active but not RACLIST'd still works, but is a configuration gap worth flagging for review.

This example introduces the REVIEW verdict: something that is not automatically a failure but that a human must inspect.

  - control_id: CUSTOM-CRITICAL-CLASSES
    title: "Critical RACF classes must be ACTIVE and RACLIST'd"
    severity: medium
    custom:
      benchmark: "Internal policy"
      category:  "Class activation"
    data_sources_needed: [setropts]
    implementation:
      engine: python
      dataset: setropts.classInfo
      select_columns: [name, CLASSACT, RACLIST]
      assertion:
        type: python_result
        pass_message:  "All critical classes are ACTIVE and RACLIST'd"
        fail_message:  "One or more critical classes are not ACTIVE"
      logic: |
        # (1) The set of classes your site considers critical
        REQUIRED_CLASSES = [
            'FACILITY', 'OPERCMDS', 'TSOAUTH',
            'STARTED',  'DATASET',  'UNIXPRIV',
        ]

        fail_list   = []   # class is not active at all → definite FAIL
        review_list = []   # class is active but not RACLIST'd → needs review

        for cls in REQUIRED_CLASSES:
            row = df[df['name'] == cls]            # (2)

            if row.empty:
                fail_list.append({'name': cls, 'CLASSACT': 'NOT FOUND', 'RACLIST': 'NOT FOUND'})
                continue

            classact = row['CLASSACT'].values[0]
            raclist  = row['RACLIST'].values[0]

            if classact != 'YES':
                fail_list.append({'name': cls, 'CLASSACT': classact, 'RACLIST': raclist})
            elif raclist != 'YES':                 # (3)
                review_list.append({'name': cls, 'CLASSACT': classact, 'RACLIST': raclist})

        # (4) Decide the overall verdict
        if fail_list:
            status   = 'FAIL'
            detail   = f"{len(fail_list)} critical class(es) not active"
            findings = fail_list + review_list
        elif review_list:
            status   = 'REVIEW'                   # (5)
            detail   = f"{len(review_list)} class(es) active but not RACLIST'd — verify"
            findings = review_list
        else:
            status = 'PASS'
            detail = "All critical classes are ACTIVE and RACLIST'd"
    remediation: >
      To activate a class and make it memory-resident:
        SETROPTS CLASSACT(<class>) RACLIST(<class>)
      After activating, always refresh:
        SETROPTS RACLIST(<class>) REFRESH

Key points

(1) Defining your own constants The logic block is ordinary Python. You can define constants, helper variables, loops — anything you need. The only constraint is that you must not import modules (only the injected variables are available). Constants defined at the top of the block make the logic easy to update without touching the control structure.

(2) df[df['name'] == cls] setropts.classInfo has a column called name (lowercase). RACF class names are stored in uppercase. The comparison is exact — 'facility' would not match.

(3) Two-tier severity Not active at all → definite FAIL. Active but not RACLIST'd → ambiguous — some sites deliberately choose not to RACLIST every class. We separate these into two lists and combine them into findings only if there are failures.

(4) Verdict priority If any class is outright inactive, the whole control is FAIL. Only if everything is active but some classes are not RACLIST'd do we downgrade to REVIEW. This ordering ensures the most severe condition always wins.

(5) status = 'REVIEW' Use REVIEW when the data is present but a human must make a judgement call. The report renders REVIEW differently from FAIL — it appears with a distinct colour and label so reviewers know it requires investigation, not just remediation.


Example 4 — DCOLLECT check with graceful SKIP

What it checks: Production datasets (those matching the PROD.** HLQ) should all have an SMS Management Class assigned. Datasets without one are not subject to automatic backup and expiry policies.

This check uses DCOLLECT data, which is optional. If --dcollect was not passed to mfaudit, the control must SKIP cleanly rather than crash.

  - control_id: CUSTOM-PROD-SMS-MGMT
    title: "All PROD.** datasets must have an SMS Management Class"
    severity: medium
    custom:
      benchmark: "Internal policy"
      category:  "Storage management"
    data_sources_needed: [dcollect]          # (1)
    implementation:
      engine: python
      dataset: dcollect.datasets             # (2)
      select_columns: [NAME, TYPE, MGMTCLAS]
      assertion:
        type: python_result
        pass_message: "All PROD.** datasets have an SMS Management Class"
        fail_message:  "One or more PROD.** datasets have no SMS Management Class"
      logic: |
        # (1) Always check for None before using dcollect
        if dcollect is None:                  # (3)
            status = 'SKIP'
            detail = "DCOLLECT data not provided — pass --dcollect to enable this check"
        else:
            ds = dcollect.datasets

            # (4) Filter to PROD.** datasets
            prod = ds[ds['NAME'].str.upper().str.startswith('PROD.')]

            if prod.empty:
                status = 'SKIP'
                detail = "No datasets matching PROD.** found in DCOLLECT"
            else:
                # (5) Missing Management Class appears as NaN or blank
                no_mgmt = prod[
                    prod['MGMTCLAS'].isna() |
                    (prod['MGMTCLAS'].str.strip() == '')
                ]

                if no_mgmt.empty:
                    status = 'PASS'
                    detail = f"All {len(prod)} PROD.** dataset(s) have an SMS Management Class"
                else:
                    status   = 'FAIL'
                    detail   = f"{len(no_mgmt)} PROD.** dataset(s) have no SMS Management Class"
                    findings = no_mgmt[['NAME', 'TYPE', 'MGMTCLAS']].to_dict('records')   # (6)
    remediation: >
      Assign an SMS Management Class to each listed dataset:
        ALTER '<dataset>' MGMTCLAS(<class>)
      Contact your SMS administrator to determine the correct Management Class
      for each dataset type.

Line-by-line explanation

(1) data_sources_needed: [dcollect] Declaring dcollect here tells the runner that this control needs DCOLLECT data. If --dcollect is not passed, the runner will set the control to SKIP automatically — but we also guard in logic (see below) for safety.

(2) dataset: dcollect.datasets When --dcollect is not provided, dcollect is None. The engine still sets df = None in this case — which is why we must check before using it.

(3) if dcollect is None: status = 'SKIP' This is the canonical SKIP guard. Always check dcollect is None at the top of any DCOLLECT-dependent logic block. It allows the control to be included in a shared controls file even on systems where DCOLLECT output is not available — it simply shows as SKIP in the report rather than ERROR.

(4) ds['NAME'].str.upper().str.startswith('PROD.') Dataset names in DCOLLECT are typically uppercase already, but .str.upper() is a defensive habit — it costs nothing and prevents false negatives from inconsistent casing. .str.startswith() matches any name beginning with PROD., which covers all levels of the hierarchy.

(5) Checking for missing SMS class DCOLLECT stores absent SMS attributes as NaN (pandas null). A dataset may also have a blank string if the field was exported but empty. The two-part condition .isna() | (... == '') handles both cases. Always use this pattern when checking optional string fields from DCOLLECT.

(6) no_mgmt[['NAME', 'TYPE', 'MGMTCLAS']].to_dict('records') .to_dict('records') converts a DataFrame to a list of dicts — one dict per row. Selecting only the columns you need with [['NAME', 'TYPE', 'MGMTCLAS']] keeps the findings table clean. The alternative — no_mgmt.to_dict('records') — dumps every column, which can be overwhelming for wide DataFrames.


Summary — choosing your verdict

Situation Verdict
Check passed, nothing to act on 'PASS'
Policy violated, clear remediation exists 'FAIL'
Data present but human judgement required 'REVIEW'
Required data source not available 'SKIP'

Avoid using 'REVIEW' as a fallback for checks that are actually well-defined. Reserve it for genuinely ambiguous situations — like TRUSTED profiles in STARTED — where the presence of the condition does not automatically mean a violation.