Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
APP_NAME=Simutransアドオン横断検索
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_DEBUG=false
APP_URL=http://localhost

LOG_CHANNEL=stack
Expand Down
7 changes: 5 additions & 2 deletions app/Actions/Extract/Japan/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Actions\Extract\ChunkRawPages;
use App\Actions\Extract\HandlerInterface;
use App\Actions\Extract\MarkExtractFailed;
use App\Actions\Extract\SyncPak;
use App\Actions\Extract\UpdateOrCreatePage;
use App\Enums\SiteName;
Expand All @@ -23,6 +24,7 @@ public function __construct(
private ExtractContents $extractContents,
private UpdateOrCreatePage $updateOrCreatePage,
private SyncPak $syncPak,
private MarkExtractFailed $markExtractFailed,
) {}

#[\Override]
Expand All @@ -49,9 +51,10 @@ public function __invoke(LoggerInterface $logger): void

($this->syncPak)($page, $contents['paks']);
}

$this->markExtractFailed->clear($rawPage);
} catch (\Throwable $th) {
$logger->error('failed', [$rawPage->url, $th]);
$rawPage->delete();
($this->markExtractFailed)($logger, $rawPage, $th);
}
}
});
Expand Down
35 changes: 35 additions & 0 deletions app/Actions/Extract/MarkExtractFailed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\Actions\Extract;

use App\Models\RawPage;
use Carbon\CarbonImmutable;
use Psr\Log\LoggerInterface;

/**
* 抽出失敗時の共通処理。
*
* 以前は失敗時に RawPage を delete() していたが、一過性のエラーでも唯一の
* スクレイプ結果が恒久消失してしまうため、削除をやめて失敗を隔離・可視化する。
* 次回以降のリトライで成功すればフラグはクリアされ自己回復する。
*/
final class MarkExtractFailed
{
public function __invoke(LoggerInterface $logger, RawPage $rawPage, \Throwable $throwable): void
{
$logger->error('failed', [$rawPage->url, $throwable]);
$rawPage->update(['extract_failed_at' => CarbonImmutable::now()]);
}

/**
* 抽出が成功したら過去の失敗フラグをクリアする(自己回復)。
*/
public function clear(RawPage $rawPage): void
{
if ($rawPage->extract_failed_at !== null) {
$rawPage->update(['extract_failed_at' => null]);
}
}
}
7 changes: 5 additions & 2 deletions app/Actions/Extract/Portal/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Actions\Extract\ChunkRawPages;
use App\Actions\Extract\HandlerInterface;
use App\Actions\Extract\MarkExtractFailed;
use App\Actions\Extract\SyncPak;
use App\Actions\Extract\UpdateOrCreatePage;
use App\Enums\SiteName;
Expand All @@ -23,6 +24,7 @@ public function __construct(
private ExtractContents $extractContents,
private UpdateOrCreatePage $updateOrCreatePage,
private SyncPak $syncPak,
private MarkExtractFailed $markExtractFailed,
) {}

#[\Override]
Expand Down Expand Up @@ -51,9 +53,10 @@ public function __invoke(LoggerInterface $logger): void

($this->syncPak)($page, $contents['paks']);
}

$this->markExtractFailed->clear($rawPage);
} catch (\Throwable $th) {
$logger->error('failed', [$rawPage->url, $th]);
$rawPage->delete();
($this->markExtractFailed)($logger, $rawPage, $th);
}
}
});
Expand Down
7 changes: 5 additions & 2 deletions app/Actions/Extract/Twitrans/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Actions\Extract\ChunkRawPages;
use App\Actions\Extract\HandlerInterface;
use App\Actions\Extract\MarkExtractFailed;
use App\Actions\Extract\SyncPak;
use App\Actions\Extract\UpdateOrCreatePage;
use App\Enums\SiteName;
Expand All @@ -23,6 +24,7 @@ public function __construct(
private ExtractContents $extractContents,
private UpdateOrCreatePage $updateOrCreatePage,
private SyncPak $syncPak,
private MarkExtractFailed $markExtractFailed,
) {}

#[\Override]
Expand All @@ -49,9 +51,10 @@ public function __invoke(LoggerInterface $logger): void

($this->syncPak)($page, $contents['paks']);
}

$this->markExtractFailed->clear($rawPage);
} catch (\Throwable $th) {
$logger->error('failed', [$rawPage->url, $th]);
$rawPage->delete();
($this->markExtractFailed)($logger, $rawPage, $th);
}
}
});
Expand Down
30 changes: 30 additions & 0 deletions app/Actions/Logging/ConvertDiscord.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,37 @@

namespace App\Actions\Logging;

use Illuminate\Contracts\Config\Repository;
use MarvinLabs\DiscordLogger\Converters\SimpleRecordConverter;
use MarvinLabs\DiscordLogger\Discord\Embed;
use MarvinLabs\DiscordLogger\Discord\Message;

final class ConvertDiscord extends SimpleRecordConverter
{
public function __construct(Repository $config, private readonly SecretScrubber $secretScrubber)
{
parent::__construct($config);
}

/**
* @param array{datetime:\DateTime,level_name:int,message:string,context:array<int|string,mixed>} $record
*/
#[\Override]
protected function addMessageContent(Message $message, array $record): void
{
try {
// context['exception'] が Throwable のままの状態で先に取得する。
// scrubArray() は Throwable を文字列化してしまうため、先に呼ぶと取得できなくなる。
$stacktrace = $this->getStacktrace($record);

// Discord は外部サービスへ送出されるため、組み立て前に機密値を伏字化する。
$record['message'] = $this->secretScrubber->scrub($record['message']);
$record['context'] = $this->secretScrubber->scrubArray($record['context']);

if ($stacktrace !== null) {
$stacktrace = $this->secretScrubber->scrub($stacktrace);
}
Comment thread
128na marked this conversation as resolved.

if (! in_array($stacktrace, [null, '', '0'], true)) {
$this->makeErrorMessage($message, $record, $stacktrace);
} else {
Expand All @@ -28,6 +45,19 @@ protected function addMessageContent(Message $message, array $record): void
}
}

/**
* 親クラスの addMessageStacktrace は未伏字化の生スタックトレースで
* $message->file を上書きしてしまうため、何もしないようにする
* (伏字化済みのスタックトレースは addMessageContent 内で既に添付済み)。
*
* @param array{datetime:\DateTime,level_name:int,message:string,context:array<int|string,mixed>} $record
*/
#[\Override]
protected function addMessageStacktrace(Message $message, array $record): void
{
// no-op: see method docblock.
}

/**
* @param array{datetime:\DateTime,level_name:int,message:string,context:array<int|string,mixed>} $record
*/
Expand Down
103 changes: 103 additions & 0 deletions app/Actions/Logging/SecretScrubber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace App\Actions\Logging;

use Illuminate\Support\Facades\Config;

/**
* ログ・例外レポート・Discord 送出に機密値が混入するのを防ぐための伏字化。
*
* 例外メッセージやスタックトレース、Guzzle 例外に含まれる Authorization ヘッダ等に
* 紛れ込んだ既知の機密値(Notion シークレット、Discord Webhook URL、DB パスワード)を
* 送出直前に [REDACTED] へ置換する予防制御。
*/
final class SecretScrubber
{
private const string MASK = '[REDACTED]';

/**
* @var list<string>|null
*/
private ?array $cachedSecrets = null;

public function scrub(string $value): string
{
$secrets = $this->secrets();
if ($secrets === []) {
return $value;
}

return str_replace($secrets, self::MASK, $value);
}

/**
* @param array<array-key,mixed> $context
* @return array<array-key,mixed>
*/
public function scrubArray(array $context): array
{
$secrets = $this->secrets();
if ($secrets === []) {
return $context;
}

/** @var array<array-key,mixed> $scrubbed */
$scrubbed = $this->walk($context, $secrets);

return $scrubbed;
}

/**
* @param list<string> $secrets
*/
private function walk(mixed $value, array $secrets): mixed
{
if (is_string($value)) {
return str_replace($secrets, self::MASK, $value);
}

if (is_array($value)) {
return array_map(fn (mixed $item): mixed => $this->walk($item, $secrets), $value);
}

if ($value instanceof \Throwable) {
// 例外オブジェクトはメッセージ + トレース文字列に展開してから伏字化する。
// 注意: これにより context['exception'] は Throwable から string に変わる。
// Sentry 等、Throwable のままであることを期待する Monolog processor/handler を
// 将来追加する場合は、本プロセッサより前段に置くこと。
return str_replace($secrets, self::MASK, (string) $value);
}
Comment thread
128na marked this conversation as resolved.

return $value;
}

/**
* 伏字化対象の機密値一覧(短すぎる値・空値は誤爆防止のため対象から除外する)。
* リクエスト中に変わらない値なのでインスタンス単位でキャッシュする
* (静的キャッシュにすると PHPUnit のテスト間で Config 変更が反映されなくなるため避ける)。
*
* @return list<string>
*/
private function secrets(): array
{
if ($this->cachedSecrets !== null) {
return $this->cachedSecrets;
}

$candidates = [
Config::get('services.notion.secret'),
Config::get('logging.channels.discord.url'),
Config::get('database.connections.mysql.password'),
Config::get('database.connections.portal.password'),
];

return $this->cachedSecrets = array_values(array_unique(array_filter(
$candidates,
// 未設定(null)や開発環境の "root"(4文字) 等の短い値まで伏字化すると
// chroot/uproot 等の無関係な単語まで壊してしまうため、5文字未満は対象外にする。
fn (mixed $value): bool => is_string($value) && mb_strlen($value) >= 5,
)));
}
Comment thread
128na marked this conversation as resolved.
}
Loading
Loading