PR #5: ServiceNow MCP server - #15
Conversation
Adds the ServiceNow MCP server under solutions/ess-agent-kit/src/mcp/servicenow/. Local stdio MCP server that proxies REST API calls to the customer's ServiceNow tenant. SFI focus areas for this review: - OAuth2 token handling (refresh, expiry, storage) - REST request construction and URL handling - Network egress (only customer-provided ServiceNow endpoint) - Error/response handling (no leakage of customer ticket data) Reviewer: @GrahamMcMynn Tracker: #10
Graham McMynn (GrahamMcMynn)
left a comment
There was a problem hiding this comment.
Code review by ESS Code Review Agent (4 comments)
| continue | ||
| raise | ||
|
|
||
| raise Exception(f"Max retries exceeded: {last_error}") |
There was a problem hiding this comment.
Bug: 429 retry exhaustion gives misleading None error
When all retry attempts receive HTTP 429 responses, the loop falls through to raise Exception(f"Max retries exceeded: {last_error}"). But last_error is only assigned inside the except httpx.RequestError block — not in the 429 branch. So if every attempt is rate-limited, the error message will be "Max retries exceeded: None", which is misleading and hides the real cause.
Suggested fix: Set last_error in the 429 branch too:
if resp.status_code == 429:
last_error = Exception(
f"Rate limited (429), Retry-After={resp.headers.get('Retry-After')}"
)
wait = int(resp.headers.get("Retry-After", str(2**attempt)))
...There was a problem hiding this comment.
Fixed in 831d343. Set last_error inside the 429 branch with a Rate limited (429), Retry-After=... Exception so Max retries exceeded reports the actual cause when every attempt is rate-limited.
| data: JSON string of field name/value pairs | ||
| """ | ||
| client = get_client() | ||
| parsed = json.loads(data) |
There was a problem hiding this comment.
Error handling: Unhandled json.loads across multiple tools
json.loads(data) is called without error handling here, in update_record (line 125), log_copilot_summary (line 503), and call_api (line 722). Since these string parameters come from LLM tool calls, malformed JSON will produce an unhandled JSONDecodeError with a raw Python traceback instead of a user-friendly MCP error.
Suggested fix: Wrap each json.loads in a try/except, or extract a helper:
def _parse_json(raw: str, field_name: str) -> dict:
try:
return json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in '{field_name}': {e}") from eThere was a problem hiding this comment.
Fixed in 831d343. Added a _parse_json(raw, field_name) helper and wired it into create_record, update_record, log_copilot_summary, and call_api. Tracebacks no longer leak; tools return a friendly ValueError the LLM can self-correct from.
|
|
||
|
|
||
| @mcp.tool() | ||
| async def call_api(method: str, path: str, data: str = "") -> str: |
There was a problem hiding this comment.
Security: call_api is an unrestricted proxy to any ServiceNow REST endpoint
This tool allows calling any REST path on the ServiceNow instance with any HTTP method. Combined with set_system_property and delete_record, this gives the LLM the ability to call destructive internal APIs (e.g., disable authentication, drop records, invoke admin scripted REST APIs) — especially concerning given SFI is a stated focus area of this PR.
Additionally, method accepts any string with no validation.
Suggested fix (at least one of):
- Path allowlist — restrict to known safe prefixes (e.g.,
/api/now/table/,/api/now/stats/) - Method allowlist — only allow GET, POST, PATCH, DELETE
- Admin-API denylist — block known dangerous paths
- Remove
call_apientirely — the typed tools already cover the intended use cases
There was a problem hiding this comment.
Fixed in 831d343. Implemented A + B + C combined:
- Path allowlist:
call_apinow requires the path to start with one of/api/now/table/,/api/now/stats/,/api/now/import/,/api/now/attachment,/api/now/v1/table/,/api/now/v2/table/. Admin/scripting paths are blocked. - Method allowlist: only
GET,POST,PATCH,DELETE.PUT,OPTIONS,HEADand anything custom are rejected. - Documentation: docstring marks the tool as
EXPLORATION ONLYand points users to the typed tools (query_table,get_record, etc.) for supported scenarios.
| @mcp.tool() | ||
| async def register_oauth_application( | ||
| name: str, | ||
| client_id: str, |
There was a problem hiding this comment.
Security: OAuth/OIDC secrets passed as plaintext tool parameters
register_oauth_application and register_oidc_provider accept client_secret as a plain-text tool parameter. MCP tool invocations are typically logged (in VS Code output, MCP inspector, etc.), which means secrets will appear in plaintext in logs and in the LLM's context window. The PR description flags OAuth2 token handling as an SFI focus area.
Suggested fix: Read the secret from an environment variable or secure reference instead:
client_secret_env: str = "SERVICENOW_OAUTH_CLIENT_SECRET"
# then: secret = os.environ[client_secret_env]Alternatively, document that this tool is intended for initial setup only and that secrets will be visible in MCP logs.
There was a problem hiding this comment.
Fixed in 831d343. Implemented env var indirection (Option A):
register_oauth_applicationandregister_oidc_providernow acceptclient_secret_env_var: str(the NAME of an env var) instead ofclient_secret(the raw value).- New
_resolve_secret_from_env(env_var, field_name)helper reads the secret fromos.environat execution. - The secret never crosses the MCP tool boundary, so it doesn't appear in MCP logs or LLM context.
Note: 4 skill docs in PR #6 (step2-oauth2.md, step2-entra.md, step2-certificate.md, step2-graph.md) reference the old client_secret parameter shape. Flagged in the commit message; will be updated as a follow-up either in PR #6 or post-merge.
…i lockdown, OAuth/OIDC secret env-var indirection
… contract PR #15 changed register_oauth_application and register_oidc_provider to accept client_secret_env_var (env var NAME) instead of client_secret (raw value), keeping secrets out of MCP logs and LLM context. Update the four skill docs that call these tools: - step2-certificate.md, step2-entra.md, step2-graph.md: add a pre-step setting SERVICENOW_OIDC_CLIENT_SECRET_NOT_USED='not-used' (OIDC verification with Entra ID does not actually consume a client secret, but the ServiceNow record requires the field to be non-empty); pass that env var name to register_oidc_provider. - step2-oauth2.md: add a pre-step writing the freshly-generated CLIENT_SECRET to env var SERVICENOW_OAUTH_CLIENT_SECRET; pass that env var name to register_oauth_application.
|
Follow-up on the env-var contract change: the 4 skill docs in PR #6 that called the old
|
rename solutions/ess-agent-kit -> solutions/ess-maker-skills
John Nguyen (johnguy0)
left a comment
There was a problem hiding this comment.
Same author, same shape as PR #14, with most of the same defects plus two new CRITICAL ones specific to ServiceNow. The good things from PR #14 carry over (env-var creds, httpx.BasicAuth, retry with Retry-After, requirements.txt). What's different here is the size of the MCP tool surface (29 tools, including 9 admin/OAuth/system-property tools that I'd argue shouldn't exist as LLM-callable at all), and the ServiceNow encoded-query injection that doesn't have a Workday equivalent.
CRITICAL:
-
ServiceNow query injection across every search_ tool.* Lines 204, 313, 383, 421, 462, 502, 560, 634, 731. User-controlled input (
query,state,priority,assigned_to,category,name) is concatenated into encoded queries with^as the separator. The^and^ORoperators are not escaped. An LLM-injectedquery="x^OR1=1"returns every record in the table.state="1^ORnumberSTARTSWITHINC"returns every incident. This is the SQL-injection equivalent for ServiceNow, and it's wide open. Fix: build an encoded query via a structured builder that URL-encodes values and rejects raw^in untrusted input. Don't passencoded_queryas a free string when individual filter parts are caller-controlled. -
No HTTPS validation on
SERVICENOW_INSTANCE_URL(client.py:34). Combined withhttpx.BasicAuth, anhttp://URL sends username + password in cleartext. Validate explicitly:if not instance_url.lower().startswith("https://"): raise ValueError(...). Same shape as PR #14 comment, even more important here because there's no fallback to digest or SAML.
HIGH:
-
Same five hardening defects as PR #14, listed inline.
client.py:40nofollow_redirects=False;client.py:37credentials as plain instance attribute;client.py:83-87ServiceNow error message echoes customer data into LLM context;client.py:12httpx logger not silenced;client.py:93retry has no jitter. See inline comments. The fixes are the same as PR #14 and should be coordinated as one shared pattern across the two MCP servers. -
Path traversal via
tableandsys_idparameters.client.py:127, 135, 140, 145, 150all interpolate user-controlled strings into the URL path:f"/api/now/table/{table}/{sys_id}". If an LLM passessys_id="abc/sys_user/admin-id", the path becomes/api/now/table/incident/abc/sys_user/admin-idwhich is a different endpoint. httpx does not auto-escape path segments. Validate: bothtableandsys_idmust match^[a-zA-Z0-9_]+$(sys_ids are 32-char hex). Reject anything else. -
Admin tools should not exist as LLM-callable.
register_oauth_application(server.py:572),register_oidc_provider(server.py:644),set_system_property(server.py:741),delete_record(server.py:166). These are admin-equivalent operations. A prompt-injected LLM can register an attacker-controlled OAuth app with their own redirect_uri (token theft primitive), modify system properties to disable security controls, or delete arbitrary records. Even with the env-var-pass-by-name pattern forclient_secret_env_var(which is a clever design and I'll note that), the LLM still controls all the other fields including redirect_url. Move these out of the tool surface. If they're needed for the/connectskill, expose them as a script that the human runs explicitly, not as an LLM-callable tool. -
call_apiallowlist is structurally correct but data-permissive. Method and path prefix are allowlisted (server.py:781-808), and admin endpoints (/api/now/auth/*,/api/now/sys_*direct access) are blocked. Good. But the allowlist includes/api/now/table/, which means the LLM can stillDELETE /api/now/table/sys_user/<admin-sys-id>to remove a user account, orPATCH /api/now/table/sys_properties/<security-property-id>to modify a security setting. The allowlist gates the URL surface but not the tables those URLs target. Add a table-name denylist (sys_user, sys_properties, sys_security_, oauth_entity, sys_oidc_, etc.) for non-GET methods.
MEDIUM:
-
No write-operation confirmation.
create_record,update_record,delete_record,create_incident,resolve_incident,create_hr_case,register_oauth_application,register_oidc_provider,set_system_property. Same shape as PR #14'srequest_time_off. Same fix: split into preview/confirm tools, or strong tool-description warnings, or move out of the tool surface entirely (preferred for #5 above). -
Full ServiceNow responses returned to LLM via
_fmt(server.py:42-53). HR cases and incidents include comments, descriptions, and reference fields that can carry SSN/employee personal data. Field allowlisting per tool is the right call. -
No domain validation on
SERVICENOW_INSTANCE_URL. Pair with the HTTPS check in #2: validate hostname matches*.service-now.comor a customer-configured allowlist. Defense-in-depth against env tampering. -
_requestopens a NEW httpx client per request (client.py:54), inside the retry loop. Every retry creates a fresh connection, fresh TLS handshake, fresh DNS. Reuse a singleAsyncClientas an instance attribute, opened in__init__(or a context manager), and use it across all calls. Performance and correctness both improve.
PR-level:
-
PR description claims "OAuth2 token handling" as an SFI focus area, but
client.pyuses onlyhttpx.BasicAuth. There is no OAuth flow to ServiceNow in this code. The OAuth-related tools (register_oauth_application,register_oidc_provider, etc.) manage OAuth applications inside ServiceNow — they don't authenticate the MCP server to ServiceNow. The PR description is misleading about the auth model. -
Stale
solutions/ess-agent-kit/path in description (same as PRs #2, #3, #4). -
No
python-dotenvin requirements.txt (PR #14 has it). If.vscode/mcp.jsonprovides env vars via${input:...}, that's fine — confirm. If anyone expects.envfile support, this needs the dep.
Inline below.
| """ | ||
| parts = [] | ||
| if query: | ||
| parts.append(f"short_descriptionLIKE{query}") |
There was a problem hiding this comment.
CRITICAL — ServiceNow encoded-query injection.
query is concatenated into the filter as short_descriptionLIKE{query}. ServiceNow encoded queries use ^ as the AND separator and ^OR for OR. None of these are escaped.
LLM-injected payloads:
query="x^OR1=1"→short_descriptionLIKEx^OR1=1returns every recordquery="x^ORassigned_to=<other-user-sys-id>"exfiltrates incidents assigned to a different user
Same pattern at lines 313, 383, 421, 462, 502, 560, 634, 731. Every search_* tool is affected.
Fix: build the encoded query via a structured builder that rejects/escapes ^ and the operator keywords (^OR, ^NQ, ^EQ, ^LIKE, ^ENDSWITH, ^STARTSWITH, ^CONTAINS, ORDERBY, GROUPBY) when those characters appear in caller-supplied values:
import re
_FORBIDDEN_QUERY_CHARS = re.compile(r"[\^]")
def _safe_value(v: str) -> str:
if _FORBIDDEN_QUERY_CHARS.search(v):
raise ValueError(f"Invalid character in query value: {v!r}")
return vThen parts.append(f"short_descriptionLIKE{_safe_value(query)}") everywhere. State/priority/category/assigned_to should additionally be regex-validated to known formats (numeric for state/priority, sys_id format for assigned_to).
| "SERVICENOW_USERNAME and SERVICENOW_PASSWORD are required in env" | ||
| ) | ||
|
|
||
| self.base_url = instance_url.rstrip("/") |
There was a problem hiding this comment.
CRITICAL — no HTTPS validation; combined with Basic Auth, http://-misconfigured URL leaks credentials in cleartext.
Fix:
if not instance_url.lower().startswith("https://"):
raise ValueError("SERVICENOW_INSTANCE_URL must use HTTPS")
self.base_url = instance_url.rstrip("/")MEDIUM (paired) — add domain allowlist.
Validate the host matches *.service-now.com or a customer-configured allowlist. Without a domain check, an attacker who can write to env vars or .vscode/mcp.json can point the MCP server at their own host with a valid TLS cert and steal Basic Auth credentials.
from urllib.parse import urlparse
host = (urlparse(instance_url).hostname or "").lower()
if not host.endswith(".service-now.com"):
raise ValueError(f"SERVICENOW_INSTANCE_URL must point to *.service-now.com (got {host})")Accept an env-var override (SERVICENOW_ALLOWED_HOSTS) for customers on custom domains.
| self.base_url = instance_url.rstrip("/") | ||
| self.max_retries = 3 | ||
| self.timeout = 30.0 | ||
| self._auth = httpx.BasicAuth(username, password) |
There was a problem hiding this comment.
HIGH — same defect as PR #14: credentials as plain instance attribute.
self._auth = httpx.BasicAuth(username, password) — httpx.BasicAuth stores both as plain attributes. repr(client) or any traceback that includes the client object exposes them.
Fix: scrub via __repr__:
def __repr__(self):
return f"ServiceNowClient(base_url={self.base_url!r})"See my comment on PR #14 client.py:78 for the same pattern.
| self._auth = httpx.BasicAuth(username, password) | ||
|
|
||
| def _build_client(self) -> httpx.AsyncClient: | ||
| return httpx.AsyncClient( |
There was a problem hiding this comment.
HIGH — three fixes in one place.
-
follow_redirects=False. httpx 0.27+ defaults to True. Combined with the BasicAuth sticking on the client, a 302 to an attacker host replays Authorization to the redirect target. Same defect as PR PR #4: Workday MCP server #14. -
verify=Trueexplicit. Defense-in-depth againstREQUESTS_CA_BUNDLEenv tampering. Same as PR PR #4: Workday MCP server #14. -
Open the client once, reuse across all calls. Currently
_requestbuilds a freshAsyncClientper call (line 54), which means every retry burns a new TLS handshake. Move client construction to__init__(or to anasync __aenter__/__aexit__) and reuse it.
Combined fix:
def __init__(self):
...
self._client = httpx.AsyncClient(
base_url=self.base_url,
auth=self._auth,
headers={"Accept": "application/json", "Content-Type": "application/json"},
timeout=self.timeout,
follow_redirects=False,
verify=True,
)
async def aclose(self):
await self._client.aclose()Then _request uses self._client.request(method, path, ...) directly. Caller (server.get_client()) becomes responsible for closing on shutdown.
|
|
||
| import httpx | ||
|
|
||
| logger = logging.getLogger("servicenow-mcp") |
There was a problem hiding this comment.
HIGH — silence httpx loggers. Same defect as PR #14 client.py:20.
If anyone enables Python DEBUG logging, httpx logs full HTTP requests including the Authorization header (which is Basic <base64(user:pass)>). Add to module scope or __init__:
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)| if fields: | ||
| params["sysparm_fields"] = fields | ||
| return await self._request( | ||
| "GET", f"/api/now/table/{table}/{sys_id}", params=params |
There was a problem hiding this comment.
HIGH — path traversal via table and sys_id.
This line and lines 127, 140, 145, 150 all interpolate user-controlled strings into the URL path with f-strings: f"/api/now/table/{table}/{sys_id}". httpx does not auto-escape path segments.
LLM-injected sys_id="abc/sys_user/admin-id" produces path /api/now/table/incident/abc/sys_user/admin-id — a different endpoint. Combined with the delete_record tool exposed at server.py:166, this is a path-traversal-to-arbitrary-delete primitive.
Fix: validate both arguments before interpolation.
import re
_TABLE_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
_SYS_ID_RE = re.compile(r"^[0-9a-f]{32}$")
def _validate_table(t):
if not _TABLE_RE.match(t):
raise ValueError(f"Invalid table name: {t!r}")
return t
def _validate_sys_id(sid):
if not _SYS_ID_RE.match(sid):
raise ValueError(f"Invalid sys_id: {sid!r}")
return sidApply to every CRUD method on the client. Tighten the table name regex against ServiceNow's actual table-naming rules if needed.
| return json.dumps( | ||
| {"count": len(results), "records": results}, | ||
| indent=2, | ||
| default=str, |
There was a problem hiding this comment.
MEDIUM — _fmt returns full ServiceNow responses to the LLM unfiltered.
Every tool ends with return _fmt(result). ServiceNow responses for HR cases, incidents, and user lookups can include comments, descriptions, employee personal info, manager chains, and reference fields with sensitive data. All of it lands in the LLM conversation context (and potentially in LLM provider telemetry).
Two paths forward:
- Field allowlist per tool. Each
search_*/get_*tool already passes afieldsparameter toquery_table. Make that allowlist explicit and minimal (sys_id, number, short_description, state) and document field-extension as a customer-controlled config. - Filter in
_fmtbased on the table. Take atableargument and apply per-table redaction (e.g., forsn_hr_core_case, dropdescriptionandcommentsunless explicitly requested).
Go with #1 — it's already mostly in place, just needs to be the contract rather than the suggestion.
|
|
||
|
|
||
| @mcp.tool() | ||
| async def delete_record(table: str, sys_id: str) -> str: |
There was a problem hiding this comment.
HIGH — delete_record exposes arbitrary table deletion to the LLM.
Combined with the path traversal in client.py:135, this is a primitive for deleting any record in any table. A prompt-injected LLM can call delete_record(table="sys_user", sys_id="<admin>") or delete_record(table="sys_audit", sys_id="<recent-audit-id>") to wipe audit records.
This tool should not be in the LLM-callable surface. If the kit needs delete capability, expose it as a script the human runs explicitly, not as a tool.
If you keep it: at minimum, denylist tables by name (sys_user, sys_audit, sys_security_, sys_properties, oauth_entity, sys_oidc_, sys_user_role, sys_user_grmember, sys_user_group), require a typed confirmation token from a separate prepare_delete tool, and log the action.
| return _fmt(result) | ||
|
|
||
|
|
||
| @mcp.tool() |
There was a problem hiding this comment.
HIGH — admin tools should not be LLM-callable.
register_oauth_application (this tool), register_oidc_provider (line 644), and set_system_property (line 741) are admin-equivalent operations. The client_secret_env_var indirection here is clever and prevents secret leakage into LLM context — credit for that. But the LLM still controls every other field, including redirect_url. A prompt-injected LLM can register an attacker-controlled OAuth app with the attacker's redirect_uri and use the resulting flow to harvest tokens from real users.
These belong in the /connect skill (PR #6), invoked by a script the human explicitly runs after reading what's about to happen. They don't belong in the LLM tool surface.
My strong recommendation: remove from server.py. Move the implementation into solutions/ess-maker-skills/scripts/connect_servicenow.py and have the connect skill walk the human through the parameters before calling it.
If there's a hard reason to keep these in the MCP surface, add prominent warnings to the docstrings, gate them behind an env-var feature flag (SERVICENOW_MCP_ENABLE_ADMIN_TOOLS=1) that defaults off, and log every invocation locally.
| f"call_api: method '{method}' not allowed. " | ||
| f"Allowed methods: {', '.join(_CALL_API_METHOD_ALLOWLIST)}." | ||
| ) | ||
| if not any(path.startswith(prefix) for prefix in _CALL_API_PATH_ALLOWLIST): |
There was a problem hiding this comment.
HIGH — allowlist is structurally correct but data-permissive.
The method allowlist (GET, POST, PATCH, DELETE) and path-prefix allowlist (lines 793-808) are good — admin endpoints (/api/now/auth/, direct /api/now/sys_*) are correctly blocked. But the path allowlist includes /api/now/table/, which means the LLM can call:
call_api("DELETE", "/api/now/table/sys_user/<admin-sys-id>")— delete admin usercall_api("PATCH", "/api/now/table/sys_properties/<security-property-id>", data='{"value":"false"}')— disable a security controlcall_api("POST", "/api/now/table/sys_user_grmember", data='{"user":"<attacker>","group":"<admin-group>"}')— escalate privileges
The allowlist gates URL structure. It doesn't gate which tables those URLs target.
Fix: add a table denylist for non-GET methods on the table API. Concretely, parse the path; if it matches /api/now/(v1/|v2/)?table/(<table>), check <table> against a denylist of admin/security/audit tables before allowing non-GET methods.
_ADMIN_TABLES = {
"sys_user", "sys_user_grmember", "sys_user_group", "sys_user_role",
"sys_properties", "sys_audit", "sys_security_acl",
"oauth_entity", "sys_oidc_provider", "sys_security_diag",
"sys_script", "sys_script_include", "sys_script_action",
}The spirit of the allowlist (EXPLORATION ONLY, per the docstring) is right. Make the implementation match the intent.
…raversal validation, denylist
server.py:
- Added _q() helper that escapes ServiceNow encoded-query special characters
(^, null, CR, LF). The encoded-query syntax uses ^ as AND and ^OR as OR;
embedding either inside a value lets prompt-injection break out of a single
filter clause and append additional ones (e.g. query="x^OR1=1" returns all
records; query="x^ORassigned_to=admin" leaks records assigned to a different
user).
- Wired _q() through every parts.append site in: search_incidents (5 fields),
search_hr_cases (4), browse_service_catalog (2), search_cmdb_items (2),
resolve_user (4), search_interactions (3). Total: 20 unsafe interpolations
closed.
client.py:
- HTTPS enforcement on SERVICENOW_INSTANCE_URL - reject http:// to keep
basic-auth credentials out of cleartext.
- Validate `table` and `sys_id` URL path components against strict regexes
(_TABLE_NAME_RE = ^[a-z][a-z0-9_]{0,63}$; _SYS_ID_RE = ^[0-9a-fA-F]{32}$).
Combined with the protected-table denylist below, prevents path traversal
attacks like sys_id="abc/sys_user/admin-id" hitting a different endpoint.
- Denylist of protected tables for mutating CRUD: sys_user, sys_user_group,
sys_user_role, sys_audit, sys_audit_delete, sys_log, sys_log_transaction,
sys_security_acl, sys_properties, oauth_entity, oauth_entity_profile,
sys_certificate. delete_record/update_record/create_record on these now
raise ValueError. Read-only query_table/get_record/get_stats can still
reach them (otherwise we couldn't look up users for resolve_user).
- Silence httpx + httpcore loggers at WARNING (DEBUG would echo
Authorization headers).
- __repr__ override hides _password from tracebacks.
- httpx clients now follow_redirects=False (302 cannot replay
Authorization to attacker-controlled host).
- Add jitter to retry backoff (random.uniform(0,1)) on both 429 and
RequestError paths.
- Strip response body from HTTPStatusError exceptions; log full body at
DEBUG (operator-only) instead of bubbling potentially-PII content into
the LLM context.
Already addressed in commit 831d343 (verified on this branch):
- 429 last_error tracking on max-retry exhaustion
- _parse_json helper for JSON tool args
- call_api lockdown to method+path allowlist
- OAuth/OIDC client_secret env-var indirection so secrets are never
passed as tool args
Repo hygiene:
- Added .gitignore for __pycache__/ and *.pyc in servicenow/.
Acknowledged but not in this commit (separate follow-ups):
- Refactor admin tools (register_oauth_application, register_oidc_provider,
set_system_property, call_api) out of the LLM tool surface entirely and
into explicit `/connect` skill scripts. They are still exposed here as
MCP tools but call_api is path-allowlisted and OAuth/OIDC use env-var
secret indirection. Need a follow-up commit to fully extract.
|
Addressed all CRITICAL+HIGH+MEDIUM findings in 5df862b. CRITICAL: Encoded query injection (9 search tools, 20 unsafe interpolations)
HIGH: Path traversal in get_record/update_record/delete_record (sys_id interpolation)
HIGH: delete_record on protected tables
HIGH: HTTPS enforcement
HIGH: httpx DEBUG credential exposure
HIGH: follow_redirects=True default
HIGH: Plain password attribute exposed by repr/tracebacks
HIGH: PII leakage via response body in HTTPStatusError
MEDIUM: No jitter in retry backoff
Already addressed in commit 831d343 (verified on this branch):
Acknowledged but not in this commit (separate follow-up):
Repo hygiene:
Ready for re-review. |
…API table denylist John's review explicitly rejected the env-var-secret-indirection-only approach for the 3 admin tools. The right end state is to extract them into the /connect skill (PR #6) as scripts the human runs explicitly. As an interim guard until that refactor lands: Admin-tool feature flag (off by default): - Added _ADMIN_TOOLS_ENABLED reading SERVICENOW_MCP_ENABLE_ADMIN_TOOLS env var (1/true/yes/on enables, anything else disables). - New _require_admin_tools(tool_name) raises PermissionError with a clear message pointing at /connect and the env-var unblock. - Wired the guard into: register_oauth_application register_oidc_provider set_system_property - Updated docstrings on each to label them ADMIN TOOL with the unblock procedure. call_api: data-permissive table allowlist (HIGH per John's PR #15 discussion_r3174960930): - Method+path allowlist already blocked admin endpoint paths but permitted non-GET on /api/now/table/* unconditionally - so a prompt-injected LLM could still DELETE /api/now/table/sys_user/<admin> PATCH /api/now/table/sys_properties/<security-prop> POST /api/now/table/sys_user_grmember (privilege escalation) - Added _CALL_API_TABLE_DENYLIST_NON_GET covering sys_user / role / group / has_role, sys_properties, sys_audit, sys_audit_delete, sys_security_acl, sys_security_diag, oauth_entity, oauth_entity_profile, sys_oidc_provider, sys_certificate, sys_script*, sys_ws_operation. - _CALL_API_TABLE_PATH_RE parses the table name out of the path; on non-GET methods, denied tables raise PermissionError. GET (read) is permitted because the typed tools (resolve_user, etc.) need to query sys_user records. Follow-up (separate PR after merge): full extraction of admin tools into solutions/ess-maker-skills/scripts/connect_servicenow.py invoked from the /connect skill, removing them from server.py entirely.
|
Follow-up commit 46ac8eb: admin tools off by default + table-API table denylist You explicitly rejected the env-var-secret-indirection-only approach for the 3 admin tools in your review. Right end state is to extract them into the Admin tools feature flag (off by default)
|
| Comment | Status |
|---|---|
| Encoded-query injection (9 sites, 20 fields) | ✅ in 5df862b (_q() helper) |
Path traversal table/sys_id regex validation |
✅ in 5df862b |
| Mutating-CRUD denylist on protected tables | ✅ in 5df862b |
| HTTPS enforcement on instance URL | ✅ in 5df862b |
Domain allowlist (*.service-now.com) |
|
__repr__ hides password |
✅ in 5df862b |
follow_redirects=False |
✅ in 5df862b |
Explicit verify=True |
|
| Reuse AsyncClient across calls | |
| httpx logger silencing | ✅ in 5df862b |
| ServiceNow error PII strip | ✅ in 5df862b |
| Retry jitter | ✅ in 5df862b |
delete_record denylist |
✅ in 5df862b (denylist applies to mutating CRUD on client) |
| Admin tools out of LLM surface | ✅ this commit (gated; full extraction = separate PR) |
call_api table denylist on non-GET |
✅ this commit |
_fmt per-tool field allowlist |
Open follow-ups (separate PR after merge)
- Full extraction of admin tools into
solutions/ess-maker-skills/scripts/connect_servicenow.pyinvoked from/connectskill, removing them fromserver.pyentirely *.service-now.comdomain allowlist withSERVICENOW_ALLOWED_HOSTSoverride- Explicit
verify=True+ reuse singleAsyncClientacross calls (move to__init__, addaclose()) _fmtfield allowlist per tool
Ready for re-review.
|
This PR was prematurely merged earlier today during a tooling incident. |
* PR #5: ServiceNow MCP server Adds the ServiceNow MCP server under solutions/ess-agent-kit/src/mcp/servicenow/. Local stdio MCP server that proxies REST API calls to the customer's ServiceNow tenant. SFI focus areas for this review: - OAuth2 token handling (refresh, expiry, storage) - REST request construction and URL handling - Network egress (only customer-provided ServiceNow endpoint) - Error/response handling (no leakage of customer ticket data) Reviewer: @GrahamMcMynn Tracker: #10 * PR #15: Address review - 429 last_error, json error handling, call_api lockdown, OAuth/OIDC secret env-var indirection * Rename folder solutions/ess-agent-kit -> solutions/ess-maker-skills rename solutions/ess-agent-kit -> solutions/ess-maker-skills * Address PR #15 review (johnguy0): encoded-query injection fix, path traversal validation, denylist server.py: - Added _q() helper that escapes ServiceNow encoded-query special characters (^, null, CR, LF). The encoded-query syntax uses ^ as AND and ^OR as OR; embedding either inside a value lets prompt-injection break out of a single filter clause and append additional ones (e.g. query="x^OR1=1" returns all records; query="x^ORassigned_to=admin" leaks records assigned to a different user). - Wired _q() through every parts.append site in: search_incidents (5 fields), search_hr_cases (4), browse_service_catalog (2), search_cmdb_items (2), resolve_user (4), search_interactions (3). Total: 20 unsafe interpolations closed. client.py: - HTTPS enforcement on SERVICENOW_INSTANCE_URL - reject http:// to keep basic-auth credentials out of cleartext. - Validate `table` and `sys_id` URL path components against strict regexes (_TABLE_NAME_RE = ^[a-z][a-z0-9_]{0,63}$; _SYS_ID_RE = ^[0-9a-fA-F]{32}$). Combined with the protected-table denylist below, prevents path traversal attacks like sys_id="abc/sys_user/admin-id" hitting a different endpoint. - Denylist of protected tables for mutating CRUD: sys_user, sys_user_group, sys_user_role, sys_audit, sys_audit_delete, sys_log, sys_log_transaction, sys_security_acl, sys_properties, oauth_entity, oauth_entity_profile, sys_certificate. delete_record/update_record/create_record on these now raise ValueError. Read-only query_table/get_record/get_stats can still reach them (otherwise we couldn't look up users for resolve_user). - Silence httpx + httpcore loggers at WARNING (DEBUG would echo Authorization headers). - __repr__ override hides _password from tracebacks. - httpx clients now follow_redirects=False (302 cannot replay Authorization to attacker-controlled host). - Add jitter to retry backoff (random.uniform(0,1)) on both 429 and RequestError paths. - Strip response body from HTTPStatusError exceptions; log full body at DEBUG (operator-only) instead of bubbling potentially-PII content into the LLM context. Already addressed in commit 831d343 (verified on this branch): - 429 last_error tracking on max-retry exhaustion - _parse_json helper for JSON tool args - call_api lockdown to method+path allowlist - OAuth/OIDC client_secret env-var indirection so secrets are never passed as tool args Repo hygiene: - Added .gitignore for __pycache__/ and *.pyc in servicenow/. Acknowledged but not in this commit (separate follow-ups): - Refactor admin tools (register_oauth_application, register_oidc_provider, set_system_property, call_api) out of the LLM tool surface entirely and into explicit `/connect` skill scripts. They are still exposed here as MCP tools but call_api is path-allowlisted and OAuth/OIDC use env-var secret indirection. Need a follow-up commit to fully extract. * Address PR #15 review (johnguy0): admin tools off by default + table-API table denylist John's review explicitly rejected the env-var-secret-indirection-only approach for the 3 admin tools. The right end state is to extract them into the /connect skill (PR #6) as scripts the human runs explicitly. As an interim guard until that refactor lands: Admin-tool feature flag (off by default): - Added _ADMIN_TOOLS_ENABLED reading SERVICENOW_MCP_ENABLE_ADMIN_TOOLS env var (1/true/yes/on enables, anything else disables). - New _require_admin_tools(tool_name) raises PermissionError with a clear message pointing at /connect and the env-var unblock. - Wired the guard into: register_oauth_application register_oidc_provider set_system_property - Updated docstrings on each to label them ADMIN TOOL with the unblock procedure. call_api: data-permissive table allowlist (HIGH per John's PR #15 discussion_r3174960930): - Method+path allowlist already blocked admin endpoint paths but permitted non-GET on /api/now/table/* unconditionally - so a prompt-injected LLM could still DELETE /api/now/table/sys_user/<admin> PATCH /api/now/table/sys_properties/<security-prop> POST /api/now/table/sys_user_grmember (privilege escalation) - Added _CALL_API_TABLE_DENYLIST_NON_GET covering sys_user / role / group / has_role, sys_properties, sys_audit, sys_audit_delete, sys_security_acl, sys_security_diag, oauth_entity, oauth_entity_profile, sys_oidc_provider, sys_certificate, sys_script*, sys_ws_operation. - _CALL_API_TABLE_PATH_RE parses the table name out of the path; on non-GET methods, denied tables raise PermissionError. GET (read) is permitted because the typed tools (resolve_user, etc.) need to query sys_user records. Follow-up (separate PR after merge): full extraction of admin tools into solutions/ess-maker-skills/scripts/connect_servicenow.py invoked from the /connect skill, removing them from server.py entirely. * Address ruff E402 lint finding (slice PR #5) src/mcp/servicenow/server.py:166 - E402: 'import re as _re' was placed after non-import statements (the EXCLUDED_TABLES set definition). Moved to the top with the other stdlib imports per PEP 8. The compiled regex constant _CALL_API_TABLE_PATH_RE stays where it is (late module-level definition is fine; only the import had to move). Mechanical, behavior-preserving. * Round-1 review: address johnguy0 architect/security/perf/prompt findings (slice PR #5) ARCHITECT * Promote client._request to public client.request; _request kept as backward-compat alias. call_api in server.py now routes through client.request (no longer reaches into a private method). * Consolidate the table denylist into client.EXCLUDED_TABLES and import from server.py. The two parallel lists were already drifting (sys_user_grmember, sys_user_has_role, sys_oidc_provider, sys_security_diag, sys_script* were only on the server side; sys_log*, sys_log_transaction, oauth_entity_profile, sys_certificate were only on the client side). Single source now; the server-side _CALL_API_TABLE_DENYLIST_NON_GET is the import alias. SECURITY * Encoded-query injection: list_oauth_applications:641, list_oidc_providers:721, check_plugin_active:808, get_system_properties:824 were interpolating the LLM-supplied value raw into sysparm_query. Patched all four to route through _q(). Sample: 'name^OR1=1' would have recreated the encoded-query injection that _q() exists to prevent. * log_copilot_summary at server.py:621: data.update(extra) let LLM-supplied additional_fields clobber the tool-controlled u_interaction / u_summary / u_resolution. Reversed merge order to data = {**extra, **data} so tool-controlled fields always win. Added isinstance(extra, dict) guard so a JSON array/scalar surfaces a clear error rather than crashing the merge. * client.py logger.debug body emission removed. The comment above the exception promised the response body would not surface (PII / internal field metadata). The debug line undermined that promise: any operator who flipped the global log level to DEBUG would have leaked the body. If an operator needs the body for triage they should reproduce with httpx logging enabled explicitly. PERFORMANCE * Long-lived httpx.AsyncClient cached on self via _ensure_client (lazy init, asyncio.Lock around the create), closed on aclose(). Saves the 50-200ms TLS handshake per call across the typical 5-50 calls in an LLM session. * Retry path extended to 502/503/504 with the same backoff used for 429 (one transient gateway error during a deploy or restart no longer fails on the first try). * _parse_retry_after handles RFC 9110 HTTP-date format, falls back to delta-seconds, falls back to None on parse failure (the call site then uses 2**attempt). Previously int(retry_after) would raise on a date value. PROMPT-ENGINEERING * FastMCP server-level instructions now include a 'prefer typed tools' routing line. This reaches the LLM before any individual tool docstring; highest-leverage place to put routing. * query_table, create_record, update_record, delete_record docstrings each carry a one-sentence routing line pointing the LLM at the typed alternative. * search_incidents:assigned_to docstring fixed: ServiceNow matches sys_id only on assigned_to, so 'display name or sys_id' silently returned zero results when a name was passed. Doc now tells the LLM to call resolve_user first if it only has a name. * log_copilot_summary:additional_fields docstring lists the supported u_* columns (u_channel, u_outcome, u_session_id, u_disposition), states the JSON-object requirement, and warns that unknown columns surface as ServiceNow validation errors. All edits behavior-preserving for the supported call paths; tightening on the abuse paths. Lint clean (ruff). * Drop accidentally-committed __pycache__ from prior commit The previous commit picked up scripts/__pycache__/ from a local syntax check (python -m py_compile). They never should have been tracked. The repo .gitignore already excludes __pycache__/ globally; these slipped in via 'git add -A'. Remove + future commits will respect .gitignore. * ci: trigger CI re-run with updated workflow from main2 (PR #47)
* PR #6: Connect + Onboarding skills Adds the connection setup and onboarding skill content under solutions/ess-agent-kit/src/skills/: - connect/ - integration setup flows for Azure, ServiceNow, and Workday (21 files) - onboarding/ - guided first-run experience for /setup (6 files) These are markdown skill files consumed by GitHub Copilot prompts to walk customers through environment connection and credential setup. SFI focus: any references to internal Microsoft tenants, hardcoded URLs, or sample credentials should be generic placeholders. Reviewer: @CavillMason Tracker: #10 * PR #6: Update ServiceNow connect skills for new client_secret_env_var contract PR #15 changed register_oauth_application and register_oidc_provider to accept client_secret_env_var (env var NAME) instead of client_secret (raw value), keeping secrets out of MCP logs and LLM context. Update the four skill docs that call these tools: - step2-certificate.md, step2-entra.md, step2-graph.md: add a pre-step setting SERVICENOW_OIDC_CLIENT_SECRET_NOT_USED='not-used' (OIDC verification with Entra ID does not actually consume a client secret, but the ServiceNow record requires the field to be non-empty); pass that env var name to register_oidc_provider. - step2-oauth2.md: add a pre-step writing the freshly-generated CLIENT_SECRET to env var SERVICENOW_OAUTH_CLIENT_SECRET; pass that env var name to register_oauth_application. * Rename folder solutions/ess-agent-kit -> solutions/ess-maker-skills rename solutions/ess-agent-kit -> solutions/ess-maker-skills * Rebrand: 'ESS Copilot Kit' -> 'ESS Maker Kit' in connect skill (slice PR #6) Inside-repo rename per the naming policy locked in chore PR. Single skill markdown file; no behavior change. * Round-2 fixes addressing all 6 in-scope findings from John's first review on PR #34. Issue #50 filed for the 7th (discover.py JSON output suggestion - touches scripts/, out of slice). #1 BLOCKER - bound the Azure device-code retry loop - Add an attempt counter persisted to my/.azure-login-attempts.json (initialize on first A.4 entry, increment per failed attempt, delete on success). - A.4 stops issuing new device codes when attempts == 3 and shows a "talk to your tenant admin" message. - A.5 manual-fallback branch now routes back to A.5 to verify (NOT A.4, which would reissue a fresh device code and overwrite the manual sign-in). #2 BLOCKER - extend /connect resume routing - Add entra, certificate, and federated (graph connector) to the step 2 unchecked branch in src/skills/connect/step1.md. - Mirror the same routes in the step 3 unchecked branch. - Add federated to the "switching auth" Step 2 router as well. #3 BLOCKER - stop persisting CERT_PASSWORD - Both "Immediately save CERT_PFX_PATH and CERT_PASSWORD" blocks in step2-certificate.md now save only PFX path, CER path, and thumbprint. Password stays in session memory. - Section 2.11 config.json schema drops `certPassword`, adds `certCerPath` and `certThumbprint`. - step3-certificate.md re-prompts for the password via vscode_askQuestions on resumed sessions. - Generation-time message updated to tell the user "save in your password manager now - this kit does not persist it." #4 fix Skip semantics on the readiness step - step3-flightcheck.md Skip branch no longer marks step 5 as [x]. It leaves it unchecked and stops, so the next /setup re-offers it. Option label changed to "Skip - remind me later" to match. #5 banner / tasks.md row-count drift - Fresh-start banner in onboarding/SKILL.md, the post-discovery banner in step1b.md, the pre-MCP banner in step2.md, and the post-MCP banner in step2.md all add row 5 ("Readiness check (optional)") so what the user sees matches the persisted task list. #6 add an "I'm not sure" diagnostic in servicenow/step1.md - New section 1.1b runs only if the user picked "I'm not sure" in 1.1. Asks one follow-up: "Do you go through a Microsoft sign-in page when you sign in to ServiceNow?" Yes -> entra, No -> basic, Still not sure -> entra with a one-line "switch later from /connect" callout. * fix(connect/azure): A.5 manual fallback bumps attempts to cap (resolves #34 thread) John flagged that when the manual az login fallback also fails, we re-read attempts (still 2 from A.4) and re-enter the manual flow indefinitely - the counter never advances past A.4. Now A.5 writes attempts=3 BEFORE waiting on 'done'. On the next A.5 iteration, the 'three failed attempts' branch fires with the talk-to-admin message and clears the counter. Manual fallback gets exactly one chance.
* PR #6: Connect + Onboarding skills Adds the connection setup and onboarding skill content under solutions/ess-agent-kit/src/skills/: - connect/ - integration setup flows for Azure, ServiceNow, and Workday (21 files) - onboarding/ - guided first-run experience for /setup (6 files) These are markdown skill files consumed by GitHub Copilot prompts to walk customers through environment connection and credential setup. SFI focus: any references to internal Microsoft tenants, hardcoded URLs, or sample credentials should be generic placeholders. Reviewer: @CavillMason Tracker: #10 * PR #6: Update ServiceNow connect skills for new client_secret_env_var contract PR #15 changed register_oauth_application and register_oidc_provider to accept client_secret_env_var (env var NAME) instead of client_secret (raw value), keeping secrets out of MCP logs and LLM context. Update the four skill docs that call these tools: - step2-certificate.md, step2-entra.md, step2-graph.md: add a pre-step setting SERVICENOW_OIDC_CLIENT_SECRET_NOT_USED='not-used' (OIDC verification with Entra ID does not actually consume a client secret, but the ServiceNow record requires the field to be non-empty); pass that env var name to register_oidc_provider. - step2-oauth2.md: add a pre-step writing the freshly-generated CLIENT_SECRET to env var SERVICENOW_OAUTH_CLIENT_SECRET; pass that env var name to register_oauth_application. * Rename folder solutions/ess-agent-kit -> solutions/ess-maker-skills rename solutions/ess-agent-kit -> solutions/ess-maker-skills * Rebrand: 'ESS Copilot Kit' -> 'ESS Maker Kit' in connect skill (slice PR #6) Inside-repo rename per the naming policy locked in chore PR. Single skill markdown file; no behavior change. * Round-2 fixes addressing all 6 in-scope findings from John's first review on PR #34. Issue #50 filed for the 7th (discover.py JSON output suggestion - touches scripts/, out of slice). #1 BLOCKER - bound the Azure device-code retry loop - Add an attempt counter persisted to my/.azure-login-attempts.json (initialize on first A.4 entry, increment per failed attempt, delete on success). - A.4 stops issuing new device codes when attempts == 3 and shows a "talk to your tenant admin" message. - A.5 manual-fallback branch now routes back to A.5 to verify (NOT A.4, which would reissue a fresh device code and overwrite the manual sign-in). #2 BLOCKER - extend /connect resume routing - Add entra, certificate, and federated (graph connector) to the step 2 unchecked branch in src/skills/connect/step1.md. - Mirror the same routes in the step 3 unchecked branch. - Add federated to the "switching auth" Step 2 router as well. #3 BLOCKER - stop persisting CERT_PASSWORD - Both "Immediately save CERT_PFX_PATH and CERT_PASSWORD" blocks in step2-certificate.md now save only PFX path, CER path, and thumbprint. Password stays in session memory. - Section 2.11 config.json schema drops `certPassword`, adds `certCerPath` and `certThumbprint`. - step3-certificate.md re-prompts for the password via vscode_askQuestions on resumed sessions. - Generation-time message updated to tell the user "save in your password manager now - this kit does not persist it." #4 fix Skip semantics on the readiness step - step3-flightcheck.md Skip branch no longer marks step 5 as [x]. It leaves it unchecked and stops, so the next /setup re-offers it. Option label changed to "Skip - remind me later" to match. #5 banner / tasks.md row-count drift - Fresh-start banner in onboarding/SKILL.md, the post-discovery banner in step1b.md, the pre-MCP banner in step2.md, and the post-MCP banner in step2.md all add row 5 ("Readiness check (optional)") so what the user sees matches the persisted task list. #6 add an "I'm not sure" diagnostic in servicenow/step1.md - New section 1.1b runs only if the user picked "I'm not sure" in 1.1. Asks one follow-up: "Do you go through a Microsoft sign-in page when you sign in to ServiceNow?" Yes -> entra, No -> basic, Still not sure -> entra with a one-line "switch later from /connect" callout. * fix(connect/azure): A.5 manual fallback bumps attempts to cap (resolves #34 thread) John flagged that when the manual az login fallback also fails, we re-read attempts (still 2 from A.4) and re-enter the manual flow indefinitely - the counter never advances past A.4. Now A.5 writes attempts=3 BEFORE waiting on 'done'. On the next A.5 iteration, the 'three failed attempts' branch fires with the talk-to-admin message and clears the counter. Manual fallback gets exactly one chance. * Round-2 review on PR #52: address all 6 of John's threads Two blockers: * (workday/step3.md:212) Drop hardcoded C:\Users\saengland\\...\\pwsh.exe path. Call 'pwsh' off PATH so it works for every contributor and on macOS/Linux. Dataverse MCP fallback already handles the not-installed case. * (workday/step2.md:412) Stop persisting isuWqlPassword/isuGenericPassword to my/connect/workday/config.json. Hold them in session memory only, mirroring the cert PFX rule one file over. ISU credentials are full reusable Workday user accounts and the Power Platform connection ref already stores them encrypted server-side after step 3. Four smaller fixes: * (servicenow/step2-certificate.md:123) Replace Guid.Substring(0,16) (~60 bits) with RandomNumberGenerator.GetBytes(24) Base64 (192 bits). Match the 2048-bit RSA key strength. * (workday/step3.md:309) Surface a Message block before the auto-push to the live agent so the user sees the consent + checkpoint name, not just the test result in 3.6. * (workday/step3.md:259) Drop the 'no, search for' edit-trace leftover. Just say 'search for **Environment variables**'. * (connect/step1.md:100) Qualify the cross-doc reference: 'src/skills/connect/servicenow/step1.md section 1.1' (the per-product file, not this top-level routing file).
Adds the ServiceNow MCP server under
solutions/ess-agent-kit/src/mcp/servicenow/. Local stdio MCP server that proxies REST API calls to the customer's ServiceNow tenant.SFI focus areas:
Tracker: #10