• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / relman-auto-nag / #4696

pending completion
#4696

push

coveralls-python

web-flow
Ensure SMTP over SSL verifies the server certificate (#2193)

This patch supplies an ssl context to the `SMTP_SSL` constructor, which enables certificate verification. The default context will use the system's trusted CA certificates. See https://docs.python.org/3/library/ssl.html#ssl-security for more.

Kudos to Martin Schobert and Tobias Ospelt of Pentagrid AG for reporting to Mozilla Security.

716 of 3566 branches covered (20.08%)

2 of 2 new or added lines in 1 file covered. (100.0%)

1925 of 8730 relevant lines covered (22.05%)

0.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/bugbot/crash/analyzer.py
1
# This Source Code Form is subject to the terms of the Mozilla Public
2
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
3
# You can obtain one at http://mozilla.org/MPL/2.0/.
4

5
import itertools
×
6
import re
×
7
from collections import defaultdict
×
8
from datetime import date, timedelta
×
9
from functools import cached_property
×
10
from typing import Iterable, Iterator
×
11

12
from libmozdata import bugzilla, clouseau, connection, socorro
×
13
from libmozdata import utils as lmdutils
×
14
from libmozdata.bugzilla import Bugzilla
×
15
from libmozdata.connection import Connection
×
16

17
from bugbot import logger, utils
×
18
from bugbot.bug.analyzer import BugAnalyzer, BugsStore
×
19
from bugbot.components import ComponentName
×
20
from bugbot.crash import socorro_util
×
21

22
# The max offset from a memory address to be considered "near".
23
OFFSET_64_BIT = 0x1000
×
24
OFFSET_32_BIT = 0x100
×
25
# Allocator poison value addresses.
26
ALLOCATOR_ADDRESSES_64_BIT = (
×
27
    (0xE5E5E5E5E5E5E5E5, OFFSET_64_BIT),
28
    # On 64-bit windows, sometimes it could be doing something with a 32-bit
29
    # value gotten from freed memory, so it'll be 0X00000000E5E5E5E5 +/-, and
30
    # because of the address limitation, quite often it will be
31
    # 0X0000E5E5E5E5E5E5 +/-.
32
    (0x00000000E5E5E5E5, OFFSET_32_BIT),
33
    (0x0000E5E5E5E5E5E5, OFFSET_64_BIT),
34
    (0x4B4B4B4B4B4B4B4B, OFFSET_64_BIT),
35
)
36
ALLOCATOR_ADDRESSES_32_BIT = (
×
37
    (0xE5E5E5E5, OFFSET_32_BIT),
38
    (0x4B4B4B4B, OFFSET_32_BIT),
39
)
40
# Ranges where addresses are considered near allocator poison values.
41
ALLOCATOR_RANGES_64_BIT = (
×
42
    (addr - offset, addr + offset) for addr, offset in ALLOCATOR_ADDRESSES_64_BIT
43
)
44
ALLOCATOR_RANGES_32_BIT = (
×
45
    (addr - offset, addr + offset) for addr, offset in ALLOCATOR_ADDRESSES_32_BIT
46
)
47

48
# NOTE: If you make changes that affect the output of the analysis, you should
49
# increment this number. This is needed in the experimental phase only.
50
EXPERIMENT_VERSION = 3
×
51

52

53
def is_near_null_address(str_address) -> bool:
×
54
    """Check if the address is near null.
55

56
    Args:
57
        str_address: The memory address to check.
58

59
    Returns:
60
        True if the address is near null, False otherwise.
61
    """
62
    address = int(str_address, 0)
×
63
    is_64_bit = len(str_address) >= 18
×
64

65
    if is_64_bit:
×
66
        return -OFFSET_64_BIT <= address <= OFFSET_64_BIT
×
67

68
    return -OFFSET_32_BIT <= address <= OFFSET_32_BIT
×
69

70

71
def is_near_allocator_address(str_address) -> bool:
×
72
    """Check if the address is near an allocator poison value.
73

74
    Args:
75
        str_address: The memory address to check.
76

77
    Returns:
78
        True if the address is near an allocator poison value, False otherwise.
79
    """
80
    address = int(str_address, 0)
×
81
    is_64_bit = len(str_address) >= 18
×
82

83
    return any(
×
84
        low <= address <= high
85
        for low, high in (
86
            ALLOCATOR_RANGES_64_BIT if is_64_bit else ALLOCATOR_RANGES_32_BIT
87
        )
88
    )
89

90

91
# TODO: Move this to libmozdata
92
def generate_signature_page_url(params: dict, tab: str) -> str:
×
93
    """Generate a URL to the signature page on Socorro
94

95
    Args:
96
        params: the parameters for the search query.
97
        tab: the page tab that should be selected.
98

99
    Returns:
100
        The URL of the signature page on Socorro
101
    """
102
    web_url = socorro.Socorro.CRASH_STATS_URL
×
103
    query = lmdutils.get_params_for_url(params)
×
104
    return f"{web_url}/signature/{query}#{tab}"
×
105

106

107
# NOTE: At this point, we will file bugs on bugzilla-dev. Once we are confident
108
# that the bug filing is working as expected, we can switch to filing bugs in
109
# the production instance of Bugzilla.
110
class DevBugzilla(Bugzilla):
×
111
    URL = "https://bugzilla-dev.allizom.org"
×
112
    API_URL = URL + "/rest/bug"
×
113
    ATTACHMENT_API_URL = API_URL + "/attachment"
×
114
    TOKEN = utils.get_login_info()["bz_api_key_dev"]
×
115

116

117
class NoCrashReportFoundError(Exception):
×
118
    """There are no crash reports that meet the required criteria."""
119

120

121
class ClouseauDataAnalyzer:
×
122
    """Analyze the data returned by Crash Clouseau about a specific crash
123
    signature.
124
    """
125

126
    MINIMUM_CLOUSEAU_SCORE_THRESHOLD: int = 8
×
127
    DEFAULT_CRASH_COMPONENT = ComponentName("Core", "General")
×
128

129
    def __init__(self, reports: Iterable[dict], bugs_store: BugsStore):
×
130
        self._clouseau_reports = reports
×
131
        self.bugs_store = bugs_store
×
132

133
    @cached_property
×
134
    def max_clouseau_score(self):
×
135
        """The maximum Clouseau score in the crash reports."""
136
        if not self._clouseau_reports:
×
137
            return 0
×
138
        return max(report["max_score"] for report in self._clouseau_reports)
×
139

140
    @cached_property
×
141
    def regressed_by_potential_bug_ids(self) -> set[int]:
×
142
        """The IDs for the bugs that their patches could have caused the crash."""
143
        minimum_accepted_score = max(
×
144
            self.MINIMUM_CLOUSEAU_SCORE_THRESHOLD, self.max_clouseau_score
145
        )
146
        return {
×
147
            changeset["bug_id"]
148
            for report in self._clouseau_reports
149
            if report["max_score"] >= minimum_accepted_score
150
            for changeset in report["changesets"]
151
            if changeset["max_score"] >= minimum_accepted_score
152
            and not changeset["is_merge"]
153
            and not changeset["is_backedout"]
154
        }
155

156
    @cached_property
×
157
    def regressed_by_patch(self) -> str | None:
×
158
        """The hash of the patch that could have caused the crash."""
159
        minimum_accepted_score = max(
×
160
            self.MINIMUM_CLOUSEAU_SCORE_THRESHOLD, self.max_clouseau_score
161
        )
162
        potential_patches = {
×
163
            changeset["changeset"]
164
            for report in self._clouseau_reports
165
            if report["max_score"] >= minimum_accepted_score
166
            for changeset in report["changesets"]
167
            if changeset["max_score"] >= minimum_accepted_score
168
            and not changeset["is_merge"]
169
            and not changeset["is_backedout"]
170
        }
171
        if len(potential_patches) == 1:
×
172
            return next(iter(potential_patches))
×
173
        return None
×
174

175
    @cached_property
×
176
    def regressed_by(self) -> int | None:
×
177
        """The ID of the bug that one of its patches could have caused
178
        the crash.
179

180
        If there are multiple bugs, the value will be `None`.
181
        """
182
        bug_ids = self.regressed_by_potential_bug_ids
×
183
        if len(bug_ids) == 1:
×
184
            return next(iter(bug_ids))
×
185
        return None
×
186

187
    @cached_property
×
188
    def regressed_by_potential_bugs(self) -> list[BugAnalyzer]:
×
189
        """The bugs whose patches could have caused the crash."""
190
        self.bugs_store.fetch_bugs(
×
191
            self.regressed_by_potential_bug_ids,
192
            [
193
                "id",
194
                "groups",
195
                "assigned_to",
196
                "product",
197
                "component",
198
            ],
199
        )
200
        return [
×
201
            self.bugs_store.get_bug_by_id(bug_id)
202
            for bug_id in self.regressed_by_potential_bug_ids
203
        ]
204

205
    @cached_property
×
206
    def regressed_by_author(self) -> dict | None:
×
207
        """The author of the patch that could have caused the crash.
208

209
        If there are multiple regressors, the value will be `None`.
210

211
        The regressor bug assignee is considered as the author, even if the
212
        assignee is not the patch author.
213
        """
214

215
        if not self.regressed_by:
×
216
            return None
×
217

218
        bug = self.regressed_by_potential_bugs[0]
×
219
        assert bug.id == self.regressed_by
×
220
        return bug.get_field("assigned_to_detail")
×
221

222
    @cached_property
×
223
    def crash_component(self) -> ComponentName:
×
224
        """The component that the crash belongs to.
225

226
        If there are multiple components, the value will be the default one.
227
        """
228
        potential_components = {
×
229
            bug.component for bug in self.regressed_by_potential_bugs
230
        }
231
        if len(potential_components) == 1:
×
232
            return next(iter(potential_components))
×
233
        return self.DEFAULT_CRASH_COMPONENT
×
234

235

236
class SocorroDataAnalyzer(socorro_util.SignatureStats):
×
237
    """Analyze the data returned by Socorro."""
238

239
    _bugzilla_os_legal_values = None
×
240
    _bugzilla_cpu_legal_values_map = None
×
241
    _platforms = [
×
242
        {"short_name": "win", "name": "Windows"},
243
        {"short_name": "mac", "name": "Mac OS X"},
244
        {"short_name": "lin", "name": "Linux"},
245
        {"short_name": "and", "name": "Android"},
246
        {"short_name": "unknown", "name": "Unknown"},
247
    ]
248

249
    def __init__(
×
250
        self,
251
        signature: dict,
252
        num_total_crashes: int,
253
    ):
254
        super().__init__(signature, num_total_crashes, platforms=self._platforms)
×
255

256
    @classmethod
×
257
    def to_bugzilla_op_sys(cls, op_sys: str) -> str:
×
258
        """Return the corresponding OS name in Bugzilla for the provided OS name
259
        from Socorro.
260

261
        If the OS name is not recognized, return "Other".
262
        """
263
        if cls._bugzilla_os_legal_values is None:
×
264
            cls._bugzilla_os_legal_values = set(
×
265
                bugzilla.BugFields.fetch_field_values("op_sys")
266
            )
267

268
        if op_sys in cls._bugzilla_os_legal_values:
×
269
            return op_sys
×
270

271
        if op_sys.startswith("OS X ") or op_sys.startswith("macOS "):
×
272
            op_sys = "macOS"
×
273
        elif op_sys.startswith("Windows"):
×
274
            op_sys = "Windows"
×
275
        elif "Linux" in op_sys or op_sys.startswith("Ubuntu"):
×
276
            op_sys = "Linux"
×
277
        else:
278
            op_sys = "Other"
×
279

280
        return op_sys
×
281

282
    @property
×
283
    def first_crash_date(self) -> str:
×
284
        """The date of the first crash within the query time range.
285

286
        The date is in YYYY-MM-DD format.
287
        """
288
        return self.signature["facets"]["histogram_date"][0]["term"][:10]
×
289

290
    @property
×
291
    def bugzilla_op_sys(self) -> str:
×
292
        """The name of the OS where the crash happens.
293

294
        The value is one of the legal values for Bugzilla's `op_sys` field.
295

296
        - If no OS name is found, the value will be "Unspecified".
297
        - If the OS name is not recognized, the value will be "Other".
298
        - If multiple OS names are found, the value will be "All". Unless the OS
299
          names can be resolved to a common name without a version. For example,
300
          "Windows 10" and "Windows 7" will become "Windows".
301
        """
302
        all_op_sys = {
×
303
            self.to_bugzilla_op_sys(op_sys["term"])
304
            for op_sys in self.signature["facets"]["platform_pretty_version"]
305
        }
306

307
        if len(all_op_sys) > 1:
×
308
            # Resolve to root OS name by removing the version number.
309
            all_op_sys = {op_sys.split(" ")[0] for op_sys in all_op_sys}
×
310

311
        if len(all_op_sys) == 2 and "Other" in all_op_sys:
×
312
            # TODO: explain this workaround.
313
            all_op_sys.remove("Other")
×
314

315
        if len(all_op_sys) == 1:
×
316
            return next(iter(all_op_sys))
×
317

318
        if len(all_op_sys) == 0:
×
319
            return "Unspecified"
×
320

321
        return "All"
×
322

323
    @classmethod
×
324
    def to_bugzilla_cpu(cls, cpu: str) -> str:
×
325
        """Return the corresponding CPU name in Bugzilla for the provided name
326
        from Socorro.
327

328
        If the CPU is not recognized, return "Other".
329
        """
330
        if cls._bugzilla_cpu_legal_values_map is None:
×
331
            cls._bugzilla_cpu_legal_values_map = {
×
332
                value.lower(): value
333
                for value in bugzilla.BugFields.fetch_field_values("rep_platform")
334
            }
335

336
        return cls._bugzilla_cpu_legal_values_map.get(cpu, "Other")
×
337

338
    @property
×
339
    def bugzilla_cpu_arch(self) -> str:
×
340
        """The CPU architecture of the devices where the crash happens.
341

342
        The value is one of the legal values for Bugzilla's `rep_platform` field.
343

344
        - If no CPU architecture is found, the value will be "Unspecified".
345
        - If the CPU architecture is not recognized, the value will be "Other".
346
        - If multiple CPU architectures are found, the value will "All".
347
        """
348
        all_cpu_arch = {
×
349
            self.to_bugzilla_cpu(cpu["term"])
350
            for cpu in self.signature["facets"]["cpu_arch"]
351
        }
352

353
        if len(all_cpu_arch) == 2 and "Other" in all_cpu_arch:
×
354
            all_cpu_arch.remove("Other")
×
355

356
        if len(all_cpu_arch) == 1:
×
357
            return next(iter(all_cpu_arch))
×
358

359
        if len(all_cpu_arch) == 0:
×
360
            return "Unspecified"
×
361

362
        return "All"
×
363

364
    @property
×
365
    def user_comments_page_url(self) -> str:
×
366
        """The URL to the Signature page on Socorro where the Comments tab is
367
        selected.
368
        """
369
        start_date = date.today() - timedelta(weeks=26)
×
370
        params = {
×
371
            "signature": self.signature_term,
372
            "date": socorro.SuperSearch.get_search_date(start_date),
373
        }
374
        return generate_signature_page_url(params, "comments")
×
375

376
    @property
×
377
    def num_user_comments(self) -> int:
×
378
        """The number of crash reports with user comments."""
379
        # TODO: count useful/interesting user comments (e.g., exclude one word comments)
380
        return self.signature["facets"]["cardinality_user_comments"]["value"]
×
381

382
    @property
×
383
    def has_user_comments(self) -> bool:
×
384
        """Whether the crash signature has any reports with a user comment."""
385
        return self.num_user_comments > 0
×
386

387
    @property
×
388
    def top_proto_signature(self) -> str:
×
389
        """The proto signature that occurs the most."""
390
        return self.signature["facets"]["proto_signature"][0]["term"]
×
391

392
    @property
×
393
    def num_top_proto_signature_crashes(self) -> int:
×
394
        """The number of crashes for the most occurring proto signature."""
395
        return self.signature["facets"]["proto_signature"][0]["count"]
×
396

397
    def _build_ids(self) -> Iterator[int]:
×
398
        """Yields the build IDs where the crash occurred."""
399
        for build_id in self.signature["facets"]["build_id"]:
×
400
            yield build_id["term"]
×
401

402
    @property
×
403
    def top_build_id(self) -> int:
×
404
        """The build ID where most crashes occurred."""
405
        return self.signature["facets"]["build_id"][0]["term"]
×
406

407
    @cached_property
×
408
    def num_near_null_crashes(self) -> int:
×
409
        """The number of crashes that occurred on addresses near null."""
410
        return sum(
×
411
            address["count"]
412
            for address in self.signature["facets"]["address"]
413
            if is_near_null_address(address["term"])
414
        )
415

416
    @property
×
417
    def is_near_null_crash(self) -> bool:
×
418
        """Whether all crashes occurred on addresses near null."""
419
        return self.num_near_null_crashes == self.num_crashes
×
420

421
    @property
×
422
    def is_potential_near_null_crash(self) -> bool:
×
423
        """Whether the signature is a potential near null crash.
424

425
        The value will be True if some but not all crashes occurred on addresses
426
        near null.
427
        """
428
        return not self.is_near_null_crash and self.num_near_null_crashes > 0
×
429

430
    @property
×
431
    def is_near_null_related_crash(self) -> bool:
×
432
        """Whether the signature is related to near null crashes.
433

434
        The value will be True if any of the crashes occurred on addresses near
435
        null.
436
        """
437
        return self.is_near_null_crash or self.is_potential_near_null_crash
×
438

439
    @cached_property
×
440
    def num_near_allocator_crashes(self) -> int:
×
441
        """The number of crashes that occurred on addresses near an allocator
442
        poison value.
443
        """
444
        return sum(
×
445
            address["count"]
446
            for address in self.signature["facets"]["address"]
447
            if is_near_allocator_address(address["term"])
448
        )
449

450
    @property
×
451
    def is_near_allocator_crash(self) -> bool:
×
452
        """Whether all crashes occurred on addresses near an allocator poison
453
        value.
454
        """
455
        return self.num_near_allocator_crashes == self.num_crashes
×
456

457
    @property
×
458
    def is_potential_near_allocator_crash(self) -> bool:
×
459
        """Whether the signature is a potential near allocator poison value
460
        crash.
461

462
        The value will be True if some but not all crashes occurred on addresses
463
        near an allocator poison value.
464
        """
465
        return not self.is_near_allocator_crash and self.num_near_allocator_crashes > 0
×
466

467
    @property
×
468
    def is_near_allocator_related_crash(self) -> bool:
×
469
        """Whether the signature is related to near allocator poison value
470
        crashes.
471

472
        The value will be True if any of the crashes occurred on addresses near
473
        an allocator poison value.
474
        """
475
        return self.is_near_allocator_crash or self.is_potential_near_allocator_crash
×
476

477

478
class SignatureAnalyzer(SocorroDataAnalyzer, ClouseauDataAnalyzer):
×
479
    """Analyze the data related to a signature.
480

481
    This includes data from Socorro and Clouseau.
482
    """
483

484
    def __init__(
×
485
        self,
486
        socorro_signature: dict,
487
        num_total_crashes: int,
488
        clouseau_reports: list[dict],
489
        bugs_store: BugsStore,
490
    ):
491
        SocorroDataAnalyzer.__init__(self, socorro_signature, num_total_crashes)
×
492
        ClouseauDataAnalyzer.__init__(self, clouseau_reports, bugs_store)
×
493

494
    def _fetch_crash_reports(
×
495
        self,
496
        proto_signature: str,
497
        build_id: int | Iterable[int],
498
        limit: int = 1,
499
    ) -> Iterator[dict]:
500
        params = {
×
501
            "proto_signature": "=" + proto_signature,
502
            "build_id": build_id,
503
            "_columns": [
504
                "uuid",
505
            ],
506
            "_results_number": limit,
507
        }
508

509
        def handler(res: dict, data: dict):
×
510
            data.update(res)
×
511

512
        data: dict = {}
×
513
        socorro.SuperSearch(params=params, handler=handler, handlerdata=data).wait()
×
514

515
        yield from data["hits"]
×
516

517
    def fetch_representative_processed_crash(self) -> dict:
×
518
        """Fetch a processed crash to represent the signature.
519

520
        This could fetch multiple processed crashes and return the one that is
521
        most likely to be useful.
522
        """
523
        limit_to_top_proto_signature = (
×
524
            self.num_top_proto_signature_crashes / self.num_crashes > 0.6
525
        )
526

527
        reports = itertools.chain(
×
528
            # Reports with a higher score from clouseau are more likely to be
529
            # useful.
530
            sorted(
531
                self._clouseau_reports,
532
                key=lambda report: report["max_score"],
533
                reverse=True,
534
            ),
535
            # Next we try find reports from the top crashing build because they
536
            # are likely to be representative.
537
            self._fetch_crash_reports(self.top_proto_signature, self.top_build_id),
538
            self._fetch_crash_reports(self.top_proto_signature, self._build_ids()),
539
        )
540
        for report in reports:
×
541
            uuid = report["uuid"]
×
542
            processed_crash = socorro.ProcessedCrash.get_processed(uuid)[uuid]
×
543
            if (
×
544
                not limit_to_top_proto_signature
545
                or processed_crash["proto_signature"] == self.top_proto_signature
546
            ):
547
                # TODO(investigate): maybe we should check if the stack is
548
                # corrupted (ask gsvelto or willkg about how to detect that)
549
                return processed_crash
×
550

551
        raise NoCrashReportFoundError(
×
552
            f"No crash report found with the most frequent proto signature for {self.signature_term}."
553
        )
554

555
    @cached_property
×
556
    def is_potential_security_crash(self) -> bool:
×
557
        """Whether the crash is related to a potential security bug.
558

559
        The value will be True if:
560
            - the signature is related to near allocator poison value crashes, or
561
            - one of the potential regressors is a security bug
562
        """
563
        return self.is_near_allocator_related_crash or any(
×
564
            bug.is_security for bug in self.regressed_by_potential_bugs
565
        )
566

567

568
class SignaturesDataFetcher:
×
569
    """Fetch the data related to the given signatures."""
570

571
    MEMORY_ACCESS_ERROR_REASONS = (
×
572
        # On Windows:
573
        "EXCEPTION_ACCESS_VIOLATION_READ",
574
        "EXCEPTION_ACCESS_VIOLATION_WRITE",
575
        "EXCEPTION_ACCESS_VIOLATION_EXEC"
576
        # On Linux:
577
        "SIGSEGV / SEGV_MAPERR",
578
        "SIGSEGV / SEGV_ACCERR",
579
    )
580

581
    EXCLUDED_MOZ_REASON_STRINGS = (
×
582
        "MOZ_CRASH(OOM)",
583
        "MOZ_CRASH(Out of memory)",
584
        "out of memory",
585
        "Shutdown hanging",
586
        # TODO(investigate): do we need to exclude signatures that their reason
587
        # contains `[unhandlable oom]`?
588
        # Example: arena_t::InitChunk | arena_t::AllocRun | arena_t::MallocLarge | arena_t::Malloc | BaseAllocator::malloc | Allocator::malloc | PageMalloc
589
        # "[unhandlable oom]",
590
    )
591

592
    # If any of the crash reason starts with any of the following, then it is
593
    # Network or I/O error.
594
    EXCLUDED_IO_ERROR_REASON_PREFIXES = (
×
595
        "EXCEPTION_IN_PAGE_ERROR_READ",
596
        "EXCEPTION_IN_PAGE_ERROR_WRITE",
597
        "EXCEPTION_IN_PAGE_ERROR_EXEC",
598
    )
599

600
    # TODO(investigate): do we need to exclude all these signatures prefixes?
601
    EXCLUDED_SIGNATURE_PREFIXES = (
×
602
        "OOM | ",
603
        "bad hardware | ",
604
        "shutdownhang | ",
605
    )
606

607
    SUMMARY_DURATION = timedelta(weeks=10)
×
608

609
    def __init__(
×
610
        self,
611
        signatures: Iterable[str],
612
        product: str = "Firefox",
613
        channel: str = "nightly",
614
    ):
615
        self._signatures = set(signatures)
×
616
        self._product = product
×
617
        self._channel = channel
×
618

619
    @classmethod
×
620
    def find_new_actionable_crashes(
×
621
        cls,
622
        product: str,
623
        channel: str,
624
        days_to_check: int = 7,
625
        days_without_crashes: int = 7,
626
    ) -> "SignaturesDataFetcher":
627
        """Find new actionable crashes.
628

629
        Args:
630
            product: The product to check.
631
            channel: The release channel to check.
632
            days_to_check: The number of days to check for crashes.
633
            days_without_crashes: The number of days without crashes before the
634
                `days_to_check` to consider the signature new.
635

636
        Returns:
637
            A list of actionable signatures.
638
        """
639
        duration = days_to_check + days_without_crashes
×
640
        end_date = lmdutils.get_date_ymd("today")
×
641
        start_date = end_date - timedelta(duration)
×
642
        earliest_allowed_date = lmdutils.get_date_str(
×
643
            end_date - timedelta(days_to_check)
644
        )
645
        date_range = socorro.SuperSearch.get_search_date(start_date, end_date)
×
646

647
        params = {
×
648
            "product": product,
649
            "release_channel": channel,
650
            "date": date_range,
651
            # TODO(investigate): should we do a local filter instead of the
652
            # following (should we exclude the signature if one of the crashes
653
            # is a shutdown hang?):
654
            # If the `ipc_shutdown_state` or `shutdown_progress` field are
655
            # non-empty then it's a shutdown hang.
656
            "ipc_shutdown_state": "__null__",
657
            "shutdown_progress": "__null__",
658
            # TODO(investigate): should we use the following instead of the
659
            # local filter.
660
            # "oom_allocation_size": "!__null__",
661
            "_aggs.signature": [
662
                "moz_crash_reason",
663
                "reason",
664
                "_histogram.date",
665
                "_cardinality.install_time",
666
                "_cardinality.oom_allocation_size",
667
            ],
668
            "_results_number": 0,
669
            "_facets_size": 10000,
670
        }
671

672
        def handler(search_resp: dict, data: list):
×
673
            logger.debug(
×
674
                "Total of %d signatures received from Socorro",
675
                len(search_resp["facets"]["signature"]),
676
            )
677

678
            for crash in search_resp["facets"]["signature"]:
×
679
                signature = crash["term"]
×
680
                if any(
×
681
                    signature.startswith(excluded_prefix)
682
                    for excluded_prefix in cls.EXCLUDED_SIGNATURE_PREFIXES
683
                ):
684
                    # Ignore signatures that start with any of the excluded prefixes.
685
                    continue
×
686

687
                facets = crash["facets"]
×
688
                installations = facets["cardinality_install_time"]["value"]
×
689
                if installations <= 1:
×
690
                    # Ignore crashes that only happen on one installation.
691
                    continue
×
692

693
                first_date = facets["histogram_date"][0]["term"]
×
694
                if first_date < earliest_allowed_date:
×
695
                    # The crash is not new, skip it.
696
                    continue
×
697

698
                if any(
×
699
                    reason["term"].startswith(io_error_prefix)
700
                    for reason in facets["reason"]
701
                    for io_error_prefix in cls.EXCLUDED_IO_ERROR_REASON_PREFIXES
702
                ):
703
                    # Ignore Network or I/O error crashes.
704
                    continue
×
705

706
                if crash["count"] < 20:
×
707
                    # For signatures with low volume, having multiple types of
708
                    # memory errors indicates potential bad hardware crashes.
709
                    num_memory_error_types = sum(
×
710
                        reason["term"] in cls.MEMORY_ACCESS_ERROR_REASONS
711
                        for reason in facets["reason"]
712
                    )
713
                    if num_memory_error_types > 1:
×
714
                        # Potential bad hardware crash, skip it.
715
                        continue
×
716

717
                # TODO: Add a filter using the `possible_bit_flips_max_confidence`
718
                # field to exclude bad hardware crashes. The filed is not available yet.
719
                # See: https://bugzilla.mozilla.org/show_bug.cgi?id=1816669#c3
720

721
                # TODO(investigate): is this needed since we are already
722
                # filtering signatures that start with "OOM | "
723
                if facets["cardinality_oom_allocation_size"]["value"]:
×
724
                    # If one of the crashes is an OOM crash, skip it.
725
                    continue
×
726

727
                # TODO(investigate): do we need to check for the `moz_crash_reason`
728
                moz_crash_reasons = facets["moz_crash_reason"]
×
729
                if moz_crash_reasons and any(
×
730
                    excluded_reason in reason["term"]
731
                    for reason in moz_crash_reasons
732
                    for excluded_reason in cls.EXCLUDED_MOZ_REASON_STRINGS
733
                ):
734
                    continue
×
735

736
                data.append(signature)
×
737

738
        signatures: list = []
×
739
        socorro.SuperSearch(
×
740
            params=params,
741
            handler=handler,
742
            handlerdata=signatures,
743
        ).wait()
744

745
        logger.debug(
×
746
            "Total of %d signatures left after applying the filtering criteria",
747
            len(signatures),
748
        )
749

750
        return cls(signatures, product, channel)
×
751

752
    def fetch_clouseau_crash_reports(self) -> dict[str, list]:
×
753
        """Fetch the crash reports data from Crash Clouseau."""
754
        if not self._signatures:
×
755
            return {}
×
756

757
        logger.debug(
×
758
            "Fetch from Clouseau: requesting reports for %d signatures",
759
            len(self._signatures),
760
        )
761

762
        signature_reports = clouseau.Reports.get_by_signatures(
×
763
            self._signatures,
764
            product=self._product,
765
            channel=self._channel,
766
        )
767

768
        logger.debug(
×
769
            "Fetch from Clouseau: received reports for %d signatures",
770
            len(signature_reports),
771
        )
772

773
        return signature_reports
×
774

775
    def fetch_socorro_info(self) -> tuple[list[dict], int]:
×
776
        """Fetch the signature data from Socorro."""
777
        if not self._signatures:
×
778
            return [], 0
×
779

780
        end_date = lmdutils.get_date_ymd("today")
×
781
        start_date = end_date - self.SUMMARY_DURATION
×
782
        date_range = socorro.SuperSearch.get_search_date(start_date, end_date)
×
783

784
        params = {
×
785
            "product": self._product,
786
            # TODO(investigate): should we included all release channels?
787
            "release_channel": self._channel,
788
            # TODO(investigate): should we limit based on the build date as well?
789
            "date": date_range,
790
            # TODO: split signatures into chunks to avoid very long query URLs
791
            "signature": ["=" + signature for signature in self._signatures],
792
            "_aggs.signature": [
793
                "address",
794
                "build_id",
795
                "cpu_arch",
796
                "proto_signature",
797
                "_cardinality.user_comments",
798
                "cpu_arch",
799
                "platform_pretty_version",
800
                "_histogram.date",
801
                # The following are needed for SignatureStats:
802
                "platform",
803
                "is_garbage_collecting",
804
                "_cardinality.install_time",
805
                "startup_crash",
806
                "_histogram.uptime",
807
                "process_type",
808
            ],
809
            "_results_number": 0,
810
            "_facets_size": 10000,
811
        }
812

813
        def handler(search_results: dict, data: dict):
×
814
            data["num_total_crashes"] = search_results["total"]
×
815
            data["signatures"] = search_results["facets"]["signature"]
×
816

817
        logger.debug(
×
818
            "Fetch from Socorro: requesting info for %d signatures",
819
            len(self._signatures),
820
        )
821

822
        data: dict = {}
×
823
        socorro.SuperSearchUnredacted(
×
824
            params=params,
825
            handler=handler,
826
            handlerdata=data,
827
        ).wait()
828

829
        logger.debug(
×
830
            "Fetch from Socorro: received info for %d signatures",
831
            len(data["signatures"]),
832
        )
833

834
        return data["signatures"], data["num_total_crashes"]
×
835

836
    def fetch_bugs(self, include_fields: list[str] = None) -> dict[str, list[dict]]:
×
837
        """Fetch bugs that are filed against the given signatures."""
838
        if not self._signatures:
×
839
            return {}
×
840

841
        params_base: dict = {
×
842
            "include_fields": [
843
                "cf_crash_signature",
844
            ],
845
        }
846

847
        if include_fields:
×
848
            params_base["include_fields"].extend(include_fields)
×
849

850
        params_list = []
×
851
        for signatures_chunk in Connection.chunks(list(self._signatures), 30):
×
852
            params = params_base.copy()
×
853
            n = int(utils.get_last_field_num(params))
×
854
            params[f"f{n}"] = "OP"
×
855
            params[f"j{n}"] = "OR"
×
856
            for signature in signatures_chunk:
×
857
                n += 1
×
858
                params[f"f{n}"] = "cf_crash_signature"
×
859
                params[f"o{n}"] = "regexp"
×
860
                params[f"v{n}"] = rf"\[(@ |@){re.escape(signature)}( \]|\])"
×
861
            params[f"f{n+1}"] = "CP"
×
862
            params_list.append(params)
×
863

864
        signatures_bugs: dict = defaultdict(list)
×
865

866
        def handler(res, data):
×
867
            for bug in res["bugs"]:
×
868
                for signature in utils.get_signatures(bug["cf_crash_signature"]):
×
869
                    if signature in self._signatures:
×
870
                        data[signature].append(bug)
×
871

872
        logger.debug(
×
873
            "Fetch from Bugzilla: requesting bugs for %d signatures",
874
            len(self._signatures),
875
        )
876
        timeout = utils.get_config("common", "bz_query_timeout")
×
877
        Bugzilla(
×
878
            timeout=timeout,
879
            queries=[
880
                connection.Query(Bugzilla.API_URL, params, handler, signatures_bugs)
881
                for params in params_list
882
            ],
883
        ).wait()
884

885
        # TODO: remove the call to DevBugzilla after moving to production
886
        for params in params_list:
×
887
            # Excluded only filed bugs with the latest version. This will
888
            # re-generate the bugs after bumping the version.
889
            n = int(utils.get_last_field_num(params))
×
890
            params[f"f{n}"] = "status_whiteboard"
×
891
            params[f"o{n}"] = "substring"
×
892
            params[f"v{n}"] = f"[bugbot-crash-v{EXPERIMENT_VERSION}]"
×
893
        DevBugzilla(
×
894
            timeout=timeout,
895
            queries=[
896
                connection.Query(DevBugzilla.API_URL, params, handler, signatures_bugs)
897
                for params in params_list
898
            ],
899
        ).wait()
900

901
        logger.debug(
×
902
            "Fetch from Bugzilla: received bugs for %d signatures", len(signatures_bugs)
903
        )
904

905
        return signatures_bugs
×
906

907
    def analyze(self) -> list[SignatureAnalyzer]:
×
908
        """Analyze the data related to the signatures."""
909
        bugs = self.fetch_bugs()
×
910
        # TODO(investigate): For now, we are ignoring signatures that have bugs
911
        # filed even if they are closed long time ago. We should investigate
912
        # whether we should include the ones with closed bugs. For example, if
913
        # the bug was closed as Fixed years ago.
914
        self._signatures.difference_update(bugs.keys())
×
915

916
        clouseau_reports = self.fetch_clouseau_crash_reports()
×
917
        # TODO(investigate): For now, we are ignoring signatures that are not
918
        # analyzed by clouseau. We should investigate why they are not analyzed
919
        # and whether we should include them.
920
        self._signatures.intersection_update(clouseau_reports.keys())
×
921

922
        signatures, num_total_crashes = self.fetch_socorro_info()
×
923
        bugs_store = BugsStore()
×
924

925
        return [
×
926
            SignatureAnalyzer(
927
                signature,
928
                num_total_crashes,
929
                clouseau_reports[signature["term"]],
930
                bugs_store,
931
            )
932
            for signature in signatures
933
        ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc