UNPKG

eslint-plugin-sonarjs

Version:
263 lines (262 loc) • 11.9 kB
"use strict"; /* * 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; }