Проблемы безопасности Docker

в 6:00, , рубрики: devops, docker, security, Блог компании Southbridge, виртуализация, Серверное администрирование, системное администрирование

Проблемы безопасности Docker - 1

По мере взросления и стабилизации экосистемы Docker связанные с безопасностью этого продукта темы привлекают все больше внимания. При проектировании инфраструктуры невозможно избежать вопроса обеспечения безопасности Docker.

В Docker уже встроено несколько замечательных средств обеспечения безопасности:

  • Docker-контейнеры минимальны: один или несколько работающих процессов, только необходимое программное обеспечение. Это снижает вероятность пострадать от уязвимостей в ПО.

  • Docker-контейнеры выполняют специфическую задачу. Заранее известно, что должно выполняться в контейнере, определены пути к директориям, открытые порты, конфигурации демонов, точки монтирования и т. д. В таких условиях проще обнаружить какие-либо связанные с безопасностью аномалии. Этот принцип организации систем идет рука об руку с микросервисной архитектурой, позволяя значительно уменьшить поверхность атаки.

  • Docker-контейнеры изолированы как от хоста, так и от других контейнеров. Этого удается добиться благодаря способности ядра Linux изолировать ресурсы с помощью cgroups и namespaces. Но есть серьезная проблема — ядро приходится делить между хостом и контейнерами (мы еще вернемся к этой теме чуть позже).

  • Docker-контейнеры воспроизводимы. Благодаря их декларативной системе сборки любой администратор может легко выяснить, из чего и как был сделан контейнер. Крайне маловероятно, что у вас в итоге окажется неизвестно кем настроенная legacy-система, которую никому не хочется конфигурировать заново. Знакомо, не правда ли? ;)

Однако в основанных на Docker системах есть и слабые места. В этой статье мы как раз о них и поговорим, рассмотрев 7 проблем безопасности Docker.

Каждая секция разделена на следующие части:

  • Описание угрозы: вектор атаки и причины возникновения.
  • Лучшие практики: что можно сделать для предотвращения угроз такого вида.
  • Пример(-ы): простые, легко воспроизводимые упражнения для практики.

Безопасность Docker-хоста и ядра

Описание

В скомпрометированной системе изоляция и прочие механизмы безопасности контейнеров уже вряд ли помогут. Кроме того, система спроектирована таким образом, что контейнеры используют ядро хоста. По многим уже знакомым вам причинам это повышает эффективность работы, но с точки зрения безопасности такая особенность является угрозой, с которой надо бороться.

Лучшие практики

Тема обеспечения безопасности Linux-хоста весьма обширна, и по ней написано немало литературы. Что касается исключительно Docker:

  • Убедитесь в безопасности конфигурации хоста и Docker engine (доступ ограничен и предоставлен только аутентифицированным пользователям, канал связи зашифрован и т. д.) Для проверки конфигурации на соответствие лучшим практикам рекомендую воспользоваться инструментом Docker bench audit tool.

  • Своевременно обновляйте систему, подпишитесь на рассылку по безопасности операционной системы и другого установленного программного обеспечения, особенно если оно устанавливается из сторонних репозиториев (например, системы оркестровки контейнеров, одну из которых вы уже наверняка установили).

  • Используйте минимальные, специально предназначенные для использования с контейнерами хост-системы, такие как CoreOS, Red Hat Atomic, RancherOS и т. д. Это позволит уменьшить поверхность атаки, а также воспользоваться такими удобными функциями, как, например, выполнение системных сервисов в контейнерах.

  • Для предотвращения выполнения нежелательных операций как на хосте, так и в контейнерах можно задействовать систему Мандатного управления доступом (Mandatory Access Control). В этом вам помогут такие инструменты, как Seccomp, AppArmor или SELinux.

Примеры:

Seccomp позволяет ограничивать действия, доступные контейнеру, в частности — системные вызовы. Это что-то вроде брандмауэра, но для интерфейса вызовов ядра.

Некоторые привилегии заблокированы по умолчанию. Попробуйте выполнить такие команды:

# docker run -it alpine sh
/ # whoami
root
/ # mount /dev/sda1 /tmp
mount: permission denied (are you root?)

или

/ # swapoff -a
swapoff: /dev/sda2: Operation not permitted

Есть возможность создать пользовательский профиль Seccomp, например, запретив вызовы chmod.

Давайте загрузим дефолтный профиль Seccomp для Docker:

https://raw.githubusercontent.com/moby/moby/master/profiles/seccomp/default.json

Во время редактирования файла вы увидите белый список (whitelist) системных вызовов (в районе строки 52), удалите из него chmod, fchmod и fchmodat.

Теперь запустите контейнер с этим профилем и проверьте работу установленных ограничений:

# docker container run --rm -it --security-opt seccomp=./default.json alpine sh
/ # chmod +r /usr
chmod: /usr: Operation not permitted

Выход за пределы Docker-контейнера

Описание

Термин «выход за пределы контейнера» (container breakout) используется для обозначения ситуации, при которой какой-либо программе, запущенной внутри Docker-контейнера, удается преодолеть механизмы изоляции и получить дополнительные привилегии или доступ к конфиденциальной информации на хосте. Для предотвращения подобных прорывов используется уменьшение количества привилегий контейнера, выдаваемых ему по умолчанию. Например, демон Docker по умолчанию выполняется под рутом, однако существует возможность создать пользовательское пространство имен (user-level namespace) или снять потенциально опасные привилегии контейнера.

Цитата из статьи об уязвимостях, связанных с конфигурацией Docker по умолчанию:

«Этот экспериментальный эксплоит основан на том, что ядро предоставляет любому процессу возможность открыть файл по его inode. В большинстве систем inode корневой директории (/) равен 2. Это дает возможность идти по дереву каталогов файловой системы хоста до тех пор, пока не будет найден искомый объект, например, файл с паролями».

Лучшие практики

  • Привилегии (capabilities), которые не нужны приложению, должны быть сняты.

    • CAP_SYS_ADMIN в плане безопасности особенно коварна, поскольку дает право на выполнение значительного количества операций уровня суперпользователя: монтирование файловых систем, вход в пространства имен ядра, операции ioctl...

  • Чтобы привилегии контейнера были эквиваленты правам обычного пользователя, создайте для ваших контейнеров изолированное пользовательское пространство имен. По возможности избегайте выполнения контейнеров с uid 0.

  • Если без привилегированного контейнера все же не обойтись, убедитесь, что он устанавливается из доверенного репозитория (см. ниже раздел «Подлинность образов контейнеров»).

  • Внимательно следите за случаями монтирования потенциально опасных ресурсов хоста: /var/run/docker.sock), /proc, /dev и т. д. Обычно эти ресурсы нужны для выполнения операций, связанных с базовой функциональностью контейнеров. Убедитесь, что вы понимаете, почему и как необходимо ограничивать доступ процессов к этой информации. Иногда достаточно лишь установки режима «только чтение». Никогда не давайте права на запись, не задавшись вопросом, зачем нужно это право. В любом случае Docker использует copy-on-write, чтобы предотвратить попадание изменений, произошедших в выполняющемся контейнере, в его базовый образ и потенциально в другие контейнеры, которые будут созданы на базе этого образа.

Примеры

Root-пользователь Docker-контейнера по умолчанию может создавать устройства. Вероятно, вы захотите это запретить:

# sudo docker run --rm -it --cap-drop=MKNOD alpine sh
/ # mknod /dev/random2 c 1 8
mknod: /dev/random2: Operation not permitted

Root также может изменить права доступа любого файла. Это легко проверить: создайте файл под любым обычным пользователем, выполните chmod 600 (чтение и запись доступны только владельцу), зайдите от имени root и убедитесь, что файл вам по-прежнему доступен.

Это также можно исправить, особенно если у вас монтируются папки с конфиденциальными пользовательскими данными.

# sudo docker run --rm -it --cap-drop=DAC_OVERRIDE alpine sh

Создайте обычного пользователя и перейдите в его домашнюю директорию. Затем:

~ $ touch supersecretfile
~ $ chmod 600 supersecretfile
~ $ exit
~ # cat /home/user/supersecretfile
cat: can't open '/home/user/supersecretfile': Permission denied

Многие сканеры безопасности и вредоносные программы собирают свои сетевые пакеты с нуля. Такое поведение можно запретить следующим образом:

# docker run --cap-drop=NET_RAW -it uzyexe/nmap -A localhost

Starting Nmap 7.12 ( https://nmap.org ) at 2017-08-16 10:13 GMT
Couldn't open a raw socket. Error: Operation not permitted (1)

Полный список привилегий можно найти здесь. Рекомендую с ним ознакомиться и убрать все привилегии, которые вашим контейнерам не нужны.

Если вы создаете контейнер без пространства имен, то по умолчанию процессы, выполняющиеся внутри контейнера, с точки зрения хоста будут работать от имени суперпользователя.

# docker run -d -P nginx
# ps aux | grep nginx
root     18951  0.2  0.0  32416  4928 ?        Ss   12:31   0:00 nginx: master process nginx -g daemon off;

Однако мы можем создать отдельное пользовательское пространство имен. Для этого добавьте ключ conf в файл /etc/docker/daemon.json (аккуратнее, соблюдайте правила синтаксиса json):

"userns-remap": "default"

Перезапустите Docker. При этом будет создан пользователь dockremap. Новое пространство имен будет пустым.

# systemctl restart docker
# docker ps

Снова запустите образ nginx:

# docker run -d -P nginx
# ps aux | grep nginx
165536   19906  0.2  0.0  32416  5092 ?        Ss   12:39   0:00 nginx: master process nginx -g daemon off;

Теперь процесс nginx выполняется в другом (пользовательском) пространстве имен. Таким образом нам удалось улучшить изоляцию контейнеров.

Подлинность образов Docker

Описание

В Интернет можно найти немало Docker-образов, которые делают всевозможные полезные и классные вещи, но если вы загружаете образы без использования каких-либо механизмов доверия и проверки подлинности, вы по сути запускаете на своих системах произвольное ПО.

  • Откуда был загружен этот образ?
  • Доверяете ли вы его создателям? Какие политики безопасности они используют?
  • Есть ли у вас объективное криптографическое доказательство того, что образ был действительно создан этими людьми.
  • Вы уверены, что никто не изменил образ после того, как он был загружен?

Docker запустит все, что попросите, поэтому инкапсуляция здесь не поможет. Даже если вы пользуетесь исключительно образами собственного производства, имеет смысл проверять, не изменяет ли их кто-нибудь после создания. Решение в итоге сводится к классической цепочке доверия на основе PKI.

Лучшие практики

  • Обычный здравый смысл: не запускайте непроверенное ПО и/или ПО, полученное из недоверенных источников.

  • С помощью серверов реестров Docker, которые можно найти в этом списке Docker Security Tools, разверните доверенный сервер (trust server).

  • Для любого образа, который загружается или запускается в системе, обеспечьте обязательную проверку цифровой подписи.

Примеры

Развертывание полноценного доверенного сервера выходит за рамки этой статьи, но вы уже сейчас можете начать с подписывания своих образов.

Если у вас еще нет учетной записи на Docker Hub, заведите ее.

Создайте директорию с простым Dockerfile следующего содержания:

# cat Dockerfile
FROM alpine:latest

Соберите образ:

# docker build -t <youruser>/alpineunsigned .

Войдите в свою учетную запись на Docker Hub и загрузите образ:

# docker login
[…]
# docker push <youruser>/alpineunsigned:latest

Включите в Docker принудительную проверку доверия:

# export DOCKER_CONTENT_TRUST=1

Теперь попробуйте получить только что загруженный вами образ:

# docker pull <youruser>/alpineunsigned

Вы должны получить следующую ошибку:

Using default tag: latest
Error: remote trust data does not exist for docker.io/<youruser>/alpineunsigned:
notary.docker.io does not have trust data for docker.io/<youruser>/alpineunsigned

При включенном DOCKER_CONTENT_TRUST соберите контейнер еще раз. Теперь он по умолчанию будет подписан.

# docker build --disable-content-trust=false -t <youruser>/alpinesigned:latest .

Теперь вы сможете скачивать и загружать подписанные контейнеры без каких-либо предупреждений, связанных с безопасностью. При первой загрузке доверенного образа Docker создаст для вас корневой ключ. Вам также понадобится ключ репозитория. В обоих случаях будет предложено задать пароль.

Ваши закрытые ключи будут сохранены в директории ~/.docker/trust, ограничьте к ним доступ и создайте резервную копию.

DOCKER_CONTENT_TRUST — это переменная окружения, которая исчезнет после закрытия терминальной сессии. Однако проверка доверия должна быть внедрена на каждом этапе процесса — от сборки образов и их размещения в реестрах до закачки и выполнения на серверах.

Злоупотребление ресурсами

Описание

В среднем контейнеры по сравнению с виртуальными машинами гораздо более многочисленны. Они легковесны, что позволяет запустить множество контейнеров даже на весьма скромном железе. Это, безусловно, преимущество, однако обратной стороной медали является серьезная конкуренция за ресурсы хоста. Ошибки в программном обеспечении, недостатки проектирования и атаки хакеров могут приводить к Отказам в обслуживании (Denial of Service). Для их предотвращения необходимо должным образом настраивать лимиты ресурсов.

Положение ухудшается тем, что ресурсов, которые необходимо контролировать, несколько: CPU, память, место на дисках, загрузка сети, I/O, подкачка и т. д. В ядре есть и не столь очевидные ресурсы, как, например, идентификаторы пользователей (UIDs).

Лучшие практики

По умолчанию в большинстве систем контейнеризации ограничение этих ресурсов отключено. Однако в production их настройка просто обязательна. Рекомендую придерживаться следующих принципов:

  • Используйте функции ограничения ресурсов, идущие в составе ядра Linux и/или системы контейнеризации.

  • Постарайтесь провести нагрузочное тестирование системы перед ее запуском в промышленную эксплуатацию. Для этого используются как синтетические тесты, так и «проигрывание» реального трафика боевой системы. Нагрузочное тестирование жизненно важно для выяснения предельных и нормальных рабочих нагрузок.

  • Разверните систему мониторинга и оповещения для Docker. Уверен, что в случае злоупотребления ресурсами (злонамеренного или нет) вы предпочтете получить своевременное предупреждение, вместо того чтобы вре́заться в стену на полном ходу.

Примеры

Контрольные группы (cgroups) — это предоставляемый ядром Linux инструмент, который позволяет ограничивать доступ процессов и контейнеров к системным ресурсам. Некоторые лимиты можно контролировать из командной строки Docker:

# docker run -it --memory=2G --memory-swap=3G ubuntu bash

Эта команда установит ограничение доступной контейнеру памяти в 2 ГБ (всего 3 ГБ на основную память и подкачку). Для проверки ограничения запустим симулятор нагрузки, например, программу stress, которая есть в репозиториях Ubuntu:

root@e05a311b401e:/# stress -m 4 --vm-bytes 8G

В выводе программы вы увидите строку ‘FAILED’.

В syslog хоста должны появиться такие строки:

Aug 15 12:09:03 host kernel: [1340695.340552] Memory cgroup out of memory: Kill process 22607 (stress) score 210 or sacrifice child
Aug 15 12:09:03 host kernel: [1340695.340556] Killed process 22607 (stress) total-vm:8396092kB, anon-rss:363184kB, file-rss:176kB, shmem-rss:0kB

С помощью docker stats вы можете уточнить текущее потребление памяти и установленные лимиты. В случае с Kubernetes в определении пода можно забронировать необходимые для нормальной работы приложения ресурсы, а также установить лимиты. См. requests and limits:

[...]
  - name: wp
    image: wordpress
    resources:
      requests:
        memory: "64Mi"
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"
[...]

Уязвимости в образах контейнеров

Описание

Контейнеры — это изолированные черные ящики. Если они выполняют свои функции, легко забыть, какие программы каких версий запущены внутри. Контейнер может отлично справляться со своими обязанностями с операционной точки зрения, при этом используя уязвимое программное обеспечение. Эти уязвимости могут быть давно исправлены в апстриме, но не в вашем локальном образе. Если не предпринимать соответствующие меры, проблемы подобного рода могут долгое время оставаться незамеченными.

Лучшие практики

Представление контейнеров в виде неизменяемых атомарных частей системы обоснованно с архитектурной точки зрения, однако для обеспечения безопасности их содержимое нужно регулярно проверять:

  • Чтобы получить свежие исправления уязвимостей, регулярно обновляйте и пересобирайте свои образы. Разумеется, не забывайте тестировать их перед тем, как отправлять в production.
    • Патчить работающие контейнеры считается дурным тоном. Лучше при каждом обновлении пересобирать образ. В Docker реализована декларативная, эффективная и легкая для понимания система сборки, так что эта процедура на самом деле проще, чем кажется на первый взгляд.
    • Используйте программное обеспечение, которое регулярно получает обновления безопасности. Все, что вы устанавливаете вручную, минуя репозитории вашего дистрибутива, необходимо в дальнейшем обновлять самостоятельно.
    • Постепенные роллинг-обновления без прерывания работы сервиса считаются фундаментальным свойством модели построения систем с помощью Docker и микросервисов.
    • Пользовательские данные отделены от образов контейнеров, что делает процесс обновления безопаснее.
  • Не усложняйте. Простые системы реже требуют обновлений. Чем меньше компонентов в системе, тем меньше поверхность атаки и проще обновления. Разбивайте контейнеры, если они становятся слишком сложными.
  • Используйте сканеры уязвимостей. Их сейчас предостаточно — и бесплатных, и коммерческих. Старайтесь быть в курсе событий, связанных с безопасностью используемого вами программного обеспечения, подпишитесь на почтовые рассылки, сервисы оповещений и т. д.
    • Сделайте сканирование безопасности обязательным этапом своей CI/CD-цепочки, автоматизируйте по возможности — не стоит полагаться лишь на ручные проверки.

Примеры

Многие реестры образов Docker предлагают услугу сканирования образов. Выберем, например, CoreOS Quay, в котором используется сканер безопасности образов Docker с открытым исходным кодом под названием Clair. Quay — это коммерческая платформа, но некоторые услуги предоставляются бесплатно. Пробную учетную запись можно создать, следуя этим инструкциям.

После регистрации аккаунта откройте Account Settings и установите новый пароль (он понадобится для создания репозиториев).

Нажмите + в правом верхнем углу и создайте новый публичный репозиторий:

Проблемы безопасности Docker - 2

Здесь мы создадим пустой репозиторий, но, как видно на скриншоте, существуют и другие варианты.

Теперь из консоли залогинимся в Quay и загрузим туда локальный образ:

# docker login quay.io
# docker push quay.io/<your_quay_user>/<your_quay_image>:<tag>

Если образ уже загружен, можно щелкнуть по его ID и посмотреть результаты сканирования безопасности, отсортированные в порядке убывания опасности уязвимости, которые снабжены ссылками на CVE и версии пакетов, содержащие исправления.

Проблемы безопасности Docker - 3

Учетные данные и секреты Docker

Описание

В большинстве случаев программам для нормальной работы нужны конфиденциальные данные: хеши паролей пользователей, сертификаты, ключи шифрования и т. д. Эта ситуация усугубляется природой контейнеров: вы не просто поднимаете сервер, а настраиваете среду, в которой микросервисы могут постоянно создаваться и уничтожаться. В этом случае необходим работающий автоматически, надежный и безопасный процесс использования конфиденциальной информации.

Лучшие практики

  • Не используйте переменные окружения для хранения секретов. Это распространенная и небезопасная практика.

  • Не сохраняйте секреты в образах контейнеров. Прочитайте этот отчет о нахождении и устранении уязвимости в одном из сервисов IBM: «Закрытый ключ и сертификат были по ошибке оставлены внутри образа контейнера».

  • Если у вас достаточно сложная система, разверните программное обеспечение управления учетными данными Docker. В статье Docker security tools мы рассмотрели несколько коммерческих и бесплатных решений. Беритесь за создание собственного хранилища секретов (с загрузкой секретов с помощью curl, монтированием томов и т. д. и т. п.) только в том случае, если вы очень хорошо знаете, что делаете.

Примеры

Для начала посмотрим, как перехватываются переменных окружения:

# docker run -it -e password='S3cr3tp4ssw0rd' alpine sh
/ # env | grep pass
password=S3cr3tp4ssw0rd

То есть это элементарно, даже если вы переключитесь на обычного пользователя с помощью su:

/ # su user
/ $ env | grep pass
password=S3cr3tp4ssw0rd

В настоящее время в состав систем оркестровки контейнеров входят инструменты для управления секретами. Например, в Kubernetes есть объекты типа secret. В Docker Swarm также есть своя функциональность по работе с секретами, которую мы сейчас продемонстрируем:

Проинициализируйте новый Docker Swarm (возможно, вы захотите сделать это в виртуальной машине):

# docker swarm init --advertise-addr <your_advertise_addr>

Создайте файл с произвольным текстом — это будет ваш секрет:

# cat secret.txt
This is my secret

На основе этого файла создайте новый секретный ресурс (secret resource):

# docker secret create somesecret secret.txt

Создайте сервис Docker Swarm с доступом к этому секрету (вы можете менять uid, gid, mode и т. д.):

# docker service create --name nginx --secret source=somesecret,target=somesecret,mode=0400 nginx

Войдите в контейнер nginx — у вас должна появиться возможность использовать сохраненный секрет:

root@3989dd5f7426:/# cat /run/secrets/somesecret
This is my secret
root@3989dd5f7426:/# ls /run/secrets/somesecret
-r-------- 1 root root 19 Aug 28 16:45 /run/secrets/somesecret

Возможности системы управления секретами на этом не исчерпываются, но приведенный пример позволит начать хранить секреты должным образом и работать с ними из одного пункта управления.

Мониторинг безопасности Docker во время выполнения

Описание

В предыдущих частях статьи мы говорили в основном о статическом аспекте безопасности Docker: уязвимые ядра, ненадежные образы, привилегии, которые могут быть даны или сняты во время запуска, и т. д. А что если, несмотря на все предпринятые меры безопасности, образ оказался скомпрометированным во время выполнения и начал проявлять подозрительную активность?

Лучшие практики

  • Вышеописанные статические контрмеры не покрывают все возможные векторы атаки. А что если в вашем собственном приложении есть уязвимости или атакующие используют 0-day, который не определяется сканером? Безопасность времени выполнения можно сравнить с антивирусным сканированием в Windows, в задачи которого входит нахождение вредоносных программ и предотвращение дальнейшего распространения атаки.

  • Не нужно заменять статическую проверку образов на динамическую защиту времени выполнения, поскольку предотвращение атаки всегда лучше ее отражения. Используйте динамическую защиту в качестве дополнительного уровня безопасности.

  • Для посмертного анализа атаки очень помогают подробные и удобные логи, содержимое которых хорошо коррелирует с внесенными в систему изменениями.

Примеры

Sysdig Falco — это система поведенческого мониторинга программного обеспечения, разработанная для обнаружения аномальной активности. Исходный код системы открыт. Sysdig Falco на Linux-хостах работает как система обнаружения вторжений и в особенности полезна при использовании Docker, поскольку при создании правил поддерживает специфический для контейнеров контекст, например container.id, container.image, ресурсы Kubernetes или пространства имен.

Правила Falco могут генерировать оповещения о разных видах аномальной активности. Давайте рассмотрим ситуацию, когда кто-то запустил командную оболочку в контейнере в production.

Для начала развернем Falco с помощью автоматического скрипта установки (не рекомендуется для production; возможно, для целей тестирования лучше устанавливать в виртуальной машине):

# curl -s https://s3.amazonaws.com/download.draios.com/stable/install-falco | sudo bash
# service falco start

А теперь запустим командную оболочку в контейнере nginx:

# docker run -d --name nginx nginx
# docker exec -it nginx bash

На хосте в конце файла /var/log/syslog должна появиться следующая запись:

Aug 15 21:25:31 host falco: 21:25:31.159081055: Debug Shell spawned by untrusted binary (user=root shell=sh parent=anacron cmdline=sh -c run-parts --report /etc/cron.weekly pcmdline=anacron -dsq)

Отмечу, что Sysdig Falco выполняет свои функции, не внося изменений в контейнеры. Мы только что рассмотрели очень простой пример, о других возможностях системы можно узнать здесь.

Заключение

Docker создавался с учетом требований безопасности, и некоторые его особенности помогают в ее обеспечении. Однако не стоит забывать об осторожности, поскольку здесь нет другого пути, кроме как постоянно следить за современными тенденциями и применять лучшие практики, сложившиеся в этой сфере. Также стоит подумать об использовании специфичных для контейнеров инструментов обеспечения безопасности, которые помогают бороться с уязвимостями и угрозами, связанными с использованием Docker.

Надеюсь, рассмотренная тема вас заинтересовала.

Ссылки:

  1. Оригинал: 7 Docker security vulnerabilities and threats.

Автор: olemskoi

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js