Publish DevRunbook source
Managed validation / full (push) Successful in 3m18s

This commit is contained in:
DevRunbook release export
2026-09-03 04:09:17 +02:00
commit cfd2804e27
928 changed files with 161642 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@devrunbook/integrations",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint src --max-warnings=0",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "5.9.3",
"vitest": "4.1.10"
}
}
+108
View File
@@ -0,0 +1,108 @@
export type ForgeCapabilityName =
| 'repositories'
| 'repository-metadata'
| 'branches'
| 'tags'
| 'releases'
| 'contents'
| 'branch-protection'
| 'templates'
| 'workflows'
| 'topics'
| 'languages'
| 'permissions'
export type ForgeCapabilityStatus =
'supported' | 'unsupported' | 'forbidden' | 'temporarily-unavailable'
export interface ForgeCapabilityState {
readonly status: ForgeCapabilityStatus
readonly checkedAt: string
readonly errorCode?: ForgeSafeErrorCode
}
export type ForgeSafeErrorCode =
| 'AUTH_INVALID'
| 'PERMISSION_MISSING'
| 'CAPABILITY_UNSUPPORTED'
| 'RATE_LIMITED'
| 'NETWORK_BLOCKED'
| 'TLS_ERROR'
| 'REMOTE_UNAVAILABLE'
| 'CONTENT_TOO_LARGE'
| 'RESPONSE_INVALID'
export interface ForgeRepositoryRef {
readonly owner: string
readonly name: string
}
export interface ForgeRepository extends ForgeRepositoryRef {
readonly id: string
readonly fullName: string
readonly defaultBranch: string | null
readonly archived: boolean
readonly private: boolean
readonly htmlUrl: string | null
}
export interface ForgeRepositoryPage {
readonly items: readonly ForgeRepository[]
readonly nextCursor: string | null
}
export interface ForgeBranch {
readonly name: string
readonly commitSha: string
readonly protected: boolean | null
}
export interface ForgeTreeEntry {
readonly path: string
readonly kind: 'file' | 'directory' | 'other'
readonly sha: string | null
readonly size: number | null
}
export interface ForgeFile {
readonly path: string
readonly sha: string | null
readonly size: number
readonly bytes: Uint8Array
}
export interface ForgeConnectionResult {
readonly serverVersion: string
readonly identity: { readonly id: string; readonly login: string }
readonly capabilities: Readonly<
Partial<Record<ForgeCapabilityName, ForgeCapabilityState>>
>
}
export interface ForgeAdapter {
testConnection(): Promise<ForgeConnectionResult>
getCapabilities(
repository: ForgeRepositoryRef,
): Promise<Readonly<Record<ForgeCapabilityName, ForgeCapabilityState>>>
listRepositories(
cursor?: string | null,
query?: string,
): Promise<ForgeRepositoryPage>
getRepository(repository: ForgeRepositoryRef): Promise<ForgeRepository>
listTree(
repository: ForgeRepositoryRef,
ref: string,
page?: number,
): Promise<readonly ForgeTreeEntry[]>
getFile(
repository: ForgeRepositoryRef,
ref: string,
path: string,
sizeLimit: number,
): Promise<ForgeFile>
getBranches(repository: ForgeRepositoryRef): Promise<readonly ForgeBranch[]>
getTags(repository: ForgeRepositoryRef): Promise<readonly string[]>
getReleases(repository: ForgeRepositoryRef): Promise<readonly string[]>
getGovernanceEvidence(repository: ForgeRepositoryRef): Promise<unknown>
getWorkflowEvidence(repository: ForgeRepositoryRef): Promise<unknown>
}
@@ -0,0 +1,251 @@
import { describe, expect, it } from 'vitest'
import { GiteaAdapter, GiteaClient, GiteaRequestError } from './gitea-client'
import type { SafeHttpClient, SafeHttpResponse } from './safe-http-client'
const repository = {
id: 42,
owner: { login: 'acme' },
name: 'widget',
full_name: 'acme/widget',
default_branch: 'main',
archived: false,
private: true,
html_url: 'https://git.example/acme/widget',
}
function json(
status: number,
value: unknown,
headers: Record<string, string> = {},
): SafeHttpResponse {
return {
status,
headers: { 'content-type': 'application/json; charset=utf-8', ...headers },
body: Buffer.from(JSON.stringify(value)),
}
}
class ScriptedHttp implements Pick<SafeHttpClient, 'get'> {
readonly urls: string[] = []
readonly headers: Readonly<Record<string, string>>[] = []
constructor(
private readonly responder: (
url: URL,
call: number,
) => SafeHttpResponse | Promise<SafeHttpResponse>,
) {}
async get(url: URL, headers: Readonly<Record<string, string>> = {}) {
this.urls.push(url.toString())
this.headers.push(headers)
return this.responder(url, this.urls.length)
}
}
function client(http: Pick<SafeHttpClient, 'get'>, token = 'secret-token') {
return new GiteaClient({
baseUrl: 'https://git.example/gitea/',
token,
networkPolicy: { privateNetworkPolicy: 'deny' },
http,
pageSize: 2,
})
}
describe('GiteaClient', () => {
it('uses normalized literal GET endpoints and paginates with an opaque local cursor', async () => {
const http = new ScriptedHttp((url) => {
if (url.pathname.endsWith('/version'))
return json(200, { version: '1.25.4' })
if (url.pathname.endsWith('/user'))
return json(200, { id: 7, login: 'reader' })
return json(
200,
{
data: [
repository,
{
...repository,
id: 43,
name: 'widget-2',
full_name: 'acme/widget-2',
},
],
},
{ 'x-total-count': '3' },
)
})
const target = client(http)
expect(await target.getVersion()).toBe('1.25.4')
const user = await target.getCurrentUser()
const first = await target.listRepositories(user.id, null, 'wid get')
expect(first.items[0]).toMatchObject({
id: '42',
owner: 'acme',
name: 'widget',
private: true,
})
expect(first.nextCursor).toBeTruthy()
await target.listRepositories(user.id, first.nextCursor, 'wid get')
expect(http.urls[0]).toBe('https://git.example/gitea/api/v1/version')
const search = new URL(http.urls[2]!)
expect(search.pathname).toBe('/gitea/api/v1/repos/search')
expect(Object.fromEntries(search.searchParams)).toMatchObject({
uid: '7',
private: 'true',
exclusive: 'false',
sort: 'alpha',
order: 'asc',
page: '1',
limit: '2',
q: 'wid get',
})
expect(new URL(http.urls[3]!).searchParams.get('page')).toBe('2')
expect(http.headers[0]!.authorization).toBe('token secret-token')
expect(http.headers[1]!.authorization).toBe('token secret-token')
expect(JSON.stringify(target)).not.toContain('secret-token')
})
it('rejects cursor substitution and unsafe file paths before transport', async () => {
const http = new ScriptedHttp(() => json(200, { data: [] }))
const target = client(http)
const cursor = Buffer.from(
JSON.stringify({ page: 2, query: 'one' }),
).toString('base64url')
await expect(target.listRepositories('7', cursor, 'two')).rejects.toThrow(
'cursor is invalid',
)
await expect(
target.getFile({ owner: 'acme', name: 'widget' }, 'main', '../.env', 10),
).rejects.toThrow('file path is invalid')
expect(http.urls).toHaveLength(0)
})
it('decodes bounded file content and rejects size inconsistencies', async () => {
const http = new ScriptedHttp((_url, call) =>
call === 1
? json(200, {
path: 'README.md',
sha: 'abc',
size: 5,
encoding: 'base64',
content: 'aGVsbG8=',
})
: json(200, {
path: 'README.md',
sha: 'abc',
size: 2,
encoding: 'base64',
content: 'aGVsbG8=',
}),
)
const target = client(http)
const file = await target.getFile(
{ owner: 'acme', name: 'widget' },
'deadbeef',
'README.md',
5,
)
expect(Buffer.from(file.bytes).toString()).toBe('hello')
await expect(
target.getFile(
{ owner: 'acme', name: 'widget' },
'deadbeef',
'README.md',
5,
),
).rejects.toMatchObject({ code: 'RESPONSE_INVALID' })
expect(http.urls[0]).toContain('/contents/README.md?ref=deadbeef')
})
it('treats Gitea null tree pages after the first page as pagination exhaustion', async () => {
const http = new ScriptedHttp(() =>
json(200, { sha: 'deadbeef', tree: null, truncated: false }),
)
await expect(
client(http).listTree({ owner: 'acme', name: 'widget' }, 'main', 2),
).resolves.toEqual([])
await expect(
client(http).listTree({ owner: 'acme', name: 'widget' }, 'main', 1),
).rejects.toMatchObject({ code: 'RESPONSE_INVALID' })
})
it.each([
[401, 'AUTH_INVALID'],
[403, 'PERMISSION_MISSING'],
[404, 'CAPABILITY_UNSUPPORTED'],
[429, 'RATE_LIMITED'],
[503, 'REMOTE_UNAVAILABLE'],
] as const)(
'maps status %s to %s without upstream body disclosure',
async (status, code) => {
const http = new ScriptedHttp(() =>
json(status, { message: 'token secret-token internal stack' }),
)
const error = await client(http)
.getCurrentUser()
.catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(GiteaRequestError)
expect(error).toMatchObject({ code, status })
expect((error as Error).message).not.toContain('secret-token')
},
)
})
describe('GiteaAdapter capability degradation', () => {
it('records optional failures independently while retaining supported capabilities', async () => {
const http = new ScriptedHttp((url) => {
const path = url.pathname
if (path.endsWith('/version')) return json(200, { version: '1.25.4' })
if (path.endsWith('/user')) return json(200, { id: 7, login: 'reader' })
if (path.endsWith('/repos/search'))
return json(200, { data: [repository] })
if (path.endsWith('/branch_protections')) return json(404, {})
if (path.endsWith('/actions/workflows')) return json(403, {})
if (path.includes('/releases')) return json(429, {})
if (path.endsWith('/branches'))
return json(200, [
{ name: 'main', commit: { id: 'abc' }, protected: true },
])
if (path.endsWith('/tags')) return json(200, [{ name: 'v1.0.0' }])
if (path.endsWith('/topics')) return json(200, { topics: ['typescript'] })
if (path.endsWith('/languages')) return json(200, { TypeScript: 10 })
return json(200, repository)
})
const adapter = new GiteaAdapter(
client(http),
() => new Date('2026-07-27T12:00:00Z'),
)
const connection = await adapter.testConnection()
expect(connection).toMatchObject({
serverVersion: '1.25.4',
identity: { id: '7', login: 'reader' },
})
const capabilities = await adapter.getCapabilities({
owner: 'acme',
name: 'widget',
})
expect(capabilities['repository-metadata'].status).toBe('supported')
expect(capabilities['branch-protection']).toMatchObject({
status: 'unsupported',
errorCode: 'CAPABILITY_UNSUPPORTED',
})
expect(capabilities.workflows).toMatchObject({
status: 'forbidden',
errorCode: 'PERMISSION_MISSING',
})
expect(capabilities.releases).toMatchObject({
status: 'temporarily-unavailable',
errorCode: 'RATE_LIMITED',
})
expect(capabilities.contents.status).toBe('supported')
expect(
http.urls.every((value) =>
new URL(value).pathname.startsWith('/gitea/api/v1/'),
),
).toBe(true)
})
})
+643
View File
@@ -0,0 +1,643 @@
import {
type ForgeAdapter,
type ForgeBranch,
type ForgeCapabilityName,
type ForgeCapabilityState,
type ForgeConnectionResult,
type ForgeFile,
type ForgeRepository,
type ForgeRepositoryPage,
type ForgeRepositoryRef,
type ForgeSafeErrorCode,
type ForgeTreeEntry,
} from './forge-adapter'
import {
normalizeGiteaBaseUrl,
type GiteaNetworkPolicy,
NetworkPolicyError,
} from './network-policy'
import {
SafeHttpClient,
SafeHttpError,
type SafeHttpResponse,
} from './safe-http-client'
export class GiteaRequestError extends Error {
constructor(
readonly code: ForgeSafeErrorCode,
readonly status: number | null,
message: string,
) {
super(message)
this.name = 'GiteaRequestError'
}
}
type JsonObject = Record<string, unknown>
function object(value: unknown, context: string): JsonObject {
if (value === null || Array.isArray(value) || typeof value !== 'object') {
throw new GiteaRequestError(
'RESPONSE_INVALID',
null,
`${context} was not an object`,
)
}
return value as JsonObject
}
function string(value: unknown, context: string): string {
if (typeof value !== 'string') {
throw new GiteaRequestError(
'RESPONSE_INVALID',
null,
`${context} was not a string`,
)
}
return value
}
function number(value: unknown, context: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new GiteaRequestError(
'RESPONSE_INVALID',
null,
`${context} was not a number`,
)
}
return value
}
function parseJson(response: SafeHttpResponse): unknown {
const contentType = response.headers['content-type']?.toLowerCase() ?? ''
if (!contentType.includes('application/json')) {
throw new GiteaRequestError(
'RESPONSE_INVALID',
response.status,
'Gitea response was not JSON',
)
}
try {
return JSON.parse(Buffer.from(response.body).toString('utf8')) as unknown
} catch {
throw new GiteaRequestError(
'RESPONSE_INVALID',
response.status,
'Gitea returned invalid JSON',
)
}
}
function statusError(response: SafeHttpResponse): GiteaRequestError {
const code: ForgeSafeErrorCode =
response.status === 401
? 'AUTH_INVALID'
: response.status === 403
? 'PERMISSION_MISSING'
: response.status === 404
? 'CAPABILITY_UNSUPPORTED'
: response.status === 429
? 'RATE_LIMITED'
: response.status >= 500
? 'REMOTE_UNAVAILABLE'
: 'RESPONSE_INVALID'
return new GiteaRequestError(
code,
response.status,
`Gitea request failed with status ${response.status}`,
)
}
function mappedError(error: unknown): GiteaRequestError {
if (error instanceof GiteaRequestError) return error
if (error instanceof NetworkPolicyError)
return new GiteaRequestError('NETWORK_BLOCKED', null, error.message)
if (error instanceof SafeHttpError)
return new GiteaRequestError(error.code, null, error.message)
return new GiteaRequestError(
'REMOTE_UNAVAILABLE',
null,
'Gitea request failed',
)
}
function repositoryFrom(value: unknown): ForgeRepository {
const item = object(value, 'Gitea repository')
const owner = object(item.owner, 'Gitea repository owner')
return {
id: String(number(item.id, 'Gitea repository id')),
owner: string(
owner.login ?? owner.username,
'Gitea repository owner login',
),
name: string(item.name, 'Gitea repository name'),
fullName: string(item.full_name, 'Gitea repository full name'),
defaultBranch:
typeof item.default_branch === 'string' ? item.default_branch : null,
archived: item.archived === true,
private: item.private === true,
htmlUrl: typeof item.html_url === 'string' ? item.html_url : null,
}
}
function encodeSegment(value: string, label: string): string {
if (
value.length === 0 ||
value.length > 255 ||
[...value].some((character) => {
const codePoint = character.codePointAt(0)!
return codePoint <= 0x1f || codePoint === 0x7f
})
) {
throw new TypeError(`${label} is invalid`)
}
return encodeURIComponent(value)
}
function encodeFilePath(path: string): string {
if (path.startsWith('/') || path.includes('\\'))
throw new TypeError('Gitea file path is invalid')
const segments = path.split('/')
if (
segments.some(
(segment) => segment === '' || segment === '.' || segment === '..',
)
) {
throw new TypeError('Gitea file path is invalid')
}
return segments
.map((segment) => encodeSegment(segment, 'Gitea file path'))
.join('/')
}
interface RepositoryCursor {
readonly page: number
readonly query: string
}
function decodeCursor(
cursor: string | null | undefined,
query: string,
): RepositoryCursor {
if (!cursor) return { page: 1, query }
try {
const parsed = object(
JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')),
'Repository cursor',
)
if (
!Number.isInteger(parsed.page) ||
(parsed.page as number) < 2 ||
parsed.query !== query
)
throw new Error()
return { page: parsed.page as number, query }
} catch {
throw new TypeError('Repository cursor is invalid')
}
}
function encodeCursor(page: number, query: string): string {
return Buffer.from(JSON.stringify({ page, query }), 'utf8').toString(
'base64url',
)
}
export interface GiteaClientOptions {
readonly baseUrl: string
readonly token: string
readonly networkPolicy: GiteaNetworkPolicy
readonly http?: Pick<SafeHttpClient, 'get'>
readonly pageSize?: number
}
export class GiteaClient {
readonly #apiBase: string
readonly #authorization: string
readonly #http: Pick<SafeHttpClient, 'get'>
readonly #pageSize: number
constructor(options: GiteaClientOptions) {
if (options.token.length === 0)
throw new TypeError('Gitea token cannot be empty')
this.#apiBase = `${normalizeGiteaBaseUrl(options.baseUrl, options.networkPolicy)}/api/v1`
this.#authorization = `token ${options.token}`
this.#http =
options.http ?? new SafeHttpClient({ policy: options.networkPolicy })
this.#pageSize = options.pageSize ?? 50
if (
!Number.isInteger(this.#pageSize) ||
this.#pageSize < 1 ||
this.#pageSize > 100
) {
throw new RangeError('Gitea page size must be between 1 and 100')
}
}
async #get(
path: string,
authenticated = true,
): Promise<{ json: unknown; response: SafeHttpResponse }> {
try {
const response = await this.#http.get(
new URL(`${this.#apiBase}${path}`),
authenticated
? { authorization: this.#authorization, accept: 'application/json' }
: { accept: 'application/json' },
)
if (response.status < 200 || response.status >= 300)
throw statusError(response)
return { json: parseJson(response), response }
} catch (error) {
throw mappedError(error)
}
}
async getVersion(): Promise<string> {
const { json } = await this.#get('/version')
return string(
object(json, 'Gitea version response').version,
'Gitea version',
)
}
async getCurrentUser(): Promise<{
readonly id: string
readonly login: string
}> {
const { json } = await this.#get('/user')
const user = object(json, 'Gitea user')
return {
id: String(number(user.id, 'Gitea user id')),
login: string(user.login, 'Gitea user login'),
}
}
async listRepositories(
userId: string,
cursor?: string | null,
query = '',
): Promise<ForgeRepositoryPage> {
const state = decodeCursor(cursor, query)
const parameters = new URLSearchParams({
uid: userId,
private: 'true',
exclusive: 'false',
sort: 'alpha',
order: 'asc',
page: String(state.page),
limit: String(this.#pageSize),
})
if (query) parameters.set('q', query)
const { json, response } = await this.#get(`/repos/search?${parameters}`)
const root = object(json, 'Gitea repository search')
const data = Array.isArray(root.data)
? root.data
: Array.isArray(json)
? json
: null
if (!data)
throw new GiteaRequestError(
'RESPONSE_INVALID',
response.status,
'Gitea repository page was invalid',
)
const totalHeader = response.headers['x-total-count']
const total = totalHeader === undefined ? null : Number(totalHeader)
const hasNext = Number.isFinite(total)
? state.page * this.#pageSize < total!
: data.length === this.#pageSize
return {
items: data.map(repositoryFrom),
nextCursor: hasNext ? encodeCursor(state.page + 1, query) : null,
}
}
async getRepository(ref: ForgeRepositoryRef): Promise<ForgeRepository> {
const { json } = await this.#get(this.#repositoryPath(ref))
return repositoryFrom(json)
}
#repositoryPath(ref: ForgeRepositoryRef): string {
return `/repos/${encodeSegment(ref.owner, 'Gitea owner')}/${encodeSegment(ref.name, 'Gitea repository')}`
}
async listBranches(ref: ForgeRepositoryRef): Promise<readonly ForgeBranch[]> {
const { json } = await this.#get(
`${this.#repositoryPath(ref)}/branches?limit=${this.#pageSize}&page=1`,
)
if (!Array.isArray(json))
throw new GiteaRequestError(
'RESPONSE_INVALID',
null,
'Gitea branches response was invalid',
)
return json.map((entry) => {
const branch = object(entry, 'Gitea branch')
const commit = object(branch.commit, 'Gitea branch commit')
return {
name: string(branch.name, 'Gitea branch name'),
commitSha: string(commit.id, 'Gitea branch commit id'),
protected:
typeof branch.protected === 'boolean' ? branch.protected : null,
}
})
}
async getBranch(
ref: ForgeRepositoryRef,
branch: string,
): Promise<ForgeBranch> {
const { json } = await this.#get(
`${this.#repositoryPath(ref)}/branches/${encodeSegment(branch, 'Gitea branch')}`,
)
const value = object(json, 'Gitea branch')
const commit = object(value.commit, 'Gitea branch commit')
return {
name: string(value.name, 'Gitea branch name'),
commitSha: string(commit.id, 'Gitea branch commit id'),
protected: typeof value.protected === 'boolean' ? value.protected : null,
}
}
async listTree(
ref: ForgeRepositoryRef,
sha: string,
page = 1,
): Promise<readonly ForgeTreeEntry[]> {
if (!Number.isInteger(page) || page < 1)
throw new RangeError('Gitea tree page is invalid')
const { json } = await this.#get(
`${this.#repositoryPath(ref)}/git/trees/${encodeSegment(sha, 'Gitea tree ref')}?recursive=false&page=${page}&per_page=${this.#pageSize}`,
)
const tree = object(json, 'Gitea tree')
if (page > 1 && tree.tree === null) return []
if (!Array.isArray(tree.tree))
throw new GiteaRequestError(
'RESPONSE_INVALID',
null,
'Gitea tree entries were invalid',
)
return tree.tree.map((entry) => {
const value = object(entry, 'Gitea tree entry')
return {
path: string(value.path, 'Gitea tree path'),
kind:
value.type === 'blob'
? 'file'
: value.type === 'tree'
? 'directory'
: 'other',
sha: typeof value.sha === 'string' ? value.sha : null,
size: typeof value.size === 'number' ? value.size : null,
}
})
}
async getFile(
ref: ForgeRepositoryRef,
revision: string,
path: string,
sizeLimit: number,
): Promise<ForgeFile> {
if (!Number.isInteger(sizeLimit) || sizeLimit < 1)
throw new RangeError('Gitea file size limit is invalid')
const { json } = await this.#get(
`${this.#repositoryPath(ref)}/contents/${encodeFilePath(path)}?ref=${encodeURIComponent(revision)}`,
)
const value = object(json, 'Gitea file')
const reportedSize = number(value.size, 'Gitea file size')
if (reportedSize > sizeLimit)
throw new GiteaRequestError(
'CONTENT_TOO_LARGE',
200,
'Gitea file exceeded the configured byte limit',
)
const encoding = string(value.encoding, 'Gitea file encoding')
if (encoding !== 'base64')
throw new GiteaRequestError(
'RESPONSE_INVALID',
200,
'Gitea file encoding was unsupported',
)
const bytes = Buffer.from(
string(value.content, 'Gitea file content').replace(/\s/gu, ''),
'base64',
)
if (bytes.byteLength !== reportedSize || bytes.byteLength > sizeLimit) {
throw new GiteaRequestError(
bytes.byteLength > sizeLimit ? 'CONTENT_TOO_LARGE' : 'RESPONSE_INVALID',
200,
'Gitea file size did not match its payload',
)
}
return {
path,
sha: typeof value.sha === 'string' ? value.sha : null,
size: bytes.byteLength,
bytes,
}
}
async listTags(ref: ForgeRepositoryRef): Promise<readonly string[]> {
return this.#names(
`${this.#repositoryPath(ref)}/tags?limit=${this.#pageSize}&page=1`,
)
}
async listReleases(ref: ForgeRepositoryRef): Promise<readonly string[]> {
return this.#names(
`${this.#repositoryPath(ref)}/releases?limit=${this.#pageSize}&page=1`,
)
}
async #names(path: string): Promise<readonly string[]> {
const { json } = await this.#get(path)
if (!Array.isArray(json))
throw new GiteaRequestError(
'RESPONSE_INVALID',
null,
'Gitea list response was invalid',
)
return json.map((item) =>
string(
object(item, 'Gitea list item').name ??
object(item, 'Gitea list item').tag_name,
'Gitea item name',
),
)
}
async getBranchProtections(ref: ForgeRepositoryRef): Promise<unknown> {
return (await this.#get(`${this.#repositoryPath(ref)}/branch_protections`))
.json
}
async getRootContents(ref: ForgeRepositoryRef): Promise<unknown> {
return (await this.#get(`${this.#repositoryPath(ref)}/contents`)).json
}
async getTopics(ref: ForgeRepositoryRef): Promise<unknown> {
return (
await this.#get(
`${this.#repositoryPath(ref)}/topics?page=1&limit=${this.#pageSize}`,
)
).json
}
async getLanguages(ref: ForgeRepositoryRef): Promise<unknown> {
return (await this.#get(`${this.#repositoryPath(ref)}/languages`)).json
}
async getWorkflows(ref: ForgeRepositoryRef): Promise<unknown> {
return (await this.#get(`${this.#repositoryPath(ref)}/actions/workflows`))
.json
}
async getRepositoryPermission(
ref: ForgeRepositoryRef,
login: string,
): Promise<unknown> {
return (
await this.#get(
`${this.#repositoryPath(ref)}/collaborators/${encodeSegment(login, 'Gitea login')}/permission`,
)
).json
}
}
const capabilityNames: readonly ForgeCapabilityName[] = [
'repositories',
'repository-metadata',
'branches',
'tags',
'releases',
'contents',
'branch-protection',
'templates',
'workflows',
'topics',
'languages',
'permissions',
]
function capabilityFromError(
error: unknown,
checkedAt: string,
): ForgeCapabilityState {
const mapped = mappedError(error)
return {
status:
mapped.code === 'PERMISSION_MISSING' || mapped.code === 'AUTH_INVALID'
? 'forbidden'
: mapped.code === 'CAPABILITY_UNSUPPORTED'
? 'unsupported'
: 'temporarily-unavailable',
checkedAt,
errorCode: mapped.code,
}
}
export class GiteaAdapter implements ForgeAdapter {
readonly #client: GiteaClient
readonly #now: () => Date
#identity: { readonly id: string; readonly login: string } | null = null
constructor(client: GiteaClient, now: () => Date = () => new Date()) {
this.#client = client
this.#now = now
}
async testConnection(): Promise<ForgeConnectionResult> {
const [serverVersion, identity] = await Promise.all([
this.#client.getVersion(),
this.#client.getCurrentUser(),
])
this.#identity = identity
await this.#client.listRepositories(identity.id)
const checkedAt = this.#now().toISOString()
return {
serverVersion,
identity,
capabilities: { repositories: { status: 'supported', checkedAt } },
}
}
async listRepositories(
cursor?: string | null,
query = '',
): Promise<ForgeRepositoryPage> {
const identity = this.#identity ?? (await this.#client.getCurrentUser())
this.#identity = identity
return this.#client.listRepositories(identity.id, cursor, query)
}
getRepository(ref: ForgeRepositoryRef) {
return this.#client.getRepository(ref)
}
listTree(ref: ForgeRepositoryRef, revision: string, page = 1) {
return this.#client.listTree(ref, revision, page)
}
getFile(
ref: ForgeRepositoryRef,
revision: string,
path: string,
sizeLimit: number,
) {
return this.#client.getFile(ref, revision, path, sizeLimit)
}
getBranches(ref: ForgeRepositoryRef) {
return this.#client.listBranches(ref)
}
getTags(ref: ForgeRepositoryRef) {
return this.#client.listTags(ref)
}
getReleases(ref: ForgeRepositoryRef) {
return this.#client.listReleases(ref)
}
getGovernanceEvidence(ref: ForgeRepositoryRef) {
return this.#client.getBranchProtections(ref)
}
getWorkflowEvidence(ref: ForgeRepositoryRef) {
return this.#client.getWorkflows(ref)
}
async getCapabilities(
ref: ForgeRepositoryRef,
): Promise<Readonly<Record<ForgeCapabilityName, ForgeCapabilityState>>> {
const checkedAt = this.#now().toISOString()
const probes: Readonly<
Partial<Record<ForgeCapabilityName, () => Promise<unknown>>>
> = {
repositories: () => this.listRepositories(),
'repository-metadata': () => this.#client.getRepository(ref),
branches: () => this.#client.listBranches(ref),
tags: () => this.#client.listTags(ref),
releases: () => this.#client.listReleases(ref),
contents: () => this.#client.getRootContents(ref),
templates: () => this.#client.getRootContents(ref),
'branch-protection': () => this.#client.getBranchProtections(ref),
workflows: () => this.#client.getWorkflows(ref),
topics: () => this.#client.getTopics(ref),
languages: () => this.#client.getLanguages(ref),
permissions: async () =>
this.#client.getRepositoryPermission(
ref,
(this.#identity ?? (await this.#client.getCurrentUser())).login,
),
}
const entries = await Promise.all(
capabilityNames.map(async (name) => {
try {
await probes[name]!()
return [name, { status: 'supported', checkedAt }] as const
} catch (error) {
return [name, capabilityFromError(error, checkedAt)] as const
}
}),
)
return Object.fromEntries(entries) as unknown as Readonly<
Record<ForgeCapabilityName, ForgeCapabilityState>
>
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './forge-adapter'
export * from './gitea-client'
export * from './network-policy'
export * from './safe-http-client'
export * from './secret-envelope'
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
assertResolvedAddressesAllowed,
classifyAddress,
normalizeGiteaBaseUrl,
type GiteaNetworkPolicy,
} from './network-policy'
const denied: GiteaNetworkPolicy = { privateNetworkPolicy: 'deny' }
describe('Gitea network policy', () => {
it.each([
['127.0.0.1', 'loopback'],
['169.254.169.254', 'metadata'],
['10.1.2.3', 'private'],
['192.168.1.2', 'private'],
['::1', 'loopback'],
['fe80::1', 'link-local'],
['fd00::1', 'private'],
['::ffff:127.0.0.1', 'loopback'],
['2001:db8::1', 'reserved'],
['8.8.8.8', 'public'],
['2606:4700:4700::1111', 'public'],
] as const)('classifies %s as %s', (address, expected) => {
expect(classifyAddress(address)).toBe(expected)
})
it('rejects one blocked answer in a mixed DNS response', () => {
expect(() =>
assertResolvedAddressesAllowed(
'gitea.example',
[
{ address: '8.8.8.8', family: 4 },
{ address: '127.0.0.1', family: 4 },
],
denied,
),
).toThrow('blocked loopback')
})
it('allows only private addresses for an explicitly allowed private hostname', () => {
const policy: GiteaNetworkPolicy = {
privateNetworkPolicy: 'allow-explicit-hosts',
allowedHosts: ['Git.Internal.'],
}
expect(() =>
assertResolvedAddressesAllowed(
'git.internal',
[{ address: '172.20.1.4', family: 4 }],
policy,
),
).not.toThrow()
expect(() =>
assertResolvedAddressesAllowed(
'other.internal',
[{ address: '172.20.1.4', family: 4 }],
policy,
),
).toThrow('blocked private')
expect(() =>
assertResolvedAddressesAllowed(
'git.internal',
[{ address: '169.254.169.254', family: 4 }],
policy,
),
).toThrow('blocked metadata')
})
it('normalizes subpath installations and rejects unsafe URL fields', () => {
expect(normalizeGiteaBaseUrl('https://GIT.EXAMPLE/gitea///', denied)).toBe(
'https://git.example/gitea',
)
for (const value of [
'ftp://git.example',
'https://user:pass@git.example',
'https://git.example?token=secret',
'https://git.example/#fragment',
]) {
expect(() => normalizeGiteaBaseUrl(value, denied)).toThrow()
}
})
it('requires both operator policy and per-connection opt-in for HTTP', () => {
const base = {
privateNetworkPolicy: 'allow-explicit-hosts' as const,
allowedHosts: ['git.internal'],
}
expect(() => normalizeGiteaBaseUrl('http://git.internal', base)).toThrow()
expect(
normalizeGiteaBaseUrl('http://git.internal', {
...base,
allowInsecureHttp: true,
}),
).toBe('http://git.internal')
})
})
+192
View File
@@ -0,0 +1,192 @@
import { isIP } from 'node:net'
export type PrivateNetworkPolicy = 'deny' | 'allow-explicit-hosts'
export interface GiteaNetworkPolicy {
readonly privateNetworkPolicy: PrivateNetworkPolicy
readonly allowedHosts?: readonly string[]
readonly allowInsecureHttp?: boolean
readonly requestTimeoutMs?: number
readonly maxResponseBytes?: number
readonly maxRedirects?: number
}
export type AddressClass =
| 'public'
| 'private'
| 'loopback'
| 'link-local'
| 'metadata'
| 'unspecified'
| 'multicast'
| 'reserved'
export interface ResolvedAddress {
readonly address: string
readonly family: 4 | 6
}
export class NetworkPolicyError extends Error {
readonly code = 'NETWORK_BLOCKED' as const
constructor(message: string) {
super(message)
this.name = 'NetworkPolicyError'
}
}
function normalizedHostname(hostname: string): string {
return hostname
.replace(/^\[|\]$/gu, '')
.toLowerCase()
.replace(/\.$/u, '')
}
function parseIpv4(value: string): readonly number[] | null {
const pieces = value.split('.')
if (pieces.length !== 4) return null
const bytes = pieces.map((piece) => {
if (!/^(0|[1-9]\d{0,2})$/u.test(piece)) return -1
const parsed = Number(piece)
return parsed <= 255 ? parsed : -1
})
return bytes.some((byte) => byte < 0) ? null : bytes
}
function ipv6Words(value: string): readonly number[] {
const withoutZone = value.split('%', 1)[0]!.toLowerCase()
const mappedIndex = withoutZone.lastIndexOf(':')
let input = withoutZone
if (withoutZone.includes('.')) {
const ipv4 = parseIpv4(withoutZone.slice(mappedIndex + 1))
if (!ipv4) throw new NetworkPolicyError('Invalid IPv6 address')
input = `${withoutZone.slice(0, mappedIndex)}:${((ipv4[0]! << 8) | ipv4[1]!).toString(16)}:${((ipv4[2]! << 8) | ipv4[3]!).toString(16)}`
}
const halves = input.split('::')
if (halves.length > 2) throw new NetworkPolicyError('Invalid IPv6 address')
const left = halves[0] ? halves[0].split(':') : []
const right = halves[1] ? halves[1].split(':') : []
const omitted = 8 - left.length - right.length
if ((halves.length === 1 && omitted !== 0) || omitted < 0) {
throw new NetworkPolicyError('Invalid IPv6 address')
}
return [...left, ...Array.from({ length: omitted }, () => '0'), ...right].map(
(word) => Number.parseInt(word, 16),
)
}
export function classifyAddress(address: string): AddressClass {
const family = isIP(address)
if (family === 4) {
const [a, b, c, d] = parseIpv4(address)!
if (a === 169 && b === 254 && c === 169 && d === 254) return 'metadata'
if (a === 0) return 'unspecified'
if (a === 127) return 'loopback'
if (a === 169 && b === 254) return 'link-local'
if (
a === 10 ||
(a === 172 && b! >= 16 && b! <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b! >= 64 && b! <= 127)
) {
return 'private'
}
if (a! >= 224 && a! <= 239) return 'multicast'
if (
a! >= 240 ||
(a === 192 && b === 0 && c === 0) ||
(a === 192 && b === 0 && c === 2) ||
(a === 198 && b === 51 && c === 100) ||
(a === 203 && b === 0 && c === 113) ||
(a === 198 && (b === 18 || b === 19))
) {
return 'reserved'
}
return 'public'
}
if (family === 6) {
const words = ipv6Words(address)
if (words.every((word) => word === 0)) return 'unspecified'
if (words.slice(0, 7).every((word) => word === 0) && words[7] === 1) {
return 'loopback'
}
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
const mapped = `${words[6]! >> 8}.${words[6]! & 255}.${words[7]! >> 8}.${words[7]! & 255}`
return classifyAddress(mapped)
}
if ((words[0]! & 0xfe00) === 0xfc00) return 'private'
if ((words[0]! & 0xffc0) === 0xfe80) return 'link-local'
if ((words[0]! & 0xff00) === 0xff00) return 'multicast'
if (words[0] === 0x2001 && words[1] === 0x0db8) return 'reserved'
return 'public'
}
throw new NetworkPolicyError('Resolved address is not a valid IP address')
}
export function normalizeGiteaBaseUrl(
input: string,
policy: GiteaNetworkPolicy,
): string {
let url: URL
try {
url = new URL(input)
} catch {
throw new NetworkPolicyError('Gitea base URL must be absolute')
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new NetworkPolicyError('Gitea base URL must use HTTPS')
}
if (url.username || url.password || url.search || url.hash) {
throw new NetworkPolicyError(
'Gitea base URL cannot contain userinfo, query parameters or a fragment',
)
}
const host = normalizedHostname(url.hostname)
const explicitlyAllowed = (policy.allowedHosts ?? []).some(
(allowed) => normalizedHostname(allowed) === host,
)
if (
url.protocol === 'http:' &&
(!policy.allowInsecureHttp ||
policy.privateNetworkPolicy !== 'allow-explicit-hosts' ||
!explicitlyAllowed)
) {
throw new NetworkPolicyError(
'Private HTTP requires request opt-in and an operator-allowed host',
)
}
url.hostname = host
url.pathname = url.pathname.replace(/\/+$/gu, '') || '/'
return url.toString().replace(/\/$/u, '')
}
export function assertResolvedAddressesAllowed(
hostname: string,
addresses: readonly ResolvedAddress[],
policy: GiteaNetworkPolicy,
): void {
if (addresses.length === 0) {
throw new NetworkPolicyError('Gitea hostname did not resolve')
}
const host = normalizedHostname(hostname)
const explicitlyAllowed = (policy.allowedHosts ?? []).some(
(allowed) => normalizedHostname(allowed) === host,
)
for (const candidate of addresses) {
if (isIP(candidate.address) !== candidate.family) {
throw new NetworkPolicyError('Resolver returned an invalid address')
}
const classification = classifyAddress(candidate.address)
if (classification === 'public') continue
if (
classification === 'private' &&
policy.privateNetworkPolicy === 'allow-explicit-hosts' &&
explicitlyAllowed
) {
continue
}
throw new NetworkPolicyError(
`Gitea hostname resolves to a blocked ${classification} address`,
)
}
}
@@ -0,0 +1,194 @@
import { createServer } from 'node:http'
import { describe, expect, it } from 'vitest'
import {
NodePinnedGetTransport,
SafeHttpClient,
SafeHttpError,
type HostResolver,
type PinnedGetTransport,
type SafeHttpRequestRecord,
type SafeHttpResponse,
} from './safe-http-client'
class SequenceResolver implements HostResolver {
calls: string[] = []
constructor(
private readonly answers: readonly (readonly {
address: string
family: 4 | 6
}[])[],
) {}
async resolve(hostname: string) {
this.calls.push(hostname)
return this.answers[this.calls.length - 1] ?? this.answers.at(-1)!
}
}
class LedgerTransport implements PinnedGetTransport {
calls: SafeHttpRequestRecord[] = []
constructor(private readonly responses: readonly SafeHttpResponse[]) {}
async get(request: SafeHttpRequestRecord) {
this.calls.push(request)
return this.responses[this.calls.length - 1]!
}
}
const response = (
status: number,
headers: Record<string, string> = {},
): SafeHttpResponse => ({ status, headers, body: new Uint8Array() })
describe('SafeHttpClient', () => {
it('uses the pinned address with the Node 24 all-address lookup contract', async () => {
const server = createServer((_request, reply) => {
reply.writeHead(200, { 'content-type': 'application/json' })
reply.end('{"version":"fixture"}')
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
try {
const address = server.address()
if (!address || typeof address === 'string')
throw new Error('Fixture server did not expose a TCP port')
const result = await new NodePinnedGetTransport().get(
{
method: 'GET',
url: `http://unresolvable.invalid:${address.port}/api/v1/version`,
address: { address: '127.0.0.1', family: 4 },
headers: { accept: 'application/json' },
},
{ signal: AbortSignal.timeout(2_000), maximumBytes: 1_024 },
)
expect(result.status).toBe(200)
expect(Buffer.from(result.body).toString('utf8')).toContain('fixture')
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
)
}
})
it('pins a vetted address and exposes only GET to the transport ledger', async () => {
const resolver = new SequenceResolver([[{ address: '8.8.8.8', family: 4 }]])
const transport = new LedgerTransport([response(200)])
const client = new SafeHttpClient({
policy: { privateNetworkPolicy: 'deny' },
resolver,
transport,
})
await client.get(new URL('https://git.example/api/v1/version'))
expect(transport.calls).toEqual([
expect.objectContaining({
method: 'GET',
address: { address: '8.8.8.8', family: 4 },
}),
])
})
it('re-resolves every redirect and blocks DNS rebinding before the second request', async () => {
const resolver = new SequenceResolver([
[{ address: '8.8.8.8', family: 4 }],
[{ address: '127.0.0.1', family: 4 }],
])
const transport = new LedgerTransport([
response(302, { location: '/second' }),
])
const client = new SafeHttpClient({
policy: { privateNetworkPolicy: 'deny' },
resolver,
transport,
})
await expect(
client.get(new URL('https://git.example/first')),
).rejects.toThrow('blocked loopback')
expect(resolver.calls).toEqual(['git.example', 'git.example'])
expect(transport.calls).toHaveLength(1)
})
it('strips authorization across origins and retains it on same-origin redirects', async () => {
const resolver = new SequenceResolver(
Array.from(
{ length: 3 },
() => [{ address: '8.8.8.8', family: 4 }] as const,
),
)
const transport = new LedgerTransport([
response(302, { location: '/same' }),
response(302, { location: 'https://other.example/final' }),
response(200),
])
const client = new SafeHttpClient({
policy: { privateNetworkPolicy: 'deny' },
resolver,
transport,
})
await client.get(new URL('https://git.example/start'), {
authorization: 'token never-log-me',
})
expect(transport.calls[1]!.headers.authorization).toBe('token never-log-me')
expect(transport.calls[2]!.headers.authorization).toBeUndefined()
})
it('blocks HTTPS downgrade and enforces the redirect limit', async () => {
const resolver = new SequenceResolver([[{ address: '8.8.8.8', family: 4 }]])
const downgrade = new LedgerTransport([
response(302, { location: 'http://git.example/unsafe' }),
])
await expect(
new SafeHttpClient({
policy: { privateNetworkPolicy: 'deny' },
resolver,
transport: downgrade,
}).get(new URL('https://git.example/start')),
).rejects.toThrow('cannot downgrade')
const looping = new LedgerTransport([response(302, { location: '/again' })])
await expect(
new SafeHttpClient({
policy: { privateNetworkPolicy: 'deny', maxRedirects: 0 },
resolver,
transport: looping,
}).get(new URL('https://git.example/start')),
).rejects.toThrow('redirect limit')
})
it('maps timeout and response-size transport failures to safe errors', async () => {
const resolver = new SequenceResolver([[{ address: '8.8.8.8', family: 4 }]])
const timeoutTransport: PinnedGetTransport = {
get: (_request, options) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener(
'abort',
() => reject(new Error('raw timeout detail')),
{ once: true },
)
}),
}
await expect(
new SafeHttpClient({
policy: { privateNetworkPolicy: 'deny', requestTimeoutMs: 5 },
resolver,
transport: timeoutTransport,
}).get(new URL('https://git.example/slow')),
).rejects.toMatchObject({
code: 'REMOTE_UNAVAILABLE',
message: 'Gitea request timed out',
})
const oversized: PinnedGetTransport = {
get: async () => {
throw new SafeHttpError('CONTENT_TOO_LARGE', 'bounded')
},
}
await expect(
new SafeHttpClient({
policy: { privateNetworkPolicy: 'deny' },
resolver,
transport: oversized,
}).get(new URL('https://git.example/large')),
).rejects.toMatchObject({ code: 'CONTENT_TOO_LARGE' })
})
})
@@ -0,0 +1,256 @@
import { lookup } from 'node:dns/promises'
import http from 'node:http'
import https from 'node:https'
import {
assertResolvedAddressesAllowed,
type GiteaNetworkPolicy,
NetworkPolicyError,
type ResolvedAddress,
} from './network-policy'
export interface SafeHttpResponse {
readonly status: number
readonly headers: Readonly<Record<string, string>>
readonly body: Uint8Array
}
export interface SafeHttpRequestRecord {
readonly method: 'GET'
readonly url: string
readonly address: ResolvedAddress
readonly headers: Readonly<Record<string, string>>
}
export interface HostResolver {
resolve(hostname: string): Promise<readonly ResolvedAddress[]>
}
export interface PinnedGetTransport {
get(
request: SafeHttpRequestRecord,
options: {
readonly signal: AbortSignal
readonly maximumBytes: number
},
): Promise<SafeHttpResponse>
}
export class SafeHttpError extends Error {
constructor(
readonly code:
| 'CONTENT_TOO_LARGE'
| 'REMOTE_UNAVAILABLE'
| 'TLS_ERROR'
| 'RESPONSE_INVALID',
message: string,
) {
super(message)
this.name = 'SafeHttpError'
}
}
export class NodeHostResolver implements HostResolver {
async resolve(hostname: string): Promise<readonly ResolvedAddress[]> {
const results = await lookup(hostname, { all: true, verbatim: true })
return results.map(({ address, family }) => ({
address,
family: family as 4 | 6,
}))
}
}
function normalizedHeaders(
source: http.IncomingHttpHeaders,
): Readonly<Record<string, string>> {
const result: Record<string, string> = {}
for (const [name, value] of Object.entries(source)) {
if (value !== undefined)
result[name.toLowerCase()] = Array.isArray(value)
? value.join(', ')
: value
}
return result
}
export class NodePinnedGetTransport implements PinnedGetTransport {
get(
request: SafeHttpRequestRecord,
options: { readonly signal: AbortSignal; readonly maximumBytes: number },
): Promise<SafeHttpResponse> {
return new Promise((resolve, reject) => {
const url = new URL(request.url)
const requester = url.protocol === 'https:' ? https.request : http.request
const outgoing = requester(
url,
{
method: 'GET',
headers: request.headers,
signal: options.signal,
servername: url.hostname,
lookup: (_hostname, lookupOptions, callback) => {
if (typeof lookupOptions === 'object' && lookupOptions.all) {
const allAddresses = callback as unknown as (
error: Error | null,
addresses: Array<{ address: string; family: 4 | 6 }>,
) => void
allAddresses(null, [request.address])
return
}
const oneAddress = callback as unknown as (
error: Error | null,
address: string,
family: 4 | 6,
) => void
oneAddress(null, request.address.address, request.address.family)
},
},
(incoming) => {
const chunks: Buffer[] = []
let total = 0
incoming.on('data', (chunk: Buffer) => {
total += chunk.byteLength
if (total > options.maximumBytes) {
incoming.destroy(
new SafeHttpError(
'CONTENT_TOO_LARGE',
'Gitea response exceeded the configured byte limit',
),
)
return
}
chunks.push(chunk)
})
incoming.once('error', reject)
incoming.on('end', () => {
resolve({
status: incoming.statusCode ?? 0,
headers: normalizedHeaders(incoming.headers),
body: Buffer.concat(chunks),
})
})
},
)
outgoing.once('error', (error) => {
if (error instanceof SafeHttpError) return reject(error)
const errorCode = (error as { readonly code?: unknown }).code
const code = typeof errorCode === 'string' ? errorCode : ''
reject(
new SafeHttpError(
code.startsWith('ERR_TLS') || code.includes('CERT')
? 'TLS_ERROR'
: 'REMOTE_UNAVAILABLE',
'Gitea request failed',
),
)
})
outgoing.end()
})
}
}
function safeTarget(input: URL): void {
if (
(input.protocol !== 'https:' && input.protocol !== 'http:') ||
input.username ||
input.password ||
input.hash
) {
throw new NetworkPolicyError('Gitea request target is not permitted')
}
}
function addressSort(left: ResolvedAddress, right: ResolvedAddress): number {
return left.family - right.family || left.address.localeCompare(right.address)
}
export class SafeHttpClient {
readonly #resolver: HostResolver
readonly #transport: PinnedGetTransport
readonly #policy: GiteaNetworkPolicy
constructor(options: {
readonly policy: GiteaNetworkPolicy
readonly resolver?: HostResolver
readonly transport?: PinnedGetTransport
}) {
this.#policy = options.policy
this.#resolver = options.resolver ?? new NodeHostResolver()
this.#transport = options.transport ?? new NodePinnedGetTransport()
}
async get(
target: URL,
headers: Readonly<Record<string, string>> = {},
): Promise<SafeHttpResponse> {
const maximumRedirects = this.#policy.maxRedirects ?? 3
const maximumBytes = this.#policy.maxResponseBytes ?? 1_048_576
const timeoutMs = this.#policy.requestTimeoutMs ?? 15_000
if (maximumRedirects < 0 || maximumRedirects > 10) {
throw new RangeError('Maximum redirects must be between zero and ten')
}
if (maximumBytes < 1 || timeoutMs < 1) {
throw new RangeError('HTTP limits must be positive')
}
let current = new URL(target)
let activeHeaders = { ...headers }
for (let redirect = 0; ; redirect += 1) {
safeTarget(current)
const addresses = [
...(await this.#resolver.resolve(current.hostname)),
].sort(addressSort)
assertResolvedAddressesAllowed(current.hostname, addresses, this.#policy)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
timer.unref()
let response: SafeHttpResponse
try {
response = await this.#transport.get(
{
method: 'GET',
url: current.toString(),
address: addresses[0]!,
headers: activeHeaders,
},
{ signal: controller.signal, maximumBytes },
)
} catch (error) {
if (controller.signal.aborted) {
throw new SafeHttpError(
'REMOTE_UNAVAILABLE',
'Gitea request timed out',
)
}
throw error
} finally {
clearTimeout(timer)
}
if (![301, 302, 303, 307, 308].includes(response.status)) return response
if (redirect >= maximumRedirects) {
throw new SafeHttpError(
'REMOTE_UNAVAILABLE',
'Gitea redirect limit exceeded',
)
}
const location = response.headers.location
if (!location)
throw new SafeHttpError(
'RESPONSE_INVALID',
'Gitea redirect omitted Location',
)
const next = new URL(location, current)
safeTarget(next)
if (current.protocol === 'https:' && next.protocol !== 'https:') {
throw new NetworkPolicyError(
'Gitea HTTPS redirects cannot downgrade transport security',
)
}
if (next.origin !== current.origin) {
const withoutAuthorization = { ...activeHeaders }
delete withoutAuthorization.authorization
activeHeaders = withoutAuthorization
}
current = next
}
}
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
decryptIntegrationSecret,
encryptIntegrationSecret,
type SecretBinding,
} from './secret-envelope'
const binding: SecretBinding = {
workspaceId: 'workspace-a',
integrationId: 'integration-a',
secretKind: 'gitea-token',
}
const oldKey = Buffer.alloc(32, 7)
const activeKey = Buffer.alloc(32, 9)
const ring = { activeVersion: 'v2', keys: { v1: oldKey, v2: activeKey } }
describe('integration secret envelope', () => {
it('round-trips without exposing plaintext in the envelope', () => {
const plaintext = 'gitea-secret-token-value'
const envelope = encryptIntegrationSecret(plaintext, binding, ring)
expect(JSON.stringify(envelope)).not.toContain(plaintext)
expect(envelope).toMatchObject({
algorithm: 'AES-256-GCM',
envelopeVersion: 1,
keyVersion: 'v2',
})
expect(decryptIntegrationSecret(envelope, binding, ring)).toBe(plaintext)
})
it('decrypts an old key version through the key ring', () => {
const envelope = encryptIntegrationSecret('old-secret', binding, {
activeVersion: 'v1',
keys: { v1: oldKey },
})
expect(decryptIntegrationSecret(envelope, binding, ring)).toBe('old-secret')
})
it('rejects tampering, wrong AAD and unavailable key versions', () => {
const envelope = encryptIntegrationSecret('bound-secret', binding, ring)
expect(() =>
decryptIntegrationSecret(
{ ...envelope, ciphertext: Buffer.from('tampered').toString('base64') },
binding,
ring,
),
).toThrow('authentication failed')
expect(() =>
decryptIntegrationSecret(
envelope,
{ ...binding, workspaceId: 'workspace-b' },
ring,
),
).toThrow('authentication failed')
expect(() =>
decryptIntegrationSecret(envelope, binding, {
activeVersion: 'v3',
keys: { v3: Buffer.alloc(32, 1) },
}),
).toThrow('key version is unavailable')
})
})
@@ -0,0 +1,120 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
export interface SecretBinding {
readonly workspaceId: string
readonly integrationId: string
readonly secretKind: string
}
export interface SecretEnvelopeV1 {
readonly algorithm: 'AES-256-GCM'
readonly envelopeVersion: 1
readonly keyVersion: string
readonly nonce: string
readonly ciphertext: string
readonly authenticationTag: string
}
export interface IntegrationKeyRing {
readonly activeVersion: string
readonly keys: Readonly<Record<string, Uint8Array | string>>
}
function keyBytes(key: Uint8Array | string): Buffer {
const bytes =
typeof key === 'string' ? Buffer.from(key, 'base64') : Buffer.from(key)
if (bytes.byteLength !== 32)
throw new TypeError('Integration encryption keys must contain 32 bytes')
return bytes
}
function assertBinding(binding: SecretBinding): void {
for (const value of [
binding.workspaceId,
binding.integrationId,
binding.secretKind,
]) {
if (value.length === 0 || value.length > 255)
throw new TypeError(
'Secret binding values must contain 1 to 255 characters',
)
}
}
function additionalData(
binding: SecretBinding,
envelopeVersion: number,
keyVersion: string,
): Buffer {
assertBinding(binding)
const fields = [
binding.workspaceId,
binding.integrationId,
binding.secretKind,
String(envelopeVersion),
keyVersion,
]
return Buffer.from(
fields
.map((field) => `${Buffer.byteLength(field, 'utf8')}:${field}`)
.join('|'),
'utf8',
)
}
export function encryptIntegrationSecret(
plaintext: string,
binding: SecretBinding,
keyRing: IntegrationKeyRing,
): SecretEnvelopeV1 {
if (plaintext.length === 0)
throw new TypeError('Integration secret cannot be empty')
const key = keyRing.keys[keyRing.activeVersion]
if (!key)
throw new TypeError('Active integration encryption key is unavailable')
const nonce = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', keyBytes(key), nonce)
cipher.setAAD(additionalData(binding, 1, keyRing.activeVersion))
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
])
return Object.freeze({
algorithm: 'AES-256-GCM',
envelopeVersion: 1,
keyVersion: keyRing.activeVersion,
nonce: nonce.toString('base64'),
ciphertext: ciphertext.toString('base64'),
authenticationTag: cipher.getAuthTag().toString('base64'),
})
}
export function decryptIntegrationSecret(
envelope: SecretEnvelopeV1,
binding: SecretBinding,
keyRing: IntegrationKeyRing,
): string {
if (envelope.algorithm !== 'AES-256-GCM' || envelope.envelopeVersion !== 1) {
throw new TypeError('Integration secret envelope is unsupported')
}
const key = keyRing.keys[envelope.keyVersion]
if (!key)
throw new TypeError('Integration encryption key version is unavailable')
try {
const decipher = createDecipheriv(
'aes-256-gcm',
keyBytes(key),
Buffer.from(envelope.nonce, 'base64'),
)
decipher.setAAD(
additionalData(binding, envelope.envelopeVersion, envelope.keyVersion),
)
decipher.setAuthTag(Buffer.from(envelope.authenticationTag, 'base64'))
return Buffer.concat([
decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
decipher.final(),
]).toString('utf8')
} catch {
throw new TypeError('Integration secret envelope authentication failed')
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}