mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-01 10:09:50 +00:00
29a7b9c5f5
To harden Renovate from being able to access internal hosts, we can add a level of enforcement on all outgoing requests. We can split this into two types of traffic: - where the response becomes configuration (i.e. `extends`) - where the response does not become configuration (i.e. `customDatasources`) To harden this, we enforce on every outbound HTTP request: - check the resolved URL before anything, including cache lookups, can proceed - catching IP-literal hosts in every spelling the WHATWG URL parser normalizes - blocking known Cloud metadata IPs, too - a `dnsLookup` wrapper validates every address a hostname resolves to, on every new connection, which also defeats DNS rebinding - a `beforeRedirect` hook re-runs the URL check on each redirect target, with the new host getting its own grant lookup rather than inheriting the original host's verdict We default this enforcement to `warn`, to not break existing deployments and instead give them visibility into required configuration before a future major version (major+1) enables `block` by default. We'll also remove allow in a future major version (major+2), to give time for admins to migrate over. When a request is blocked, this degrades like an unreachable registry, so a Renovate run isn't aborted by a blocked request. As part of this, we can also provide a worked example of the different cases that do/do not need allowing via `hostRules`. Requests through an admin-configured proxy resolve DNS at the proxy, so `dnsLookup` never runs for them, so we can resolve the hostname locally and validate every address before the request instead. This doesn't catch everything, especially as the proxy may resolve the name to a different result, and if we can't resolve the name - possible in locked-down corporate environments - we fail open. Child processes aren't currently able to route traffic through Renovate's HTTP layer, so are unrestricted. Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io> Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io> Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
248 lines
7.8 KiB
TypeScript
248 lines
7.8 KiB
TypeScript
import { isNonEmptyArray } from '@sindresorhus/is';
|
|
import {
|
|
HOST_BLOCKED,
|
|
HOST_DISABLED,
|
|
} from '../../../constants/error-messages.ts';
|
|
import { logger } from '../../../logger/index.ts';
|
|
import { ExternalHostError } from '../../../types/errors/external-host-error.ts';
|
|
import { withCache } from '../../../util/cache/package/with-cache.ts';
|
|
import { refusedHostMessage } from '../../../util/http/util.ts';
|
|
import { getQueryString, joinUrlParts } from '../../../util/url.ts';
|
|
import { Datasource } from '../datasource.ts';
|
|
import type { GetReleasesConfig, ReleaseResult } from '../types.ts';
|
|
import { type RepologyPackage, RepologyPackages } from './schema.ts';
|
|
import type { RepologyPackageType } from './types.ts';
|
|
|
|
const packageTypes: RepologyPackageType[] = ['binname', 'srcname'];
|
|
|
|
function findPackageInResponse(
|
|
response: RepologyPackage[],
|
|
repoName: string,
|
|
pkgName: string,
|
|
types: RepologyPackageType[],
|
|
): RepologyPackage[] | null {
|
|
const repoPackages = response.filter((pkg) => pkg.repo === repoName);
|
|
|
|
if (repoPackages.length === 0) {
|
|
// no packages associated with repoName
|
|
return null;
|
|
}
|
|
|
|
if (repoPackages.length === 1) {
|
|
// repo contains exactly one package, so we can return them safely
|
|
return repoPackages;
|
|
}
|
|
|
|
// In some cases Repology bundles multiple packages into a single project, which might result in ambiguous results.
|
|
// We need to do additional filtering by matching allowed package types passed as params with package description.
|
|
// Remaining packages are the one we are looking for
|
|
const packagesWithType = repoPackages.filter((pkg) => {
|
|
for (const pkgType of types) {
|
|
if (pkg[pkgType] && pkg[pkgType] === pkgName) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
|
|
return packagesWithType.length > 0 ? packagesWithType : null;
|
|
}
|
|
|
|
export class RepologyDatasource extends Datasource {
|
|
static readonly id = 'repology';
|
|
|
|
override readonly defaultRegistryUrls = ['https://repology.org/'];
|
|
|
|
override readonly registryStrategy = 'hunt';
|
|
|
|
constructor() {
|
|
super(RepologyDatasource.id);
|
|
}
|
|
|
|
private async queryPackages(url: string): Promise<RepologyPackage[]> {
|
|
try {
|
|
const res = await this.http.getJson(url, RepologyPackages);
|
|
return res.body;
|
|
} catch (err) {
|
|
if (err.statusCode === 404) {
|
|
// Return an array here because the api does not return proper http codes
|
|
// and instead of an 404 error an empty array with code 200 is returned
|
|
// When querying the resolver 404 is thrown if package could not be resolved
|
|
// and 403 if the repo is not supported
|
|
// 403 is handled later because in this case we are trying the API
|
|
return [];
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private async queryPackagesViaResolver(
|
|
registryUrl: string,
|
|
repoName: string,
|
|
packageName: string,
|
|
packageType: RepologyPackageType,
|
|
): Promise<RepologyPackage[]> {
|
|
const query = getQueryString({
|
|
repo: repoName,
|
|
name_type: packageType,
|
|
target_page: 'api_v1_project',
|
|
noautoresolve: 'on',
|
|
name: packageName,
|
|
});
|
|
|
|
// Retrieve list of packages by looking up Repology project
|
|
const packages = await this.queryPackages(
|
|
joinUrlParts(registryUrl, `tools/project-by?${query}`),
|
|
);
|
|
|
|
return packages;
|
|
}
|
|
|
|
private async queryPackagesViaAPI(
|
|
registryUrl: string,
|
|
packageName: string,
|
|
): Promise<RepologyPackage[]> {
|
|
// Directly query the package via the API. This will only work if `packageName` has the
|
|
// same name as the repology project
|
|
const packages = await this.queryPackages(
|
|
joinUrlParts(registryUrl, `api/v1/project`, packageName),
|
|
);
|
|
|
|
return packages;
|
|
}
|
|
|
|
private async _queryPackage(
|
|
registryUrl: string,
|
|
repoName: string,
|
|
pkgName: string,
|
|
): Promise<RepologyPackage[] | undefined> {
|
|
let response: RepologyPackage[];
|
|
// Try getting the packages from tools/project-by first for type binname and
|
|
// afterwards for srcname. This needs to be done first, because some packages
|
|
// resolve to repology projects which have a different name than the package
|
|
// e.g. `pulseaudio-utils` resolves to project `pulseaudio`, BUT there is also
|
|
// a project called `pulseaudio-utils` but it does not contain the package we
|
|
// are looking for.
|
|
try {
|
|
for (const pkgType of packageTypes) {
|
|
response = await this.queryPackagesViaResolver(
|
|
registryUrl,
|
|
repoName,
|
|
pkgName,
|
|
pkgType,
|
|
);
|
|
|
|
if (response) {
|
|
const pkg = findPackageInResponse(response, repoName, pkgName, [
|
|
pkgType,
|
|
]);
|
|
if (isNonEmptyArray(pkg)) {
|
|
// exit immediately if package found
|
|
return pkg;
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err.statusCode === 403) {
|
|
logger.debug(
|
|
{ repoName, pkgName },
|
|
'Repology does not support tools/project-by lookups for repository. Will try direct API access now',
|
|
);
|
|
|
|
// If the repository is not supported in tools/project-by we try directly accessing the
|
|
// API. This will support all repositories but requires that the project name is equal to the
|
|
// package name. This won't be always the case but for a good portion we might be able to resolve
|
|
// the package this way.
|
|
response = await this.queryPackagesViaAPI(registryUrl, pkgName);
|
|
const pkg = findPackageInResponse(
|
|
response,
|
|
repoName,
|
|
pkgName,
|
|
packageTypes,
|
|
);
|
|
if (isNonEmptyArray(pkg)) {
|
|
// exit immediately if package found
|
|
return pkg;
|
|
}
|
|
} else if (err.statusCode === 300) {
|
|
logger.warn(
|
|
{ repoName, pkgName },
|
|
'Ambiguous redirection from package name to project name in Repology. Skipping this package',
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
|
|
logger.debug(
|
|
{ repoName, pkgName },
|
|
'Repository or package not found on Repology',
|
|
);
|
|
|
|
return undefined;
|
|
}
|
|
|
|
queryPackage(
|
|
registryUrl: string,
|
|
repoName: string,
|
|
pkgName: string,
|
|
): Promise<RepologyPackage[] | undefined> {
|
|
return withCache(
|
|
{
|
|
ttlMinutes: 60,
|
|
namespace: `datasource-${RepologyDatasource.id}`,
|
|
key: joinUrlParts(registryUrl, repoName, pkgName),
|
|
},
|
|
() => this._queryPackage(registryUrl, repoName, pkgName),
|
|
);
|
|
}
|
|
|
|
async getReleases({
|
|
packageName,
|
|
registryUrl,
|
|
}: GetReleasesConfig): Promise<ReleaseResult | null> {
|
|
/* v8 ignore next -- should never happen */
|
|
if (!registryUrl) {
|
|
return null;
|
|
}
|
|
// Ensure lookup name contains both repository and package
|
|
const [repoName, pkgName] = packageName.split('/', 2);
|
|
if (!repoName || !pkgName) {
|
|
throw new ExternalHostError(
|
|
new Error(
|
|
'Repology lookup name must contain repository and package separated by slash (<repo>/<pkg>)',
|
|
),
|
|
);
|
|
}
|
|
|
|
logger.trace(`repology.getReleases(${repoName}, ${pkgName})`);
|
|
try {
|
|
// Try to retrieve (cached) package information from Repology
|
|
const pkg = await this.queryPackage(registryUrl, repoName, pkgName);
|
|
if (!pkg) {
|
|
return null;
|
|
}
|
|
|
|
// Always prefer origversion if available, otherwise default to version
|
|
// This is required as source packages usually have no origversion
|
|
const releases = pkg.map((item) => ({
|
|
version: item.origversion ?? item.version,
|
|
}));
|
|
return { releases };
|
|
} catch (err) {
|
|
if ([HOST_BLOCKED, HOST_DISABLED].includes(err.message)) {
|
|
logger.trace({ packageName, err }, refusedHostMessage(err));
|
|
} else {
|
|
logger.once.warn(
|
|
{ packageName, err },
|
|
'Repology lookup failed with unexpected error',
|
|
);
|
|
}
|
|
|
|
this.handleGenericErrors(err);
|
|
}
|
|
}
|
|
}
|