aegis-rex 1.5.1 → 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 +138 -32
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."
|
|
@@ -714,24 +806,51 @@ class AuditEngine:
|
|
|
714
806
|
return status, out or "No world-accessible binaries found in System32"
|
|
715
807
|
|
|
716
808
|
else: # Linux + macOS
|
|
717
|
-
|
|
809
|
+
# Where a distro's package manager is allowed to drop a
|
|
810
|
+
# SUID/SGID binary. A stock Ubuntu box carries ~30 helpers in
|
|
811
|
+
# /usr/bin and /usr/lib (sudo, passwd, mount, su, pkexec …);
|
|
812
|
+
# reporting that inventory as a defect buries the one binary
|
|
813
|
+
# that was actually put there by hand. So the bit itself is
|
|
814
|
+
# inventoried, and only *where* it was found decides the status.
|
|
815
|
+
managed_dirs = (
|
|
816
|
+
"/usr/bin/", "/usr/sbin/", "/usr/lib/", "/usr/lib32/",
|
|
817
|
+
"/usr/lib64/", "/usr/libexec/", "/bin/", "/sbin/",
|
|
818
|
+
"/lib/", "/lib32/", "/lib64/",
|
|
819
|
+
)
|
|
718
820
|
if IS_MAC:
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
821
|
+
managed_dirs = ("/usr/bin/", "/usr/sbin/", "/usr/lib/",
|
|
822
|
+
"/usr/libexec/", "/bin/", "/sbin/", "/System/")
|
|
823
|
+
# /home is deliberately absent: a recursive scan of home dirs
|
|
824
|
+
# exceeded 120s on a real machine, and an audit that stalls is
|
|
825
|
+
# worse than one with a stated scope. The remaining roots are
|
|
826
|
+
# sub-second.
|
|
827
|
+
scan_roots = ["/usr", "/bin", "/sbin", "/lib", "/lib64",
|
|
828
|
+
"/usr/local", "/opt", "/tmp", "/var/tmp"]
|
|
829
|
+
existing = [p for p in scan_roots if os.path.isdir(p)]
|
|
830
|
+
out = cmd(["find"] + existing + ["-perm", "/6000", "-type", "f"], timeout=25)
|
|
722
831
|
real, noise = self._split_noise(out)
|
|
723
|
-
if
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
if
|
|
733
|
-
|
|
734
|
-
|
|
832
|
+
managed = [f for f in real if f.startswith(managed_dirs)]
|
|
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)
|
|
840
|
+
suffix = f"\n({len(noise)} path(s) not readable, skipped)" if noise else ""
|
|
841
|
+
if hand:
|
|
842
|
+
lines = hand[:30]
|
|
843
|
+
body = "\n".join(lines)
|
|
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)")
|
|
849
|
+
return "warn", body + suffix
|
|
850
|
+
if inventory:
|
|
851
|
+
return "ok", (f"{inventory} SUID/SGID binaries found, all under distro- "
|
|
852
|
+
f"or package-managed locations (expected inventory)" + suffix)
|
|
853
|
+
return "ok", "No SUID/SGID files found in scanned paths" + suffix
|
|
735
854
|
|
|
736
855
|
# ── World-Writable Files ──────────────────────────────
|
|
737
856
|
elif section == "World-Writable Files":
|
|
@@ -828,22 +947,9 @@ class AuditEngine:
|
|
|
828
947
|
|
|
829
948
|
# ── Environment Secrets ───────────────────────────────
|
|
830
949
|
elif section == "Environment Secrets":
|
|
831
|
-
SECRET_NAME = re.compile(
|
|
832
|
-
r"(key|token|secret|password|passwd|pwd|credential|auth|"
|
|
833
|
-
r"apikey|api_key|access_key|private|bearer|jwt|oauth|webhook)",
|
|
834
|
-
re.IGNORECASE,
|
|
835
|
-
)
|
|
836
|
-
SECRET_VAL = re.compile(
|
|
837
|
-
r"^[A-Za-z0-9+/]{32,}$" # base64-like long string
|
|
838
|
-
r"|^[a-f0-9]{32,}$" # hex hash
|
|
839
|
-
r"|^sk-[A-Za-z0-9]{20,}" # OpenAI-style
|
|
840
|
-
r"|^ghp_|^ghs_|^github_pat_" # GitHub tokens
|
|
841
|
-
r"|^xox[bpoas]-", # Slack tokens
|
|
842
|
-
re.IGNORECASE,
|
|
843
|
-
)
|
|
844
950
|
suspicious, clean = [], []
|
|
845
951
|
for k, v in os.environ.items():
|
|
846
|
-
if
|
|
952
|
+
if _is_secret_var(k, v):
|
|
847
953
|
masked = (v[:4] + "****" + v[-2:]) if len(v) > 6 else "****"
|
|
848
954
|
suspicious.append(f"⚠ {k} = {masked}")
|
|
849
955
|
else:
|