Каждая интеграция — это новый вход в систему и новый ключ, который может утечь. Базовый набор защит: отдельный ключ с минимальными правами на каждую интеграцию и окружение, плановая ротация с перекрытием старого и нового ключа, проверка подписи, свежести и схемы каждого входящего события, запросы по чужим URL только после проверки адреса (SSRF) и журналы без токенов и персональных данных. Данные партнёра проверяются так же строго, как ввод пользователя.
После материала вы сможете
- составить модель угроз интеграции и для каждой угрозы назвать защиту: ключ, права, подпись, схема, SSRF-фильтр, журнал
- спланировать ротацию ключа интеграции с перекрытием, при котором партнёр не получает отказов
- принять входящее событие партнёра в правильном порядке: размер, подпись, свежесть, схема
zod, дубликаты - защитить исходящий запрос по URL от пользователя от SSRF: схема, порт, IP после DNS, запрет редиректов, таймаут и лимит размера
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)zod 4.6OWASP API Security Top 10 (2023)
Сверено с документацией: 11 октября 2026 г.
Содержание
Модель угроз интеграции
Интеграция — это любой обмен данными с внешней системой: оплата, CRM, 1С, рассылка, загрузка аватара по ссылке, вебхук от маркетплейса. У каждой есть три точки, где что-то может пойти не так: ключ, которым вы подписываетесь при обращении к партнёру; входящие данные, которые партнёр присылает вам; и исходящие запросы, которые ваш сервер делает по чьему-то адресу. К ним добавляется четвёртая — журналы, куда всё это записывается.
OWASP выделяет эту тему отдельным пунктом списка API Security Top 10 (2023) — API10, «Unsafe Consumption of APIs»: разработчики склонны доверять данным от сторонних API больше, чем вводу пользователя, и ослабляют проверки. Атакующему проще взломать слабого партнёра и прислать через него вредные данные, чем атаковать вас напрямую. Там же, под номером API7, — SSRF: сервер ходит по URL, который подставил пользователь.
Утечка ключа
- Пример
- Ключ CRM в репозитории, в логе или во фронтенде
- Защита
- Хранилище секретов, отдельные ключи, ротация, маскирование в логах
Слишком широкие права
- Пример
- Ключ «администратора» для выгрузки одного отчёта
- Защита
- Минимальный scope, отдельный сервисный аккаунт на интеграцию
Поддельное входящее событие
- Пример
- Кто-то шлёт «заказ оплачен» на ваш адрес вебхука
- Защита
- Подпись HMAC, метка времени, mTLS или IP allowlist
Вредные данные от партнёра
- Пример
- Лишние поля, SQL в строке, огромный ответ, редирект на чужой сервер
- Защита
- Строгая схема, лимиты размера и времени, запрет редиректов
SSRF
- Пример
- Пользователь указывает адрес вебхука
http://169.254.169.254/… - Защита
- Проверка схемы, порта и IP после DNS, сетевая изоляция
Утечка через журналы
- Пример
- В логе токен, телефон и e-mail клиента
- Защита
- Структурированный лог с вырезанием секретов и маскированием
- 01Партнёр
- 02Приёмник событий
- 03Ваша система
- 04Исходящий клиент
- 05Чужой URL
Ключи: хранение, ротация, отдельные ключи
Где хранить секреты, как не допустить их попадания в git и бандл и что делать при утечке, подробно разобрано в уроке о секретах и переменных окружения. Для интеграций к этому добавляются три правила.
- Отдельный ключ на каждую интеграцию. Шпаргалка OWASP по управлению секретами отмечает: когда сервисы делят один секрет, источник утечки трудно установить. Ключ CRM для сайта и ключ CRM для фонового воркера — это два ключа: утёк один — отзываете один.
- Отдельные ключи на окружения. Тестовый стенд работает с sandbox-ключом партнёра, production — со своим. Ключ production не попадает на компьютеры разработчиков и в тестовые CI-задачи.
- Владелец и срок. Для каждого ключа записано, кто его выпустил, где он используется, когда истекает и когда ротировался. OWASP рекомендует журналировать создание, использование, ротацию и удаление секретов.
Ротация — плановая замена ключа, чтобы украденная копия работала недолго. OWASP советует автоматизировать её или хотя бы описать процесс, потому что ручная замена часто ломает интеграцию. Чтобы обойтись без простоя, ключи меняют с перекрытием — какое-то время принимаются оба:
Выпустить новый ключ
У партнёра или в вашем хранилище появляется второй действующий ключ. Старый пока работает.
Принимать оба
Приёмник проверяет подпись сначала новым, затем старым ключом — так работает
acceptPartnerEventниже с массивом[current, previous].Переключить отправителя
Ваш исходящий клиент и партнёр начинают подписывать новым ключом. Мониторинг показывает, каким ключом пришли последние запросы.
Отозвать старый
Старый ключ удаляется из конфигурации и отзывается у партнёра. Тест ниже показывает: после этого подпись старым ключом даёт 401.
Минимальные права, mTLS и IP allowlist
Принцип минимальных привилегий для интеграции означает: у каждого подключения — свой сервисный аккаунт или OAuth-клиент, и ему выдано ровно то, что нужно. Воркеру выгрузки отчётов — только чтение отчётов, форме заявок на сайте — только создание лида, без чтения базы клиентов. Если партнёр поддерживает OAuth 2.0, используйте client credentials с узким scope; как это выглядит в коде, показано в уроке об OAuth 2.0 и OpenID Connect.
Права проверяются и в обратную сторону: что партнёр может сделать с вашим API. Отдельный ключ партнёра не должен открывать административные эндпоинты, а действия по чужим объектам проверяются так же, как для пользователя: в API Security Top 10 это API1 и API5 — нарушение авторизации на уровне объекта и функции.
Подпись HMAC
- Что даёт
- Доказывает, что тело отправил владелец общего секрета, и не изменилось
- Когда уместен
- Почти всегда для вебхуков и событий
- Ограничения
- Общий секрет хранится у обеих сторон; нужна защита от повтора
mTLS (взаимный TLS)
- Что даёт
- Клиент предъявляет свой сертификат, сервер проверяет его при соединении
- Когда уместен
- Банки, B2B-обмен, сервис-сервис внутри компании; когда партнёр это поддерживает
- Ограничения
- Выпуск и ротация сертификатов, настройка на прокси
IP allowlist
- Что даёт
- Отсекает запросы не с адресов партнёра ещё до приложения
- Когда уместен
- Партнёр публикует стабильный список адресов
- Ограничения
- Не заменяет подпись: адреса облаков общие и меняются; список нужно обновлять
OAuth client credentials
- Что даёт
- Короткоживущий токен с узким scope вместо вечного ключа
- Когда уместен
- Партнёр выступает сервером авторизации
- Ограничения
- Секрет клиента всё равно нужно хранить и ротировать
Входящие данные: подпись, свежесть, схема, размер
Входящее событие проверяется по цепочке, и порядок важен: дешёвые проверки — раньше дорогих, подпись — до разбора JSON, бизнес-логика — только после схемы. Подробно подпись HMAC, повторную доставку и идемпотентность разбирает урок о вебхуках; здесь акцент на том, что идёт после подписи: подпись доказывает, что событие прислал партнёр, но не то, что его содержимое безопасно.
Размер
Тело больше лимита отклоняется с 413 до любых вычислений: огромный JSON — простой способ занять память и процессор (API4 — неограниченное потребление ресурсов).
Подпись
HMAC-SHA256 по метке времени и сырому телу, сравнение за постоянное время через
timingSafeEqual. Принимаются текущий и предыдущий ключи — для ротации.Свежесть
Метка времени не старше пяти минут: перехваченный запрос нельзя переслать завтра.
Схема
Строгая схема: точные типы, форматы, диапазоны, перечисления допустимых значений, запрет лишних полей. Ошибка схемы — 422 с перечнем полей.
Дубликаты
Событие с уже обработанным
idне выполняется второй раз. В продакшне — уникальный ключ в таблице, а неSetв памяти.
mkdir integration-security && cd integration-security
npm init -y && npm pkg set type=module
npm install zod@4.6.6zod — единственная зависимость// Incoming event from a partner: size -> signature -> freshness -> schema -> duplicates.
// The signature is HMAC-SHA256 over "<timestamp>.<raw body>" with a shared secret.
import { createHmac, timingSafeEqual } from "node:crypto";
import * as z from "zod";
const MAX_BODY_BYTES = 64 * 1024;
const TOLERANCE_SECONDS = 300;
const OrderPaid = z.strictObject({
id: z.string().regex(/^evt_[A-Za-z0-9]{8,32}$/),
type: z.literal("order.paid"),
data: z.strictObject({
orderId: z.string().regex(/^[0-9]{1,12}$/),
amount: z.number().int().positive().max(100_000_000),
currency: z.enum(["KZT", "USD"]),
}),
});
export class RejectedEvent extends Error {
name = "RejectedEvent";
constructor(status, reason) {
super(reason);
this.status = status;
}
}
export function sign(secret, timestamp, rawBody) {
return createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
}
function signatureMatches(secret, timestamp, rawBody, received) {
const expected = Buffer.from(sign(secret, timestamp, rawBody), "hex");
const got = Buffer.from(received, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
}
const seen = new Set(); // in production: a table with a unique key on the event id
// secrets: [current, previous] — both are accepted while a key is being rotated
export function acceptPartnerEvent(rawBody, headers, secrets, now = Date.now()) {
if (rawBody.length > MAX_BODY_BYTES) throw new RejectedEvent(413, "body too large");
const timestamp = headers["x-partner-timestamp"] ?? "";
const signature = headers["x-partner-signature"] ?? "";
if (!/^\d{10}$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) {
throw new RejectedEvent(401, "missing or malformed signature headers");
}
if (!secrets.filter(Boolean).some((s) => signatureMatches(s, timestamp, rawBody, signature))) {
throw new RejectedEvent(401, "bad signature");
}
if (Math.abs(now / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
throw new RejectedEvent(401, "stale timestamp");
}
let json;
try {
json = JSON.parse(rawBody.toString("utf8"));
} catch {
throw new RejectedEvent(400, "invalid JSON");
}
const parsed = OrderPaid.safeParse(json);
if (!parsed.success) {
throw new RejectedEvent(422, parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "));
}
if (seen.has(parsed.data.id)) return { duplicate: true, event: parsed.data };
seen.add(parsed.data.id);
return { duplicate: false, event: parsed.data };
}Почему strictObject, а не обычный объект: неизвестное поле isAdmin в событии — либо ошибка партнёра, либо попытка массового присваивания (mass assignment), если данные дальше передаются в ORM целиком. Строгая схема отклоняет такое событие, а не молча копирует поле в базу. Регулярное выражение для orderId разрешает только цифры — строка с SQL до запроса не доходит, хотя параметризованные запросы всё равно обязательны.
$ node --test partner-event.test.mjs
first: false { orderId: '1042', amount: 15000, currency: 'KZT' } | repeat: true
after rotation: 401 bad signature
401 bad signature
401 missing or malformed signature headers
401 stale timestamp
422 data: Unrecognized key: "isAdmin"
422 data.orderId: Invalid string: must match pattern /^[0-9]{1,12}$/; data.amount: Too small: expected number to be >0; data.currency: Invalid option: expected one of "KZT"|"USD"
413 body too large
✔ valid event is accepted once, the repeat is marked as duplicate (2.769ms)
✔ an event signed with the previous key passes during rotation (0.452ms)
✔ tampering, stale timestamps, junk and oversized bodies are rejected (0.8696ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0node --test на Node.js 24.18: дубль распознан, старый ключ принимается только до конца ротации, подмена суммы, просроченная метка, лишнее поле, неверные форматы и огромное тело отклоненыИсходящие запросы: SSRF и allowlist
SSRF (server-side request forgery) возникает, когда сервер делает запрос по адресу, который задал пользователь: импорт аватара по ссылке, адрес вебхука клиента, предпросмотр страницы. Атакующий подставляет внутренний адрес — базу, админку, сервис метаданных облака 169.254.169.254 с временными ключами — и сервер сам приносит ему ответ из закрытой сети.
Шпаргалка OWASP различает два случая. Если адресаты известны заранее — платёжный шлюз, CRM, API партнёра, — используйте allowlist: пользователь выбирает из списка, а URL собирает сервер; пример такой проверки есть в уроке про OWASP Top 10. Если адрес действительно произвольный, как у вебхуков клиентов, остаётся блок-лист внутренних сетей, и сделать его правильно сложнее, чем кажется:
- URL разбирается одним стандартным парсером:
https://0x7f000001/иhttps://2130706433/после разбора — это127.0.0.1. Проверять нужно результатnew URL, а не исходную строку. - Доменное имя может указывать на внутренний IP. Проверяются все адреса, в которые оно разрешается, — A и AAAA.
- Проверка и соединение должны использовать один и тот же результат DNS. Если сначала проверить имя, а потом дать HTTP-клиенту разрешить его заново, атакующий успеет поменять запись (DNS rebinding). Поэтому проверка встроена в
lookupсамого соединения. - Редиректы не выполняются: разрешённый внешний сервер может ответить 302 на внутренний адрес. Это же требование есть в API10: не следовать за редиректами партнёра вслепую.
- Таймаут и лимит размера ответа обязательны: медленный или бесконечный ответ занимает соединения вашего сервера.
// Outgoing request to a URL supplied by a user (avatar import, customer webhook URL).
// SSRF defences from the OWASP cheat sheet: scheme allowlist, IP check after DNS,
// connection only to the checked address, no redirects, timeout and size limit.
import { lookup as dnsLookup } from "node:dns";
import { request } from "node:https";
import { BlockList, isIP } from "node:net";
const denied = new BlockList();
for (const [net, prefix] of [
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16],
["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
]) denied.addSubnet(net, prefix, "ipv4");
for (const [net, prefix] of [
["::", 128], ["::1", 128], ["64:ff9b::", 96], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
]) denied.addSubnet(net, prefix, "ipv6");
export function isPublicAddress(ip) {
const family = isIP(ip);
if (family === 0) return false;
// BlockList also matches IPv4-mapped IPv6 (::ffff:127.0.0.1) against IPv4 rules
return !denied.check(ip, family === 4 ? "ipv4" : "ipv6");
}
export class BlockedUrlError extends Error {
name = "BlockedUrlError";
}
// Every address the name resolves to must be allowed; the socket then connects
// to exactly these addresses, so there is no second, unchecked DNS lookup.
function guardedLookup(isAllowed) {
return (hostname, options, callback) => {
dnsLookup(hostname, { all: true }, (err, addresses) => {
if (err) return callback(err);
const bad = addresses.find((a) => !isAllowed(a.address));
if (bad) return callback(new BlockedUrlError(`${hostname} resolves to a blocked address ${bad.address}`));
if (options.all) return callback(null, addresses);
callback(null, addresses[0].address, addresses[0].family);
});
};
}
export function safeGet(input, { timeoutMs = 5000, maxBytes = 1_000_000, isAllowed = isPublicAddress, ports = ["", "443"] } = {}) {
let url;
try {
url = new URL(input);
} catch {
return Promise.reject(new BlockedUrlError("invalid URL"));
}
if (url.protocol !== "https:") return Promise.reject(new BlockedUrlError(`scheme ${url.protocol} is not allowed`));
if (url.username || url.password) return Promise.reject(new BlockedUrlError("credentials in URL are not allowed"));
if (!ports.includes(url.port)) return Promise.reject(new BlockedUrlError(`port ${url.port} is not allowed`));
const host = url.hostname.replace(/^\[|\]$/g, "");
// An IP literal skips DNS, so it is checked here
if (isIP(host) && !isAllowed(host)) return Promise.reject(new BlockedUrlError(`address ${host} is blocked`));
return new Promise((resolve, reject) => {
const req = request(url, {
method: "GET",
lookup: guardedLookup(isAllowed),
signal: AbortSignal.timeout(timeoutMs),
headers: { "user-agent": "interlab-fetcher/1.0" },
}, (res) => {
// node:https does not follow redirects; we do not follow them either
if (res.statusCode >= 300 && res.statusCode < 400) {
res.resume();
return reject(new BlockedUrlError(`redirect to ${res.headers.location} was not followed`));
}
const chunks = [];
let size = 0;
res.on("data", (chunk) => {
size += chunk.length;
if (size > maxBytes) {
req.destroy(new BlockedUrlError(`response is larger than ${maxBytes} bytes`));
return;
}
chunks.push(chunk);
});
res.on("end", () => resolve({ status: res.statusCode, type: res.headers["content-type"], body: Buffer.concat(chunks) }));
res.on("error", reject);
});
req.on("error", reject);
req.end();
});
}$ node --test safe-fetch.test.mjs
http://example.com/ -> scheme http: is not allowed
file:///etc/passwd -> scheme file: is not allowed
https://user:pass@example.com/ -> credentials in URL are not allowed
https://example.com:8443/ -> port 8443 is not allowed
https://169.254.169.254/latest/meta-data/ -> address 169.254.169.254 is blocked
https://0x7f000001/ -> address 127.0.0.1 is blocked
https://2130706433/admin -> address 127.0.0.1 is blocked
https://[::ffff:127.0.0.1]/ -> address ::ffff:7f00:1 is blocked
✔ isPublicAddress: private, loopback, link-local and mapped addresses are blocked (5.2406ms)
https://localhost/ -> localhost resolves to a blocked address ::1
✔ dangerous URLs are rejected before any connection (10.0721ms)
https://example.com/ -> 200 text/html; charset=utf-8, 577 bytes
✔ a public HTTPS address works (42.96ms)
/redirect -> BlockedUrlError: redirect to http://169.254.169.254/latest/meta-data/ was not followed
/big -> BlockedUrlError: response is larger than 1000000 bytes
/slow -> AbortError: The operation was aborted
✔ redirects are not followed, size and time are limited (529.0931ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0localhost — после DNS, публичный адрес работает. Для проверки редиректа, размера и таймаута тест явно разрешает локальный HTTPS-сервер с самоподписанным сертификатомЛоги без секретов и персональных данных
Интеграции отлаживают по логам, и именно туда чаще всего утекают ключи: заголовок Authorization, ?api_key= в URL, целиком сохранённый запрос партнёра. Шпаргалка OWASP по журналированию перечисляет, что не пишут в журнал напрямую: токены доступа, идентификаторы сессий, ключи шифрования и другие первичные секреты, чувствительные персональные данные; несенситивные персональные данные — имя, телефон, e-mail — по возможности маскируют или псевдонимизируют. Там же — требование очищать данные от переводов строк, чтобы чужое значение не подделало новую запись.
// Integration log: JSON lines, secrets removed, personal data masked.
const SECRET_KEYS = /authorization|cookie|token|secret|password|api[-_]?key|signature/i;
// Query strings often carry keys (?api_key=…): keep only the origin and path
function stripQuery(value) {
const url = new URL(value);
return url.search ? `${url.origin}${url.pathname}?[REDACTED]` : value;
}
function maskEmail(value) {
const [user, domain] = value.split("@");
return `${user.slice(0, 1)}***@${domain}`;
}
function maskPhone(value) {
const digits = value.replace(/\D/g, "");
return `***${digits.slice(-2)}`;
}
export function redact(value, key = "") {
if (SECRET_KEYS.test(key)) return "[REDACTED]";
if (Array.isArray(value)) return value.map((v) => redact(v));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, k)]));
}
if (typeof value === "string" && /^https?:\/\//.test(value)) return stripQuery(value);
if (typeof value === "string" && /^[^@\s]+@[^@\s]+$/.test(value)) return maskEmail(value);
if (typeof value === "string" && /^phone$/i.test(key)) return maskPhone(value);
if (typeof value === "string" && /^(name|full_?name)$/i.test(key)) return `${value.slice(0, 1)}***`;
return value;
}
export function logIntegration(event, fields) {
// JSON.stringify escapes \r and \n, so a partner's value cannot forge a new log line
console.log(JSON.stringify({ time: new Date().toISOString(), event, ...redact(fields) }));
}import { logIntegration } from "./safe-log.mjs";
logIntegration("crm.lead.create", {
integration: "crm-main",
status: 401,
durationMs: 184,
request: {
url: "https://crm.example.test/api/v4/leads?api_key=YOUR_API_KEY",
headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json" },
},
lead: { name: "Test Client", phone: "+7 701 000 00 42", email: "client@example.test" },
partnerMessage: "invalid token\nINFO payment ok",
});$ node safe-log.demo.mjs
{"time":"2026-10-12T03:00:27.507Z","event":"crm.lead.create","integration":"crm-main","status":401,"durationMs":184,"request":{"url":"https://crm.example.test/api/v4/leads?[REDACTED]","headers":{"Authorization":"[REDACTED]","Content-Type":"application/json"}},"lead":{"name":"T***","phone":"***42","email":"c***@example.test"},"partnerMessage":"invalid token\nINFO payment ok"}Такой фильтр — страховка, а не основная защита. Надёжнее не передавать в логгер лишнее: писать идентификатор события, интеграцию, статус, длительность и код ошибки партнёра, а не всё тело. По нашему опыту, этого хватает для разбора большинства инцидентов, а полные тела запросов, если они действительно нужны, хранят отдельно, с коротким сроком хранения и ограниченным доступом.
Чек-лист перед запуском интеграции
- Ключ интеграции выпущен отдельно для этой интеграции и этого окружения, хранится в секретах хостинга или менеджере секретов, а не в коде
- У ключа или сервисного аккаунта минимальные права; проверено, что он не открывает лишние методы
- Известны владелец ключа, срок действия и порядок ротации; приёмник умеет принимать два ключа на время замены
- Все входящие события проверяются по цепочке: размер → подпись → свежесть → строгая схема → дубликаты
- Для B2B-обмена с поддержкой mTLS или стабильными адресами партнёра включены mTLS или IP allowlist — как дополнительный слой
- Данные от партнёра проходят ту же валидацию, что и ввод пользователя, и попадают в базу только через параметризованные запросы
- Исходящие запросы: TLS, таймаут, лимит размера, без слепого следования за редиректами
- Запросы по URL от пользователя проходят SSRF-фильтр, а сервис изолирован от внутренней сети на сетевом уровне
- Логи не содержат токенов, ключей, query-строк с ключами и открытых персональных данных; есть алерт на всплеск 401 и ошибок подписи
- Есть план на случай утечки: кто отзывает ключ, как быстро выпускается новый, как уведомляется партнёр
Как проверено4 · пройденосверено 11 октября 2026 г.
- запущено, работает
partner-event.mjs (zod 4.6.6)
node --test на Node 24.18, 3 теста: приём и дубль, ротация (старый ключ до/после удаления), подмена суммы/нет заголовков/просрочка → 401, лишнее поле и неверные форматы → 422, 70 КБ → 413; вывод в статье — реальный
- запущено, работает
safe-fetch.mjs
node --test, 4 теста: таблица isPublicAddress; http/file/userinfo/порт/169.254.169.254/0x7f000001/2130706433/[::ffff:127.0.0.1] отклонены до соединения, localhost — в lookup после DNS; реальный GET https://example.com → 200; локальный HTTPS (самоподписанный сертификат, явно разрешён только в тесте): 302 не выполнен, 2 МБ > лимита, таймаут 500 мс → AbortError
- запущено, работает
safe-log.mjs + safe-log.demo.mjs
node 24.18, реальный вывод: Authorization и query скрыты, имя/телефон/e-mail замаскированы, \n остался внутри JSON-строки
- запущено, работает
команда установки (bash)
npm init -y, npm pkg set type=module, npm install zod@4.6.6 во временном проекте
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.