Files
vulncheck/tests/test_msrc_edge_flap.py
T
vulncheck ad2abc63d4 fix(msrc): a 429 from the MSRC API no longer drops the nightly catch-up
On 29.10.2026 at 04:34 the remediation catch-up made one call to
/cvrf/v3.0/updates, got 429 Too Many Requests and aborted, although both
documents it needed were cached by the 01:30 index build. MSRC documents no
quota, offers no API key (v3.0 is anonymous) and sends neither Retry-After nor
rate-limit headers, so the throttling has to be absorbed on our side.

- Every CVRF call goes through _get. It retries 429, 5xx and network errors
  after 20/60/180 s (or Retry-After), and other 4xx raise at once.
  MsrcUnavailable stops both document walks instead of hammering on.
- A failed /updates falls back to the last listing that worked. Documents
  never leave /updates, so an index built from it cannot lose a month.
- CVRF documents stay cached per revision for 6 days instead of 4 h. The
  01:30 build downloaded all 18 documents (20-30 MB each, uncompressed) every
  night. Now it only downloads the revised ones.
- The on-demand Update Guide call no longer caches a 429/5xx as "CVE not in
  MSRC" until restart.
- Failed documents in the ingest and scan errors are logged as warnings;
  every caller dropped them from its log line.
2026-10-29 10:26:06 +01:00

197 lines
7.4 KiB
Python

"""MSRC Edge findings must not flip open/patched, and only Edge's newest build
counts when the inventory lists it several times.
Run: python tests/test_msrc_edge_flap.py
Field reports 09./10.10.2026:
1. Wazuh-only Server 2016, one Edge 152.0.4191.62, fix 154.0.4258.37. At
09.10. 12:08:03, 15 MSRC-only Edge CVEs went open → patched with "MSRC scan:
… is now at or past the fixed build", then patched → open with "MSRC
fixed-build scan reports this CVE again". build_product_index skipped a
CVRF document that failed to load and stored the rest, and the package
reconcile read every CVE missing from that index as fixed.
2. Intune-only: "Microsoft Edge" 154.0.4258.62 + 152.0.4191.53 +
"Microsoft.MicrosoftEdge.Stable" 152.0.4191.53. Intune's app inventory
lists the device-wide Edge at 154.x; the 152 entries are stale per-user
registrations, and the scan flagged the 152 one.
3. Intune-only: "Microsoft Edge" 153.0.4234.32 (stale) next to the MSIX
"Microsoft.MicrosoftEdge.Stable" 154.0.4258.37, the real build.
"""
import json
import os
import sys
import types
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
for _name in ("lxml", "lxml.html"):
sys.modules.setdefault(_name, types.ModuleType(_name))
sys.modules["lxml"].html = sys.modules["lxml.html"]
import httpx # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
import app.models # noqa: F401,E402
from app.models.base import Base # noqa: E402
from app.models.asset import Asset, AssetSource, AssetStatus # noqa: E402
from app.models.vulnerability import Vulnerability # noqa: E402
from app.services import msrc_scan_service as m, msrc_service # noqa: E402
CVE, FIX = "CVE-2026-95385", "154.0.4258.37"
OCT = {"ProductTree": {"FullProductName": [
{"ProductID": "11655", "Value": "Microsoft Edge (Chromium-based)"}]},
"Vulnerability": [{"CVE": CVE, "Remediations": [
{"Description": {"Value": "Release Notes"}, "ProductID": ["11655"],
"Type": 2, "FixedBuild": FIX}]}]}
FAIL = set()
class _Resp:
status_code = 200
text = ""
def __init__(self, payload):
self._p = payload
def raise_for_status(self):
pass
def json(self):
return self._p
class _FakeHttpx:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
pass
def get(self, url, headers=None, **k):
if url.endswith("/updates"):
return _Resp({"value": [{"ID": "2026-Sep"}, {"ID": "2026-Oct"}]})
if any(url.endswith(f"/cvrf/{d}") for d in FAIL):
raise httpx.ReadTimeout("timed out")
if url.endswith("/cvrf/2026-Oct"):
return _Resp(OCT)
return _Resp({"ProductTree": {"FullProductName": []}, "Vulnerability": []})
def _edge(version, name="Microsoft Edge"):
return {"name": name, "version": version, "vendor": "Microsoft Corporation"}
def demo():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
def host(name):
a = Asset(hostname=name, operating_system="Microsoft Windows Server 2016 Standard",
os_version="10.0.14393.9418", source=AssetSource.WAZUH,
wazuh_agent_id=name, status=AssetStatus.ACTIVE)
db.add(a)
db.flush()
return a
def app_scan(a, packages, index):
"""What the app scan's MSRC package pass does per asset."""
touched = set()
m.scan_asset_packages(db, a, packages, index, [], touched=touched)
m.resolve_stale_packages(db, a, touched, packages)
db.commit()
def status(a):
v = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == a.id, Vulnerability.cve_id == CVE)
.first())
return v.status.value if v else None
old_client, httpx.Client = httpx.Client, _FakeHttpx
old_waits, msrc_service._RETRY_WAITS = msrc_service._RETRY_WAITS, (0, 0, 0) # the timeout is retried
try:
full = m.build_product_index(db, months_back=2)
assert [e["cve"] for e in full["edge"]] == [CVE], full
# ---- 1. a document that fails to load never becomes the index ----
FAIL.add("2026-Oct")
try:
m.build_product_index(db, months_back=2)
raise AssertionError("a partial index build must raise")
except RuntimeError as e:
assert "2026-Oct" in str(e), e
finally:
FAIL.clear()
assert m.load_index(db) == full, "the complete cached index is kept"
finally:
httpx.Client = old_client
msrc_service._RETRY_WAITS = old_waits
srv = host("srv-2016")
pkgs = [_edge("152.0.4191.62")]
app_scan(srv, pkgs, full)
assert status(srv) == "open"
# Even an index that lacks the CVE (fetched fine, MSRC-side gap) must not
# close it while the host is still below the fix recorded on the finding.
for _ in range(3):
app_scan(srv, pkgs, {"edge": []})
assert status(srv) == "open", "absent from the index is not fixed"
app_scan(srv, pkgs, full)
assert status(srv) == "open"
# Every detection still has a path to close: updated past the fix …
app_scan(srv, [_edge("154.0.4258.62")], full)
assert status(srv) == "patched"
# … or uninstalled, even when the index no longer carries the CVE.
other = host("srv-b")
app_scan(other, [_edge("152.0.4191.62")], full)
assert status(other) == "open"
app_scan(other, [{"name": "7-Zip", "version": "24.09"}], {"edge": []})
assert status(other) == "patched"
# ---- 2. Intune: newest Edge build counts, stale entries do not ----
c2 = host("intune-2")
app_scan(c2, [_edge("154.0.4258.62"), _edge("152.0.4191.53"),
_edge("152.0.4191.53", "Microsoft.MicrosoftEdge.Stable")], full)
assert status(c2) is None, "Edge is at 154.0.4258.62"
# ---- 3. the MSIX registration can carry the current build ----
c3 = host("intune-3")
app_scan(c3, [_edge("153.0.4234.32"),
_edge(FIX, "Microsoft.MicrosoftEdge.Stable")], full)
assert status(c3) is None, "Edge is at 154.0.4258.37"
# A host that IS below the fix is still flagged, whichever entry says so.
c4 = host("intune-4")
app_scan(c4, [_edge("153.0.4234.32"),
_edge("152.0.4191.53", "Microsoft.MicrosoftEdge.Stable")], full)
v = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == c4.id, Vulnerability.cve_id == CVE).one())
assert v.status.value == "open" and v.package_version == "153.0.4234.32", \
(v.status, v.package_version)
# The MSIX alone never flags: it is a per-user copy, not the install.
c5 = host("intune-5")
app_scan(c5, [_edge("152.0.4191.53", "Microsoft.MicrosoftEdge.Stable")], full)
assert status(c5) is None
# WebView2 and the Edge DevTools client are other products.
pairs = m.installed_products([
_edge("152.0.4191.99", "Microsoft Edge WebView2 Runtime"),
_edge("1000.25128.1000.0", "Microsoft.MicrosoftEdgeDevToolsClient"),
_edge("153.0.4234.32")])
assert [(p["key"], v) for p, v in pairs] == [("edge", "153.0.4234.32")], pairs
print("msrc edge flap OK")
def test_msrc_edge_flap():
demo()
if __name__ == "__main__":
demo()