aegis-rex 1.5.2 → 1.5.3
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.
- package/package.json +1 -1
- package/vendor/rex.py +111 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-rex",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.3",
|
|
4
4
|
"description": "Headless-drivable security auditor for Linux, macOS and Windows. Runs a 17-check local audit and can be driven by an AI agent (--capabilities / --audit --json / --fix). npm is the distribution channel; the tool itself is dependency-free Python.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"rex": "bin/rex.js"
|
package/vendor/rex.py
CHANGED
|
@@ -118,7 +118,7 @@ IS_LINUX = _OS == "Linux"
|
|
|
118
118
|
# ============================================================
|
|
119
119
|
# KONFIGURATION
|
|
120
120
|
# ============================================================
|
|
121
|
-
VERSION = "1.5.
|
|
121
|
+
VERSION = "1.5.3"
|
|
122
122
|
APP_NAME = "ÆGIS Security Audit"
|
|
123
123
|
|
|
124
124
|
# Lines emitted by find(1)/stat(1) *about* a path rather than *as a result*.
|
|
@@ -126,9 +126,68 @@ APP_NAME = "ÆGIS Security Audit"
|
|
|
126
126
|
# with real hits — without this split, "Permission denied" is counted as a
|
|
127
127
|
# world-writable file that was never actually found.
|
|
128
128
|
_FIND_NOISE = re.compile(
|
|
129
|
-
r"^(?:find|stat|ls|du|grep|getfacl)\s*:\s", re.IGNORECASE
|
|
129
|
+
r"^(?:find|stat|ls|du|grep|getfacl|dpkg-query|rpm|pacman)\s*:\s", re.IGNORECASE
|
|
130
130
|
)
|
|
131
131
|
|
|
132
|
+
# A variable whose *name* marks it as a credential. Matched on segment
|
|
133
|
+
# boundaries, not as a bare substring: SSH_AUTH_SOCK and XAUTHORITY used to be
|
|
134
|
+
# reported as secrets because they contain "auth", and PWD because it contains
|
|
135
|
+
# "pwd" — neither holds a credential.
|
|
136
|
+
_SECRET_NAME_ANY = re.compile(
|
|
137
|
+
r"(?:^|[_\-.0-9])(key|keys|token|secret|passwd|password|credential|"
|
|
138
|
+
r"credentials|apikey|api_key|access_key|private_key|bearer|jwt|oauth|"
|
|
139
|
+
r"webhook)(?:$|[_\-.0-9])",
|
|
140
|
+
re.IGNORECASE,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Weaker words that are only credential-ish as the *last* segment: MY_AUTH and
|
|
144
|
+
# MYSQL_PWD are secrets, SSH_AUTH_SOCK is a socket path.
|
|
145
|
+
_SECRET_NAME_TAIL = re.compile(r"(?:^|[_\-.0-9])(auth|pwd|private)$", re.IGNORECASE)
|
|
146
|
+
|
|
147
|
+
# Values that are credentials whatever the variable is called.
|
|
148
|
+
_SECRET_VALUE = re.compile(
|
|
149
|
+
r"^sk-[A-Za-z0-9_\-]{16,}" # OpenAI / Anthropic / DeepSeek
|
|
150
|
+
r"|^whsec_[A-Za-z0-9]{16,}" # Stripe webhook secret
|
|
151
|
+
r"|^gsk_[A-Za-z0-9]{20,}" # Groq
|
|
152
|
+
r"|^ghp_|^ghs_|^github_pat_" # GitHub tokens
|
|
153
|
+
r"|^xox[bpoas]-" # Slack tokens
|
|
154
|
+
r"|^[a-f0-9]{40,}$" # long hex digest
|
|
155
|
+
r"|^[A-Za-z0-9+/]{40,}={0,2}$", # long base64 blob
|
|
156
|
+
re.IGNORECASE,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Names that merely *contain* a credential-ish word but are paths, sockets or
|
|
160
|
+
# session identifiers, not secrets.
|
|
161
|
+
_BENIGN_VARS = frozenset({
|
|
162
|
+
"PWD", "OLDPWD", "XAUTHORITY", "SSH_AUTH_SOCK", "SSH_ASKPASS",
|
|
163
|
+
"DBUS_SESSION_BUS_ADDRESS", "XDG_SESSION_PATH", "XDG_SEAT_PATH",
|
|
164
|
+
"XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CONFIG_DIRS",
|
|
165
|
+
"GPG_AGENT_INFO", "HISTFILE", "KEYRING_PID",
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _looks_like_path(value):
|
|
170
|
+
"""True for filesystem/socket paths that merely resemble a long blob.
|
|
171
|
+
|
|
172
|
+
/org/freedesktop/DisplayManager/Session0 is 38 characters drawn entirely
|
|
173
|
+
from [A-Za-z0-9+/], so it satisfied the "long base64 blob" rule even
|
|
174
|
+
though "/" is only in that class to allow real base64 padding.
|
|
175
|
+
"""
|
|
176
|
+
return value.startswith(("/", "~", "./", "../")) or os.path.exists(value)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_secret_var(name, value):
|
|
180
|
+
"""Decide whether one environment entry looks like a credential."""
|
|
181
|
+
if name.upper() in _BENIGN_VARS:
|
|
182
|
+
return False
|
|
183
|
+
if _SECRET_NAME_ANY.search(name) or _SECRET_NAME_TAIL.search(name):
|
|
184
|
+
return True
|
|
185
|
+
if value and _SECRET_VALUE.search(value):
|
|
186
|
+
# A value-only hit must not be a path, or XDG_SESSION_PATH and
|
|
187
|
+
# XDG_SEAT_PATH come back as secrets.
|
|
188
|
+
return not _looks_like_path(value)
|
|
189
|
+
return False
|
|
190
|
+
|
|
132
191
|
# ============================================================
|
|
133
192
|
# AI PROVIDERS
|
|
134
193
|
# ============================================================
|
|
@@ -385,6 +444,39 @@ class AuditEngine:
|
|
|
385
444
|
findings.append(line)
|
|
386
445
|
return findings, noise
|
|
387
446
|
|
|
447
|
+
@staticmethod
|
|
448
|
+
def _pkg_split(paths):
|
|
449
|
+
"""Partition paths into (hand-placed, installed-by-a-package-manager).
|
|
450
|
+
|
|
451
|
+
The SUID section used to judge by *path* alone, so a setuid helper that
|
|
452
|
+
shipped inside a .deb but unpacked to /opt/<App>/ was reported as if
|
|
453
|
+
someone had placed it by hand. Asking the package manager is the honest
|
|
454
|
+
test. If no package manager is available the paths stay on the
|
|
455
|
+
hand-placed side — absence of evidence is not a pass.
|
|
456
|
+
"""
|
|
457
|
+
if not paths:
|
|
458
|
+
return [], []
|
|
459
|
+
owned = set()
|
|
460
|
+
for exe_name in ("dpkg-query", "rpm", "pacman"):
|
|
461
|
+
exe = shutil.which(exe_name)
|
|
462
|
+
if not exe:
|
|
463
|
+
continue
|
|
464
|
+
out = AuditEngine._cmd([exe, "-S"] + [str(p) for p in paths], timeout=20)
|
|
465
|
+
real, _ = AuditEngine._split_noise(out)
|
|
466
|
+
for line in real:
|
|
467
|
+
if ":" not in line:
|
|
468
|
+
continue
|
|
469
|
+
# "terminal-ds: /opt/Terminal DS/chrome-sandbox" — split on the
|
|
470
|
+
# first colon only; the path itself may contain spaces.
|
|
471
|
+
_, _, resolved = line.partition(":")
|
|
472
|
+
resolved = resolved.strip()
|
|
473
|
+
if resolved:
|
|
474
|
+
owned.add(resolved)
|
|
475
|
+
break # first package manager that exists is the right one
|
|
476
|
+
hand = [p for p in paths if p not in owned]
|
|
477
|
+
packaged = [p for p in paths if p in owned]
|
|
478
|
+
return hand, packaged
|
|
479
|
+
|
|
388
480
|
@staticmethod
|
|
389
481
|
def _na(feature):
|
|
390
482
|
return "ok", f"[N/A on {_OS}] {feature} is not applicable on this platform."
|
|
@@ -739,19 +831,25 @@ class AuditEngine:
|
|
|
739
831
|
real, noise = self._split_noise(out)
|
|
740
832
|
managed = [f for f in real if f.startswith(managed_dirs)]
|
|
741
833
|
unexpected = [f for f in real if not f.startswith(managed_dirs)]
|
|
834
|
+
# Ruling by path alone reported /opt/Terminal DS/chrome-sandbox
|
|
835
|
+
# as hand-placed; it belongs to the terminal-ds package. Only a
|
|
836
|
+
# binary that neither the distro nor a package shipped is a
|
|
837
|
+
# finding.
|
|
838
|
+
hand, packaged = self._pkg_split(unexpected)
|
|
839
|
+
inventory = len(managed) + len(packaged)
|
|
742
840
|
suffix = f"\n({len(noise)} path(s) not readable, skipped)" if noise else ""
|
|
743
|
-
if
|
|
744
|
-
lines =
|
|
841
|
+
if hand:
|
|
842
|
+
lines = hand[:30]
|
|
745
843
|
body = "\n".join(lines)
|
|
746
|
-
if len(
|
|
747
|
-
body += f"\n[truncated at 30 of {len(
|
|
748
|
-
if
|
|
749
|
-
body += (f"\n({
|
|
750
|
-
f"
|
|
844
|
+
if len(hand) > 30:
|
|
845
|
+
body += f"\n[truncated at 30 of {len(hand)} results]"
|
|
846
|
+
if inventory:
|
|
847
|
+
body += (f"\n({inventory} SUID/SGID binary(ies) under distro- or "
|
|
848
|
+
f"package-managed locations, expected inventory)")
|
|
751
849
|
return "warn", body + suffix
|
|
752
|
-
if
|
|
753
|
-
return "ok", (f"{
|
|
754
|
-
f"
|
|
850
|
+
if inventory:
|
|
851
|
+
return "ok", (f"{inventory} SUID/SGID binaries found, all under distro- "
|
|
852
|
+
f"or package-managed locations (expected inventory)" + suffix)
|
|
755
853
|
return "ok", "No SUID/SGID files found in scanned paths" + suffix
|
|
756
854
|
|
|
757
855
|
# ── World-Writable Files ──────────────────────────────
|
|
@@ -849,22 +947,9 @@ class AuditEngine:
|
|
|
849
947
|
|
|
850
948
|
# ── Environment Secrets ───────────────────────────────
|
|
851
949
|
elif section == "Environment Secrets":
|
|
852
|
-
SECRET_NAME = re.compile(
|
|
853
|
-
r"(key|token|secret|password|passwd|pwd|credential|auth|"
|
|
854
|
-
r"apikey|api_key|access_key|private|bearer|jwt|oauth|webhook)",
|
|
855
|
-
re.IGNORECASE,
|
|
856
|
-
)
|
|
857
|
-
SECRET_VAL = re.compile(
|
|
858
|
-
r"^[A-Za-z0-9+/]{32,}$" # base64-like long string
|
|
859
|
-
r"|^[a-f0-9]{32,}$" # hex hash
|
|
860
|
-
r"|^sk-[A-Za-z0-9]{20,}" # OpenAI-style
|
|
861
|
-
r"|^ghp_|^ghs_|^github_pat_" # GitHub tokens
|
|
862
|
-
r"|^xox[bpoas]-", # Slack tokens
|
|
863
|
-
re.IGNORECASE,
|
|
864
|
-
)
|
|
865
950
|
suspicious, clean = [], []
|
|
866
951
|
for k, v in os.environ.items():
|
|
867
|
-
if
|
|
952
|
+
if _is_secret_var(k, v):
|
|
868
953
|
masked = (v[:4] + "****" + v[-2:]) if len(v) > 6 else "****"
|
|
869
954
|
suspicious.append(f"⚠ {k} = {masked}")
|
|
870
955
|
else:
|