eslint-plugin-sonarjs
Version:
263 lines (262 loc) • 11.9 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;
// Namespace / spec-mandated identifier 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',
'cyclonedx.org',
'snomed.info',
'adlnet.gov',
// jabber.org is also a live public XMPP server, not just a namespace string — only exempt the
// paths reserved for XEP namespaces (/protocol/ for protocol namespaces, /features/ for stream
// features); anything else on this host, e.g. the BOSH endpoint http://jabber.org/http-bind,
// is a real cleartext network target.
{ host: 'jabber.org', pathPrefix: '/protocol/' },
{ host: 'jabber.org', pathPrefix: '/features/' },
'etherx.jabber.org',
];
// 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;
// Left undefined when only the lenient fallback could extract the authority: path-scoped
// authorities then fail closed rather than being exempted on an unknown path.
let pathname;
try {
const url = new node_url_1.URL(value);
host = url.hostname;
pathname = url.pathname;
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, pathname);
}
function isSafeHost(host, pathname) {
return (SAFE_HOSTS.test(host) ||
NAMESPACE_URI_AUTHORITIES.some(entry => isNamespaceAuthority(host, pathname, entry)) ||
DOCUMENTATION_HOSTS.test(host));
}
function isNamespaceAuthority(host, pathname, entry) {
const authority = typeof entry === 'string' ? entry : entry.host;
if (host !== authority && !host.startsWith(`${authority}:`)) {
return false;
}
return typeof entry === 'string' || pathname?.startsWith(entry.pathPrefix) === true;
}