Webhook Secrets¶
A Bitbucket webhook can carry two credentials, and bb treats both as things it
holds rather than things it prints.
- The shared secret signs every delivery. Bitbucket sends it as
X-Hub-Signature: sha256=…so the receiving endpoint can prove the request came from Bitbucket and not from someone who learned the URL. - The endpoint credentials are a username and password
bbnever sees the second half of. Bitbucket sends them as anAuthorization: Basic …header on every delivery, so the endpoint can authenticate the caller.
What bb publishes, and what it does not¶
Bitbucket returns the shared secret in plaintext on every read of a webhook.
bb does not pass it on. What the model publishes instead is whether one is
configured:
$ bb webhook get 42 --json
{
"data": {
"webhook": {
"id": 42,
"name": "ci",
"url": "https://ci.example.com/hooks/bitbucket",
"active": true,
"events": ["repo:refs_changed"],
"sslVerificationRequired": true,
"scopeType": "repository",
"secretConfigured": true,
"credentialsUsername": "bitbucket"
}
}
}
Three details are worth knowing:
secretConfiguredis always present.secretis not — it appears only with--reveal-secret.sslVerificationRequiredis absent when the server did not report it, rather thanfalse. Absent and "TLS verification is off" are different answers, and this is the field an audit reads.- There is no field for the endpoint password. Bitbucket never returns it, so
bbhas nothing to publish.credentialsUsernameis the only half that comes back.
bb webhook test publishes the delivery record Bitbucket produced, and that
record contains the request headers — including Authorization. bb replaces
the value with <redacted>. Base64 is not encryption: the header carries the
endpoint password, and under --json that record is the machine contract.
Recovering a secret on purpose¶
--reveal-secret prints what would otherwise be redacted. It exists so that
losing a secret does not mean going to the database, and it has to be typed:
bb webhook get 42 --json --reveal-secret # the shared secret
bb webhook test 42 --json --reveal-secret # the Authorization header
Both write a warning to stderr saying a credential went through stdout, so a log that contains one also contains the note that it does. The warning never repeats the value.
Setting a secret¶
No flag takes a secret as its value (ADR-047): a flag value lands in the process argument list, which is world-readable on Linux, and in shell history. Two routes are open instead.
From the environment — the automation path¶
| Variable | Sets |
|---|---|
BB_WEBHOOK_SECRET |
the shared secret |
BB_WEBHOOK_PASSWORD |
the endpoint password |
export BB_WEBHOOK_SECRET="$(vault read -field=value secret/bitbucket/webhook)"
bb webhook create ci https://ci.example.com/hooks/bitbucket \
--event repo:refs_changed --ssl-verification=true
This is the form to reach for in a pipeline: the secret comes from wherever the runner keeps secrets and never appears in a command line.
An empty variable counts as unset, so a typo in a variable name two files away cannot quietly configure an empty credential.
From stdin — one secret at a time¶
printf '%s' "$SECRET" | bb webhook create ci https://ci.example.com/hooks/bitbucket --secret-stdin
printf '%s' "$PASSWORD" | bb webhook update 42 --credentials-username bitbucket --credentials-password-stdin
--secret-stdin and --credentials-password-stdin cannot both be given: there
is one stdin, and bb refuses rather than guessing which secret arrived. Put
the other one in its environment variable.
A secret piped on stdin may not contain spaces, tabs or newlines. A value that arrived with a stray space is far more likely to be a piping mistake than a real credential, and the failure it causes shows up much later, at the receiving endpoint. If your secret genuinely contains whitespace, set it through the environment instead.
Precedence¶
stdin wins over the environment. Piping is the explicit act; a variable can come from a shell profile the caller has forgotten.
A flag typed on this invocation outranks the environment entirely, which is what
makes --no-secret usable on a host that exports BB_WEBHOOK_SECRET for
everything:
bb webhook update 42 --no-secret # removes it, variable or no variable
bb webhook update 42 --no-credentials # removes the endpoint credentials
Combining --no-secret with --secret-stdin is refused. One of the two has to
win and neither should.
What an update leaves alone¶
Bitbucket's update endpoint replaces the webhook rather than patching it: a
field that does not arrive is cleared. bb reads the webhook first and sends
back what you did not mention, so --name changes the name and nothing else.
This matters most for the two credentials:
- The shared secret survives because Bitbucket returns it and
bbsends it straight back. Sending an update without a configuration object clears it. - The endpoint password survives for a different reason.
bbcannot send it back — Bitbucket never returned it — so it sends the credentials object with the username alone, and Bitbucket keeps the password it already had. This is verified against a live instance by observing theAuthorizationheader on a real delivery before and after an update, because no API response can show it.
Dry runs name the variable, not the value¶
A dry run is written to stdout, is what an operator reads before applying, and is what gets pasted into a ticket when it looks wrong. It says where the secret will come from and never what it is:
$ export BB_WEBHOOK_SECRET=…
$ bb webhook create ci https://ci.example.com/hook --dry-run --json
{
"data": {
"items": [
{
"intent": "repo.webhook.create",
"target": {
"repository": "PROJ/repo",
"name": "ci",
"url": "https://ci.example.com/hook",
"secret": "will be set from $BB_WEBHOOK_SECRET"
},
"predictedAction": "create"
}
]
}
}
Naming the variable is also the more useful answer: the mistake a plan makes is
reading the wrong one. Removing a credential shows as "will be removed".
Bulk plans hold a variable name¶
A bulk plan is a file. It gets written to disk, committed, attached to a change request and read by whoever reviews it — so a literal secret in one is a secret in version control, for the same reason ADR-047 keeps secrets off the command line.
The policy therefore names the variable, and bb bulk apply reads it at
apply time:
apiVersion: bb.io/v1alpha1
selector:
projectKey: PROJ
operations:
- type: repo.webhook.create
name: ci
url: https://ci.example.com/hooks/bitbucket
events: [repo:refs_changed]
sslVerificationRequired: true
secretEnv: BB_WEBHOOK_SECRET
credentialsUsername: bitbucket
credentialsPasswordEnv: BB_WEBHOOK_PASSWORD
secretEnvandcredentialsPasswordEnvmust look like environment variable names (^[A-Za-z_][A-Za-z0-9_]*$). A pasted credential almost never does, so the mistake the field invites — readingsecretEnvas "the secret" — is refused at plan time, by the published JSON schema as you type and bybbwhen it validates the policy.- The plan file records the name only. Neither
bb bulk plannor the plan on disk carries the value. bb bulk applyrefuses when a named variable is unset, rather than creating a webhook without a secret. A webhook whose deliveries carry no signature is not a smaller version of one whose deliveries do, and finding that out from a receiver that has started rejecting everything is a bad way to find out. The recorded failure names the variable that was missing:
$ bb bulk apply --from-plan plan.json
Error: bulk apply op-4f2c… completed with failures
$ bb bulk status op-4f2c… --json | grep BB_WEBHOOK_SECRET
"secretEnv names $BB_WEBHOOK_SECRET, which is not set in this environment; export it before applying the plan"
- The same plan applies against different environments without being edited, which is the other thing a name buys over a value.
- The apply status is printed and written to the status store on disk. It
reports
credentialsUsernamefor the webhook it created, never the credentials themselves.
A create response cannot tell you whether a secret is set
Bitbucket answers identical webhook creates inconsistently: some responses
carry configuration.secret in full, others carry an empty object. Measured
against 10.4.2, that is roughly an even split.
So a bulk apply status omits secretConfigured rather than reporting
false — on a create, an empty configuration means "the server did not
say", and claiming otherwise would state a fact the payload cannot know. It
also means the apply status has to redact a secret it usually does not
receive.
To confirm a secret was configured, read the webhook: bb webhook list and
bb webhook get answer from the read endpoint, which always returns it.
Limitation: one secret per operation¶
A secretEnv belongs to an operation, and the selector applies every
operation to every repository it matches. So a plan can create several webhooks
each reading its own variable:
operations:
- type: repo.webhook.create
name: ci
url: https://ci.example.com/hooks/bitbucket
secretEnv: BB_WEBHOOK_SECRET_CI
- type: repo.webhook.create
name: audit
url: https://audit.example.com/hooks/bitbucket
secretEnv: BB_WEBHOOK_SECRET_AUDIT
— but every repository the selector matches gets the same secret for a given
operation. There is no way to say "this variable for service-a, that one for
service-b" inside one plan.
If each repository needs its own secret, that is one plan per repository (or per group of repositories sharing a secret), each applied with its own variable exported:
for repo in service-a service-b; do
BB_WEBHOOK_SECRET="$(vault read -field=value "secret/bitbucket/$repo")" \
bb bulk apply --from-plan "plans/$repo.json"
done
This is a real limitation of the plan model rather than a temporary gap: the selector exists precisely so that one operation describes many repositories, and per-repository values would make a plan stop being the reviewable artifact it is for. A plan whose effect differs per target cannot be read once and understood.
Where the fields can be set¶
The same flags are registered wherever a webhook is configured, so the answer to "can I set the shared secret here" does not depend on which command you reached for:
| Command | create | update |
|---|---|---|
bb webhook |
yes | yes |
bb project webhook |
yes | yes |
bb repo settings workflow webhooks |
yes | no update subcommand |
bb bulk (repo.webhook.create) |
yes, by variable name | n/a |