Skip to content

test(network): cover outbound proxy/TLS config, resolver and replay routing - #153

Open
lorenzozanee wants to merge 1449 commits into
CyberStrikeus:devfrom
lorenzozanee:restore/pr-109
Open

lorenzozanee wants to merge 1449 commits into
CyberStrikeus:devfrom
lorenzozanee:restore/pr-109

Conversation

@lorenzozanee

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds the missing test coverage for the outbound proxy/TLS feature from #102: the network config schema (including secret redaction), the Network resolver (forHost/forUrl, bypass matching, per-host client certificates, TLS precedence), and replay routing through a real local proxy.

The hackbrowser side of #102 reuses the same Network layer through forBrowser() (covered here: proxy server/bypass, SOCKS+auth refusal, certificate-error handling); the browser itself is exercised end-to-end in the existing crawler tests, so no separate hackbrowser wiring test was added.

Fixes #102

Type of change

  • Bug fix
  • New feature / agent
  • Security tool / MCP server / Bolt plugin
  • Agent skill / knowledge base
  • UI / TUI improvement
  • Documentation
  • Refactor / performance
  • CI / infrastructure

Security impact

  • This PR adds or modifies tool execution (shell, file, network)
  • This PR changes agent permissions or scope
  • This PR modifies authentication / authorization logic
  • This PR has no security impact

How did you verify it works?

bun test test/network/network.test.ts from packages/cyberstrike → 52 pass; bun turbo typecheck clean. The wire tests spin up local loopback origin + proxy servers, no external network needed.

Checklist

  • bun turbo typecheck passes
  • Tested locally with at least one LLM provider
  • PR is focused on a single change
  • No secrets, credentials, or API keys in the diff
  • Breaking changes are documented (if any)

Supersedes #109(原PR分支已删,按原提交重建)

cyberstrike-bot Bot and others added 30 commits August 11, 2026 13:30
- 001: SSH target enumeration
- 002: SSH tunnel establishment
- 003: SSH command execution/pivot
- AutoLogon: full password kept in output (Category C, agent may use it),
  masked in findings details
- VNC: raw registry output replaced with length indicator
network.ts keeps 001 (single target scan), lateral.ts gets 002 (subnet discovery).
…breakdown

cloud-security.txt AWS section expanded from 7 bullets to full 7-category listing matching azurehook detail level.
Add 10 missing programs to kill chain phases and reference table: multi_region_scan, kms_enum, opensearch_enum, efs_enum, elb_enum, cloudtrail_selector_tamper, backup_vault_enum, cloudwatch_logs_dump, sns_sqs_siphon, kinesis_tap.
…nstead of silent exit

When a provider's safety classifier blocks a response, the session loop exited
silently with no error shown — appearing as if CyberStrike hung. Now sets an
error on the assistant message so TUI/web renders a visible notice, and logs
at WARN level instead of INFO for diagnosability.

Closes CyberStrikeus#62
…Strikeus#58)

Guard clause in provider.ts now checks for providerConfig — when a user explicitly
configures amazon-bedrock in their config, the provider loads even without explicit
env var credentials, allowing fromInstanceMetadata() to resolve IAM role credentials.

Fixes CyberStrikeus#58
…S fallback

fromNodeProviderChain covers all credential sources: env vars, SSO profiles,
~/.aws/credentials, credential_process, web identity, and IMDS (EC2 instance
roles). Falls back to fromInstanceMetadata if the chain import fails under Bun.
Passive questions like "what is the tech stack?" were triggering
ensure_tools to install 22 attack tools on smaller local models
(e.g. Ollama qwen2.5-coder:14b). The methodology prompt gave an
unconditional "Use ensure_tools before testing" instruction that
small models followed blindly for any prompt.

Closes CyberStrikeus#43
Defense-in-depth: tool description now tells the model to NEVER call
ensure_tools for passive/informational requests — reinforces the
methodology prompt change for models that weight tool descriptions
more than system prompt instructions.
OrcaRouter is already functional via the models.dev catalog
(openai-compatible SDK, 81 models). Adding it to the provider
table for discoverability.

Closes CyberStrikeus#64
OrcaRouter is one of 160+ providers in the auto-generated models.dev
snapshot — no dedicated code, no tests, no active support. The README
table lists core integrations we actively maintain, not every catalog
entry.

Reopens CyberStrikeus#64
Commands that produce binary output (e.g. raw exe content via WSL,
cat on binary files) were dumping non-printable bytes directly into
the TUI, corrupting the terminal display with garbled ANSI sequences.

Adds a per-chunk binary detection heuristic (>30% non-printable bytes)
that strips binary data and replaces it with a placeholder message.
Normal text output including ANSI colors and UTF-8 passes through.

Fixes CyberStrikeus#38
Serhatcck and others added 26 commits August 28, 2026 13:52
`{env:VAR}` keeps a proxy password out of the config file, but the loader
expands it into the in-memory config — which GET /config returned verbatim and
`debug config` printed. The field description promises the file, and a reader
can easily hear that as a promise about the process.

Both display paths now go through Config.redactSecrets: the proxy password and
client-certificate passphrases come out as "[redacted]" while the field stays
visible, so it is still obvious that one is configured. Everything non-secret
(username, url, certificate paths) is untouched.

The redactor copies rather than mutates — get() hands back the live cached
config, so masking in place would blank the credential the running process
still needs. Verified explicitly.

Scope: only the secrets this feature introduced. Provider API keys ride the
same channel and are equally exposed, but that predates this work and the TUI
consumes that response — a behaviour change there belongs in its own issue.

Verified through the real route (Request in, Response out): the secret is live
in the process, absent from the response, and still live afterwards.

Refs CyberStrikeus#102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…red proxy

These scripts send real traffic at the target and were the last senders
ignoring the proxy entirely — which mattered more than it looks: the bash
denies on curl/wget/python-requests only just became effective, making
attack_script the path of least resistance for unproxied HTTP.

Analysis of what actually had to be covered, per script:

  jwt_tamper          no network            nothing to do
  ssrf_listener       inbound only          nothing to do
  race_tester         aiohttp               IGNORES the environment
  file_upload_tester  requests              honours it
  subdomain_takeover  requests              honours it
  cloud_storage_enum  requests              honours it
  wayback_endpoints   requests              honours it
  github_dorker       gh CLI                honours it

So environment variables alone would have covered six of seven and missed
race_tester — the most target-facing one of the set. It needed trust_env=True.
It was also the only script still verifying TLS while its four siblings all
pass verify=False, so behind an intercepting proxy it would have been the one
script that broke the moment a proxy was configured; it is now aligned with
them and with http_replay's own default.

Environment variables are the right mechanism HERE and the module says why:
inside our own process an ambient proxy variable would silently capture traffic
we never routed, so callers pass options explicitly — but a third-party script
has no such seam, the variables ARE the interface. Both cases of each name are
set, and NO_PROXY carries the bypass list in the same exact+suffix pairs used
for the browser.

Verified by running the real tool (real python3, real proxy), covering both
client families: aiohttp and requests each reach an otherwise-unresolvable host
through the proxy, neither touches it when no proxy is configured, and the
bypass list still excludes a host.

Refs CyberStrikeus#102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nreadably

Handed a socks:// URL in HTTP_PROXY, the scripts' clients speak HTTP at a SOCKS
server and report "400, message=Expected HTTP/" — an error that names neither
the proxy nor the setting that caused it, sending the reader to look at the
target. The tool now stops before spawning and says which setting is the
problem and what to do about it.

Measured first, because the answer changed what to build: the traffic does NOT
leak past the proxy in that state — with a resolvable non-loopback target and a
SOCKS proxy configured, the scripts reach it zero times versus 2 and 27 with no
proxy at all. So this is a legibility fix, not a containment one. (Two earlier
attempts at that measurement were wrong: an unresolvable host cannot tell
"refused" from "went direct and failed DNS", and a loopback target is bypassed
by design.)

childEnv throws here where forHost only warns, and the asymmetry is deliberate:
a replayed request fails closed on its own, so returning the proxy keeps the
safe behaviour, while a child process handed the same URL produces garbage.
Refusing to start is the only outcome that is both safe and explicable.

Verified across every proxy configuration, both client families (aiohttp and
requests), real python3 and real proxies: open, authenticated, wrong
credentials, intercepting (TLS decrypted end to end), SOCKS, disabled, bypassed
and dead — 16/16.

Refs CyberStrikeus#102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corporate proxies commonly demand NTLM or Negotiate, which nothing here can
satisfy — only Basic is sent. Measured against a Digest-challenging proxy: one
attempt, Basic offered, 407 returned, no hang and no retry loop. That part is
fine; the problem was the report. A bare 407 in the tool output reads as an
authentication finding about the ENDPOINT, and a tester agent would record it
as one.

The result now carries a note naming the proxy authority (never the
credentials) and the scheme the proxy actually asked for, so an unsatisfiable
enterprise proxy is legible instead of looking like a target behaviour. Carried
on the result rather than added at each call site, so every output path picks
it up.

Also measured while covering enterprise-shaped setups, all as expected:
- https:// proxy (TLS to the proxy itself) works — the scheme the validator
  accepts is not a dead option
- NODE_EXTRA_CA_CERTS is honoured, so a corporate CA installed system-wide
  reaches the replay backends without being repeated in caPath. This matters:
  the fetch stack does NOT read the OS keychain, so without it caPath would
  have been the only route
- an IPv6 proxy address works

Refs CyberStrikeus#102

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on the pinned Playwright: setting clientCertificates makes EVERY
page load in that context hang until timeout. Reproduced against bare
chromium.launch() with none of our launch args, with a server certificate
signed by a trusted CA, and with a server that never requests a client
certificate — so it is neither a trust problem nor a mutual-TLS problem.
Playwright inserts a local interceptor whenever the option is present and
that interceptor never completes.

One entry re-routes the whole context, so a plausible config would have
silently killed a crawl on hosts the certificate had nothing to do with.
forBrowser() now warns instead of emitting the option. Replayed requests
still honour client certificates (verified on the wire), so the capability
is not lost — only the crawler cannot reach a mutual-TLS host, and it now
fails fast with ERR_BAD_SSL_CLIENT_AUTH_CERT rather than hanging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tial-crawl diagnosis (CyberStrikeus#105)

The worker already listened for browser.disconnected / page.close / page.crash, but logged a
terse one-line message with no context, and never logged a popup/new tab opening — so a crawl
that stopped early left little to diagnose from. Add a guarded lifecycleContext(browser, page?)
helper (url, open-page count, isConnected — every read wrapped so a crash/disconnect handler
can never throw while gathering diagnostics) and attach its output to the existing terminal
logs on BOTH the single- and multi-credential paths. Also add a context "page" listener that
logs (debug) when a popup/tab opens, with the same context — no such listener existed.

Scope kept proportionate: no dedicated per-session log file (worker logs are already persisted
via the parent's log pipeline, and CyberStrikeus#104 removed the main mid-crawl crash, so the crash-window
loss the file would guard is now rare), no verbosity flag, no crawl-progress plumbing.

Verified: a real crawl that opens a popup logs "new page/popup opened" with {url, openPages,
connected} and the terminal logs now carry the same context; the guarded reads don't throw
even on a not-yet-committed popup URL or a disconnected browser; 99/99 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t_probe

http_replay resolved the proxy/TLS transport, applied governance and framed proxy
errors inside its own sendGoverned; inject_probe (after CyberStrikeus#106) composed the same
Send.governed(() => BackendFetch.send(...)) inline — but without the Network.forUrl
step, so it sent DIRECTLY, bypassing a configured proxy. Two send paths that must
agree on every outbound attack request had already drifted.

Extract the one thing every target-facing tool must do identically into
`src/tool/web-send.ts` — `WebSend.send(msg, origin, opts)`:
- resolve Network.forUrl (proxy, extra CA, client cert, TLS strictness) ONCE,
- the rejectUnauthorized precedence (per-call insecureTls -> config -> accept-bad-certs),
- Send.governed with the caller's governor(s),
- the pure BackendFetch transport (primitives only — the backend stays config-free),
- the proxy-aware error annotation and the 407 proxyNote.

Both tools now route through it: http_replay.sendGoverned is a thin adapter that
only supplies its per-call governor + parameter names; inject_probe passes its
per-battery governor. inject_probe therefore honours the configured proxy for the
first time, and — the point — any future change to the send (a TLS rule, an auth
step, an observability hook) reaches both tools at once instead of one silently
drifting. Per-tool concerns (credential, observation, mutations/battery) stay in
the tools; only putting a request on the wire is shared.

DAST intent: every attack request goes through one core request service.

Backend purity is preserved (WebSend passes primitive fields to BackendFetch, which
still needs no config/Instance context); the hackbrowser worker's own transport is
untouched (separate process).

Verified against live Juice Shop + a byte-level echo capture: inject_probe still
finds the SQLi (50 governed sends) and injection payloads (quotes, OR/||) arrive
intact through the new path; http_replay's request_id and target paths still return
200. Typecheck clean in both packages.

Refs CyberStrikeus#102, CyberStrikeus#106
Found by an end-to-end proxy test campaign (78 scenarios, real local proxies)
against the WebSend refactor. The core inject_probe-through-proxy path was green
(payloads byte-intact, all classes transit the proxy); these are the three bugs:

1. http_replay_raw ignored config's tls.rejectUnauthorized (HIGH, dead-config TLS
   downgrade). The raw path forwarded net.ca/clientCertificate but hard-coded
   `rejectUnauthorized: insecure_tls === false`, dropping the resolved strictness,
   so a global strict-TLS setting was silently ignored for every byte-exact send.
   Fix: a single source of truth — Network.tlsRejectUnauthorized(insecureTls, out),
   per-call -> config -> default — now used by BOTH WebSend and the raw path, so the
   security precedence cannot drift between them.

2. A malformed/unsupported proxy URL threw out of execute() instead of returning a
   clean error (MED). WebSend now catches the Network.forUrl throw and fails CLOSED
   with a structured error — never a direct send that leaks past the proxy — fixing
   http_replay and inject_probe at once.

3. redactSecrets did not mask credentials embedded in proxy.url (MED, secret
   disclosure through GET /config). It masked proxy.auth.password only; now it also
   blanks a password inside proxy.url, on the copy (never mutating cached config).

Deferred (reported, not fixed here): cert file-cache staleness on rotation (low,
acknowledged by-design) and http_replay sweep/compare using a per-send governor
(tied to CyberStrikeus#91, not a proxy bug).

Refs CyberStrikeus#102
…srf through WebSend

Follow-up review of the WebSend refactor found three related gaps — the first a
serious false-negative on the DAST backbone, opened BY the refactor (inject_probe
is now proxied):

1. inject_probe read a proxy 407 as a target response (HIGH). looksBlocked covered
   403/406/429/503 but not 407, and proxyNote was never read — so against an
   auth-refusing proxy every payload got a 407, marker "not reflected", and the
   battery closed "nothing found", indistinguishable from a genuinely safe endpoint.
   WebSend already produced the explanation (proxyNote); inject_probe dropped it.
   Fix: surface result.proxyNote as a stop signal in send(), and add 407 to
   looksBlocked for a bare 407 that reaches the observation.

2. csrf_extract was the last target-facing tool outside WebSend — its own
   BackendFetch.send + Network.forUrl composition, so the fail-closed fix for a bad
   proxy.url did not reach it (raw throw out of execute()), it had no governance, and
   its rejectUnauthorized defaulted the opposite way to the others (verify-ON vs the
   shared helper's accept-bad-certs — csrf failed on a self-signed target the other
   tools accept). Route it through WebSend: proxy/TLS/fail-closed/governance/precedence
   now identical, and WebSend is the single core sender for all three attack tools.

3. proxyNote was an untyped side-channel (`as typeof result` on the write, an ad-hoc
   `as { proxyNote?: string }` on the read) — which is exactly why (1) was easy to
   miss. Add `proxyNote?: string` to Send.Result; the writer needs no cast and the
   reader references the real type, so a future rename/typo is compiler-visible.

Typecheck clean. Detailed re-test (incl. 407-on-inject_probe and csrf-through-proxy,
the coverage the first campaign missed) to follow.

Refs CyberStrikeus#102
…ks (CyberStrikeus#87 root cause 2)

collectDOMLinks only queried a[href], so on SPAs where navigation is a button with an
onclick handler (or a data-href element) it found 0 links and BFS discovery depended
entirely on the planner happening to click each button — which it does not do exhaustively,
so pages behind unclicked buttons were never visited.

Widen the query to a[href], [data-href], [onclick] and additionally extract the target from
the unambiguous navigation forms: data-href, and onclick handlers that assign location /
call location.assign / window.open. Deliberately NOT navigate()/push()/go() — those collide
with analytics.push and Array.push and would enqueue phantom pages. Extracted targets flow
through the existing enqueue path (normalizeUrl + visitedPages dedup + per-path cap), so no
new control flow. The anchor branch is unchanged, so this is purely additive to discovery.

Verified in practice: a pure button-nav SPA (no a[href], 10 views) went from 5/10 views
reached to 10/10; a normal a[href] multi-page site is unaffected (anchor path untouched);
99/99 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…directs (CyberStrikeus#87 root cause 3)

The multi-credential access-denied filter used /\/(login|…|auth)/i to decide whether a
redirect landed on a login/denied gate and should be skipped. The bare `auth` alternative
matched legitimate resources — /auth/profile, /auth/settings, even /authors/123 — so those
pages were wrongly skipped (coverage loss) and, in the per-credential access-control diff,
mislabeled.

Replace bare `auth` with `authenticate`. Real /auth/login and /auth/signin gates are still
caught via the existing login/signin terms; only bare /auth (rare, and low-cost to explore)
is no longer force-skipped. This biases toward fewer false positives, which is the right
trade — a wrongly skipped page loses coverage, while a wrongly explored login page costs one
page.

Verified against 11 URL cases: old regex produced 3 false positives (/auth/profile,
/auth/settings, /authors/123); new regex has 0 false positives and 0 false negatives (all
login/signin/auth-login/unauthorized/access-denied/authenticate gates still match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etch — fixes Copilot crawls

The Copilot request path in the hackbrowser worker subprocess set its User-Agent with a bare
`CYBERSTRIKE_VERSION` — a build-time define that the standalone worker bundle build never
injects. In the subprocess that identifier is undefined, so the header line threw
`ReferenceError: CYBERSTRIKE_VERSION is not defined` on every planner LLM call, before the
fetch even ran; planPage then returned an empty plan for every page and the crawl "completed"
immediately with no exploration. Only surfaces when a GitHub Copilot model is used — other
providers never enter this branch.

Guard it exactly like Installation.VERSION already does (installation/index.ts):
`typeof CYBERSTRIKE_VERSION === "string" ? CYBERSTRIKE_VERSION : "local"`, so it falls back to
"local" instead of throwing. The main-process chat path uses this same guarded accessor, so the
worker now matches it. No token-exchange/header changes are needed: chat and worker both
authenticate with the raw token and equivalent headers, and chat works — aligning the
User-Agent is sufficient.

Verified: the bare reference throws ReferenceError; the guarded form returns "cyberstrike/local"
with no throw; cyberstrike typecheck clean.

Related: CyberStrikeus#107.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the crawler worker

The worker built every Copilot model with sdk.languageModel(), which the Copilot SDK
maps to the Chat Completions endpoint. GPT-5+ Copilot models are only served on the
Responses API — the main process already routes them there via
shouldUseCopilotResponsesApi — so GPT-5 planner calls hit the wrong endpoint and every
plan failed. Mirror the main-process selection: GPT-5+ -> sdk.responses, others ->
sdk.chat (identical to the old languageModel for non-GPT-5, so those are unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The worker runs as a separate Node subprocess and makes its own TLS connection to the
LLM API. Node trusts only its bundled CA list, so behind a TLS-intercepting proxy / VPN
/ antivirus (a root CA the OS trusts but Node's bundle does not) the worker's LLM call
failed with UNABLE_TO_GET_ISSUER_CERT_LOCALLY -- "planPage failed: cannot connect to
API: unable to get local issuer certificate" -- while the main-process chat with the
same token worked. Merge the OS trust store into the default CA set at worker startup
(what `node --use-system-ca` does), in-process. It only adds trust, so it cannot break
a connection that already verifies; on runtimes without the APIs (bun, Node < 22.15) it
is a graceful no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rawler worker

createModelFromDescriptor now emits an info log naming the endpoint (responses / chat /
languageModel) each Copilot model routes to, so an operator can confirm from the crawl
log that GPT-5+ models use the Responses API and spot an endpoint mismatch after the
fact. Routing is unchanged; only the log line is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Only model calls went through the proxy. The OAuth refreshes that keep those
calls alive did not, nor did the device flows, nor llmhook — each is a plain
fetch of its own. Behind a proxy-only egress that meant the chat worked and the
hourly token refresh failed, reported as "oauth token refresh failed" with
nothing pointing at the proxy.

The obvious fix — a network-aware helper swapped in at each of the 12 known call
sites — was rejected. It makes coverage a matter of discipline: the next
provider plugin writes a plain fetch and silently escapes, and third-party
plugin code we do not control never had the helper to begin with. That exact
failure already happened here once: inject_probe moved onto a new send path and
went direct while the operator believed everything was proxied.

So route the process's own fetch once, from the CLI entry point:

- Network.installGlobalTransport() wraps globalThis.fetch. Every present and
  future call site is covered, including code we do not own. Nothing to remember.
- An init that already carries proxy/tls is passed through untouched — the replay
  backends route themselves and must not have policy applied twice.
- Reading the config can itself fetch (a remote well-known config), which would
  re-enter the hook and await the load it sits inside. Guarded, and direct is the
  only possible answer there: a config-derived proxy cannot route the request
  that loads that config.
- No Instance context or an unreadable config falls through to a plain fetch,
  so an early CLI path behaves exactly as if the hook were absent.
- Installed from index.ts, not on import: a global side effect at import time
  would reach unit tests and anything embedding this package.

provider.ts's own copy of the transport resolution is deleted — it is now the
same rule as everywhere else rather than a duplicate that could drift.

Renamed includeProviders to includeInternal: the setting no longer describes
providers alone but all of CyberStrike's own outbound traffic. Unreleased.

Verified on the wire (9 cases): off stays direct; on proxies a plain fetch; an
explicit per-call proxy wins with no double-apply; a tls-only init is untouched;
the bypass list and loopback still apply; no Instance context falls through;
three concurrent fetches during a first config load all complete, no deadlock.
Provider path 3/3, worker subprocess 4/4, resolver 15/15, main matrix 23/23,
suite 1663 pass (the one failure predates this branch).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… chain

The guard was a module-level boolean held across the config read. That looks
equivalent to a reentrancy check and is not: while one request resolves the
config, every other request that arrives sees the flag set and goes DIRECT.

Measured, 12 parallel fetches with the proxy configured and includeInternal on:
1 proxied, 11 silently direct — cold config and warm alike. That is exactly the
failure this whole module exists to prevent, and my own first harness missed it
because it asserted the concurrent requests COMPLETED rather than that they were
PROXIED. Completing and being proxied are different claims.

Use an AsyncLocalStorage marker instead, so "am I inside the config read?" is
answered per call chain rather than per process. Concurrent requests each get
their own scope and are all proxied; a fetch issued from within the config load
still sees the marker and goes direct, which is the only possible answer there.
Not Context (util/context.ts) because that throws when absent by design, and
this marker is optional by nature.

Verified 12/12 proxied cold and warm. Adversarial pass also covers the input
shapes and body kinds the first harness never exercised: URL and Request inputs,
a POST body byte-for-byte, a streaming request body, a streamed (SSE) response,
an init naming proxy:undefined, and a dead proxy failing closed instead of
falling back to a direct send.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Loopback was bypassed as a law, so a target on 127.0.0.1 — an app in Docker, a
staging build, something behind an SSH tunnel — could not be tested through the
proxy at all, and nothing said why: it simply never appeared in the proxy's
history. The reasons behind the law turned out to be conditional, not absolute:
"the proxy is on another machine" is usually true, not always, and with Burp on
the same host there is nothing to protect that traffic from.

So loopback becomes the DEFAULT bypass rather than a rule, and
network.proxy.includeLoopback turns it off. Default unchanged.

The rule had three consumers and they had already drifted: the predicate behind
isBypassed covered 0.0.0.0 and the whole 127.x range, while the child-process
NO_PROXY and the browser bypass list each spelled out their own shorter version.
127.0.0.2 was bypassed for replayed requests and proxied in the browser. The two
pattern lists are now one constant, so they cannot disagree again. 127.x beyond
127.0.0.1 stays out of it deliberately — NO_PROXY has no range syntax, and a
rule only half the consumers can express is the drift this replaces.

isBypassed takes the flag as a parameter instead of reading config, so it stays
pure and the config is still read in exactly one place.

All-or-nothing on purpose: turning it on covers CyberStrike's own local traffic
too, because the bypass list matches hosts and cannot separate the target's port
from the server's. Stated in the setting's description rather than papered over
with a carve-out — it only makes sense with the proxy on this machine, which is
the case that motivated it.

Verified that all three consumers move together (12 cases): resolver off/false/
true, a replayed request transiting the proxy for real, NO_PROXY emptied, the
browser's bypass list emptied, and the browser proxying a localhost page ON THE
WIRE (0 hits by default, 2 with the flag, page loads either way). A non-loopback
host is unaffected in both directions, and the flag is inert with no proxy set.

Regression: matrix 23/23, resolver 15/15, matchers 18/18, bypass parity 8/8,
attack_script 16/16, transport hook 9/9, suite 1663 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A proxy in the operator's environment quietly outranked the config. Measured on
the runtime: HTTP_PROXY is read at process start, and no per-request value means
"go direct" — undefined, "", null and false all fall back to it, "direct://" and
"http://" throw, and deleting the variable or setting NO_PROXY at runtime changes
nothing. So the config won where it NAMED a proxy and lost where it said to
bypass one. Loopback included, and that is the damaging part: the crawler posts
every captured request to our local server, so on a machine with HTTP_PROXY
exported those POSTs went to the corporate proxy and the crawl captured nothing,
with no error pointing anywhere near the proxy.

The config should win everywhere. It now does everywhere it can:

- The crawler subprocess is spawned with the config's NO_PROXY merged into its
  environment. A process cannot undo its own captured proxy, but the environment
  we hand it at spawn IS honoured — measured — and this is the process that
  builds it. Its real outbound traffic is untouched: the browser gets the proxy
  from Playwright and the model calls from their descriptor, both explicit.
- childEnv now distinguishes "no proxy configured" from "proxy turned off".
  Silence still leaves the machine's setting alone, which is right when the user
  said nothing; enabled:false now blanks it, because a script must not use a
  proxy the config explicitly disabled.
- The NO_PROXY list is built once and shared by both, rather than a third copy.

What remains cannot be fixed from inside this process — for a host the config
bypasses, the shell's proxy wins — so it is stated once at startup instead of
being left as a guarantee that does not hold, naming the proxy and how to
resolve it.

Verified (10 cases): a child with the raw environment does send loopback to the
ambient proxy, and with ours it goes direct — with a proxy configured, with none
configured, and with a bypass entry, which also reaches the child. includeLoopback
still wins where the operator asked for loopback to be proxied. enabled:false
blanks the ambient proxy and the script really goes direct. Saying nothing adds
no variables at all.

Regression: attack_script 16/16, loopback 12/12, resolver 15/15, transport hook
9/9 + 9/9 adversarial, suite 1663 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Outbound proxy configuration, second round. The first round shipped the config
surface and routed target traffic; this closes the three gaps found afterwards.

- CyberStrike's own outbound traffic (model calls, the OAuth refreshes that keep
  them alive, device flows, llmhook) now follows the proxy under one setting,
  applied by a single hook on the process's fetch rather than a helper each call
  site has to remember. Renamed includeProviders to includeInternal to match.
- network.proxy.includeLoopback lets a target on localhost be tested through the
  proxy. Off by default; the loopback rule was written in three places that had
  already drifted and is now one.
- A proxy exported in the operator's shell no longer silently outranks the
  config where it can be enforced: the crawler subprocess is spawned with the
  config's NO_PROXY, and an explicitly disabled proxy blanks the ambient one.
  What cannot be enforced from inside the process is warned about once at
  startup instead of being left as a guarantee that does not hold.

Verified on the merged tree: 1663 unit tests, plus ~180 wire-level cases across
the proxy matrix, the transport hook and its adversarial pass, includeLoopback
across all three consumers, the ambient-proxy handling, attack_script and the
crawler worker. The single failing unit test (xai/grok-3 variants) predates this
work and is unrelated.
… starts

Reported: with a proxy configured and includeInternal on, the TUI chat worked
normally and the proxy stayed completely empty. Not one request.

The hook was installed at the CLI entry point. The TUI does not do its work in
the process that entry point starts — it runs the server inside a Worker
(cli/cmd/tui/thread.ts), which is a separate realm with its own globalThis and
never executes index.ts. So the TUI went direct while the operator believed
everything was proxied. `run` and the other in-process commands were proxied,
which is why it looked like the config was being ignored rather than the hook
being absent.

This is the exact weakness I noted when adding the hook and then failed to check:
installing per entry point makes coverage depend on knowing every entry point.
There was already a second one.

Move the install into InstanceBootstrap, which every runtime path reaches —
cli/bootstrap.ts, the TUI worker, the server, and worktree — so the question
becomes "did this runtime start?" rather than "did someone remember?". The call
at the CLI entry point stays for the few commands (auth, upgrade) that reach the
network without bootstrapping a project; the install is idempotent, so the two
cannot disagree.

Verified by reproducing the failure shape: a real Worker, bootstrapped the way
the TUI worker bootstraps, now has its fetch proxied (0 hits before, 1 after) and
the request still succeeds. Hook 9/9, adversarial 9/9, final sweep 10/10,
resolver 15/15, loopback 12/12, ambient 10/10, attack_script 16/16, suite 1663.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit eec07de)
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants