Fence Sensitive ServiceNow Tables
Fences off the most sensitive ServiceNow tables from two routes that reach them:
- Direction
- ingress
- Rego package
servicenow.ingress.fence_sensitive_tables- App
- servicenow
- Bundles
- soc2hipaapci-dssgdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:9eca0d4f6f3221fc2cc8c6081fb12deec811764c612ad86d883c7f2134071193
servicenowfence-sensitive-tablespiiingresssoc2hipaapci-dssgdpr-ccpa
What this policy does
Direction: ingress (tool_pre_invoke)
Default: deny on a sensitive, ungrouped table; allow otherwise
Package: servicenow.ingress.fence_sensitive_tables
What it does
Fences off the most sensitive ServiceNow tables from two routes that reach them:
The generic Table-API tools from the michaelbuckner server —
perform_query,search_records,get_record, andnatural_language_search. These take a table name argument and reach any table the underlying credential can read (sys_user,sn_hr_core_*,cmdb_ci*, custom PII/payroll tables), not just incidents. The policy denies the call when the requested table is on the sensitive list unless the caller's IdP groups include the table's owner group.The fixed-vocabulary user-directory reads from the echelon-ai-labs server —
list_usersandget_user. These are the named route to the samesys_userPII (names, emails, phones, manager chains) that a genericperform_queryonsys_userwould return, so they are gated behind the same groups. Closing the generic route while leaving the named route open would be a trivial bypass.
The sensitive-table mapping is:
| Table (case-insensitive) | Owner group(s) that may read it |
|---|---|
sys_user (exact) |
hr or infosec |
sn_hr_core_* (prefix — HRSD case tables: health/leave/comp) |
hr |
cmdb_ci* (prefix — CMDB configuration items) |
infosec |
A caller in hr may read HR case tables and sys_user; a caller in
infosec may read CMDB and sys_user; neither may read the other's tables.
list_users/get_user require hr or infosec (they surface
sys_user).
The check runs at ingress, before the call reaches the ServiceNow MCP server, so a denied read never executes and no sensitive row is returned.
Fail-closed on an uninspectable query
A generic Table-API call whose table argument is missing or empty is
denied, not allowed. Without a table name the query cannot be scoped, so
there is no way to prove it does not touch a sensitive table — the safe
default is to reject it and ask the caller to name the table explicitly.
Pin the sensitive-table list to YOUR instance at import time
The shipped sensitive_table_groups / sensitive_prefix_groups constants
are a documented starter set covering the standard high-risk tables
(sys_user, sn_hr_core_*, cmdb_ci*). They are not a complete inventory
of your sensitive data. At import time, extend the constants with the
custom PII, payroll, and regulated tables your instance holds (e.g.
u_payroll_*, u_ssn_vault, sn_hr_core_* siblings, finance tables) and
map each to the group that owns it.
Compliance alignment
- SOC 2 C1.1 — supports identifying and protecting confidential
information by keeping sensitive tables (workforce, CMDB) behind owner
groups; P4.1 — supports limiting personal-information use to the
identified purpose by restricting who may pull
sys_userPII over the agent channel. - HIPAA §164.502(b) / §164.514(d) — supports the minimum-necessary and
role-based-limit standards by gating HRSD case tables
(
sn_hr_core_*, which can hold health/leave data) to thehrgroup; §164.308(a)(4) — supports information-access management by tying table access to IdP group membership; §164.522(a) — supports agreed-to access restrictions as an enforceable predicate on the MCP path. - PCI DSS 7.2.6 — supports restricting programmatic query access to stored data by role: the generic Table-API tools are exactly the programmatic-query surface, and this policy binds them to owner groups.
- GDPR Art. 9 — supports restricting access to special-category data
(HR/health tables) on the agent channel; §1798.121 (CPRA) — supports
the right to limit use of sensitive personal information by fencing
sys_userand HR tables; Art. 5(1)(b) — supports purpose limitation by denying broad, unscoped table reads.
Tool name matching
The DTwo gateway prefixes tool names with the configured MCP server name
(e.g. servicenow-mcp-perform_query), and that prefix is not standardized,
so the policy matches by suffix on lower(input.resource.name):
- Generic Table-API (any table arg):
*perform_query,*search_records,*get_record,*natural_language_search - User directory:
*list_users,*get_user
Suffix matching covers the two big community servers (echelon-ai-labs and
michaelbuckner both use verb_noun snake_case). If you run a server that uses
a different convention (e.g. LokiMCPUniverse's noun_verb), add its suffixes.
Verify the exact names your gateway sends with the dump-input debug technique
before relying on this in production.
Argument shape
- Generic Table-API tools read the target table from
input.payload.args.table(verified for the michaelbucknerperform_query/search_records/get_recordREADME shape). The value is lowercased and whitespace-trimmed (trim_space) before matching, so a"sys_user\n"/"sys_user "variant that ServiceNow might still resolve cannot slip past the exact/prefix match. Payload/args are read throughobject.getchains, so a call with nopayloador noargsat all is treated as a missing-table call and fails closed for the generic tools. - Caller groups are read from
input.subject.claims.groups(an array), lowercased before comparison.
Examples
Allowed — non-sensitive table
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "servicenow-mcp-perform_query", "type": "tool" },
"payload": {
"name": "servicenow-mcp-perform_query",
"args": { "table": "incident", "query": "active=true" }
}
}
}
allow = true, no reason.
Allowed — sensitive table, caller in owner group
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "servicenow-mcp-get_record", "type": "tool" },
"subject": { "claims": { "groups": ["infosec"] } },
"payload": {
"name": "servicenow-mcp-get_record",
"args": { "table": "cmdb_ci_server", "sys_id": "abc123" }
}
}
}
allow = true — infosec owns CMDB.
Denied — sensitive table, no matching group
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "servicenow-mcp-perform_query", "type": "tool" },
"subject": { "claims": { "groups": ["service-desk"] } },
"payload": {
"name": "servicenow-mcp-perform_query",
"args": { "table": "sys_user", "query": "active=true" }
}
}
}
allow = false, reason = "Access to the ServiceNow table 'sys_user' is restricted to the hr, infosec group(s). Narrow your query to a non-sensitive table, or request membership in one of those groups."
Denied — user-directory read without a group
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "servicenow-mcp-list_users", "type": "tool" },
"subject": { "claims": { "groups": [] } },
"payload": { "name": "servicenow-mcp-list_users", "args": { "limit": 50 } }
}
}
allow = false — list_users surfaces sys_user PII.
Composition
This policy is single-purpose (scope fencing). Useful companions on the same ServiceNow gateway:
servicenow/default-deny-unknown-tools— allowlist the audited tool surface so a renamed/new table tool cannot slip past this suffix match.- An egress PII-redaction policy on
list_incidents/get_record/search_records/list_usersresponses, so anysys_user-shaped PII that leaks through a permitted-but-broad read is masked for non-HR/security callers (defense in depth against this policy's residual bypasses).
Known limitations
natural_language_searchargument shape is unverified. The michaelbuckner README documents the tool but not its argument keys. This policy treats it as a generic Table-API tool that reads atableargument; if it does not carry atableargument it fails closed (denied) under the missing-table rule, because an NL query whose target table cannot be read is uninspectable and cannot be proven safe. Confirm the real argument shape with the dump-input technique; if the tool exposes the table under a different key (or infers it server-side), updatetable_arg/ the missing-table handling accordingly. As shipped,natural_language_searchis effectively blocked unless it carries an inspectabletableargument.- Group names are placeholders — replace
hrandinfosecwith your IdP's group names at import time. They are read frominput.subject.claims.groups; if your IdP emits groups under a different claim (e.g. a namespacedhttps://acme.com/groups) or a non-array shape, adjustcaller_groups. If the gateway has no IdP configured,groupsis absent and every sensitive read fails closed. - Sensitive list is not exhaustive. Only
sys_user,sn_hr_core_*, andcmdb_ci*ship by default. Custom PII/payroll tables (u_*) are not fenced until you add them to the constants — see "Pin the sensitive-table list" above. sys_useris matched exactly, so its siblingsys_user_*tables are not fenced. The mapping keyssys_useras an exact name (not a prefix), which is deliberate — the crown-jewel contact PII (names, emails, phones, manager chains) lives insys_useritself. But the standardsys_user_*family holds related workforce identity and access data that some instances treat as equally sensitive:sys_user_group,sys_user_grmember(group membership — ACL reconnaissance),sys_user_role,sys_user_has_role, andsys_user_preference. These are not on the sensitive list, so an ungrouped caller can read them throughperform_query/get_record/search_records. If your instance treats the family as sensitive, add"sys_user"tosensitive_prefix_groups(mapped to{"hr", "infosec"}) to fence the whole family, or list specific siblings insensitive_table_groups. Prefixingsys_userwill also fence benign lookups some service-desk workflows rely on (e.g. readingsys_user_groupfor ticket routing), so choose per your instance. (Red-team confirmed:perform_queryonsys_user_grmemberby an ungrouped caller is allowed as shipped.)- Row-level scoping is coarse. The policy fences by table name, not by
row. A caller in
hrwho may readsys_usercan read all of it; use an egress redaction policy if you need per-field or per-row limits. - Dot-walk / field-selection exfiltration through a permitted table is not
caught. A
perform_query/get_recordon an allowed table (e.g.incident) can pull referencedsys_userfields with a dot-walked query or field list (sysparm_fields=caller_id.email,caller_id.phone,caller_id.user_name=…). The policy fences on the target table name only; it cannot see PII columns reached by reference. This is the primary residual — pair the egress PII-redaction companion below so anysys_user-shaped data that comes back through a permitted read is masked for non-HR/security callers. (Red-team confirmed:table:"incident"with acaller_id.emailfield selection is allowed.) - The michaelbuckner server also exposes tables as MCP resources
(
servicenow://tables/{table}), not just tools. This policy matches tool suffixes ontool_pre_invoke; a resource read ofservicenow://tables/sys_userdoes not end with a guarded suffix and passes through. If your gateway proxies MCP resource reads through policy, add a companion rule that fences resource URIs, or disable the resource surface on the server. (Red-team confirmed: aservicenow://tables/sys_userresource read is allowed by this policy.) - Other reach paths exist. ServiceNow PII is also reachable through
incident
description/commentbodies, HRSD Now Assist skills on the official server, and Knowledge Graph queries — none of which take atableargument. This policy only fences the generic Table-API and the named user-directory reads; compose the egress redaction companion for the rest.
Compliance note. This policy supports alignment with the cited framework controls on the MCP path only. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.
Policy source (Rego)
package servicenow.ingress.fence_sensitive_tables
# Deny-by-default: only the explicit allow rules below permit the request.
default allow := false
# --- Tool surfaces we guard (matched by suffix for gateway-prefix portability) ---
# Generic Table-API tools (michaelbuckner). Each takes a `table` argument and
# can reach ANY table the credential can read.
generic_table_tool_suffixes := {
"perform_query",
"search_records",
"get_record",
"natural_language_search",
}
# Fixed-vocabulary user-directory reads (echelon-ai-labs). These surface the
# same sys_user PII, so they are gated behind the same groups as sys_user.
user_directory_tool_suffixes := {
"list_users",
"get_user",
}
# --- Sensitive-table mapping (documented starter set; tenants EXTEND these) ---
# Exact sensitive table names -> the set of groups permitted to read them.
sensitive_table_groups := {
"sys_user": {"hr", "infosec"},
}
# Sensitive table-name PREFIXES -> the set of groups permitted to read them.
# `sn_hr_core_` = HRSD case tables (health/leave/comp); `cmdb_ci` = CMDB items.
sensitive_prefix_groups := {
"sn_hr_core_": {"hr"},
"cmdb_ci": {"infosec"},
}
# Groups permitted to use the named user-directory reads (they surface sys_user).
user_directory_groups := {"hr", "infosec"}
# --- Tool classification ---
is_generic_table_tool if {
some suffix in generic_table_tool_suffixes
endswith(lower(input.resource.name), suffix)
}
is_user_directory_tool if {
some suffix in user_directory_tool_suffixes
endswith(lower(input.resource.name), suffix)
}
# --- Identity: caller's IdP groups, lowercased. Missing claims -> empty set. ---
claims := object.get(object.get(input, "subject", {}), "claims", {})
caller_groups := {lower(g) | some g in object.get(claims, "groups", [])}
# --- Requested table (case-insensitive, whitespace-trimmed) ---
# Safe args access: missing payload/args -> {} (no direct index of input.payload.args).
args := object.get(object.get(input, "payload", {}), "args", {})
# trim_space closes a whitespace-evasion bypass: "sys_user\n" / "sys_user "
# would otherwise miss the exact/prefix match yet may still resolve server-side.
table_arg := trim_space(lower(object.get(args, "table", "")))
table_arg_present if {
table_arg != ""
}
# The union of every owner-group set that matches the requested table
# (exact match on sys_user, or prefix match on sn_hr_core_/cmdb_ci).
table_required_groups := union(matched_group_sets)
matched_group_sets := {groups |
some name, groups in sensitive_table_groups
name == table_arg
} | {groups |
some prefix, groups in sensitive_prefix_groups
startswith(table_arg, prefix)
}
table_is_sensitive if {
count(table_required_groups) > 0
}
caller_authorized_for_table if {
some g in table_required_groups
caller_groups[g]
}
caller_authorized_for_directory if {
some g in user_directory_groups
caller_groups[g]
}
# --- Allow rules ---
# Any tool we don't guard passes through untouched.
allow if {
not is_generic_table_tool
not is_user_directory_tool
}
# Generic Table-API call on a non-sensitive table (with a table named).
allow if {
is_generic_table_tool
table_arg_present
not table_is_sensitive
}
# Generic Table-API call on a sensitive table by an owner-group member.
allow if {
is_generic_table_tool
table_arg_present
table_is_sensitive
caller_authorized_for_table
}
# Named user-directory read by an hr/infosec member.
allow if {
is_user_directory_tool
caller_authorized_for_directory
}
# --- Deny reasons ---
# Fail closed: generic tool with no table argument cannot be scoped.
reasons contains sprintf("The ServiceNow tool '%s' was called without a 'table' argument, so the query cannot be scoped to a non-sensitive table. Name the specific table you are authorized to read.", [lower(input.resource.name)]) if {
is_generic_table_tool
not table_arg_present
}
# Sensitive table, caller lacks the owner group.
reasons contains sprintf("Access to the ServiceNow table '%s' is restricted to the %s group(s). Narrow your query to a non-sensitive table, or request membership in one of those groups.", [table_arg, concat(", ", sort([g | some g in table_required_groups]))]) if {
is_generic_table_tool
table_arg_present
table_is_sensitive
not caller_authorized_for_table
}
# Named user-directory read, caller lacks hr/infosec.
reasons contains "Access to the ServiceNow user directory (the sys_user table: names, emails, phones, manager chains) is restricted to the hr or infosec group. Narrow your query to a non-sensitive lookup, or request membership in one of those groups." if {
is_user_directory_tool
not caller_authorized_for_directory
}
reason := joined if {
count(reasons) > 0
reason_list := sort([r | some r in reasons])
joined := concat("; ", reason_list)
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)
Related policies
Airtable: Redact PII in Record Reads
Scans the responses of the Airtable record-read tools — the calls that return row fields values — and rewrites high-confidence PII shapes to a fixed…
Asana: Redact PII in Task & Comment Reads
On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — notes,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
Block BigQuery Exfiltration and Cross-Project Writes
Inspects the raw GoogleSQL string carried by BigQuery SQL tools and denies any statement that moves data out of the tenant's own project — even when the call…
bigqueryguard-warehouse-exportingresssqlexfiltrationsoc2pci-dssgdpr-ccpa
Block Bulk Export & External Staging (Snowflake)
Blocks Snowflake SQL-execution tool calls whose query text moves whole tables off the Snowflake perimeter — bulk export to cloud storage or a stage, and…
snowflakeguard-warehouse-sqlexportexfiltrationingresssoc2pci-dssgdpr-ccpa