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.
- intake_gatekeeper/__init__.py +3 -0
- intake_gatekeeper/__main__.py +3 -0
- intake_gatekeeper/asar.py +117 -0
- intake_gatekeeper/cli.py +149 -0
- intake_gatekeeper/diff.py +154 -0
- intake_gatekeeper/electron.py +190 -0
- intake_gatekeeper/engine.py +93 -0
- intake_gatekeeper/golang.py +143 -0
- intake_gatekeeper/model.py +207 -0
- intake_gatekeeper/osv.py +302 -0
- intake_gatekeeper/output.py +618 -0
- intake_gatekeeper/policy.py +192 -0
- intake_gatekeeper/py.typed +0 -0
- intake_gatekeeper/rules.py +156 -0
- intake_gatekeeper/scanner.py +477 -0
- software_intake_gatekeeper-0.2.1.dist-info/METADATA +407 -0
- software_intake_gatekeeper-0.2.1.dist-info/RECORD +20 -0
- software_intake_gatekeeper-0.2.1.dist-info/WHEEL +4 -0
- software_intake_gatekeeper-0.2.1.dist-info/entry_points.txt +2 -0
- software_intake_gatekeeper-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
intake_gatekeeper/cli.py
ADDED
|
@@ -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
|