// Uploads the build's SBOM file(s) to Declarable's /api/v1 and decides pass or fail. // Plain Node 18+, global fetch, no dependency to install. Inputs arrive as DECLARABLE_* env vars // (action.yml sets them). Exit 0 = uploaded and inside your thresholds, or uploaded with the // findings not checked (said in words); 1 = a threshold was crossed; 2 = the upload itself did // not work. A re-run of a version that already exists is not // an upload failure: the same files again record nothing new, and the findings are checked as usual. // "Checked" is the product read's `currentRelease.checked` for the release this job uploaded: if // that release is not the product's current one, or has not been matched, nothing is checked. import { appendFileSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, isAbsolute, join, relative, resolve } from 'node:path'; const GAP_LEVELS = ['blocker', 'major', 'minor', 'none']; /** Thrown by `stop()` to unwind to `main()`'s catch without running anything after it; the exit * code itself travels on `process.exitCode`, set before the throw. Keeps Windows from hitting * libuv's handle-closing assertion that a bare `process.exit()` after a `fetch` can trigger. */ class StopSignal extends Error {} const env = (name, fallback = '') => (process.env[name] ?? fallback).trim(); const apiKey = env('DECLARABLE_API_KEY'); const productId = env('DECLARABLE_PRODUCT_ID'); const sbomPath = env('DECLARABLE_SBOM_PATH'); const version = env('DECLARABLE_VERSION') || env('GITHUB_SHA'); const baseUrl = env('DECLARABLE_API_BASE_URL', 'https://declarable.io').replace(/\/+$/, ''); const failOnGap = env('DECLARABLE_FAIL_ON_GAP', 'blocker').toLowerCase(); const replace = env('DECLARABLE_REPLACE', 'false').toLowerCase() === 'true'; const releasedAt = env('DECLARABLE_RELEASED_AT'); // The commit defaults to the CI system's own variable; "none" sends no commit. const commitSetting = env('DECLARABLE_COMMIT') || env('GITHUB_SHA') || env('CI_COMMIT_SHA'); const commit = commitSetting.toLowerCase() === 'none' ? '' : commitSetting; const buildId = env('DECLARABLE_BUILD_ID'); const imageSha256 = env('DECLARABLE_IMAGE_SHA256'); const supportedUntil = env('DECLARABLE_SUPPORTED_UNTIL'); // Off by default: each file goes under its base name, as every existing recipe expects. "true" sends // each file under its path relative to the workspace, so a reader that takes the environment from // the path (PlatformIO's .pio/libdeps///library.json) can see it. A step sets it with // action.yml's `keep-paths` input, mapped to this variable in its `env:` block. const keepPaths = env('DECLARABLE_KEEP_PATHS', 'false').toLowerCase() === 'true'; const failOnExploited = env('DECLARABLE_FAIL_ON_EXPLOITED', 'true').toLowerCase() !== 'false'; /** Removes the key from any text before it is printed, even if a library echoed it back. */ function redact(text) { let out = String(text); if (apiKey) out = out.split(apiKey).join('***'); return out.replace(/dk_[A-Za-z0-9]+_[A-Za-z0-9_-]+/g, 'dk_***'); } /** Hands a value to later steps of the same job through the file GITHUB_OUTPUT names. Off GitHub it * does nothing. A value is one line: a newline in it would start a second output. */ function setOutput(name, value) { const file = env('GITHUB_OUTPUT'); if (file) appendFileSync(file, `${name}=${String(value ?? '').replace(/[\r\n]+/g, ' ')}\n`); } /** Prints a GitHub workflow command (an annotation beside the check) and does nothing off GitHub, so * GitLab and a shell see no extra line. Source: GitHub Docs, "Workflow commands for GitHub Actions", * read 13 Oct 2026. `file` and `line` are optional and left out on purpose: a gap belongs to the * SBOM, not to a source line. A message escapes % CR LF; a property such as the title also escapes * : and ,. The text goes through redact() first, because a gap's title comes from the server. */ function annotate(level, title, message) { if (!env('GITHUB_ACTIONS')) return; const escape = (text) => redact(text).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); const property = (text) => escape(text).replace(/:/g, '%3A').replace(/,/g, '%2C'); console.log(`::${level} title=${property(title)}::${escape(message)}`); } function writeSummary(markdown) { const file = env('GITHUB_STEP_SUMMARY'); if (file) appendFileSync(file, `${markdown}\n`); else console.log(markdown); } function stop(code, message) { console.error(redact(message)); annotate('error', 'Declarable SBOM not uploaded', message); writeSummary(`## Declarable SBOM upload\n\n**Not uploaded.** ${redact(message)}\n`); process.exitCode = code; throw new StopSignal(); } function readdirSafe(dir) { try { return readdirSync(dir, { withFileTypes: true }); } catch { return []; } } /** `*` and `?` inside one path segment. */ function segmentMatcher(pattern) { const source = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '[^/]*') .replace(/\?/g, '[^/]'); return new RegExp(`^${source}$`); } /** Walks `segments` under `dir`; a `**` segment matches any depth. */ function walk(dir, segments, found) { if (segments.length === 0) return; const [head, ...rest] = segments; if (head === '**') { walk(dir, rest, found); for (const entry of readdirSafe(dir)) { if (entry.isDirectory()) walk(join(dir, entry.name), segments, found); } return; } const matches = segmentMatcher(head); for (const entry of readdirSafe(dir)) { if (!matches.test(entry.name)) continue; const full = join(dir, entry.name); if (rest.length === 0) { if (entry.isFile()) found.add(full); } else if (entry.isDirectory()) { walk(full, rest, found); } } } /** The files a single path or a glob names, sorted. */ function expand(pattern) { const normal = pattern.replace(/\\/g, '/'); if (!/[*?]/.test(normal)) { try { return statSync(normal).isFile() ? [resolve(normal)] : []; } catch { return []; } } const parts = normal.split('/').filter((p) => p !== ''); let root = '.'; if (normal.startsWith('/')) root = '/'; else if (/^[A-Za-z]:$/.test(parts[0] ?? '')) root = `${parts.shift()}/`; const found = new Set(); walk(root, parts, found); return [...found].map((f) => resolve(f)).sort(); } async function readJson(response) { try { return await response.json(); } catch { return null; } } function apiMessage(body, response) { const message = body?.error?.message; return message ? `${message} (HTTP ${response.status})` : `HTTP ${response.status} ${response.statusText}`; } // --- checks before any network call ------------------------------------------------------- async function main() { // Names a missing setting in the words of the system the script runs in. const onGithub = Boolean(env('GITHUB_ACTIONS')); const setting = (input, variable) => (onGithub ? `input ${input}` : variable); if (!apiKey) { stop( 2, onGithub ? 'No API key. Pass api-key: ${{ secrets.DECLARABLE_API_KEY }}. Pull requests from forks get no secrets.' : 'No API key. Set DECLARABLE_API_KEY. In GitLab, a protected variable reaches only protected branches and protected tags.', ); } if (!productId) stop(2, `No product id. Set ${setting('product-id', 'DECLARABLE_PRODUCT_ID')}.`); if (!sbomPath) stop(2, `No SBOM path. Set ${setting('sbom-path', 'DECLARABLE_SBOM_PATH')}.`); if (!version) stop(2, `No version. Set ${setting('version', 'DECLARABLE_VERSION')}.`); if (!GAP_LEVELS.includes(failOnGap)) { stop(2, `fail-on-gap must be one of ${GAP_LEVELS.join(', ')}; got "${failOnGap}".`); } // The date goes to the server untouched: it reads it as a UTC day and refuses one in the future. if (releasedAt && !/^\d{4}-\d{2}-\d{2}$/.test(releasedAt)) { stop(2, `released-at must look like 2026-09-30 (YYYY-MM-DD); got "${releasedAt}".`); } // The same checks, in the same words, as the releases route (lib/products/releases.ts // parseBuildIdentity), so a value the server would refuse stops the job before any upload. const MAX_BUILD_FIELD = 200; const imageHash = imageSha256.replace(/^sha256:/i, '').trim(); if (imageSha256 && !/^[0-9a-f]{64}$/i.test(imageHash)) { stop(2, `${setting('image-sha256', 'DECLARABLE_IMAGE_SHA256')}: A firmware image hash is 64 hex characters.`); } if (buildId.length > MAX_BUILD_FIELD) stop(2, `${setting('build-id', 'DECLARABLE_BUILD_ID')}: Keep the build id under ${MAX_BUILD_FIELD} characters.`); if (commit.length > MAX_BUILD_FIELD) stop(2, `${setting('commit', 'DECLARABLE_COMMIT')}: Keep the commit under ${MAX_BUILD_FIELD} characters.`); if (supportedUntil) { const day = /^\d{4}-\d{2}-\d{2}$/.test(supportedUntil) ? new Date(`${supportedUntil}T00:00:00.000Z`) : null; if (!day || Number.isNaN(day.getTime()) || day.toISOString().slice(0, 10) !== supportedUntil) { stop(2, `${setting('supported-until', 'DECLARABLE_SUPPORTED_UNTIL')}: Give the support date as YYYY-MM-DD.`); } } console.log( `Thresholds: fail-on-gap = ${failOnGap} (input fail-on-gap, ${GAP_LEVELS.join('|')}); ` + `fail-on-exploited = ${failOnExploited} (input fail-on-exploited, true|false).`, ); // sbom-path holds one or more paths or globs, separated by newlines or spaces (so a path with a // space in it cannot be named). Every match goes in the one release, each file once. const patterns = sbomPath.split(/\s+/).filter((p) => p !== ''); const fileSet = new Set(); const unmatched = []; for (const pattern of patterns) { const hits = expand(pattern); if (hits.length === 0) unmatched.push(pattern); for (const hit of hits) fileSet.add(hit); } if (unmatched.length > 0) { const named = unmatched.map((p) => `"${p}"`).join(', '); stop(2, `No file matches sbom-path ${named}. Check that the SBOM step ran first and wrote there.`); } const files = [...fileSet]; /** The file's path under the workspace, with forward slashes. A file outside the workspace has no * such path, and sending only its base name would hide the very thing keep-paths is for, so it stops. */ function workspaceRelative(file) { const rel = relative(env('GITHUB_WORKSPACE') || process.cwd(), file).replaceAll('\\', '/'); if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) { stop(2, `DECLARABLE_KEEP_PATHS is on but ${file} is outside the workspace, so it has no relative path. Run the step from the project folder.`); } return rel; } // --- upload ------------------------------------------------------------------------------- const form = new FormData(); form.set('version', version); if (replace) form.set('replace', 'true'); // promise: releasedAt is read by the releases route and stored by setReleasedAt if (releasedAt) form.set('releasedAt', releasedAt); // promise: commit, buildId, imageSha256 and supportedUntil are read by the releases route and stored with the release if (commit) form.set('commit', commit); if (buildId) form.set('buildId', buildId); if (imageSha256) form.set('imageSha256', imageHash); if (supportedUntil) form.set('supportedUntil', supportedUntil); let totalBytes = 0; for (const file of files) { const data = readFileSync(file); totalBytes += data.length; // promise: the releases route keeps a part's file name as sent, path included (measured with the same FormData parse it uses, 11 Oct 2026), so the engine reads the environment from it form.append('files[]', new Blob([data]), keepPaths ? workspaceRelative(file) : basename(file)); } console.log(`Sending ${files.length} file(s), ${totalBytes} bytes total.`); const productUrl = `${baseUrl}/api/v1/products/${encodeURIComponent(productId)}`; const headers = { Authorization: `Bearer ${apiKey}` }; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Waits and retries `doFetch` once on a 429 whose Retry-After is 60s or less; a longer wait or * any other status is returned as-is for the caller to handle. */ async function withRetry429(doFetch) { const response = await doFetch(); if (response.status !== 429) return response; const wait = Number(response.headers.get('retry-after')); if (!(Number.isFinite(wait) && wait > 0 && wait <= 60)) return response; const body = await readJson(response); console.log(`${apiMessage(body, response)} Waiting ${wait}s and retrying once.`); await sleep(wait * 1000); return doFetch(); } const UPLOAD_LIMIT_S = 330; // the route may run 300s const READ_LIMIT_S = 30; /** One try plus one retry after a 5xx or a dropped connection. A timeout is not retried: the * server may still be working on the first request. */ async function send(limitSeconds, doFetch) { for (let attempt = 1; ; attempt += 1) { let response; try { response = await withRetry429(() => doFetch(AbortSignal.timeout(limitSeconds * 1000))); } catch (cause) { if (cause?.name === 'TimeoutError') throw new Error(`no answer after ${limitSeconds}s`); if (attempt > 1) throw cause; const text = cause instanceof Error ? cause.message : String(cause); console.log(`Connection failed (${text}). Waiting 5s and retrying once.`); await sleep(5000); continue; } if (response.status >= 500 && attempt === 1) { console.log(`Server answered HTTP ${response.status}. Waiting 5s and retrying once.`); await sleep(5000); continue; } return response; } } let release; let alreadyExists = false; let unchanged = false; let replaced = null; try { const response = await send(UPLOAD_LIMIT_S, (signal) => fetch(`${productUrl}/releases`, { method: 'POST', headers, body: form, signal }), ); const body = await readJson(response); if (response.status === 409 && body?.error?.code === 'duplicate_version') { alreadyExists = true; // The id sits under `release` now; a self-hosted copy older than that sends a top-level // `releaseId`. Drop the fallback one release cycle after every copy has updated. const existing = body.release ?? {}; const id = existing.id ?? body.releaseId; release = { id, version: existing.version ?? version, componentCount: null, url: existing.url ?? `${baseUrl}/app/products/${encodeURIComponent(productId)}/releases/${encodeURIComponent(id ?? '')}`, }; } else { if (!response.ok) { if (response.status === 413 && !body?.error) { stop( 2, `Upload refused: the host refused the request for its size before Declarable read it (${totalBytes} bytes).`, ); } stop(2, `Upload refused: ${apiMessage(body, response)}`); } release = body?.release; if (!release) stop(2, 'Upload answered without a release in the body.'); unchanged = response.status === 200 && body?.unchanged === true; replaced = body?.replaced ?? null; } } catch (cause) { // A stop(2, ...) inside the try above (a non-ok response, a missing release) already printed // its own refusal and set the exit code; let that StopSignal keep unwinding instead of // re-wrapping it here, or it is reported twice. if (cause instanceof StopSignal) throw cause; const text = cause instanceof Error ? cause.message : String(cause); const timedOut = text.startsWith('no answer after'); stop( 2, `Upload failed: ${text}.` + (timedOut ? ' The release may have been recorded; sending the same files again is safe.' : ''), ); } if (alreadyExists) { // promise: the replace input sends replace=true, which the releases route passes to createRelease console.log( `Release ${release.version} already exists (${release.id}) with different files. Nothing was replaced; ` + `set replace to true to replace it. ${release.url}`, ); } else if (unchanged) { console.log( `Release ${release.version} already uploaded with the same files; nothing new was recorded.`, ); } else { console.log(`Uploaded ${files.length} file(s) as ${release.version}: ${release.componentCount} components.`); if (replaced) { console.log( `Replaced release ${replaced.version} (${replaced.id})` + (replaced.kept ? ': it was kept under a new label rather than deleted.' : '.'), ); } } // promise: action.yml declares these four outputs and this is the only place that sets them setOutput('release-id', release.id ?? ''); setOutput('release-url', release.url ?? ''); setOutput('component-count', release.componentCount ?? ''); setOutput('recorded', !alreadyExists && !unchanged); // --- what is open in the release just uploaded -------------------------------------------- let status = null; let statusNote = null; // Set only when the product read itself failed. A read that worked but has no current release // leaves it null, and the gap line below says that instead. let unreadReason = null; try { const response = await send(READ_LIMIT_S, (signal) => fetch(productUrl, { headers, signal })); const body = await readJson(response); if (response.ok) status = body; else { unreadReason = `the product read failed (${apiMessage(body, response)})`; statusNote = `The findings could not be read (${apiMessage(body, response)}).`; if (response.status === 403) statusNote += ' Give the key the products:read scope to check them.'; } } catch (cause) { const text = cause instanceof Error ? cause.message : String(cause); unreadReason = `the product read failed (${text})`; statusNote = `The findings could not be read (${text}).`; } if (status && !unreadReason) { // The read worked; does it say anything about THIS release? If not, none of its numbers are used. // promise: GET /api/v1/products/:id answers currentRelease {id, version, checked} (api/v1/products/[id]/route.ts) const current = status.currentRelease; if (current === undefined) { unreadReason = 'this Declarable server does not say whether the release has been matched'; } else if (current === null) { unreadReason = 'the product has no current release'; } else if (release.id && current.id !== release.id) { unreadReason = `release ${release.version} is not the product's current release (${current.version} is), ` + 'and findings are read for the current release only'; } else if (current.checked !== true) { unreadReason = 'the release has not been matched yet'; } if (unreadReason) status = null; } const open = status?.openVulnerabilities ?? null; const exploited = open?.exploited ?? null; const productPage = `${baseUrl}/app/products/${encodeURIComponent(productId)}`; const gapList = Array.isArray(status?.gaps) ? status.gaps : null; const gapCounts = { blocker: 0, major: 0, minor: 0 }; for (const gap of gapList ?? []) { if (gap?.severity in gapCounts) gapCounts[gap.severity] += 1; } const gapsOver = gapList === null || failOnGap === 'none' ? 0 : gapCounts.blocker + (failOnGap === 'blocker' ? 0 : gapCounts.major) + (failOnGap === 'blocker' || failOnGap === 'major' ? 0 : gapCounts.minor); const unchecked = unreadReason !== null || gapList === null; const uncheckedReason = unreadReason ?? 'the release has not been matched yet'; const gapText = unchecked ? `Not checked yet (${uncheckedReason})` : `${gapCounts.blocker} blocker, ${gapCounts.major} major, ${gapCounts.minor} minor`; console.log(`Gaps: ${gapText}`); const SHOWN_GAPS = 10; const levelsShown = failOnGap === 'none' ? [] : GAP_LEVELS.slice(0, GAP_LEVELS.indexOf(failOnGap) + 1); const namedGaps = (gapList ?? []).filter((gap) => levelsShown.includes(gap?.severity)); for (const gap of namedGaps.slice(0, SHOWN_GAPS)) console.log(` ${gap.severity}: ${gap.title}`); if (namedGaps.length > SHOWN_GAPS) console.log(` and ${namedGaps.length - SHOWN_GAPS} more`); console.log(`Release: ${release.url}`); const failures = []; if (gapsOver > 0) failures.push(`${gapsOver} gap${gapsOver === 1 ? '' : 's'} at "${failOnGap}" or worse.`); if (failOnExploited && exploited !== null && exploited > 0) { failures.push(`${exploited} open vulnerabilit${exploited === 1 ? 'y is' : 'ies are'} known to be exploited.`); } const uncheckedLine = `Uploaded. Findings not checked: ${uncheckedReason}.`; const cell = (n) => (n === null || n === undefined ? 'Not checked yet' : String(n)); const byConfidence = open?.byConfidence ?? null; const gapLines = namedGaps.slice(0, SHOWN_GAPS).map((gap) => `- ${gap.severity}: ${gap.title}`); if (namedGaps.length > SHOWN_GAPS) gapLines.push(`- and ${namedGaps.length - SHOWN_GAPS} more`); const lines = [ '## Declarable SBOM upload', '', alreadyExists ? `Release **${release.version}** already exists with different files. Nothing was replaced. [Open the release](${release.url}) · [Product page](${productPage})` : unchanged ? `Release **${release.version}** already uploaded with the same files; nothing new was recorded. [Open the release](${release.url}) · [Product page](${productPage})` : `Uploaded **${files.length}** file(s) as release **${release.version}** (${release.componentCount} components). [Open the release](${release.url}) · [Product page](${productPage})`, '', '| Open vulnerabilities, by match confidence | Count |', '| --- | --- |', `| High confidence | ${cell(byConfidence?.high)} |`, `| Medium confidence | ${cell(byConfidence?.medium)} |`, `| Low confidence | ${cell(byConfidence?.low)} |`, `| Known to be exploited | ${cell(exploited)} |`, `| Gaps | ${gapText} |`, '', ]; if (gapLines.length) lines.push(...gapLines, ''); if (statusNote) lines.push(statusNote, ''); lines.push( failures.length ? `**Failed:** ${failures.join(' ')}` : unchecked ? `**${uncheckedLine}**` : '**Inside your thresholds.**', '', 'Draft findings from your SBOM, for review. Not legal advice and not a certification.', ); writeSummary(lines.join('\n')); if (failures.length) { console.error(`Failed: ${failures.join(" ")}`); annotate('error', 'Declarable threshold crossed', `Failed: ${failures.join(' ')}`); process.exitCode = 1; return; } // Exit stays 0: an upload that worked is not a failed build. The log's last line says what was // and was not checked. console.log(unchecked ? uncheckedLine : 'Inside your thresholds.'); if (unchecked) annotate('warning', 'Declarable findings not checked', uncheckedLine); } main().catch((error) => { if (error instanceof StopSignal) return; throw error; });