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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.1",
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.1"
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
- safe_paths = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt"]
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
- safe_paths = ["/usr", "/bin", "/sbin", "/opt"]
720
- existing = [p for p in safe_paths if os.path.isdir(p)]
721
- out = cmd(["find"] + existing + ["-perm", "/6000", "-type", "f"], timeout=20)
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 real:
724
- lines = real[:30]
725
- out = "\n".join(lines)
726
- if len(lines) == 30:
727
- out += "\n[truncated at 30 results]"
728
- if noise:
729
- out += f"\n({len(noise)} path(s) not readable, skipped)"
730
- return "warn", out
731
- note = "No SUID/SGID files found in system dirs"
732
- if noise:
733
- note += f" ({len(noise)} path(s) not readable, skipped)"
734
- return "ok", note
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 SECRET_NAME.search(k) or (v and SECRET_VAL.search(v)):
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: