Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/sentinel_one/changelog.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
# newer versions go on top
- version: "2.11.3"
changes:
- description: Fix error handling for first phase of agent data collection.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🔵 Low confidence: medium path: packages/sentinel_one/changelog.yml:4

The changelog entry says "first phase of agent data collection" without naming the data stream; since this package also ships an agent data stream it reads as if that stream changed. Name threat_event explicitly.

Details

All three entries in 2.11.2 describe changes that are specific to the threat_event data stream, but none of them says so. The first entry is the most ambiguous: packages/sentinel_one/data_stream/agent/ exists, so "agent data collection" is naturally read as the agent data stream rather than as Elastic Agent collection for threat_event. The changelog is user-facing and is what an operator reads when deciding whether an upgrade affects them.

Recommendation:

Name the data stream in each entry:

- version: "2.11.2"
  changes:
    - description: Fix error handling for the first collection phase of the threat_event data stream.
      type: bugfix
      link: https://github.com/elastic/integrations/pull/20484
    - description: Fix handling of deleted threats in the threat_event data stream.
      type: bugfix
      link: https://github.com/elastic/integrations/pull/20484
    - description: Fix global error handler message formatting in the threat_event data stream.
      type: bugfix
      link: https://github.com/elastic/integrations/pull/20484

🤖 AI-Generated Review | Vera Review Bot | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

type: bugfix
link: https://github.com/elastic/integrations/pull/20484
- description: Fix handling of deleted threats.
type: bugfix
link: https://github.com/elastic/integrations/pull/20484
- description: Fix global error handler message formatting.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🔵 Low confidence: medium path: packages/sentinel_one/changelog.yml:10

The 2.11.2 changelog covers the three CEL/error-formatting fixes but not the ingest pipeline null-safety fix to convert_file_size_to_long; add a fourth bugfix entry for it.

Details

elasticsearch/ingest_pipeline/default.yml changes the convert_file_size_to_long condition from ctx.sentinel_one?.threat_event?.file.size to ctx.sentinel_one?.threat_event?.file?.size. That is a distinct, user-visible fix: without it the condition raises a null-pointer error for any document where sentinel_one.threat_event.file is absent, which surfaces as a pipeline_error document. None of the three entries in the 2.11.2 block describes it — 'Fix error handling for first phase of agent data collection', 'Fix handling of deleted threats' and 'Fix global error handler message formatting' all refer to the CEL program and the pipeline's global on_failure block.

Recommendation:

Add an entry to the 2.11.2 block:

- version: "2.11.2"
  changes:
    - description: Fix error handling for first phase of agent data collection.
      type: bugfix
      link: https://github.com/elastic/integrations/pull/20484
    - description: Fix handling of deleted threats.
      type: bugfix
      link: https://github.com/elastic/integrations/pull/20484
    - description: Fix global error handler message formatting.
      type: bugfix
      link: https://github.com/elastic/integrations/pull/20484
    - description: Fix null pointer error when converting file size for threat events without file information.
      type: bugfix
      link: https://github.com/elastic/integrations/pull/20484

🤖 AI-Generated Review | Vera Review Bot | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

type: bugfix
link: https://github.com/elastic/integrations/pull/20484
- version: "2.11.2"
changes:
- description: Set agentless deployment mode `release` field to `ga`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ inputs:
"sortBy": ["updatedAt"],
"sortOrder": ["asc"],
"updatedAt__gte": [
state.?cursor.?list_updated_at_gte.orValue(
state.?cursor.?last_timestamp.orValue(
state.?cursor.list_updated_at_gte.orValue(
state.?cursor.last_timestamp.orValue(
(now - duration(state.initial_interval)).format(time_layout.RFC3339)
)
)
],
?"siteIds": state.?site_ids.optMap(v, [string(v)]),
?"cursor": state.?cursor.?next_page.token.optMap(v, [v]),
?"cursor": state.?cursor.next_page.token.optMap(v, [v]),
}.format_query()
).with(
{
Expand All @@ -60,8 +60,8 @@ inputs:
?"token": (body.?pagination.nextCursor.orValue(null) != null) ? optional.of(body.pagination.nextCursor) : optional.none(),
},
"fetch_more": body.?pagination.nextCursor.orValue(null) != null,
"list_updated_at_gte": state.?cursor.?list_updated_at_gte.orValue(
state.?cursor.?last_timestamp.orValue(
"list_updated_at_gte": state.?cursor.list_updated_at_gte.orValue(
state.?cursor.last_timestamp.orValue(
(now - duration(state.initial_interval)).format(time_layout.RFC3339)
)
),
Expand All @@ -87,7 +87,9 @@ inputs:
}
)
)
).as(state,
).as(state, has(state.?events.error) ?
state
:
state.with(
!has(state.?cursor.worklist) ?
state
Expand All @@ -105,14 +107,20 @@ inputs:
"Authorization": ["ApiToken " + state.api_token],
},
}
).do_request().as(resp, (resp.StatusCode == 200) ?
resp.Body.decode_json().as(body,
).do_request().as(resp, (resp.StatusCode == 200 || resp.StatusCode == 404) ?
(
(resp.StatusCode == 200) ?
resp.Body.decode_json()
:
// Do not bother getting 404 body since it's not used.
{}
).as(body,
(body.?pagination.nextCursor.orValue(null) != null).as(has_more_events,
{
"data": has_more_events ? state.cursor.worklist.data : tail(state.cursor.worklist.data),
}.as(new_worklist,
{
"events": (has(body.data) && body.data.size() > 0) ?
"events": (resp.StatusCode == 200 && has(body.data) && body.data.size() > 0) ?
body.data.map(e,
{
"message": e.encode_json(),
Expand All @@ -133,7 +141,16 @@ inputs:
?"token": has_more_events ? optional.of(body.pagination.nextCursor) : optional.none(),
},
"fetch_more": state.?cursor.fetch_more.orValue(false),
"list_updated_at_gte": state.cursor.list_updated_at_gte,
?"list_updated_at_gte": (body.?pagination.nextCursor.orValue(null) != null) ?
optional.of(
state.?cursor.list_updated_at_gte.orValue(
state.?cursor.last_timestamp.orValue(
(now - duration(state.initial_interval)).format(time_layout.RFC3339)
)
)
)
:
optional.none(),
?"last_timestamp": has_more_events ?
state.?cursor.last_timestamp
:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ inputs:
"sortBy": ["updatedAt"],
"sortOrder": ["asc"],
"updatedAt__gte": [
state.?cursor.?list_updated_at_gte.orValue(
state.?cursor.?last_timestamp.orValue(
state.?cursor.list_updated_at_gte.orValue(
state.?cursor.last_timestamp.orValue(
(now - duration(state.initial_interval)).format(time_layout.RFC3339)
)
)
],
?"siteIds": state.?site_ids.optMap(v, [string(v)]),
?"cursor": state.?cursor.?next_page.token.optMap(v, [v]),
?"cursor": state.?cursor.next_page.token.optMap(v, [v]),
}.format_query()
).with(
{
Expand All @@ -49,8 +49,8 @@ inputs:
?"token": (body.?pagination.nextCursor.orValue(null) != null) ? optional.of(body.pagination.nextCursor) : optional.none(),
},
"fetch_more": body.?pagination.nextCursor.orValue(null) != null,
"list_updated_at_gte": state.?cursor.?list_updated_at_gte.orValue(
state.?cursor.?last_timestamp.orValue(
"list_updated_at_gte": state.?cursor.list_updated_at_gte.orValue(
state.?cursor.last_timestamp.orValue(
(now - duration(state.initial_interval)).format(time_layout.RFC3339)
)
),
Expand All @@ -76,7 +76,9 @@ inputs:
}
)
)
).as(state,
).as(state, has(state.?events.error) ?
state
:
state.with(
!has(state.?cursor.worklist) ?
state
Expand All @@ -94,14 +96,20 @@ inputs:
"Authorization": ["ApiToken " + state.api_token],
},
}
).do_request().as(resp, (resp.StatusCode == 200) ?
resp.Body.decode_json().as(body,
).do_request().as(resp, (resp.StatusCode == 200 || resp.StatusCode == 404) ?
(
(resp.StatusCode == 200) ?
resp.Body.decode_json()
:
// Do not bother getting 404 body since it's not used.
{}
).as(body,
(body.?pagination.nextCursor.orValue(null) != null).as(has_more_events,
{
"data": has_more_events ? state.cursor.worklist.data : tail(state.cursor.worklist.data),
}.as(new_worklist,
{
"events": (has(body.data) && body.data.size() > 0) ?
"events": (resp.StatusCode == 200 && has(body.data) && body.data.size() > 0) ?
body.data.map(e,
{
"message": e.encode_json(),
Expand All @@ -122,7 +130,16 @@ inputs:
?"token": has_more_events ? optional.of(body.pagination.nextCursor) : optional.none(),
},
"fetch_more": state.?cursor.fetch_more.orValue(false),
"list_updated_at_gte": state.cursor.list_updated_at_gte,
?"list_updated_at_gte": (body.?pagination.nextCursor.orValue(null) != null) ?
optional.of(
state.?cursor.list_updated_at_gte.orValue(
state.?cursor.last_timestamp.orValue(
(now - duration(state.initial_interval)).format(time_layout.RFC3339)
)
)
)
:
optional.none(),
?"last_timestamp": has_more_events ?
state.?cursor.last_timestamp
:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Test that a 404 on a deleted threat's explore/events endpoint does not

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🔵 Low confidence: medium path: packages/sentinel_one/data_stream/threat_event/_dev/test/scripts/deleted_threat_404.txt:1

The new script test covers the deleted-threat 404 path but nothing exercises the headline fix — preserving a phase-1 error when cursor.worklist already exists with an empty data list; add a script test for that sequence.

Details

The has(state.?events.error) guard added at cel.yml.hbs:93 fixes the case where a phase-1 (threats list) failure is silently discarded: when state.cursor.worklist exists but data is empty, the second phase previously fell through to its {"events": [], "want_more": false} branch and overwrote the error event produced by phase 1, so the failure was never indexed.

Neither test in this data stream asserts that behaviour. deleted_threat_404.txt only covers the explore/events 404 path. auth_errors.txt hits phase-1 failures only on the first two polls, before any cursor exists, so it exercises the !has(state.?cursor.worklist) branch rather than the empty-worklist branch that this PR fixes.

Recommendation:

Add a script test whose mock returns a successful threats list first (so cursor.worklist is created and then drained) and a failure on the next list call, then assert the error event is indexed:

# s1-mock/config.yml
rules:
  - path: /web/api/v2.1/threats
    methods: [GET]
    responses:
      # 1st call: one threat, drains into an empty worklist.
      - status_code: 200
        body: |-
          {"data":[{"id":"threat-001","threatInfo":{"updatedAt":"2024-01-15T10:01:00.000Z"}}],"pagination":{"nextCursor":null}}
      # 2nd call: fails while cursor.worklist exists with an empty data list.
      - status_code: 500
        body: |-
          {"errors":[{"code":5000000,"detail":"Internal error."}]}
# Assert the phase-1 error survives instead of being overwritten by phase 2.
get_docs -profile ${CONFIG_PROFILES}/${PROFILE} -want 3 -confirm 15s -timeout 5m ${DATA_STREAM_NAME}
cp stdout got_docs.json
exec jq '[.hits.hits[]._source | select(.error.code == "500")] | length' got_docs.json
stdout '^1$'

🤖 AI-Generated Review | Vera Review Bot | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

# stall the input. The mock returns two threats: a deleted one (404 on
# explore/events) followed by a valid one (200 with events). The CEL
# program should skip the deleted threat and collect events from the
# valid one.

[!external_stack] skip 'Skipping external stack test.'
[!exec:jq] skip 'Skipping test requiring absent jq command'

use_stack -profile ${CONFIG_PROFILES}/${PROFILE}
install_agent -profile ${CONFIG_PROFILES}/${PROFILE} -network_name NETWORK_NAME
docker_up -profile ${CONFIG_PROFILES}/${PROFILE} -network ${NETWORK_NAME} s1-mock
add_package -profile ${CONFIG_PROFILES}/${PROFILE}
add_package_policy -profile ${CONFIG_PROFILES}/${PROFILE} test_config.yaml DATA_STREAM_NAME

# Wait for 2 threat event documents from the valid threat. The deleted
# threat's 404 produces a "retry" message that the pipeline drops.
get_docs -profile ${CONFIG_PROFILES}/${PROFILE} -want 2 -confirm 15s -timeout 5m ${DATA_STREAM_NAME}
cp stdout got_docs.json

# Verify both documents are real threat events, not error events.
exec jq '[.hits.hits[]._source | select(.sentinel_one.threat_event != null)] | length' got_docs.json
stdout '^2$'

# Verify no error events were indexed.
exec jq '[.hits.hits[]._source | select(.error.message != null)] | length' got_docs.json
stdout '^0$'

remove_package_policy -profile ${CONFIG_PROFILES}/${PROFILE} ${DATA_STREAM_NAME}
uninstall_agent -profile ${CONFIG_PROFILES}/${PROFILE} -timeout 1m
docker_down s1-mock

-- test_config.yaml --
input: cel
vars:
url: http://s1-mock:8080
api_token: test-api-token
data_stream:
vars:
interval: 30s
batch_size: 10
preserve_original_event: true
enable_request_tracer: false
-- s1-mock/docker-compose.yml --
version: '2.3'
services:
s1-mock:
image: docker.elastic.co/observability/stream:v0.20.0
hostname: s1-mock
ports:
- 8080
environment:
PORT: "8080"
volumes:
- ./config.yml:/config.yml
command:
- http-server
- --addr=:8080
- --config=/config.yml
-- s1-mock/config.yml --
rules:
# Threats list: returns two threats. deleted-threat-001 has been removed
# from SentinelOne; threat-002 is still valid. The deleted threat is
# first so the CEL program hits the 404 before reaching the valid one.
- path: /web/api/v2.1/threats
methods: [GET]
request_headers:
Authorization:
- "ApiToken test-api-token"
responses:
- status_code: 200
headers:
Content-Type:
- "application/json"
body: |-
{"data":[{"id":"deleted-threat-001","threatInfo":{"threatName":"DeletedThreat","classification":"Malware","createdAt":"2024-01-15T10:00:00.000Z","updatedAt":"2024-01-15T10:01:00.000Z"}},{"id":"threat-002","threatInfo":{"threatName":"ValidThreat","classification":"Trojan","createdAt":"2024-01-15T11:00:00.000Z","updatedAt":"2024-01-15T11:01:00.000Z"}}],"pagination":{"nextCursor":null,"totalItems":2}}

# Explore events for deleted threat: 404 with SentinelOne error format.
- path: /web/api/v2.1/threats/deleted-threat-001/explore/events
methods: [GET]
request_headers:
Authorization:
- "ApiToken test-api-token"
responses:
- status_code: 404
headers:
Content-Type:
- "application/json"
body: |-
{"code":4040010,"detail":"Threat ID deleted-threat-001 not found","title":"Requested resource was not found"}

# Explore events for valid threat: 200 with 2 events.
- path: /web/api/v2.1/threats/threat-002/explore/events
methods: [GET]
request_headers:
Authorization:
- "ApiToken test-api-token"
responses:
- status_code: 200
headers:
Content-Type:
- "application/json"
body: |-
{"data":[{"id":"event-001","eventType":"DNS","createdAt":"2024-01-15T11:00:00.000Z","agentId":"agent-001","processName":"explorer.exe","pid":"1234","srcIp":"89.160.20.128","dstIp":"127.0.0.1","dstPort":53},{"id":"event-002","eventType":"NETWORK","createdAt":"2024-01-15T11:01:00.000Z","agentId":"agent-001","processName":"chrome.exe","pid":"5678","srcIp":"89.160.20.128","dstIp":"127.0.0.1","dstPort":443}],"pagination":{"nextCursor":null,"totalItems":2}}
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ program: |-
"sortBy": ["updatedAt"],
"sortOrder": ["asc"],
"updatedAt__gte": [
state.?cursor.?list_updated_at_gte.orValue(
state.?cursor.?last_timestamp.orValue(
state.?cursor.list_updated_at_gte.orValue(
state.?cursor.last_timestamp.orValue(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🔵 Low confidence: medium path: packages/sentinel_one/data_stream/threat_event/agent/stream/cel.yml.hbs:43

This PR drops the second ? from state.?cursor.?last_timestamp here (line 43) and in the new phase-two code (line 150), but leaves it in the equivalent expression at line 67, so the same fallback chain is now spelled two ways in one program. Normalise line 67 as well.

Details

Optional chaining propagates absence through the rest of the selection chain, so state.?cursor.last_timestamp and state.?cursor.?last_timestamp behave identically. The diff normalises the query-parameter expression on line 43 and writes the new phase-two expression on line 150 without the extra ?, but the cursor write at lines 66-68 still carries state.?cursor.?last_timestamp. Three copies of one expression, two spellings, makes it harder to see that they are meant to stay in lockstep.

Recommendation:

Drop the redundant ? at line 67 so all three copies match:

                "list_updated_at_gte": state.?cursor.list_updated_at_gte.orValue(
                  state.?cursor.last_timestamp.orValue(
                    (now - duration(state.initial_interval)).format(time_layout.RFC3339)
                  )
                ),

Mirror the change in _dev/test/policy/test-all.expected and _dev/test/policy/test-default.expected.


🤖 AI-Generated Review | Vera Review Bot | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

(now - duration(state.initial_interval)).format(time_layout.RFC3339)
)
)
],
?"siteIds": state.?site_ids.optMap(v, [string(v)]),
?"cursor": state.?cursor.?next_page.token.optMap(v, [v]),
?"cursor": state.?cursor.next_page.token.optMap(v, [v]),
}.format_query()
).with(
{
Expand All @@ -63,8 +63,8 @@ program: |-
?"token": (body.?pagination.nextCursor.orValue(null) != null) ? optional.of(body.pagination.nextCursor) : optional.none(),
},
"fetch_more": body.?pagination.nextCursor.orValue(null) != null,
"list_updated_at_gte": state.?cursor.?list_updated_at_gte.orValue(
state.?cursor.?last_timestamp.orValue(
"list_updated_at_gte": state.?cursor.list_updated_at_gte.orValue(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟠 High confidence: high path: packages/sentinel_one/data_stream/threat_event/agent/stream/cel.yml.hbs:66

The list watermark list_updated_at_gte is carried forward unconditionally and is never cleared, so updatedAt__gte freezes at its first value and every completed pass re-lists the whole threat history; clear it with optional.none() when there is no next page, as the sibling threat stream does.

Details

list_updated_at_gte is meant to pin updatedAt__gte for the duration of one paginated pass over /threats so paging stays stable. Here it is only ever self-referentially preserved: this line computes it as state.?cursor.list_updated_at_gte.orValue(state.?cursor.?last_timestamp.orValue(now - initial_interval)), and phase two (line 141) copies it forward with ?"list_updated_at_gte": state.?cursor.list_updated_at_gte. No branch ever removes it.

Consequences, all present in the current code:

  1. Once the first phase-one request sets the key, orValue never falls through again, so updatedAt__gte is frozen at the value computed on the very first execution.
  2. last_timestamp is advanced in phase two (line 145) but is only reachable through that dead orValue fallback, so the advancing watermark is never consumed.
  3. When a pass finishes, next_page.token is dropped (line 63 resolves to optional.none() on a null nextCursor), so the next phase-one request has no page token and restarts from the frozen timestamp - re-listing every threat and re-issuing a /explore/events request per threat, forever, with the volume growing as threats accumulate. Ingest-side fingerprint dedup hides the duplicate documents but not the API load.

The sibling threat data stream implements the same watermark correctly in packages/sentinel_one/data_stream/threat/agent/stream/httpjson_as_cel.yml.hbs:69: ?"list_updated_at_gte": has_more_list ? optional.of(state.cursor.list_updated_at_gte) : optional.none() - it drops the key when the listing is complete so the next pass starts from last_update_at.

Recommendation:

Make the key optional in phase one and drop it when there is no next page, mirroring the threat data stream. Phase two's ?"list_updated_at_gte": state.?cursor.list_updated_at_gte then correctly carries it only while a pass is still in flight:

                "fetch_more": body.?pagination.nextCursor.orValue(null) != null,
                ?"list_updated_at_gte": (body.?pagination.nextCursor.orValue(null) != null) ?
                  optional.of(
                    state.?cursor.list_updated_at_gte.orValue(
                      state.?cursor.last_timestamp.orValue(
                        (now - duration(state.initial_interval)).format(time_layout.RFC3339)
                      )
                    )
                  )
                :
                  optional.none(),
                ?"last_timestamp": state.?cursor.last_timestamp,

🤖 AI-Generated Review | Vera Review Bot | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

state.?cursor.last_timestamp.orValue(
(now - duration(state.initial_interval)).format(time_layout.RFC3339)
)
),
Expand All @@ -90,7 +90,9 @@ program: |-
}
)
)
).as(state,
).as(state, has(state.?events.error) ?
state
:
state.with(
!has(state.?cursor.worklist) ?
state
Expand All @@ -108,14 +110,20 @@ program: |-
"Authorization": ["ApiToken " + state.api_token],
},
}
).do_request().as(resp, (resp.StatusCode == 200) ?
resp.Body.decode_json().as(body,
).do_request().as(resp, (resp.StatusCode == 200 || resp.StatusCode == 404) ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: medium path: packages/sentinel_one/data_stream/threat_event/agent/stream/cel.yml.hbs:113

Folding 404 into the 200 branch sends the 404 body through resp.Body.decode_json(), so an empty or non-JSON 404 body aborts the whole CEL evaluation; give 404 its own branch that advances the worklist without decoding, as the sibling threat stream does.

Details

Accepting 404 here routes the response into resp.Body.decode_json() on line 114. That is safe only for SentinelOne's JSON error document. For any 404 whose body is empty or not JSON - the case the program's own error branch explicitly anticipates on line 158 with size(resp.Body) != 0 - decode_json() raises a CEL evaluation error, which aborts the entire program run: no events are published, the cursor is not advanced, and the input logs an evaluation failure every interval instead of skipping the threat. The 404 path is now a normal, expected path in this program, so it should not depend on the body being well-formed JSON.

The sibling threat data stream keeps 404 in a dedicated branch that never decodes the body (packages/sentinel_one/data_stream/threat/agent/stream/httpjson_as_cel.yml.hbs:164-176): it emits its placeholder event, advances worklist with tail(...), clears next_chain, and moves the timestamp forward.

Splitting the branch also removes the two now-conditional expressions the shared branch needs - resp.StatusCode == 200 && has(body.data) ... on line 120 and the has_more_events computation on line 115, which can only ever be false for a 404.

Recommendation:

Restore 200 as the sole decode branch and handle 404 separately:

        ).do_request().as(resp, (resp.StatusCode == 200) ?
          resp.Body.decode_json().as(body,
            # ...unchanged 200 handling...
          )
        : (resp.StatusCode == 404) ?
          {
            "events": [{"message": "retry"}],
            "want_more": state.?cursor.fetch_more.orValue(false) ?
              state.cursor.fetch_more
            :
              size(state.cursor.worklist.data) > 1,
            "cursor": {
              "worklist": {"data": tail(state.cursor.worklist.data)},
              "next_page": {
                ?"token": state.?cursor.next_page.token,
              },
              "next_chain": {},
              "fetch_more": state.?cursor.fetch_more.orValue(false),
              ?"list_updated_at_gte": state.?cursor.list_updated_at_gte,
              ?"last_timestamp": state.cursor.worklist.data[0].?threatInfo.updatedAt.or(state.?cursor.last_timestamp),
            },
          }
        :
          # ...unchanged error branch...
        )

🤖 AI-Generated Review | Vera Review Bot | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

(
(resp.StatusCode == 200) ?
resp.Body.decode_json()
:
// Do not bother getting 404 body since it's not used.
{}
).as(body,
(body.?pagination.nextCursor.orValue(null) != null).as(has_more_events,
{
"data": has_more_events ? state.cursor.worklist.data : tail(state.cursor.worklist.data),
}.as(new_worklist,
{
"events": (has(body.data) && body.data.size() > 0) ?
"events": (resp.StatusCode == 200 && has(body.data) && body.data.size() > 0) ?
body.data.map(e,
{
"message": e.encode_json(),
Expand All @@ -136,7 +144,16 @@ program: |-
?"token": has_more_events ? optional.of(body.pagination.nextCursor) : optional.none(),
},
"fetch_more": state.?cursor.fetch_more.orValue(false),
"list_updated_at_gte": state.cursor.list_updated_at_gte,
?"list_updated_at_gte": (body.?pagination.nextCursor.orValue(null) != null) ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: medium path: packages/sentinel_one/data_stream/threat_event/agent/stream/cel.yml.hbs:147

The new ?"list_updated_at_gte" clear is keyed on the explore/events page cursor, but the worklist only advances on that same condition, so the value is cleared every time the worklist moves and is always absent when the list request reads it. Key the clear on the threats-list pagination (fetch_more) instead.

Details

body here is the /explore/events response, so the guard (body.?pagination.nextCursor.orValue(null) != null) is exactly has_more_events, already bound on line 121. The worklist advances (tail(...), line 123) under precisely the negation of that condition. So list_updated_at_gte is written only in states where the worklist has NOT advanced, and it is cleared on every step that does advance it. The first phase (line 42, and its own write at line 66) only runs when the worklist is empty, and the worklist can only become empty via a tail() step that cleared the key — so state.?cursor.list_updated_at_gte at line 42 is always absent and updatedAt__gte always falls back to last_timestamp. The freeze this key exists to provide never takes effect: when the threats list itself is paginated (fetch_more true, next_page.token set), the continuation request is sent with an updatedAt__gte that has moved to the last processed threat's updatedAt rather than the value the first page was queried with. The sibling threat stream keys the same key on the list-level has_more_list (packages/sentinel_one/data_stream/threat/agent/stream/httpjson_as_cel.yml.hbs:69), which is the signal intended here.

Recommendation:

Clear on the threats-list pagination state (fetch_more, set from the list response at line 65) rather than on the events page cursor, mirroring the threat stream:

                    ?"list_updated_at_gte": state.?cursor.fetch_more.orValue(false) ?
                      optional.of(
                        state.?cursor.list_updated_at_gte.orValue(
                          state.?cursor.last_timestamp.orValue(
                            (now - duration(state.initial_interval)).format(time_layout.RFC3339)
                          )
                        )
                      )
                    :
                      optional.none(),

Apply the identical change to the two rendered policy expectations (_dev/test/policy/test-all.expected, _dev/test/policy/test-default.expected).


🤖 AI-Generated Review | Vera Review Bot | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: medium path: packages/sentinel_one/data_stream/threat_event/agent/stream/cel.yml.hbs:147

The new ?"list_updated_at_gte" retention is gated on the event-chain cursor (body.pagination.nextCursor from explore/events) instead of the threat-list cursor (cursor.fetch_more), so the list watermark is dropped while list pagination is still in flight; gate it on state.cursor.fetch_more instead.

Details

list_updated_at_gte pins the updatedAt__gte filter used by the phase-1 threats-list request (line 42) so that it stays constant while the list is paginated with cursor.next_page.token. The new conditional keeps it only when body.?pagination.nextCursor != null, but in this branch body is the explore/events response for a single threat, so that expression is has_more_events — it describes the event chain of the current threat, not the threats list.

The two conditions are effectively inverted with respect to what the pin is for:

  • While an event chain is paginating (has_more_events true) the worklist is non-empty, so phase 1 does not run and the pin is never read.
  • While the threats list is paginating (cursor.fetch_more true, cursor.next_page.token set) the pin IS read by phase 1, but it is exactly the case where this code drops it.

Concretely: after phase 1 returns page 1 of the threats list with a nextCursor, the first threat processed clears list_updated_at_gte, so when the worklist drains phase 1 re-issues the list request with the same cursor token but a different updatedAt__gte (now last_timestamp, the updatedAt of the last threat from page 1). The filter sent alongside an in-flight cursor changes between pages, and because updatedAt__gte is inclusive the boundary threat is re-selected and its explore/events re-fetched.

The sibling threat data stream implements this correctly: in packages/sentinel_one/data_stream/threat/agent/stream/httpjson_as_cel.yml.hbs the pin is retained with ?"list_updated_at_gte": has_more_list ? optional.of(state.cursor.list_updated_at_gte) : optional.none(), where has_more_list is the list response's nextCursor.

Recommendation:

Gate the pin on the threats-list pagination state (cursor.fetch_more), matching the threat data stream:

"fetch_more": state.?cursor.fetch_more.orValue(false),
?"list_updated_at_gte": state.?cursor.fetch_more.orValue(false) ?
  optional.of(
    state.?cursor.list_updated_at_gte.orValue(
      state.?cursor.last_timestamp.orValue(
        (now - duration(state.initial_interval)).format(time_layout.RFC3339)
      )
    )
  )
:
  optional.none(),

Remember to regenerate _dev/test/policy/test-all.expected and _dev/test/policy/test-default.expected after the change.


🤖 AI-Generated Review | Vera Review Bot | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: medium path: packages/sentinel_one/data_stream/threat_event/agent/stream/cel.yml.hbs:147

The new ?"list_updated_at_gte" guard tests the explore/events cursor (has_more_events) rather than the threats-list pagination state, so the pinned list window is dropped while list pagination is still in flight; key the retention off state.cursor.fetch_more instead.

Details

list_updated_at_gte exists to pin updatedAt__gte on the phase-1 threats-list request so the value stays constant while the cursor-paginated list is walked. The new guard retains it only when body.?pagination.nextCursor != null, where body is the explore/events response for the current threat — i.e. exactly has_more_events, which is about event pagination inside one threat, not about list pagination.

Those two conditions are inverted with respect to when the value is read. When has_more_events is true the worklist is not consumed (line 123 keeps state.cursor.worklist.data), so phase 1 is skipped on the next execution and the retained value is never read. When has_more_events is false the worklist entry is popped and the value is dropped — including the case where fetch_more is still true and more threats-list pages remain.

Concrete effect on the multi-page path: want_more (line 134) is unconditionally true while fetch_more is true, so as soon as the worklist drains the input immediately re-enters phase 1 with next_page.token still set (line 141 preserves it) but with list_updated_at_gte now absent. updatedAt__gte therefore falls back to last_timestamp, which phase 2 has advanced to the last processed threat's threatInfo.updatedAt (line 160). The continuation request for page N+1 is sent with the page-N cursor token but a different updatedAt__gte than the request that produced that token. The only path on which the retained value is ever read back is the empty-list branch at line 185, where the pin then sticks indefinitely — the opposite of the intended behaviour.

This change is also not described in the 2.11.3 changelog entries, which cover only the phase-1 error handling, the deleted-threat 404, and the on_failure message formatting.

Recommendation:

Gate the retention on the threats-list pagination flag (fetch_more) rather than the events cursor, so the window stays pinned for as long as the list cursor is being walked and is cleared once the list is exhausted:

                    ?"list_updated_at_gte": state.?cursor.fetch_more.orValue(false) ?
                      optional.of(
                        state.?cursor.list_updated_at_gte.orValue(
                          state.?cursor.last_timestamp.orValue(
                            (now - duration(state.initial_interval)).format(time_layout.RFC3339)
                          )
                        )
                      )
                    :
                      optional.none(),

Apply the same edit to the rendered policy fixtures (_dev/test/policy/test-all.expected and test-default.expected), and add a changelog entry describing the cursor-window change.


🤖 AI-Generated Review | Vera Review Bot | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

optional.of(
state.?cursor.list_updated_at_gte.orValue(
state.?cursor.last_timestamp.orValue(
(now - duration(state.initial_interval)).format(time_layout.RFC3339)
)
)
)
:
optional.none(),
?"last_timestamp": has_more_events ?
state.?cursor.last_timestamp
:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,7 @@ processors:
tag: convert_file_size_to_long
target_field: file.size
type: long
if: ctx.sentinel_one?.threat_event?.file.size != ''
if: ctx.sentinel_one?.threat_event?.file?.size != ''
ignore_missing: true
on_failure:
- append:
Expand Down Expand Up @@ -907,7 +907,7 @@ processors:
on_failure:
- append:
field: error.message
value: |-
value: >-
Processor '{{{ _ingest.on_failure_processor_type }}}'
{{{#_ingest.on_failure_processor_tag}}}with tag '{{{ _ingest.on_failure_processor_tag }}}'
{{{/_ingest.on_failure_processor_tag}}}failed with message '{{{ _ingest.on_failure_message }}}'
Expand Down
2 changes: 1 addition & 1 deletion packages/sentinel_one/manifest.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
format_version: "3.4.0"
name: sentinel_one
title: SentinelOne
version: "2.11.2"
version: "2.11.3"
description: Collect logs from SentinelOne with Elastic Agent.
type: integration
categories:
Expand Down
Loading