Merge pull request #2211 from open-gsd/fix/2198-security-dead-scan-exports
fix(#2198): remove dead scan exports, correct injection-scan docs
This commit is contained in:
5
.changeset/2198-security-dead-scan-exports.md
Normal file
5
.changeset/2198-security-dead-scan-exports.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 2211
|
||||||
|
---
|
||||||
|
**Dead security scan exports removed; injection-scan docs corrected to match reality** — `scanEntropyAnomalies` and `shannonEntropy` were dead code with zero production callers (live hooks inline their own patterns for independence). REQ-SCAN-INJ-02/-03 now accurately describe what runs live (injection patterns, invisible Unicode) vs CI-only (base64-decode, codebase scan). (#2198)
|
||||||
@@ -2267,15 +2267,15 @@ Test suite that scans all agent, workflow, and command files for embedded inject
|
|||||||
|
|
||||||
### 99. Improved Prompt Injection Scanner
|
### 99. Improved Prompt Injection Scanner
|
||||||
|
|
||||||
**Hook:** `gsd-prompt-guard.js`
|
**Hook:** `gsd-prompt-guard.js`, `gsd-read-injection-scanner.js`
|
||||||
**Script:** `scripts/prompt-injection-scan.sh`
|
**Script:** `scripts/prompt-injection-scan.sh`, `scripts/base64-scan.sh`
|
||||||
|
|
||||||
**Purpose:** Enhanced detection of prompt injection attempts in planning artifacts, adding invisible Unicode character detection, encoding obfuscation patterns, and entropy-based analysis.
|
**Purpose:** Defense-in-depth detection of prompt injection attempts in planning artifacts and ingested content. Live hooks inline their own pattern subsets for hook independence (they do not import from `security.cts`). The CI scanner (`scanForInjection` in `security.cts`) provides a centralized engine for codebase-wide scanning in tests.
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- REQ-SCAN-INJ-01: Scanner MUST detect invisible Unicode characters (zero-width spaces, soft hyphens, etc.)
|
- REQ-SCAN-INJ-01: Live hooks MUST detect invisible Unicode characters (zero-width spaces, soft hyphens, Unicode tag block U+E0000–E007F)
|
||||||
- REQ-SCAN-INJ-02: Scanner MUST detect encoding obfuscation patterns (base64-encoded instructions, homoglyphs)
|
- REQ-SCAN-INJ-02: Live hooks MUST detect known injection patterns (instruction override, role manipulation, system-prompt extraction, fake message boundaries). Base64-decode scanning is a CI-time control (`scripts/base64-scan.sh`), not a live hook — live hooks match a base64-exfiltration phrase regex only, they do not decode.
|
||||||
- REQ-SCAN-INJ-03: Scanner MUST apply entropy analysis to flag high-entropy strings in unexpected positions
|
- REQ-SCAN-INJ-03: ~~Scanner MUST apply entropy analysis~~ — Entropy analysis (`scanEntropyAnomalies`) was removed in #2198 as dead code (zero production callers; live hooks do not perform entropy analysis). This requirement is deferred pending a maintainable live implementation.
|
||||||
- REQ-SCAN-INJ-04: Scanner MUST remain advisory-only — detection is logged, not blocking
|
- REQ-SCAN-INJ-04: Scanner MUST remain advisory-only — detection is logged, not blocking
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2195,15 +2195,15 @@ Claude が GSD ワークフローコンテキスト外でファイル編集を
|
|||||||
|
|
||||||
### 99. 改善されたプロンプトインジェクションスキャナー
|
### 99. 改善されたプロンプトインジェクションスキャナー
|
||||||
|
|
||||||
**フック:** `gsd-prompt-guard.js`
|
**フック:** `gsd-prompt-guard.js`、`gsd-read-injection-scanner.js`
|
||||||
**スクリプト:** `scripts/prompt-injection-scan.sh`
|
**スクリプト:** `scripts/prompt-injection-scan.sh`、`scripts/base64-scan.sh`
|
||||||
|
|
||||||
**目的:** プランニングアーティファクト内のプロンプトインジェクション試みの検出を強化し、不可視 Unicode 文字検出、エンコードの難読化パターン、エントロピーベースの分析を追加します。
|
**目的:** プランニングアーティファクトおよび取り込んだコンテンツ内のプロンプトインジェクション試行の多層防御検出。ライブフックはフック独立性のために独自のパターンサブセットをインライン化します(`security.cts` からインポートしません)。CIスキャナー(`security.cts` の `scanForInjection`)は、テストでのコードベース全体スキャン用の集中エンジンを提供します。
|
||||||
|
|
||||||
**要件:**
|
**要件:**
|
||||||
- REQ-SCAN-INJ-01: スキャナーは不可視 Unicode 文字(ゼロ幅スペース、ソフトハイフンなど)を検出しなければならない
|
- REQ-SCAN-INJ-01: ライブフックは不可視 Unicode 文字(ゼロ幅スペース、ソフトハイフン、Unicode タグブロック U+E0000–E007F)を検出しなければならない
|
||||||
- REQ-SCAN-INJ-02: スキャナーはエンコードの難読化パターン(base64 エンコードされた命令、ホモグリフ)を検出しなければならない
|
- REQ-SCAN-INJ-02: ライブフックは既知のインジェクションパターン(命令オーバーライド、ロール操作、システムプロンプト抽出、偽のメッセージ境界)を検出しなければならない。Base64 デコードスキャンは CI 時制御(`scripts/base64-scan.sh`)であり、ライブフックではない — ライブフックは base64 持ち出しフレーズ正規表現のみを一致させ、デコードはしない。
|
||||||
- REQ-SCAN-INJ-03: スキャナーは予期しない位置の高エントロピー文字列にフラグを立てるためにエントロピー分析を適用しなければならない
|
- REQ-SCAN-INJ-03: ~~スキャナーはエントロピー分析を適用しなければならない~~ — エントロピー分析(`scanEntropyAnomalies`)は #2198 でデッドコードとして削除された(本番呼び出し元ゼロ;ライブフックはエントロピー分析を実行しない)。この要件は保守可能なライブ実装まで保留。
|
||||||
- REQ-SCAN-INJ-04: スキャナーは勧告的のみでなければならない — 検出はログに記録されるが、ブロッキングではない
|
- REQ-SCAN-INJ-04: スキャナーは勧告的のみでなければならない — 検出はログに記録されるが、ブロッキングではない
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -133,11 +133,16 @@ file before the job passes.
|
|||||||
|
|
||||||
### 2.4 Locale-safe text scanning
|
### 2.4 Locale-safe text scanning
|
||||||
|
|
||||||
**Control:** Text output and user-facing strings are scanned for locale-unsafe
|
**Control:** Text output and user-facing strings are scanned for invisible
|
||||||
constructs (non-ASCII homoglyphs, bidirectional override characters, invisible
|
Unicode and bidirectional override characters that could be used to obscure
|
||||||
Unicode) that could be used to obscure malicious content in diffs or logs.
|
malicious content in diffs or logs. The live hooks
|
||||||
|
(`gsd-prompt-guard.js`, `gsd-read-injection-scanner.js`) inline their own
|
||||||
|
Unicode-detection patterns for hook independence — they do not call
|
||||||
|
`scanForInjection` from `security.cts`. The centralized `scanForInjection`
|
||||||
|
function serves as the CI codebase-scanner engine
|
||||||
|
(`tests/prompt-injection-scan.security.test.cjs`).
|
||||||
|
|
||||||
**Why it matters:** Unicode homoglyph and BiDi attacks are documented
|
**Why it matters:** Unicode invisible-character and BiDi attacks are documented
|
||||||
supply-chain vectors (CVE-2021-42574 — "Trojan Source"). Detecting them at scan
|
supply-chain vectors (CVE-2021-42574 — "Trojan Source"). Detecting them at scan
|
||||||
time prevents invisible payload injection in source and output files.
|
time prevents invisible payload injection in source and output files.
|
||||||
|
|
||||||
|
|||||||
@@ -2211,15 +2211,15 @@ PreToolUse 钩子,检测 Claude 在 GSD 工作流上下文之外尝试文件
|
|||||||
|
|
||||||
### 99. 改进的提示注入扫描器
|
### 99. 改进的提示注入扫描器
|
||||||
|
|
||||||
**钩子:** `gsd-prompt-guard.js`
|
**钩子:** `gsd-prompt-guard.js`、`gsd-read-injection-scanner.js`
|
||||||
**脚本:** `scripts/prompt-injection-scan.sh`
|
**脚本:** `scripts/prompt-injection-scan.sh`、`scripts/base64-scan.sh`
|
||||||
|
|
||||||
**目的:** 增强对规划构件中提示注入尝试的检测,添加不可见 Unicode 字符检测、编码混淆模式和基于熵的分析。
|
**目的:** 对规划构件和摄入内容中提示注入尝试的深度防御检测。实时钩子为保持独立性内联了自己的模式子集(不导入 `security.cts`)。CI 扫描器(`security.cts` 中的 `scanForInjection`)为测试中的全代码库扫描提供集中引擎。
|
||||||
|
|
||||||
**需求:**
|
**需求:**
|
||||||
- REQ-SCAN-INJ-01:扫描器必须检测不可见 Unicode 字符(零宽空格、软连字符等)
|
- REQ-SCAN-INJ-01:实时钩子必须检测不可见 Unicode 字符(零宽空格、软连字符、Unicode 标签块 U+E0000–E007F)
|
||||||
- REQ-SCAN-INJ-02:扫描器必须检测编码混淆模式(base64 编码的指令、同形字)
|
- REQ-SCAN-INJ-02:实时钩子必须检测已知注入模式(指令覆盖、角色操纵、系统提示提取、伪造消息边界)。Base64 解码扫描是 CI 时控制(`scripts/base64-scan.sh`),不是实时钩子 — 实时钩子仅匹配 base64 外泄短语正则,不解码。
|
||||||
- REQ-SCAN-INJ-03:扫描器必须应用熵分析以标记意外位置的高熵字符串
|
- REQ-SCAN-INJ-03:~~扫描器必须应用熵分析~~ — 熵分析(`scanEntropyAnomalies`)在 #2198 中作为死代码被移除(零生产调用者;实时钩子不执行熵分析)。此需求推迟到有可维护的实时实现时。
|
||||||
- REQ-SCAN-INJ-04:扫描器必须保持仅建议性 — 检测会被记录,而不会阻止
|
- REQ-SCAN-INJ-04:扫描器必须保持仅建议性 — 检测会被记录,而不会阻止
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -50,11 +50,12 @@
|
|||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"files": [
|
"files": [
|
||||||
|
"security-dead-exports.regression.test.cjs",
|
||||||
"security-prompt-injection.security.test.cjs",
|
"security-prompt-injection.security.test.cjs",
|
||||||
"security-scan.security.test.cjs",
|
"security-scan.security.test.cjs",
|
||||||
"security.test.cjs"
|
"security.test.cjs"
|
||||||
],
|
],
|
||||||
"issue": "TBD"
|
"issue": "2198"
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -477,40 +477,9 @@ export function validatePromptStructure(text: unknown, fileType: string): { vali
|
|||||||
return { valid: violations.length === 0, violations };
|
return { valid: violations.length === 0, violations };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Layer 4: Paragraph-Level Entropy Anomaly Detection ─────────────────────────────────────────────────────────────────────
|
// NOTE (#2198): scanEntropyAnomalies + shannonEntropy were removed as dead exports.
|
||||||
|
// They had zero production callers — the live hooks (gsd-prompt-guard.js,
|
||||||
function shannonEntropy(text: string): number {
|
// gsd-read-injection-scanner.js) inline their own pattern subsets for hook
|
||||||
if (!text || text.length === 0) return 0;
|
// independence and never called these functions. scanForInjection is retained
|
||||||
const freq: Record<string, number> = {};
|
// below: it serves as the CI codebase-scanner engine
|
||||||
for (const ch of text) {
|
// (tests/prompt-injection-scan.security.test.cjs), not as a live hook.
|
||||||
freq[ch] = (freq[ch] || 0) + 1;
|
|
||||||
}
|
|
||||||
const len = text.length;
|
|
||||||
let entropy = 0;
|
|
||||||
for (const count of Object.values(freq)) {
|
|
||||||
const p = count / len;
|
|
||||||
entropy -= p * Math.log2(p);
|
|
||||||
}
|
|
||||||
return entropy;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan text for paragraphs with anomalously high Shannon entropy.
|
|
||||||
*/
|
|
||||||
export function scanEntropyAnomalies(text: unknown): { clean: boolean; findings: string[] } {
|
|
||||||
if (!text || typeof text !== 'string') {
|
|
||||||
return { clean: true, findings: [] };
|
|
||||||
}
|
|
||||||
const findings: string[] = [];
|
|
||||||
const paragraphs = text.split(/\n\n+/);
|
|
||||||
for (const para of paragraphs) {
|
|
||||||
if (para.length <= 50) continue;
|
|
||||||
const entropy = shannonEntropy(para);
|
|
||||||
if (entropy > 5.5) {
|
|
||||||
findings.push(
|
|
||||||
`High-entropy paragraph detected (${entropy.toFixed(2)} bits/char) — possible encoded payload`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { clean: findings.length === 0, findings };
|
|
||||||
}
|
|
||||||
|
|||||||
90
tests/security-dead-exports.regression.test.cjs
Normal file
90
tests/security-dead-exports.regression.test.cjs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Regression test for #2198 — advertised base64/entropy/homoglyph scanning
|
||||||
|
* never runs live. `scanEntropyAnomalies` + `shannonEntropy` were dead exports
|
||||||
|
* (zero callers outside their own unit tests). The live hooks inline their
|
||||||
|
* own pattern subsets "for hook independence" and never call these functions.
|
||||||
|
*
|
||||||
|
* This test asserts the chosen contract: the dead export was removed and the
|
||||||
|
* docs no longer over-claim entropy analysis as a live MUST.
|
||||||
|
*
|
||||||
|
* Contract: `scanForInjection` is retained — it serves as the CI codebase
|
||||||
|
* scanner engine (tests/prompt-injection-scan.security.test.cjs). It is NOT
|
||||||
|
* called from live hooks; hooks inline their own patterns.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PROJECT_ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
describe('#2198 regression: dead scan exports removed, docs corrected', () => {
|
||||||
|
test('scanEntropyAnomalies is no longer exported from security.cjs', () => {
|
||||||
|
const security = require('../gsd-core/bin/lib/security.cjs');
|
||||||
|
assert.equal(
|
||||||
|
security.scanEntropyAnomalies,
|
||||||
|
undefined,
|
||||||
|
'scanEntropyAnomalies should have been removed as a dead export (#2198)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shannonEntropy is not accessible from the security module', () => {
|
||||||
|
const security = require('../gsd-core/bin/lib/security.cjs');
|
||||||
|
assert.equal(
|
||||||
|
security.shannonEntropy,
|
||||||
|
undefined,
|
||||||
|
'shannonEntropy was the private helper for the removed scanEntropyAnomalies'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scanForInjection is retained (CI codebase scanner uses it)', () => {
|
||||||
|
const security = require('../gsd-core/bin/lib/security.cjs');
|
||||||
|
assert.equal(
|
||||||
|
typeof security.scanForInjection,
|
||||||
|
'function',
|
||||||
|
'scanForInjection is retained: it serves as the CI codebase scanner engine'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FEATURES.md does not over-claim entropy analysis as a live MUST', () => {
|
||||||
|
const features = fs.readFileSync(
|
||||||
|
path.join(PROJECT_ROOT, 'docs', 'FEATURES.md'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!features.includes('REQ-SCAN-INJ-03: Scanner MUST apply entropy analysis'),
|
||||||
|
'REQ-SCAN-INJ-03 should not claim entropy analysis runs as a live MUST — ' +
|
||||||
|
'the implementation was dead code (#2198)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FEATURES.md documents that base64-decode is CI-only, not live', () => {
|
||||||
|
const features = fs.readFileSync(
|
||||||
|
path.join(PROJECT_ROOT, 'docs', 'FEATURES.md'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
features.includes('CI-time control'),
|
||||||
|
'FEATURES.md should note base64-decode is a CI-time control, not a live hook (#2198)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live hooks inline patterns independently (do not import security.cjs)', () => {
|
||||||
|
const hookFiles = [
|
||||||
|
'hooks/gsd-prompt-guard.js',
|
||||||
|
'hooks/gsd-read-injection-scanner.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const relPath of hookFiles) {
|
||||||
|
const fullPath = path.join(PROJECT_ROOT, relPath);
|
||||||
|
const source = fs.readFileSync(fullPath, 'utf-8');
|
||||||
|
assert.ok(
|
||||||
|
!source.match(/require\s*\(\s*['"][^'"]*security\.(cjs|js)['"]\s*\)/) &&
|
||||||
|
!source.match(/import\s+.*from\s+['"][^'"]*security\.(cjs|js)['"]\s*;?/),
|
||||||
|
`${relPath} must not require/import security.cjs — hooks inline patterns for independence`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,6 @@ const {
|
|||||||
validateFieldName,
|
validateFieldName,
|
||||||
validateShellArg,
|
validateShellArg,
|
||||||
validatePromptStructure,
|
validatePromptStructure,
|
||||||
scanEntropyAnomalies,
|
|
||||||
} = require('../gsd-core/bin/lib/security.cjs');
|
} = require('../gsd-core/bin/lib/security.cjs');
|
||||||
|
|
||||||
// ─── Path Traversal Prevention ──────────────────────────────────────────────
|
// ─── Path Traversal Prevention ──────────────────────────────────────────────
|
||||||
@@ -765,79 +764,8 @@ describe('validatePromptStructure', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Layer 4: Paragraph-Level Entropy Anomaly Detection ─────────────────────
|
// NOTE (#2198): scanEntropyAnomalies test block removed — the function was a
|
||||||
|
// dead export (zero production callers) and has been deleted from security.cts.
|
||||||
describe('scanEntropyAnomalies', () => {
|
|
||||||
test('is exported from security.cjs', () => {
|
|
||||||
assert.equal(typeof scanEntropyAnomalies, 'function');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns { clean, findings } shape', () => {
|
|
||||||
const result = scanEntropyAnomalies('Normal text here.');
|
|
||||||
assert.ok(typeof result.clean === 'boolean');
|
|
||||||
assert.ok(Array.isArray(result.findings));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clean natural language text passes', () => {
|
|
||||||
const text = [
|
|
||||||
'Build an authentication system with JWT tokens.',
|
|
||||||
'',
|
|
||||||
'The system should support login, logout, and token refresh.',
|
|
||||||
].join('\n');
|
|
||||||
const result = scanEntropyAnomalies(text);
|
|
||||||
assert.ok(result.clean, `Expected clean but got: ${result.findings.join(', ')}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detects high-entropy paragraph (random-character content)', () => {
|
|
||||||
// A string cycling through 90 distinct chars has entropy ~6.4 bits/char, well above 5.5 threshold
|
|
||||||
const highEntropyPara = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=!@#$%^&*()_-[]{}|;:,.<>?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr';
|
|
||||||
const result = scanEntropyAnomalies(highEntropyPara);
|
|
||||||
assert.ok(!result.clean, 'should detect high-entropy paragraph');
|
|
||||||
assert.ok(
|
|
||||||
result.findings.some(f => f.includes('High-entropy paragraph')),
|
|
||||||
'finding should mention high-entropy paragraph'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('finding includes entropy value in bits/char', () => {
|
|
||||||
const highEntropyPara = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=!@#$%^&*()_-[]{}|;:,.<>?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr';
|
|
||||||
const result = scanEntropyAnomalies(highEntropyPara);
|
|
||||||
assert.ok(result.findings.some(f => f.includes('bits/char')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('skips paragraphs shorter than or equal to 50 chars', () => {
|
|
||||||
// Even a high-entropy short paragraph should not be flagged
|
|
||||||
const shortPara = 'SGVsbG8gV29ybGQ='; // 16 chars — under 50
|
|
||||||
const result = scanEntropyAnomalies(shortPara);
|
|
||||||
assert.ok(result.clean, 'short paragraphs should be skipped');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles empty text gracefully', () => {
|
|
||||||
const result = scanEntropyAnomalies('');
|
|
||||||
assert.ok(result.clean);
|
|
||||||
assert.equal(result.findings.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles null gracefully', () => {
|
|
||||||
const result = scanEntropyAnomalies(null);
|
|
||||||
assert.ok(result.clean);
|
|
||||||
assert.equal(result.findings.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multiple paragraphs — flags only high-entropy ones', () => {
|
|
||||||
const highEntropyPara = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=!@#$%^&*()_-[]{}|;:,.<>?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqr';
|
|
||||||
const text = [
|
|
||||||
'This is a perfectly normal English sentence describing a feature.',
|
|
||||||
'',
|
|
||||||
highEntropyPara,
|
|
||||||
'',
|
|
||||||
'Another clean sentence about the authentication requirements.',
|
|
||||||
].join('\n');
|
|
||||||
const result = scanEntropyAnomalies(text);
|
|
||||||
assert.ok(!result.clean);
|
|
||||||
assert.equal(result.findings.length, 1, 'only 1 high-entropy paragraph should be flagged');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user