software-intake-gatekeeper 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Software Intake Gatekeeper: evidence-first intake checks for third-party software."""
2
+
3
+ __version__ = "0.2.1"
@@ -0,0 +1,3 @@
1
+ from intake_gatekeeper.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,117 @@
1
+ """Read-only ASAR archive reader that never extracts files to disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import struct
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from typing import BinaryIO
10
+
11
+ from intake_gatekeeper.model import GatekeeperError
12
+
13
+ _MAX_DEPTH = 64
14
+
15
+
16
+ class AsarError(GatekeeperError):
17
+ pass
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class AsarEntry:
22
+ path: str
23
+ offset: int
24
+ size: int
25
+ unpacked: bool
26
+
27
+
28
+ def _u32(data: bytes, at: int) -> int:
29
+ return int(struct.unpack_from("<I", data, at)[0])
30
+
31
+
32
+ def read_header(handle: BinaryIO, archive_size: int, max_header_bytes: int) -> tuple[dict[str, object], int]:
33
+ """Return the parsed header and the absolute offset where file contents start."""
34
+ prefix = handle.read(8)
35
+ if len(prefix) != 8 or _u32(prefix, 0) != 4:
36
+ raise AsarError("not an ASAR archive (missing size pickle)")
37
+ header_pickle_size = _u32(prefix, 4)
38
+ if header_pickle_size < 8 or header_pickle_size > archive_size - 8:
39
+ raise AsarError("ASAR header size exceeds the archive size")
40
+ if header_pickle_size > max_header_bytes:
41
+ raise AsarError(f"ASAR header is larger than the {max_header_bytes} byte limit")
42
+ pickle = handle.read(header_pickle_size)
43
+ if len(pickle) != header_pickle_size:
44
+ raise AsarError("truncated ASAR header")
45
+ payload_size = _u32(pickle, 0)
46
+ string_size = struct.unpack_from("<i", pickle, 4)[0]
47
+ if payload_size > header_pickle_size - 4 or not 0 <= string_size <= payload_size - 4:
48
+ raise AsarError("corrupt ASAR header pickle")
49
+ try:
50
+ header = json.loads(pickle[8 : 8 + string_size].decode("utf-8"))
51
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
52
+ raise AsarError(f"ASAR header is not valid JSON: {error}") from None
53
+ if not isinstance(header, dict) or not isinstance(header.get("files"), dict):
54
+ raise AsarError("ASAR header has no root 'files' object")
55
+ return header, 8 + header_pickle_size
56
+
57
+
58
+ def iter_entries(header: dict[str, object], max_entries: int) -> Iterator[AsarEntry | str]:
59
+ """Yield file entries, or a problem description for entries that were skipped."""
60
+ stack: list[tuple[str, dict[str, object], int]] = [("", header, 0)]
61
+ emitted = 0
62
+ while stack:
63
+ prefix, node, depth = stack.pop()
64
+ files = node.get("files")
65
+ if not isinstance(files, dict):
66
+ continue
67
+ for name in sorted(files):
68
+ child = files[name]
69
+ if (
70
+ not isinstance(name, str)
71
+ or name in ("", ".", "..")
72
+ or "/" in name
73
+ or "\\" in name
74
+ or "\x00" in name
75
+ ):
76
+ yield f"unsafe entry name {name!r} under '{prefix or '/'}'"
77
+ continue
78
+ path = f"{prefix}{name}"
79
+ if not isinstance(child, dict):
80
+ yield f"malformed entry '{path}'"
81
+ continue
82
+ if "files" in child:
83
+ if depth + 1 >= _MAX_DEPTH:
84
+ yield f"directory nesting limit reached at '{path}'"
85
+ continue
86
+ stack.append((path + "/", child, depth + 1))
87
+ continue
88
+ if "link" in child:
89
+ continue
90
+ emitted += 1
91
+ if emitted > max_entries:
92
+ yield f"entry limit of {max_entries} reached"
93
+ return
94
+ unpacked = child.get("unpacked") is True
95
+ size = child.get("size")
96
+ offset = child.get("offset", "0" if unpacked else None)
97
+ if (
98
+ not isinstance(size, int)
99
+ or isinstance(size, bool)
100
+ or size < 0
101
+ or not isinstance(offset, str)
102
+ or not offset.isdigit()
103
+ ):
104
+ yield f"entry '{path}' has an invalid offset or size"
105
+ continue
106
+ yield AsarEntry(path=path, offset=int(offset), size=size, unpacked=unpacked)
107
+
108
+
109
+ def read_entry(handle: BinaryIO, data_start: int, archive_size: int, entry: AsarEntry) -> bytes:
110
+ start = data_start + entry.offset
111
+ if start + entry.size > archive_size:
112
+ raise AsarError(f"entry '{entry.path}' points outside the archive")
113
+ handle.seek(start)
114
+ data = handle.read(entry.size)
115
+ if len(data) != entry.size:
116
+ raise AsarError(f"entry '{entry.path}' is truncated")
117
+ return data
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Callable, Sequence
6
+ from pathlib import Path
7
+
8
+ from intake_gatekeeper import __version__, output
9
+ from intake_gatekeeper.diff import compare
10
+ from intake_gatekeeper.engine import run_scan
11
+ from intake_gatekeeper.model import Decision, GatekeeperError, clean_text
12
+ from intake_gatekeeper.osv import DEFAULT_URL, OsvClient, validate_base_url
13
+ from intake_gatekeeper.policy import load_policy
14
+ from intake_gatekeeper.rules import RULES
15
+ from intake_gatekeeper.scanner import Limits
16
+
17
+ EXIT_OK = 0
18
+ EXIT_GATE = 1
19
+ EXIT_USAGE = 2
20
+ _FAIL_LEVELS = {
21
+ "reject": {Decision.REJECT},
22
+ "review": {Decision.REJECT, Decision.REVIEW},
23
+ "never": set(),
24
+ }
25
+
26
+
27
+ def _common(command: argparse.ArgumentParser) -> None:
28
+ command.add_argument(
29
+ "--osv",
30
+ action="store_true",
31
+ help="look up known vulnerabilities on OSV.dev (sends ecosystem, name and version of "
32
+ "each queryable component; nothing else)",
33
+ )
34
+ command.add_argument("--osv-url", default=DEFAULT_URL, help="OSV API origin (default: %(default)s)")
35
+ command.add_argument("--policy", type=Path, help="policy TOML file")
36
+ command.add_argument("--json", type=Path, metavar="FILE", help="write the full JSON report")
37
+ command.add_argument(
38
+ "--sarif",
39
+ type=Path,
40
+ metavar="FILE",
41
+ help="write SARIF 2.1.0 (paths are relative to the current directory when the target "
42
+ "is inside it, which is what GitHub code scanning expects)",
43
+ )
44
+ command.add_argument("--cyclonedx", type=Path, metavar="FILE", help="write a CycloneDX 1.6 SBOM")
45
+ command.add_argument("--markdown", type=Path, metavar="FILE", help="write a Markdown summary")
46
+ command.add_argument(
47
+ "--fail-on",
48
+ choices=sorted(_FAIL_LEVELS),
49
+ default="reject",
50
+ help="decision that produces exit status 1 (default: %(default)s)",
51
+ )
52
+ command.add_argument("--max-files", type=int, default=Limits.max_files, help=argparse.SUPPRESS)
53
+ command.add_argument("-q", "--quiet", action="store_true", help="do not print the summary")
54
+
55
+
56
+ def _parser() -> argparse.ArgumentParser:
57
+ parser = argparse.ArgumentParser(
58
+ prog="intake-gatekeeper",
59
+ description="Evidence-first intake checks for third-party apps and binaries. "
60
+ "Reads files only; never executes or extracts the target.",
61
+ )
62
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
63
+ commands = parser.add_subparsers(dest="command", required=True)
64
+
65
+ scan = commands.add_parser("scan", help="scan a directory, archive or binary")
66
+ scan.add_argument("target", type=Path, help="installed app directory, .asar or executable")
67
+ _common(scan)
68
+
69
+ diff = commands.add_parser(
70
+ "diff",
71
+ help="compare two versions and gate the update on what it adds",
72
+ description="Scan the previous and the new version and report new and resolved findings, "
73
+ "component changes, Electron fuse changes, new network endpoints and new binaries.",
74
+ )
75
+ diff.add_argument("old", type=Path, help="previous version (directory, .asar or executable)")
76
+ diff.add_argument("new", type=Path, help="new version")
77
+ _common(diff)
78
+
79
+ commands.add_parser("rules", help="list configuration rules")
80
+ return parser
81
+
82
+
83
+ def _list_rules() -> int:
84
+ for rule in RULES.values():
85
+ print(f"{rule.id:36} {rule.severity.label:8} {rule.title}")
86
+ return EXIT_OK
87
+
88
+
89
+ def _write(outputs: Sequence[tuple[Path | None, Callable[[], str]]]) -> None:
90
+ for path, render in outputs:
91
+ if path is not None:
92
+ output.write_atomic(path, render())
93
+
94
+
95
+ def main(
96
+ argv: Sequence[str] | None = None,
97
+ osv_factory: Callable[[str], OsvClient] = OsvClient,
98
+ ) -> int:
99
+ args = _parser().parse_args(argv)
100
+ for stream in (sys.stdout, sys.stderr):
101
+ reconfigure = getattr(stream, "reconfigure", None)
102
+ if reconfigure is not None:
103
+ reconfigure(errors="replace")
104
+ if args.command == "rules":
105
+ return _list_rules()
106
+ try:
107
+ if args.max_files < 1:
108
+ raise GatekeeperError("--max-files must be positive")
109
+ policy = load_policy(args.policy)
110
+ limits = Limits(max_files=args.max_files)
111
+ osv = osv_factory(validate_base_url(args.osv_url)) if args.osv else None
112
+ try:
113
+ if args.command == "diff":
114
+ old_report = run_scan(args.old, policy, osv, limits)
115
+ new_report = run_scan(args.new, policy, osv, limits)
116
+ else:
117
+ report = run_scan(args.target, policy, osv, limits)
118
+ finally:
119
+ if osv is not None:
120
+ osv.close()
121
+ if args.command == "diff":
122
+ diff = compare(old_report, new_report, policy)
123
+ prefix = output.sarif_prefix(args.new, Path.cwd())
124
+ _write(
125
+ [
126
+ (args.json, lambda: output.to_diff_json(diff)),
127
+ (args.sarif, lambda: output.diff_sarif(diff, prefix)),
128
+ (args.cyclonedx, lambda: output.to_cyclonedx(new_report)),
129
+ (args.markdown, lambda: output.to_diff_markdown(diff)),
130
+ ]
131
+ )
132
+ summary, decision = output.to_diff_text(diff), diff.decision
133
+ else:
134
+ prefix = output.sarif_prefix(args.target, Path.cwd())
135
+ _write(
136
+ [
137
+ (args.json, lambda: output.to_json(report)),
138
+ (args.sarif, lambda: output.to_sarif(report, prefix)),
139
+ (args.cyclonedx, lambda: output.to_cyclonedx(report)),
140
+ (args.markdown, lambda: output.to_markdown(report)),
141
+ ]
142
+ )
143
+ summary, decision = output.to_text(report), report.decision
144
+ except (GatekeeperError, OSError) as error:
145
+ print(f"intake-gatekeeper: error: {clean_text(str(error), 500)}", file=sys.stderr)
146
+ return EXIT_USAGE
147
+ if not args.quiet:
148
+ sys.stdout.write(summary)
149
+ return EXIT_GATE if decision in _FAIL_LEVELS[args.fail_on] else EXIT_OK
@@ -0,0 +1,154 @@
1
+ """Compare two scanned versions of the same software and decide whether the update adds risk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from intake_gatekeeper.electron import FUSE_RULES
8
+ from intake_gatekeeper.model import Decision, Finding, Report
9
+ from intake_gatekeeper.policy import Policy
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ComponentChange:
14
+ ecosystem: str
15
+ name: str
16
+ old: tuple[str, ...]
17
+ new: tuple[str, ...]
18
+
19
+ @property
20
+ def kind(self) -> str:
21
+ if not self.old:
22
+ return "added"
23
+ if not self.new:
24
+ return "removed"
25
+ return "changed"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class FuseChange:
30
+ executable: str
31
+ fuse: str
32
+ old: str | None
33
+ new: str | None
34
+ weakened: bool
35
+
36
+
37
+ @dataclass
38
+ class UpdateDiff:
39
+ old: Report
40
+ new: Report
41
+ components: list[ComponentChange] = field(default_factory=list)
42
+ new_findings: list[Finding] = field(default_factory=list)
43
+ resolved_findings: list[Finding] = field(default_factory=list)
44
+ persisting_findings: int = 0
45
+ electron_versions: tuple[str | None, str | None] | None = None
46
+ fuse_changes: list[FuseChange] = field(default_factory=list)
47
+ endpoints_added: list[str] = field(default_factory=list)
48
+ endpoints_removed: list[str] = field(default_factory=list)
49
+ binaries_added: list[str] = field(default_factory=list)
50
+ binaries_removed: list[str] = field(default_factory=list)
51
+ binaries_changed: list[str] = field(default_factory=list)
52
+ decision: Decision = Decision.APPROVE
53
+ reasons: list[str] = field(default_factory=list)
54
+
55
+
56
+ def _finding_key(finding: Finding) -> tuple[str, str]:
57
+ if finding.component_purl:
58
+ return finding.rule_id, finding.component_purl.rsplit("@", 1)[0]
59
+ return finding.rule_id, finding.location.path if finding.location else ""
60
+
61
+
62
+ def _versions(report: Report) -> dict[tuple[str, str], set[str]]:
63
+ versions: dict[tuple[str, str], set[str]] = {}
64
+ for component in report.inventory.components:
65
+ if component.kind == "application":
66
+ continue
67
+ versions.setdefault((component.ecosystem, component.name), set()).add(component.version)
68
+ return versions
69
+
70
+
71
+ def compare(old: Report, new: Report, policy: Policy) -> UpdateDiff:
72
+ diff = UpdateDiff(old=old, new=new)
73
+
74
+ old_versions, new_versions = _versions(old), _versions(new)
75
+ for key in sorted(old_versions.keys() | new_versions.keys()):
76
+ before, after = old_versions.get(key, set()), new_versions.get(key, set())
77
+ if before != after:
78
+ diff.components.append(
79
+ ComponentChange(key[0], key[1], tuple(sorted(before)), tuple(sorted(after)))
80
+ )
81
+
82
+ old_keys = {_finding_key(f) for f in old.inventory.findings}
83
+ new_keys = {_finding_key(f) for f in new.inventory.findings}
84
+ diff.new_findings = [f for f in new.inventory.findings if _finding_key(f) not in old_keys]
85
+ diff.resolved_findings = [f for f in old.inventory.findings if _finding_key(f) not in new_keys]
86
+ diff.persisting_findings = len(new.inventory.findings) - len(diff.new_findings)
87
+
88
+ old_electron = {info.executable: info for info in old.inventory.electron}
89
+ new_electron = {info.executable: info for info in new.inventory.electron}
90
+ old_version = next((i.version for i in old.inventory.electron if i.version), None)
91
+ new_version = next((i.version for i in new.inventory.electron if i.version), None)
92
+ if old_version != new_version:
93
+ diff.electron_versions = (old_version, new_version)
94
+ risky = {fuse: state for fuse, state, _ in FUSE_RULES}
95
+ for executable in sorted(old_electron.keys() | new_electron.keys()):
96
+ old_fuses = old_electron[executable].fuses if executable in old_electron else {}
97
+ new_fuses = new_electron[executable].fuses if executable in new_electron else {}
98
+ for fuse in sorted(old_fuses.keys() | new_fuses.keys()):
99
+ if old_fuses.get(fuse) != new_fuses.get(fuse):
100
+ weakened = fuse in risky and new_fuses.get(fuse) == risky[fuse]
101
+ diff.fuse_changes.append(
102
+ FuseChange(executable, fuse, old_fuses.get(fuse), new_fuses.get(fuse), weakened)
103
+ )
104
+
105
+ old_hosts, new_hosts = set(old.inventory.endpoints), set(new.inventory.endpoints)
106
+ diff.endpoints_added = sorted(new_hosts - old_hosts)
107
+ diff.endpoints_removed = sorted(old_hosts - new_hosts)
108
+
109
+ old_bins = {b.path: b.sha256 for b in old.inventory.binaries}
110
+ new_bins = {b.path: b.sha256 for b in new.inventory.binaries}
111
+ diff.binaries_added = sorted(new_bins.keys() - old_bins.keys())
112
+ diff.binaries_removed = sorted(old_bins.keys() - new_bins.keys())
113
+ diff.binaries_changed = sorted(p for p in new_bins.keys() & old_bins.keys() if new_bins[p] != old_bins[p])
114
+
115
+ diff.decision, diff.reasons = _decide(diff, policy)
116
+ return diff
117
+
118
+
119
+ def _decide(diff: UpdateDiff, policy: Policy) -> tuple[Decision, list[str]]:
120
+ active = [f for f in diff.new_findings if not f.waived]
121
+ at_reject = [f for f in active if f.severity >= policy.reject_at]
122
+ at_review = [f for f in active if policy.review_at <= f.severity < policy.reject_at]
123
+ weakened = [c for c in diff.fuse_changes if c.weakened]
124
+ added_components = [c for c in diff.components if c.kind == "added"]
125
+ blocking = [g for g in diff.new.inventory.gaps if g.blocking]
126
+ reasons: list[str] = []
127
+ if at_reject:
128
+ reasons.append(f"{len(at_reject)} new finding(s) at or above {policy.reject_at.label}.")
129
+ if at_review:
130
+ reasons.append(
131
+ f"{len(at_review)} new finding(s) between {policy.review_at.label} and {policy.reject_at.label}."
132
+ )
133
+ if weakened:
134
+ reasons.append(f"{len(weakened)} Electron fuse(s) weakened.")
135
+ if diff.endpoints_added:
136
+ reasons.append(f"{len(diff.endpoints_added)} new network endpoint(s) referenced by app code.")
137
+ if diff.binaries_added:
138
+ reasons.append(f"{len(diff.binaries_added)} new executable(s) or native module(s).")
139
+ if added_components:
140
+ reasons.append(f"{len(added_components)} new component(s).")
141
+ if blocking:
142
+ reasons.append(
143
+ f"{len(blocking)} coverage gap(s) in the new version: "
144
+ + "; ".join(sorted({g.code for g in blocking}))
145
+ + "."
146
+ )
147
+ if at_reject:
148
+ return Decision.REJECT, reasons
149
+ if reasons:
150
+ return Decision.REVIEW, reasons
151
+ summary = "No new risk compared with the previous version"
152
+ if diff.resolved_findings:
153
+ summary += f"; {len(diff.resolved_findings)} finding(s) resolved"
154
+ return Decision.APPROVE, [summary + "."]
@@ -0,0 +1,190 @@
1
+ """Static Electron checks: fuse wire, runtime version and risky BrowserWindow preferences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from intake_gatekeeper.golang import Bytes
9
+ from intake_gatekeeper.model import Location, clean_text
10
+
11
+ SENTINEL = b"dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX"
12
+ FUSE_NAMES = (
13
+ "RunAsNode",
14
+ "EnableCookieEncryption",
15
+ "EnableNodeOptionsEnvironmentVariable",
16
+ "EnableNodeCliInspectArguments",
17
+ "EnableEmbeddedAsarIntegrityValidation",
18
+ "OnlyLoadAppFromAsar",
19
+ "LoadBrowserProcessSpecificV8Snapshot",
20
+ "GrantFileProtocolExtraPrivileges",
21
+ "WasmTrapHandlers",
22
+ )
23
+ _STATES = {0x30: "disabled", 0x31: "enabled", 0x72: "removed"}
24
+ # (fuse, risky state, rule id)
25
+ FUSE_RULES = (
26
+ ("RunAsNode", "enabled", "EGK-FUSE-RUN-AS-NODE"),
27
+ ("EnableCookieEncryption", "disabled", "EGK-FUSE-COOKIE-ENCRYPTION-OFF"),
28
+ ("EnableNodeOptionsEnvironmentVariable", "enabled", "EGK-FUSE-NODE-OPTIONS"),
29
+ ("EnableNodeCliInspectArguments", "enabled", "EGK-FUSE-NODE-INSPECT"),
30
+ ("EnableEmbeddedAsarIntegrityValidation", "disabled", "EGK-FUSE-ASAR-INTEGRITY-OFF"),
31
+ ("OnlyLoadAppFromAsar", "disabled", "EGK-FUSE-LOAD-OUTSIDE-ASAR"),
32
+ ("GrantFileProtocolExtraPrivileges", "enabled", "EGK-FUSE-FILE-PROTOCOL-PRIVILEGES"),
33
+ )
34
+ _VERSION_PREFIX = b"Electron/"
35
+ _VERSION = re.compile(rb"Electron/(\d{1,3}\.\d{1,4}\.\d{1,4}(?:-(?:alpha|beta|nightly)\.[0-9.]{1,20})?)")
36
+ _MAX_VERSION_PROBES = 4096
37
+
38
+ _TRUE = r"(?:true|!0|1)\b"
39
+ _FALSE = r"(?:false|!1|0)\b"
40
+
41
+
42
+ def _key(name: str) -> str:
43
+ # Patterns start with a literal so re can use its fast substring search; see _boundary().
44
+ return rf"{name}[\"']?\s*(?::|=(?!=))\s*"
45
+
46
+
47
+ JS_RULES: tuple[tuple[str, str, re.Pattern[str]], ...] = (
48
+ ("EGK-JS-NODE-INTEGRATION", "nodeIntegration", re.compile(_key("nodeIntegration") + _TRUE)),
49
+ (
50
+ "EGK-JS-CONTEXT-ISOLATION-OFF",
51
+ "contextIsolation",
52
+ re.compile(_key("contextIsolation") + _FALSE),
53
+ ),
54
+ ("EGK-JS-WEB-SECURITY-OFF", "webSecurity", re.compile(_key("webSecurity") + _FALSE)),
55
+ (
56
+ "EGK-JS-INSECURE-CONTENT",
57
+ "allowRunningInsecureContent",
58
+ re.compile(_key("allowRunningInsecureContent") + _TRUE),
59
+ ),
60
+ (
61
+ "EGK-JS-NODE-INTEGRATION-EXTENDED",
62
+ "nodeIntegrationIn",
63
+ re.compile(_key("nodeIntegrationIn(?:SubFrames|Worker)") + _TRUE),
64
+ ),
65
+ ("EGK-JS-REMOTE-MODULE", "enableRemoteModule", re.compile(_key("enableRemoteModule") + _TRUE)),
66
+ ("EGK-JS-WEBVIEW-TAG", "webviewTag", re.compile(_key("webviewTag") + _TRUE)),
67
+ (
68
+ "EGK-JS-EXPERIMENTAL-FEATURES",
69
+ "experimentalFeatures",
70
+ re.compile(_key("experimentalFeatures") + _TRUE),
71
+ ),
72
+ ("EGK-JS-OPEN-EXTERNAL", "openExternal", re.compile(r"shell\s*\.\s*openExternal\s*\(")),
73
+ )
74
+ _WEB_PREFERENCES = re.compile(r"webPreferences")
75
+ _SANDBOX_OFF = re.compile(_key("sandbox") + _FALSE)
76
+ _WEB_PREFERENCES_WINDOW = 1200
77
+ MAX_MATCHES_PER_RULE = 10
78
+
79
+
80
+ def _boundary(text: str, start: int) -> bool:
81
+ """True when the match is not the tail of a longer identifier (an opening quote is skipped)."""
82
+ before = start - 1
83
+ if before >= 0 and text[before] in "\"'":
84
+ before -= 1
85
+ return before < 0 or not (text[before].isalnum() or text[before] in "_$")
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class FuseWire:
90
+ offset: int
91
+ version: int
92
+ states: dict[str, str]
93
+ raw: str
94
+
95
+
96
+ def read_fuse_wires(data: Bytes, limit: int = 4) -> list[FuseWire]:
97
+ wires: list[FuseWire] = []
98
+ start = 0
99
+ while len(wires) < limit:
100
+ index = data.find(SENTINEL, start)
101
+ if index < 0:
102
+ break
103
+ start = index + 1
104
+ header = index + len(SENTINEL)
105
+ if header + 2 > len(data):
106
+ break
107
+ version = data[header]
108
+ length = data[header + 1]
109
+ wire = data[header + 2 : header + 2 + length]
110
+ states: dict[str, str] = {}
111
+ for position, value in enumerate(wire):
112
+ name = FUSE_NAMES[position] if position < len(FUSE_NAMES) else f"Fuse{position}"
113
+ states[name] = _STATES.get(value, f"unknown(0x{value:02x})")
114
+ raw = "".join(chr(value) if 0x20 < value < 0x7F else "?" for value in wire)
115
+ wires.append(FuseWire(offset=index, version=version, states=states, raw=raw))
116
+ return wires
117
+
118
+
119
+ def find_electron_versions(data: Bytes) -> list[str]:
120
+ versions: set[str] = set()
121
+ start = 0
122
+ for _ in range(_MAX_VERSION_PROBES):
123
+ index = data.find(_VERSION_PREFIX, start)
124
+ if index < 0:
125
+ break
126
+ start = index + 1
127
+ match = _VERSION.match(data[index : index + 64])
128
+ if match:
129
+ versions.add(match.group(1).decode("ascii"))
130
+ return sorted(versions)
131
+
132
+
133
+ _URL_HOST = re.compile(r"(?:https?|wss?)://([A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63})+)(?![A-Za-z0-9-])")
134
+ # Namespace and spec URLs that appear in almost every bundle but are not network endpoints.
135
+ _NOT_ENDPOINTS = {
136
+ "www.w3.org",
137
+ "w3.org",
138
+ "schemas.xmlsoap.org",
139
+ "schemas.microsoft.com",
140
+ "json-schema.org",
141
+ }
142
+
143
+
144
+ def find_endpoints(text: str) -> set[str]:
145
+ hosts: set[str] = set()
146
+ for match in _URL_HOST.finditer(text):
147
+ host = match.group(1).lower().rstrip(".")
148
+ if host in _NOT_ENDPOINTS or host.startswith(("localhost", "127.")) or host.endswith(".localhost"):
149
+ continue
150
+ if host.replace(".", "").isdigit():
151
+ continue
152
+ hosts.add(host)
153
+ return hosts
154
+
155
+
156
+ def _locate(text: str, path: str, start: int, end: int) -> Location:
157
+ line = text.count("\n", 0, start) + 1
158
+ column = start - (text.rfind("\n", 0, start) + 1) + 1
159
+ snippet = clean_text(" ".join(text[max(0, start - 40) : end + 40].split()), 160)
160
+ return Location(path=path, line=line, column=column, snippet=snippet)
161
+
162
+
163
+ def scan_javascript(text: str, path: str) -> list[tuple[str, Location]]:
164
+ hits: list[tuple[str, Location]] = []
165
+ for rule_id, keyword, pattern in JS_RULES:
166
+ if keyword not in text:
167
+ continue
168
+ count = 0
169
+ for match in pattern.finditer(text):
170
+ if not _boundary(text, match.start()):
171
+ continue
172
+ count += 1
173
+ if count > MAX_MATCHES_PER_RULE:
174
+ break
175
+ hits.append((rule_id, _locate(text, path, match.start(), match.end())))
176
+ if "sandbox" not in text:
177
+ return hits
178
+ sandbox_hits: set[int] = set()
179
+ for preferences in _WEB_PREFERENCES.finditer(text):
180
+ window = text[preferences.end() : preferences.end() + _WEB_PREFERENCES_WINDOW]
181
+ sandbox = next((m for m in _SANDBOX_OFF.finditer(window) if _boundary(window, m.start())), None)
182
+ if sandbox is None:
183
+ continue
184
+ absolute = preferences.end() + sandbox.start()
185
+ if absolute in sandbox_hits or len(sandbox_hits) >= MAX_MATCHES_PER_RULE:
186
+ continue
187
+ sandbox_hits.add(absolute)
188
+ end = preferences.end() + sandbox.end()
189
+ hits.append(("EGK-JS-SANDBOX-OFF", _locate(text, path, absolute, end)))
190
+ return hits