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/__init__.py +0 -0
- php_radar/__main__.py +6 -0
- php_radar/cli.py +375 -0
- php_radar/data/php2shell.json +57 -0
- php_radar/http.py +276 -0
- php_radar/knowledge.py +339 -0
- php_radar/model.py +143 -0
- php_radar/report.py +430 -0
- php_radar/scope.py +174 -0
- php_radar/sources/__init__.py +0 -0
- php_radar/sources/cloud/__init__.py +64 -0
- php_radar/sources/cloud/aws.py +435 -0
- php_radar/sources/github.py +399 -0
- php_radar/sources/host.py +571 -0
- php_radar/sources/network.py +375 -0
- php_radar/sources/repo.py +579 -0
- php_radar/versions.py +51 -0
- php_radar-0.1.0.dist-info/METADATA +97 -0
- php_radar-0.1.0.dist-info/RECORD +24 -0
- php_radar-0.1.0.dist-info/WHEEL +5 -0
- php_radar-0.1.0.dist-info/entry_points.txt +2 -0
- php_radar-0.1.0.dist-info/licenses/LICENSE +202 -0
- php_radar-0.1.0.dist-info/licenses/NOTICE +4 -0
- php_radar-0.1.0.dist-info/top_level.txt +1 -0
php_radar/model.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# In every enum here the value is the member name, so .name and .value serialize alike.
|
|
7
|
+
class Verdict(Enum):
|
|
8
|
+
EXPOSED = "EXPOSED"
|
|
9
|
+
INDETERMINATE = "INDETERMINATE"
|
|
10
|
+
LIKELY_PATCHED = "LIKELY_PATCHED"
|
|
11
|
+
NOT_PHP = "NOT_PHP"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Confidence(Enum):
|
|
15
|
+
HIGH = "HIGH"
|
|
16
|
+
MEDIUM = "MEDIUM"
|
|
17
|
+
LOW = "LOW"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Sapi(Enum):
|
|
21
|
+
MOD_PHP = "MOD_PHP"
|
|
22
|
+
FPM = "FPM"
|
|
23
|
+
CGI = "CGI"
|
|
24
|
+
CLI = "CLI"
|
|
25
|
+
UNKNOWN = "UNKNOWN"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Source(Enum):
|
|
29
|
+
REPO = "REPO"
|
|
30
|
+
CLOUD = "CLOUD"
|
|
31
|
+
NETWORK = "NETWORK"
|
|
32
|
+
HOST = "HOST"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Evidence:
|
|
37
|
+
source: Source
|
|
38
|
+
location: str
|
|
39
|
+
detected_version: Optional[str]
|
|
40
|
+
sapi: Sapi
|
|
41
|
+
signals: dict[str, str]
|
|
42
|
+
note: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Asset:
|
|
47
|
+
key: str
|
|
48
|
+
kind: str
|
|
49
|
+
evidence: list[Evidence]
|
|
50
|
+
|
|
51
|
+
def add(self, ev: Evidence) -> None:
|
|
52
|
+
self.evidence.append(ev)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Higher wins when sources disagree, ranked by authority: a host's own read, then the
|
|
56
|
+
# cloud's record of it, then an outside probe, and a version pinned in a repo last.
|
|
57
|
+
SOURCE_PRECEDENCE: dict[Source, int] = {
|
|
58
|
+
Source.HOST: 3,
|
|
59
|
+
Source.CLOUD: 2,
|
|
60
|
+
Source.NETWORK: 1,
|
|
61
|
+
Source.REPO: 0,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_hostname(name: str) -> str:
|
|
66
|
+
"""The form hostnames are compared in: trimmed, lowercased, one trailing dot dropped."""
|
|
67
|
+
# A short name and its FQDN stay distinct: "web1" and "web1.corp.example" never merge.
|
|
68
|
+
name = name.strip().lower()
|
|
69
|
+
return name[:-1] if name.endswith(".") else name
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _named(ev: Evidence) -> dict[str, str]:
|
|
73
|
+
# Identity signals that name something. Only the hostname is rewritten. The rest stay
|
|
74
|
+
# exactly as given, because ARNs are case-sensitive.
|
|
75
|
+
named: dict[str, str] = {}
|
|
76
|
+
for key in ("resource", "hostname", "ip", "repo"):
|
|
77
|
+
value = ev.signals.get(key, "")
|
|
78
|
+
if key == "hostname":
|
|
79
|
+
value = normalize_hostname(value)
|
|
80
|
+
if value.strip():
|
|
81
|
+
named[key] = value
|
|
82
|
+
return named
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _strong(ev: Evidence) -> set[tuple[str, str]]:
|
|
86
|
+
# What can merge evidence. Repo evidence merges on the repo alone, so a version pinned in
|
|
87
|
+
# a Dockerfile never folds into a live host. An ip merges nothing: private and shared
|
|
88
|
+
# ranges repeat across accounts, and a false merge could hide a vulnerable host.
|
|
89
|
+
named = _named(ev)
|
|
90
|
+
keys = ("repo",) if ev.source is Source.REPO else ("resource", "hostname")
|
|
91
|
+
return {(key, named[key]) for key in keys if key in named}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def asset_identity(ev: Evidence) -> tuple[str, str]:
|
|
95
|
+
"""(kind, display key). The key names an asset and merges nothing."""
|
|
96
|
+
named = _named(ev)
|
|
97
|
+
if ev.source is Source.REPO:
|
|
98
|
+
return "repo", named.get("repo", ev.location)
|
|
99
|
+
for key in ("resource", "hostname", "ip"):
|
|
100
|
+
if key in named:
|
|
101
|
+
return "host", named[key]
|
|
102
|
+
return "host", ev.location
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AssetInventory:
|
|
106
|
+
"""Evidence joins an asset when it shares a strong identifier with evidence already in one.
|
|
107
|
+
|
|
108
|
+
assets() builds its list fresh on each call, in first-seen order, with each asset named
|
|
109
|
+
by its earliest evidence.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self._evidence: list[Evidence] = []
|
|
114
|
+
# Union-find over evidence positions, so one record can join assets made earlier.
|
|
115
|
+
self._parent: list[int] = []
|
|
116
|
+
self._first: dict[tuple[str, str], int] = {} # identifier -> first evidence carrying it
|
|
117
|
+
|
|
118
|
+
def add(self, ev: Evidence) -> None:
|
|
119
|
+
i = len(self._evidence)
|
|
120
|
+
self._evidence.append(ev)
|
|
121
|
+
self._parent.append(i)
|
|
122
|
+
for ident in _strong(ev):
|
|
123
|
+
self._join(i, self._first.setdefault(ident, i))
|
|
124
|
+
|
|
125
|
+
def assets(self) -> list[Asset]:
|
|
126
|
+
found: dict[int, Asset] = {}
|
|
127
|
+
# Walking in arrival order makes the first evidence seen for a group its earliest.
|
|
128
|
+
for i, ev in enumerate(self._evidence):
|
|
129
|
+
root = self._root(i)
|
|
130
|
+
if root not in found:
|
|
131
|
+
kind, key = asset_identity(ev)
|
|
132
|
+
found[root] = Asset(key, kind, [])
|
|
133
|
+
found[root].add(ev)
|
|
134
|
+
return list(found.values())
|
|
135
|
+
|
|
136
|
+
def _root(self, i: int) -> int:
|
|
137
|
+
while self._parent[i] != i:
|
|
138
|
+
self._parent[i] = self._parent[self._parent[i]]
|
|
139
|
+
i = self._parent[i]
|
|
140
|
+
return i
|
|
141
|
+
|
|
142
|
+
def _join(self, a: int, b: int) -> None:
|
|
143
|
+
self._parent[self._root(a)] = self._root(b)
|
php_radar/report.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Renders graded assets as console text, JSON, or SARIF 2.1.0.
|
|
2
|
+
|
|
3
|
+
The three renderers all build from _row, and every string a row takes from the evidence has been
|
|
4
|
+
through redact. No renderer reads a raw value, so none can print one. Grading reads the caller's
|
|
5
|
+
evidence as given. Only what is printed gets filtered.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from php_radar.knowledge import Matrix, grade, grade_evidence
|
|
13
|
+
from php_radar.model import SOURCE_PRECEDENCE, Asset, Confidence, Evidence, Sapi, Source, Verdict
|
|
14
|
+
|
|
15
|
+
_MASK = "[REDACTED]"
|
|
16
|
+
|
|
17
|
+
# Words that mark a credential. A person types a password, so its value can hold spaces, and an
|
|
18
|
+
# Authorization value is a scheme and its parameters. The other words name machine-made secrets.
|
|
19
|
+
_LINE_WORDS = ("password", "passwd", "passphrase", "pwd", "authorization")
|
|
20
|
+
_TOKEN_WORDS = ("secret", "token", "key", "credential", "auth", "bearer")
|
|
21
|
+
# pass and pw mark a credential only as a word of their own (see _WHOLE), so passthru and bypass
|
|
22
|
+
# are left alone.
|
|
23
|
+
_WHOLE_WORDS = ("pass", "pw")
|
|
24
|
+
|
|
25
|
+
# A signal whose name holds one of these never reaches output, whatever its value is. Auth also
|
|
26
|
+
# covers oauth.
|
|
27
|
+
_SENSITIVE = _LINE_WORDS + _TOKEN_WORDS
|
|
28
|
+
|
|
29
|
+
# A banner can come from a hostile server, so every pattern below is length-bounded or anchored
|
|
30
|
+
# to stay linear on text built to make it backtrack.
|
|
31
|
+
|
|
32
|
+
# A private key block (RSA, EC, OpenSSH, PGP, and the rest), through its END line or to the end of
|
|
33
|
+
# the text when it never closes.
|
|
34
|
+
_PEM = re.compile(
|
|
35
|
+
r"-----BEGIN[ A-Z0-9]{0,40}PRIVATE KEY(?: BLOCK)?-----.*?"
|
|
36
|
+
r"(?:-----END[ A-Z0-9]{0,40}PRIVATE KEY(?: BLOCK)?-----|\Z)", re.S)
|
|
37
|
+
|
|
38
|
+
# user:password@ inside a URL. Greedy to the last @ before the path, so an unescaped @ in the
|
|
39
|
+
# password leaves no tail behind.
|
|
40
|
+
_USERINFO = re.compile(r"(?<=://)[^/?#\s]*@")
|
|
41
|
+
|
|
42
|
+
# The same when the password holds a / ? or #, which cuts the authority short. This one runs to the
|
|
43
|
+
# last @ before whitespace, so it comes after the exact rule and only sees what that missed. A URL
|
|
44
|
+
# with a port and a later @ in its path or query (https://h:8443/p?e=a@b) is masked too, the price
|
|
45
|
+
# of not leaking a password that holds a slash. An image digest (registry:5000/php@sha256:...) is
|
|
46
|
+
# no password.
|
|
47
|
+
_USERINFO_LOOSE = re.compile(r"(?<=://)[^\s/?#@:]{0,64}:\S{0,256}@(?!sha\d+:)", re.I)
|
|
48
|
+
|
|
49
|
+
# user:password@host with no scheme, as an error message prints it. It has to start a word, so a
|
|
50
|
+
# letter, a digit, _, or one of . + % ~ / : @ - right before it rules the match out. Any other
|
|
51
|
+
# character allows it, a backslash included, because repr and JSON write a newline as \n. It also
|
|
52
|
+
# skips what only looks like userinfo: mailto: and email: addresses, ARNs, purls, and image
|
|
53
|
+
# digests (php:8.3-fpm@sha256:...). The password cannot hold a / or \, so a path or a Windows
|
|
54
|
+
# profile directory named after an email address is safe.
|
|
55
|
+
_USERINFO_BARE = re.compile(
|
|
56
|
+
r"(?<![\w.+%~/:@-])(?!(?:mailto|email|arn|pkg|urn):)"
|
|
57
|
+
r"[\w.+%~-]{1,64}:[^\s/\\]{0,128}@(?=[\w\[])(?!sha\d+:)", re.I)
|
|
58
|
+
|
|
59
|
+
# A name whose last segment (split at _ or -) says it counts something, like token_ttl, key_size,
|
|
60
|
+
# or auth_port, holds a number and no credential. The segment has to be whole, so account_key,
|
|
61
|
+
# transport_secret, and portal_password are still credentials. Nothing about the value decides
|
|
62
|
+
# this. The lookahead runs from the credential word to the end of the name, so a spared name never
|
|
63
|
+
# matches and so never swallows what follows it.
|
|
64
|
+
_COUNTS = r"(?![\w-]{0,64}[_-](?:ttl|timeout|port|size|days|rotation|count|limit)(?![\w-]))"
|
|
65
|
+
|
|
66
|
+
# A word starts after anything but a letter or digit, after a literal \n, \r, or \t (repr and JSON
|
|
67
|
+
# print a newline or a tab that way), at a capital that follows a lowercase letter or digit
|
|
68
|
+
# (dbPass), and at a capitalized word that follows a run of capitals (DBPass). It ends before
|
|
69
|
+
# anything but a letter or digit, or before a capital that follows a lowercase letter (dbPassHash).
|
|
70
|
+
# The patterns are compiled with re.I, so the checks that read case switch it off. A name joined
|
|
71
|
+
# in one case (dbpass, DBPASS) is not split, because nothing in it marks where a word ends.
|
|
72
|
+
_UPPER, _LOWER = r"(?-i:[A-Z])", r"(?-i:[a-z])"
|
|
73
|
+
_WHOLE = (r"(?:(?<![A-Za-z0-9])|(?<=\\[nrt])|(?<=(?-i:[a-z0-9]))(?=" + _UPPER + r")"
|
|
74
|
+
r"|(?<=" + _UPPER + r")(?=" + _UPPER + _LOWER + r"))"
|
|
75
|
+
r"(?:" + "|".join(_WHOLE_WORDS) + r")"
|
|
76
|
+
r"(?:(?![A-Za-z0-9])|(?<=" + _LOWER + r")(?=" + _UPPER + r"))")
|
|
77
|
+
# The signal screen reads a name with the same rule.
|
|
78
|
+
_WHOLE_NAME = re.compile(_WHOLE, re.I)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _named(*words: str) -> str:
|
|
82
|
+
# One of the words, the rest of the name, and no counting suffix.
|
|
83
|
+
return r"(?:" + "|".join(words) + r")" + _COUNTS + r"[\w-]{0,64}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_LINE = _named(*_LINE_WORDS, _WHOLE)
|
|
87
|
+
_ANY = _named(*_LINE_WORDS, *_TOKEN_WORDS, _WHOLE)
|
|
88
|
+
|
|
89
|
+
# The visibility var_dump adds to the key of an object property: ["name":protected]=> and
|
|
90
|
+
# ["name":"Class":private]=>. That colon is no separator.
|
|
91
|
+
_VISIBILITY = r'(?:protected|"[\w\\]{1,64}":private)'
|
|
92
|
+
|
|
93
|
+
# What can sit between a credential name and its value: = or := with a quote or closing bracket
|
|
94
|
+
# allowed in front (PHP writes $_ENV['API_TOKEN'] = x), or a colon with a quote or a space on at
|
|
95
|
+
# least one side. A colon with nothing on either side is _BARE's, and => is _ARROW's.
|
|
96
|
+
_OPS = r'''(?:["'\]]{0,3}\s*(?::=|=(?!>))|["']\s*:(?!''' + _VISIBILITY + r'''\])|\s+:|:(?=\s))'''
|
|
97
|
+
|
|
98
|
+
# A quoted span runs to its closing quote and steps over a backslash escape, so "ab\"cd" is one
|
|
99
|
+
# value. Every repeat starts at a backslash, which the plain run before it cannot match, so the
|
|
100
|
+
# loop has one way to match and stays linear.
|
|
101
|
+
_DQUOTED = r'"[^"\\]*(?:\\[\s\S][^"\\]*)*"?'
|
|
102
|
+
_SQUOTED = r"'[^'\\]*(?:\\[\s\S][^'\\]*)*'?"
|
|
103
|
+
|
|
104
|
+
# What a credential is set to. A quoted span, or else _VALUE stops at a delimiter and an auth
|
|
105
|
+
# scheme word in front of it goes with it. _LINE_VALUE takes the rest of the line, starting at the
|
|
106
|
+
# first character that is not a space.
|
|
107
|
+
_VALUE = (r"(" + _DQUOTED + "|" + _SQUOTED
|
|
108
|
+
+ r'''|(?:(?:bearer|basic|digest|negotiate|ntlm|token)\s+)?[^\s"',;&]+)''')
|
|
109
|
+
_LINE_VALUE = r"(" + _DQUOTED + "|" + _SQUOTED + r"|\S[^\r\n]*)"
|
|
110
|
+
|
|
111
|
+
# var_dump prints a pair as ["name"]=> and puts the value on the next line, as string(7) "hunter2".
|
|
112
|
+
# An object adds the visibility to the key (["name":protected]=>, ["name":"Class":private]=>).
|
|
113
|
+
# var_dump does not escape a quote, so the value is the rest of its line. The newline is crossed
|
|
114
|
+
# only when a type follows it, so print_r's empty pair does not reach over to the entry after it.
|
|
115
|
+
_VARDUMP = re.compile(
|
|
116
|
+
r"(" + _ANY + r'''["']?(?::''' + _VISIBILITY + r''')?\][ \t]*=>\s*'''
|
|
117
|
+
r"(?=(?:string|int|float|bool)\()(?:string\(\d+\)[ \t]*)?)(\S[^\r\n]*)", re.I)
|
|
118
|
+
|
|
119
|
+
# name => value, which is how php -i, print_r, and var_export print a pair. Any credential name
|
|
120
|
+
# takes the rest of the line, because php -i prints a second column after the first.
|
|
121
|
+
_ARROW = re.compile(r"(" + _ANY + r'''["'\]]{0,3}[ \t]*=>[ \t]*)''' + _LINE_VALUE, re.I)
|
|
122
|
+
|
|
123
|
+
# A password or Authorization name with a space after its separator takes the rest of the line
|
|
124
|
+
# (password: a long passphrase, Authorization: Negotiate <blob>). Written inline, with no space,
|
|
125
|
+
# its value stops at a delimiter like any other.
|
|
126
|
+
_SPACED = re.compile(r"(" + _LINE + _OPS + r"[ \t]+)" + _LINE_VALUE, re.I)
|
|
127
|
+
|
|
128
|
+
# name=value, name: value, name :value, "name": value, and name := value for any credential name.
|
|
129
|
+
_ASSIGNED = re.compile(r"(" + _ANY + _OPS + r"\s*)" + _VALUE, re.I)
|
|
130
|
+
|
|
131
|
+
# name:value with no space on either side of the colon, which is also how host:port and image:tag
|
|
132
|
+
# are written. The name has to start a word and cannot follow a / : or @, which keeps the segments
|
|
133
|
+
# of an ARN, an image reference, and a path out of it (function:auth-service:prod,
|
|
134
|
+
# registry/auth-api:v2). A bare keycloak:8443 reads as name:value and loses its port. Inside a URL
|
|
135
|
+
# it has a / in front and stays.
|
|
136
|
+
_BARE = re.compile(r"((?<![\w/:@-])[\w-]{0,64}?" + _ANY + r":)(?![=:])" + _VALUE, re.I)
|
|
137
|
+
|
|
138
|
+
# A bearer or basic credential with no name in front of it. Sixteen characters keeps prose such
|
|
139
|
+
# as "basic authentication" out, which is why no word boundary is needed in front.
|
|
140
|
+
_SCHEME = re.compile(r"(?:bearer|basic)\s+[\w.~+/=-]{16,}", re.I)
|
|
141
|
+
|
|
142
|
+
# Token and key shapes by prefix. None needs a boundary in front, so a token glued to a letter
|
|
143
|
+
# still goes, except sk-, which would otherwise match inside a name like disk-images. AKIA also
|
|
144
|
+
# allows _ in its tail, so a placeholder like AKIA_EXAMPLE_KEY goes with the real ones.
|
|
145
|
+
_SHAPES = re.compile(
|
|
146
|
+
r"gh[pousr]_[A-Za-z0-9]{20,}"
|
|
147
|
+
r"|github_pat_\w{20,}"
|
|
148
|
+
r"|AKIA[A-Z0-9_]{8,}"
|
|
149
|
+
r"|(?:ASIA|ABIA|ACCA)[A-Z0-9]{16}"
|
|
150
|
+
r"|AIza[\w-]{35}"
|
|
151
|
+
r"|xox[abeoprs]-[A-Za-z0-9-]{10,}"
|
|
152
|
+
r"|[sr]k_(?:live|test)_[A-Za-z0-9]{16,}"
|
|
153
|
+
r"|(?<![A-Za-z0-9])sk-[\w-]{20,}"
|
|
154
|
+
r"|glpat-[\w-]{20,}"
|
|
155
|
+
r"|npm_[A-Za-z0-9]{36}"
|
|
156
|
+
r"|eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]*")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _mask_value(m: "re.Match[str]") -> str:
|
|
160
|
+
# Keep the name and the quote marks, so masked text still reads as what it was.
|
|
161
|
+
name, value = m.groups()
|
|
162
|
+
q = value[0] if value[0] in "\"'" else ""
|
|
163
|
+
return name + q + _MASK + q
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# The name rules go before _USERINFO_BARE. A credential name keeps its whole value even when the
|
|
167
|
+
# value holds an @ (password:P@ssw0rd), and the bare userinfo rule sees what is left.
|
|
168
|
+
_STEPS = (
|
|
169
|
+
(_PEM, _MASK),
|
|
170
|
+
(_USERINFO, _MASK + "@"),
|
|
171
|
+
(_USERINFO_LOOSE, _MASK + "@"),
|
|
172
|
+
(_VARDUMP, _mask_value),
|
|
173
|
+
(_ARROW, _mask_value),
|
|
174
|
+
(_SPACED, _mask_value),
|
|
175
|
+
(_ASSIGNED, _mask_value),
|
|
176
|
+
(_BARE, _mask_value),
|
|
177
|
+
(_USERINFO_BARE, _MASK + "@"),
|
|
178
|
+
(_SCHEME, _MASK),
|
|
179
|
+
(_SHAPES, _MASK),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# An AWS secret access key is 40 characters of base64 with no marker. Only a run with upper case,
|
|
183
|
+
# lower case, and a digit counts, so a git SHA or an ordinary name passes.
|
|
184
|
+
_B64_40 = re.compile(r"(?<![A-Za-z0-9/+])[A-Za-z0-9/+]{40}(?![A-Za-z0-9/+])")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _long_secret(m: "re.Match[str]") -> str:
|
|
188
|
+
run = m.group()
|
|
189
|
+
has = (any(c.islower() for c in run), any(c.isupper() for c in run),
|
|
190
|
+
any(c.isdigit() for c in run))
|
|
191
|
+
return _MASK if all(has) else run
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# (level, kind, description) per verdict. A SARIF result of the default kind, fail, takes the level
|
|
195
|
+
# error, warning, or note. Any other kind takes none, so a patched or non-PHP asset reports as a
|
|
196
|
+
# pass or a not-applicable result.
|
|
197
|
+
_SARIF = {
|
|
198
|
+
Verdict.EXPOSED: (
|
|
199
|
+
"error", None, "PHP build is vulnerable to PHP2Shell (CVE-2026-109618, CVE-2026-109619)."),
|
|
200
|
+
Verdict.INDETERMINATE: (
|
|
201
|
+
"warning", None, "php-radar could not determine the PHP2Shell status of this asset."),
|
|
202
|
+
Verdict.LIKELY_PATCHED: ("none", "pass", "PHP build carries the PHP2Shell fix."),
|
|
203
|
+
Verdict.NOT_PHP: ("none", "notApplicable", "No PHP found on this asset."),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def redact(value: str, *, identifier: bool = False) -> str:
|
|
208
|
+
"""Mask the credential shapes found in value and leave the rest as it was.
|
|
209
|
+
|
|
210
|
+
A secret with no recognizable shape passes through, which is why the renderers also drop
|
|
211
|
+
signals by name.
|
|
212
|
+
|
|
213
|
+
identifier says that value names a thing, such as an ARN, a URL, or a file path. Two rules
|
|
214
|
+
read a value by its shape alone: a Bearer or Basic word with a long run after it, and a bare
|
|
215
|
+
run of 40 base64 characters. A name can hold either by chance, so an identifier skips both.
|
|
216
|
+
The rest still run. A credential in URL userinfo, in a name=value pair, or in a known token
|
|
217
|
+
shape is masked in an identifier as it is anywhere else.
|
|
218
|
+
"""
|
|
219
|
+
for pattern, mask in _STEPS:
|
|
220
|
+
if not (identifier and pattern is _SCHEME):
|
|
221
|
+
value = pattern.sub(mask, value)
|
|
222
|
+
return value if identifier else _B64_40.sub(_long_secret, value)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _text(value: object) -> str:
|
|
226
|
+
# Signals are typed str, but a producer can hand over None or a number, and output has to cope.
|
|
227
|
+
return "" if value is None else str(value)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _clean(value: object) -> str:
|
|
231
|
+
return redact(_text(value))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _ident(value: object) -> str:
|
|
235
|
+
# For the location of a read and its resource signal. Both name the thing the read covers.
|
|
236
|
+
return redact(_text(value), identifier=True)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# An evidence with the verdict and confidence it earns alone.
|
|
240
|
+
_Graded = tuple[Evidence, Verdict, Confidence]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _sensitive(name: str) -> bool:
|
|
244
|
+
# _WHOLE decides pass and pw here as it does in the value patterns, so a name is never masked
|
|
245
|
+
# in text and still printed as a signal, or the other way round.
|
|
246
|
+
return (any(word in name.lower() for word in _SENSITIVE)
|
|
247
|
+
or _WHOLE_NAME.search(name) is not None)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _build(ev: Evidence) -> str:
|
|
251
|
+
# A packaged PHP is graded on its build. Showing 8.1.2 alone would look end of life when
|
|
252
|
+
# 8.1.2-1ubuntu2.28 is patched.
|
|
253
|
+
return _text(ev.signals.get("package_version")).strip() or _text(ev.detected_version).strip()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _reads(asset: Asset, verdict: Verdict, matrix: Matrix) -> list[_Graded]:
|
|
257
|
+
"""Each evidence with its own grade, the most telling first.
|
|
258
|
+
|
|
259
|
+
A read that agrees with the asset verdict leads, then the more authoritative source. The sort
|
|
260
|
+
is stable, so equal reads keep the order they arrived in.
|
|
261
|
+
"""
|
|
262
|
+
reads = [(ev, *grade_evidence(ev, matrix)) for ev in asset.evidence]
|
|
263
|
+
reads.sort(key=lambda r: (r[1] is not verdict, -SOURCE_PRECEDENCE[r[0].source]))
|
|
264
|
+
return reads
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _entry(ev: Evidence, verdict: Verdict, confidence: Confidence) -> dict:
|
|
268
|
+
return {
|
|
269
|
+
"source": ev.source.name,
|
|
270
|
+
"location": _ident(ev.location),
|
|
271
|
+
"detected_version": None if ev.detected_version is None else _clean(ev.detected_version),
|
|
272
|
+
"sapi": ev.sapi.name,
|
|
273
|
+
"verdict": verdict.name,
|
|
274
|
+
"confidence": confidence.name,
|
|
275
|
+
"signals": {_clean(k): (_ident(v) if k == "resource" else _clean(v))
|
|
276
|
+
for k, v in ev.signals.items() if not _sensitive(_text(k))},
|
|
277
|
+
"note": _clean(ev.note),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _key(asset: Asset) -> str:
|
|
282
|
+
# A cloud asset is keyed by its resource signal and prints as that signal does. A key built from
|
|
283
|
+
# a host name, an ip, or a repo id is scanned in full. A key that equals a read's resource
|
|
284
|
+
# signal is printed no more freely than that signal already is.
|
|
285
|
+
key = _text(asset.key)
|
|
286
|
+
resources = (ev.signals["resource"] for ev in asset.evidence if "resource" in ev.signals)
|
|
287
|
+
return _ident(key) if any(_text(r) == key for r in resources) else _clean(key)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _row(asset: Asset, matrix: Matrix) -> dict:
|
|
291
|
+
verdict, confidence = grade(asset, matrix)
|
|
292
|
+
reads = _reads(asset, verdict, matrix)
|
|
293
|
+
# The SAPI is a fact about the machine, so any read may name it. The build has to come from a
|
|
294
|
+
# read that agrees with the verdict, or a NOT_PHP asset would show a stale banner's version.
|
|
295
|
+
sapi = next((ev.sapi for ev, _, _ in reads if ev.sapi is not Sapi.UNKNOWN), Sapi.UNKNOWN)
|
|
296
|
+
builds = [_build(ev) for ev, v, _ in reads if v is verdict]
|
|
297
|
+
return {
|
|
298
|
+
"key": _key(asset),
|
|
299
|
+
"kind": _clean(asset.kind),
|
|
300
|
+
"verdict": verdict.name,
|
|
301
|
+
"confidence": confidence.name,
|
|
302
|
+
"sapi": sapi.name,
|
|
303
|
+
"version": _clean(next((b for b in builds if b), "")) or None,
|
|
304
|
+
"evidence": [_entry(*r) for r in reads],
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _rows(assets: list[Asset], matrix: Matrix) -> list[dict]:
|
|
309
|
+
# One row per asset, in the order given. Asset.key is a display name and two assets can share
|
|
310
|
+
# one, so nothing here is keyed on it.
|
|
311
|
+
return [_row(a, matrix) for a in assets]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _tally(rows: list[dict]) -> dict:
|
|
315
|
+
count = Counter(r["verdict"] for r in rows)
|
|
316
|
+
return {"total": len(rows), "by_verdict": {v.name: count[v.name] for v in Verdict}}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def render_json(assets: list[Asset], matrix: Matrix) -> str:
|
|
320
|
+
"""JSON with a summary and one entry per asset, in the order given.
|
|
321
|
+
|
|
322
|
+
Each asset lists its evidence with its own grade, the read that decided the verdict first.
|
|
323
|
+
"""
|
|
324
|
+
rows = _rows(assets, matrix)
|
|
325
|
+
return json.dumps({"tool": "php-radar", "summary": _tally(rows), "assets": rows}, indent=2)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _detail(row: dict) -> str:
|
|
329
|
+
# With no version to show, say what is true of both a hidden version and a read that saw
|
|
330
|
+
# nothing. Only NOT_PHP knows there is no PHP.
|
|
331
|
+
if row["version"]:
|
|
332
|
+
php = "PHP " + row["version"]
|
|
333
|
+
else:
|
|
334
|
+
php = "no PHP found" if row["verdict"] == Verdict.NOT_PHP.name else "no PHP version found"
|
|
335
|
+
parts = [row["confidence"] + " confidence", php]
|
|
336
|
+
if row["sapi"] != Sapi.UNKNOWN.name:
|
|
337
|
+
parts.append(row["sapi"])
|
|
338
|
+
return ", ".join(parts)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _line(entry: dict) -> str:
|
|
342
|
+
line = f" {entry['source']:<7} {entry['verdict']:<14} {entry['confidence']:<6} "
|
|
343
|
+
line += entry["location"]
|
|
344
|
+
if entry["detected_version"]:
|
|
345
|
+
line += " PHP " + entry["detected_version"]
|
|
346
|
+
if entry["note"]:
|
|
347
|
+
line += f" ({entry['note']})"
|
|
348
|
+
return line.rstrip()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _plain(line: str) -> str:
|
|
352
|
+
# Escape everything but printable ASCII. A banner from a hostile server can otherwise forge a
|
|
353
|
+
# row with a newline or move the cursor, and a lookalike hostname would print as the real one.
|
|
354
|
+
return "".join(c if c.isascii() and c.isprintable() else c.encode("unicode_escape").decode()
|
|
355
|
+
for c in line)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def render_console(assets: list[Asset], matrix: Matrix) -> str:
|
|
359
|
+
"""Text for a terminal, grouped by verdict with EXPOSED first.
|
|
360
|
+
|
|
361
|
+
Within a group the surest finding comes first. Each line is printable ASCII.
|
|
362
|
+
"""
|
|
363
|
+
rows = _rows(assets, matrix)
|
|
364
|
+
tally = _tally(rows)["by_verdict"]
|
|
365
|
+
noun = "asset" if len(rows) == 1 else "assets"
|
|
366
|
+
counts = ", ".join(f"{name} {n}" for name, n in tally.items())
|
|
367
|
+
lines = [f"php-radar: {len(rows)} {noun} ({counts})"]
|
|
368
|
+
surest = {c.name: i for i, c in enumerate(Confidence)}
|
|
369
|
+
# Verdict is declared worst-first and Confidence strongest-first, the order wanted here.
|
|
370
|
+
for verdict in Verdict:
|
|
371
|
+
group = [r for r in rows if r["verdict"] == verdict.name]
|
|
372
|
+
if group:
|
|
373
|
+
lines += ["", f"{verdict.name} ({len(group)})"]
|
|
374
|
+
for row in sorted(group, key=lambda r: surest[r["confidence"]]):
|
|
375
|
+
lines.append(f" {row['key']} ({row['kind']}): {_detail(row)}")
|
|
376
|
+
lines += [_line(entry) for entry in row["evidence"]]
|
|
377
|
+
return "\n".join(_plain(line) for line in lines)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _where(entry: dict) -> dict:
|
|
381
|
+
# A file in a repository is a physical location. A host, an ARN, or a URL has no file behind
|
|
382
|
+
# it, so it is a resource. Nothing here knows a line, so a file points at line 1.
|
|
383
|
+
if entry["source"] == Source.REPO.name:
|
|
384
|
+
# A file name with an undecodable byte reaches here as a lone surrogate, which quote
|
|
385
|
+
# cannot encode. Spelling it out keeps one odd file name from losing the whole report.
|
|
386
|
+
uri = quote(entry["location"].replace("\\", "/"), errors="backslashreplace")
|
|
387
|
+
where = {"physicalLocation": {"artifactLocation": {"uri": uri},
|
|
388
|
+
"region": {"startLine": 1}}}
|
|
389
|
+
else:
|
|
390
|
+
where = {"logicalLocations": [{"name": entry["location"], "kind": "resource"}]}
|
|
391
|
+
where["message"] = {
|
|
392
|
+
"text": f"{entry['source']}: {entry['verdict']}, {entry['confidence']} confidence"}
|
|
393
|
+
return where
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _result(row: dict) -> dict:
|
|
397
|
+
verdict = Verdict[row["verdict"]]
|
|
398
|
+
level, kind, _ = _SARIF[verdict]
|
|
399
|
+
where = [_where(entry) for entry in row["evidence"]]
|
|
400
|
+
result = {
|
|
401
|
+
"ruleId": "PHP2SHELL-" + verdict.name,
|
|
402
|
+
"ruleIndex": list(Verdict).index(verdict),
|
|
403
|
+
"level": level,
|
|
404
|
+
"message": {"text": f"{verdict.name}: {row['key']} ({row['kind']}), {_detail(row)}"},
|
|
405
|
+
# Code scanning draws a result at its first location only, so the read that decided the
|
|
406
|
+
# verdict goes there and the others ride along as related locations.
|
|
407
|
+
"locations": where[:1],
|
|
408
|
+
}
|
|
409
|
+
if kind:
|
|
410
|
+
result["kind"] = kind
|
|
411
|
+
if where[1:]:
|
|
412
|
+
result["relatedLocations"] = [dict(w, id=i) for i, w in enumerate(where[1:])]
|
|
413
|
+
result["properties"] = {k: row[k] for k in ("verdict", "confidence", "sapi", "version")}
|
|
414
|
+
return result
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def render_sarif(assets: list[Asset], matrix: Matrix) -> str:
|
|
418
|
+
"""A SARIF 2.1.0 log with one result per asset, in the order given."""
|
|
419
|
+
rules = []
|
|
420
|
+
for verdict in Verdict:
|
|
421
|
+
level, _, text = _SARIF[verdict]
|
|
422
|
+
rules.append({"id": "PHP2SHELL-" + verdict.name, "shortDescription": {"text": text},
|
|
423
|
+
"defaultConfiguration": {"level": level}})
|
|
424
|
+
run = {
|
|
425
|
+
"tool": {"driver": {"name": "php-radar", "rules": rules}},
|
|
426
|
+
"results": [_result(row) for row in _rows(assets, matrix)],
|
|
427
|
+
}
|
|
428
|
+
doc = {"$schema": "https://json.schemastore.org/sarif-2.1.0.json", "version": "2.1.0",
|
|
429
|
+
"runs": [run]}
|
|
430
|
+
return json.dumps(doc, indent=2)
|