-
-
Notifications
You must be signed in to change notification settings - Fork 1
Safety and Policy
The policy layer defends against four classes of accident: writes to dangerous tables that store credentials or key material, leakage of secret-shaped field values through read responses, runaway queries against large platform tables, and untrusted script paths escaping their allowed root. It does NOT defend against a malicious ServiceNow admin who has already configured their instance to expose sensitive data through normal APIs, nor against a malicious operator who holds SERVICENOW_ENV=dev credentials and deliberately points them at a production instance.
Eight tables are unconditionally blocked from all access - read or write:
| Table | Rationale |
|---|---|
sys_user_has_password |
Password hashes |
oauth_credential |
OAuth tokens |
oauth_entity |
OAuth configuration secrets |
sys_certificate |
TLS certificates and private keys |
sys_ssh_key |
SSH key material |
sys_credentials |
Stored credentials |
discovery_credentials |
Discovery/scan credentials |
sys_user_token |
API tokens |
check_table_access(table) raises PolicyError when table.lower() appears in this set.
Six case-insensitive regex patterns (matched via re.search against field names) identify sensitive fields:
password
token
secret
credential
api_key
private_key
Matched values are replaced with the constant MASK_VALUE = "***MASKED***".
Two masking paths exist:
-
mask_record(table, record)dispatches on table name. For most tables it callsmask_sensitive_fields, which recurses into nested dicts and lists replacing values whose keys match a sensitive pattern. - For
sys_auditrows,mask_audit_entry(entry)handles the indirect structure: the sensitive field name lives in thefieldname/fieldkey, while actual data lives inoldvalue/newvalue/old_value/new_value. Standard field masking would miss these because the dict keys are generic.
enforce_query_safety(table, query, limit, settings) applies three constraints in order:
- Denied-table check via
check_table_access. - Limit capping - the effective limit is floored at 1 and capped at
settings.max_row_limit(default 100, configurable 1-10000). - Large-table guard - tables in
large_table_names(default:syslog,sys_audit,sys_log_transaction,sys_email_log) must carry a date-bounded filter on a recognized date field. Without one,QuerySafetyErroris raised.
Returns {"limit": effective_limit}.
INTERNAL_QUERY_LIMIT = 1000 caps internal metadata fetches (schema lookups, choice loading) that are not user-facing.
validate_identifier(name) enforces:
^[a-z0-9_]+(\.[a-z0-9_]+)*$
This permits dot-walked references like change_request.number while rejecting SQL injection fragments, encoded-query operators smuggled as names, path traversal, and uppercase characters. Every table and field name passes through this validator before reaching the HTTP client.
Write control is function-based. There is no WriteGatingError exception class.
Four public functions:
-
write_blocked_reason(table, settings) -> str | None- checks in order: (1) denied table, (2)settings.is_production. Returns a human-readable reason orNone. -
can_write(table, settings, override=False) -> bool-override=Truebypasses all checks; otherwise returnswrite_blocked_reason(...) is None. -
write_gate(table, settings, correlation_id) -> str | None- callswrite_blocked_reason; returns a serialized error envelope if blocked,Noneif allowed. -
production_write_blocked(settings, correlation_id) -> str | None- environment-level gate for operations where the target table is unknown pre-fetch (e.g. attachment deletion must fetch metadata to learn the owning table).
The combined entry point gate_write(table, settings, correlation_id) chains validate_identifier then check_table_access then write_gate and never raises - it returns an error envelope string on any failure.
In production (servicenow_env equals prod or production), ALL writes are blocked regardless of table. The write_blocked_reason function returns "Write operations are blocked in production environments" whenever settings.is_production is true, and this check fires after the denied-table check but applies universally.
When record_write accepts a local file via script_path:
-
script_allowed_rootis resolved and confirmed as a directory before any user path is touched. - The user path is resolved and must satisfy four conditions: it exists, it is a regular file, it resides within the allowed root, and it does not escape via symlinks.
- All four user-path rejections collapse to one opaque error:
"script_path is not readable or is outside the allowed root". This prevents path-existence probing. - Configuration errors (root unset, root not accessible, root not a directory) and the size error remain verbose.
- Maximum file size:
MAX_SCRIPT_FILE_BYTES = 1_048_576(1 MB). - The outer payload cap
MAX_PAYLOAD_BYTES = 1 MiBis enforced on thedataparameter in_validate_action_argsbefore any token creation;parse_payload_jsonenforces a tighter 256 KiB cap downstream as defence in depth. - When the resolved target field has
internal_type == "xml", content is validated as well-formed XML viaxml.etree.ElementTree.fromstringbefore the platform call. Malformed content yields a structured error.
DictionaryRegistry resolves which fields on a table carry executable script or markup content. The algorithm:
- Walks
sys_db_object.super_classchild-first, bounded at depth 8, with a cycle guard. - Fetches active
sys_dictionaryrows for each table in the chain. - Admits a field when its
internal_typeis inUNAMBIGUOUS_SCRIPT_TYPES:
script, script_plain, script_server, script_client,
email_script, html_script, html_template, css
-
For ambiguous types (
html,xml), admits the field only when_parse_attributesyields an exact token-boundary match fortinymce_allow_all=trueorhtml_sanitize=false. The parser splits on commas, partitions on the first=, and lowercases both sides. Substring matches likemy_tinymce_allow_all=truedo not admit. -
Drops anything in
EXCLUDED_ELEMENTS:
translated_html, template_value, glide_var, json, conditions,
condition_string, glide_action_list, variable_conditions,
snapshot_template_value, variable_template_value
Exclusion takes precedence over type check.
- It does not emulate ServiceNow's ACL engine. If the platform returns a field to the configured user, the server shows it (unless the field name matches a sensitive pattern).
- It does not perform row-level filtering beyond denied tables. The platform's own ACLs remain the final authority on record visibility.
- It does not inspect field values for secrets - masking operates on field names only (except the
sys_auditvalue-masking path where the field name is stored indirectly). - It does not rate-limit requests. A caller can issue many queries in rapid succession.
- It does not validate that the ServiceNow instance URL actually points to the environment declared in
SERVICENOW_ENV. An operator who setsdevwhile targeting a production instance bypasses the production write gate.
See also: Architecture for where policy checks fire in the request flow, Tool-Reference for per-tool write-gate behavior.