Skip to content

[Osquery] Resolve saved query references before authorizing runSavedQueries - #287882

Open
tomsonpl wants to merge 27 commits into
elastic:mainfrom
tomsonpl:fix-runsavedqueries-permission
Open

[Osquery] Resolve saved query references before authorizing runSavedQueries#287882
tomsonpl wants to merge 27 commits into
elastic:mainfrom
tomsonpl:fix-runsavedqueries-permission

Conversation

@tomsonpl

@tomsonpl tomsonpl commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

runSavedQueries now authorizes against a resolved saved query or pack in the request space, and live-query / rule-run dispatch uses that stored content. Callers who hold writeLiveQueries are unchanged.

This is a breaking API change for clients that send a query, queries, or ecs_mapping that does not match the referenced object, or a reference that does not resolve: those requests now return 403. A matching query is still accepted so the Kibana UI (which posts the selected saved query's SQL) keeps working; it does not override what is dispatched. Parameterized saved queries continue to accept client-substituted SQL when it matches the stored template; substitution is also applied server-side when alert context is present.

The same authorization applies when attaching an osquery response action to a detection rule (create, update, patch, import, bulk duplicate).

Key changes

  • Resolve saved_query_id / pack_id before authorizing runSavedQueries (blank ids do not resolve).
  • For runSavedQueries without writeLiveQueries, dispatch stored SQL and mapping rather than the request body.
  • Rule-run always dispatches stored content for a referenced saved query or pack.
  • Rule response-action validators forward query / queries / ecs_mapping (snake and camel case) into the same checker.
  • Unreadable or missing alert on the deny path returns 403.
  • Breaking-change note for Stack 9.6.0 and Serverless.

Test plan

  • Osquery Jest (authz, resolve, create queries/handler, live-query route)
  • Rule response-action validator Jest
  • Scout RBAC and response-action specs updated (rbac_t1_analyst, rbac_t2_analyst, rbac_alert_test, response_actions_rules)
  • CI

AI-Assisted: This PR was assisted by Cursor Grok 4.6.

Made with Cursor

…ueries

A truthy saved_query_id or pack_id was treated as authorization, so a
runSavedQueries-only caller could dispatch arbitrary SQL. Resolve the
reference in the request space and dispatch stored query content instead.

Relates to elastic/security#11789 and elastic/security#12007

Co-authored-by: Cursor <cursoragent@cursor.com>
@tomsonpl tomsonpl added release_note:breaking Team:Defend Workflows “EDR Workflows” sub-team of Security Solution labels Aug 28, 2026
@tomsonpl
tomsonpl requested a review from a team as a code owner August 28, 2026 15:38
@tomsonpl tomsonpl added backport:all-open Backport to all branches that could still receive a release v9.6.0 labels Aug 28, 2026
@tomsonpl
tomsonpl requested a review from a team as a code owner August 28, 2026 15:38
@tomsonpl
tomsonpl requested review from joeypoon and pzl August 28, 2026 15:38
@tomsonpl tomsonpl added Team:Defend Workflows “EDR Workflows” sub-team of Security Solution backport:all-open Backport to all branches that could still receive a release v9.6.0 labels Aug 28, 2026
@infra-vault-gh-plugin-prod

Copy link
Copy Markdown

Pinging @elastic/security-defend-workflows (Team:Defend Workflows)

1 similar comment
@infra-vault-gh-plugin-prod

Copy link
Copy Markdown

Pinging @elastic/security-defend-workflows (Team:Defend Workflows)

@kibanamachine kibanamachine added the reviewer:scout Agentic PR Scout test review label Aug 28, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the osquery runSavedQueries authorization hardening. The core refactor is solid: references are resolved to real saved objects with blank/whitespace ids and cross-space references correctly failing to resolve, queries[] is always rejected for the runSavedQueries-only persona, mismatched query/ecs_mapping are rejected rather than silently ignored, the unresolvable-reference and unreadable-alert paths now return 403 instead of 500, and the rule-response-action validators forward query/queries/ecs_mapping into the same checker so rule-run dispatch can't be used to bypass the boundary. Test coverage across the unit, route, and Scout RBAC layers is thorough. Left one inline note about a residual gap in the parameterised-query matching.

Generated by Claude Reviewer for #287882 · opus · 200.2 AIC · ⌖ 14.6 AIC · ⊞ 5.1K

Comment thread x-pack/platform/plugins/shared/osquery/server/lib/check_response_action_authz.ts Outdated
response_actions: [smuggledOsqueryAction()],
});
const ndjson = `${JSON.stringify(rule)}\n`;
const boundary = '----scoutFormBoundary';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contribute to Scout when possible

Hand-assembling multipart/form-data with a fixed boundary in a spec file is fragile and non-reusable. Consider promoting this to a helper (ideally on apiServices) so the next rule-import test doesn't rebuild it from scratch.

See details

Rules import is a first-class detection-engine endpoint with no apiServices helper today. Every future test that wants to exercise it will need this same block, and each copy is a chance to drift on:

  • boundary escaping (one wrong \r\n and the request silently posts empty NDJSON),
  • elastic-api-version / x-elastic-internal-origin headers,
  • content-type formatting.

Two directions, roughly ordered by scope:

  1. Local helper in this suite's fixtures — a buildRulesImportMultipart(rules) returning { headers, body }. Cheap, keeps the spec readable.
  2. Contribute to apiServices — e.g. detectionEngine.rules.import(rules, { auth }). Best long-term home, matches how packs/savedQueries are already exposed to these specs.

Either way, the spec should just call the helper and assert on the response.

Share feedback in the #appex-qa Slack channel.


## 9.6.0 [kibana-9.6.0-breaking-changes]

$$$kibana-287882$$$

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we should be publicizing these breaking changes before the stack version is released. A separate PR that did something similar was approved though, so...perhaps it's not as much of an issue as I think it is?

On serverless, is this a breaking change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, good point! Yes it's also a breaking change on serverless. However I am not sure what the proper process look like. Do you know who can we check this with?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for missing this rely. I pinged @lcawl for more help, since the Serverless release notes follow a different generation process these days.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've sent a private message but the gist is that you open an issue for review by the breaking changes committee. If it's approved, you add the text to the ## Release note section of the PR description and only then add the release_note: breaking label. That suffices to ensure it's picked up in both the Serverless and Stack release notes.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

🔍 Preview links for changed docs

@tomsonpl
tomsonpl marked this pull request as draft August 31, 2026 12:59
…iour

Reverts 5ae6085. The alert-response-actions e2e asserts on the outgoing request
body:

  expect(request.body.query).to.not.contain('{{host.os.name}}')

That assertion was added deliberately in elastic#286175 ("Assert take-action param
substitution on the dispatched request"), so the request body carrying the
substituted SQL is intended behaviour, not an artefact. Moving substitution to
the server necessarily puts the template in the request body, which conflicts
with it.

The dispatched query was correct either way -- `defaultValues.alertIds` already
flows to the flyout, so the server did load the alert document and substitute --
but that is not what the test guards, and reworking an intentional assertion to
suit this PR is out of scope for a privilege fix.

Client-side substitution and its pre-existing TODO are restored verbatim. The
server-side work from 0169fc1 (pack ecs_mapping scoping, logger hoist,
applies_to widening) is unrelated to substitution and stays.

Konrad's substitution finding therefore remains open, to be handled separately
alongside the alert_ids refactor the TODO already describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
// rule is being *removed*, and there is nothing to authorize. Without this, a saved query
// that is later deleted or moved out of the space would pin the action in place forever,
// because the user could neither keep it (403) nor take it off the rule.
if (!isActionInPayload(actionData)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consequence of the removal carve-out: the bulk-duplicate route validation is now a no-op.

bulk_actions/route.ts:344-352 calls validateRuleResponseActions with rulePayload: {}, so with this carve-out every existing osquery action is "being removed" and is skipped. The real duplicate is still denied — rulesClient.create at bulk_actions/route.ts:381 reaches alerting/.../create_rule.ts:167, where the duplicated params are the payload — which is why response_actions_rules.spec.ts:519-560 still passes. But the dry-run branch returns at route.ts:355-357, before create, so POST /api/detection_engine/rules/_bulk_action?dry_run=true with {action: 'duplicate'} now reports success where it previously surfaced the 403. API-only today (the rules table calls duplicate without a dry run, use_bulk_actions.tsx:189-190), and nothing covers it.

The carve-out itself is right — the fix belongs at the call site: pass the actions that would be created (the duplicated rule's response_actions) instead of {}, so dry run and real run agree and "absent from the payload" keeps meaning "being removed". Fine to accept as-is if you'd rather keep the change minimal, but it should be a conscious call rather than a side effect.

@szwarckonrad szwarckonrad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All four blockers from my earlier review are genuinely fixed in 2a50e79 / 0169fc1, and I verified each against c240429 rather than taking the replies on faith:

  • Unchanged osquery actions now dedupe across the snake/camel boundary and a removal is never re-authorized, so saved-query drift no longer pins an action on a rule. Both halves are unit-tested, and every existing Scout denial spec still passes because each one puts the action in the payload.
  • Rule runs record SAVED_QUERY_NOT_FOUND on the action document instead of throwing into a swallowed .catch(); the dispatched SQL is forced to undefined, so there is no fallback to caller-supplied content.
  • Pack substitution is gated on useStoredQuery, so an ad-hoc writeLiveQueries pack run dispatches templates exactly as before.
  • The flyout no longer posts single-query fields in pack mode, and authorization no longer leaks saved-object errors as unhandled 500s.

Two items are left open deliberately, neither blocking:

  1. The bulk-duplicate route validation is now a no-op as a side effect of the removal carve-out — the real duplicate is still denied via rulesClient.create, but dry_run=true reports success. Left as its own thread; fine either fixed at the call site or consciously accepted.
  2. The client/server substitution divergence stays open with #289296 filed. Correct trade-off given it is a false deny and the proper fix collides with the intentional e2e assertion from #286175 — it just wants a known-limitation line in the release note.

The remaining asks are documentation-only (pack case in the new 400 paragraph, and confirming which branches carry the entry given backport:all-open while the block sits under a 9.6.0 heading). Nice work on the churn on the substitution thread — reverting twice and filing the issue was the right call over forcing a fix that broke someone else's assertion.

…rrors, docs

Bulk duplicate passed `rulePayload: {}` with `existingRule`, so the osquery
removal carve-out added in 2a50e79 treated every action as being removed and
skipped validation entirely. The dry run therefore reported success for a
duplicate that `rulesClient.create` goes on to deny. A duplicate persists the
source rule's response actions on a new rule, so it is now validated exactly
like a create: the actions are the payload and there is no `existingRule`,
matching both `create_rule/route.ts` and the alerting `authorize.params`
layer that enforces this downstream. Uses the existing
`transformAlertToRuleResponseAction` converter rather than a cast.

A deleted or cross-space pack referenced by a rule action threw out of
`createActionHandler`; the throw was swallowed by `processResponseActionClientError`
and the rule run still reported `succeeded` with no action document. Rule runs
now record `PACK_NOT_FOUND` on the action so the failure is visible in the
alert's Osquery Results tab, mirroring the saved-query path. A missing pack no
longer falls through to caller-supplied SQL. Direct API callers still get the
throw.

Test cleanup guards in response_actions_rules.spec.ts registered created rule
IDs after the 403 assertion, making them unreachable: a regression to 200 would
leak the rule. Registration now precedes the assertion.

Docs: drop the `:applies_to:` line that contradicted the 9.6.0 heading it sat
under (the entry is the only one in the file using it, and the backport gives
each branch its own correctly-headed copy); extend the 400 note to cover packs,
which hit the same PARAMETER_NOT_FOUND path; add the known-limitation note for
the deferred client/server substitution divergence (elastic#289296).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…client substitution

Follow-up to review feedback on the runSavedQueries authz change.

- Stop substituting `{{params}}` client-side in the live-query form. The client
  saw the ECS-shaped alert while the server re-substituted from the flattened
  technical fields, and authz required the two strings to be equal, so
  parameterized saved queries 403'd for runSavedQueries-only users from the
  alert flyout. Send the template and let the route resolve it (elastic#289296).

- Derive `ecs_mapping` from the saved object when stored SQL is dispatched, and
  treat an empty caller mapping as unset. The rule-action form defaults it to
  `{}`, so a stored mapping never reached the agent from a rule.

- Stop treating an absent/empty `ecs_mapping` as an assertion in authz. It made
  an action whose mapping did not match the referenced object unmodifiable by a
  runSavedQueries author, leaving removal as the only possible edit. `{}` still
  cannot strip a stored mapping: dispatch now derives it, which holds regardless
  of what the caller sends.

- Require pack `queries[]` entries to match a stored pack query. Dispatch
  rebuilds the query set from the pack so there was no SQL escalation, but the
  persisted copy decides whether a run fans out per alert with substitution.

- Decide parameterization from the resolved stored content rather than the copy
  persisted on the rule. Editing a saved query to add a parameter needs only
  writeSavedQueries, and the stale copy still looked static, so the run took the
  non-parameterized branch and dispatched nothing.

- Rename the scout case that no longer reaches the investigation-guide recovery
  branch; that branch stays covered in create_live_query_route.test.ts.

Verified: osquery 197 suites / 2493 tests, security_solution server/lib 57
suites / 869 tests, eslint clean on all touched files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…micQueries params

- Register duplicated rule ids before asserting in the bulk-duplicate authz test.
  Playwright's `expect` throws synchronously, so a regression to 200 — the exact
  escalation this test guards — skipped the cleanup extraction and leaked the
  duplicates into the next run on the shared server. Matches the ordering the
  create-time tests above already use. Audited the rest of the scout API suite:
  the other `push`-after-`expect` sites are all guarded by a 200 assertion, where
  a failure means nothing was created, so this was the only affected case.

- Narrow `containsDynamicQueries` to the fields it actually reads instead of
  taking a full `CreateLiveQueryRequestBodySchema`. Callers pass a rule's
  persisted params, not a live-query payload, and requiring every pack-query
  field (id/ecs_mapping/version/platform/removed/snapshot) broke type_check.

Verified: osquery 197 suites / 2493 tests, eslint clean, and the osquery project
now type-checks with no errors from this PR (the remaining `plugin.ts`
`isCpsActive` error predates it, from the CPSServerStart drift in merged main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng it from the client

Corrects the earlier attempt at the alert-flyout 403. Removing the client-side
`{{param}}` substitution broke `alert_response_actions.cy.ts` — those tests
assert on `request.body.query`, i.e. the outgoing request before it reaches the
server, so no amount of server-side substitution can satisfy them. Restore
`live_queries/form/index.tsx` exactly as it was (it is now byte-identical to the
state before this branch touched it) and fix the actual defect instead.

Root cause: `replaceParamsQuery` reads fields with `lodash.get`, which already
resolves a dotted path against a literal dotted key, so the two document shapes
were not the problem. Flattened `ParsedTechnicalFields` stores every value as an
array, so the server substituted `name='["Ubuntu"]'` instead of `name='Ubuntu'`,
the `queriesMatch` string comparison failed, and the request was rejected 403.
Unwrap the array so both the nested ECS object the client holds and the flattened
alert document the server reads produce the same string.

Verified: osquery 197 suites / 2496 tests, security_solution rule_response_actions
38 tests, eslint clean, no new type_check errors. No `public/` or `cypress/` file
differs from the PR head, so the Cypress suite sees unchanged client code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tomsonpl

tomsonpl commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

@dhurley14 hey, can you take a look at this please? :) Thanks!

@kibanamachine

Copy link
Copy Markdown
Contributor

💛 Build succeeded, but was flaky

Failed CI Steps

Metrics [docs]

Async chunks

Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app

id before after diff
osquery 625.4KB 625.6KB +166.0B
Unknown metric groups

total optimizer output size

id before after diff
all 64.0MB 64.0MB +166.0B

Test Failures

  • [job] [logs] Jest Tests #2 / useResolutionGroup fetches resolution group with correct params

History

Saved query and pack ids are unconstrained strings, so joining them with
`::` let two distinct references collide on one cache entry: a reference
with `pack_id: '::my-pack'` and one with `saved_query_id` ending in `::`
produce the same key. The second caller then reused the first's resolved
object.

The cache is keyed by KibanaRequest, and rule import and bulk duplicate
authorize many response actions under a single request, so the collision
was reachable there. Because a resolved-undefined result is cached too,
the practical effect was a spurious 403 on an unrelated rule in the same
request.

Key on a structured value instead so ids cannot straddle the separator.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:all-open Backport to all branches that could still receive a release release_note:breaking reviewer:scout Agentic PR Scout test review Team:Defend Workflows “EDR Workflows” sub-team of Security Solution v9.6.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants