mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-01 10:09:50 +00:00
e44c9a2070
* ci: move custom oxlint rules to typescript and alternative API * ci: add to test sharding
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
import type { ESTree } from '@oxlint/plugins';
|
|
import { defineRule } from '@oxlint/plugins';
|
|
import { templatableFields } from './templatable-fields.generated.ts';
|
|
|
|
/**
|
|
* Fields none of the shared lists cover: host rule credentials, and metadata read
|
|
* by a single manager. These are where every injection found so far actually lived
|
|
* -- `distributionUrl` (gradle-wrapper), `distributionType` (maven-wrapper),
|
|
* `organization` and `token` (mix), `token` (nix) -- so they are worth carrying by
|
|
* hand until there is a list to derive them from.
|
|
*/
|
|
const MANAGER_SPECIFIC_UNTRUSTED_NAMES = [
|
|
'distributionType',
|
|
'distributionUrl',
|
|
'extraIndexUrl',
|
|
'indexUrl',
|
|
'lockFile',
|
|
'lockFileName',
|
|
'organization',
|
|
'packageFileName',
|
|
'password',
|
|
'token',
|
|
'username',
|
|
];
|
|
|
|
/**
|
|
* Booleans, numbers and timestamps among the derived fields. They cannot carry
|
|
* shell metacharacters, and flagging them is pure noise -- `newMajor` is a
|
|
* `number`, and reporting `mod upgrade -t=${newMajor}` in manager/gomod is wrong.
|
|
*/
|
|
const NON_STRING_FIELD_RE =
|
|
/^(?:is|has)[A-Z]|(?:Major|Minor|Patch|AgeInDays|Timestamp)$/;
|
|
|
|
/**
|
|
* Fields that hold a value the repository controls, and that therefore should not
|
|
* reach a shell unescaped.
|
|
*
|
|
* The bulk is taken from `templatableFields`, generated from the lists Renovate
|
|
* already maintains (`validMatchFields`, `exposedConfigOptions`, `allowedFields`),
|
|
* rather than restated here -- so a field added to any of them is covered without
|
|
* touching this rule. It is read from the generated file because this runs inside
|
|
* oxlint, which must not depend on `lib/`;
|
|
* `test/other/validate-templatable-fields.spec.ts` keeps the two in step.
|
|
*/
|
|
const UNTRUSTED_NAMES = new Set(
|
|
[...templatableFields, ...MANAGER_SPECIFIC_UNTRUSTED_NAMES].filter(
|
|
(name) => !NON_STRING_FIELD_RE.test(name),
|
|
),
|
|
);
|
|
|
|
/**
|
|
* shlex's escaping helpers. `quote` escapes one value; `join` escapes each element
|
|
* of an array and joins them, so both produce shell-safe output.
|
|
*
|
|
* Only matched as a bare identifier (`quote(x)`) or off a namespace import
|
|
* (`shlex.quote(x)`) -- deliberately not as an arbitrary `.quote()`/`.join()`
|
|
* method, because `Array.prototype.join` shares the name and escapes nothing.
|
|
*/
|
|
const SHLEX_HELPERS = new Set(['quote', 'join']);
|
|
|
|
/** Chain methods that pass a built string through without making it a command. */
|
|
const ARRAY_METHODS = new Set(['map', 'filter', 'flatMap', 'flat', 'concat']);
|
|
|
|
/**
|
|
* Variables/properties that hold a command being assembled. Used to decide
|
|
* whether a template literal is command-shaped, so that log messages and regexes
|
|
* interpolating the same values are left alone.
|
|
*/
|
|
const COMMAND_NAME_RE = /(?:^|[a-z])(?:cmd|command|args)/i;
|
|
|
|
/**
|
|
* Child links probed by `mentionsQuote`'s generic walk. Each key exists on only
|
|
* some node types, so the discriminated `ESTree.Node` union cannot be indexed
|
|
* by them; this reduced view describes just what that walk reads.
|
|
*/
|
|
interface NodeChildren {
|
|
type: string;
|
|
arguments?: ESTree.Node[];
|
|
body?: ESTree.Node | ESTree.Node[] | null;
|
|
callee?: ESTree.Node | null;
|
|
/**
|
|
* On `ArrowFunctionExpression` this is a boolean flag rather than a child
|
|
* node; the walk's `typeof !== 'object'` guard filters it out.
|
|
*/
|
|
expression?: ESTree.Node | boolean | null;
|
|
object?: ESTree.Node | null;
|
|
}
|
|
|
|
function unwrap(node: ESTree.Node | undefined): ESTree.Node | undefined {
|
|
let current = node;
|
|
while (
|
|
current?.type === 'ChainExpression' ||
|
|
current?.type === 'TSNonNullExpression' ||
|
|
current?.type === 'TSAsExpression'
|
|
) {
|
|
current = current.expression;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* The name an interpolated expression reads, or null when it isn't a plain
|
|
* identifier or property access (a call, a ternary, a literal, ...).
|
|
*/
|
|
function readName(input: ESTree.Node | undefined): string | null {
|
|
const node = unwrap(input);
|
|
if (node?.type === 'Identifier') {
|
|
return node.name;
|
|
}
|
|
if (
|
|
node?.type === 'MemberExpression' &&
|
|
node.property.type === 'Identifier'
|
|
) {
|
|
return node.property.name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether this expression mentions shlex's `quote` anywhere within it, which is
|
|
* what makes `deps.map(quote).join(' ')` safe while `deps.join(' ')` is not.
|
|
*/
|
|
function mentionsQuote(
|
|
node: ESTree.Node | boolean | null | undefined,
|
|
): boolean {
|
|
if (!node || typeof node !== 'object') {
|
|
return false;
|
|
}
|
|
if (node.type === 'Identifier' && node.name === 'quote') {
|
|
return true;
|
|
}
|
|
if (
|
|
node.type === 'MemberExpression' &&
|
|
node.property.type === 'Identifier' &&
|
|
node.property.name === 'quote'
|
|
) {
|
|
return true;
|
|
}
|
|
const children: NodeChildren = node;
|
|
for (const key of [
|
|
'callee',
|
|
'object',
|
|
'arguments',
|
|
'body',
|
|
'expression',
|
|
] as const) {
|
|
const child = children[key];
|
|
if (Array.isArray(child)) {
|
|
if (child.some(mentionsQuote)) {
|
|
return true;
|
|
}
|
|
} else if (mentionsQuote(child)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* True when the interpolated expression has been escaped: a `quote()` /
|
|
* `shlex.quote()` / shlex `join()` call, or an array chain that maps `quote` over
|
|
* its elements before joining.
|
|
*
|
|
* A bare `x.join(...)` is NOT accepted -- `Array.prototype.join` escapes nothing,
|
|
* and every safe site in the repo spells it `.map(quote).join(...)`.
|
|
*/
|
|
function isQuoted(input: ESTree.Node | undefined): boolean {
|
|
const node = unwrap(input);
|
|
if (node?.type !== 'CallExpression') {
|
|
return false;
|
|
}
|
|
const callee = unwrap(node.callee);
|
|
|
|
// `quote(x)` / `join(xs)` imported from shlex
|
|
if (callee?.type === 'Identifier' && SHLEX_HELPERS.has(callee.name)) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
callee?.type === 'MemberExpression' &&
|
|
callee.property.type === 'Identifier'
|
|
) {
|
|
// `shlex.quote(x)` / `shlex.join(xs)` off a namespace import
|
|
if (
|
|
SHLEX_HELPERS.has(callee.property.name) &&
|
|
readName(callee.object) === 'shlex'
|
|
) {
|
|
return true;
|
|
}
|
|
// `xs.map(quote).join(' ')` -- the escaping is in the receiver chain
|
|
if (callee.property.name === 'join') {
|
|
return mentionsQuote(callee.object);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The untrusted names an interpolated expression reads, looking through calls that
|
|
* neither escape nor launder their input, so `String(depName)` and `token.trim()`
|
|
* are seen the same way a bare `depName` is. Deliberately shallow: it inspects a
|
|
* call's own arguments and receiver, not arbitrary nesting.
|
|
*/
|
|
function readUntrustedNames(input: ESTree.Node | undefined): string[] {
|
|
const node = unwrap(input);
|
|
const direct = readName(node);
|
|
if (direct !== null) {
|
|
return UNTRUSTED_NAMES.has(direct) ? [direct] : [];
|
|
}
|
|
if (node?.type !== 'CallExpression') {
|
|
return [];
|
|
}
|
|
const callee = unwrap(node.callee);
|
|
const candidates: (ESTree.Node | undefined)[] = [...node.arguments];
|
|
if (callee?.type === 'MemberExpression') {
|
|
candidates.push(callee.object);
|
|
}
|
|
const found = candidates
|
|
.map((candidate) => readName(candidate))
|
|
.filter(
|
|
(name): name is string => name !== null && UNTRUSTED_NAMES.has(name),
|
|
);
|
|
return [...new Set(found)];
|
|
}
|
|
|
|
/**
|
|
* Whether this template literal is being used to build a command, rather than a
|
|
* log line or a regex. Matches the shapes the managers actually use: an argument
|
|
* to `exec()`, the value of a `cmd`/`command`/`args` binding, or something pushed
|
|
* onto a `commands` array.
|
|
*/
|
|
function isCommandContext(
|
|
node: ESTree.Node,
|
|
parent: ESTree.Node | null,
|
|
): boolean {
|
|
if (!parent) {
|
|
return false;
|
|
}
|
|
switch (parent.type) {
|
|
case 'CallExpression': {
|
|
const callee = unwrap(parent.callee);
|
|
if (callee?.type === 'Identifier' && callee.name === 'exec') {
|
|
return true;
|
|
}
|
|
if (callee?.type === 'MemberExpression') {
|
|
// `commands.push(...)`, `cmds.unshift(...)`
|
|
if (COMMAND_NAME_RE.test(readName(callee.object) ?? '')) {
|
|
return true;
|
|
}
|
|
// `deps.map(dep => `cmd ${dep.depName}`).join(' ')` — the mapped strings
|
|
// are commands only if what the chain ends up in is one, so keep walking.
|
|
if (ARRAY_METHODS.has(readName(callee) ?? '')) {
|
|
return isCommandContext(parent, parent.parent);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
case 'VariableDeclarator':
|
|
return COMMAND_NAME_RE.test(readName(parent.id) ?? '');
|
|
case 'AssignmentExpression':
|
|
return COMMAND_NAME_RE.test(readName(parent.left) ?? '');
|
|
case 'ArrowFunctionExpression':
|
|
// implicit return: `dep => `cmd ${dep.depName}``
|
|
return parent.body === node
|
|
? isCommandContext(parent, parent.parent)
|
|
: false;
|
|
case 'ReturnStatement':
|
|
case 'ArrayExpression':
|
|
case 'TemplateLiteral':
|
|
case 'ConditionalExpression':
|
|
case 'LogicalExpression':
|
|
// `return `...``, `[`...`]`, nested templates, `x ? `...` : y`
|
|
return isCommandContext(parent, parent.parent);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flags repo-controlled values interpolated into a command string without being
|
|
* escaped with `quote()` from `shlex`.
|
|
*
|
|
* Under `binarySource=docker` (and anywhere `ExecOptions.shell` is set) the
|
|
* assembled string is evaluated by a real shell, so an unescaped `$(...)`, `;` or
|
|
* backtick in a dependency name, path or token becomes command execution.
|
|
*
|
|
* Prefer passing `CommandWithOptions.command` as a `string[]`, which is escaped
|
|
* centrally; otherwise wrap each interpolated value in `quote()`.
|
|
*
|
|
* Accepts `quote()`, `shlex.quote()`, shlex's `join()`, and `.map(quote).join()`.
|
|
* A bare `xs.join()` is not accepted, since `Array.prototype.join` escapes
|
|
* nothing. Calls that neither escape nor launder their input are looked through,
|
|
* so `String(depName)` and `token.trim()` read the same as a bare `depName`.
|
|
*
|
|
* Known blind spots, both from being a purely syntactic, local check:
|
|
*
|
|
* - It matches on the *name* interpolated, so an untrusted value copied into a
|
|
* differently-named local is not seen. `const type = distributionType ??
|
|
* 'script'` (maven-wrapper) and the derived `importHosts` list (deno) were both
|
|
* real injections this would have missed.
|
|
* - An unescaped `join()` is only reported when the receiver is an inline
|
|
* expression, because a bare identifier may have had its elements escaped as
|
|
* they were added -- which is how manager/helmv3 builds its `parameters` array,
|
|
* and is not knowable without resolving the variable.
|
|
*
|
|
* It is a backstop for the common shape, not a substitute for review.
|
|
*/
|
|
export default defineRule({
|
|
meta: {
|
|
type: 'problem',
|
|
messages: {
|
|
noUnquotedExecInterpolation:
|
|
"`{{name}}` is repository-controlled, so interpolating it into a command unescaped allows shell injection. Wrap it in `quote()` from 'shlex', or pass the command as a `string[]` via `CommandWithOptions`.",
|
|
noUnquotedJoin:
|
|
"`Array.prototype.join` escapes nothing, so joining these values into a command allows shell injection if any of them is repository-controlled. Use `.map(quote).join(...)` with `quote()` from 'shlex', or `join()` from 'shlex', or pass the command as a `string[]` via `CommandWithOptions`.",
|
|
},
|
|
},
|
|
createOnce(context) {
|
|
return {
|
|
TemplateLiteral(node) {
|
|
if (!isCommandContext(node, node.parent)) {
|
|
return;
|
|
}
|
|
for (const expression of node.expressions) {
|
|
if (isQuoted(expression)) {
|
|
continue;
|
|
}
|
|
|
|
const inner = unwrap(expression);
|
|
const callee =
|
|
inner?.type === 'CallExpression' ? unwrap(inner.callee) : null;
|
|
|
|
if (
|
|
callee?.type === 'MemberExpression' &&
|
|
callee.property.type === 'Identifier' &&
|
|
callee.property.name === 'join'
|
|
) {
|
|
// Reachable only when nothing in the chain escaped anything. Report
|
|
// just the inline forms -- `deps.map((d) => d.depName).join(' ')` --
|
|
// where that is locally visible. A bare identifier is skipped
|
|
// because its elements may already have been escaped as they were
|
|
// added, which is not knowable syntactically; manager/helmv3 builds
|
|
// its `parameters` array exactly that way.
|
|
if (unwrap(callee.object)?.type !== 'Identifier') {
|
|
context.report({ node: expression, messageId: 'noUnquotedJoin' });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Otherwise look at the value being read -- directly, or through a
|
|
// call that neither escapes nor launders it (`String(depName)`,
|
|
// `token.trim()`), so wrapping an untrusted value doesn't hide it.
|
|
for (const name of readUntrustedNames(inner)) {
|
|
context.report({
|
|
node: expression,
|
|
messageId: 'noUnquotedExecInterpolation',
|
|
data: { name },
|
|
});
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
});
|