Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- Add `user-agent-prefix` for workflow traceability in B2 User-Agent attribution. The prefix is validated as a short ASCII, header-safe product marker and prepended before `b2-github-action/<version>`. ([#106](https://github.com/backblaze-labs/b2-action/issues/106))

## [1.1.0] - 2026-06-23

### Security
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ Set `bypass-governance: true` to shorten governance-mode retention or to remove
| `allow-bucket-purge` | purge only | `false` | Permit `purge` to target the entire bucket when `source` is empty or `/`. |
| `presign-ttl` | no | `3600` | Presigned URL TTL in seconds. Must be a positive decimal integer. |
| `endpoint` | no | | Override B2 realm (staging/custom). |
| `user-agent-prefix` | no | | Custom workflow traceability prefix prepended before the action User-Agent token. Do not include secrets. |
| `fail-on-empty` | no | `true` | Fail if an upload glob matches zero files. |
| `sse` | no | | Server-side encryption: `B2` (SSE-B2) or `C:<base64-32-byte-key>` (SSE-C). SSE-C keys must use canonical base64 and decode to exactly 32 bytes. |
| `compare-mode` | no | `modtime` | Sync comparison: `modtime` \| `size` \| `none`. |
Expand All @@ -406,6 +407,16 @@ Set `bypass-governance: true` to shorten governance-mode retention or to remove

\* Either set the input or one of the env-var fallbacks.

`user-agent-prefix`, when set, is prepended to the SDK User-Agent attribution as
`<prefix> b2-github-action/<version>`. Prefixes are limited to 128 UTF-8 bytes
and may contain only ASCII letters, digits, `/`, and these RFC 9110 token
symbols: ``!#$%&'*+-.^_`|~``. Spaces, control characters, non-ASCII
characters, surrogate characters, and other unsafe header characters are
rejected before the SDK client is constructed.

For repository/workflow traceability, use a compact product token such as
`repository/backblaze-labs-b2-action.workflow/ci.run/123456789`.

## Outputs (full reference)

| Output | When | Description |
Expand Down
1 change: 1 addition & 0 deletions __tests__/_parsed-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function makeParsedInputs(
allowBucketPurge: false,
presignTtlSeconds: 3600,
endpoint: undefined,
userAgentPrefix: undefined,
failOnEmpty: true,
sse: undefined,
encryption: undefined,
Expand Down
23 changes: 23 additions & 0 deletions __tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,29 @@ describe('client helpers', () => {
expect(constructedOptions[1]).toMatchObject({
realm: TEST_ENDPOINT,
})

await buildMockedClient({
applicationKeyId: 'mock-key-id',
applicationKey: 'mock-key',
bucket: 'trace-bucket',
userAgentPrefix: 'ci-trace/123',
})

expect(constructedOptions[2]).toMatchObject({
userAgent: expect.stringMatching(/^ci-trace\/123 b2-github-action\//),
})

await buildMockedClient({
applicationKeyId: 'mock-key-id',
applicationKey: 'mock-key',
bucket: 'empty-prefix-bucket',
userAgentPrefix: '',
})

expect(constructedOptions[3]).toMatchObject({
userAgent: expect.stringMatching(/^b2-github-action\//),
})
expect((constructedOptions[3] as { userAgent: string }).userAgent).not.toMatch(/^\s/)
})

describe('fixture-backed helpers', () => {
Expand Down
47 changes: 46 additions & 1 deletion __tests__/inputs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { collectInputSecretsForScrubbing, parseInputs } from '../src/inputs.ts'
import {
collectInputSecretsForScrubbing,
parseInputs,
parseUserAgentPrefix,
USER_AGENT_PREFIX_MAX_BYTES,
} from '../src/inputs.ts'
import { captureStdout, resetInputEnv, setInput } from './_helpers.ts'

describe('parseInputs', () => {
Expand Down Expand Up @@ -225,6 +230,46 @@ describe('parseInputs', () => {
expect(r.dryRun).toBe(true)
})

it('parses and validates a custom user-agent prefix', () => {
setInput('action', 'list')
setInput('application-key-id', 'k')
setInput('application-key', 's')
setInput('bucket', 'b')
setInput('user-agent-prefix', 'ci-trace/123')

expect(parseInputs().userAgentPrefix).toBe('ci-trace/123')
})

it.each([
['CR', 'bad\rtrace'],
['LF', 'bad\ntrace'],
['TAB', 'bad\ttrace'],
['NUL', 'bad\0trace'],
['DEL', `bad${String.fromCharCode(0x7f)}trace`],
['emoji', 'bad🚀trace'],
['surrogate', `bad${String.fromCharCode(0xd800)}trace`],
])('rejects unsafe user-agent prefix characters: %s', (_label, value) => {
expect(() => parseUserAgentPrefix(value)).toThrow(/Invalid 'user-agent-prefix'.*ASCII/)
})

it('rejects oversized user-agent prefixes', () => {
const oversized = 'a'.repeat(USER_AGENT_PREFIX_MAX_BYTES + 1)

expect(() => parseUserAgentPrefix(oversized)).toThrow(
`Invalid 'user-agent-prefix' input: ${USER_AGENT_PREFIX_MAX_BYTES + 1} bytes exceeds ${USER_AGENT_PREFIX_MAX_BYTES}.`,
)
})

it('rejects invalid user-agent prefixes before client construction', () => {
setInput('action', 'list')
setInput('application-key-id', 'k')
setInput('application-key', 's')
setInput('bucket', 'b')
setInput('user-agent-prefix', 'bad trace')

expect(() => parseInputs()).toThrow(/Invalid 'user-agent-prefix'/)
})

it('keeps an empty purge source only when whole-bucket purge is confirmed', () => {
setInput('action', 'purge')
setInput('application-key-id', 'k')
Expand Down
34 changes: 34 additions & 0 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,40 @@ describe('main dispatcher', () => {
})
})

it('passes user-agent prefix only when the parsed input supplies one', async () => {
Comment thread
goanpeca marked this conversation as resolved.
Comment thread
goanpeca marked this conversation as resolved.
const ctx = await loadMain()
const files = [
listedFile({
fileName: 'trace.txt',
fileId: 'id-trace',
size: 12,
}),
]
ctx.parseInputs.mockReturnValue(inputs('list', { userAgentPrefix: 'ci-trace/123' }))
ctx.commands.listCommand.mockResolvedValue({ files, truncated: false })

await ctx.run()

expect(ctx.buildClient).toHaveBeenCalledWith({
applicationKeyId: TEST_APPLICATION_KEY_ID,
applicationKey: TEST_APPLICATION_KEY,
bucket: DISPATCH_BUCKET,
userAgentPrefix: 'ci-trace/123',
})

const withoutPrefix = await loadMain()
withoutPrefix.parseInputs.mockReturnValue(inputs('list'))
withoutPrefix.commands.listCommand.mockResolvedValue({ files, truncated: false })

await withoutPrefix.run()

expect(withoutPrefix.buildClient).toHaveBeenCalledWith({
applicationKeyId: TEST_APPLICATION_KEY_ID,
applicationKey: TEST_APPLICATION_KEY,
bucket: DISPATCH_BUCKET,
})
})

it('renders copy summaries with source and destination URLs', async () => {
const ctx = await loadMain()
setupSuccessfulAction(ctx, 'copy')
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ inputs:
endpoint:
description: 'Override the B2 realm endpoint. Leave empty for production.'
required: false
user-agent-prefix:
description: "Optional workflow traceability prefix prepended before b2-github-action/<version> with a separating space. Maximum 128 UTF-8 bytes; use only ASCII letters, digits, '/', and RFC 9110 token symbols (!#$%&'*+-.^_`|~). Control characters, spaces, non-ASCII, surrogate characters, and other unsafe header characters are rejected. Do not include secrets."
required: false
fail-on-empty:
description: 'Fail the action if an upload glob matches zero files (default true).'
required: false
Expand Down
49 changes: 45 additions & 4 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39003,9 +39003,10 @@ class SecretMaskingAccountInfo extends InMemoryAccountInfo {
* Build an authorized B2Client.
*
* Steps:
* 1. Construct the client with `userAgent: 'b2-github-action/<version>'`. The
* SDK preserves its own `b2-sdk-typescript/` and `@backblaze-labs/b2-sdk` tokens before
* ours so Backblaze server-side logs see both attribution layers.
* 1. Construct the client with `userAgent: 'b2-github-action/<version>'`, optionally
* prefixed by a caller-provided workflow marker. The SDK preserves its own
* `b2-sdk-typescript/` and `@backblaze-labs/b2-sdk` tokens before ours so
* Backblaze server-side logs see both attribution layers.
* 2. `await client.authorize()`. This is one-shot for the lifetime of the
* action invocation. B2 auth tokens carry a 24h TTL; typical GitHub
* Actions runs finish well inside that window. If a long-running job
Expand All @@ -39021,7 +39022,10 @@ class SecretMaskingAccountInfo extends InMemoryAccountInfo {
* default FetchTransport with its built-in SSRF guard.
*/
async function buildClient(options) {
const userAgent = `b2-github-action/${version_VERSION}`;
const actionUserAgent = `b2-github-action/${version_VERSION}`;
const userAgent = options.userAgentPrefix
? `${options.userAgentPrefix} ${actionUserAgent}`
: actionUserAgent;
const client = new B2Client({
applicationKeyId: options.applicationKeyId,
applicationKey: options.applicationKey,
Expand Down Expand Up @@ -39190,6 +39194,9 @@ const CONTENT_HEADER_FILE_INFO_KEYS = [
['content-language', 'b2-content-language'],
['expires', 'b2-expires'],
];
/** Maximum UTF-8 byte length accepted for the `user-agent-prefix` input. */
const USER_AGENT_PREFIX_MAX_BYTES = 128;
const USER_AGENT_PREFIX_ALLOWED_SYMBOLS = "!#$%&'*+-.^_`|~/";
const inputs_utf8Encoder = new TextEncoder();
/**
* Sensitive raw values that can appear in parser-scope errors before
Expand Down Expand Up @@ -39244,6 +39251,7 @@ function parseInputs() {
const presignTtlSeconds = parsePositiveInt('presign-ttl', getInput('presign-ttl') || '3600');
const maxResults = parsePositiveInt('max-results', getInput('max-results') || '1000');
const endpoint = optional('endpoint');
const userAgentPrefix = parseUserAgentPrefix(optional('user-agent-prefix'));
const sse = optional('sse');
const encryption = parseSse(sse);
const contentType = optional('content-type');
Expand Down Expand Up @@ -39283,6 +39291,7 @@ function parseInputs() {
allowBucketPurge,
presignTtlSeconds,
endpoint,
userAgentPrefix,
failOnEmpty,
sse,
encryption,
Expand Down Expand Up @@ -39344,6 +39353,37 @@ function optional(name) {
const v = getInput(name);
return v === '' ? undefined : v;
}
/**
* Validate the optional `user-agent-prefix` input before it reaches the SDK
* transport and return the prefix when it is present.
*/
function parseUserAgentPrefix(value) {
if (value === undefined)
return undefined;
const byteLength = Buffer.byteLength(value, 'utf8');
if (byteLength > USER_AGENT_PREFIX_MAX_BYTES) {
throw new Error(`Invalid 'user-agent-prefix' input: ${byteLength} bytes exceeds ${USER_AGENT_PREFIX_MAX_BYTES}.`);
}
if (!isUserAgentPrefixSafe(value)) {
throw new Error("Invalid 'user-agent-prefix' input: use only ASCII letters, digits, '/', and RFC 9110 token symbols (!#$%&'*+-.^_`|~).");
}
return value;
}
function isUserAgentPrefixSafe(value) {
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code >= 0x30 && code <= 0x39)
continue;
if (code >= 0x41 && code <= 0x5a)
continue;
if (code >= 0x61 && code <= 0x7a)
continue;
if (USER_AGENT_PREFIX_ALLOWED_SYMBOLS.includes(value[i] ?? ''))
continue;
return false;
}
return true;
}
function optionalSource(action, allowBucketPurge) {
const v = getInput('source');
if (v !== '')
Expand Down Expand Up @@ -49538,6 +49578,7 @@ async function run() {
applicationKey: inputs.applicationKey,
bucket: inputs.bucket,
...(inputs.endpoint !== undefined ? { endpoint: inputs.endpoint } : {}),
...(inputs.userAgentPrefix !== undefined ? { userAgentPrefix: inputs.userAgentPrefix } : {}),
});
const authToken = authorized.client.accountInfo.getAuthToken();
if (authToken)
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface BuildClientOptions {
bucket: string
/** Override the default B2 realm endpoint. Only set for staging / custom realms. */
endpoint?: string | undefined
/** Optional User-Agent prefix for workflow traceability. */
userAgentPrefix?: string | undefined
/** Inject a custom transport (used by tests with the SDK's `B2Simulator`). */
transport?: HttpTransport | undefined
}
Expand All @@ -53,9 +55,10 @@ class SecretMaskingAccountInfo extends InMemoryAccountInfo {
* Build an authorized B2Client.
*
* Steps:
* 1. Construct the client with `userAgent: 'b2-github-action/<version>'`. The
* SDK preserves its own `b2-sdk-typescript/` and `@backblaze-labs/b2-sdk` tokens before
* ours so Backblaze server-side logs see both attribution layers.
* 1. Construct the client with `userAgent: 'b2-github-action/<version>'`, optionally
* prefixed by a caller-provided workflow marker. The SDK preserves its own
* `b2-sdk-typescript/` and `@backblaze-labs/b2-sdk` tokens before ours so
* Backblaze server-side logs see both attribution layers.
* 2. `await client.authorize()`. This is one-shot for the lifetime of the
* action invocation. B2 auth tokens carry a 24h TTL; typical GitHub
* Actions runs finish well inside that window. If a long-running job
Expand All @@ -71,7 +74,10 @@ class SecretMaskingAccountInfo extends InMemoryAccountInfo {
* default FetchTransport with its built-in SSRF guard.
*/
export async function buildClient(options: BuildClientOptions): Promise<AuthorizedClient> {
const userAgent = `b2-github-action/${VERSION}`
const actionUserAgent = `b2-github-action/${VERSION}`
const userAgent = options.userAgentPrefix
? `${options.userAgentPrefix} ${actionUserAgent}`
: actionUserAgent

const client = new B2Client({
applicationKeyId: options.applicationKeyId,
Expand Down
39 changes: 39 additions & 0 deletions src/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ const CONTENT_HEADER_FILE_INFO_KEYS = [
['content-language', 'b2-content-language'],
['expires', 'b2-expires'],
] as const
/** Maximum UTF-8 byte length accepted for the `user-agent-prefix` input. */
export const USER_AGENT_PREFIX_MAX_BYTES = 128
const USER_AGENT_PREFIX_ALLOWED_SYMBOLS = "!#$%&'*+-.^_`|~/"
const utf8Encoder = new TextEncoder()

/**
Expand Down Expand Up @@ -152,6 +155,8 @@ export interface ParsedInputs {
presignTtlSeconds: number
/** Override B2 realm endpoint for staging / custom realms. */
endpoint: string | undefined
/** Optional User-Agent prefix for workflow traceability. */
userAgentPrefix: string | undefined
/** Fail the action when upload/sync matches zero files. */
failOnEmpty: boolean
/** Raw `sse:` input value as the user typed it. Retained for diagnostics. */
Expand Down Expand Up @@ -246,6 +251,7 @@ export function parseInputs(): ParsedInputs {
const maxResults = parsePositiveInt('max-results', core.getInput('max-results') || '1000')

const endpoint = optional('endpoint')
const userAgentPrefix = parseUserAgentPrefix(optional('user-agent-prefix'))
const sse = optional('sse')
const encryption = parseSse(sse)

Expand Down Expand Up @@ -308,6 +314,7 @@ export function parseInputs(): ParsedInputs {
allowBucketPurge,
presignTtlSeconds,
endpoint,
userAgentPrefix,
failOnEmpty,
sse,
encryption,
Expand Down Expand Up @@ -382,6 +389,38 @@ function optional(name: string): string | undefined {
return v === '' ? undefined : v
}

/**
* Validate the optional `user-agent-prefix` input before it reaches the SDK
* transport and return the prefix when it is present.
*/
export function parseUserAgentPrefix(value: string | undefined): string | undefined {
if (value === undefined) return undefined
const byteLength = Buffer.byteLength(value, 'utf8')
if (byteLength > USER_AGENT_PREFIX_MAX_BYTES) {
throw new Error(
`Invalid 'user-agent-prefix' input: ${byteLength} bytes exceeds ${USER_AGENT_PREFIX_MAX_BYTES}.`,
)
}
if (!isUserAgentPrefixSafe(value)) {
throw new Error(
"Invalid 'user-agent-prefix' input: use only ASCII letters, digits, '/', and RFC 9110 token symbols (!#$%&'*+-.^_`|~).",
)
}
return value
}

function isUserAgentPrefixSafe(value: string): boolean {
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i)
if (code >= 0x30 && code <= 0x39) continue
if (code >= 0x41 && code <= 0x5a) continue
if (code >= 0x61 && code <= 0x7a) continue
if (USER_AGENT_PREFIX_ALLOWED_SYMBOLS.includes(value[i] ?? '')) continue
return false
}
return true
}

function optionalSource(action: ActionName, allowBucketPurge: boolean): string | undefined {
const v = core.getInput('source')
if (v !== '') return v
Expand Down
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export async function run(): Promise<void> {
applicationKey: inputs.applicationKey,
bucket: inputs.bucket,
...(inputs.endpoint !== undefined ? { endpoint: inputs.endpoint } : {}),
...(inputs.userAgentPrefix !== undefined ? { userAgentPrefix: inputs.userAgentPrefix } : {}),
})
const authToken = authorized.client.accountInfo.getAuthToken()
if (authToken) registerSecretValue(secretValues, authToken)
Expand Down
Loading