php-radar 0.1.0__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.
php_radar/scope.py ADDED
@@ -0,0 +1,174 @@
1
+ """Scope file parsing, and the guardrails the network scanner passes before it sends a packet.
2
+
3
+ The scope file is the authorization boundary. Each line is an IPv4 address, an IPv4 range in
4
+ CIDR form, or a hostname, with an optional :port. IPv6 literals are refused for now. Parsing is
5
+ offline: nothing here resolves a name or opens a socket.
6
+ """
7
+ import ipaddress
8
+ import string
9
+ from dataclasses import dataclass
10
+ from typing import Iterator, Union
11
+
12
+ # A line with no port is probed on the two usual web ports.
13
+ DEFAULT_PORTS = (80, 443)
14
+
15
+ _LABEL_CHARS = frozenset(string.ascii_letters + string.digits + "-")
16
+
17
+ _Spec = Union[ipaddress.IPv4Network, str]
18
+
19
+
20
+ class ScopeError(Exception):
21
+ """The scope file cannot be read or parsed, or guard refused what it names."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Target:
26
+ host: str
27
+ port: int
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class _Entry:
32
+ line: int
33
+ text: str # the line as written, minus the comment and the padding
34
+ spec: _Spec # a bare address is a /32; a name stays a string
35
+ ports: tuple[int, ...]
36
+
37
+ def hosts(self) -> Iterator[tuple[str, bool]]:
38
+ """Each host the line names, with whether the guard counts it as public."""
39
+ if isinstance(self.spec, str):
40
+ # Telling a name from public space takes a DNS lookup, and a lookup here is a side
41
+ # effect whose answer can change before the probe. So a name counts as public and
42
+ # needs the override. The one exception is localhost, which resolvers pin to loopback.
43
+ yield self.spec, self.spec.lower().rstrip(".") != "localhost"
44
+ return
45
+ # Judge each address on its own. A range's ends say nothing about its middle, and
46
+ # 0.0.0.0/1 starts and ends in private space. The host string that gets probed is
47
+ # built from the same address object that gets classified.
48
+ for addr in self.spec.hosts():
49
+ yield str(addr), addr.is_global
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Scope:
54
+ source: str
55
+ entries: tuple[_Entry, ...]
56
+
57
+ def targets(self) -> Iterator[Target]:
58
+ for _, target, _ in self._walk():
59
+ yield target
60
+
61
+ def _walk(self) -> Iterator[tuple[_Entry, Target, bool]]:
62
+ # Lazy, so guard can stop at the cap instead of expanding a /8. A target listed twice
63
+ # (overlapping ranges, a range and one of its members) comes out once. Probing it twice
64
+ # would report the same box twice, because the asset inventory never merges on an ip.
65
+ seen: set[Target] = set()
66
+ for entry in self.entries:
67
+ for host, public in entry.hosts():
68
+ for port in entry.ports:
69
+ target = Target(host, port)
70
+ if target not in seen:
71
+ seen.add(target)
72
+ yield entry, target, public
73
+
74
+
75
+ def _port(text: str) -> int:
76
+ # ASCII digits only, because int() also takes fullwidth and Arabic-Indic digits. A leading
77
+ # zero is out, which also rules out 0, and the length check keeps a huge string away from int().
78
+ if text.isascii() and text.isdigit() and text[0] != "0" and len(text) <= 5:
79
+ port = int(text)
80
+ if port <= 65535:
81
+ return port
82
+ raise ValueError(f"{text!r} is not a port from 1 to 65535")
83
+
84
+
85
+ def _label_ok(label: str) -> bool:
86
+ return (0 < len(label) <= 63 and label[0] != "-" and label[-1] != "-"
87
+ and _LABEL_CHARS.issuperset(label))
88
+
89
+
90
+ def _hostname(text: str) -> str:
91
+ name = text[:-1] if text.endswith(".") else text # one trailing dot marks a rooted name
92
+ labels = name.split(".")
93
+ # The last label has to start with a letter. The OS resolver reads 2130706433, 0x7f.1 and
94
+ # 127.1 as addresses while ipaddress rejects them, and the last label of every such spelling
95
+ # starts with a digit. Without this rule they would pass as names and skip the public check.
96
+ if not (len(name) <= 253 and all(map(_label_ok, labels)) and labels[-1][0].isalpha()):
97
+ raise ValueError(f"{text!r} is not an IPv4 address, an IPv4 range, or a hostname")
98
+ return text
99
+
100
+
101
+ def _spec(text: str) -> _Spec:
102
+ try:
103
+ # Strict, so 10.0.0.5/24 is an error. Widening it to 10.0.0.0/24 would probe hosts
104
+ # nobody listed.
105
+ return ipaddress.IPv4Network(text)
106
+ except ValueError:
107
+ if "/" in text:
108
+ raise
109
+ return _hostname(text)
110
+
111
+
112
+ def _parse_entry(text: str) -> tuple[_Spec, tuple[int, ...]]:
113
+ if any(ch.isspace() for ch in text):
114
+ raise ValueError("one entry per line, with no spaces inside it")
115
+ if text.count(":") > 1:
116
+ raise ValueError("too many colons; IPv6 is not supported, so use an IPv4 address "
117
+ "or a hostname")
118
+ spec, colon, port = text.partition(":")
119
+ return _spec(spec), ((_port(port),) if colon else DEFAULT_PORTS)
120
+
121
+
122
+ def parse_scope(path: str) -> Scope:
123
+ """Read a scope file. An unreadable file or a single bad line raises ScopeError."""
124
+ try:
125
+ # utf-8-sig drops the byte order mark that Windows editors put at the front.
126
+ with open(path, encoding="utf-8-sig") as f:
127
+ lines = f.read().split("\n")
128
+ except UnicodeDecodeError as exc:
129
+ raise ScopeError(f"cannot read scope file {path}: it must be UTF-8 text") from exc
130
+ except OSError as exc:
131
+ raise ScopeError(f"cannot read scope file {path}: {exc.strerror or exc}") from exc
132
+ entries = []
133
+ for number, raw in enumerate(lines, 1):
134
+ text = raw.split("#", 1)[0].strip()
135
+ if not text:
136
+ continue
137
+ try:
138
+ spec, ports = _parse_entry(text)
139
+ except ValueError as exc:
140
+ raise ScopeError(f"{path} line {number}: {exc}") from exc
141
+ entries.append(_Entry(number, text, spec, ports))
142
+ # An empty scan would read as a clean result, so an empty file is an error.
143
+ if not entries:
144
+ raise ScopeError(f"{path} lists no targets")
145
+ return Scope(path, tuple(entries))
146
+
147
+
148
+ def guard(scope: Scope, allow_public: bool, max_hosts: int) -> list[Target]:
149
+ """The targets a scan may probe, or a ScopeError. It fails closed and sends nothing itself.
150
+
151
+ Public space needs allow_public. So does every hostname except localhost, because a name
152
+ cannot be classified without DNS. max_hosts caps targets, which are host and port pairs, so
153
+ a host on two ports counts twice. The list comes back only when every target has passed.
154
+ """
155
+ # Only the literal True opens the gate. A flag read from the environment or a config file
156
+ # is a string, and the string "false" is truthy.
157
+ open_gate = allow_public is True
158
+ found: list[Target] = []
159
+ for entry, target, public in scope._walk():
160
+ if public and not open_gate:
161
+ where = f"{scope.source} line {entry.line}: {entry.text!r}"
162
+ if isinstance(entry.spec, str):
163
+ raise ScopeError(f"{where} is a hostname. A name cannot be checked against "
164
+ "private address space without a DNS lookup, so it needs the "
165
+ "explicit public override")
166
+ raise ScopeError(f"{where} reaches {target.host}, a public address, which needs "
167
+ "the explicit public override")
168
+ found.append(target)
169
+ if len(found) > max_hosts:
170
+ raise ScopeError(f"scope expands past the cap of {max_hosts} targets (a host counts "
171
+ "once per port). Narrow the ranges or raise the cap")
172
+ if not found:
173
+ raise ScopeError("scope lists no targets")
174
+ return found
File without changes
@@ -0,0 +1,64 @@
1
+ """Cloud inventory sources, one per provider, behind one interface.
2
+
3
+ A cloud source lists what an account runs and returns CLOUD Evidence for the resources that run or
4
+ hold PHP. get_cloud_source(name) builds one. The provider's SDK is an optional install, imported
5
+ only when its source is asked for, so the rest of php-radar runs without it.
6
+ """
7
+ from typing import Any, Callable, Optional, Protocol, runtime_checkable
8
+
9
+ from php_radar.model import Evidence
10
+ from php_radar.sources.cloud.aws import AwsSource
11
+
12
+
13
+ @runtime_checkable
14
+ class CloudSource(Protocol):
15
+ """What a provider adapter offers. The runtime check lets a caller test for it."""
16
+
17
+ def inventory(self) -> list[Evidence]:
18
+ ...
19
+
20
+
21
+ # The default of _boto3. A sentinel, because None has to mean a boto3 that is not installed.
22
+ _IMPORT = object()
23
+
24
+
25
+ def _import_boto3() -> Optional[Any]:
26
+ try:
27
+ import boto3
28
+ except ImportError:
29
+ return None
30
+ return boto3
31
+
32
+
33
+ def _aws(sdk: Any) -> CloudSource:
34
+ module = _import_boto3() if sdk is _IMPORT else sdk
35
+ if module is None:
36
+ raise RuntimeError("the aws source needs boto3, and it is not installed. "
37
+ "Install the extra with: pip install php-radar[aws]")
38
+ try:
39
+ # No argument, so credentials come from boto3's default chain and from nowhere else.
40
+ session = module.Session()
41
+ except Exception as exc: # a profile that is not there, or a config file boto3 cannot read
42
+ # The message stays out. It can quote the profile name or a path.
43
+ raise RuntimeError(f"boto3 could not start a session ({type(exc).__name__})") from None
44
+ return AwsSource(session)
45
+
46
+
47
+ _PROVIDERS: dict[str, Callable[[Any], CloudSource]] = {"aws": _aws}
48
+
49
+
50
+ def get_cloud_source(name: str, _boto3: Any = _IMPORT) -> CloudSource:
51
+ """The source for the provider called name.
52
+
53
+ The only provider is aws. ValueError means name is no provider. RuntimeError means the source
54
+ cannot start: boto3 is not installed, and the message gives the pip command that installs it,
55
+ or boto3 could not make a session.
56
+
57
+ _boto3 is for tests and stands in for the boto3 module. Anything with a Session() that returns
58
+ a session will do. None means boto3 is not installed. By default boto3 is imported here, on
59
+ the call, and nowhere else.
60
+ """
61
+ if name not in _PROVIDERS:
62
+ choices = ", ".join(_PROVIDERS)
63
+ raise ValueError(f"unknown cloud provider {name!r}, the choices are: {choices}")
64
+ return _PROVIDERS[name](_boto3)
@@ -0,0 +1,435 @@
1
+ """Reads an AWS account, read-only, and reports where PHP runs.
2
+
3
+ AwsSource(session).inventory() looks at the Elastic Beanstalk environments, the Lambda functions,
4
+ and the ECR images of a region and returns one CLOUD Evidence for each that runs or holds PHP. It
5
+ makes four calls and every one is a read: DescribeEnvironments, ListFunctions, DescribeRepositories,
6
+ and ListImages. A policy that allows those four is all the access it uses.
7
+
8
+ The session is the way in. Its default credential chain supplies credentials when a call goes
9
+ out, so this module takes no key as an argument and never asks the session for one. From each
10
+ answer it copies the few fields it names, because a Lambda function record carries the values of
11
+ its environment variables. A failed call is recorded by the class of its error and the service's
12
+ error code. The message stays out. It can name the caller's ARN or repeat part of the request.
13
+
14
+ No service reports a PHP build, so PHP is found by name, and each find carries what the name gave.
15
+ A Beanstalk platform called "PHP 8.3 running on 64bit Amazon Linux 2023/4.3.0" gives the branch,
16
+ 8.3. The 4.3.0 is the platform's own version and is never read as PHP's. A Lambda layer called
17
+ php-83 marks a PHP runtime and gives no version. An ECR tag gives a version when it names one. In
18
+ a repository called php, the official image's tags do (8.3.12-fpm). Anywhere else the tag has to
19
+ say php (php8.3, php-8.3.12).
20
+
21
+ Only a full 8.3.12 hands the grader a build to compare. A branch, or the mark of PHP alone, grades
22
+ INDETERMINATE. The PHP2Shell fix landed in a patch release of each branch, so a branch cannot say
23
+ whether it is in, and a packaged PHP can carry the fix under an older number. For the same reason
24
+ a full number counts unless what follows it reads as a distro revision or a pre-release, by the
25
+ grader's own test. 8.3.12-1ubuntu2.28 could be a Ubuntu build, and a tag like it gives the branch
26
+ alone.
27
+
28
+ Every tag of an ECR repository carries the repository's ARN, so the tags of one repository fold
29
+ into one asset whose verdict is the worst of them, and each tag keeps its own evidence line. A
30
+ Beanstalk find carries the environment's DNS name in hostname (its CNAME, or the load balancer's
31
+ name when there is none), so a host read or a network probe of that name merges with it. A single
32
+ instance has an address and no load balancer. The address goes in ip, which merges nothing.
33
+
34
+ Nothing is skipped quietly. A failed call, a listing that runs past its page limit, and an entry
35
+ that cannot be read each leave a row. The row has the reason in note and kind skipped, as the repo
36
+ scan marks its own, and with no PHP in it the row grades INDETERMINATE at LOW. A failure inside a
37
+ repository that already showed PHP leaves a row that says PHP is there, so a repository read only
38
+ in part is never cleared by the part that was read. Three repositories in a row that cannot be
39
+ listed end the ECR read. A credential that expired midway would otherwise cost a slow failure for
40
+ every repository left.
41
+
42
+ Limits. A run reads one region, the session's own unless regions says otherwise. PHP that does not
43
+ say so by name goes unseen: a Docker or ECS Beanstalk platform, a Lambda container image, a zip
44
+ with PHP inside it, an image whose tags never say php. An ECR tag is a label somebody typed. The
45
+ image behind it may never run, and a tag that can be pushed again may point at another build
46
+ tomorrow. boto3's own timeouts and retries apply.
47
+ """
48
+ import string
49
+ from collections import Counter
50
+ from typing import Any, Callable, Iterable, Iterator, NamedTuple, Optional, Union
51
+
52
+ from php_radar.model import Evidence, Sapi, Source
53
+ from php_radar.versions import PhpVersion, parse_branch
54
+ # The grader decides what a distro revision and a pre-release are. The network source tells a name
55
+ # from an address, and the repo scan reads a SAPI out of an image tag. Using theirs keeps a cloud
56
+ # read in step with the other sources.
57
+ from php_radar.knowledge import _is_prerelease, _is_revision
58
+ from php_radar.sources.network import _identity
59
+ from php_radar.sources.repo import _sapi
60
+
61
+ # Pages read from one listing. A listing past this is cut and says so, which also ends a service
62
+ # that keeps handing back a token.
63
+ _MAX_PAGES = 200
64
+
65
+ # The page size asked of ECR, its largest. Beanstalk and Lambda choose their own.
66
+ _PAGE = 1000
67
+
68
+ # Repositories in a row whose images cannot be listed before the ECR read stops.
69
+ _MAX_MISSES = 3
70
+
71
+ # The longest value of each kind taken from an answer. A DNS name is 253 characters at most, a tag
72
+ # 128, and an ARN 2048. An AWS error code is far shorter than 64.
73
+ _MAX_DNS = 253
74
+ _MAX_TAG = 128
75
+ _MAX_ARN = 2048
76
+ _MAX_CODE = 64
77
+ _MAX_TEXT = 256
78
+
79
+ _NAME = frozenset(string.ascii_letters + string.digits + "-._") # DNS names, layers, tags, codes
80
+ _PATH = _NAME | frozenset("/") # repository names and the URIs docker pulls from
81
+
82
+ # PHP 5 is the oldest major worth reading and 9 the newest accepted, so an app version such as
83
+ # 2.1.0 in a repository called php is not taken for PHP.
84
+ _MAJORS = range(5, 10)
85
+
86
+ _BRANCH = "{} names only the PHP branch, so the build is not read"
87
+
88
+ # What an entry of each list lacks when it cannot be read. The row that counts the entries says so,
89
+ # because a dropped environment is a puzzle otherwise. A non-ASCII application name is one cause.
90
+ _UNREAD = {"environments": "no usable ARN", "functions": "no usable ARN",
91
+ "repositories": "no usable ARN or name", "images": "no usable tag"}
92
+
93
+ # A person meets these two first, and the class name alone says too little.
94
+ _KNOWN = {"NoCredentialsError": "no AWS credentials were found",
95
+ "NoRegionError": "no AWS region is configured"}
96
+
97
+
98
+ class _Stop(Exception):
99
+ """A read that cannot go on. The message is the reason to record."""
100
+
101
+
102
+ class _Repo(NamedTuple):
103
+ arn: str
104
+ name: str
105
+ uri: str # what docker pulls from, or the name when the answer gave no usable URI
106
+ official: bool # php is the last part of the name, as in a mirror of the official image
107
+
108
+
109
+ def _fits(value: object, chars: frozenset, limit: int) -> bool:
110
+ return isinstance(value, str) and 0 < len(value) <= limit and chars.issuperset(value)
111
+
112
+
113
+ def _arn(value: object, spaces: bool = False) -> Optional[str]:
114
+ # Printable ASCII, because the ARN becomes the identity of an asset and goes into a report
115
+ # line. A space is allowed only where AWS allows one. A Beanstalk environment ARN holds the
116
+ # application name, as in environment/My First Elastic Beanstalk Application/Test.
117
+ ok = (isinstance(value, str) and value.startswith("arn:") and len(value) <= _MAX_ARN
118
+ and value.isascii() and value.isprintable() and (spaces or " " not in value))
119
+ return value if ok else None
120
+
121
+
122
+ def _dns(value: object) -> Optional[str]:
123
+ return value if _fits(value, _NAME, _MAX_DNS) and value[0] not in ".-" else None
124
+
125
+
126
+ def _printable(value: str) -> bool:
127
+ return 0 < len(value) <= _MAX_TEXT and value.isascii() and value.isprintable()
128
+
129
+
130
+ def _why(exc: Exception) -> str:
131
+ """What to record about a failed call: the error's class and, for a service error, its code.
132
+
133
+ The message is left out. It can name the caller's ARN or account, or repeat part of the request.
134
+ """
135
+ name = type(exc).__name__
136
+ if name in _KNOWN:
137
+ return f"{_KNOWN[name]} ({name})"
138
+ response = getattr(exc, "response", None)
139
+ error = response.get("Error") if isinstance(response, dict) else None
140
+ code = error.get("Code") if isinstance(error, dict) else None
141
+ return f"{name} ({code})" if _fits(code, _NAME, _MAX_CODE) and code != name else name
142
+
143
+
144
+ def _listing(call: Callable[..., Any], items: str, into: str, out_of: str,
145
+ **fixed: Any) -> Iterator[object]:
146
+ """Each item on every page that call returns.
147
+
148
+ The continuation comes back in the answer field out_of and goes into the next call as the
149
+ parameter into. A call that fails, or an answer that is not the shape the service documents,
150
+ ends the listing with _Stop, once the items already read have been handed over.
151
+ """
152
+ params = dict(fixed)
153
+ for _ in range(_MAX_PAGES):
154
+ try:
155
+ page = call(**params)
156
+ except Exception as exc: # botocore is optional here, so its error classes cannot be named
157
+ raise _Stop(_why(exc)) from None
158
+ if not isinstance(page, dict):
159
+ raise _Stop("the answer is not understood")
160
+ found = page.get(items)
161
+ if found is None:
162
+ found = [] # a service may leave out a list that is empty
163
+ if not isinstance(found, list):
164
+ raise _Stop("the answer is not understood")
165
+ yield from found
166
+ after = page.get(out_of)
167
+ if not after:
168
+ return
169
+ if not isinstance(after, str):
170
+ raise _Stop("the answer is not understood")
171
+ params[into] = after
172
+ raise _Stop(f"the list runs past {_MAX_PAGES} pages")
173
+
174
+
175
+ def _where(service: str, region: Optional[str]) -> str:
176
+ return f"aws {service} {region}" if region else f"aws {service}"
177
+
178
+
179
+ def _gap(service: str, region: Optional[str], why: str) -> Evidence:
180
+ # The repo scan marks a row about something it passed over with kind skipped. The same mark
181
+ # lets a caller count the gaps of every source one way.
182
+ return Evidence(Source.CLOUD, _where(service, region), None, Sapi.UNKNOWN,
183
+ {"service": service, "kind": "skipped"}, "not inventoried: " + why)
184
+
185
+
186
+ def _unread(service: str, region: Optional[str], count: int, what: str) -> Evidence:
187
+ entries = "entry" if count == 1 else "entries"
188
+ return _gap(service, region,
189
+ f"{count} {entries} in the list of {what} could not be read ({_UNREAD[what]})")
190
+
191
+
192
+ def _number(word: str) -> Optional[str]:
193
+ """X.Y.Z or X.Y when the word is exactly that and could be PHP, else None."""
194
+ release = PhpVersion.parse(word)
195
+ if release is not None:
196
+ parts = None if release.suffix else (release.major, release.minor, release.micro)
197
+ else:
198
+ parts = parse_branch(word)
199
+ return ".".join(map(str, parts)) if parts and parts[0] in _MAJORS else None
200
+
201
+
202
+ # --- Elastic Beanstalk ---
203
+
204
+ def _php_platform(env: dict) -> Optional[tuple[str, str]]:
205
+ """(platform name, PHP number) when the environment runs a platform that names PHP."""
206
+ arn, stack = env.get("PlatformArn"), env.get("SolutionStackName")
207
+ names = []
208
+ if isinstance(arn, str):
209
+ # The name follows the first slash: ...::platform/PHP 8.3 running on 64bit Amazon Linux.
210
+ names.append(arn.partition("/")[2])
211
+ if isinstance(stack, str):
212
+ names.append(stack)
213
+ for name in names:
214
+ words = name.split()
215
+ for word, after in zip(words, words[1:]):
216
+ number = _number(after) if word.lower() == "php" else None
217
+ if number:
218
+ return name, number
219
+ return None
220
+
221
+
222
+ def _environment(arn: str, env: dict) -> Optional[Evidence]:
223
+ found = _php_platform(env)
224
+ if found is None:
225
+ return None
226
+ name, number = found
227
+ signals = {"resource": arn, "service": "elasticbeanstalk", "php_present": "true"}
228
+ named = "the platform"
229
+ if _printable(name):
230
+ signals["platform"] = name
231
+ # The platform version ends the name and fixes which PHP release the environment runs,
232
+ # so the note gives a reader the whole name.
233
+ named = "platform " + name
234
+ host = _dns(env.get("CNAME")) or _dns(env.get("EndpointURL"))
235
+ if host:
236
+ signals.update(_identity(host))
237
+ note = "" if number.count(".") == 2 else _BRANCH.format(named)
238
+ return Evidence(Source.CLOUD, arn, number, Sapi.UNKNOWN, signals, note)
239
+
240
+
241
+ def _beanstalk(client: Any, odd: Counter) -> Iterator[Evidence]:
242
+ for env in _listing(client.describe_environments, "Environments", "NextToken", "NextToken"):
243
+ arn = _arn(env.get("EnvironmentArn"), spaces=True) if isinstance(env, dict) else None
244
+ if arn is None:
245
+ odd["environments"] += 1
246
+ elif env.get("Status") != "Terminated":
247
+ ev = _environment(arn, env)
248
+ if ev is not None:
249
+ yield ev
250
+
251
+
252
+ # --- Lambda ---
253
+
254
+ def _layer_name(arn: object) -> Optional[str]:
255
+ # arn:aws:lambda:us-east-1:534081306603:layer:php-83:20, with or without the version.
256
+ parts = arn.split(":") if isinstance(arn, str) else []
257
+ ok = len(parts) >= 7 and parts[2] == "lambda" and parts[5] == "layer"
258
+ return parts[6] if ok and _fits(parts[6], _NAME, _MAX_TEXT) else None
259
+
260
+
261
+ def _says_php(name: str) -> bool:
262
+ # A word of the name is php, or php and a number: php-83, php83, my-php-runtime. Not phpunit.
263
+ words = name.lower().replace("_", "-").split("-")
264
+ return any(w == "php" or (w.startswith("php") and w[3:].isdigit()) for w in words)
265
+
266
+
267
+ def _php_function(fn: dict) -> str:
268
+ """Why the function runs PHP, in words for a note, or "" when nothing says it does."""
269
+ runtime = fn.get("Runtime")
270
+ # AWS offers no PHP runtime today. A custom one, with PHP in a layer, is how PHP gets here.
271
+ if _fits(runtime, _NAME, _MAX_TEXT) and runtime.lower().startswith("php"):
272
+ return f"runtime {runtime} names PHP, and the build is not read"
273
+ layers = fn.get("Layers")
274
+ for layer in layers if isinstance(layers, list) else []:
275
+ name = _layer_name(layer.get("Arn") if isinstance(layer, dict) else None)
276
+ if name is not None and _says_php(name):
277
+ return f"layer {name} marks a PHP runtime, and the build is not read"
278
+ return ""
279
+
280
+
281
+ def _lambda(client: Any, odd: Counter) -> Iterator[Evidence]:
282
+ for fn in _listing(client.list_functions, "Functions", "Marker", "NextMarker"):
283
+ arn = _arn(fn.get("FunctionArn")) if isinstance(fn, dict) else None
284
+ if arn is None:
285
+ odd["functions"] += 1
286
+ continue
287
+ why = _php_function(fn)
288
+ if why:
289
+ signals = {"resource": arn, "service": "lambda", "php_present": "true"}
290
+ yield Evidence(Source.CLOUD, arn, None, Sapi.UNKNOWN, signals, why)
291
+
292
+
293
+ # --- ECR ---
294
+
295
+ def _number_word(words: list[str], official: bool) -> Optional[tuple[int, str]]:
296
+ """(index, text) of the word that should hold the PHP number in a tag, or None.
297
+
298
+ Two shapes name PHP. The word php can be followed by the number (php-8.3.12), or a word can
299
+ start with php and a digit and hold the number itself (php8.3.12). In a repository called php,
300
+ a tag names PHP by starting with the number, as the official image's tags do.
301
+ """
302
+ for at, word in enumerate(words):
303
+ if word == "php" and at + 1 < len(words):
304
+ return at + 1, words[at + 1]
305
+ if word.startswith("php") and word[3:4].isdigit():
306
+ return at, word[3:]
307
+ return (0, words[0]) if official else None
308
+
309
+
310
+ def _tag_version(tag: str, official: bool) -> Optional[str]:
311
+ """The PHP version an image tag names, X.Y.Z or X.Y, or None when it names none.
312
+
313
+ A full number stands unless what follows it reads as a distro revision or a pre-release, by
314
+ the grader's own test. Then the tag gives the branch alone. The number could be the upstream
315
+ one of a build with fixes of its own.
316
+ """
317
+ words = tag.lower().replace("_", "-").split("-")
318
+ found = _number_word(words, official)
319
+ number = _number(found[1]) if found else None
320
+ if found and number is not None and number.count(".") == 2:
321
+ # The number parsed on its own, so it parses with a suffix after it too.
322
+ release = PhpVersion.parse("-".join([found[1]] + words[found[0] + 1:]))
323
+ if _is_revision(release) or _is_prerelease(release):
324
+ number = number.rpartition(".")[0]
325
+ return number
326
+
327
+
328
+ def _repository(item: object) -> Optional[_Repo]:
329
+ if not isinstance(item, dict):
330
+ return None
331
+ arn, name = _arn(item.get("repositoryArn")), item.get("repositoryName")
332
+ if arn is None or not _fits(name, _PATH, _MAX_TEXT):
333
+ return None
334
+ uri = item.get("repositoryUri")
335
+ return _Repo(arn, name, uri if _fits(uri, _PATH, _MAX_ARN) else name,
336
+ name.rpartition("/")[2] == "php")
337
+
338
+
339
+ def _image(repo: _Repo, tag: str) -> Optional[Evidence]:
340
+ number = _tag_version(tag, repo.official)
341
+ if number is None:
342
+ return None
343
+ signals = {"resource": repo.arn, "service": "ecr", "php_present": "true"}
344
+ full = number.count(".") == 2
345
+ note = "version read from the image tag" if full else _BRANCH.format("the tag")
346
+ return Evidence(Source.CLOUD, f"{repo.uri}:{tag}", number, _sapi(tag.lower()), signals, note)
347
+
348
+
349
+ def _repo_gap(repo: _Repo, why: str, saw_php: bool) -> Evidence:
350
+ signals = {"resource": repo.arn, "service": "ecr", "kind": "skipped"}
351
+ if saw_php:
352
+ # The part not read may hold an older build, so the part that was read cannot clear it.
353
+ signals["php_present"] = "true"
354
+ return Evidence(Source.CLOUD, repo.uri, None, Sapi.UNKNOWN, signals,
355
+ "not fully inventoried: " + why)
356
+
357
+
358
+ def _ecr(client: Any, odd: Counter) -> Iterator[Evidence]:
359
+ misses = 0
360
+ for item in _listing(client.describe_repositories, "repositories", "nextToken", "nextToken",
361
+ maxResults=_PAGE):
362
+ repo = _repository(item)
363
+ if repo is None:
364
+ odd["repositories"] += 1
365
+ continue
366
+ saw_php = False
367
+ try:
368
+ for image in _listing(client.list_images, "imageIds", "nextToken", "nextToken",
369
+ repositoryName=repo.name, filter={"tagStatus": "TAGGED"},
370
+ maxResults=_PAGE):
371
+ tag = image.get("imageTag") if isinstance(image, dict) else None
372
+ if not _fits(tag, _NAME, _MAX_TAG):
373
+ odd["images"] += 1
374
+ continue
375
+ ev = _image(repo, tag)
376
+ if ev is not None:
377
+ saw_php = True
378
+ yield ev
379
+ except _Stop as stop:
380
+ misses += 1
381
+ yield _repo_gap(repo, str(stop), saw_php)
382
+ if misses == _MAX_MISSES:
383
+ raise _Stop(f"{_MAX_MISSES} repositories in a row could not be read") from None
384
+ else:
385
+ misses = 0
386
+
387
+
388
+ _READERS = (("elasticbeanstalk", _beanstalk), ("lambda", _lambda), ("ecr", _ecr))
389
+
390
+
391
+ class AwsSource:
392
+ """The PHP in an AWS account, read through a boto3 session.
393
+
394
+ session is a boto3 Session, or anything with its region_name and client(). regions is the
395
+ region to read as a name, or several as a list, a tuple, or any iterator of names. It is the
396
+ session's own by default and when it is empty. The regions read are kept in regions, so a
397
+ caller can say what was covered. inventory() reads afresh each time it is called.
398
+ """
399
+
400
+ def __init__(self, session: Any,
401
+ regions: Union[str, Iterable[Optional[str]], None] = None) -> None:
402
+ self._session = session
403
+ if isinstance(regions, str):
404
+ # One name is one region. Read as a sequence it would be one region for each letter.
405
+ # An empty name is no region, so it leaves the session's own.
406
+ regions = [regions] if regions else None
407
+ self.regions = tuple(regions or ()) or (session.region_name,)
408
+
409
+ def inventory(self) -> list[Evidence]:
410
+ """One Evidence for each Beanstalk environment, Lambda function, and ECR image tag that
411
+ names PHP. Whatever could not be read leaves a row in the same list."""
412
+ found: list[Evidence] = []
413
+ for region in self.regions:
414
+ for service, read in _READERS:
415
+ found += self._read(service, region, read)
416
+ return found
417
+
418
+ def _client(self, service: str, region: Optional[str]) -> Any:
419
+ try:
420
+ return self._session.client(service, region_name=region)
421
+ except Exception as exc: # a missing profile or region, and anything else boto3 raises
422
+ raise _Stop(_why(exc)) from None
423
+
424
+ def _read(self, service: str, region: Optional[str],
425
+ read: Callable[[Any, Counter], Iterator[Evidence]]) -> list[Evidence]:
426
+ found: list[Evidence] = []
427
+ odd: Counter = Counter() # entries that could not be read, by the list they came from
428
+ try:
429
+ for ev in read(self._client(service, region), odd):
430
+ found.append(ev)
431
+ except _Stop as stop:
432
+ found.append(_gap(service, region, str(stop)))
433
+ # After the stop as well, so a count is never lost to a failure that comes later.
434
+ found.extend(_unread(service, region, count, what) for what, count in odd.items())
435
+ return found