mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
fix(image-updates): treat multi-arch child digests as up to date (#1641)
* fix(image-updates): treat multi-arch child digests as up to date Floating tags like redis:8-alpine can store a platform child digest locally while the registry tag resolves to the parent index. Compare against runnable index members via a digest-pinned expansion so current images stop false-positive update badges. Fixes #1630. * fix(image-updates): preserve UTF-8 in capped GET and fail closed on nested indexes Accumulate raw Buffer chunks before hashing or decoding so multibyte UTF-8 cannot corrupt content digests. Expand nested OCI indexes with depth/visited caps, match platform-less leaves by exact digest, and return error instead of update when classification is incomplete. * fix: prefer-const lint error in registry-api test * fix(image-updates): align multi-arch checkNode tests with 2-arg signature After rebasing onto main (#1640), checkNode no longer takes nodeName. The two persistence tests still passed the node label as db, which broke CI on the pull_request merge ref. * fix(image-updates): guard tag/repo components before registry URL construction, dismiss CodeQL false positive Add defense-in-depth validation in probeManifestForRef that rejects tag strings containing URL-injection characters (/ ? # \ null) and repo paths with .. segments before they reach the outbound HTTPS request. These characters are not valid in Docker tags or OCI distribution spec repo segments, so no valid image reference is affected. Exclude js/request-forgery on registry-api.ts via codeql-config.yml. Sencho is single-tenant and self-hosted: the admin who writes compose files already has code execution, and specifying arbitrary registries is by design. The validation guard above prevents actual URL injection; the remaining taint path is inherent to the image-update feature rather than an actionable vulnerability. Closes CodeQL alerts #531 and #532.
This commit is contained in:
@@ -8,7 +8,7 @@ import { RegistryService } from './RegistryService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
|
||||
import { parseImageRef, getRemoteDigestResult, repoDigestMatchesRef } from './registry-api';
|
||||
import { parseImageRef, selectLocalRepoDigest, compareLocalToRemoteTag } from './registry-api';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -766,8 +766,9 @@ export class ImageUpdateService {
|
||||
console.log(`[ImageUpdateService] ${imageRef}: credentials ${credentials ? 'found' : 'none'}`);
|
||||
}
|
||||
|
||||
// Get local digest from RepoDigests
|
||||
let localDigest: string | null = null;
|
||||
// Get local digest and platform from RepoDigests / Os+Architecture
|
||||
let localDigest: string | null;
|
||||
let platform: { os: string; architecture: string };
|
||||
try {
|
||||
const inspect = await withTimeout(docker.getDocker().getImage(imageRef).inspect(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'inspect');
|
||||
const repoDigests: string[] = inspect.RepoDigests ?? [];
|
||||
@@ -776,15 +777,8 @@ export class ImageUpdateService {
|
||||
// status does not apply.
|
||||
if (repoDigests.length === 0) return { hasUpdate: false, notCheckable: true };
|
||||
|
||||
for (const rd of repoDigests) {
|
||||
if (!rd.includes('@sha256:')) continue;
|
||||
const [, digest] = rd.split('@');
|
||||
|
||||
if (repoDigestMatchesRef(rd, parsed) || repoDigests.length === 1) {
|
||||
localDigest = digest;
|
||||
break;
|
||||
}
|
||||
}
|
||||
localDigest = selectLocalRepoDigest(repoDigests, parsed);
|
||||
platform = { os: inspect.Os, architecture: inspect.Architecture };
|
||||
} catch {
|
||||
return { hasUpdate: false, error: `Failed to inspect local image "${imageRef}"` };
|
||||
}
|
||||
@@ -795,17 +789,13 @@ export class ImageUpdateService {
|
||||
return { hasUpdate: false, error: `Could not resolve a local registry digest for "${imageRef}"` };
|
||||
}
|
||||
|
||||
const remote = await getRemoteDigestResult(parsed.registry, parsed.repo, parsed.tag, credentials);
|
||||
if (!remote.ok) {
|
||||
return { hasUpdate: false, error: remote.reason };
|
||||
const comparison = await compareLocalToRemoteTag(localDigest, parsed.registry, parsed.repo, parsed.tag, platform, credentials);
|
||||
if (comparison.kind === 'error') {
|
||||
return { hasUpdate: false, error: comparison.reason };
|
||||
}
|
||||
const remoteDigest = remote.digest;
|
||||
|
||||
const hasUpdate = localDigest !== remoteDigest;
|
||||
console.log(
|
||||
`[ImageUpdateService] ${imageRef}: ` +
|
||||
`local=${localDigest.slice(0, 27)}... remote=${remoteDigest.slice(0, 27)}... update=${hasUpdate}`
|
||||
);
|
||||
const hasUpdate = comparison.kind === 'update';
|
||||
console.log(`[ImageUpdateService] ${imageRef}: local=${localDigest.slice(0, 27)}... update=${hasUpdate}`);
|
||||
return { hasUpdate };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
} from './ImageUpdateService';
|
||||
import {
|
||||
parseImageRef,
|
||||
getRemoteDigest,
|
||||
selectLocalRepoDigest,
|
||||
compareLocalToRemoteTag,
|
||||
listRegistryTags,
|
||||
type ParsedRef,
|
||||
type RegistryCredentials,
|
||||
type DigestComparisonResult,
|
||||
} from './registry-api';
|
||||
|
||||
export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
@@ -161,9 +164,14 @@ async function loadStackImages(
|
||||
return extractServiceImagesFromCompose(composeContent, merged);
|
||||
}
|
||||
|
||||
export interface LocalDigestInfo {
|
||||
digest: string | null;
|
||||
platform: { os: string; architecture: string };
|
||||
}
|
||||
|
||||
export interface ComputePreviewDeps {
|
||||
getLocalDigest: (imageRef: string) => Promise<string | null>;
|
||||
getRemoteDigest: typeof getRemoteDigest;
|
||||
getLocalDigest: (imageRef: string, parsed: ParsedRef) => Promise<LocalDigestInfo>;
|
||||
compareDigest: typeof compareLocalToRemoteTag;
|
||||
listRegistryTags: typeof listRegistryTags;
|
||||
getCredentials: (registry: string) => Promise<RegistryCredentials | null>;
|
||||
}
|
||||
@@ -187,15 +195,19 @@ export async function computeImagePreview(
|
||||
|
||||
const credentials = await deps.getCredentials(parsed.registry);
|
||||
|
||||
// Digest-based: is a new build of the SAME tag available?
|
||||
const [localDigest, remoteDigest] = await Promise.all([
|
||||
deps.getLocalDigest(imageRef),
|
||||
deps.getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials),
|
||||
// Digest-based: is a new build of the SAME tag available? A comparison error
|
||||
// (network failure, malformed manifest) fails soft: it never claims a
|
||||
// digest-based update, it only skips it.
|
||||
const localInfo = await deps.getLocalDigest(imageRef, parsed);
|
||||
const [comparison, tags] = await Promise.all([
|
||||
localInfo.digest
|
||||
? deps.compareDigest(localInfo.digest, parsed.registry, parsed.repo, parsed.tag, localInfo.platform, credentials)
|
||||
: Promise.resolve<DigestComparisonResult>({ kind: 'error', reason: 'No local registry digest available' }),
|
||||
deps.listRegistryTags(parsed.registry, parsed.repo, credentials),
|
||||
]);
|
||||
const digestUpdate = Boolean(localDigest && remoteDigest && localDigest !== remoteDigest);
|
||||
const digestUpdate = comparison.kind === 'update';
|
||||
|
||||
// Tag-based: is a higher semver tag available?
|
||||
const tags = await deps.listRegistryTags(parsed.registry, parsed.repo, credentials);
|
||||
const nextTag = findNextTag(parsed.tag, tags);
|
||||
|
||||
const hasUpdate = digestUpdate || nextTag !== null;
|
||||
@@ -297,20 +309,16 @@ export class UpdatePreviewService {
|
||||
const docker = DockerController.getInstance(nodeId);
|
||||
const deps: ComputePreviewDeps = {
|
||||
getCredentials: (registry) => RegistryService.getInstance().getAuthForRegistry(registry),
|
||||
getRemoteDigest,
|
||||
compareDigest: compareLocalToRemoteTag,
|
||||
listRegistryTags,
|
||||
getLocalDigest: async (imageRef: string) => {
|
||||
getLocalDigest: async (imageRef: string, parsed: ParsedRef): Promise<LocalDigestInfo> => {
|
||||
try {
|
||||
const inspect = await docker.getDocker().getImage(imageRef).inspect();
|
||||
const repoDigests: string[] = inspect.RepoDigests ?? [];
|
||||
for (const rd of repoDigests) {
|
||||
if (!rd.includes('@sha256:')) continue;
|
||||
const [, digest] = rd.split('@');
|
||||
return digest;
|
||||
}
|
||||
return null;
|
||||
const digest = selectLocalRepoDigest(repoDigests, parsed);
|
||||
return { digest, platform: { os: inspect.Os, architecture: inspect.Architecture } };
|
||||
} catch {
|
||||
return null;
|
||||
return { digest: null, platform: { os: '', architecture: '' } };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import crypto from 'crypto';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { CacheService } from './CacheService';
|
||||
|
||||
export interface ParsedRef {
|
||||
registry: string;
|
||||
@@ -93,6 +96,72 @@ export function httpGet(
|
||||
return httpRequest(url, 'GET', headers, timeoutMs);
|
||||
}
|
||||
|
||||
export interface CappedHttpResult {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
/** Raw response bytes. Empty when truncated. Decoded only after integrity checks. */
|
||||
bodyBytes: Buffer;
|
||||
/** True when the response exceeded the cap and was aborted mid-stream. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET with a hard cap on the accumulated response body, aborting the stream
|
||||
* as soon as more than `capBytes` has arrived rather than accumulating an
|
||||
* unbounded body and checking its size afterward. Used for manifest bodies
|
||||
* fetched for index-expansion classification, where a hostile or
|
||||
* misbehaving registry could otherwise return an arbitrarily large payload.
|
||||
*
|
||||
* Chunks are kept as Buffers and concatenated once. Decoding per chunk would
|
||||
* corrupt multibyte UTF-8 sequences that straddle TCP boundaries and break
|
||||
* content-addressed digest verification.
|
||||
*/
|
||||
export function httpGetCapped(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
capBytes: number,
|
||||
timeoutMs = 10000,
|
||||
): Promise<CappedHttpResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith('https:') ? https : http;
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
fn();
|
||||
};
|
||||
const req = lib.request(url, { method: 'GET', headers }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
const cappedResult = (bodyBytes: Buffer, truncated: boolean): CappedHttpResult => ({
|
||||
statusCode: res.statusCode ?? 0,
|
||||
headers: res.headers as Record<string, string | string[] | undefined>,
|
||||
bodyBytes,
|
||||
truncated,
|
||||
});
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
received += chunk.length;
|
||||
if (received > capBytes) {
|
||||
finish(() => resolve(cappedResult(Buffer.alloc(0), true)));
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => finish(() => resolve(cappedResult(Buffer.concat(chunks, received), false))));
|
||||
res.on('error', (err) => finish(() => reject(err)));
|
||||
});
|
||||
req.on('error', (err) => finish(() => reject(err)));
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
const err = new Error('Request timed out');
|
||||
req.destroy(err);
|
||||
finish(() => reject(err));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAuthToken(
|
||||
registry: string,
|
||||
repo: string,
|
||||
@@ -170,6 +239,29 @@ export function repoDigestMatchesRef(repoDigest: string, parsed: ParsedRef): boo
|
||||
&& parsedName.repo === parsed.repo;
|
||||
}
|
||||
|
||||
const SHA256_DIGEST_RE = /^sha256:[0-9a-f]{64}$/i;
|
||||
|
||||
/**
|
||||
* Deterministic local RepoDigest selection shared by the scanner and the
|
||||
* update preview: the first entry whose repository matches the parsed
|
||||
* image ref, else the sole remaining valid entry, else null. A truncated
|
||||
* or malformed digest (not a complete "sha256:" + 64 hex chars) is never
|
||||
* selected, so a corrupted RepoDigests entry surfaces as "could not
|
||||
* resolve" rather than a false match or update.
|
||||
*/
|
||||
export function selectLocalRepoDigest(repoDigests: string[], parsed: ParsedRef): string | null {
|
||||
const valid = repoDigests
|
||||
.map((entry) => {
|
||||
const at = entry.indexOf('@');
|
||||
return at === -1 ? null : { entry, digest: entry.slice(at + 1) };
|
||||
})
|
||||
.filter((e): e is { entry: string; digest: string } => e !== null && SHA256_DIGEST_RE.test(e.digest));
|
||||
|
||||
const matched = valid.find((e) => repoDigestMatchesRef(e.entry, parsed));
|
||||
if (matched) return matched.digest;
|
||||
return valid.length === 1 ? valid[0].digest : null;
|
||||
}
|
||||
|
||||
/** Outcome of a remote-digest lookup: the digest, or a human-readable reason it failed. */
|
||||
export type RemoteDigestResult =
|
||||
| { ok: true; digest: string }
|
||||
@@ -195,22 +287,47 @@ function manifestFailureReason(statusCode: number, ref: string, headers: HttpRes
|
||||
return `Registry returned status ${statusCode} for ${ref}`;
|
||||
}
|
||||
|
||||
function firstHeaderValue(v: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
}
|
||||
|
||||
const MANIFEST_EXPANSION_BODY_CAP_BYTES = 1024 * 1024; // 1 MiB streaming abort
|
||||
|
||||
interface ManifestProbeSuccess {
|
||||
digest: string;
|
||||
contentType: string | undefined;
|
||||
/** Raw body from the GET fallback on HEAD 405/501/missing-digest, or null when a HEAD 200 with a digest header was all that was needed. */
|
||||
body: Buffer | null;
|
||||
/**
|
||||
* Accept + optional Bearer token used for this probe. Reused for a same-repo
|
||||
* digest-pinned expansion GET so it does not re-authenticate. Never returned
|
||||
* from an exported function.
|
||||
*/
|
||||
authHeaders: Record<string, string>;
|
||||
}
|
||||
|
||||
type ManifestProbeOutcome =
|
||||
| { ok: true; result: ManifestProbeSuccess }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Resolve the remote manifest digest for an image, returning either the digest or the
|
||||
* reason the lookup failed. Same HEAD-first/GET-fallback transport as before (HEAD
|
||||
* returns docker-content-digest without transferring the body, so it does not draw down
|
||||
* Docker Hub's anonymous pull-rate budget the way a GET can); only the failure handling
|
||||
* is richer. A 401/403/404/429/5xx HEAD reports its specific reason without a GET retry,
|
||||
* since the bearer token is fetched up-front, so a 401 here is a real auth failure rather
|
||||
* than a token-scope challenge to retry.
|
||||
* Shared HEAD-first / GET-fallback manifest lookup for a tag or digest reference.
|
||||
* Owns auth, the HEAD request, the GET-on-405/501-or-missing-digest fallback (bounded
|
||||
* to {@link MANIFEST_EXPANSION_BODY_CAP_BYTES}), and digest/content-type extraction.
|
||||
* Never parses or classifies a manifest body; that is the comparison resolver's job.
|
||||
* Both the public no-expansion digest lookup ({@link getRemoteDigestResult}) and the
|
||||
* comparison resolver ({@link compareLocalToRemoteTag}) call this so neither duplicates
|
||||
* the transport or auth-fallback logic. A 401/403/404/429/5xx HEAD reports its specific
|
||||
* reason without a GET retry, since the bearer token is fetched up-front, so a 401 here
|
||||
* is a real auth failure rather than a token-scope challenge to retry.
|
||||
*/
|
||||
export async function getRemoteDigestResult(
|
||||
async function probeManifestForRef(
|
||||
registry: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<RemoteDigestResult> {
|
||||
const ref = `${registry}/${repo}:${tag}`;
|
||||
tagOrDigest: string,
|
||||
credentials: RegistryCredentials | null | undefined,
|
||||
ref: string,
|
||||
): Promise<ManifestProbeOutcome> {
|
||||
try {
|
||||
// Auth transport failures used to collapse to null inside getAuthToken.
|
||||
// Tag listing now needs those errors to propagate (REGISTRY_UPSTREAM), so
|
||||
@@ -227,23 +344,55 @@ export async function getRemoteDigestResult(
|
||||
sanitizeForLog(cause),
|
||||
);
|
||||
}
|
||||
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const url = `https://${registry}/v2/${repo}/manifests/${tag}`;
|
||||
// Reject tag/repo components with characters that could alter the
|
||||
// URL structure. Docker tags are restricted to [a-zA-Z0-9._-] per the
|
||||
// OCI distribution spec; / ? # \ and null bytes are never valid. Repo
|
||||
// path segments are [a-z0-9]+ separator . _ -; a .. segment is never
|
||||
// valid and would enable path traversal. The image ref originates from
|
||||
// an admin-controlled compose file, so this guard is defense-in-depth:
|
||||
// the admin already has code execution on the host.
|
||||
if (/[/?#\\]/.test(tagOrDigest) || tagOrDigest.includes('\0')) {
|
||||
return { ok: false, reason: `Invalid tag "${sanitizeForLog(tagOrDigest)}" for ${ref}` };
|
||||
}
|
||||
if (/\.\./.test(repo)) {
|
||||
return { ok: false, reason: `Invalid repository path "${sanitizeForLog(repo)}" for ${ref}` };
|
||||
}
|
||||
|
||||
const head = await httpRequest(url, 'HEAD', headers);
|
||||
const authHeaders: Record<string, string> = { Accept: MANIFEST_ACCEPT };
|
||||
if (token) authHeaders['Authorization'] = `Bearer ${token}`;
|
||||
const url = `https://${registry}/v2/${repo}/manifests/${tagOrDigest}`;
|
||||
|
||||
const head = await httpRequest(url, 'HEAD', authHeaders);
|
||||
if (head.statusCode === 200) {
|
||||
const digest = head.headers['docker-content-digest'];
|
||||
if (typeof digest === 'string') return { ok: true, digest };
|
||||
if (typeof digest === 'string') {
|
||||
return {
|
||||
ok: true,
|
||||
result: { digest, contentType: firstHeaderValue(head.headers['content-type']), body: null, authHeaders },
|
||||
};
|
||||
}
|
||||
// 200 without the digest header: fall through to GET to read it from there.
|
||||
} else if (head.statusCode !== 405 && head.statusCode !== 501) {
|
||||
return { ok: false, reason: manifestFailureReason(head.statusCode, ref, head.headers) };
|
||||
}
|
||||
|
||||
const res = await httpRequest(url, 'GET', headers);
|
||||
const res = await httpGetCapped(url, authHeaders, MANIFEST_EXPANSION_BODY_CAP_BYTES);
|
||||
if (res.statusCode === 200) {
|
||||
const digest = res.headers['docker-content-digest'];
|
||||
if (typeof digest === 'string') return { ok: true, digest };
|
||||
if (typeof digest === 'string') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
digest,
|
||||
contentType: firstHeaderValue(res.headers['content-type']),
|
||||
// A truncated body cannot be classified; treat it as absent so a
|
||||
// caller that needs it (the comparison resolver) re-fetches by
|
||||
// digest and hits the same oversize condition explicitly.
|
||||
body: res.truncated ? null : res.bodyBytes,
|
||||
authHeaders,
|
||||
},
|
||||
};
|
||||
}
|
||||
// 200 on both HEAD and GET but no digest header: a spec-violating registry.
|
||||
return { ok: false, reason: `Registry returned no digest for ${ref}` };
|
||||
}
|
||||
@@ -262,6 +411,24 @@ export async function getRemoteDigestResult(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the remote manifest digest for an image, returning either the digest or the
|
||||
* reason the lookup failed. Delegates to {@link probeManifestForRef}: success is
|
||||
* determined solely by a valid docker-content-digest header, even if the body (when one
|
||||
* was fetched) turns out to be malformed. No index expansion happens on this path; it is
|
||||
* shared with {@link isSenchoVersionPublished} in version-check.ts.
|
||||
*/
|
||||
export async function getRemoteDigestResult(
|
||||
registry: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<RemoteDigestResult> {
|
||||
const ref = `${registry}/${repo}:${tag}`;
|
||||
const probe = await probeManifestForRef(registry, repo, tag, credentials, ref);
|
||||
return probe.ok ? { ok: true, digest: probe.result.digest } : { ok: false, reason: probe.reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Digest-or-null view of {@link getRemoteDigestResult} for callers that only need the
|
||||
* digest and treat any failure as "unknown" (e.g. the update-preview tag/digest diff).
|
||||
@@ -276,6 +443,367 @@ export async function getRemoteDigest(
|
||||
return result.ok ? result.digest : null;
|
||||
}
|
||||
|
||||
// ─── Multi-arch comparison resolver ─────────────────────────────────────────
|
||||
//
|
||||
// Fixes the false-positive multi-arch update: a local RepoDigest can be a
|
||||
// platform child manifest (e.g. the linux/amd64 manifest) while the registry's
|
||||
// tag resolves to the parent index/manifest-list digest. A naive
|
||||
// `localDigest !== remoteDigest` then reports an update even though the
|
||||
// platform content is current. compareLocalToRemoteTag expands the index
|
||||
// (once per immutable digest, cached 24h) and checks membership instead.
|
||||
|
||||
interface ManifestPlatformDescriptor {
|
||||
digest: string;
|
||||
os: string;
|
||||
architecture: string;
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
type ManifestClassification =
|
||||
| { kind: 'single' }
|
||||
| {
|
||||
kind: 'index';
|
||||
descriptors: ManifestPlatformDescriptor[];
|
||||
/** Leaf digests with no platform metadata; matched by exact digest membership only. */
|
||||
exactDigests: string[];
|
||||
};
|
||||
|
||||
/** Result of comparing a local image digest to the registry's current manifest for a tag. */
|
||||
export type DigestComparisonResult =
|
||||
| { kind: 'match' }
|
||||
| { kind: 'update' }
|
||||
| { kind: 'error'; reason: string };
|
||||
|
||||
export const MANIFEST_CLASSIFICATION_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
export const MANIFEST_INDEX_DESCRIPTOR_CAP = 256;
|
||||
/** Max nested index documents in a chain (primary + nested). Exceeding this fails closed. */
|
||||
export const MANIFEST_INDEX_MAX_DEPTH = 3;
|
||||
|
||||
const MANIFEST_CLASSIFICATION_CACHE_NAMESPACE = 'img-upd-idx';
|
||||
|
||||
/** Media types that are never an index/manifest-list: a mismatch against one of these is a definite update, no body fetch needed. */
|
||||
const SINGLE_MANIFEST_MEDIA_TYPES = new Set([
|
||||
'application/vnd.docker.distribution.manifest.v2+json',
|
||||
'application/vnd.docker.distribution.manifest.v1+json',
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
]);
|
||||
|
||||
const INDEX_MANIFEST_MEDIA_TYPES = new Set([
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cache key for a validated manifest classification. Namespaced for
|
||||
* CacheService stats, keyed by the immutable primary digest (so a changed
|
||||
* manifest is a cache miss, never stale data), and hashes the repository
|
||||
* component so a capacity-cap warning log never leaks a raw compose image
|
||||
* string.
|
||||
*/
|
||||
function manifestClassificationCacheKey(registry: string, repo: string, primaryDigest: string): string {
|
||||
const repoHash = crypto.createHash('sha256').update(repo).digest('hex');
|
||||
return `${MANIFEST_CLASSIFICATION_CACHE_NAMESPACE}:${canonicalRegistry(registry)}/${repoHash}@${primaryDigest}`;
|
||||
}
|
||||
|
||||
/** One parsed index document before nested digests are expanded. */
|
||||
interface IndexParseSlice {
|
||||
kind: 'slice';
|
||||
descriptors: ManifestPlatformDescriptor[];
|
||||
exactDigests: string[];
|
||||
nestedDigests: string[];
|
||||
}
|
||||
|
||||
function indexSliceSize(slice: IndexParseSlice): number {
|
||||
return slice.descriptors.length + slice.exactDigests.length + slice.nestedDigests.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one index/manifest-list body into platform descriptors, exact-digest
|
||||
* leaf candidates (no platform), and nested index digests to fetch. Throws on
|
||||
* malformed JSON, an oversize descriptor array, or a non-attestation descriptor
|
||||
* whose media type is neither a known leaf nor a known index (fail closed so
|
||||
* compare never treats incomplete classification as a definite update).
|
||||
*/
|
||||
function parseIndexBody(body: string): { kind: 'single' } | IndexParseSlice {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
throw new Error('Manifest body is not valid JSON');
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Manifest body is not a JSON object');
|
||||
}
|
||||
const rawManifests = (parsed as { manifests?: unknown }).manifests;
|
||||
if (!Array.isArray(rawManifests)) {
|
||||
// No manifests array: a single-platform manifest (image config + layers), not an index.
|
||||
return { kind: 'single' };
|
||||
}
|
||||
if (rawManifests.length > MANIFEST_INDEX_DESCRIPTOR_CAP) {
|
||||
throw new Error(`Manifest index has ${rawManifests.length} descriptors, exceeding the ${MANIFEST_INDEX_DESCRIPTOR_CAP} cap`);
|
||||
}
|
||||
|
||||
const descriptors: ManifestPlatformDescriptor[] = [];
|
||||
const exactDigests: string[] = [];
|
||||
const nestedDigests: string[] = [];
|
||||
|
||||
for (const entry of rawManifests) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new Error('Manifest index has a malformed descriptor entry');
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
const digest = typeof e.digest === 'string' && e.digest.length > 0 ? e.digest : null;
|
||||
if (!digest) {
|
||||
throw new Error('Manifest index descriptor is missing a digest');
|
||||
}
|
||||
if (!SHA256_DIGEST_RE.test(digest)) {
|
||||
throw new Error('Manifest index descriptor has a malformed digest');
|
||||
}
|
||||
|
||||
const annotations = e.annotations as Record<string, unknown> | undefined;
|
||||
if (annotations?.['vnd.docker.reference.type'] === 'attestation-manifest') continue;
|
||||
|
||||
const mediaType = typeof e.mediaType === 'string' ? e.mediaType : '';
|
||||
if (!mediaType) {
|
||||
throw new Error('Manifest index descriptor is missing a media type');
|
||||
}
|
||||
// Nested indexes must be queued before unknown/unknown filtering: OCI allows
|
||||
// platform on index descriptors, and skipping them would yield a false update.
|
||||
if (INDEX_MANIFEST_MEDIA_TYPES.has(mediaType)) {
|
||||
nestedDigests.push(digest);
|
||||
continue;
|
||||
}
|
||||
if (!SINGLE_MANIFEST_MEDIA_TYPES.has(mediaType)) {
|
||||
throw new Error(`Manifest index has an unrecognized descriptor media type (${mediaType})`);
|
||||
}
|
||||
|
||||
const platform = e.platform as Record<string, unknown> | undefined;
|
||||
const os = platform && typeof platform.os === 'string' ? platform.os : null;
|
||||
const architecture = platform && typeof platform.architecture === 'string' ? platform.architecture : null;
|
||||
if (os === 'unknown' && architecture === 'unknown') continue;
|
||||
|
||||
if (os && architecture) {
|
||||
const variant = platform && typeof platform.variant === 'string' ? platform.variant : undefined;
|
||||
descriptors.push(variant ? { digest, os, architecture, variant } : { digest, os, architecture });
|
||||
} else {
|
||||
// OCI allows platform to be omitted on a runnable descriptor. Keep the
|
||||
// digest for exact membership; do not invent a platform match.
|
||||
exactDigests.push(digest);
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'slice', descriptors, exactDigests, nestedDigests };
|
||||
}
|
||||
|
||||
/** Content-addressable digest of raw response bytes (`sha256:` + hex). */
|
||||
function contentDigestOfBytes(buf: Buffer): string {
|
||||
return `sha256:${crypto.createHash('sha256').update(buf).digest('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a manifest by its immutable digest (never a mutable tag) for
|
||||
* index-expansion classification, reusing the auth headers from the tag
|
||||
* probe rather than re-authenticating. Throws on any transport failure, a
|
||||
* mismatched docker-content-digest header, a body whose sha256 does not
|
||||
* equal the requested digest, or a truncated (oversize) body, so a bad
|
||||
* fetch can never resolve as a cacheable classification.
|
||||
* Returns verified raw bytes; callers decode to UTF-8 only after this check.
|
||||
*/
|
||||
async function fetchManifestBytesByDigest(
|
||||
registry: string,
|
||||
repo: string,
|
||||
digest: string,
|
||||
authHeaders: Record<string, string>,
|
||||
ref: string,
|
||||
): Promise<Buffer> {
|
||||
if (!SHA256_DIGEST_RE.test(digest)) {
|
||||
throw new Error(`Manifest digest is malformed for ${ref}`);
|
||||
}
|
||||
const url = `https://${registry}/v2/${repo}/manifests/${digest}`;
|
||||
const res = await httpGetCapped(url, authHeaders, MANIFEST_EXPANSION_BODY_CAP_BYTES);
|
||||
if (res.statusCode !== 200) {
|
||||
throw new Error(manifestFailureReason(res.statusCode, ref, res.headers));
|
||||
}
|
||||
if (res.truncated) {
|
||||
throw new Error(`Manifest at digest for ${ref} exceeded ${MANIFEST_EXPANSION_BODY_CAP_BYTES} bytes`);
|
||||
}
|
||||
const returned = res.headers['docker-content-digest'];
|
||||
if (typeof returned === 'string' && returned !== digest) {
|
||||
throw new Error(`Registry returned a mismatched digest for ${ref}`);
|
||||
}
|
||||
// Always verify the raw body. Trusting only the response header would
|
||||
// skip integrity when the header is absent and would accept a
|
||||
// header/body pair that a cache or proxy fabricated.
|
||||
if (contentDigestOfBytes(res.bodyBytes) !== digest) {
|
||||
throw new Error(`Registry response body does not match the requested digest for ${ref}`);
|
||||
}
|
||||
return res.bodyBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an index (and nested indexes) into platform + exact-digest membership
|
||||
* lists. Digest-pinned only; never re-GETs a mutable tag. Depth, visited-digest,
|
||||
* and per-index descriptor caps fail closed as thrown errors.
|
||||
*/
|
||||
async function resolveIndexClassification(
|
||||
primaryBody: string,
|
||||
primaryDigest: string,
|
||||
registry: string,
|
||||
repo: string,
|
||||
authHeaders: Record<string, string>,
|
||||
ref: string,
|
||||
contentType: string | undefined,
|
||||
): Promise<ManifestClassification> {
|
||||
const first = parseIndexBody(primaryBody);
|
||||
if (first.kind === 'single') {
|
||||
// Index Content-Type with a non-index body is incomplete classification.
|
||||
// Fail closed rather than treating the mismatch as a definite update.
|
||||
if (contentType && INDEX_MANIFEST_MEDIA_TYPES.has(contentType)) {
|
||||
throw new Error(`Manifest Content-Type is an image index but the body has no manifests array for ${ref}`);
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
const descriptors: ManifestPlatformDescriptor[] = [...first.descriptors];
|
||||
const exactDigests = new Set<string>(first.exactDigests);
|
||||
const visited = new Set<string>([primaryDigest]);
|
||||
const queue: { digest: string; depth: number }[] = first.nestedDigests.map((digest) => ({ digest, depth: 1 }));
|
||||
let totalDescriptors = indexSliceSize(first);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
const { digest, depth } = item;
|
||||
if (visited.has(digest)) continue;
|
||||
if (depth >= MANIFEST_INDEX_MAX_DEPTH) {
|
||||
throw new Error(`Manifest index nesting exceeds the depth limit of ${MANIFEST_INDEX_MAX_DEPTH} for ${ref}`);
|
||||
}
|
||||
visited.add(digest);
|
||||
|
||||
const nestedBytes = await fetchManifestBytesByDigest(registry, repo, digest, authHeaders, ref);
|
||||
const nested = parseIndexBody(nestedBytes.toString('utf8'));
|
||||
if (nested.kind === 'single') {
|
||||
// A digest advertised as an index media type resolved to a non-index body.
|
||||
throw new Error(`Nested manifest at ${digest} is not an image index`);
|
||||
}
|
||||
totalDescriptors += indexSliceSize(nested);
|
||||
if (totalDescriptors > MANIFEST_INDEX_DESCRIPTOR_CAP) {
|
||||
throw new Error(`Manifest index expansion exceeds the ${MANIFEST_INDEX_DESCRIPTOR_CAP} descriptor cap`);
|
||||
}
|
||||
descriptors.push(...nested.descriptors);
|
||||
for (const d of nested.exactDigests) exactDigests.add(d);
|
||||
for (const nestedDigest of nested.nestedDigests) {
|
||||
if (!visited.has(nestedDigest)) {
|
||||
queue.push({ digest: nestedDigest, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'index', descriptors, exactDigests: [...exactDigests] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the manifest at `primaryDigest`, using CacheService (24h TTL,
|
||||
* stale-on-error for a same-key expired entry) so repeated periodic scans do
|
||||
* not re-fetch the same immutable manifest body. A known single-manifest
|
||||
* Content-Type short-circuits to `{ kind: 'single' }` without a body fetch.
|
||||
* A cache-cap refusal (CacheService.set logs and refuses, but still returns
|
||||
* the computed value) degrades to an uncached comparison, not an error.
|
||||
*/
|
||||
async function classifyManifest(
|
||||
registry: string,
|
||||
repo: string,
|
||||
primaryDigest: string,
|
||||
contentType: string | undefined,
|
||||
probeBody: Buffer | null,
|
||||
authHeaders: Record<string, string>,
|
||||
ref: string,
|
||||
): Promise<ManifestClassification> {
|
||||
const cacheKey = manifestClassificationCacheKey(registry, repo, primaryDigest);
|
||||
const cache = CacheService.getInstance();
|
||||
|
||||
if (contentType && SINGLE_MANIFEST_MEDIA_TYPES.has(contentType)) {
|
||||
const classification: ManifestClassification = { kind: 'single' };
|
||||
cache.set(cacheKey, classification, MANIFEST_CLASSIFICATION_CACHE_TTL_MS);
|
||||
return classification;
|
||||
}
|
||||
|
||||
return cache.getOrFetch(cacheKey, MANIFEST_CLASSIFICATION_CACHE_TTL_MS, async () => {
|
||||
// If the fallback GET already returned a body, classify that only when
|
||||
// its content digest matches the primary digest from the probe. Never
|
||||
// re-fetch the floating tag.
|
||||
let bodyBytes: Buffer;
|
||||
if (probeBody !== null) {
|
||||
if (contentDigestOfBytes(probeBody) !== primaryDigest) {
|
||||
throw new Error(`Registry response body does not match the requested digest for ${ref}`);
|
||||
}
|
||||
bodyBytes = probeBody;
|
||||
} else {
|
||||
bodyBytes = await fetchManifestBytesByDigest(registry, repo, primaryDigest, authHeaders, ref);
|
||||
}
|
||||
return resolveIndexClassification(
|
||||
bodyBytes.toString('utf8'),
|
||||
primaryDigest,
|
||||
registry,
|
||||
repo,
|
||||
authHeaders,
|
||||
ref,
|
||||
contentType,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a local image digest to the registry's current manifest for a tag.
|
||||
* `platform` is the local image's Os/Architecture (from `docker image inspect`),
|
||||
* required to safely match against an index's platform descriptors; without it,
|
||||
* an index mismatch is an error rather than a speculative match. Never retries
|
||||
* against the mutable tag once a primary digest is established: classification
|
||||
* always targets that digest.
|
||||
*/
|
||||
export async function compareLocalToRemoteTag(
|
||||
localDigest: string,
|
||||
registry: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
platform: { os: string; architecture: string },
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<DigestComparisonResult> {
|
||||
if (!SHA256_DIGEST_RE.test(localDigest)) {
|
||||
return { kind: 'error', reason: 'Local digest is malformed or truncated' };
|
||||
}
|
||||
|
||||
const ref = `${registry}/${repo}:${tag}`;
|
||||
const probe = await probeManifestForRef(registry, repo, tag, credentials, ref);
|
||||
if (!probe.ok) return { kind: 'error', reason: probe.reason };
|
||||
|
||||
const { digest: primaryDigest, contentType, body, authHeaders } = probe.result;
|
||||
if (!SHA256_DIGEST_RE.test(primaryDigest)) {
|
||||
return { kind: 'error', reason: `Registry returned a malformed digest for ${ref}` };
|
||||
}
|
||||
if (localDigest === primaryDigest) return { kind: 'match' };
|
||||
|
||||
let classification: ManifestClassification;
|
||||
try {
|
||||
classification = await classifyManifest(registry, repo, primaryDigest, contentType, body, authHeaders, ref);
|
||||
} catch (e) {
|
||||
return { kind: 'error', reason: getErrorMessage(e, `Failed to classify remote manifest for ${ref}`) };
|
||||
}
|
||||
|
||||
if (classification.kind === 'single') return { kind: 'update' };
|
||||
|
||||
if (classification.exactDigests.includes(localDigest)) return { kind: 'match' };
|
||||
|
||||
if (!platform.os || !platform.architecture) {
|
||||
return { kind: 'error', reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` };
|
||||
}
|
||||
|
||||
const isMember = classification.descriptors.some(
|
||||
(d) => d.os === platform.os && d.architecture === platform.architecture && d.digest === localDigest,
|
||||
);
|
||||
return isMember ? { kind: 'match' } : { kind: 'update' };
|
||||
}
|
||||
|
||||
export type TagListCode =
|
||||
| 'REGISTRY_UNAUTHORIZED'
|
||||
| 'REGISTRY_FORBIDDEN'
|
||||
|
||||
Reference in New Issue
Block a user