Repository navigation
Added
-
A connector must now read its poll window to the end, and CI enforces it.
(#1714)
scripts/check_connector_paging.pyrequires every connector whose
fetch_alertsissues an HTTP request to either follow a continuation — the
sharedapp/connectors/_paging.pyhelpers, its own paging loop, or a boto3
paginator that is not cut short byMaxItems— or carry a written reason in
_REVIEWEDrecording what the vendor's own documentation says it returns.
Six connectors shipped the same defect inside two days, so this stopped
being a review question.The gate has no silent third state, which was the design problem worth
solving. "I could not find a page cap" and "this vendor returns the whole
window" are different statements and only the second is a reason, so a
connector that neither pages nor carries a reason is named in the output
on every run and counted.MAX_UNCLASSIFIEDis a debt balance rather than
a target: it may only be lowered, and lowering it means somebody read a
vendor's document and wrote down what they found. Twenty of the connectors
it names were identified by the audit as matching the data-loss shape but
could not be confirmed against a citable vendor document —slack_audit,
carbon_black,cloudflare,cortex_xdr,cortex_xsiam,github,
gitlab,salesforce,servicenow,crowdstrikeand the two AWS
connectors whose paginators carry a silentMaxItemscap among them. A
change that cites the vendor contract for any one of them and then fixes or
clears it is exactly the contribution that is wanted.The detector keys on loop shape rather than on a vocabulary of cursor
names. The first cut matchednextLink,pageTokenand their siblings and
flagged every connector that pages by page number or offset — Auth0,
CrowdStrike and Cisco Umbrella among them, all correct — and a gate that
flags correct code is switched off within a week.
Fixed
-
A fusion worker that lost its broker reported the loss and could never
undo it. (#1725, closing
#1724 — thanks
@alexmateescu for the analysis and the
original patch.)FusionWorker.start()ran one start/consume/stop cycle, so
any exception that escaped the consume loop — a broker not up yet at cold
boot, a connection lost mid-stream — ended the task, and nothing started it
again. Recovery meant a human noticing and restarting the container.Reporting the death and healing from it are separate properties and only the
first was in place:app/main.pyalready registersattachedas a
readiness subscription andbroker_reachableas a bounded dependency
probe, so/readyzhas answered 503 withdetached: ["alerts+raw_events"]
since those landed. Verified rather than assumed, by driving the real worker
and the real route: healthy reads 200, dead reads 503, and the dead one
never came back. The missing half was the coming back, and no second probe
was added —attachedis already false for the whole of a backoff window,
so one probe covers both directions and a second reading the same flag would
publish the same answer under a different name.start()now supervises, retrying with capped exponential backoff, and
readiness returns to 200 on its own when consumption resumes. Three
properties a bare retry loop would have got wrong, each with a test that
fails when it is reverted:- A retry is attempted only where retrying can help. The same three
classesstreamhealthgives the Go consumer inservices/ingest: a
transient fault backs off; a permanent one — a refused credential, a
missing ACL, an unsupported SASL mechanism — stops and names the operator
action, because a loop retrying a misconfiguration every 60 seconds turns
it into churn an operator reads as ordinary noise; a poison message is
still dead-lettered by the consume loop, which is the right place for it.
An unrecognised error is treated as transient and escalated to an error
naming where to look once it has outlived any transient explanation. - Teardown runs exactly once per attempt. A bootstrap failure is the
commonest retry there is, and the next attempt reassigns the Kafka
handles, so an attempt that raised before reaching the consume loop left
both clients open and unreachable — measured at six leaked consumers and
six leaked producers over seven attempts. Teardown now covers the
bootstrap as well as the loop, and is idempotent so the two callers that
legitimately race on shutdown do not stop the alert sink twice. - A healthy worker is unaffected. It attaches, builds exactly one
consumer and is never restarted — a supervisor stuck retrying reads as
working caution, which has shipped here before.
- A retry is attempted only where retrying can help. The same three
-
The cancelled-run gate on
mainoverstated the window it graded by about
100x, so it went green by forgetting.
(#1723)
scripts/check_main_run_cancellations.pyprintedgraded window created at or after 2026-09-29— about five weeks — on a fetch of 300 push runs, which
at this push rate is 8.8 hours. Roughly sixty workflows run on every
push, so the constant and the fetch were describing completely different
spans and only the constant was printed. The finding logic was never wrong;
the coverage it claimed was. The consequence is the one that matters: a
cancelled run ages out of reach within a day, so the gate reports clean
because the evidence scrolled past rather than because anyone fixed
anything — a passing condition that is not the property it claims, inside
the gate written to catch exactly that.SINCEturned out to be unreachable by construction. The Actions
workflow-runs API refuses any result past offset 1,000 for this query —
per_page=100page 11 andper_page=50page 21 both answer empty, the
same boundary from two page sizes, so it is a result-offset cap and no
paging strategy gets past it — and 1,000 push runs reaches 20.6 hours.
No value ofFETCH_LIMITcould ever have made the printed claim true.The window is now derived from the oldest run actually returned and printed
with both durations it has: the span those runs cover, which is the rate
that decides how fast a finding ages out, and how far back that reaches
from now.SINCEis kept as a declared floor and reported as reached or
NOT REACHED rather than printed as though it had been graded, because the
relationship is not permanent — if push volume fell, 1,000 runs would reach
past it again. The warning against moving the date to silence a finding is
unchanged and now sits beside a separate note on why the date is currently
out of reach.FETCH_LIMITis raised 300 → 1,000, taken once the backlog in
reach was measured at zero: over the full depth there were 3 cancelled runs,
allpublish-images.yml, already excepted with a written justification.Two things the output now says that it could not before. The horizon is
printed every run — a cancellation has to be noticed and re-run inside about
a day or it is not findable here at all, which is an operational deadline
rather than a footnote — and a populated fetch whose runs carry no readable
created_atnow fails instead of reporting clean. Those runs were
previously dropped insideclassify, leaving a count of zero that was read
as a young window and exited 0: a window that cannot be established is an
unknown, and an unknown is never a pass. -
The grading-integrity gate was being killed by its own timeout, and a
killed job reportscancelled— which the gate beside it correctly reads
as a commit nothing graded.
(#1721)timeout-minutes: 15
on therequired-check-substancejob was not derived from anything. Over
the 100 most recent runs of the workflow (2026-11-03 22:14Z → 2026-11-05
13:49Z) it completed successfully 31 times: fastest 8m13s, median 12m51s,
slowest 14m30s. The ordinary worst case finished about 3% inside its
own ceiling, so any slowdown in the Actions API pushed it over — and it
did, one run killed at 15m16s, three red boards on a non-defect in two
days.The failure mode is what makes the number worth a release note rather than
a line in a diff. GitHub reports a job that hitstimeout-minutesas
cancelled, notfailed, socheck_main_run_cancellations.pyin the
same workflow then reports — accurately — that a commit onmainwent
ungraded. The gate was right and the budget was what was broken, which is
the one case where moving a threshold is the fix rather than the evasion.
It is now 40 minutes, sized from the measured 14m30s plus the 300-second
rate-limit ceiling below, doubled; it still binds, so a genuinely hung job
dies here instead of running to the six-hour default.The rate limit is the other half, because raising a budget without it would
have traded a timeout for a hard failure. GitHub answers both a primary and
a secondary rate limit with 403 or 429, so the status code alone cannot
separate "you are going too fast" from "this token may not read this" — and
waiting on the second would hang the gate on an answer that will never
change. The discriminator is the headers, as GitHub documents it: a
rate-limited answer carriesretry-afterorx-ratelimit-remaining: 0, and
a 403 carrying neither is still raised immediately. Pauses are bounded
three ways — a singleretry-afteris honoured only up to 60 seconds, so
the API cannot decide when this job dies; the process may spend 300 seconds
in total across every call it makes; and spending it leaves the gate
reporting "could not read", which is still a failure, because an
unknown is never a pass — but an honest one naming the API rather than a
cancelledconclusion that reads as a grading hole.check_main_run_cancellations.pykept its own single-attempt copy of the
same request and now delegates to that reader. The two gates run in the
same workflow against the same API, so whatever makes one unreadable makes
the other unreadable at the same moment, and only one of them rode it out.
The semgrep ceiling falls 114 → 113 as a consequence rather than as a
concession: deleting that duplicated request removed one real
dynamic-urllib-use-detectedcall site, attributed by scanning the single
file atmainand on the branch with the same pinned scanner and vendored
packs (1 finding against 0) because two runs reading 113 against a ceiling
of 114 is also what this scanner's recorded ±1 drift looks like. No
suppression was added anderroris unchanged at 44, which is the control:
the drift moveserrorandtotaltogether. -
Google Workspace OAuth token grants and revocations had never once been
ingested, on any deployment.
(#1713) Google withholds a
Reports API log event until it has published it, and that delay is
documented per application across three orders of magnitude: Admin, Drive,
Devices and Login events are "near real time (couple of minutes)" while
Token log events are "a couple of hours".fetch_alertssent only
startTime, and withendTimeunset the report "returns all activities
from the startTime until the current time" — a window pinned tonow. The
next poll began where that one ended, so the unpublished tail of every
window was read once while empty and never requeried. For Token, whose lag
exceeds the default 300-second interval by a factor of about twenty-four,
no poll ever overlapped the published part of the timeline at all: the loss
was not a fraction of that feed, it was the whole of it.Each window now ends where Google has published rather than at
now, per
application, and keeps its length so consecutive polls stay contiguous
across that boundary. Each figure sits above the documented one, because
the same page warns that some events are delayed beyond the stated times
and undershooting puts the window back inside the band Google has not
published. An application absent from the table takes a conservative
default rather than a window pinned tonow, so adding one cannot silently
reintroduce the defect. The cost is latency; the alternative was silence. -
Microsoft Entra directory audits and risky sign-ins never followed
@odata.nextLink, and what truncation discarded was the oldest part of a
burst. (#1708) Graph returns
$topentries and an@odata.nextLinkwhile more of a collection remains.
Both feeds read the first page at$top=200and stopped.fetch_alerts
derives its window from the clock with no checkpoint and the scheduler
passes the poll interval assince_seconds, so consecutive windows abut
exactly and nothing re-reads an earlier one — anything past the first page
fell below the next window's lower bound and was never requested again.
Both queries are ordered newest-first (activityDateTime descfor directory
audits,createdDateTime descfor risky sign-ins), so what went missing was
the beginning of whatever burst overflowed the window, which is the part
an investigation needs. Both feeds now follow the link as-is through a
shared_read_pages()helper, bounded and logged when the bound binds.Independently diagnosed and fixed by
@rudycelekli. -
Microsoft Defender and Sentinel each read one page of every poll window.
(#1711) Graph answers
Defender's$top=200alerts request with a first page and an
@odata.nextLink; Azure Resource Manager answers Sentinel's$top=100
incidents request with a first page and anextLinkcarrying a
$skipToken. Neither was followed, and the contiguous-window argument above
applies unchanged: the remainder of the window was never requested again.
Defender's query is orderedcreatedDateTime desc, so there too it was the
earliest alerts of a burst that went missing.Both now use
read_linked_pages()from the new
services/connectors/app/connectors/_paging.py, which holds three
properties, each of which is a defect somebody has shipped: a partial read
is kept rather than thrown away when a later page fails, the page cap is
announced when it binds rather than silently cutting the result, and the
link is requested verbatim — Graph states outright that the$skiptoken
must not be extracted and reused in a different request. -
Google Cloud Audit, Security Command Center and Workspace each read one
page per window, and Cloud Logging could return nothing at all from a window
that was full of events.
(#1712) All three page by
nextPageTokenand none of them followed it. Cloud Audit read 200 entries
of atimestamp descquery; SCC read onelistFindingspage; Workspace's
loop walked a fixed set of applications issuing one request each, so a
single noisy application —driveon a busy tenant — displaced the rest of
the window behind it.Cloud Logging's variant is worse than truncation, and it is why the loop
ends on the token rather than on an empty page. Its reference states that a
nextPageTokenreturned with an emptyentriesfield means the search
"found no log entries so far but it did not have time to search all the
possible log entries", and that the method should be retried with that
token. A connector that stopped at the first empty response therefore
returned zero events from a full window, which reads downstream as a
quiet estate rather than as a read that did not finish.All three now go through a shared
read_token_pages()in the same
_paging.py. The Google Workspace half of this was independently diagnosed
and fixed by @rudycelekli in
#1715; that fix was correct
and lost only to the shared-helper version landing first as595ab1aa7. -
Wiz issues past the first 100 of a poll window were never ingested.
(#1720)
WizConnector.fetch_alertssentfirst: 100, readdata.issues.nodes
once and returned. There was nopageInfoin the query, no cursor and no
loop — while the same module already defined a correct_paginate()that
walkspageInfoto the end, and the three context reads added later all
used it. The alert path was the one read that did not. The window is clock
derived and contiguous like every other connector in this family, so a
tenant raising more than one page of issues in a poll interval lost the
remainder permanently;apply_checkpoint()cannot recover it, because it
de-duplicates rows the vendor already returned and never widens a later
request.fetch_alertsnow goes through that same_paginate(), so the alert path
inherits the cursor walk, the page cap and the behaviour of raising on a
GraphQLerrorsblock rather than reading a missing scope as a clean cloud
estate. The issues query gains$afterand apageInfoselection, which
the request needs in order to mean anything: a GraphQL server coerces only
the variables an operation declares, so sendingafterto a query that did
not declare it would have been silently ignored. The test drives the real
fetch_alertsthrough a transport serving three pages and asserts both the
issues returned and the cursor the connector sent, because a canned page
sequence will answer any request and cannot by itself tell a correct
implementation from a wrong one.MAX_UNCLASSIFIEDin the paging gate
falls from 59 to 58 with it. -
Entra directory-audit alerts named the Microsoft service that performed a
change, never the user, group or device that changed.
(#1722, reported in
#1718 by
@gdncomm-dika-wibowo)
Measured across ten alerts from one tenant: an "Add member to group" alert
readaffected_users: ["Microsoft Teams Services"]and named neither the
member added nor the group, and a device update carried no host at all. Six
causes across four layers, and a fix in any one of them reaches nothing —
the fusion promoter builds an alert'susernamefrom OCSF
actor.user.nameand itshostnamefromdevice.name, so a target
sitting anywhere else reached no column, no entity and no correlation key.azure_entra.normalize()now prefersinitiatedBy.user.userPrincipalName
for the actor: Graph puts a first-party client inuser.displayNamewhen a
person acts through one, so the display name named Teams, Intune or the
password-reset service as the actor on events a person performed. The
display name is carried asactor_display_namerather than dropped,
because nothing downstream can tell from the value alone whether that slot
held a person or a client._target_fieldskeeps every target and every
modified property —modifiedProperties[0]on a device update is the empty
Included Updated Propertiesmarker, so a device's id, OS and trust type
were lost on every device event. Its docstring had refusedtarget_user_2
on the reasoning that a rule written against it would pass its fixture and
miss the real shape; that hazard is position, not plurality, so nothing
added is positional —targetsandmodified_propertiesare keyed by what
a thing is, andtarget_group_name/target_hostnameare found by
searching for the group or the device wherever Graph put it. The original
flat fields still describetargetResources[0]byte for byte.The ingest normalizer maps
target_useronto OCSFtarget_user.nameand
target_hostnameontodevice.name(nottarget_name, which is the
display name of whatever the target is and would put a person in the host
column), and the fusion sink lists the target beside the actor in both
affected_usersandentitiesfrom one shared helper, so the two can
never name a different set of people. A device change now also correlates
per device instead of collapsing every device event in the tenant onto the
Intune client that performed them.det-identity-210("Entra User Password Reset By An Administrator", high)
required onlyactivity == "Reset user password", so every self-service
reset raised a high-severity administrator reset. It now also requires
initiator_type == "user". One rule with a clause rather than a split: the
defect is that the condition did not match the rule's own name, and an SSPR
rule would be new coverage rather than a fix. The id is unchanged, no new
id was allocated, and the negative fixture is a real self-service reset
rather than a flipped literal.method_execution_resultadditionally
surfaces the directory's own result, so an "add member" that returned
DirectoryValueExistsExceptionis distinguishable from a real add without
rewriting Graph's ownresult: success.Not fixed, and recorded as such: the group is available to rules and on the
alert's raw event but is not an entity chip, because the Investigation
Rail's entity vocabulary has no group kind and adding one is an API change
the report did not ask for. Nothing here was exercised against a live Entra
tenant; the fixtures are the anonymised shapes from the report and Graph's
published schema. -
The Agentmetry connector page said its live leg was "not verified" after
somebody had verified it, so the one claim that was wrong was the modest
one. (#1719, contributed by
@blitzcrieg1, Agentmetry's maintainer)
A flat "not verified" reads as honest and functions as an exemption: it
says nothing about what was never run, so nobody can tell a leg that
failed from one nobody attempted, and nobody can tell which part is still
open. It had in fact been run — on 2026-11-02, Agentmetry 0.10.4 installed
from PyPI postingcanonicalevents over HTTP to a local AiSOC v21 stack
brought up withmake upandmake smokepassing 10 of 10 first, at an
inbox minted with this template. 11 of 11 events were forwarded and
accepted with none dead-lettered; both detections (credential-exfil,
remote-staging-then-execute) arrived as critical alerts carrying their
ATT&CK techniques; the nine routine events — tool calls, a denial, session
start and end — raised no alert; andclaudeandcursorappeared in the
AI estate with the operator attached.The page now states that run with its date, its versions and its result,
and says plainly that no CI job repeats it, so a later change on either
side can break it without a gate failing. What remains unverified is named
rather than implied: a deployment with the sensor and AiSOC on different
hosts, and any AiSOC release after v21.The claim-matrix row stays GATED and only its caveat moves. The gated
claim — that Agentmetry telemetry lands split, routine tool calls in the
lake and correlated detections as alerts with severity and ATT&CK ids
preserved — is proved by the same ingest, fusion and graph tests as before
and did not change. Promoting a row because a human ran something once
would make the matrix a record of what somebody remembers rather than of
what CI enforces, which is the failure the file exists to prevent. -
Published figures re-derived from the tree, in two passes, because the
first one did not reach every surface.
(#1709 and this release's
documentation sweep) #1709 corrected nine surfaces that published a
detection-corpus figure counted before the corpus moved — 2,511 / 2,530 /
2,535 against 2,966 executable, 6,991 / 7,015 against 7,407 on disk — and
found the claim matrix's connector row stale in both cells while its gate
printed OK, because that gate reconciles the README phrase against the
generated artefact and never reads the table.The sweep for this release found the surfaces the first pass missed, which
is the useful half: the glossary, the feature index, the operations FAQ,
detections/README.md,services/connectors/README.mdand the funnel-KPI
page were each still quoting a superseded count, and
docs/architecture/README.mdsaid "of the 87 declared connector types, 45
name a class of their own and the other 42" against a real 91 / 46 / 45. A
figure gated at one claim site goes stale at every other site silently,
so correcting the gated one is not the same as correcting the figure.Two numbers were deliberately not rewritten. The fusion rule-index
measurement in
ADR-0012
and on the performance page was taken against a 2,511-rule corpus and was
not re-run, so restating it against 2,966 would be falsifying a
measurement rather than refreshing a claim. Both now say what corpus they
were measured on and that the ratio, not the absolute count, is the part
that carries forward.The claim matrix gains rows for the two capabilities this release shipped
without one — the connector paging gate and fusion worker supervision —
and its "rules that cannot fire" row is corrected from a ratchet of 45 to
the 2 the gate actually enforces. The matrix moves 398 rows / 389
GATED to 400 / 391, with 9 PARTIAL and 0 NO GATE unchanged; the
summary is recomputed withcheck_claim_gate_matrix.py --writerather
than hand-edited, because that figure is published in three places and a
disagreement between them reds every open pull request.The technical guide is re-rendered with the pinned toolchain and gains the
poll-window contract and three new troubleshooting rows; it is 28 pages
now rather than 27, and the README badge moves with it.