Risk Assessment: Medium (no secrets found; one permissions gap that contradicts DECISIONS.md, one privacy item for the first push, the rest Low)
Status (2026-09-29): every finding below is fixed and covered by a scenario test (S25-S33 in
tests/SCENARIOS.md); the commit e-mail was replaced with a GitHub noreply address before the
first push. Observations were left as they are unless noted.
Scope: src/flx/*, tests/*, README.md, .github/workflows/test.yml, pyproject.toml, all 12 commits
(git log -p --all), and the live ~/.config/flx on the dev machine.
-
Config dir permissions not enforced; README creates it group-writable (Severity: Medium)
- Location:
src/flx/files.py:51(ensure_config),src/flx/trace.py:83(_write),src/flx/files.py:106-110(save_seen),README.md:54 - The Exploit: DECISIONS.md promises a
700config dir, butmkdir(mode=0o700, exist_ok=True)does nothing to a dir that already exists. The README tells users to runmkdir -p ~/.config/flxbefore the firstflxrun, so with the commonumask 002the dir is born775. Verified on the dev machine:~/.config/flxis775,traces/775, trace files664(only.env.localis600, thanks to the README'schmod). On a machine where the user's group has other members (shared servers,umask 002+ shared group), any group member can: plant or replace.env.local/keywords.txt; read traces (search history); pre-createseen.json.tmpas a symlink, and the nextscan --only-new(tmp.write_textfollows symlinks) overwrites the linked file of the victim with JSON. Theprintf ... > .env.localstep also leaves the token664for a moment beforechmod 600. Local exposure on this machine: none (the user's own group has no other members), but every other user of the published tool inherits the gap. - The Fix:
README:
# files.py, ensure_config config.mkdir(mode=0o700, parents=True, exist_ok=True) if config.stat().st_uid == os.getuid() and config.stat().st_mode & 0o077: os.chmod(config, 0o700) # tighten a dir created by `mkdir -p` or an old flx # trace.py, _write path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) # files.py, save_seen: open the tmp file with O_CREAT|O_EXCL|O_NOFOLLOW (or unlink it first)
mkdir -p -m 700 ~/.config/flxand write the token under(umask 077; read -rs T && printf ... > ...). Optional: warn when.env.localis group/world-readable (like ssh does). Needs a scenario + test (dir pre-created775becomes700).
- Location:
-
Commit author e-mail becomes public on first push (Severity: Medium, privacy)
- Location: all 12 commits, author and committer carry a personal e-mail address
- The Exploit: a public repo exposes the address to anyone (
git log, GitHub API,.patchURLs); scrapers harvest it for spam/phishing and it links this project to the personal account. Cannot be undone after the push without a force-push, and forks/caches keep it. - The Fix: decide before the first push. Either accept it, or set
git config user.email <id>+<user>@users.noreply.github.comand rewrite the 12 local commits (git rebase -r --root --exec 'git commit --amend --no-edit --reset-author') while nothing is published yet. Enable "Block command line pushes that expose my email" on GitHub.
-
Unsanitised API text reaches the terminal via
scanwarnings (Severity: Low)- Location:
src/flx/commands.py:47-49(Context.warn), called fromcommands.py:91 - The Exploit:
ApiErrormessages embedmessage[:200]from the server's error body (client.py:254-256).cli.mainprints errors throughclean_line(tracer.scrub(...)), butwarnprints raw, so a hostile or compromised API response (or a future endpoint that echoes user content) can put ANSI/OSC escapes on stderr: rewrite earlier lines, set the window title, or emit OSC 52 clipboard writes on terminals that allow it. Breaks the terminal-injection rule in DECISIONS.md for this one path. - The Fix:
print(f"flx: {render.clean_line(self.tracer.scrub(message))}", file=sys.stderr)inwarn; add a scenario (API error body with\x1b[2Jduringscandoes not reach stderr raw).
- Location:
-
--debugstderr copy passes C1 controls and bidi/zero-width characters (Severity: Low)- Location:
src/flx/trace.py:63-66 - The Exploit:
json.dumps(..., ensure_ascii=False)escapes C0 (\x1b) but emits U+0080-U+009F (U+009B is an 8-bit CSI on some terminals), U+202E (right-to-left override) and zero-width characters verbatim. Trace records carry server error messages and the--debugcopy goes straight to the terminal;cat-ing a trace file has the same effect. - The Fix: use
ensure_ascii=True(at least for the stderr copy; simplest for both, traces stay valid JSON and greppable).
- Location:
-
Current-directory overrides trust whatever folder
flxruns in (Severity: Low)- Location:
src/flx/auth.py:30,57-63,src/flx/files.py:59-85 - The Exploit: running
flxinside an untrusted checkout: (a) a planted.env.localsilently wins over the user's own token (session confusion,whoamishows the attacker's account); (b).env.localas a symlink to/dev/zeroor a FIFO makesread_texthang or eat all memory (keywords.txtis protected byis_file(),.env.localis not); (c) a plantedkeywords.txtwith thousands of lines makesscansend thousands of GETs under the user's token (one per second, for hours), risking rate limiting or an API ban on the user's account. The token itself cannot leak (host is fixed), so this is nuisance/DoS only. - The Fix: check
is_file()and a size cap (e.g. 64 KiB) before reading.env.local/keywords.txt; cap the keyword count (e.g. 50) with a clear error. Changing whether cwd files override is a DECISIONS.md item; ask Meriç first.
- Location:
-
Unbounded response read and uncaught
RecursionError(Severity: Low)- Location:
src/flx/client.py:160,188 - The Exploit:
response.read()has no size limit, andjson.loadson deeply nested JSON raisesRecursionError, which is not aValueError, so it escapes as a traceback. Needs a hostile server behind valid TLS forwww.freelancer.com; robustness more than security. - The Fix:
response.read(MAX_BODY + 1)with e.g.MAX_BODY = 10 MiB->BadResponseErrorif exceeded;except (ValueError, RecursionError)aroundjson.loads.
- Location:
- Secrets: clean. No token or credential in the working tree or in any commit (
git log -p --allscanned forFREELANCER_TOKEN=assignments and long opaque strings; only commit hashes and example slugs)..env*is ignored.keywords.txtwas committed once and later removed; it holds only search terms. - Examples and tests are synthetic: made-up ids (
40000001-3),meric@example.com; no real project, user or client data. - Verified good: GET-only enforced before a request is built; redirects become errors (
_NoRedirect); TLS verified (urllib default context); token validated (check_token) so no header injection; token masked before truncation in traces and in CLI errors; query params URL-encoded (quote_via=quote); project id[0-9]{1,12}, ASCII only;ensure_configusesopen("x")so it never follows a planted symlink;Retry-Aftercapped and NaN/inf-safe;--jsonusesensure_ascii=True. - CI hardening:
permissions: contents: readandpull_request(notpull_request_target) are right. Actions are pinned to tags (@v4,@v5); pinning to commit SHAs guards against a hijacked tag. - Traces grow forever and record every search query. Not secret, but a retention cap (e.g. delete files older than 30 days) or a README note keeps the history bounded.
seen.json.tmphas a fixed name: two concurrentscan --only-newruns race (last writer wins). Harmless;tempfile.NamedTemporaryFile(dir=config, delete=False)would fix it together with the symlink item above.- Proxy env vars (
https_proxy) are honoured by urllib; the token then travels inside the proxy's CONNECT tunnel, still under TLS. Fine, worth one README line.