eslint-plugin-sonarjs
Version:
212 lines (211 loc) • 10.7 kB
JavaScript
;
/*
* SonarQube JavaScript Plugin
* Copyright (C) SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* You can redistribute and/or modify this program under the terms of
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
// https://sonarsource.github.io/rspec/#/rspec/S5332/javascript
Object.defineProperty(exports, "__esModule", { value: true });
exports.rule = void 0;
const node_url_1 = require("node:url");
const module_js_1 = require("../helpers/module.js");
const ancestor_js_1 = require("../helpers/ancestor.js");
const ast_js_1 = require("../helpers/ast.js");
const cdk_js_1 = require("../helpers/aws/cdk.js");
// Single source of truth: cleartext scheme → recommended secure alternative.
// Mirrors CleartextProtocolFilter.CLEARTEXT_PROTOCOL_ALTERNATIVES from sonar-analyzer-commons.
// To add a new protocol, add one entry here — INSECURE_PROTOCOLS and LENIENT_AUTHORITY are derived.
const CLEARTEXT_PROTOCOL_ALTERNATIVES = {
http: 'https',
ftp: 'sftp, scp or ftps',
ws: 'wss',
telnet: 'ssh',
gopher: 'https',
tftp: 'sftp',
smtp: 'smtps',
ldap: 'ldaps',
imap: 'imaps',
pop3: 'pop3s',
amqp: 'amqps',
mqtt: 'mqtts',
sip: 'sips',
rtmp: 'rtmps',
irc: 'ircs',
nntp: 'nntps',
stomp: 'stomps',
};
const INSECURE_PROTOCOLS = Object.keys(CLEARTEXT_PROTOCOL_ALTERNATIVES).map(s => `${s}://`);
// Internal / non-public hosts — port of CleartextProtocolFilter.SAFE_HOSTS from sonar-analyzer-commons
// Note: ^127(?:\.\d+){1,3} covers abbreviated loopback forms (127.1, 127.0.1) that are valid on POSIX systems
const SAFE_HOSTS = /(?:^localhost|^127(?:\.\d+){1,3}|^\[(?:0*:){7}:?0*1]|^\[::1]|^169\.254\.\d+\.\d+|^\[fd00:ec2::254]|^168\.63\.129\.16|^100\.100\.100\.200|^metadata\.google\.internal|^metadata\.internal|^host\.docker\.internal|^gateway\.docker\.internal|\.svc\.cluster\.local)(?=:|$)/i;
// XML namespace URI authorities — port of CleartextProtocolFilter.NAMESPACE_URI_AUTHORITIES, extended with pre-existing sonar-js exceptions
const NAMESPACE_URI_AUTHORITIES = /(?:^www\.w3\.org|^schemas\.android\.com|^schemas\.microsoft\.com|^schemas\.xmlsoap\.org|^www\.sap\.com|^www\.opengis\.net|^hl7\.org|^unitsofmeasure\.org|^purl\.org|^docs\.oasis-open\.org|^xmlns\.com|^json-ld\.org|^schema\.org|^www\.springframework\.org|^maven\.apache\.org|^dublincore\.org|^ogp\.me|^xml\.apache\.org|^schemas\.openxmlformats\.org|^rdfs\.org|^schemas\.google\.com|^a9\.com|^ns\.adobe\.com|^ltsc\.ieee\.org|^docbook\.org|^graphml\.graphdrawing\.org|^json-schema\.org)(?=:|$)/i;
// IANA-reserved documentation / placeholder domains — port of CleartextProtocolFilter.DOCUMENTATION_HOSTS
const DOCUMENTATION_HOSTS = /(?:(?:^|\.)example\.(?:com|net|org)|\.(?:example|test|localhost))(?=:|$)/i;
// Lenient authority extractor for URLs that fail strict URL parsing (e.g. template placeholders, underscores in hostnames).
// Derived from CLEARTEXT_PROTOCOL_ALTERNATIVES — no manual update needed when adding protocols.
const LENIENT_AUTHORITY = new RegExp(String.raw `^(?:${Object.keys(CLEARTEXT_PROTOCOL_ALTERNATIVES).join('|')}):\/\/(?:[^@\s/?#]+@)?([^\s/?#]+)`, 'i');
exports.rule = {
meta: {
messages: {
insecureProtocol: 'Using {{protocol}} protocol is insecure. Use {{alternative}} instead.',
},
},
create(context) {
function checkNodemailer(callExpression) {
const firstArg = callExpression.arguments.length > 0 ? callExpression.arguments[0] : null;
if (!firstArg) {
return;
}
const firstArgValue = (0, ast_js_1.getValueOfExpression)(context, firstArg, 'ObjectExpression');
const ses = (0, ast_js_1.getProperty)(firstArgValue, 'SES', context);
if (ses && usesSesCommunication(ses)) {
return;
}
const secure = (0, ast_js_1.getProperty)(firstArgValue, 'secure', context);
if (secure && (secure.value.type !== 'Literal' || secure.value.raw !== 'false')) {
return;
}
const requireTls = (0, ast_js_1.getProperty)(firstArgValue, 'requireTLS', context);
if (requireTls && (requireTls.value.type !== 'Literal' || requireTls.value.raw !== 'false')) {
return;
}
const port = (0, ast_js_1.getProperty)(firstArgValue, 'port', context);
if (port && (port.value.type !== 'Literal' || port.value.raw === '465')) {
return;
}
context.report({ node: callExpression.callee, ...getMessageAndData('http') });
}
function usesSesCommunication(sesProperty) {
const configuration = (0, ast_js_1.getValueOfExpression)(context, sesProperty.value, 'ObjectExpression');
if (!configuration) {
return false;
}
const ses = (0, ast_js_1.getValueOfExpression)(context, (0, ast_js_1.getProperty)(configuration, 'ses', context)?.value, 'NewExpression');
if (!ses || (0, cdk_js_1.normalizeFQN)((0, module_js_1.getFullyQualifiedName)(context, ses)) !== '@aws_sdk.client_ses.SES') {
return false;
}
const aws = (0, ast_js_1.getProperty)(configuration, 'aws', context);
if (!aws ||
(0, cdk_js_1.normalizeFQN)((0, module_js_1.getFullyQualifiedName)(context, aws.value)) !== '@aws_sdk.client_ses') {
return false;
}
return true;
}
function checkCallToFtp(callExpression) {
if (callExpression.callee.type === 'MemberExpression' &&
callExpression.callee.property.type === 'Identifier' &&
callExpression.callee.property.name === 'connect') {
const newExpression = (0, ast_js_1.getValueOfExpression)(context, callExpression.callee.object, 'NewExpression');
if (!!newExpression && (0, module_js_1.getFullyQualifiedName)(context, newExpression.callee) === 'ftp') {
const firstArg = callExpression.arguments.length > 0 ? callExpression.arguments[0] : null;
if (!firstArg) {
return;
}
const firstArgValue = (0, ast_js_1.getValueOfExpression)(context, firstArg, 'ObjectExpression');
const secure = (0, ast_js_1.getProperty)(firstArgValue, 'secure', context);
if (secure?.value.type === 'Literal' && secure.value.raw === 'false') {
context.report({
node: callExpression.callee,
...getMessageAndData('ftp'),
});
}
}
}
}
function checkCallToRequire(callExpression) {
if (callExpression.callee.type === 'Identifier' && callExpression.callee.name === 'require') {
const firstArg = callExpression.arguments.length > 0 ? callExpression.arguments[0] : null;
if (firstArg?.type === 'Literal' &&
typeof firstArg.value === 'string' &&
firstArg.value === 'telnet-client') {
context.report({
node: firstArg,
...getMessageAndData('telnet'),
});
}
}
}
function isExceptionUrl(value, node) {
if (INSECURE_PROTOCOLS.includes(value)) {
const parent = (0, ancestor_js_1.getParent)(context, node);
return !(parent?.type === 'BinaryExpression' && parent.operator === '+');
}
return hasExceptionHost(value);
}
return {
Literal: (node) => {
const literal = node;
if (typeof literal.value === 'string') {
const value = literal.value.trim().toLocaleLowerCase();
const insecure = INSECURE_PROTOCOLS.find(protocol => value.startsWith(protocol));
if (insecure && !isExceptionUrl(value, node)) {
const protocol = insecure.substring(0, insecure.indexOf(':'));
context.report({
...getMessageAndData(protocol),
node,
});
}
}
},
CallExpression: (node) => {
const callExpression = node;
if ((0, module_js_1.getFullyQualifiedName)(context, callExpression) === 'nodemailer.createTransport') {
checkNodemailer(callExpression);
}
checkCallToFtp(callExpression);
checkCallToRequire(callExpression);
},
ImportDeclaration: (node) => {
const importDeclaration = node;
if (typeof importDeclaration.source.value === 'string' &&
importDeclaration.source.value === 'telnet-client') {
context.report({
node: importDeclaration.source,
...getMessageAndData('telnet'),
});
}
},
};
},
};
function getMessageAndData(protocol) {
return {
messageId: 'insecureProtocol',
data: { protocol, alternative: CLEARTEXT_PROTOCOL_ALTERNATIVES[protocol] },
};
}
function hasExceptionHost(value) {
let host;
try {
const url = new node_url_1.URL(value);
host = url.hostname;
if (host.length === 0) {
return false;
}
}
catch {
// Lenient fallback for template placeholders (e.g. http://localhost:${port}) and underscores in hostnames.
// Mirrors CleartextProtocolFilter's authority-extraction logic.
const match = LENIENT_AUTHORITY.exec(value);
if (!match) {
return false;
}
host = match[1];
}
return isSafeHost(host);
}
function isSafeHost(host) {
return (SAFE_HOSTS.test(host) || NAMESPACE_URI_AUTHORITIES.test(host) || DOCUMENTATION_HOSTS.test(host));
}