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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.2",
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.2"
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 unexpected:
744
- lines = unexpected[:30]
841
+ if hand:
842
+ lines = hand[:30]
745
843
  body = "\n".join(lines)
746
- if len(unexpected) > 30:
747
- body += f"\n[truncated at 30 of {len(unexpected)} results]"
748
- if managed:
749
- body += (f"\n({len(managed)} SUID/SGID binary(ies) under "
750
- f"distro-managed paths, expected inventory)")
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 managed:
753
- return "ok", (f"{len(managed)} SUID/SGID binaries found, all under "
754
- f"distro-managed paths (expected inventory)" + 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)
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 SECRET_NAME.search(k) or (v and SECRET_VAL.search(v)):
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: