git/orchard/main
updater/audit.py
1009 lines35 KB last change raw · history
   1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
"""Audit what changed in a PKGBUILD.

Two principles, both of which exist to stop this becoming noise the user learns
to ignore:

1. **Audit the delta, not the absolute state.** A `curl | sh` that has been in a
   PKGBUILD for five years is not news. One that appeared in this update is.
   Rules therefore compare old against new wherever they can, and pre-existing
   patterns are reported at most as information.

2. **Every finding carries its evidence.** No opaque risk score. A finding names
   the rule, the severity, and the exact spec, URL, or line that triggered it, so
   the user can judge it themselves.

Nothing here ever executes a PKGBUILD. See srcinfo.py for why.
"""

from __future__ import annotations

import enum
import re
from dataclasses import dataclass, field

from .srcinfo import Pkgbuild, SrcInfo

VCS_SUFFIXES = ("-git", "-cvs", "-svn", "-hg", "-bzr", "-darcs", "-nightly", "-fossil")


class Severity(enum.IntEnum):
    """Ordered, so the report can show the worst finding."""

    INFO = 0  # context, no action implied
    LOOK = 1  # worth a look before building
    BLOCK = 2  # do not auto-apply; needs an explicit decision

    @property
    def glyph(self) -> str:
        return {Severity.INFO: "·", Severity.LOOK: "!", Severity.BLOCK: "!!"}[self]


@dataclass(frozen=True)
class Finding:
    rule: str
    severity: Severity
    title: str
    evidence: str = ""
    where: str = ""

    def render(self) -> str:
        bits = [f"[{self.severity.glyph}] {self.title}"]
        if self.evidence:
            bits.append(f"    {self.evidence}")
        if self.where:
            bits.append(f"    in {self.where}")
        return "\n".join(bits)


@dataclass
class SetDiff:
    """Added/removed members of a list-valued field."""

    added: list[str] = field(default_factory=list)
    removed: list[str] = field(default_factory=list)

    def __bool__(self) -> bool:
        return bool(self.added or self.removed)

    def summary(self) -> str:
        if not self:
            return "unchanged"
        parts = []
        if self.added:
            parts.append(f"+{len(self.added)}")
        if self.removed:
            parts.append(f"-{len(self.removed)}")
        return " ".join(parts)


@dataclass
class SourceMove:
    """A source file that now comes from somewhere else."""

    filename: str
    old_url: str
    new_url: str

    @property
    def old_domain(self) -> str:
        return domain_of(self.old_url)

    @property
    def new_domain(self) -> str:
        return domain_of(self.new_url)


@dataclass
class SemanticDiff:
    """What changed, in terms of PKGBUILD fields rather than lines of text."""

    pkgver: tuple[str, str] | None = None
    pkgrel: tuple[str, str] | None = None
    epoch: tuple[str, str] | None = None
    source: SetDiff = field(default_factory=SetDiff)
    sums: SetDiff = field(default_factory=SetDiff)
    depends: SetDiff = field(default_factory=SetDiff)
    makedepends: SetDiff = field(default_factory=SetDiff)
    optdepends: SetDiff = field(default_factory=SetDiff)
    provides: SetDiff = field(default_factory=SetDiff)
    conflicts: SetDiff = field(default_factory=SetDiff)
    replaces: SetDiff = field(default_factory=SetDiff)
    validpgpkeys: SetDiff = field(default_factory=SetDiff)
    sources_moved: list[SourceMove] = field(default_factory=list)
    funcs_added: list[str] = field(default_factory=list)
    funcs_removed: list[str] = field(default_factory=list)
    funcs_changed: list[str] = field(default_factory=list)
    lines_added: int = 0
    lines_removed: int = 0

    @property
    def is_empty(self) -> bool:
        return not any(
            [
                self.pkgver,
                self.pkgrel,
                self.epoch,
                self.source,
                self.sums,
                self.depends,
                self.makedepends,
                self.optdepends,
                self.provides,
                self.conflicts,
                self.replaces,
                self.validpgpkeys,
                self.sources_moved,
                self.funcs_added,
                self.funcs_removed,
                self.funcs_changed,
                self.lines_added,
                self.lines_removed,
            ]
        )


@dataclass
class Change:
    """One package's old and new metadata. Anything may be None on first import."""

    package: str
    old_srcinfo: SrcInfo | None = None
    new_srcinfo: SrcInfo | None = None
    old_pkgbuild: Pkgbuild | None = None
    new_pkgbuild: Pkgbuild | None = None
    old_maintainer: str | None = None
    new_maintainer: str | None = None
    old_url: str = ""
    new_url: str = ""
    #: `.install` scriptlet text, keyed by filename, read when the Change was
    #: assembled. Carried as data because these run as root on install, and
    #: resolving the filename against the *process* working directory meant they
    #: were silently never examined.
    old_install: dict[str, str] = field(default_factory=dict)
    new_install: dict[str, str] = field(default_factory=dict)

    @property
    def is_vcs(self) -> bool:
        return any(self.package.endswith(s) for s in VCS_SUFFIXES)


class Basis(enum.IntEnum):
    """What an audit actually had to work with.

    "No findings" and "nothing to examine" look identical in a report and mean
    completely different things. A package paru has never cloned has no PKGBUILD
    to check, so every rule returns nothing -- which rendered as a clean bill of
    health for a package nobody had looked at.
    """

    #: compared an old revision against the new one
    COMPARED = 2
    #: only the new revision was available; pre-existing patterns cannot be told
    #: apart from newly introduced ones
    NEW_ONLY = 1
    #: no PKGBUILD at all: nothing was examined
    NOTHING = 0

    @property
    def label(self) -> str:
        return {
            Basis.COMPARED: "compared with the previous revision",
            Basis.NEW_ONLY: "no baseline: only the current revision was read",
            Basis.NOTHING: "not audited: no PKGBUILD available",
        }[self]


@dataclass
class Report:
    package: str
    diff: SemanticDiff
    findings: list[Finding] = field(default_factory=list)
    #: what could be examined, so a caller can never present an empty report as
    #: a clean result
    basis: Basis = field(default_factory=lambda: Basis.NOTHING)

    @property
    def audited(self) -> bool:
        return self.basis is not Basis.NOTHING

    @property
    def verdict(self) -> Severity:
        return max((f.severity for f in self.findings), default=Severity.INFO)

    @property
    def loud(self) -> list[Finding]:
        return [f for f in self.findings if f.severity >= Severity.LOOK]


# --------------------------------------------------------------------------
# helpers
# --------------------------------------------------------------------------

_URL_RE = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
#: a source may carry an optional `filename::` prefix before the URL
_SOURCE_PREFIX_RE = re.compile(r"^[^:/]+::")
#: PKGBUILD VCS schemes: `git+https://...`, `svn+https://...`
_VCS_SCHEMES = ("git+", "svn+", "hg+", "bzr+", "darcs+", "cvs+")


#: a detached signature: its integrity comes from the trusted key, not a hash
_SIGNATURE_RE = re.compile(r"\.(?:sig|asc|gpg|sign)$", re.IGNORECASE)


def is_signature_source(source: str) -> bool:
    """Whether a source entry is a detached signature.

    `SKIP` is the convention for these: the signature is checked against
    `validpgpkeys`, so demanding a checksum as well is redundant. Packages that
    verify with GPG were being reported as though they had left a network
    download unverified.
    """
    if not source:
        return False
    name = filename_of(source)
    return bool(name and _SIGNATURE_RE.search(name))


def strip_source_prefix(url: str) -> str:
    """Drop any `filename::` prefixes, of which there may be more than one."""
    while True:
        prefix = _SOURCE_PREFIX_RE.match(url)
        if not prefix:
            return url
        url = url[prefix.end() :]


def is_vcs_source(url: str) -> bool:
    """Whether a source entry pulls a live repository rather than a fixed file.

    This, not the package name, is what makes a SKIP checksum legitimate. A
    package called `foo-git` whose source is a pinned tarball still needs sums.
    """
    return strip_source_prefix(url).startswith(_VCS_SCHEMES)


def domain_of(url: str) -> str:
    """Host of a source entry, or a marker for local sources."""
    url = strip_source_prefix(url)
    if not _URL_RE.match(url):
        return "(local)"
    rest = url.split("://", 1)[1]
    return rest.split("/", 1)[0].split("@")[-1]


def filename_of(url: str) -> str:
    """The filename a source will land as, honouring an explicit `::` prefix."""
    prefix = _SOURCE_PREFIX_RE.match(url)
    if prefix:
        return prefix.group(0)[:-2]
    base = url.split("://", 1)[-1]
    base = base.split("?", 1)[0].split("#", 1)[0].rstrip("/")
    return base.rsplit("/", 1)[-1]


def _setdiff(old: list[str], new: list[str]) -> SetDiff:
    o, n = set(old), set(new)
    return SetDiff(added=sorted(n - o), removed=sorted(o - n))


def _pair(old: str | None, new: str | None) -> tuple[str, str] | None:
    if old is None or new is None or old == new:
        return None
    return (old, new)


def _lines_changed(old: str, new: str) -> tuple[int, int]:
    import difflib

    added = removed = 0
    for line in difflib.unified_diff(old.splitlines(), new.splitlines(), n=0):
        if line.startswith(("+++", "---")):
            continue
        if line.startswith("+"):
            added += 1
        elif line.startswith("-"):
            removed += 1
    return added, removed


# --------------------------------------------------------------------------
# suspicious patterns
# --------------------------------------------------------------------------


@dataclass(frozen=True)
class Pattern:
    rule: str
    severity: Severity
    title: str
    regex: re.Pattern[str]
    #: restrict the scan to these PKGBUILD functions; None means the whole file
    funcs: tuple[str, ...] | None = None
    #: only report when the pattern is newly introduced
    new_only: bool = True
    #: an optional veto for a match the regex alone cannot judge, such as text
    #: that is merely inside a quoted string
    guard: object = None


PATTERNS: tuple[Pattern, ...] = (
    Pattern(
        "pipe-to-shell",
        Severity.BLOCK,
        "pipes a download straight into a shell",
        # A shell is reached by more than its bare name: `| /bin/bash`,
        # `| /usr/bin/env bash`, `| dash`, `| zsh`. Matching only the bare
        # name meant the most explicit form of the same attack was not a
        # BLOCK, only a generic "network in build" LOOK.
        re.compile(
            r"(?:curl|wget|fetch)\b[^|;\n]*+\|\s*+(?:sudo\s+)?"
            r"(?:[\w./-]*/)?(?:env\s+)?(?:ba|z|k|da)?sh\b"
        ),
    ),
    Pattern(
        "decode-to-shell",
        Severity.BLOCK,
        "decodes a blob and pipes it into a shell",
        # One `[^|;\n]*` either side of a literal is cubic: both classes can
        # match the literal, so every start position backtracks over every
        # split. An 8 KB line took 4.5s and a 20 KB line over a minute, and
        # the text is whatever the maintainer wrote. The gap before `-d` is
        # bounded and the rest are possessive, so each start position does a
        # fixed amount of work.
        # Both gaps are bounded. A possessive `*` here still scans to the
        # end of the file on every attempt, which is what made it quadratic;
        # 120 characters is far more than a real `base64 -d <file> | sh`.
        re.compile(
            r"(?:base64|xxd|openssl\s+enc)\b[^|;\n]{0,30}-d"
            r"[^|;\n]{0,120}\|[^|;\n]{0,120}sh\b"
        ),
    ),
    Pattern(
        "sudo-in-pkgbuild",
        Severity.BLOCK,
        "invokes sudo during the build",
        # At a command position and followed by a command, so that the
        # dependency `depends=('sudo')` is not reported as running sudo.
        # `sudo -v` is still an invocation, hence the dash.
        re.compile(r"(?:^|[;&|(`\n]|\s)\s*sudo\s+[a-zA-Z-]"),
        # and not merely mentioned inside a message: scriptlets routinely
        # echo "now run sudo whatever" to the user
        guard=lambda text, match: not inside_quotes(text, match.start()),
    ),
    Pattern(
        "rm-outside-builddir",
        Severity.BLOCK,
        "deletes an absolute path or the user's home",
        # `~` and `$HOME` may be a prefix of the path being deleted, so only
        # an absolute path is anchored. /tmp is exempt: wiping scratch space
        # is normal, wiping /etc is not.
        re.compile(
            r"""\brm\s+-[a-zA-Z]*[rf][a-zA-Z]*\s+["']?"""
            r"""(?:/(?!tmp(?:/|\s|$))|\*|~/|\$HOME|\$\{HOME\})"""
        ),
    ),
    Pattern(
        "write-system-path",
        Severity.BLOCK,
        "writes into a system path instead of $pkgdir",
        # The `>` must not close a quote or a tag, or XML inside a heredoc
        # (`path">/usr/bin/thing</annotate>`) reads as a redirect.
        # A redirect, or the far more common `install`/`cp` into a system
        # path. Two things keep this honest:
        #   - the system path must be the *last* argument, or
        #     `ln -s /opt/X "$pkgdir/etc/Y"` matches on the source it reads
        #   - `$pkgdir/usr` and `$DESTDIR/usr` have no whitespace before the
        #     slash, which is exactly what makes them correct
        # MULTILINE so `$` means end of line: without it the system path
        # had to be the last thing in the whole file.
        re.compile(
            r"(?<![\x22\x27/>])>>?\s*/(?:etc|usr|boot|var|opt)/"
            r"|\b(?:install|cp|mv|ln|tee)\b[^\n]*[ \t]"
            r"/(?:etc|usr|boot|var|opt)/[^\s\x22\x27]*[\x22\x27]?"
            r"[ \t]*[;&|)}\]]*[ \t]*$",
            re.MULTILINE,
        ),
    ),
    Pattern(
        "setuid",
        Severity.BLOCK,
        "sets a setuid or setgid bit",
        # `chmod +s` has no `u`/`g`, and a five-digit octal (`04755`) puts
        # the setuid digit second, so both spellings were missed.
        re.compile(r"\bchmod\s+(?:-R\s+)?(?:[ug]?\+s\b|[0-7]?[24][0-7]{3}\b)"),
    ),
    Pattern(
        "network-in-build",
        Severity.LOOK,
        "fetches from the network while building",
        # `[ \t]`, not `\s`: `\s` matches a newline, so a variable
        # assignment ending in the word (`_tool=curl`) read as a fetch.
        re.compile(r"\b(?:curl|wget)[ \t]"),
        # Every function, not the four that usually build. A download in
        # `pkgver()` or in a helper that `build()` calls is the same act and
        # was simply not looked at.
        funcs=("*",),
    ),
    Pattern(
        "git-clone-in-build",
        Severity.LOOK,
        "clones a git repository while building",
        re.compile(r"\bgit\s+clone\b"),
        funcs=("*",),
    ),
    Pattern("eval", Severity.LOOK, "uses eval", re.compile(r"\beval\b")),
    Pattern(
        "chmod-777",
        Severity.LOOK,
        "sets world-writable permissions",
        re.compile(r"\bchmod\s+(?:-R\s+)?777\b"),
    ),
    Pattern(
        "skippgpcheck",
        Severity.LOOK,
        "disables PGP signature checking",
        re.compile(r"\b(?:--skippgpcheck|!?skippgpcheck|!?pgpcheck)\b"),
    ),
    Pattern(
        "weak-checksum",
        Severity.LOOK,
        "uses a weak checksum algorithm",
        re.compile(r"\b(?:md5sums|sha1sums)\s*="),
    ),
    Pattern(
        "curl-pipe-no-fail",
        Severity.INFO,
        "pipes a download without failing on HTTP errors",
        # Both quantifiers are bounded and the last is possessive. With an
        # unbounded lookahead, one long line containing `curl` but no pipe
        # made every start position scan to the end of the file.
        re.compile(r"\bcurl\b(?![^\n|]{0,120}(?:-f|--fail))[^\n|]{0,400}+\|"),
        funcs=("*",),
    ),
)


#: a PKGBUILD that creates its own functions cannot be attributed per-function
_DYNAMIC_FUNCS = re.compile(r"\beval\b[^\n]*\(\s*\)", re.IGNORECASE)


def functions_generated(pkgbuild) -> bool:
    """Whether this PKGBUILD builds functions at runtime.

    `eval "package_${pkgbase}() { ... }"` produces function bodies that static
    parsing cannot attribute to a name. Scoped rules would then find no body and
    quietly report nothing, which is the worst possible outcome for a security
    check: it looks like a clean result.
    """
    return bool(pkgbuild is not None and _DYNAMIC_FUNCS.search(pkgbuild.raw))


#: Patterns are run against at most this much text.
#:
#: The input is whatever the maintainer wrote, and a regex that backtracks turns
#: one long line into a hung review. The largest real PKGBUILD across this
#: machine's 92 clones is 30 KB (median 1.5 KB), so 64 KB cannot truncate a real
#: package while bounding the worst case. Truncation is reported, never silent.
MAX_SCAN = 64 * 1024


def inside_quotes(text: str, position: int) -> bool:
    """Whether `position` falls inside a double- or single-quoted run on its line.

    `echo "run sudo thing"` is a message, not an invocation, and a regex cannot
    tell the two apart. Counting the quotes earlier on the line can.
    """
    start = text.rfind("\n", 0, position) + 1
    quote = ""
    i = start
    while i < position:
        char = text[i]
        if char == "\\" and quote != "'":
            i += 2
            continue
        if quote:
            if char == quote:
                quote = ""
        elif char in "'\"":
            quote = char
        i += 1
    return bool(quote)


def scan_patterns(change: Change) -> list[Finding]:
    """Scan the new PKGBUILD, suppressing patterns that were already there."""
    new = change.new_pkgbuild
    if new is None:
        return []
    old = change.old_pkgbuild
    findings: list[Finding] = []
    truncated = len(new.raw) > MAX_SCAN
    if truncated:
        findings.append(
            Finding(
                "scan-truncated",
                Severity.INFO,
                f"the PKGBUILD is larger than {MAX_SCAN // 1024} KiB; only the "
                "first part was pattern-scanned",
            )
        )

    dynamic = functions_generated(new)

    for pat in PATTERNS:
        scoped_to_raw = False
        if pat.funcs == ("*",) and not dynamic:
            scope_names = {fn: body for fn, body in new.funcs.items() if body}
        elif pat.funcs and not dynamic:
            scopes = {fn: new.funcs.get(fn, "") for fn in pat.funcs}
            scope_names = {fn: text for fn, text in scopes.items() if text}
        else:
            # Either the rule is file-wide, or the file generates its own
            # functions and per-function attribution is impossible. Scanning the
            # whole file is noisy, but reporting nothing is a false negative.
            scope_names = {"PKGBUILD": new.code}
            scoped_to_raw = bool(pat.funcs)

        for scope, text in scope_names.items():
            match = pat.regex.search(text[:MAX_SCAN])
            if not match:
                continue
            if pat.guard is not None and not pat.guard(text, match):
                continue
            if pat.new_only and old is not None:
                old_text = (
                    old.funcs.get(scope, "")
                    if pat.funcs and not scoped_to_raw and not dynamic
                    else old.code
                )
                if pat.regex.search(old_text[:MAX_SCAN]):
                    continue  # pre-existing; not this update's problem
            line = _line_of(text, match.start())
            findings.append(
                Finding(
                    rule=pat.rule,
                    severity=pat.severity,
                    title=pat.title,
                    evidence=line.strip()[:120],
                    where=scope if pat.funcs else "PKGBUILD",
                )
            )
    return findings


def _line_of(text: str, offset: int) -> str:
    start = text.rfind("\n", 0, offset) + 1
    end = text.find("\n", offset)
    return text[start : end if end != -1 else len(text)]


# --------------------------------------------------------------------------
# field-level rules
# --------------------------------------------------------------------------


def scan_sums(change: Change, diff: SemanticDiff) -> list[Finding]:
    """SKIP is judged per source entry, against that entry's scheme.

    The checksum list is parallel to `source`, so the question is never "is this
    a -git package" but "is this particular entry a live repository". A tarball
    source with SKIP is unverified no matter what the package is called.

    Reads `primary_sums`, not `sha256sums`: a package may checksum with b2sums
    or sha512sums instead, and looking only at sha256sums made those packages
    appear to declare no checksums at all -- so a SKIP newly introduced on a
    plain tarball source produced no finding.
    """
    new = change.new_srcinfo
    if new is None:
        return []
    sums = new.primary_sums
    if not sums:
        return []

    old = change.old_srcinfo
    old_sums = old.primary_sums if old else []
    # A package that verifies with GPG and ships a detached signature covers its
    # sibling metadata through that signature: a repository `Release` file is
    # signed, and the `Packages` index is hashed inside it. SKIP on those is the
    # convention, not an unverified download.
    gpg_verified = bool(new.validpgpkeys) and any(
        is_signature_source(entry) for entry in new.source
    )
    out: list[Finding] = []
    for index, checksum in enumerate(sums):
        if checksum.strip().upper() != "SKIP":
            continue
        source = new.source[index] if index < len(new.source) else ""
        if source and is_signature_source(source):
            out.append(
                Finding(
                    "sums-skip-signature",
                    Severity.INFO,
                    "SKIP checksum for a detached signature (verified against validpgpkeys)",
                    evidence=source[:120],
                    where=f"source[{index}]",
                )
            )
            continue
        if source and domain_of(source) == "(local)":
            # Nothing is downloaded, so there is no supply chain to trust. It
            # removes integrity checking on a file shipped inside the package,
            # which is worth knowing and not worth blocking on.
            out.append(
                Finding(
                    "sums-skip-local",
                    Severity.INFO,
                    "SKIP checksum for a file shipped in the package",
                    evidence=source[:120],
                    where=f"source[{index}]",
                )
            )
            continue
        if source and is_vcs_source(source):
            out.append(
                Finding(
                    "sums-skip-vcs",
                    Severity.INFO,
                    "SKIP checksum for a live VCS source (expected)",
                    evidence=source[:120],
                    where=f"source[{index}]",
                )
            )
            continue

        was_skip = bool(index < len(old_sums) and old_sums[index].strip().upper() == "SKIP")
        if was_skip or gpg_verified:
            severity = Severity.INFO
        else:
            severity = Severity.BLOCK
        out.append(
            Finding(
                "sums-skip-existing" if was_skip else "sums-skip-introduced",
                severity,
                "SKIP checksum on a source that is not a live repository"
                + ("" if was_skip else " (newly introduced)"),
                evidence=source[:120] or f"checksum entry {index}",
                where=f"source[{index}]" if source else "",
            )
        )
    return out


def scan_pgp(change: Change, diff: SemanticDiff) -> list[Finding]:
    """Removing a trusted key matters only if nothing replaced it.

    A maintainer rotating an upstream signing key removes the old one and adds
    the new one in the same commit, which is routine. Reporting that as a BLOCK
    trains the reader to ignore the one that is not routine.
    """
    removed = diff.validpgpkeys.removed
    if not removed:
        return []
    if diff.validpgpkeys.added:
        return [
            Finding(
                "pgpkey-rotated",
                Severity.INFO,
                "a trusted PGP key was replaced",
                evidence=", ".join(removed)[:120],
                where="the new key is in this revision",
            )
        ]
    return [
        Finding(
            "pgpkey-removed",
            Severity.BLOCK,
            "a trusted PGP key was removed and not replaced",
            evidence=", ".join(removed)[:120],
        )
    ]


def scan_deps(change: Change, diff: SemanticDiff) -> list[Finding]:
    """New AUR trust edges: a new dep means trusting another maintainer."""
    out: list[Finding] = []
    if diff.depends.added:
        out.append(
            Finding(
                "new-runtime-dep",
                Severity.INFO,
                f"{len(diff.depends.added)} new runtime dependency(ies)",
                evidence=", ".join(diff.depends.added)[:120],
            )
        )
    if diff.makedepends.added:
        out.append(
            Finding(
                "new-build-dep",
                Severity.INFO,
                f"{len(diff.makedepends.added)} new build dependency(ies)",
                evidence=", ".join(diff.makedepends.added)[:120],
            )
        )
    if diff.replaces.added:
        out.append(
            Finding(
                "new-replaces",
                Severity.LOOK,
                "now declares that it replaces another package",
                evidence=", ".join(diff.replaces.added)[:120],
            )
        )
    if diff.conflicts.added:
        out.append(
            Finding(
                "new-conflicts",
                Severity.LOOK,
                "now declares a conflict",
                evidence=", ".join(diff.conflicts.added)[:120],
            )
        )
    return out


def scan_install_scriptlet(change: Change, diff: SemanticDiff) -> list[Finding]:
    """A `.install` scriptlet runs as root on the user's machine.

    The text arrives on the Change rather than being read here. Reading it with
    `Path(name)` resolved against the process working directory, and the CLI
    never chdirs, so from a normal invocation the file was never found and the
    scriptlet was never looked at. The old side was read from the same working
    tree rather than from the baseline commit, so a scriptlet that changed
    reported "already present".
    """
    if change.new_pkgbuild is None:
        return []

    dangerous = re.compile(
        r"\b(?:systemctl|sysusers|useradd|groupadd|udevadm|update-desktop-database)\b"
        r"|(?<![\x22\x27/>])>>?\s*/(?:etc|usr|boot|var)/"
        r"|\bcrontab\b|\bat\s+now\b"
    )

    out: list[Finding] = []
    for name, text in sorted(change.new_install.items()):
        match = dangerous.search(text)
        if not match:
            continue
        old_text = change.old_install.get(name, "")
        newly = not old_text or not dangerous.search(old_text)
        out.append(
            Finding(
                "install-scriptlet-system",
                Severity.LOOK if newly else Severity.INFO,
                "an install scriptlet touches the system"
                + ("" if newly else " (unchanged from the previous revision)"),
                evidence=match.group(0)[:120],
                where=name,
            )
        )

    # A scriptlet is shell like any other, so the same patterns apply. Nothing
    # was scanning it before, which meant `curl | bash` inside one was invisible.
    for name, text in sorted(change.new_install.items()):
        old_text = change.old_install.get(name, "")
        for pat in PATTERNS:
            found = pat.regex.search(text[:MAX_SCAN])
            if not found:
                continue
            if pat.guard is not None and not pat.guard(text, found):
                continue
            if old_text and pat.regex.search(old_text[:MAX_SCAN]):
                continue
            out.append(
                Finding(
                    pat.rule,
                    pat.severity,
                    pat.title + " (in an install scriptlet)",
                    evidence=found.group(0)[:120],
                    where=name,
                )
            )
    return out


def scan_maintainer(change: Change) -> list[Finding]:
    """Who maintains the package now, against who maintained it before.

    `None` is "no record" and `""` is "orphaned". Adoption -- an orphaned
    package acquiring a maintainer -- is the AUR account-takeover vector, so it
    is reported even though nothing else about the update looks unusual.
    Orphaning is not suspicious on its own and is only context.
    """
    old, new = change.old_maintainer, change.new_maintainer

    if new is None:
        # the package is orphaned now
        if old:
            return [
                Finding(
                    "orphaned",
                    Severity.INFO,
                    "package has been orphaned",
                    evidence=f"was maintained by {old}",
                )
            ]
        return []

    if not new.strip():
        if old:
            return [
                Finding(
                    "orphaned",
                    Severity.INFO,
                    "package has been orphaned",
                    evidence=f"was maintained by {old}",
                )
            ]
        return []

    if old is None:
        # no record of who had it: a first audit cannot tell adoption from
        # continuity, and reporting every package would be pure noise
        return []

    if not old:
        return [
            Finding(
                "maintainer-adopted",
                Severity.LOOK,
                "an orphaned package has been adopted by a new maintainer",
                evidence=f"was orphaned -> {new}",
            )
        ]

    if old == new:
        return []
    return [
        Finding(
            "maintainer-changed",
            Severity.LOOK,
            "package changed maintainer",
            evidence=f"{old} -> {new}",
        )
    ]


def scan_sources(change: Change, diff: SemanticDiff) -> list[Finding]:
    out: list[Finding] = []
    for move in diff.sources_moved:
        if move.old_domain == move.new_domain:
            continue
        out.append(
            Finding(
                "source-domain-changed",
                Severity.LOOK,
                f"{move.filename} now comes from a different host",
                evidence=f"{move.old_domain} -> {move.new_domain}",
            )
        )
    # A version bump renames the tarball, so `added` and `removed` are both
    # non-empty on almost every commit; requiring `added and not removed`
    # reports nothing at all and hides the shape that matters, which is slipping
    # an extra source in during a routine update. The signal is the list getting
    # *longer*, not an entry being unfamiliar.
    # Without an old side there is nothing to compare: every source looks newly
    # added, which is how "was 0, now 1" appears on packages that have no
    # baseline. Not knowing is not evidence of a change.
    if change.old_srcinfo is None or change.new_srcinfo is None:
        return out

    old_sources = change.old_srcinfo.source
    new_sources = change.new_srcinfo.source
    grew = len(new_sources) > len(old_sources)
    if not grew:
        return out

    known_hosts = {domain_of(entry) for entry in old_sources}
    for entry in diff.source.added:
        host = domain_of(entry)
        if host and host != "(local)" and host not in known_hosts:
            out.append(
                Finding(
                    "source-host-added",
                    Severity.LOOK,
                    f"a source from {host} was added, a host this package has not used before",
                    evidence=entry[:120],
                )
            )
    out.append(
        Finding(
            "source-added",
            Severity.INFO,
            f"the package now has {len(new_sources)} source(s), was {len(old_sources)}",
            evidence=", ".join(diff.source.added)[:120],
        )
    )
    return out


RULES = (
    scan_sums,
    scan_pgp,
    scan_deps,
    scan_install_scriptlet,
    scan_maintainer,
    scan_sources,
    scan_patterns,
)


# --------------------------------------------------------------------------
# entry point
# --------------------------------------------------------------------------


def semantic_diff(change: Change) -> SemanticDiff:
    old, new = change.old_srcinfo, change.new_srcinfo
    d = SemanticDiff()
    if old and new:
        d.pkgver = _pair(old.pkgver, new.pkgver)
        d.pkgrel = _pair(old.pkgrel, new.pkgrel)
        d.epoch = _pair(old.epoch, new.epoch)
        d.source = _setdiff(old.source, new.source)
        d.sums = _setdiff(old.primary_sums, new.primary_sums)
        d.depends = _setdiff(old.depends, new.depends)
        d.makedepends = _setdiff(old.makedepends, new.makedepends)
        d.optdepends = _setdiff(old.optdepends, new.optdepends)
        d.provides = _setdiff(old.provides, new.provides)
        d.conflicts = _setdiff(old.conflicts, new.conflicts)
        d.replaces = _setdiff(old.replaces, new.replaces)
        d.validpgpkeys = _setdiff(old.validpgpkeys, new.validpgpkeys)

        # "the source moved" detection. Pairing by index handles the common case
        # where a version bump renames the tarball; pairing by filename is the
        # fallback when the source list itself changed shape.
        pairs: list[tuple[str, str]]
        if len(old.source) == len(new.source):
            pairs = list(zip(old.source, new.source))
        else:
            before = {filename_of(u): u for u in old.source}
            after = {filename_of(u): u for u in new.source}
            pairs = [(before[k], after[k]) for k in sorted(set(before) & set(after))]
        for old_url, new_url in pairs:
            if old_url != new_url:
                d.sources_moved.append(SourceMove(filename_of(new_url), old_url, new_url))

    if change.old_pkgbuild and change.new_pkgbuild:
        o, n = change.old_pkgbuild, change.new_pkgbuild
        d.funcs_added = sorted(set(n.funcs) - set(o.funcs))
        d.funcs_removed = sorted(set(o.funcs) - set(n.funcs))
        d.funcs_changed = sorted(
            fn for fn in set(o.funcs) & set(n.funcs) if o.funcs[fn] != n.funcs[fn]
        )
        d.lines_added, d.lines_removed = _lines_changed(o.raw, n.raw)

    return d


def audit(change: Change) -> Report:
    diff = semantic_diff(change)
    findings: list[Finding] = []
    for rule in RULES:
        try:
            findings.extend(rule(change, diff) if _takes_diff(rule) else rule(change))
        except Exception as exc:  # noqa: BLE001 - a broken rule must not kill the run
            findings.append(
                Finding(rule.__name__, Severity.INFO, f"rule failed: {type(exc).__name__}: {exc}")
            )
    order = {Severity.BLOCK: 0, Severity.LOOK: 1, Severity.INFO: 2}
    findings.sort(key=lambda f: (order[f.severity], f.rule))
    if change.new_pkgbuild is None:
        basis = Basis.NOTHING
    elif change.old_pkgbuild is None:
        basis = Basis.NEW_ONLY
    else:
        basis = Basis.COMPARED
    return Report(package=change.package, diff=diff, findings=findings, basis=basis)


def _takes_diff(rule) -> bool:
    return rule.__name__ in {
        "scan_sums",
        "scan_pgp",
        "scan_deps",
        "scan_sources",
        "scan_install_scriptlet",
    }