Commit Graph
6 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 ea85683796 fix(security): hoist uniform credential no_log to the include and mask bridge loops
b3ce43a596 set no_log on every task of two included files, which
test_include_uniform_apply_keyword rejects: a keyword every included task
repeats belongs in the include's apply block. The Cloudflare development
mode tasks and the ChatGPT bridge token tasks now receive no_log through
apply at their include sites. The matrix include had set no_log on the
include task itself, which never reaches the included tasks.

Two loops over MATRIX_BRIDGES were unmasked: the bridge folder creation
and the readiness gate. A loop result prints the whole item, and every item
carries the bridge's database_password, so both printed it at -v whenever
MASK_CREDENTIALS_IN_LOGS is on. The credential lint cannot see this because
the loop variable's name carries no secret marker.

Verified: test_include_uniform_apply_keyword, the credential masking lint,
the task lints and the matrix unit tests pass, and ansible-lint is clean on
the five files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 22:50:54 +02:00
kevinveenbirkenbachandClaude Opus 5 b3ce43a596 fix(security): require no_log on every task that carries a credential
At -vvv Ansible prints each module argument and result, so a password in
a command line, a request body, a header or a fact lands in the deploy
log. Nothing enforced masking outside the MCP task files, and no check
looked at parents at all.

A new lint, test_credential_tasks_are_masked, finds tasks whose printed
parts (module arguments, environment, loop) reference an identifier ending
in password, secret, token, api_key or private_key, or
secrets.credentials. A task passes when it or an enclosing block sets
no_log, when every file that pulls it in does so through include_tasks
with apply.no_log or import_tasks with no_log (transitively), when the
secret only reaches a parameter the module masks itself, or when the
module masks its values. no_log on an include_tasks task itself does not
count, because it does not reach the included tasks. The rule can be
suppressed per task with `# nocheck: credential-masking`, now listed in
the suppression catalog, and the lint tests its parent and suppression
logic on synthetic cases.

It found 39 unmasked tasks in 19 roles, all real: join tokens, client
secrets, API tokens in Cloudflare headers, database passwords in
command lines, the ChatGPT bot login body and more. Each now sets
no_log from MASK_CREDENTIALS_IN_LOGS. The sys-dns-hetzner-rdns alias
hetzner_no_log, which only restated that switch, is gone.

Not covered yet: template and copy tasks whose rendered file carries a
secret print it as a diff under --diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 21:52:18 +02:00
kevinveenbirkenbachandClaude Opus 5 907851a3ce fix(matrix): hand the realign SQL to stdin in the form the guard reads
Moving the statement into files/sql left it on stdin as a quoted scalar, and
the password-quoting guard strips stdin sub-blocks only when they are block
scalars. A quoted one falls through and is scanned as if it were shell, where
any expression naming a password must carry the quote filter - and the file is
called realign_user_password.sql.

    realign_synapse_password.yml:24: In shell tasks, password expressions must
    include '| quote': {{ lookup('file', 'sql/realign_user_password.sql') }}

Quoting it would paste literal quotes into the SQL, and renaming the file
would only hide the token from the pattern. A folded scalar is the shape the
guard already understands, and it holds whatever the file is called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 12:44:36 +02:00
kevinveenbirkenbachandClaude Opus 5 57920344bb fix(matrix): send the password realign as a script psql will interpolate
The realign statement was passed through psql -c with :'hash' and :'name'
placeholders. psql expands those only while reading a script; under -c the
string goes to the server untouched, so postgres answered

    ERROR:  syntax error at or near ":"
    LINE 1: UPDATE users SET password_hash = :'hash' WHERE name = :'name...

on every attempt, in both matrix rows of the run. The statement now arrives on
stdin through -f -, where the lexer does substitute, so the values stay
parameters instead of being pasted into SQL.

Move the statement to files/sql and read it with lookup('file', ...), the way
listmonk already provisions its MCP identity. That also makes the script form
structural: the statement now lives where only psql's script lexer reaches it,
so folding it back into -c is no longer a one-character edit.

The exec target was mode-blind as well. lookup('database', ..., 'container')
resolves the same value in every mode, while 'address' is the one that becomes
the resolver subshell under swarm; the shell evaluates it, so the quote filter
goes with it.

This alone does not turn the role green: the swarm rows die earlier, when
docker stack config rejects a compose value carrying the same kind of resolver
subshell. That is a separate defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 11:49:48 +02:00
kevinveenbirkenbachandClaude Opus 5 8cc9c86f8a fix(matrix): realign the Synapse accounts a rotation left behind
register_new_matrix_user only creates. On PASS 2 of a matrix-deploy round the administrator and the ChatGPT bridge bot already exist carrying their PASS-1 password, the call returns 'User ID already taken', and both tasks tolerate that. chatgpt_bridge_token.yml then logs in with the rotated password against an account that still holds the old one and burns ten retries, which is where job 99670614406 of run 33445831564 ended.

A new realign_synapse_password.yml hashes the declared password with Synapse's own hash_password and writes it into the users table, addressing the engine through lookup('database', application_id, 'container') the way realign_dedicated_db.yml already does. It runs for both accounts and only when the registration found them, so a first deploy never reaches it.

The hash is handed to psql as a variable and used as :'hash', so it never reaches the SQL parser as text, and it is passed through Ansible's quote filter, so the bcrypt dollar segments survive the shell. Verified by running the rendered command against a stub that prints its argv: hash=$2b$12$abc/def$ghi arrives as a single unsplit argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 23:20:35 +02:00
kevinveenbirkenbachandClaude Opus 5 945f816193 fix(matrix): give the ChatGPT bridge a session Synapse will accept
The bridge container was in a restart loop and never joined a room, so no
Matrix surface could reach the gateway. Four distinct causes, in the order
they surfaced.

MATRIX_ACCESS_TOKEN came from a credential declared with algorithm: plain,
a random string Synapse would never accept, so it resolved empty. With no
token, matrix-chatgpt-bot runs its token-fetch path, prints "Set
MATRIX_ACCESS_TOKEN to above token" and exits after 0.6s. Log the bot in
against Synapse and hand the container the token it actually issued.

compose.yml is written before the containers start, but the bot account only
exists after the Synapse health check further down the play, so the file has
to be rendered a second time once the token exists.

E2EE is disabled. The bridge uploads device keys that Synapse 1.159.0
rejects with `M_INVALID_PARAM: device_keys must not be null`, after which it
backs off forever and never completes a sync. This is a deliberate tradeoff:
bot DMs are no longer end-to-end encrypted, and the alternative is upstream
(a newer bot or an older Synapse). It hangs on MATRIX_CHATGPT_ENCRYPTION and
is one variable to flip back. The spec exercises an unencrypted room anyway,
because an E2EE timeline is opaque to an API-only client.

rc_login and rc_joins get a burst of 30. Synapse's defaults (login burst 3)
are too tight for a bridge that re-authenticates on every deploy, and the
restart loop drove them into M_LIMIT_EXCEEDED and later 429 (rc_joins.local).
This widens the burst allowance while leaving the sustained rate at 0.17/s.

The stack-host work moves into utils/stack_host_provisioning.yml so the
IS_STACK_HOST condition appears once instead of on two consecutive siblings.

Verified live: token present, container up rather than restarting, and
test-litellm-chatgpt.js passing. Not covered: a bot that meets an unjoinable
invite still crashes; 26 stale test rooms had to be cleared by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:45:09 +02:00