Compare commits

..

3 Commits

Author SHA1 Message Date
posimai 36293e5ec7 feat: posimai-sc 支援士学習PWAを同梱し本番用設定を追加
Made-with: Cursor
2026-04-20 01:40:48 +09:00
posimai 222238f2b9 fix(together): require member auth on GET /together/groups/:groupId
invite_code was accessible without authentication to anyone who knew
the groupId (sequential integer). Now requires ?u= + member check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 01:04:24 +09:00
posimai 6cae7daa87 fix(together): revert broken userCheck that caused 403 for all JWT users
userCheck required username to match users.user_id/name, but Together
usernames (mai, EIJI) never matched users table entries (maita, partner).
All JWT-bearing clients were getting 403. Reverts to member-row-only
check until user_id backfill is complete (Phase 1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 19:08:36 +09:00
14 changed files with 2012 additions and 14 deletions

View File

@ -8,7 +8,8 @@
## mai のPC から実行待ち ## mai のPC から実行待ち
- 特になし(**posimai-boki** は独立リポジトリで Gitea/GitHub へ push 済み。追加分を出したときは `cd posimai-boki && npm run deploy` - **posimai-scVercel**: GitHub 連携後の自動ビルドが正しく `posimai-sc` フォルダだけを出すよう、Vercel の Project Settings > General > **Root Directory**`posimai-sc` に設定(未設定のままだとリポジトリルートでビルドされる可能性あり)。設定後、空コミットで `posimai-root` を push してデプロイ確認。
- **posimai-boki** は独立リポジトリで Gitea/GitHub へ push 済み。追加分を出したときは `cd posimai-boki && npm run deploy`
## 次にやること(優先順) ## 次にやること(優先順)

View File

@ -56,3 +56,4 @@
| posimai-dev | `#A78BFA` Violet | `#7C3AED` | 開発ポータル — コード・AI・ターミナルの融合。Atlas と差別化 | | posimai-dev | `#A78BFA` Violet | `#7C3AED` | 開発ポータル — コード・AI・ターミナルの融合。Atlas と差別化 |
| ponshu-room | `#D4A574` 琥珀Amber | `#D4A574` | **Posimai デザインシステム適用外**。独自テーマ(和紙×墨×琥珀)を使用 | | ponshu-room | `#D4A574` 琥珀Amber | `#D4A574` | **Posimai デザインシステム適用外**。独自テーマ(和紙×墨×琥珀)を使用 |
| posimai-analytics | TailwindCSS + ライトテーマ | — | **Posimai デザインシステム適用外**。Kintone 向け BtoBダッシュボード。TailwindCSS / React / light theme で構築。絵文字禁止・Lucide @0.344.0 固定は適用 | | posimai-analytics | TailwindCSS + ライトテーマ | — | **Posimai デザインシステム適用外**。Kintone 向け BtoBダッシュボード。TailwindCSS / React / light theme で構築。絵文字禁止・Lucide @0.344.0 固定は適用 |
| posimai-sc | `#818CF8` Indigo | `#6366F1` | セキュリティ学習アプリ — 信頼感のあるクールトーン(簿記アプリのネイビー背景と整合) |

View File

@ -1,6 +1,6 @@
# Posimai Project — マスターアーキテクチャドキュメント # Posimai Project — マスターアーキテクチャドキュメント
最終更新: 2026-04-06 最終更新: 2026-04-20
対象: Claude Code / Cursor / Gemini / 全 AI エージェント 対象: Claude Code / Cursor / Gemini / 全 AI エージェント
**このドキュメントはプロジェクトの現状を一元管理します。実装の前に必ず読んでください。** **このドキュメントはプロジェクトの現状を一元管理します。実装の前に必ず読んでください。**
@ -36,7 +36,7 @@
║ フロントエンドVercel / CDN ║ フロントエンドVercel / CDN
║ posimai.soar-enrich.com → posimai-dashboard ║ ║ posimai.soar-enrich.com → posimai-dashboard ║
║ *.posimai.soar-enrich.com → Vercelワイルドカード設定済 ║ *.posimai.soar-enrich.com → Vercelワイルドカード設定済
║ 全 27 本アプリ(全て PWA ║ 全 30 本前後の PWA静的/Next 混在)
╚══════════════════════════════════════════════════════════╝ ╚══════════════════════════════════════════════════════════╝
│ https://api.soar-enrich.com/brain/api/... │ https://api.soar-enrich.com/brain/api/...
@ -169,7 +169,7 @@ posimai-storeLP
--- ---
## 7. アプリ一覧(全 27 本・2026-04-06 時点 ## 7. アプリ一覧(代表・2026-04-20 追記
| アプリ | バックエンド | 備考 | | アプリ | バックエンド | 備考 |
|--------|------------|------| |--------|------------|------|
@ -200,6 +200,11 @@ posimai-storeLP
| posimai-events | VPS API | イベント情報モック・Beta | | posimai-events | VPS API | イベント情報モック・Beta |
| posimai-hotels | — | ホテル価格モック・Beta | | posimai-hotels | — | ホテル価格モック・Beta |
| posimai-analytics | — | Next.js / RFM分析 | | posimai-analytics | — | Next.js / RFM分析 |
| posimai-guard | Gemini API | AI コードセキュリティスキャン |
| posimai-store | Stripe / VPS | アプリ販売 LP |
| posimai-log | — | 開発ログビューワーscribe 連携) |
| posimai-boki | — | 簿記2級 学習 PWAlocalStorage |
| posimai-sc | — | 支援士試験 学習 PWAlocalStorage・非公式補助 |
--- ---

1
posimai-sc/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.vercel

813
posimai-sc/index.html Normal file
View File

@ -0,0 +1,813 @@
<!DOCTYPE html>
<html lang="ja" data-app-id="posimai-sc">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex, nofollow">
<script>
(function(){
var t=localStorage.getItem('posimai-sc-theme')||'system';
var dark=t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);
document.documentElement.setAttribute('data-theme',dark?'dark':'light');
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="description" content="情報処理安全確保支援士試験 概念学習・理解度チェックアプリ">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0C1221" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#EEF2FF" media="(prefers-color-scheme: light)">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="SC">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="apple-touch-icon" href="/logo.png">
<title>SC — 支援士</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root,[data-theme="dark"]{
--accent:#818CF8;
--accent-dim:rgba(129,140,248,0.10);
--accent-border:rgba(129,140,248,0.22);
--bg:#0C1221;
--surface:rgba(15,22,40,0.82);
--surface2:rgba(26,34,53,0.92);
--border:rgba(255,255,255,0.07);
--border2:rgba(255,255,255,0.04);
--text:#F1F5F9;
--text2:#94A3B8;
--text3:#64748B;
--ok:#4ADE80;
--warn:#FB923C;
--err:#F87171;
--radius:12px;
--radius-sm:8px;
--safe-top:env(safe-area-inset-top, 0px);
--safe-right:env(safe-area-inset-right, 0px);
--safe-bottom:env(safe-area-inset-bottom, 0px);
--safe-left:env(safe-area-inset-left, 0px);
}
[data-theme="light"]{
--accent:#6366F1;
--accent-dim:rgba(99,102,241,0.08);
--accent-border:rgba(99,102,241,0.22);
--bg:#EEF2FF;
--surface:rgba(255,255,255,0.88);
--surface2:rgba(238,242,255,0.92);
--border:rgba(0,0,0,0.07);
--border2:rgba(0,0,0,0.04);
--text:#0F172A;
--text2:#475569;
--text3:#94A3B8;
--ok:#16A34A;
--warn:#D97706;
--err:#DC2626;
}
html{height:100%}
html,body{background:var(--bg);color:var(--text);font-family:'Geist',sans-serif;overflow:hidden}
body{min-height:100%;min-height:-webkit-fill-available;height:100%}
.aurora{position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:0}
.aurora-blob{position:absolute;border-radius:50%;will-change:transform}
.aurora-blob-1{width:800px;height:550px;background:radial-gradient(ellipse,rgba(129,140,248,0.14) 0%,transparent 68%);top:-120px;right:-100px;filter:blur(90px);animation:ab1 20s ease-in-out infinite alternate}
.aurora-blob-2{width:650px;height:480px;background:radial-gradient(ellipse,rgba(167,139,250,0.10) 0%,transparent 68%);bottom:-100px;left:-80px;filter:blur(100px);animation:ab2 26s ease-in-out infinite alternate}
.aurora-blob-3{width:440px;height:360px;background:radial-gradient(ellipse,rgba(99,102,241,0.07) 0%,transparent 68%);top:45%;right:28%;filter:blur(80px);animation:ab3 16s ease-in-out infinite alternate}
@keyframes ab1{from{transform:translate(0,0) scale(1)}to{transform:translate(-60px,80px) scale(1.1)}}
@keyframes ab2{from{transform:translate(0,0) scale(1)}to{transform:translate(80px,-50px) scale(1.07)}}
@keyframes ab3{from{transform:translate(0,0) scale(1)}to{transform:translate(-50px,60px) scale(0.93)}}
[data-theme="light"] .aurora-blob{opacity:0.35}
#app{position:relative;z-index:1;box-sizing:border-box;height:100vh;height:100dvh;max-height:100dvh;display:flex;flex-direction:column;min-height:0;padding:var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left)}
/* Header */
header{display:flex;align-items:center;justify-content:space-between;padding:0 16px;height:52px;border-bottom:1px solid var(--border);background:rgba(12,18,33,0.75);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);flex-shrink:0}
[data-theme="light"] header{background:rgba(238,242,255,0.88)}
.brand{display:flex;align-items:center;gap:10px}
.brand-home{display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 6px;border-radius:var(--radius-sm);transition:background .15s;margin-left:-6px;background:none;border:none;font-family:inherit}
.brand-home:hover{background:var(--accent-dim)}
.brand-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);flex-shrink:0}
.brand-title{font-size:14px;font-weight:600;letter-spacing:-0.01em;color:var(--text)}
.brand-sub{font-size:11px;color:var(--text3)}
.icon-btn{background:none;border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px;cursor:pointer;color:var(--text2);display:flex;align-items:center;justify-content:center;transition:color .15s,border-color .15s;font-family:inherit}
.icon-btn:hover{color:var(--accent);border-color:var(--accent-border)}
/* Body */
#body{flex:1;min-height:0;display:flex;overflow:hidden}
/* Sidebar */
#sidebar{width:240px;flex-shrink:0;min-height:0;border-right:1px solid var(--border);overflow-y:auto;-webkit-overflow-scrolling:touch;background:rgba(12,18,33,0.5);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);display:flex;flex-direction:column}
[data-theme="light"] #sidebar{background:rgba(238,242,255,0.6)}
.sidebar-search{padding:10px 12px;border-bottom:1px solid var(--border)}
.search-wrap{position:relative}
.search-icon{position:absolute;left:8px;top:50%;transform:translateY(-50%);color:var(--text3);pointer-events:none}
.search-input{width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:7px 10px 7px 30px;font-size:12px;color:var(--text);outline:none;font-family:'Geist',sans-serif;transition:border-color .15s}
.search-input:focus{border-color:var(--accent-border)}
.search-input::placeholder{color:var(--text3)}
.progress-wrap{padding:10px 12px;border-bottom:1px solid var(--border)}
.progress-label{font-size:10px;color:var(--text3);letter-spacing:.07em;text-transform:uppercase;margin-bottom:6px;display:flex;justify-content:space-between}
.progress-track{height:3px;background:var(--surface2);border-radius:2px;overflow:hidden}
.progress-fill{height:100%;background:var(--accent);border-radius:2px;transition:width .4s}
.sidebar-cat{padding:8px 12px 3px;font-size:10px;font-weight:600;color:var(--text3);letter-spacing:.10em;text-transform:uppercase;display:flex;align-items:center;gap:6px}
.sidebar-cat-line{flex:1;height:1px;background:var(--border)}
.sidebar-item{display:flex;align-items:center;gap:8px;padding:7px 12px;cursor:pointer;font-size:12px;color:var(--text2);transition:background .12s,color .12s;border-left:2px solid transparent;user-select:none}
.sidebar-item:hover{background:var(--accent-dim);color:var(--text)}
.sidebar-item.active{background:var(--accent-dim);color:var(--accent);border-left-color:var(--accent)}
.item-num{font-family:'JetBrains Mono',monospace;font-size:9px;font-weight:500;background:var(--surface2);border:1px solid var(--border2);border-radius:4px;padding:2px 5px;min-width:26px;text-align:center;flex-shrink:0;color:var(--text3);transition:background .15s,color .15s}
.sidebar-item.active .item-num{background:var(--accent-dim);border-color:var(--accent-border);color:var(--accent)}
.sidebar-item.done .item-num{background:rgba(74,222,128,.12);border-color:rgba(74,222,128,.25);color:var(--ok)}
.item-title{flex:1;line-height:1.3}
.item-check{width:12px;height:12px;flex-shrink:0;color:var(--ok);opacity:0;transition:opacity .15s}
.sidebar-item.done .item-check{opacity:1}
/* Main */
#main{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;padding:24px 28px;touch-action:pan-y}
/* Home */
.home-hero{text-align:center;padding:36px 0 28px;max-width:500px;margin:0 auto}
.home-hero h1{font-size:26px;font-weight:300;letter-spacing:-.02em;line-height:1.2}
.home-hero h1 span{color:var(--accent)}
.home-hero p{font-size:13px;color:var(--text2);margin-top:8px;line-height:1.7}
.stats-row{display:flex;gap:10px;justify-content:center;margin-top:20px;flex-wrap:wrap}
.stat-card{background:var(--surface);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:var(--radius);padding:14px 22px;text-align:center}
.stat-val{font-family:'JetBrains Mono',monospace;font-size:22px;font-weight:500;color:var(--accent)}
.stat-lbl{font-size:10px;color:var(--text3);margin-top:3px;letter-spacing:.05em;text-transform:uppercase}
.home-cats{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:28px;max-width:540px;margin-inline:auto}
.home-cat-card{background:var(--surface);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:var(--radius);padding:18px;cursor:pointer;transition:border-color .2s,background .2s;position:relative;overflow:hidden}
.home-cat-card::before{content:'';position:absolute;inset:0;border-radius:var(--radius);background:linear-gradient(145deg,rgba(255,255,255,0.025) 0%,transparent 55%);pointer-events:none}
.home-cat-card:hover{border-color:var(--accent-border);background:var(--accent-dim)}
.home-cat-icon{width:30px;height:30px;border-radius:8px;background:var(--accent-dim);border:1px solid var(--accent-border);display:flex;align-items:center;justify-content:center;margin-bottom:10px;color:var(--accent)}
.home-cat-title{font-size:13px;font-weight:600}
.home-cat-sub{font-size:11px;color:var(--text3);margin-top:2px}
.home-cat-progress{height:3px;background:var(--border);border-radius:2px;margin-top:10px;overflow:hidden}
.home-cat-bar{height:100%;background:var(--accent);border-radius:2px;transition:width .5s}
/* Card */
.card{background:var(--surface);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:14px;position:relative;overflow:hidden}
.card::before{content:'';position:absolute;inset:0;border-radius:var(--radius);background:linear-gradient(145deg,rgba(255,255,255,0.025) 0%,transparent 55%);pointer-events:none}
.card-title{font-size:11px;font-weight:600;color:var(--text3);letter-spacing:.09em;text-transform:uppercase;margin-bottom:14px;display:flex;align-items:center;gap:6px}
.card-title svg{width:13px;height:13px;color:var(--accent)}
/* Unit header */
.unit-header{margin-bottom:18px}
.unit-meta{min-width:0}
.unit-cat-badge{font-size:10px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--accent);background:var(--accent-dim);border:1px solid var(--accent-border);border-radius:20px;padding:3px 9px;display:inline-flex;align-items:center}
.unit-title{font-size:19px;font-weight:600;letter-spacing:-.01em;line-height:1.3}
/* Concept text */
.concept-text{font-size:13px;line-height:1.85;color:var(--text2)}
.concept-text strong{color:var(--text);font-weight:600}
.concept-text .hl{color:var(--accent);font-weight:500}
.concept-text p{margin-bottom:10px}
.concept-text p:last-child{margin-bottom:0}
.formula-box{background:var(--surface2);border:1px solid var(--accent-border);border-radius:var(--radius-sm);padding:12px 16px;margin:12px 0;font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--accent);line-height:1.8}
.formula-label{font-size:10px;color:var(--text3);font-family:'Geist',sans-serif;font-weight:600;letter-spacing:.07em;text-transform:uppercase;margin-bottom:6px}
/* Keypoints */
.key-list{list-style:none;display:flex;flex-direction:column;gap:8px}
.key-item{display:flex;gap:10px;font-size:13px;color:var(--text2);line-height:1.65}
.key-dot{width:5px;height:5px;border-radius:50%;background:var(--accent);flex-shrink:0;margin-top:8px}
/* Buttons */
.btn-sm{display:flex;align-items:center;gap:4px;padding:7px 13px;border-radius:var(--radius-sm);font-size:12px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--surface);color:var(--text2);transition:all .15s;font-family:'Geist',sans-serif}
.btn-sm:hover:not(:disabled){color:var(--accent);border-color:var(--accent-border)}
.btn-sm:disabled{opacity:.4;cursor:default}
.btn-accent{background:rgba(129,140,248,0.12);color:var(--accent);border-color:var(--accent-border)}
.btn-accent:hover:not(:disabled){background:rgba(129,140,248,0.20)}
[data-theme="light"] .btn-accent{background:rgba(99,102,241,0.10);color:var(--accent)}
/* Quiz */
.quiz-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.quiz-title-label{font-size:11px;font-weight:600;color:var(--text3);letter-spacing:.09em;text-transform:uppercase;display:flex;align-items:center;gap:6px}
.quiz-score-lbl{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text3)}
.q-card{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:14px;margin-bottom:10px;transition:border-color .2s,background .2s}
.q-card.correct{border-color:rgba(74,222,128,.35);background:rgba(74,222,128,.04)}
.q-card.wrong{border-color:rgba(248,113,113,.30);background:rgba(248,113,113,.03)}
.q-num{font-size:10px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;margin-bottom:7px;display:flex;align-items:center;gap:5px}
.q-text{font-size:13px;color:var(--text);line-height:1.7;margin-bottom:12px}
.q-choices{display:flex;flex-direction:column;gap:6px}
.q-choice{display:flex;align-items:flex-start;gap:9px;padding:8px 12px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);cursor:pointer;font-size:12px;color:var(--text2);transition:all .15s;text-align:left;font-family:'Geist',sans-serif;line-height:1.5;width:100%}
.q-choice:hover:not(:disabled){border-color:var(--accent-border);color:var(--text);background:var(--accent-dim)}
.q-choice:disabled{cursor:default}
.q-choice.sel-ok{border-color:rgba(74,222,128,.45);background:rgba(74,222,128,.09);color:var(--ok)}
.q-choice.sel-ng{border-color:rgba(248,113,113,.40);background:rgba(248,113,113,.07);color:var(--err)}
.q-choice.rev-ok{border-color:rgba(74,222,128,.35);background:rgba(74,222,128,.05);color:var(--ok)}
.choice-key{font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:600;min-width:16px;flex-shrink:0;margin-top:1px}
.q-exp{margin-top:10px;padding:9px 11px;background:rgba(129,140,248,.05);border:1px solid var(--accent-border);border-radius:var(--radius-sm);font-size:12px;color:var(--text2);line-height:1.65}
.q-exp strong{color:var(--accent)}
.quiz-result{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:22px;text-align:center;margin-top:14px}
.result-score{font-family:'JetBrains Mono',monospace;font-size:38px;font-weight:500;color:var(--accent);letter-spacing:-.02em}
.result-lbl{font-size:11px;color:var(--text3);margin-top:4px;letter-spacing:.05em;text-transform:uppercase}
.result-msg{font-size:13px;color:var(--text2);margin-top:10px;line-height:1.5}
.result-actions{margin-top:16px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap}
/* Unit nav */
.unit-nav{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px;padding-bottom:24px}
/* Mobile */
.sidebar-toggle{display:none}
.overlay-mob{display:none;position:fixed;inset:0;z-index:40;background:rgba(0,0,0,.5)}
.overlay-mob.open{display:block}
@media(max-width:680px){
#sidebar{position:fixed;left:0;top:calc(52px + var(--safe-top));bottom:var(--safe-bottom);z-index:50;transform:translateX(-100%);transition:transform .25s cubic-bezier(.2,.9,.2,1);width:260px}
#sidebar.open{transform:translateX(0)}
.sidebar-toggle{display:flex}
#main{padding:10px}
.home-hero{padding:28px 0 20px}
.card{padding:15px;margin-bottom:12px}
.unit-header{margin-bottom:14px}
.unit-nav{padding-bottom:20px}
.home-cats{grid-template-columns:1fr}
.q-card{padding:12px;margin-bottom:8px}
.q-text{font-size:12.5px;word-break:keep-all;line-break:strict}
.q-choice{padding:8px 10px;font-size:11.5px;line-height:1.55;word-break:keep-all;line-break:strict}
.q-exp{font-size:11.5px}
}
::-webkit-scrollbar{width:4px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
/* Badges */
.badge-row{display:flex;align-items:center;gap:6px;margin-bottom:8px;flex-wrap:wrap}
.badge{display:inline-flex;align-items:center;gap:3px;font-size:10px;font-weight:600;padding:3px 8px;border-radius:20px;letter-spacing:.04em}
.badge-high{background:rgba(248,113,113,.12);border:1px solid rgba(248,113,113,.3);color:var(--err)}
.badge-mid{background:rgba(251,146,60,.10);border:1px solid rgba(251,146,60,.28);color:var(--warn)}
.badge-base{background:rgba(129,140,248,.08);border:1px solid rgba(129,140,248,.2);color:var(--accent)}
.badge-diff{background:var(--surface2);border:1px solid var(--border);color:var(--text3)}
.score-chip{font-family:'JetBrains Mono',monospace;font-size:9px;font-weight:600;padding:1px 5px;border-radius:4px;background:rgba(74,222,128,.12);border:1px solid rgba(74,222,128,.25);color:var(--ok);margin-left:auto;flex-shrink:0}
.score-chip.partial{background:rgba(251,146,60,.10);border-color:rgba(251,146,60,.28);color:var(--warn)}
.score-chip.zero{background:var(--surface2);border-color:var(--border);color:var(--text3)}
.weak-dot{font-size:9px;font-weight:700;padding:1px 5px;border-radius:4px;background:rgba(248,113,113,.12);border:1px solid rgba(248,113,113,.3);color:var(--err);margin-left:auto;flex-shrink:0}
/* Today's review */
.today-section{margin-bottom:20px}
.today-label{font-size:10px;font-weight:600;color:var(--text3);letter-spacing:.09em;text-transform:uppercase;display:flex;align-items:center;gap:6px;margin-bottom:10px}
.today-label svg{width:13px;height:13px;color:var(--accent)}
.today-card{display:flex;align-items:center;gap:10px;padding:12px 16px;background:var(--surface);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;transition:border-color .15s,background .15s;margin-bottom:8px}
.today-card:hover{border-color:var(--accent-border);background:var(--accent-dim)}
.today-card:last-child{margin-bottom:0}
.today-tag{font-size:9px;font-weight:700;padding:2px 7px;border-radius:20px;letter-spacing:.04em;flex-shrink:0}
.today-tag-weak{background:rgba(248,113,113,.12);border:1px solid rgba(248,113,113,.3);color:var(--err)}
.today-tag-new{background:rgba(129,140,248,.08);border:1px solid rgba(129,140,248,.2);color:var(--accent)}
.today-tag-review{background:rgba(251,146,60,.10);border:1px solid rgba(251,146,60,.28);color:var(--warn)}
.today-num{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text3);flex-shrink:0;min-width:30px}
.today-title{font-size:13px;font-weight:500;color:var(--text);flex:1}
.today-arrow{color:var(--text3);flex-shrink:0}
/* Exam tips card */
.exam-tips-text{font-size:13px;line-height:1.8;color:var(--text2)}
.exam-tips-text strong{color:var(--text);font-weight:600}
.exam-tips-text .hl{color:var(--accent);font-weight:500}
.tips-point{display:flex;gap:8px;margin-bottom:8px;font-size:12px;color:var(--text2);line-height:1.65}
.tips-point:last-child{margin-bottom:0}
.tips-icon{flex-shrink:0;width:16px;height:16px;border-radius:50%;background:rgba(248,113,113,.15);border:1px solid rgba(248,113,113,.3);display:flex;align-items:center;justify-content:center;margin-top:2px;font-size:9px;color:var(--err);font-weight:700}
/* Visual diagrams */
.viz-flow{display:flex;align-items:center;gap:6px;margin:14px 0;flex-wrap:wrap;justify-content:center}
.vf-node{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 14px;text-align:center;font-size:12px;font-weight:600;color:var(--text);min-width:68px}
.vf-sub{font-size:10px;color:var(--text3);font-weight:400;margin-top:3px}
.vf-hl{border-color:var(--accent-border) !important;background:var(--accent-dim) !important;color:var(--accent) !important}
.vf-hl .vf-sub{color:rgba(129,140,248,.65)}
.vf-arrow{color:var(--text3);font-size:15px;flex-shrink:0}
/* Security-specific diagrams */
.viz-cia{display:flex;gap:8px;margin:14px 0;flex-wrap:wrap}
.cia-card{flex:1;min-width:100px;border-radius:var(--radius-sm);padding:12px;text-align:center;border:1px solid}
.cia-c{background:rgba(129,140,248,.08);border-color:rgba(129,140,248,.25)}.cia-c .cia-name{color:var(--accent)}
.cia-i{background:rgba(74,222,128,.07);border-color:rgba(74,222,128,.22)}.cia-i .cia-name{color:var(--ok)}
.cia-a{background:rgba(251,146,60,.07);border-color:rgba(251,146,60,.22)}.cia-a .cia-name{color:var(--warn)}
.cia-name{font-size:13px;font-weight:700;margin-bottom:4px}
.cia-en{font-size:10px;color:var(--text3);margin-bottom:6px}
.cia-desc{font-size:11px;color:var(--text2);line-height:1.6}
.viz-layers{display:flex;flex-direction:column;gap:4px;margin:14px 0}
.layer-item{padding:8px 14px;border-radius:var(--radius-sm);font-size:12px;font-weight:600;border:1px solid;display:flex;align-items:center;justify-content:space-between}
.layer-sub{font-size:10px;font-weight:400;color:var(--text3)}
.layer-1{background:rgba(129,140,248,.08);border-color:rgba(129,140,248,.2);color:var(--accent)}
.layer-2{background:rgba(74,222,128,.07);border-color:rgba(74,222,128,.2);color:var(--ok)}
.layer-3{background:rgba(251,146,60,.07);border-color:rgba(251,146,60,.2);color:var(--warn)}
.layer-4{background:rgba(248,113,113,.07);border-color:rgba(248,113,113,.2);color:var(--err)}
/* Weak drill mode */
.drill-unit-lbl{font-size:10px;font-weight:600;color:var(--text3);letter-spacing:.07em;text-transform:uppercase;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.drill-prog-wrap{margin-bottom:14px}
.drill-prog-label{font-size:10px;color:var(--text3);display:flex;justify-content:space-between;margin-bottom:4px}
.drill-prog-bar{height:3px;background:var(--surface2);border-radius:2px;overflow:hidden}
.drill-prog-fill{height:100%;background:var(--accent);border-radius:2px;transition:width .3s}
.drill-result{text-align:center;padding:20px 0}
.drill-result-pct{font-family:'JetBrains Mono',monospace;font-size:42px;font-weight:500;color:var(--accent)}
.drill-result-sub{font-size:11px;color:var(--text3);letter-spacing:.05em;text-transform:uppercase;margin-top:4px}
.drill-result-detail{font-size:13px;color:var(--text2);margin-top:14px;line-height:1.7}
.drill-unit-row{display:flex;align-items:center;justify-content:space-between;padding:7px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:5px;font-size:12px}
.drill-unit-row-title{color:var(--text2);flex:1}
.drill-unit-row-ok{color:var(--ok);font-weight:600;font-family:'JetBrains Mono',monospace;font-size:11px}
.drill-unit-row-ng{color:var(--err);font-weight:600;font-family:'JetBrains Mono',monospace;font-size:11px}
/* Step mode */
.step-pips{display:flex;gap:6px;margin-bottom:18px}
.step-pip{height:4px;flex:1;border-radius:2px;background:var(--border);transition:background .3s}
.step-pip.s-active{background:var(--accent)}
.step-pip.s-done{background:rgba(74,222,128,.5)}
.flash-card{background:var(--surface2);border:2px solid var(--border);border-radius:var(--radius);padding:22px 18px;text-align:center;min-height:120px;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;transition:border-color .2s,background .15s;margin-bottom:14px;user-select:none;-webkit-tap-highlight-color:transparent}
.flash-card:hover{border-color:var(--accent-border);background:var(--accent-dim)}
.flash-card-front{display:flex;flex-direction:column;align-items:center;gap:10px;width:100%}
.flash-card-term{font-size:15px;font-weight:600;color:var(--text);line-height:1.45;letter-spacing:-.01em}
.flash-card-hint{font-size:11px;color:var(--text3);letter-spacing:.04em}
.flash-card-hint-footer{margin-top:10px;width:100%}
.flash-card-back{font-size:13px;color:var(--text);line-height:1.75;padding-top:12px;border-top:1px solid var(--border);width:100%;margin-top:0;text-align:left}
.step-count{font-size:10px;color:var(--text3);text-align:center;margin-bottom:10px;letter-spacing:.04em}
.s2choice{display:flex;gap:10px;margin-bottom:10px}
.s2btn{flex:1;padding:16px 10px;border-radius:var(--radius);border:2px solid var(--border);background:var(--surface);font-size:14px;font-weight:600;color:var(--text2);cursor:pointer;transition:border-color .2s,background .15s,color .15s;font-family:'Geist',sans-serif;line-height:1.35}
.s2btn:hover:not(:disabled){border-color:var(--accent-border);color:var(--accent);background:var(--accent-dim)}
.s2btn:disabled{cursor:default}
.s2btn.s2-ok{border-color:rgba(74,222,128,.5) !important;background:rgba(74,222,128,.1) !important;color:var(--ok) !important}
.s2btn.s2-ng{border-color:rgba(248,113,113,.4) !important;background:rgba(248,113,113,.08) !important;color:var(--err) !important}
.s2-exp{font-size:12px;color:var(--text2);padding:8px 11px;background:rgba(129,140,248,.05);border:1px solid var(--accent-border);border-radius:var(--radius-sm);margin-bottom:10px;line-height:1.65}
.s2-exp strong{color:var(--accent)}
.step-action-row{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
/* Concept expand */
.concept-expand-btn{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--accent);background:none;border:none;cursor:pointer;padding:4px 0 0;font-family:'Geist',sans-serif;font-weight:500}
.concept-expand-btn:hover{opacity:.75}
.concept-chevron-wrap{display:inline-flex;min-width:11px;height:11px;align-items:center;justify-content:center;flex-shrink:0}
</style>
</head>
<body>
<div class="aurora" aria-hidden="true">
<div class="aurora-blob aurora-blob-1"></div>
<div class="aurora-blob aurora-blob-2"></div>
<div class="aurora-blob aurora-blob-3"></div>
</div>
<div id="app" x-data="scApp" x-init="init()">
<div class="overlay-mob" :class="{open:sidebarOpen}" @click="sidebarOpen=false" aria-hidden="true"></div>
<header>
<div class="brand">
<button class="icon-btn sidebar-toggle" @click="sidebarOpen=!sidebarOpen" aria-label="メニュー">
<i data-lucide="menu" style="width:16px;height:16px"></i>
</button>
<button class="brand-home" @click="goHome()" aria-label="ホームへ戻る">
<div class="brand-dot"></div>
<span class="brand-title">SC</span>
<span class="brand-sub">情報処理安全確保支援士</span>
</button>
</div>
<div style="display:flex;align-items:center;gap:8px">
<button class="icon-btn" @click="toggleTheme()" aria-label="テーマ切替">
<i data-lucide="sun" style="width:15px;height:15px" x-show="isDark"></i>
<i data-lucide="moon" style="width:15px;height:15px" x-show="!isDark"></i>
</button>
</div>
</header>
<div id="body">
<nav id="sidebar" :class="{open:sidebarOpen}">
<div class="sidebar-search">
<div class="search-wrap">
<i data-lucide="search" class="search-icon" style="width:12px;height:12px"></i>
<input class="search-input" type="search" placeholder="単元を検索..." x-model="search" aria-label="単元検索">
</div>
</div>
<div class="progress-wrap">
<div class="progress-label">
<span>進捗</span>
<span x-text="doneCount + ' / ' + totalCount"></span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="'width:' + progressPct + '%'"></div>
</div>
</div>
<template x-for="cat in filteredCats" :key="cat.id">
<div>
<div class="sidebar-cat">
<span x-text="cat.label"></span>
<div class="sidebar-cat-line"></div>
</div>
<template x-for="u in cat.units" :key="u.id">
<div class="sidebar-item"
role="button" tabindex="0"
:class="{active: currentUnit && currentUnit.id===u.id, done: isDone(u.id)}"
@click="openUnit(u); sidebarOpen=false"
@keydown.enter.prevent="openUnit(u); sidebarOpen=false"
@keydown.space.prevent="openUnit(u); sidebarOpen=false">
<span class="item-num" x-text="u.num"></span>
<span class="item-title" x-text="u.title"></span>
<span class="score-chip" x-show="bestScore(u.id) && !hasWrong(u.id)" :class="scoreChipCls(u.id)" x-text="bestScore(u.id)"></span>
<span class="weak-dot" x-show="hasWrong(u.id)" x-text="bestScore(u.id)||'苦'"></span>
<i data-lucide="check" class="item-check" style="width:11px;height:11px" x-show="!bestScore(u.id) && !hasWrong(u.id)"></i>
</div>
</template>
</div>
</template>
</nav>
<main id="main">
<!-- HOME -->
<div x-show="!currentUnit && !weakDrillActive">
<div class="home-hero">
<h1>情報処理<span>安全確保支援士</span></h1>
<p>セキュリティの基礎から攻撃手法・法規まで、AM2試験に対応した20単元を体系的に学習します。開発者の視点から「なぜそうなのか」を理解しましょう。</p>
<div class="stats-row">
<div class="stat-card">
<div class="stat-val" x-text="doneCount"></div>
<div class="stat-lbl">修了単元</div>
</div>
<div class="stat-card">
<div class="stat-val" x-text="totalCount"></div>
<div class="stat-lbl">全単元</div>
</div>
<div class="stat-card">
<div class="stat-val" x-text="quizCorrect + '/' + quizAnswered"></div>
<div class="stat-lbl">クイズ正解</div>
</div>
</div>
</div>
<!-- Today's review -->
<div class="today-section" x-show="todayUnits.length > 0">
<div class="today-label">
<i data-lucide="calendar-check" style="width:13px;height:13px"></i>
今日の学習
</div>
<template x-for="u in todayUnits" :key="u.id">
<div class="today-card" @click="openUnit(u)">
<span class="today-tag" :class="u.todayTag==='苦手'?'today-tag-weak':u.todayTag==='復習'?'today-tag-review':'today-tag-new'" x-text="u.todayTag"></span>
<span class="today-num" x-text="u.num"></span>
<span class="today-title" x-text="u.title"></span>
<i data-lucide="chevron-right" class="today-arrow" style="width:14px;height:14px"></i>
</div>
</template>
</div>
<!-- Weak drill button -->
<div style="margin-bottom:20px;display:flex;justify-content:center">
<button class="btn-sm" :class="weakDrillCandidates.length ? 'btn-accent' : ''"
:disabled="!weakDrillCandidates.length"
@click="weakDrillCandidates.length && startWeakDrill()">
<i data-lucide="zap" style="width:12px;height:12px"></i>
<span x-text="weakDrillCandidates.length ? '弱点特訓(' + weakDrillCandidates.length + '単元)' : '弱点なし'"></span>
</button>
</div>
<div class="home-cats">
<template x-for="cat in categories" :key="cat.id">
<div class="home-cat-card" @click="openUnit(cat.units[0])">
<div class="home-cat-icon">
<i :data-lucide="cat.icon" style="width:15px;height:15px"></i>
</div>
<div class="home-cat-title" x-text="cat.label"></div>
<div class="home-cat-sub" x-text="cat.units.length + ' 単元'"></div>
<div class="home-cat-progress">
<div class="home-cat-bar" :style="'width:' + catPct(cat) + '%'"></div>
</div>
</div>
</template>
</div>
</div>
<!-- WEAK DRILL -->
<div x-show="weakDrillActive">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
<div class="card-title" style="margin-bottom:0">
<i data-lucide="zap" style="width:13px;height:13px"></i>
弱点集中特訓
</div>
<button class="btn-sm" type="button" @click="exitWeakDrill()" aria-label="弱点特訓を終了する">
<i data-lucide="x" style="width:11px;height:11px"></i>
特訓をやめる
</button>
</div>
<!-- Summary after all done -->
<div x-show="weakDrillDone" class="drill-result">
<div class="drill-result-pct" x-text="weakDrillClearCount + ' / ' + weakDrillResults.length"></div>
<div class="drill-result-sub">単元クリア</div>
<div class="drill-result-detail">
<template x-for="r in weakDrillResults" :key="r.unitId">
<div class="drill-unit-row">
<span class="drill-unit-row-title" x-text="r.num + ' ' + r.unitTitle"></span>
<span :class="r.correct >= r.total ? 'drill-unit-row-ok' : 'drill-unit-row-ng'"
x-text="r.correct + '/' + r.total"></span>
</div>
</template>
</div>
<div class="result-actions" style="margin-top:18px">
<button class="btn-sm btn-accent" @click="exitWeakDrill()">
<i data-lucide="home" style="width:11px;height:11px"></i>
ホームへ
</button>
</div>
</div>
<!-- Active drill -->
<div x-show="!weakDrillDone && weakDrillCurrentUnit">
<div class="drill-prog-wrap">
<div class="drill-prog-label">
<span x-text="'単元 ' + (weakDrillUnitIdx+1) + ' / ' + weakDrillUnits.length"></span>
<span x-text="weakDrillCurrentUnit?.num"></span>
</div>
<div class="drill-prog-bar">
<div class="drill-prog-fill" :style="'width:' + ((weakDrillUnitIdx)/weakDrillUnits.length*100) + '%'"></div>
</div>
</div>
<div class="drill-unit-lbl">
<span x-text="weakDrillCurrentUnit?.catLabel + ' — ' + weakDrillCurrentUnit?.title"></span>
</div>
<template x-for="(q, qi) in (weakDrillCurrentUnit?.quiz || [])" :key="qi">
<div class="q-card" :class="wdQCardCls(qi)">
<div class="q-num">
<i data-lucide="help-circle" style="width:11px;height:11px"></i>
<span x-text="qi+1"></span>
</div>
<div class="q-text" x-html="safeHtml(q.q)"></div>
<div class="q-choices">
<template x-for="(ch, ci) in q.choices" :key="ci">
<button class="q-choice"
:class="wdChoiceCls(qi, ci, q.answer)"
:disabled="wdAnswered(qi)"
@click="doWeakDrillAnswer(qi, ci, q.answer, q.exp)">
<span class="choice-key" x-text="keys[ci]"></span>
<span x-html="safeHtml(ch)"></span>
</button>
</template>
</div>
<div class="q-exp" x-show="wdAnswered(qi)">
<strong>解説:</strong> <span x-html="safeHtml(weakDrillQuizState[qi]?.exp)"></span>
</div>
</div>
</template>
<div class="quiz-result" x-show="weakDrillAllAnswered" style="margin-top:14px">
<div class="result-score" x-text="weakDrillUnitScore"></div>
<div class="result-lbl">正解 / 全問</div>
<div class="result-actions">
<button class="btn-sm btn-accent" @click="nextWeakDrillUnit()"
x-text="weakDrillUnitIdx < weakDrillUnits.length - 1 ? '次の単元へ' : '結果を見る'">
</button>
</div>
</div>
</div>
</div>
</div>
<!-- UNIT VIEW -->
<div x-show="currentUnit && !weakDrillActive">
<div class="unit-header">
<div class="unit-meta" x-show="currentUnit">
<div class="badge-row">
<div class="unit-cat-badge" x-text="currentUnit?.catLabel"></div>
<div class="badge" :class="freqBadgeCls" x-text="freqLabel"></div>
<div class="badge badge-diff" x-text="diffLabel"></div>
</div>
<div class="unit-title" x-text="currentUnit?.title"></div>
</div>
</div>
<!-- Concept -->
<div class="card" x-show="currentUnit && !stepMode">
<div class="card-title">
<i data-lucide="book-open" style="width:13px;height:13px"></i>
概念解説
</div>
<div class="concept-text" x-html="safeHtml(conceptPreview)"></div>
<div class="concept-text" x-show="conceptExpanded" x-html="safeHtml(conceptRest)"></div>
<button type="button" class="concept-expand-btn" x-show="conceptRest"
:aria-expanded="conceptExpanded ? 'true' : 'false'"
@click="conceptExpanded = !conceptExpanded">
<span class="concept-chevron-wrap" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" x-show="!conceptExpanded"><polyline points="6 9 12 15 18 9"></polyline></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" x-show="conceptExpanded"><polyline points="18 15 12 9 6 15"></polyline></svg>
</span>
<span x-text="conceptExpanded ? '閉じる' : 'もっと詳しく'"></span>
</button>
</div>
<!-- Keypoints -->
<div class="card" x-show="!stepMode && currentUnit?.keypoints?.length">
<div class="card-title">
<i data-lucide="zap" style="width:13px;height:13px"></i>
重要ポイント
</div>
<ul class="key-list">
<template x-for="kp in (currentUnit?.keypoints||[])" :key="kp">
<li class="key-item">
<div class="key-dot"></div>
<span x-html="safeHtml(kp)"></span>
</li>
</template>
</ul>
</div>
<!-- Exam tips -->
<div class="card" x-show="currentUnit?.examtips?.length && !stepMode" style="border-color:rgba(248,113,113,.18);background:rgba(248,113,113,.03)">
<div class="card-title" style="color:var(--err)">
<i data-lucide="alert-triangle" style="width:13px;height:13px;color:var(--err)"></i>
試験対策メモ
</div>
<template x-for="tip in (currentUnit?.examtips||[])" :key="tip">
<div class="tips-point">
<div class="tips-icon">!</div>
<span x-html="safeHtml(tip)"></span>
</div>
</template>
</div>
<!-- Study mode buttons -->
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;align-items:center" x-show="currentUnit && !stepMode">
<button class="btn-sm btn-accent" type="button" @click="startStepMode()"
x-show="currentUnit?.keypoints?.length">
<i data-lucide="layers" style="width:11px;height:11px"></i>
3ステップで学ぶ
</button>
<button class="btn-sm" type="button" @click="startStepModeClearQuiz()"
x-show="currentUnit?.keypoints?.length && currentUnit?.quiz?.length && Object.keys(quizState).length">
<i data-lucide="refresh-cw" style="width:11px;height:11px"></i>
理解度をクリアして学ぶ
</button>
</div>
<!-- Step mode card -->
<div class="card" x-show="stepMode" style="margin-bottom:14px">
<div style="display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:14px">
<div class="card-title" style="margin-bottom:0;flex:1;min-width:0">
<i data-lucide="layers" style="width:13px;height:13px"></i>
3ステップで学ぶ
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end">
<button class="btn-sm" type="button" @click="stepGoBack()" aria-label="1つ前のカードに戻る">
<i data-lucide="arrow-left" style="width:11px;height:11px"></i>
戻る
</button>
<button class="btn-sm" type="button" @click="finishStepMode()" aria-label="学習フローを終了し理解度チェックへ">
<i data-lucide="check" style="width:11px;height:11px"></i>
学習フローを終了
</button>
</div>
</div>
<div class="step-pips">
<div class="step-pip" :class="stepPipCls(1)"></div>
<div class="step-pip" :class="stepPipCls(2)"></div>
<div class="step-pip" :class="stepPipCls(3)"></div>
</div>
<!-- Step 1: flashcards -->
<div x-show="stepStep === 1">
<div class="card-title" style="margin-bottom:10px">
<i data-lucide="book-open" style="width:12px;height:12px"></i>
Step 1 — 用語確認
</div>
<div class="step-count" x-text="(stepKpIdx+1) + ' / ' + (currentUnit?.keypoints?.length||0) + ' ポイント'"></div>
<div class="flash-card" role="button" tabindex="0" :aria-expanded="stepFlashRevealed || !keypointHasFlip(stepKpIdx)"
@click="advanceStep1Card()" @keydown.enter.prevent="advanceStep1Card()" @keydown.space.prevent="advanceStep1Card()">
<div class="flash-card-front" x-show="!stepFlashRevealed && keypointHasFlip(stepKpIdx)">
<div class="flash-card-term" x-html="safeHtml(keypointTerm(stepKpIdx))"></div>
<div class="flash-card-hint">タップして全文を表示</div>
</div>
<div class="flash-card-back" x-show="stepFlashRevealed"
x-html="safeHtml(currentUnit?.keypoints?.[stepKpIdx])"></div>
<div class="flash-card-hint flash-card-hint-footer"
x-show="stepFlashRevealed || !keypointHasFlip(stepKpIdx)">タップで次へ</div>
</div>
</div>
<!-- Step 2: 2-choice drills -->
<div x-show="stepStep === 2">
<div class="card-title" style="margin-bottom:10px">
<i data-lucide="git-branch" style="width:12px;height:12px"></i>
Step 2 — 二択ドリル
</div>
<template x-if="unitDrills.length > 0">
<div>
<div class="step-count" x-text="(stepDrillIdx+1) + ' / ' + unitDrills.length + ' 問'"></div>
<div class="q-text" style="font-size:13px;margin-bottom:12px"
x-html="safeHtml(unitDrills[stepDrillIdx]?.q)"></div>
<div class="s2choice">
<template x-for="(ch, ci) in (unitDrills[stepDrillIdx]?.choices||[])" :key="ci">
<button class="s2btn"
:class="stepDrillAnswered ? (ci === unitDrills[stepDrillIdx].answer ? 's2-ok' : (stepDrillSelected === ci ? 's2-ng' : '')) : ''"
:disabled="stepDrillAnswered"
@click="doStepDrill(ci)">
<span x-html="safeHtml(ch)"></span>
</button>
</template>
</div>
<div class="s2-exp" x-show="stepDrillAnswered">
<strong>解説:</strong> <span x-html="safeHtml(unitDrills[stepDrillIdx]?.exp)"></span>
</div>
<div class="step-action-row">
<button class="btn-sm btn-accent" @click="nextStepDrill()" x-show="stepDrillAnswered">
<span x-text="stepDrillIdx < unitDrills.length - 1 ? '次の問題' : 'Step 3 へ'"></span>
<i data-lucide="arrow-right" style="width:11px;height:11px"></i>
</button>
</div>
</div>
</template>
</div>
<!-- Step 3: header only (quiz is below) -->
<div x-show="stepStep === 3">
<div class="card-title" style="margin-bottom:8px">
<i data-lucide="check-circle-2" style="width:12px;height:12px"></i>
Step 3 — 本番問題
</div>
<p style="font-size:12px;color:var(--text2);line-height:1.65;margin-bottom:8px">
下の理解度チェックで確認します。途中まで答えた内容はそのまま続きからできます。最初から出し直すときは、全問に一度答えたあとに表示される「やり直し」か、フロー外の「理解度をクリアして学ぶ」を使ってください。
</p>
<p style="font-size:11px;color:var(--text3);line-height:1.55;margin-bottom:12px"
x-show="Object.keys(quizState).length">
現在の解答は保持されています。
</p>
<button class="btn-sm btn-accent" type="button" @click="scrollToComprehension()">
<i data-lucide="chevron-down" style="width:11px;height:11px"></i>
理解度チェックへ移動
</button>
</div>
</div>
<!-- Quiz -->
<div id="comprehension-quiz" class="card" x-show="currentUnit?.quiz?.length && (!stepMode || stepStep === 3)">
<div class="quiz-header">
<div class="quiz-title-label">
<i data-lucide="check-circle-2" style="width:13px;height:13px"></i>
理解度チェック
</div>
<div class="quiz-score-lbl" x-text="scoreLabel"></div>
</div>
<template x-for="(q, qi) in (currentUnit?.quiz||[])" :key="qi">
<div class="q-card" :class="qCardCls(qi)">
<div class="q-num">
<i data-lucide="help-circle" style="width:11px;height:11px"></i>
<span x-text="qi+1"></span>
</div>
<div class="q-text" x-html="safeHtml(q.q)"></div>
<div class="q-choices">
<template x-for="(ch, ci) in q.choices" :key="ci">
<button class="q-choice"
:class="choiceCls(qi,ci,q.answer)"
:disabled="answered(qi)"
@click="doAnswer(qi,ci,q.answer,q.exp)">
<span class="choice-key" x-text="keys[ci]"></span>
<span x-html="safeHtml(ch)"></span>
</button>
</template>
</div>
<div class="q-exp" x-show="answered(qi)">
<strong>解説:</strong> <span x-html="safeHtml(quizState[qi]?.exp)"></span>
</div>
</div>
</template>
<!-- Result -->
<div class="quiz-result" x-show="allAnswered">
<div class="result-score" x-text="resultScore"></div>
<div class="result-lbl">正解 / 全問</div>
<div class="result-msg" x-text="resultMsg"></div>
<div class="result-actions">
<button class="btn-sm" @click="resetQuiz">
<i data-lucide="refresh-cw" style="width:11px;height:11px"></i>
やり直し
</button>
<button class="btn-sm btn-accent" @click="nextUnit" :disabled="!hasNext">
次の単元
<i data-lucide="arrow-right" style="width:11px;height:11px"></i>
</button>
</div>
</div>
</div>
<!-- Navigation -->
<div class="unit-nav">
<button class="btn-sm" @click="prevUnit" :disabled="!hasPrev">
<i data-lucide="arrow-left" style="width:11px;height:11px"></i>
前の単元
</button>
<button class="btn-sm btn-accent" @click="markDone" x-show="!isDone(currentUnit?.id)">
<i data-lucide="check" style="width:11px;height:11px"></i>
学習済みにする
</button>
<button class="btn-sm" @click="nextUnit" :disabled="!hasNext">
次の単元
<i data-lucide="arrow-right" style="width:11px;height:11px"></i>
</button>
</div>
</div>
</main>
</div>
</div>
<script type="module" src="/js/app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js" integrity="sha384-eEu5CTj3qGvu9PdJuS+YlkNi7d2XxQROAFYOr59zgObtlcux1ae1Il3u7jvdCSWu" crossorigin="anonymous" defer></script>
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js" integrity="sha384-tTkFttkBclaU1cloKwOi9xk3pbao3VZxTjLNBt8iFABWDBQibbAbWpVmO28zMuxq" crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js" integrity="sha384-iZD2X8o1Zdq0HR5H/7oa8W30WS4No+zWCKUPD7fHRay9I1Gf+C4F8sVmw7zec1wW" crossorigin="anonymous" defer></script>
</body>
</html>

551
posimai-sc/js/app.js Normal file
View File

@ -0,0 +1,551 @@
import { DRILLS } from './data/drills.js';
import { CATEGORIES } from './data/categories.js';
function sanitizeTrustedHtml(dirty) {
if (dirty == null || dirty === '') return '';
const s = String(dirty);
if (typeof window.DOMPurify === 'undefined') {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
return window.DOMPurify.sanitize(s, {
USE_PROFILES: { html: true, svg: true, svgFilters: true },
ALLOW_UNKNOWN_PROTOCOLS: false
});
}
function sanitizeStoredHtml(html) {
return sanitizeTrustedHtml(html);
}
document.addEventListener('alpine:init', () => {
Alpine.data('scApp', () => ({
safeHtml(s) {
return sanitizeTrustedHtml(s);
},
categories:[],
currentUnit:null,
quizState:{},
search:'',
sidebarOpen:false,
isDark:true,
progress:{},
scores:{},
wrongUnits:{},
keys:['A','B','C','D','E'],
// concept fold
conceptExpanded:false,
// step mode
stepMode:false,
stepStep:1,
stepKpIdx:0,
stepFlashRevealed:false,
stepDrillIdx:0,
stepDrillAnswered:false,
stepDrillSelected:-1,
// weak drill
weakDrillActive:false,
weakDrillUnits:[],
weakDrillUnitIdx:0,
weakDrillQuizState:{},
weakDrillResults:[],
weakDrillDone:false,
init(){
this.categories = CATEGORIES.map(cat=>({
...cat,
units: cat.units.map(u=>({...u, catLabel:cat.label}))
}));
try{ this.progress = JSON.parse(localStorage.getItem('posimai-sc-progress')||'{}'); }
catch{ this.progress = {}; }
try{ this.scores = JSON.parse(localStorage.getItem('posimai-sc-scores')||'{}'); }
catch{ this.scores = {}; }
try{ this.wrongUnits = JSON.parse(localStorage.getItem('posimai-sc-wrong')||'{}'); }
catch{ this.wrongUnits = {}; }
const t = localStorage.getItem('posimai-sc-theme')||'system';
this.isDark = t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);
const uid=(new URLSearchParams(location.search).get('unit')||'').trim().toLowerCase();
if(uid){
const u=this.allUnits().find(x=>x.id===uid);
if(u){ this.currentUnit=u; this.quizState={}; }
else{ try{ const url=new URL(window.location.href); url.searchParams.delete('unit'); const q=url.searchParams.toString(); history.replaceState(null,'',url.pathname+(q?'?'+q:'')+url.hash); }catch(e){} }
}
this.syncUnitToUrl();
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
this.$watch('search', ()=>{
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
});
if('serviceWorker' in navigator){
navigator.serviceWorker.register('/sw.js').catch(()=>{});
}
},
syncUnitToUrl(){
try{
const url=new URL(window.location.href);
if(this.currentUnit&&this.currentUnit.id) url.searchParams.set('unit',this.currentUnit.id);
else url.searchParams.delete('unit');
const q=url.searchParams.toString();
const path=url.pathname+(q?'?'+q:'')+url.hash;
if(path!==window.location.pathname+window.location.search+window.location.hash)
history.replaceState(null,'',path);
}catch(e){}
},
goHome(){
this.currentUnit=null;
this.quizState={};
this.conceptExpanded=false;
this.stepMode=false;
this.weakDrillActive=false;
this.syncUnitToUrl();
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
get filteredCats(){
if(!this.search) return this.categories;
const q=this.search.toLowerCase();
return this.categories.map(cat=>({
...cat,
units:cat.units.filter(u=>u.title.toLowerCase().includes(q)||u.num.toLowerCase().includes(q))
})).filter(cat=>cat.units.length>0);
},
get totalCount(){ return this.categories.reduce((s,c)=>s+c.units.length,0); },
get doneCount(){ return Object.keys(this.progress).length; },
get progressPct(){ return this.totalCount ? Math.round(this.doneCount/this.totalCount*100) : 0; },
get quizAnswered(){
let n=0;
Object.values(this.quizState).forEach(s=>{ if(s) n++; });
return n;
},
get quizCorrect(){
let n=0;
Object.values(this.quizState).forEach(s=>{ if(s?.correct) n++; });
return n;
},
catPct(cat){
if(!cat.units.length) return 0;
return Math.round(cat.units.filter(u=>this.progress[u.id]).length/cat.units.length*100);
},
allUnits(){ return this.categories.flatMap(c=>c.units); },
isDone(id){ return !!this.progress[id]; },
markDone(){
if(!this.currentUnit) return;
this.progress[this.currentUnit.id]=true;
localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress));
},
bestScore(id){
const s=this.scores[id];
if(!s) return '';
return s.best+'/'+s.total;
},
scoreChipCls(id){
const s=this.scores[id];
if(!s) return 'zero';
if(s.best===s.total) return '';
if(s.best>=s.total*0.6) return 'partial';
return 'zero';
},
saveScore(id,correct,total){
const prev=this.scores[id];
if(!prev||correct>prev.best){
this.scores[id]={best:correct,total};
localStorage.setItem('posimai-sc-scores',JSON.stringify(this.scores));
}
},
get freqBadgeCls(){
const f=this.currentUnit?.freq;
if(f==='high') return 'badge badge-high';
if(f==='mid') return 'badge badge-mid';
return 'badge badge-base';
},
get freqLabel(){
const f=this.currentUnit?.freq;
if(f==='high') return '頻出';
if(f==='mid') return '重要';
return '基礎';
},
get diffLabel(){
const d=this.currentUnit?.diff||1;
return '難易度 '+'★'.repeat(d)+'☆'.repeat(3-d);
},
openUnit(unit){
this.currentUnit=unit;
this.quizState={};
this.conceptExpanded=false;
this.stepMode=false;
this.syncUnitToUrl();
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:0,behavior:'smooth'});
});
},
// Weak unit tracking
hasWrong(id){ return !!this.wrongUnits[id]; },
_onAllAnswered(){
if(!this.currentUnit) return;
const uid=this.currentUnit.id;
const total=this.currentUnit.quiz?.length||0;
const correct=Object.values(this.quizState).filter(s=>s?.correct).length;
this.saveScore(uid,correct,total);
if(correct>=total){
delete this.wrongUnits[uid];
} else {
this.wrongUnits[uid]=(this.wrongUnits[uid]||0)+1;
}
localStorage.setItem('posimai-sc-wrong',JSON.stringify(this.wrongUnits));
this.wrongUnits={...this.wrongUnits};
},
get weakUnits(){
return this.allUnits().filter(u=>this.wrongUnits[u.id]>0)
.sort((a,b)=>(this.wrongUnits[b.id]||0)-(this.wrongUnits[a.id]||0));
},
get todayUnits(){
const result=[];
const weak=this.weakUnits;
if(weak.length>0) result.push({...weak[0], todayTag:'苦手'});
const all=this.allUnits();
const weakId=result[0]?.id;
const review=all.find(u=>!this.wrongUnits[u.id] && this.scores[u.id] && u.id!==weakId && !this.progress[u.id]);
if(review) result.push({...review, todayTag:'復習'});
if(result.length<2){
const next=all.find(u=>!this.progress[u.id] && u.id!==weakId && !result.find(r=>r.id===u.id));
if(next) result.push({...next, todayTag:'未学習'});
}
return result;
},
// Quiz
answered(qi){ return !!this.quizState[qi]; },
doAnswer(qi,ci,correct,exp){
if(this.quizState[qi]) return;
this.quizState[qi]={selected:ci,correct:ci===correct,exp:sanitizeStoredHtml(exp)};
this.quizState={...this.quizState};
const total=this.currentUnit?.quiz?.length||0;
if(total>0 && Object.keys(this.quizState).length>=total){
this._onAllAnswered();
}
},
qCardCls(qi){
const s=this.quizState[qi];
if(!s) return '';
return s.correct?'correct':'wrong';
},
choiceCls(qi,ci,correct){
const s=this.quizState[qi];
if(!s) return '';
if(ci===s.selected && s.correct) return 'sel-ok';
if(ci===s.selected && !s.correct) return 'sel-ng';
if(ci===correct && !s.correct) return 'rev-ok';
return '';
},
get scoreLabel(){
const total=this.currentUnit?.quiz?.length||0;
const answered=Object.keys(this.quizState).length;
const correct=Object.values(this.quizState).filter(s=>s?.correct).length;
if(!answered) return total+'問';
return correct+'/'+answered+'問正解';
},
get allAnswered(){
const total=this.currentUnit?.quiz?.length||0;
return total>0 && Object.keys(this.quizState).length>=total;
},
get resultScore(){
const correct=Object.values(this.quizState).filter(s=>s?.correct).length;
const total=this.currentUnit?.quiz?.length||0;
return correct+' / '+total;
},
get resultMsg(){
const correct=Object.values(this.quizState).filter(s=>s?.correct).length;
const total=this.currentUnit?.quiz?.length||0;
if(correct===total) return '完璧です!次の単元へ進みましょう。';
if(correct>=total*0.8) return 'よくできました。あと少しで完璧です。';
if(correct>=total*0.6) return '合格ラインです。間違えた問題の解説を確認しましょう。';
return '解説をよく読んで、もう一度チャレンジしてみましょう。';
},
resetQuiz(){ this.quizState={}; },
// Navigation
currentIdx(){ return this.allUnits().findIndex(u=>u.id===this.currentUnit?.id); },
get hasPrev(){ return this.currentIdx()>0; },
get hasNext(){ return this.currentIdx()<this.allUnits().length-1; },
prevUnit(){
const i=this.currentIdx();
if(i>0) this.openUnit(this.allUnits()[i-1]);
},
nextUnit(){
const i=this.currentIdx();
const all=this.allUnits();
if(i<all.length-1) this.openUnit(all[i+1]);
},
toggleTheme(){
this.isDark=!this.isDark;
const val=this.isDark?'dark':'light';
document.documentElement.setAttribute('data-theme',val);
localStorage.setItem('posimai-sc-theme',val);
},
// ---- Concept expand ----
get conceptPreview(){
const c=this.currentUnit?.concept||'';
const i=c.indexOf('</p>');
return i===-1 ? c : c.substring(0,i+4);
},
get conceptRest(){
const c=this.currentUnit?.concept||'';
const i=c.indexOf('</p>');
return i===-1 ? '' : c.substring(i+4);
},
// ---- Weak drill ----
get weakDrillCandidates(){
return this.allUnits()
.filter(u=>this.hasWrong(u.id))
.sort((a,b)=>{
const sa=this.scores[a.id]?.best||0;
const sb=this.scores[b.id]?.best||0;
return sa-sb;
})
.slice(0,5);
},
startWeakDrill(){
this.weakDrillUnits=[...this.weakDrillCandidates];
this.weakDrillUnitIdx=0;
this.weakDrillQuizState={};
this.weakDrillResults=[];
this.weakDrillDone=false;
this.weakDrillActive=true;
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
exitWeakDrill(){
this.weakDrillActive=false;
this.weakDrillDone=false;
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
get weakDrillCurrentUnit(){
return this.weakDrillUnits[this.weakDrillUnitIdx]||null;
},
wdAnswered(qi){ return !!this.weakDrillQuizState[qi]; },
wdQCardCls(qi){
const s=this.weakDrillQuizState[qi];
if(!s) return '';
return s.correct?'correct':'wrong';
},
wdChoiceCls(qi,ci,correct){
const s=this.weakDrillQuizState[qi];
if(!s) return '';
if(ci===s.selected&&s.correct) return 'sel-ok';
if(ci===s.selected&&!s.correct) return 'sel-ng';
if(ci===correct&&!s.correct) return 'rev-ok';
return '';
},
doWeakDrillAnswer(qi,ci,correct,exp){
if(this.weakDrillQuizState[qi]) return;
this.weakDrillQuizState[qi]={selected:ci,correct:ci===correct,exp:sanitizeStoredHtml(exp)};
this.weakDrillQuizState={...this.weakDrillQuizState};
},
get weakDrillAllAnswered(){
const total=this.weakDrillCurrentUnit?.quiz?.length||0;
return total>0 && Object.keys(this.weakDrillQuizState).length>=total;
},
get weakDrillUnitScore(){
const correct=Object.values(this.weakDrillQuizState).filter(s=>s?.correct).length;
const total=this.weakDrillCurrentUnit?.quiz?.length||0;
return correct+' / '+total;
},
nextWeakDrillUnit(){
const unit=this.weakDrillCurrentUnit;
if(!unit) return;
const correct=Object.values(this.weakDrillQuizState).filter(s=>s?.correct).length;
const total=unit.quiz?.length||0;
this.saveScore(unit.id,correct,total);
this.weakDrillResults.push({unitId:unit.id,num:unit.num,unitTitle:unit.title,correct,total});
if(correct>=total){
delete this.wrongUnits[unit.id];
localStorage.setItem('posimai-sc-wrong',JSON.stringify(this.wrongUnits));
this.wrongUnits={...this.wrongUnits};
}
if(this.weakDrillUnitIdx<this.weakDrillUnits.length-1){
this.weakDrillUnitIdx++;
this.weakDrillQuizState={};
} else {
this.weakDrillDone=true;
}
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
get weakDrillClearCount(){
return this.weakDrillResults.filter(r=>r.correct>=r.total).length;
},
// ---- Step mode ----
startStepMode(){
this.stepMode=true;
this.stepStep=1;
this.stepKpIdx=0;
this.resetFlashRevealState();
this.stepDrillIdx=0;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:0,behavior:'smooth'});
});
},
startStepModeClearQuiz(){
this.quizState={};
this.startStepMode();
},
exitStepMode(){
this.stepMode=false;
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
finishStepMode(){
this.stepMode=false;
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const el=document.getElementById('comprehension-quiz');
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
});
},
scrollToComprehension(){
this.$nextTick(()=>{
const el=document.getElementById('comprehension-quiz');
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
if(window.lucide) lucide.createIcons();
});
},
stepGoBack(){
if(!this.stepMode) return;
if(this.stepStep===1){
if(this.stepKpIdx>0){
this.stepKpIdx--;
this.resetFlashRevealState();
} else {
this.exitStepMode();
}
} else if(this.stepStep===2){
if(this.stepDrillIdx>0){
this.stepDrillIdx--;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
} else {
const kps=this.currentUnit?.keypoints||[];
this.stepStep=1;
this.stepKpIdx=Math.max(0,kps.length-1);
this.resetFlashRevealState();
}
} else if(this.stepStep===3){
const drills=this.unitDrills;
if(drills.length>0){
this.stepStep=2;
this.stepDrillIdx=drills.length-1;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
} else {
const kps=this.currentUnit?.keypoints||[];
this.stepStep=1;
this.stepKpIdx=Math.max(0,kps.length-1);
this.resetFlashRevealState();
}
}
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:0,behavior:'smooth'});
});
},
stepPipCls(n){
if(this.stepStep===n) return 's-active';
if(this.stepStep>n) return 's-done';
return '';
},
_kpDelimiterIndex(kp){
if(!kp||typeof kp!=='string') return -1;
const ja=kp.indexOf('');
if(ja>=0) return ja;
let depth=0;
for(let i=0;i<kp.length;i++){
const ch=kp[i];
if(ch==='<') depth++;
else if(ch==='>') depth=Math.max(0,depth-1);
else if(ch===':'&&depth===0) return i;
}
return -1;
},
keypointHasFlip(idx){
const kp=(this.currentUnit?.keypoints||[])[idx];
const i=this._kpDelimiterIndex(kp);
return i>0;
},
keypointTerm(idx){
const kp=(this.currentUnit?.keypoints||[])[idx];
const i=this._kpDelimiterIndex(kp);
return i>0 ? kp.slice(0,i).trim() : '';
},
resetFlashRevealState(){
this.stepFlashRevealed=!this.keypointHasFlip(this.stepKpIdx);
},
advanceStep1Card(){
if(this.stepStep!==1) return;
if(this.keypointHasFlip(this.stepKpIdx)&&!this.stepFlashRevealed){
this.stepFlashRevealed=true;
return;
}
this.nextStepFlash();
},
nextStepFlash(){
const kps=this.currentUnit?.keypoints||[];
if(this.stepKpIdx<kps.length-1){
this.stepKpIdx++;
this.resetFlashRevealState();
} else {
const drills=DRILLS[this.currentUnit?.id]||[];
this.stepStep=drills.length>0 ? 2 : 3;
this.stepDrillIdx=0;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
if(this.stepStep===3){
this.$nextTick(()=>{
const m=document.getElementById('main');
if(m) m.scrollTo({top:m.scrollHeight,behavior:'smooth'});
});
}
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
}
},
get unitDrills(){
return DRILLS[this.currentUnit?.id]||[];
},
doStepDrill(ci){
if(this.stepDrillAnswered) return;
this.stepDrillSelected=ci;
this.stepDrillAnswered=true;
},
nextStepDrill(){
const drills=this.unitDrills;
if(this.stepDrillIdx<drills.length-1){
this.stepDrillIdx++;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
} else {
this.stepStep=3;
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:m.scrollHeight,behavior:'smooth'});
});
}
}
}));
});

View File

@ -0,0 +1,425 @@
export const CATEGORIES = [
{ id:'basics', label:'セキュリティ基礎', icon:'shield', units:[
{ id:'s01', num:'S01', title:'CIA三要素', freq:'high', diff:1,
concept:`<p>情報セキュリティの根幹は<strong>CIA三要素</strong>です。すべてのセキュリティ対策はこのどれかを守るために存在します。</p><div class="viz-cia"><div class="cia-card cia-c"><div class="cia-name">機密性</div><div class="cia-en">Confidentiality</div><div class="cia-desc">許可された人だけが情報にアクセスできる。暗号化・アクセス制御で実現。</div></div><div class="cia-card cia-i"><div class="cia-name">完全性</div><div class="cia-en">Integrity</div><div class="cia-desc">情報が正確で改ざんされていない。ハッシュ・デジタル署名で検証。</div></div><div class="cia-card cia-a"><div class="cia-name">可用性</div><div class="cia-en">Availability</div><div class="cia-desc">必要なときに情報を使える。冗長化・バックアップで確保。</div></div></div><p>開発者視点で言い換えると機密性は「見せない」、完全性は「改ざんさせない」、可用性は「止めない」です。DoS攻撃は可用性を、SQLインジェクションは機密性・完全性を脅かします。</p>`,
examtips:[
'三要素のどれが損なわれているかを問う問題が頻出。攻撃とCIA要素のマッピングを整理すること。',
'<strong>真正性Authenticity・信頼性Reliability・否認防止Non-repudiation</strong>など追加要素も出題される。CIAだけが全てではない。',
'DoS攻撃→可用性、盗聴→機密性、改ざん→完全性。この対応を即答できるようにする。'
],
keypoints:[
'機密性C許可された者だけがアクセスできる。暗号化・認証で守る',
'完全性Iデータが正確で改ざんされていない。ハッシュ・署名で検証',
'可用性A必要なときに使える。冗長化・DDoS対策で守る',
'真正性:通信相手が本当に本人かを確認できること(認証で実現)',
'否認防止:後から「やっていない」と言えないようにする(ログ・署名)'
],
quiz:[
{q:'DoS攻撃が損なう主なCIA要素はどれか。',choices:['機密性','完全性','可用性','真正性'],answer:2,exp:'DoS攻撃はサービスを停止させ、利用できなくするため可用性を損ないます。'},
{q:'盗聴によって主に損なわれるCIA要素はどれか。',choices:['可用性','完全性','機密性','信頼性'],answer:2,exp:'盗聴は許可なく情報を取得するため、機密性が損なわれます。'},
{q:'ハッシュ値を用いてデータが改ざんされていないことを確認する行為は、主にどのCIA要素を守るものか。',choices:['機密性','完全性','可用性','否認防止'],answer:1,exp:'ハッシュによる改ざん検知は完全性Integrityを守る手段です。'},
{q:'情報セキュリティの3要素CIAに含まれないものはどれか。',choices:['機密性','可用性','拡張性','完全性'],answer:2,exp:'拡張性ScalabilityはCIA三要素に含まれません。機密性・完全性・可用性が三要素です。'}
]
},
{ id:'s02', num:'S02', title:'脅威・脆弱性・リスク', freq:'high', diff:1,
concept:`<p>リスク管理の基礎概念を整理します。<strong>脅威</strong>は攻撃者や災害など「悪いことが起きる原因」、<strong>脆弱性</strong>はシステムや運用の「穴」、<strong>リスク</strong>はそれらが組み合わさって「実際に被害が発生する可能性と影響度」です。</p><div class="formula-box"><div class="formula-label">リスクの計算(概念式)</div>リスク = 脅威 × 脆弱性 × 資産価値<br><br>リスク対応の4戦略<br>・回避(リスクが生じる活動をやめる)<br>・低減(対策を実施して確率・影響を下げる)<br>・移転(保険・外部委託でリスクを移す)<br>・受容(コスト対効果で許容する)</div><p>開発者にとって「脆弱性」とはバグや設定ミスのこと。CVEデータベースに登録される脆弱性情報は、脅威アクターが攻撃に利用する前にパッチを当てるために使います。</p>`,
examtips:[
'脅威・脆弱性・リスクの定義の違いを問う問題は毎回出る。「脆弱性」は弱点であり攻撃そのものではない。',
'リスク対応4戦略回避・低減・移転・受容の具体例を各1つ言えるようにする。',
'残留リスク:対策後にも残るリスクのこと。ゼロにはできないことを前提に許容・監視する。'
],
keypoints:[
'脅威:資産に損害を与える原因となり得るもの(マルウェア・内部不正・災害)',
'脆弱性:脅威が利用できるシステムや運用の弱点(パッチ未適用・設定ミス)',
'リスク:脅威が脆弱性を突くことで損害が発生する可能性と影響の組み合わせ',
'リスク対応回避・低減・移転・受容の4戦略',
'残留リスク:対策後も残るリスク。経営判断で受容・監視する'
],
quiz:[
{q:'「パッチが未適用のため既知の脆弱性が存在するシステム」はリスク管理上、何に該当するか。',choices:['脅威','脆弱性','リスク','インシデント'],answer:1,exp:'未パッチのシステムは攻撃者に利用される「穴」であり、脆弱性に該当します。'},
{q:'サイバー保険を契約することで損害発生時の費用リスクを保険会社に負わせる対応は、リスク対応の4戦略のうちどれか。',choices:['回避','低減','移転','受容'],answer:2,exp:'保険やアウトソースでリスクを第三者に転嫁することをリスク移転と呼びます。'},
{q:'リスクアセスメントの手順として正しい順序はどれか。',choices:['リスク特定→リスク評価→リスク分析','リスク特定→リスク分析→リスク評価','リスク評価→リスク特定→リスク分析','リスク分析→リスク評価→リスク特定'],answer:1,exp:'JIS Q 27005に基づくリスクアセスメントは「特定→分析→評価」の順です。'},
{q:'脅威と脆弱性の説明として正しい組み合わせはどれか。',choices:['脅威:パッチ未適用/脆弱性:マルウェア','脅威:マルウェア/脆弱性:パッチ未適用','脅威:暗号化/脆弱性:認証','脅威ファイアウォール脆弱性DoS'],answer:1,exp:'マルウェアは脅威(攻撃原因)、パッチ未適用は脆弱性(システムの弱点)です。'}
]
},
{ id:'s03', num:'S03', title:'認証技術', freq:'high', diff:2,
concept:`<p>認証は「あなたが本当にあなたか」を確認するプロセスです。認証要素は3種類あり、複数組み合わせることで強度が上がります。</p><div class="formula-box"><div class="formula-label">認証の3要素</div>知識要素Something you knowパスワード・PIN・秘密の質問<br>所持要素Something you haveICカード・スマートフォン・ハードウェアトークン<br>生体要素Something you are指紋・顔認証・虹彩</div><p>2要素以上を組み合わせるのが<strong>多要素認証MFA</strong>。SSOはIDPが認証を一元管理し、アプリはそのトークンを信頼するモデルです。開発者にとってJWTJSON Web TokenはSSO文脈でよく使うものです。</p><div class="viz-flow"><div class="vf-node">ユーザー<div class="vf-sub">ID/PW入力</div></div><div class="vf-arrow">→</div><div class="vf-node vf-hl">IdP<div class="vf-sub">認証・発行</div></div><div class="vf-arrow">→</div><div class="vf-node">トークン<div class="vf-sub">JWT/SAML</div></div><div class="vf-arrow">→</div><div class="vf-node">サービス<div class="vf-sub">検証・許可</div></div></div>`,
examtips:[
'「2段階認証」は同一要素を2回使う場合もあるPW+秘密の質問→どちらも知識要素)。<strong>多要素認証は異なる要素を組み合わせる</strong>点が重要。',
'チャレンジレスポンス認証:サーバがランダム値(チャレンジ)を送り、クライアントがハッシュ等で応答(レスポンス)する。パスワードを平文で送らない。',
'FIDO2 / パスキー:最新の試験でも出始めている。秘密鍵をデバイスに保管し、公開鍵で検証するフィッシング耐性の高い認証方式。'
],
keypoints:[
'知識・所持・生体の3要素。異なる要素を2つ以上組み合わせると多要素認証MFA',
'チャレンジレスポンス認証:パスワードを平文で送らずに認証する',
'SSOシングルサインオン一度の認証で複数サービスを利用SAML/OIDCで実現',
'リスクベース認証:ログイン状況(場所・デバイス)が異常なら追加認証',
'FIDO2/パスキー:公開鍵暗号ベースのフィッシング耐性が高い認証'
],
quiz:[
{q:'多要素認証の説明として正しいものはどれか。',choices:['同じ知識要素を2回入力させる','異なる種類の認証要素を2つ以上組み合わせる','生体認証のみを使用する','毎回異なるパスワードを使用する'],answer:1,exp:'多要素認証は知識・所持・生体など異なる種類の認証要素を2つ以上組み合わせます。'},
{q:'チャレンジレスポンス認証の目的はどれか。',choices:['生体情報の保護','パスワードの平文送信を回避する','SSO実現','セッション固定攻撃の防止'],answer:1,exp:'チャレンジレスポンス認証はパスワード自体を送らず、ハッシュ等の応答で認証します。盗聴対策。'},
{q:'SSOにおいてIdPアイデンティティプロバイダの役割はどれか。',choices:['各サービスのDBを管理する','ユーザー認証を一元管理しトークンを発行する','ファイアウォールとして機能する','セッションIDを生成する'],answer:1,exp:'IdPはユーザーを認証し、各サービスSPが信頼できるトークンを発行する中央認証機関です。'},
{q:'パスキーFIDO2の特徴として正しいものはどれか。',choices:['パスワードをサーバに保存する','秘密鍵をデバイスに保管し公開鍵で検証するためフィッシング耐性が高い','知識要素だけで認証する','SMSによるワンタイムパスワードを使う'],answer:1,exp:'FIDO2/パスキーは公開鍵暗号ベース。秘密鍵はデバイスから外に出ないのでフィッシングが効きません。'}
]
},
{ id:'s04', num:'S04', title:'アクセス制御', freq:'high', diff:2,
concept:`<p>アクセス制御は「誰が何にどう操作できるか」を管理する仕組みです。3つのモデルが試験に頻出です。</p><div class="formula-box"><div class="formula-label">主なアクセス制御モデル</div>DAC任意アクセス制御所有者が権限を設定Linuxのchmod等<br>MAC強制アクセス制御システムがラベルで強制機密文書管理<br>RBAC役割ベース制御役割に権限を付与多くのWebアプリで採用</div><p>最小権限の原則Least Privilege業務に必要な最小限の権限のみ与える。開発者でいえばEC2に付けるIAM RoleはReadOnlyで十分な場合にAdminを付けないことです。</p><p>Need-to-know業務上知る必要がある情報だけに絞るデータ分離の原則。MACと組み合わせることが多い。</p>`,
examtips:[
'DAC・MAC・RBACの違いを具体例で説明できること。試験では「誰が権限を設定するか」がポイント。',
'最小権限の原則は選択問題の正答選択肢になることが多い。「できるだけ多くの権限を与える」は誤答。',
'職務分離Separation of Duties一人の担当者がすべてを担当しない。承認者と執行者を分ける。よく問われる。'
],
keypoints:[
'DAC所有者が自分で権限を決めるUNIX パーミッション等)',
'MACラベルに基づいてシステムが強制軍・政府系情報管理',
'RBAC役割に権限を紐付けWebアプリのユーザー・管理者ロール',
'最小権限の原則:必要最低限の権限だけ付与する',
'職務分離不正防止のため1人に全権限を集中させない'
],
quiz:[
{q:'Linuxのファイルオーナーが chmod でパーミッションを設定するのはどのアクセス制御モデルか。',choices:['MAC','RBAC','DAC','ABAC'],answer:2,exp:'DACは所有者が自分で権限を設定できる任意アクセス制御モデルです。'},
{q:'「社員の役職(マネージャー・一般社員等)に権限セットを割り当てる」手法はどれか。',choices:['DAC','MAC','RBAC','NAC'],answer:2,exp:'役割Roleに権限を紐付けるRBACです。Webアプリでは最も広く使われます。'},
{q:'最小権限の原則の説明として正しいものはどれか。',choices:['管理者には全権限を与える','業務に必要な最小限の権限だけ付与する','権限はデフォルトで全員に開放する','上位職者ほど権限が多くなる'],answer:1,exp:'最小権限Least Privilegeは必要最小限の権限のみ与え、不正・ミスの影響範囲を最小化します。'},
{q:'資金送金の「申請」と「承認」を別々の担当者が行うようにするセキュリティ原則はどれか。',choices:['最小権限','職務分離','Need-to-know','MAC'],answer:1,exp:'職務分離Separation of Dutiesは一人が全工程を担わないようにして不正を防ぐ原則です。'}
]
},
{ id:'s05', num:'S05', title:'ISMS・セキュリティマネジメント', freq:'mid', diff:2,
concept:`<p><strong>ISMS</strong>Information Security Management SystemはJIS Q 27001をベースにした、組織全体で情報セキュリティを継続的に改善する仕組みです。</p><div class="formula-box"><div class="formula-label">PDCAサイクルISMS</div>P計画リスクアセスメント→セキュリティポリシー策定<br>D実行管理策の実施・社員教育<br>C確認内部監査・マネジメントレビュー<br>A改善不適合の是正・継続的改善</div><p>ISMSの認証取得はISO/IEC 27001に基づきます。プライバシーマークPMSは個人情報保護に特化した日本独自の認証制度です。ゼロトラストアーキテクチャは「境界の内側も信頼しない」という現代的なセキュリティ思想で、ISMSの文脈でも登場します。</p>`,
examtips:[
'ISMSはJIS Q 27001・ISO/IEC 27001が根拠。プライバシーマークとの違い対象範囲・認定機関を整理。',
'PDCAのどのフェーズの話かを問う問題が多い。「内部監査」はC確認、「リスクアセスメント」はP計画。',
'ゼロトラスト「常に検証、決して信頼しないNever trust, always verify」が合言葉。境界型セキュリティとの対比で問われる。'
],
keypoints:[
'ISMSJIS Q 27001に基づく情報セキュリティマネジメントシステム。PDCAで継続改善',
'リスクアセスメントリスクの特定→分析→評価。ISMS計画フェーズの中核',
'プライバシーマーク個人情報保護専用。日本国内の制度JIS Q 15001',
'ゼロトラスト:内部ネットワークも信頼しない。常に認証・認可を確認',
'CSIRTインシデント対応チームS06以降で詳述'
],
quiz:[
{q:'ISMSの根拠となる国際規格はどれか。',choices:['ISO/IEC 15408','ISO/IEC 27001','JIS Q 15001','PCI DSS'],answer:1,exp:'ISMSはISO/IEC 27001日本ではJIS Q 27001が根拠規格です。'},
{q:'ISMS認証のPDCAサイクルにおいて「内部監査」はどのフェーズに位置するか。',choices:['Plan','Do','Check','Act'],answer:2,exp:'内部監査は実施内容を確認する「Check」フェーズに位置します。'},
{q:'ゼロトラストアーキテクチャの考え方として正しいものはどれか。',choices:['内部ネットワークは安全として信頼する','社外からのアクセスだけを検証する','内部・外部を問わずすべてのアクセスを常に検証する','一度認証すれば内部では再認証不要'],answer:2,exp:'ゼロトラストは「Never trust, always verify」として内部も含め常に検証します。'}
]
}
]},
{ id:'crypto', label:'暗号・PKI', icon:'key', units:[
{ id:'s06', num:'S06', title:'共通鍵暗号', freq:'high', diff:2,
concept:`<p><strong>共通鍵暗号(対称暗号)</strong>は送受信者が同じ鍵を使って暗号化・復号する方式です。速度が速く大量データの暗号化に向いています。</p><div class="formula-box"><div class="formula-label">主要アルゴリズム</div>AESAdvanced Encryption Standard現在の標準。128/192/256ビット鍵。ブロック暗号<br>DES56ビット鍵。現在は脆弱で使用禁止<br>3DESDESを3回適用。AESへ移行中<br>ChaCha20ストリーム暗号。モバイル・TLS1.3で採用</div><p>鍵配送問題共通鍵を安全に相手に渡す手段が必要。これを解決したのが公開鍵暗号とDiffie-Hellman鍵交換です。TLSハンドシェイクではDHECDHで共通鍵を合意し、AESで通信を暗号化するという組み合わせが標準です。</p>`,
examtips:[
'AES・DES・3DESのビット長と現在の安全性を整理する。DESは2024年現在使用禁止。',
'n人で共通鍵を持ち合う場合の鍵の数n(n-1)/2本。公開鍵暗号ならn本で済む。この違いが問われる。',
'ブロック暗号のモードECB・CBC・GCM等が出題されることも。ECBは同じ平文→同じ暗号文になる弱点がある。'
],
keypoints:[
'共通鍵暗号:暗号化と復号に同じ鍵を使う。高速・大量データ向き',
'AES現在の標準暗号。鍵長128/192/256ビット',
'DES56ビット鍵で現在は脆弱。使用禁止',
'n人の鍵数公開鍵ならn本、共通鍵ならn(n-1)/2本',
'鍵配送問題共通鍵を安全に渡す手段が課題→DHや公開鍵暗号で解決'
],
quiz:[
{q:'現在の標準的な共通鍵暗号アルゴリズムはどれか。',choices:['DES','3DES','AES','RC4'],answer:2,exp:'AESは2001年に米国標準となり、現在最も広く使われる共通鍵暗号です。'},
{q:'10人のユーザーが互いに安全に通信するために必要な共通鍵の総数はいくつか1対1通信。',choices:['10本','20本','45本','100本'],answer:2,exp:'n(n-1)/2 = 10×9/2 = 45本の共通鍵が必要です。'},
{q:'共通鍵暗号の「鍵配送問題」とは何か。',choices:['鍵が長すぎる問題','暗号化に時間がかかる問題','共通鍵を安全に相手に渡す手段がない問題','鍵の保管場所がない問題'],answer:2,exp:'共通鍵を事前に安全に渡す方法がないことを鍵配送問題と言います。公開鍵暗号やDHで解決します。'},
{q:'ブロック暗号モードのうち同一ブロックの平文が常に同一の暗号文になるため脆弱とされるものはどれか。',choices:['CBC','GCM','CTR','ECB'],answer:3,exp:'ECBElectronic Codebookモードはブロックを独立に暗号化するため、同一平文→同一暗号文になり、パターン漏洩の危険があります。'}
]
},
{ id:'s07', num:'S07', title:'公開鍵暗号・デジタル署名', freq:'high', diff:2,
concept:`<p><strong>公開鍵暗号(非対称暗号)</strong>は公開鍵と秘密鍵のペアを使います。公開鍵で暗号化→秘密鍵で復号(機密性)、秘密鍵で署名→公開鍵で検証(完全性・否認防止)。</p><div class="viz-flow"><div class="vf-node">送信者<div class="vf-sub">秘密鍵で署名</div></div><div class="vf-arrow">→</div><div class="vf-node vf-hl">署名付き<div class="vf-sub">メッセージ</div></div><div class="vf-arrow">→</div><div class="vf-node">受信者<div class="vf-sub">公開鍵で検証</div></div></div><div class="formula-box"><div class="formula-label">主要アルゴリズム</div>RSA素因数分解の困難性に基づく。2048ビット以上が現在の最低基準<br>ECDSA/EdDSA楕円曲線暗号。RSAより短い鍵で同等の安全性<br>DH/ECDH鍵交換アルゴリズム暗号化ではなく鍵合意</div><p>デジタル署名の手順:①メッセージのハッシュ値を計算②秘密鍵でハッシュを暗号化(=署名)③受信側は公開鍵で署名を復号しハッシュと照合。改ざんと否認を同時に防ぎます。</p>`,
examtips:[
'暗号化と署名で使う鍵の向きが逆なことを確実に理解する。暗号化は「受信者の公開鍵」、署名は「送信者の秘密鍵」。',
'RSAの最低鍵長は2048ビット2024年現在。1024ビットは脆弱で不推奨。',
'デジタル署名≠暗号化。署名は「本人確認・改ざん検知」が目的。機密性(内容を隠す)は公開鍵暗号化が担う。'
],
keypoints:[
'暗号化:受信者の公開鍵で暗号化→受信者の秘密鍵で復号(機密性)',
'署名:送信者の秘密鍵で署名→送信者の公開鍵で検証(完全性・否認防止)',
'RSA素因数分解の困難性。最低2048ビット',
'ECDSA楕円曲線暗号。短い鍵で高い安全性',
'DH/ECDH鍵を共有するプロトコル。TLSのforward secrecyに使う'
],
quiz:[
{q:'デジタル署名を生成する際に使用する鍵はどれか。',choices:['受信者の公開鍵','送信者の公開鍵','受信者の秘密鍵','送信者の秘密鍵'],answer:3,exp:'署名は「送信者の秘密鍵」で生成し、受信者は「送信者の公開鍵」で検証します。'},
{q:'公開鍵暗号で暗号化する際に使用する鍵はどれか。',choices:['送信者の秘密鍵','送信者の公開鍵','受信者の秘密鍵','受信者の公開鍵'],answer:3,exp:'「受信者の公開鍵」で暗号化し、受信者だけが持つ「秘密鍵」で復号します。'},
{q:'デジタル署名が提供するセキュリティ機能として正しいものの組み合わせはどれか。',choices:['機密性・可用性','完全性・否認防止','機密性・完全性','可用性・否認防止'],answer:1,exp:'デジタル署名は改ざん検知(完全性)と「やっていない」と言わせない(否認防止)を提供します。'},
{q:'RSA暗号の安全性の根拠となる数学的困難性はどれか。',choices:['離散対数問題','素因数分解の困難性','楕円曲線の困難性','ナップサック問題'],answer:1,exp:'RSAは大きな整数の素因数分解が困難であることを安全性の根拠としています。'}
]
},
{ id:'s08', num:'S08', title:'ハッシュ関数', freq:'high', diff:1,
concept:`<p><strong>ハッシュ関数</strong>は任意のデータから固定長のダイジェスト(ハッシュ値)を生成する一方向関数です。同じ入力から常に同じ出力が得られ、逆算は(計算量的に)不可能です。</p><div class="formula-box"><div class="formula-label">主要アルゴリズムと出力長</div>MD5128ビット。衝突脆弱性あり。使用禁止整合性確認のみ残存<br>SHA-1160ビット。衝突脆弱性あり。証明書用途では使用禁止<br>SHA-256256ビット。現在の標準。Bitcoinでも使用<br>SHA-3Keccakアルゴリズム。SHA-2と並行して使える代替</div><p>ハッシュの3性質①衝突耐性同じハッシュを持つ別データを作れない②第二原像耐性ハッシュ値から元データを求められない③雪崩効果入力が1ビット変わると出力が大きく変わる</p><p>パスワード保存にはハッシュにソルトランダム値を加えることでレインボーテーブル攻撃を防ぎます。bcrypt・Argon2が推奨。</p>`,
examtips:[
'MD5・SHA-1は衝突脆弱性が実証されており現在は安全でない。SHA-256以上を使う。',
'ハッシュは一方向(復号不可)。「ハッシュ値からパスワードを復元」はできない→レインボーテーブルは元の値のハッシュを事前計算して照合するもの。',
'ソルトパスワードに加えるランダム値。ユーザーごとに異なるため、同じPWでもハッシュ値が変わりレインボーテーブルを無効化する。'
],
keypoints:[
'ハッシュ関数:一方向変換。固定長ダイジェストを生成',
'SHA-256現在の標準。MD5・SHA-1は使用禁止衝突脆弱性',
'衝突耐性・第二原像耐性・雪崩効果がセキュアなハッシュの条件',
'パスワード保存bcrypt/Argon2ソルトが推奨。単純SHA-256は不十分',
'レインボーテーブル攻撃:事前計算ハッシュとの照合。ソルトで無効化'
],
quiz:[
{q:'現在セキュリティ用途として推奨されないハッシュアルゴリズムはどれか。',choices:['SHA-256','SHA-3','SHA-512','MD5'],answer:3,exp:'MD5は衝突脆弱性が実証されており、セキュリティ用途での使用は禁止されています。'},
{q:'ハッシュ関数の性質として正しいものはどれか。',choices:['出力から入力が復元できる','同じ入力から毎回異なる出力が得られる','任意のデータから固定長のダイジェストを生成する','暗号化と復号に使用できる'],answer:2,exp:'ハッシュ関数は任意の入力から固定長ダイジェストを生成する一方向関数です。'},
{q:'パスワードのハッシュ保存においてソルトを使用する目的はどれか。',choices:['ハッシュ計算を高速化する','レインボーテーブル攻撃を無効化する','パスワードを復元できるようにする','認証速度を向上する'],answer:1,exp:'ソルト(ユーザーごとのランダム値)を加えることで、事前計算済みのレインボーテーブルを無効化します。'},
{q:'デジタル署名においてハッシュ関数を使う主な理由はどれか。',choices:['メッセージを暗号化するため','メッセージ全体を秘密鍵で処理するコストを削減するため','認証局の検証を省略するため','公開鍵を生成するため'],answer:1,exp:'メッセージ全体を秘密鍵で暗号化すると非常に遅いため、ハッシュ値のみ署名します。'}
]
},
{ id:'s09', num:'S09', title:'PKI・TLS', freq:'high', diff:2,
concept:`<p><strong>PKI</strong>Public Key Infrastructureは公開鍵の正当性を証明する仕組みです。中核となるのが<strong>認証局CA</strong>が発行する<strong>デジタル証明書X.509</strong>です。</p><div class="viz-flow"><div class="vf-node">CA<div class="vf-sub">認証局</div></div><div class="vf-arrow">→</div><div class="vf-node vf-hl">証明書<div class="vf-sub">公開鍵+CAの署名</div></div><div class="vf-arrow">→</div><div class="vf-node">ブラウザ<div class="vf-sub">CAの公開鍵で検証</div></div></div><p>TLS 1.3のハンドシェイク①サーバ証明書を送る②クライアントがCAチェーンを検証③ECDHで共通鍵を合意④AES-GCMで通信開始。開発者にとってHTTPS=TLSは日常的な技術です。</p><div class="formula-box"><div class="formula-label">証明書の失効確認</div>CRL証明書失効リスト失効した証明書のリスト。定期更新<br>OCSPOnline Certificate Status Protocolリアルタイムで1証明書の状態を確認<br>OCSP Staplingサーバ自身が失効情報をキャッシュしてクライアントに渡す</div>`,
examtips:[
'ルートCA→中間CA→サーバ証明書のチェーン信頼の連鎖を理解する。ブラウザはルートCAを信頼リストで持つ。',
'TLS1.2と1.3の違いTLS1.3はハンドシェイクが1-RTTに短縮、前方秘匿性PFSがデフォルト。',
'CRLとOCSPの違いCRLは定期ダウンロード式・大きい。OCSPはリアルタイム・1件ずつ。試験でよく比較される。'
],
keypoints:[
'CA認証局公開鍵の正当性を証明するデジタル証明書を発行',
'X.509証明書公開鍵・所有者情報・CAの署名を含む',
'TLS 1.3ECDH鍵交換AES-GCM暗号。1-RTTハンドシェイク',
'CRL失効証明書リスト定期更新。OCSPリアルタイム失効確認',
'信頼の連鎖ルートCA→中間CA→エンドエンティティ証明書'
],
quiz:[
{q:'デジタル証明書においてCAが担う役割はどれか。',choices:['通信を暗号化する','公開鍵の正当性を証明する','秘密鍵を生成する','ファイアウォールとして機能する'],answer:1,exp:'CAは公開鍵が本当にその所有者のものであることをデジタル署名で証明します。'},
{q:'TLS通信でサーバ証明書の失効をリアルタイムに1件確認するプロトコルはどれか。',choices:['CRL','OCSP','LDAP','PKI'],answer:1,exp:'OCSPは1枚の証明書の失効状態をリアルタイムに確認するプロトコルです。'},
{q:'TLS 1.3の特徴として正しいものはどれか。',choices:['RC4で通信を暗号化する','1-RTTハンドシェイクで接続が速い','DES鍵交換を使う','前方秘匿性PFSはオプション'],answer:1,exp:'TLS1.3は1-RTTハンドシェイクで高速化し、ECDHによる前方秘匿性がデフォルトです。'},
{q:'証明書の信頼チェーンで「ルートCAの証明書を格納しているのは主にどこか。',choices:['Webサーバ','DNS','OSやブラウザの信頼リスト','OCSP Responder'],answer:2,exp:'ブラウザやOSはルートCAの証明書を信頼リストトラストストアとして事前に搭載しています。'}
]
}
]},
{ id:'network', label:'ネットワーク防御', icon:'network', units:[
{ id:'s10', num:'S10', title:'ファイアウォール・DMZ', freq:'high', diff:2,
concept:`<p><strong>ファイアウォール</strong>はネットワーク境界でトラフィックをフィルタリングします。<strong>DMZ</strong>非武装地帯はインターネットと内部ネットワークの間に置く中間ゾーンで、Webサーバ等の公開サービスを置きます。</p><div class="viz-layers"><div class="layer-item layer-1">インターネット(外部)<span class="layer-sub">不特定多数のアクセス</span></div><div class="layer-item layer-2">DMZ非武装地帯<span class="layer-sub">Webサーバ・DNSサーバ・メールサーバ</span></div><div class="layer-item layer-3">内部ネットワーク<span class="layer-sub">社内PC・DBサーバ・ファイルサーバ</span></div></div><div class="formula-box"><div class="formula-label">ファイアウォールの種類</div>パケットフィルタリングIPアドレス・ポートで許可/拒否。高速だがアプリ層の攻撃に弱い<br>SPIステートフルパケットインスペクションコネクション状態を追跡。戻りパケットを自動許可<br>アプリケーションゲートウェイ:プロキシとして動作。アプリ層まで検査</div>`,
examtips:[
'DMZにはWebサーバ・メールサーバを置く。DBサーバはDMZではなく内部ネットワークに置く頻出引っかけ。',
'デフォルト拒否(ホワイトリスト型)が原則。「明示的に許可されていないものは拒否」。',
'ステートフルとステートレスの違いステートフルはセッション状態を管理するためSYN/ACKなどの戻り方向を自動許可できる。'
],
keypoints:[
'ファイアウォール:パケットをルールに基づき許可/拒否',
'DMZ外部と内部の中間ゾーン。公開サービスのみ配置',
'パケットフィルタリングIP/ポートベース。高速だがL7攻撃に弱い',
'SPI接続状態を追跡。戻りパケットを動的に許可',
'デフォルト拒否原則:明示的に許可されていないものはすべて遮断'
],
quiz:[
{q:'DMZに設置すべきサーバとして適切なものはどれか。',choices:['データベースサーバ','人事管理システム','Webサーバ公開用','ファイルサーバ(社内用)'],answer:2,exp:'DMZには外部からアクセスされる公開Webサーバ等を置きます。DBや社内システムは内部ネットワークに置きます。'},
{q:'ステートフルパケットインスペクションSPIの特徴はどれか。',choices:['IPアドレスのみでフィルタリングする','コネクションの状態を追跡し戻りパケットを自動許可する','アプリケーション層のデータを復号して検査する','UDPのみに対応している'],answer:1,exp:'SPIはTCPセッション状態を管理し、正規の戻りパケットACKを動的に許可します。'},
{q:'ファイアウォールの基本原則「デフォルト拒否」の意味はどれか。',choices:['すべての通信を拒否する','許可ルールに一致しない通信はすべて拒否する','認証なしの通信を拒否する','外部からの通信のみ拒否する'],answer:1,exp:'デフォルト拒否deny allは、明示的に許可されていないすべての通信を遮断する原則です。'}
]
},
{ id:'s11', num:'S11', title:'IDS・IPS・WAF', freq:'high', diff:2,
concept:`<p>ファイアウォールを補完するセキュリティデバイスです。<strong>IDS</strong>は検知のみ、<strong>IPS</strong>は検知して遮断します。<strong>WAF</strong>はWebアプリケーション専用でHTTPレベルの攻撃を防ぎます。</p><div class="formula-box"><div class="formula-label">検知方式</div>シグネチャ型(不正検知):既知の攻撃パターンと照合。既知攻撃に強い・ゼロデイに弱い<br>アノマリ型(異常検知):正常な行動からの逸脱を検知。未知攻撃も検知可能・誤検知が多い</div><p>WAFの導入パターンリバースプロキシ型が最も一般的。クラウドWAFはCDNと統合されることも多い。SQLインジェクション・XSS・CSRFを主に防ぎます。誤検知FPのチューニングが運用上の課題です。</p>`,
examtips:[
'IDS検知のみとIPS検知遮断の違いは必須。「遮断」のワードがあればIPS。',
'シグネチャ型はゼロデイ脆弱性の攻撃を検知できない。アノマリ型は未知攻撃を検知できるが誤検知率が高い。',
'WAFとファイアウォールの違いFWはL3/4IP/ポート、WAFはL7HTTPのパラメータ・ヘッダを検査。'
],
keypoints:[
'IDS不正侵入の検知のみ通知・ログ',
'IPS不正侵入の検知と遮断インライン配置が必要',
'WAFWebアプリケーション専用。SQL・XSS等L7攻撃を防ぐ',
'シグネチャ型:既知攻撃に強い・ゼロデイに弱い',
'アノマリ型:未知攻撃も検知可・誤検知率が高い'
],
quiz:[
{q:'IPSとIDSの違いとして正しいものはどれか。',choices:['IDSは遮断できるがIPSは検知のみ','IPSは検知と遮断ができるがIDSは検知のみ','IPSはWAFの別名','IDSはファイアウォールの別名'],answer:1,exp:'IDSは検知・通知のみ。IPSはインラインに設置し不正トラフィックを遮断します。'},
{q:'未知のゼロデイ攻撃の検知に適したIDSの検知方式はどれか。',choices:['シグネチャ型','アノマリ型(異常検知型)','プロトコル解析型','パターンマッチ型'],answer:1,exp:'アノマリ型は正常行動の基準から逸脱を検知するため、シグネチャにない未知の攻撃にも対応できます。'},
{q:'WAFが主に防ぐ攻撃として正しいものの組み合わせはどれか。',choices:['DoS攻撃・ARP Spoofing','SQLインジェクション・XSS','DDoS攻撃・DNSハイジャック','フィッシング・スミッシング'],answer:1,exp:'WAFはWebアプリケーションへのSQLインジェクション・XSS・CSRFなどL7攻撃を防ぎます。'}
]
},
{ id:'s12', num:'S12', title:'VPN', freq:'mid', diff:2,
concept:`<p><strong>VPN</strong>Virtual Private Networkはパブリックネットワーク上に暗号化された仮想トンネルを作り、安全な通信路を提供します。</p><div class="formula-box"><div class="formula-label">主なVPN方式</div>IPsec-VPNL3でパケット全体を暗号化。ESP/AHプロトコル。拠点間接続に多い<br>SSL/TLS-VPNL5/7でHTTPS上にトンネル。ブラウザから利用可能。リモートアクセスに多い<br>WireGuard最新の軽量プロトコル。ChaCha20/Poly1305を使用</div><p>IPsecの2モードトランスポートモードペイロードのみ暗号化とトンネルモードパケット全体を暗号化して新しいIPヘッダを付与。拠点間VPNではトンネルモードが一般的です。</p>`,
examtips:[
'IPsec-VPNはL3ネットワーク層、SSL-VPNはL5以上セッション/アプリ層)での動作という層の違いが問われる。',
'IPsecのESPEncapsulating Security Payloadは暗号化と認証の両方を提供。AHAuthentication Headerは認証のみ暗号化なし。',
'スプリットトンネリングVPN接続中に社内宛て通信のみVPNを通し、一般ネット通信は直接出る設定。セキュリティリスクとして問われることがある。'
],
keypoints:[
'IPsec-VPNL3暗号化。拠点間接続向き。ESPは暗号化認証',
'SSL/TLS-VPNHTTPS上のトンネル。リモートアクセス向き。ブラウザ対応',
'トンネルモード元パケット全体を暗号化新IPヘッダ。拠点間に使用',
'トランスポートモード:ペイロードのみ暗号化。ホスト間通信に使用',
'WireGuard現代的な軽量VPN。ChaCha20/Poly1305で高速'
],
quiz:[
{q:'IPsecのESPが提供する機能として正しいものはどれか。',choices:['認証のみ','暗号化のみ','暗号化と認証の両方','鍵交換のみ'],answer:2,exp:'ESPEncapsulating Security Payloadはデータの暗号化と送信元認証の両方を提供します。'},
{q:'SSL-VPNの説明として正しいものはどれか。',choices:['IPレベルで全パケットを暗号化する','TLS上にトンネルを構築しリモートアクセスに使われる','AHとESPプロトコルを使用する','ネットワーク層で動作する'],answer:1,exp:'SSL/TLS-VPNはHTTPSTLSを利用したトンネリングで、ブラウザからアクセスできるリモートアクセスVPNです。'},
{q:'IPsecのトンネルモードとトランスポートモードの違いはどれか。',choices:['トンネルモードはUDPのみ対応','トンネルモードは元パケット全体を暗号化し新IPヘッダを付与する','トランスポートモードは拠点間接続に使われる','両モードに機能差はない'],answer:1,exp:'トンネルモードは元のIPパケット全体を暗号化し新IPヘッダを付けます。拠点間VPNで使われます。'}
]
},
{ id:'s13', num:'S13', title:'DNSセキュリティ・メールセキュリティ', freq:'high', diff:2,
concept:`<p>DNSとメールはインターネットの基盤であり、攻撃の標的になりやすいプロトコルです。</p><div class="formula-box"><div class="formula-label">DNS攻撃と対策</div>DNSキャッシュポイズニング偽のDNS応答をキャッシュに送り込む→DNSSEC・ランダムポートで対策<br>DNSハイジャックDNSサーバ自体を乗っ取る→DNSSEC・監視<br>DNSSECDNSレスポンスにデジタル署名を付与し改ざんを検知</div><div class="formula-box"><div class="formula-label">メール認証技術</div>SPFSender Policy Framework送信元IPをDNSで検証。なりすましドメイン対策<br>DKIMDomainKeys Identified Mailメールヘッダにデジタル署名。改ざん検知<br>DMARCSPF/DKIMの結果に基づいて受信側の処理ポリシーを指定拒否・隔離</div>`,
examtips:[
'SPF・DKIM・DMARCはセットで覚える。SPFはIP検証、DKIMは署名、DMARCはポリシーどう処理するか。',
'DNSキャッシュポイズニングはカミンスキー攻撃が有名。ランダムポート番号とTXID乱数化が対策。',
'メールの暗号化S/MIMEやPGPとDKIMの違いDKIMは送信元の正当性確認。S/MIMEは内容の暗号化。目的が違う。'
],
keypoints:[
'DNSキャッシュポイズニング偽応答でキャッシュを汚染。DNSSEC・ランダムポートで対策',
'DNSSECDNS応答にデジタル署名を付けて改ざんを検知',
'SPF送信ドメインのIPアドレスをDNSで公開しなりすましを検証',
'DKIMメールヘッダの署名で改ざん・なりすましを検知',
'DMARCSPF/DKIMに基づくポリシーを定義none/quarantine/reject'
],
quiz:[
{q:'DNSキャッシュポイズニング攻撃の目的はどれか。',choices:['DNSサーバを過負荷にする','偽のDNS応答をキャッシュに記録させ利用者を偽サイトに誘導する','メールサーバを乗っ取る','TLS証明書を偽造する'],answer:1,exp:'DNSキャッシュポイズニングは偽の名前解決情報をキャッシュさせ、フィッシングサイト等に誘導します。'},
{q:'メール送信ドメインの正当性をIPアドレスで検証する仕組みはどれか。',choices:['DKIM','DMARC','SPF','DNSSEC'],answer:2,exp:'SPFSender Policy Frameworkは送信元IPをDNSのTXTレコードで検証しなりすましを防ぎます。'},
{q:'DMARCが提供する主な機能はどれか。',choices:['メール本文を暗号化する','送信元IPを検証する','SPF/DKIMの結果に基づく受信ポリシーを定義する','メールのデジタル署名を行う'],answer:2,exp:'DMARCはSPF/DKIM両方の検証結果に基づいて、受信側が「none/quarantine/reject」などのポリシーを適用します。'},
{q:'DKIMが提供する機能はどれか。',choices:['送信元IPアドレスの検証','メールヘッダへのデジタル署名による改ざん・なりすましの検知','メール本文の暗号化','SPFポリシーの実施'],answer:1,exp:'DKIMはメールヘッダに秘密鍵で署名し、受信側が公開鍵で署名を検証することで改ざんを検知します。'}
]
}
]},
{ id:'attacks', label:'攻撃手法と対策', icon:'sword', units:[
{ id:'s14', num:'S14', title:'マルウェア', freq:'high', diff:1,
concept:`<p>マルウェアはMalicious Softwareの略で、悪意のあるプログラムの総称です。種類と感染経路・対策を整理します。</p><div class="formula-box"><div class="formula-label">主なマルウェアの種類</div>ウイルス:他のプログラムに寄生して感染拡大<br>ワーム:ネットワークを自力で伝播(ホスト不要)<br>トロイの木馬:正常なソフトに見せかけて侵入<br>ランサムウェア:ファイルを暗号化し身代金を要求<br>スパイウェア:情報を窃取して送信<br>ルートキット:管理者権限を奪い存在を隠蔽<br>ボットC&Cサーバの指令でDDoS等に悪用</div><p>検知方式:シグネチャ型(パターン照合)・ビヘイビア型(振る舞い検知)・サンドボックス(隔離環境で動作確認)。ゼロデイマルウェアにはシグネチャが効かないためビヘイビア型が重要です。</p>`,
examtips:[
'ランサムウェア対策の3-2-1ルール3つのバックアップ・2種類のメディア・1つはオフライン。試験でも対策として正答になりやすい。',
'ルートキットは感染後にOS自体を改ざんして自分を隠す。通常のアンチウイルスで検知しにくい。',
'C&CサーバCommand and Controlボットを制御するサーバ。通信を遮断することが対策の一つ。'
],
keypoints:[
'ウイルス:宿主ファイルに寄生。ワーム:自力でネット感染',
'ランサムウェア:暗号化→身代金。対策はオフラインバックアップ',
'トロイの木馬:正規ソフトに偽装。ルートキット:自身を隠蔽',
'ビヘイビア検知:振る舞いから未知マルウェアを検出',
'サンドボックス:隔離環境でマルウェアを動作させ分析'
],
quiz:[
{q:'ネットワークを通じて自己複製しホストプログラムなしに拡散するマルウェアはどれか。',choices:['ウイルス','ワーム','スパイウェア','ルートキット'],answer:1,exp:'ワームは宿主プログラムを必要とせず、ネットワーク経由で自力に拡散します。'},
{q:'ランサムウェア対策として最も有効なものはどれか。',choices:['パスワードを強化する','定期的なオフラインバックアップを取得する','ファイアウォールを設置する','アカウントの2段階認証を導入する'],answer:1,exp:'ランサムウェアでファイルが暗号化されても、オフラインバックアップから復元できます。オンラインバックアップは暗号化される場合があります。'},
{q:'シグネチャ型の検知が無効な攻撃手法はどれか。',choices:['既知マルウェアの変種','ゼロデイマルウェア(未知の攻撃)','添付ファイルウイルス','既知の脆弱性を狙った攻撃'],answer:1,exp:'シグネチャ型はパターン照合のため、まだシグネチャがないゼロデイマルウェアを検知できません。'},
{q:'C&CサーバCommand and Controlの役割はどれか。',choices:['バックアップデータを保管する','感染したボットに指令を出し統制する','ファイアウォールのルールを管理する','証明書を発行する'],answer:1,exp:'C&Cサーバはボットネットの司令塔で、感染端末ボットに攻撃命令を送ります。'}
]
},
{ id:'s15', num:'S15', title:'Webアプリケーション攻撃', freq:'high', diff:2,
concept:`<p>Webアプリ開発者が必ず知るべき攻撃パターンです。OWASP Top 10の常連が試験でも頻出です。</p><div class="formula-box"><div class="formula-label">主要な攻撃と対策</div>SQLインジェクションDB操作を注入。→プリペアドステートメント・入力検証<br>XSSクロスサイトスクリプティング悪意のあるスクリプトを挿入→エスケープ・CSP<br>CSRFクロスサイトリクエストフォージェリログイン中ユーザーに意図しないリクエスト→CSRFトークン・Same-Site Cookie<br>パストラバーサル:../でディレクトリを遡り任意ファイルを読む→パス正規化・バリデーション<br>コマンドインジェクションOSコマンドを注入→外部コマンド使用禁止</div><p>XSSの種類反射型URLパラメータ経由・格納型DBに保存されたスクリプト・DOMベース型クライアントサイドJS処理。試験では格納型が最も危険とされる。</p>`,
examtips:[
'SQLインジェクションの対策は「プリペアドステートメントバインド変数」が正答になる。エスケープだけでは不十分な場合がある。',
'XSS対策出力時のHTMLエスケープ&lt; &gt; 等Content Security PolicyCSPヘッダ。',
'CSRF対策CSRFトークンセッションに紐づくランダム値をフォームに埋めるSame-Site Cookie属性。'
],
keypoints:[
'SQLインジェクションプリペアドステートメントで対策',
'XSSHTMLエスケープCSPヘッダで対策。Cookieには HttpOnly 属性',
'CSRFCSRFトークンSame-Site CookieStrict/Laxで対策',
'パストラバーサル:../によるファイルパス操作。正規化・ホワイトリストで対策',
'セキュリティヘッダCSP・X-Frame-Options・HSTS等を必ず設定'
],
quiz:[
{q:'SQLインジェクションの最も効果的な対策はどれか。',choices:['入力文字数を制限する','プリペアドステートメント(バインド変数)を使用する','WAFを設置する','ファイアウォールでDBポートを塞ぐ'],answer:1,exp:'プリペアドステートメントはSQLとデータを分離するため、SQLインジェクションを根本的に防ぎます。'},
{q:'XSSクロスサイトスクリプティング攻撃の目的として最も適切なものはどれか。',choices:['DBのデータを削除する','サービスを停止させる','セッションCookieを盗みセッションハイジャックを行う','管理者権限を奪取する'],answer:2,exp:'XSS攻撃の典型的な目的はCookieからセッションIDを盗み、被害者として操作することです。'},
{q:'CSRF攻撃への対策として有効なものはどれか。',choices:['パラメータのSQLエスケープ','CSRFトークンをフォームに埋め込む','パスワードを強化する','HTTPSを使用する'],answer:1,exp:'CSRFトークンはセッションに紐づくランダム値で、正規フォームからのリクエストであることを検証します。'},
{q:'格納型persistentXSSとはどのような攻撃か。',choices:['URLパラメータに悪意のあるスクリプトを含める','悪意のあるスクリプトをDBに保存しアクセスした全ユーザーに実行させる','クライアントサイドのJS処理でスクリプトを注入する','セッションクッキーを改ざんする'],answer:1,exp:'格納型XSSはDBに保存したスクリプトがページ表示のたびに全ユーザーで実行されるため最も危険です。'}
]
},
{ id:'s16', num:'S16', title:'パスワード攻撃・フィッシング', freq:'high', diff:1,
concept:`<p>パスワードとフィッシングはエンドユーザーを標的にした攻撃であり、技術的対策と人的対策の両方が必要です。</p><div class="formula-box"><div class="formula-label">パスワード攻撃の種類</div>ブルートフォース:全パターンを試す。アカウントロックで緩和<br>辞書攻撃:よく使われる単語リストを試す<br>パスワードスプレー多アカウントに対し少数のPWを試すロック回避<br>クレデンシャルスタッフィング:他サービスの漏洩リストを転用<br>レインボーテーブル:事前計算ハッシュとの照合。ソルトで無効化</div><p>フィッシング正規サービスを偽った詐欺メールやサイトで認証情報を奪う。スピアフィッシング特定の個人・組織を狙うはターゲットを調査して巧妙な文面を作成します。ビジネスメール詐欺BECはCEOや取引先を名乗り送金指示をするものです。</p>`,
examtips:[
'クレデンシャルスタッフィングは「他サービスで漏洩したIDとPWをそのまま別サービスに使う」。パスワードの使い回しが被害を広げる。',
'パスワードスプレー攻撃少数のPW"Spring2024!"等)を多数のアカウントに試す。アカウントロックを回避するための手法。',
'スピアフィッシング対策多要素認証・送信元確認DMARC・セキュリティ教育。技術だけでは防げない。'
],
keypoints:[
'ブルートフォース:全試行。アカウントロックとレート制限で対策',
'クレデンシャルスタッフィング:流出リストの転用。パスワード使い回しが最大の原因',
'パスワードスプレー少数PWを多数アカウントに試す。ロック閾値をずらす攻撃',
'フィッシング:偽メール・偽サイトで認証情報を奪う',
'スピアフィッシング特定個人を調査した標的型。BECはその一種'
],
quiz:[
{q:'他のサービスで漏洩したID・パスワードのリストを使って別サービスにログインを試みる攻撃はどれか。',choices:['パスワードスプレー攻撃','ブルートフォース攻撃','クレデンシャルスタッフィング','辞書攻撃'],answer:2,exp:'クレデンシャルスタッフィングはパスワードの使い回しを悪用して漏洩リストを他サービスに転用する攻撃です。'},
{q:'アカウントロックアウト機能を回避しながらパスワードを試みる攻撃手法はどれか。',choices:['ブルートフォース攻撃','パスワードスプレー攻撃','レインボーテーブル攻撃','クレデンシャルスタッフィング'],answer:1,exp:'パスワードスプレーは少数の一般的なパスワードを多数のアカウントに分散して試すため、ロックアウト閾値を超えません。'},
{q:'スピアフィッシングの説明として正しいものはどれか。',choices:['不特定多数に大量配信するフィッシング','特定の個人・組織を調査した標的型フィッシング','電話を使ったソーシャルエンジニアリング','SMSを使ったフィッシング'],answer:1,exp:'スピアフィッシングは標的を事前に調査し、信頼性の高い偽メール・偽サイトで情報を盗む標的型攻撃です。'}
]
},
{ id:'s17', num:'S17', title:'DoS/DDoS・APT', freq:'high', diff:2,
concept:`<p><strong>DoS/DDoS</strong>はサービスを停止させる攻撃(可用性の侵害)、<strong>APT</strong>Advanced Persistent Threatは国家レベルの高度な標的型持続攻撃です。</p><div class="formula-box"><div class="formula-label">DDoS攻撃の種類</div>Volumetric帯域消費型大量トラフィックで帯域を埋めるUDP flood・ICMP flood<br>Protocolプロトコル型SYN floodでサーバリソースを枯渇させる<br>Applicationアプリ型HTTP floodでWebサーバのCPUを使い切る<br>Amplification増幅型DNSやNTPを踏み台にして大量レスポンスを標的に向ける</div><p>APTはKill Chain偵察→侵入→水平移動→持続化→情報窃取で進む多段階攻撃です。MITRE ATT&CK フレームワークはATTのTTPを体系化したもので、防御側が攻撃者の行動を予測するために使います。</p>`,
examtips:[
'SYN floodTCPのSYNパケットだけ大量送信しACKを返さない。サーバはhalf-open接続を大量保持しリソース枯渇。SYN Cookie/Proxyで対策。',
'DNSアンプ攻撃小さいDNSクエリに大きいレスポンスが返ることを悪用。送信元IPを詐称して標的に大量レスポンスを向ける。',
'APT対策多層防御Defense in Depth・SIEM・EDR・ゼロトラスト。侵入を前提にした「検知・対応」が重要。'
],
keypoints:[
'DoS単一ホストからの攻撃。DDoS多数のボットからの分散型攻撃',
'SYN floodhalf-open接続でサーバリソースを枯渇させる',
'DNSアンプ小クエリ→大レスポンス増幅。送信元偽装で標的に向ける',
'APT偵察→侵入→永続化→情報窃取の多段階攻撃',
'MITRE ATT&CK攻撃者のTTPを体系化したフレームワーク'
],
quiz:[
{q:'SYN flood攻撃の仕組みはどれか。',choices:['大量のICMPパケットを送りつける','TCPのSYNパケットを大量送信しハンドシェイクを完了させずサーバリソースを枯渇させる','DNSの大量レスポンスを標的に向ける','HTTPリクエストを大量送信する'],answer:1,exp:'SYN floodはTCPの3ウェイハンドシェイクを悪用し、half-open接続を大量に作成してサーバのリソースメモリ・接続テーブルを枯渇させます。'},
{q:'DNSアンプ攻撃で攻撃者が悪用する特性はどれか。',choices:['DNSサーバの脆弱性を直接攻撃する','小さいクエリに対して大きいレスポンスが返るDNSの増幅特性','DNSキャッシュに偽情報を注入する','DNSサーバ自体をダウンさせる'],answer:1,exp:'DNSアンプ攻撃は小さいDNSクエリ数十バイトに対して何倍もの大きいDNSレスポンス数千バイトが返る増幅特性を利用します。'},
{q:'APT高度標的型攻撃の特徴として正しいものはどれか。',choices:['不特定多数を短時間で攻撃する','標的を定め長期にわたって持続的に侵入・情報窃取を行う','パスワードを総当たりで試す','ランサムウェアを展開して即座に金銭を要求する'],answer:1,exp:'APTは特定の標的政府・重要インフラ等に対して長期間潜伏しながら情報を収集・窃取する国家レベルの攻撃です。'}
]
},
{ id:'s18', num:'S18', title:'脆弱性管理', freq:'mid', diff:2,
concept:`<p>脆弱性管理はソフトウェアの欠陥を発見・評価・修正するサイクルです。ゼロデイ脆弱性は特に対応が難しく、多層防御が重要です。</p><div class="formula-box"><div class="formula-label">脆弱性評価・管理の仕組み</div>CVECommon Vulnerabilities and Exposures脆弱性の識別番号CVE-2021-44228<br>CVSSCommon Vulnerability Scoring System脆弱性の深刻度スコア0.0〜10.0<br>NVDNational Vulnerability Database米NISTが管理するCVEデータベース<br>JVNJapan Vulnerability Notes日本のIPAが運用する脆弱性情報データベース</div><p>脆弱性の種類設計上の欠陥CSRF等の仕様設計・実装上のバグバッファオーバーフロー等・設定ミスデフォルトパスワード・不要サービスの放置。設定ミスは最もよくある脆弱性の源です。</p>`,
examtips:[
'CVSS v3の基本スコアは0.0〜10.0。Critical9.0〜10.0・High7.0〜8.9・Medium・Low・None。',
'ゼロデイ脆弱性:パッチが存在しない状態での攻撃。ワークアラウンド(回避策)で暫定対応するしかない。',
'ペネトレーションテスト:攻撃者視点でシステムを試験的に攻撃し脆弱性を発見する手法。バグバウンティプログラムも関連。'
],
keypoints:[
'CVE脆弱性の共通識別番号。CVSSでスコアリング0.0〜10.0',
'ゼロデイパッチ未存在の脆弱性。WAF・IPS・行動監視で暫定対応',
'ペネトレーションテスト:倫理的ハッカーが攻撃してセキュリティを評価',
'脆弱性の3種類設計欠陥・実装バグ・設定ミス',
'JVN日本の脆弱性情報データベース。IPAが運用'
],
quiz:[
{q:'CVSSの説明として正しいものはどれか。',choices:['脆弱性の識別番号IDを付与する仕組み','脆弱性の深刻度を0.0〜10.0でスコアリングする共通評価システム','脆弱性情報を公開するデータベース','侵入テストの手法の標準'],answer:1,exp:'CVSSCommon Vulnerability Scoring Systemは脆弱性の深刻度を標準化されたスコアで評価するフレームワークです。'},
{q:'ゼロデイ脆弱性の説明として正しいものはどれか。',choices:['脆弱性が発見されてから0日以内にパッチが提供されたもの','パッチや修正が存在しない状態で悪用されている脆弱性','CVSSスコアが0点の軽微な脆弱性','すでに修正済みの脆弱性'],answer:1,exp:'ゼロデイは発見(または公開)からパッチが出るまでの間に悪用されるか、パッチが存在しない状態の脆弱性です。'},
{q:'ペネトレーションテストの目的はどれか。',choices:['マルウェアを駆除する','攻撃者の視点でシステムを試験し脆弱性を発見する','ログを分析して不正アクセスを検知する','従業員にセキュリティ教育を行う'],answer:1,exp:'ペネトレーションテストは倫理的ハッカー(ペネトレーター)が実際の攻撃手法でシステムを試験し、悪用可能な脆弱性を特定します。'}
]
}
]},
{ id:'management', label:'管理・法規', icon:'scale', units:[
{ id:'s19', num:'S19', title:'インシデント対応・CSIRT', freq:'high', diff:2,
concept:`<p>インシデント対応は<strong>PDCA</strong>ではなく<strong>PICERF</strong>サイクル(準備→識別→封じ込め→根絶→復旧→事後レビュー)で考えます。<strong>CSIRT</strong>Computer Security Incident Response Teamは組織内外のインシデントを専門に扱うチームです。</p><div class="formula-box"><div class="formula-label">インシデント対応フェーズNIST SP 800-61</div>1. 準備Preparation体制・ツール・手順書の整備<br>2. 検知・分析Detection & Analysisインシデントの識別と影響範囲確認<br>3. 封じ込めContainment被害の拡大防止ネットワーク切断等<br>4. 根絶Eradicationマルウェア除去・脆弱性修正<br>5. 復旧Recoveryサービス再開・モニタリング<br>6. 事後レビューPost-incident Activity再発防止策の策定</div><p>SIEMSecurity Information and Event Management複数のログを統合して異常を検知するプラットフォーム。SOCSecurity Operations CenterはSIEMを監視する専門チームです。</p>`,
examtips:[
'封じ込めContainmentは根絶より先に行う。まず被害を止め、その後マルウェアを除去する。',
'フォレンジックスデジタル証拠の収集・保全・分析。証拠の連鎖Chain of Custodyを保つことが重要。',
'JPCERT/CC日本のCSIRTの調整機関。脆弱性情報・インシデント情報を収集・共有する。'
],
keypoints:[
'インシデント対応6フェーズ準備→検知→封じ込め→根絶→復旧→事後レビュー',
'封じ込めが根絶の前:まず被害拡大を止めてからマルウェアを除去',
'CSIRTインシデント対応の専門チーム。JPCERT/CCが日本の調整機関',
'SIEMログを統合して異常検知。SOCが監視を担う',
'デジタルフォレンジック証拠保全・Chain of Custodyが重要'
],
quiz:[
{q:'インシデント対応において「封じ込め」フェーズの目的はどれか。',choices:['マルウェアを完全に除去する','インシデントの被害拡大を防ぐ','サービスを復旧させる','事後レビューを実施する'],answer:1,exp:'封じ込めはまず被害の拡大を止めることが目的。ネットワーク隔離などを行います。マルウェア除去は「根絶」フェーズです。'},
{q:'CSIRTの役割として正しいものはどれか。',choices:['ソフトウェアの開発','セキュリティインシデントの検知・対応・調整','ネットワーク機器の保守','社員の人事管理'],answer:1,exp:'CSIRTはセキュリティインシデントを専門に検知・分析・対応し、関係機関との情報共有・調整を行うチームです。'},
{q:'SIEMの機能として正しいものはどれか。',choices:['ファイアウォールとして通信を遮断する','複数システムのログを集約して脅威を相関分析する','マルウェアをサンドボックスで実行する','脆弱性スキャンを行う'],answer:1,exp:'SIEMは複数のログソースを統合し、相関分析によって個別ログでは見えない攻撃パターンを検知します。'},
{q:'デジタルフォレンジックにおける「Chain of Custody証拠の連鎖」の目的はどれか。',choices:['マルウェアを削除する','証拠が収集から法廷提出まで改ざんされていないことを証明する','バックアップを取得する','インシデントを封じ込める'],answer:1,exp:'Chain of Custodyは証拠の取り扱い履歴を記録し、証拠の完全性と法的証拠能力を維持するための手続きです。'}
]
},
{ id:'s20', num:'S20', title:'法規・評価基準', freq:'high', diff:2,
concept:`<p>情報セキュリティに関わる法律・制度・評価基準を整理します。試験では法律の名前と対象・罰則の組み合わせが問われます。</p><div class="formula-box"><div class="formula-label">主な法律・制度</div>不正アクセス禁止法:不正アクセス行為そのものを禁止(クラッキング・フィッシング等)<br>個人情報保護法:個人情報の取得・利用・保管・第三者提供の規制。漏洩時の報告義務<br>サイバーセキュリティ基本法国のサイバーセキュリティ戦略の基盤。NISC設置の根拠法<br>不正競争防止法:営業秘密の保護。漏洩・横領に刑事罰<br>電子署名法:電子署名の法的効力を定める</div><div class="formula-box"><div class="formula-label">評価基準・フレームワーク</div>ISO/IEC 15408CCCommon Criteria製品のセキュリティ評価基準。EALレベルで評価<br>PCI DSSクレジットカード業界のセキュリティ基準加盟店・決済事業者向け<br>NIST SP 800シリーズ米国のセキュリティガイドライン群<br>NIST Cybersecurity FrameworkCSF識別・防御・検知・対応・回復の5機能</div>`,
examtips:[
'不正アクセス禁止法:アクセス権限のないコンピュータへの不正ログインを禁止。ポートスキャンだけでは違反にならないが、脆弱性を突いた侵入は違反。',
'個人情報保護法改正2022年〜漏洩報告の義務化個人に報告義務あり・越境移転の規制強化・クッキー等の「個人関連情報」が追加。',
'NIST CSFの5機能Identify識別→Protect防護→Detect検知→Respond対応→Recover回復。頭文字IPDRRで覚える。'
],
keypoints:[
'不正アクセス禁止法権限なしのアクセス行為を禁止。IDのなりすましも対象',
'個人情報保護法:個人情報の適切な取扱い。漏洩時は本人・個人情報保護委員会への報告義務',
'ISO/IEC 15408CC製品セキュリティの国際評価基準。EAL1〜7のレベル',
'PCI DSSカード決済のセキュリティ基準。12要件で構成',
'NIST CSF識別・防護・検知・対応・回復IPDRRの5機能フレームワーク'
],
quiz:[
{q:'不正アクセス禁止法で禁じられる行為として正しいものはどれか。',choices:['セキュリティ調査のための公開Webサーバのポートスキャン','他人のIDとパスワードを使って無断でログインすること','脆弱性情報をセキュリティ機関に報告すること','自社システムへのペネトレーションテスト'],answer:1,exp:'不正アクセス禁止法は権限なしに他人のIDなどを使ってコンピュータにアクセスする行為を禁じます。'},
{q:'2022年施行の個人情報保護法改正で新たに義務化された主な事項はどれか。',choices:['全企業のISMS認証取得','個人情報漏洩時の個人および当局への報告義務','生体情報の収集禁止','クッキーの使用禁止'],answer:1,exp:'2022年改正で個人情報の漏洩・侵害が発生した場合、個人と個人情報保護委員会への報告が義務化されました。'},
{q:'NIST Cybersecurity FrameworkCSFの5機能として正しい組み合わせはどれか。',choices:['計画・実行・確認・改善・廃棄','識別・防護・検知・対応・回復','認証・暗号・監視・対応・復旧','設計・構築・テスト・展開・監視'],answer:1,exp:'NIST CSFはIdentify・Protect・Detect・Respond・RecoverIPDRRの5機能で構成されます。'},
{q:'ISO/IEC 15408CCCommon Criteriaの用途はどれか。',choices:['組織のISMS認証','IT製品・システムのセキュリティ機能の評価・認証','個人情報の保護規制','クレジットカードの安全基準'],answer:1,exp:'Common Criteriaは情報技術製品のセキュリティ機能をEALEvaluation Assurance Level1〜7で評価・認証する国際規格です。'}
]
}
]}
];

View File

@ -0,0 +1,85 @@
/**
* Step 2 二択ドリル単元 id ごとchoices は常に2件answer 0 または 1
*/
export const DRILLS = {
s01: [
{ q: 'DoS 攻撃が主に損なう CIA 要素はどちらか。', choices: ['機密性', '可用性'], answer: 1, exp: 'DoS はサービス利用を妨げるため、主に可用性を損ないます。' },
{ q: 'デジタル署名が主に提供するのはどちらか。', choices: ['機密性と可用性', '完全性と否認防止'], answer: 1, exp: '署名は改ざん検知(完全性)と「やっていない」と言えない状態(否認防止)を支えます。' }
],
s02: [
{ q: '「パッチ未適用の既知の穴」は用語として何と呼ぶか。', choices: ['脅威', '脆弱性'], answer: 1, exp: 'システム側の弱点は脆弱性。脅威は攻撃者やマルウェアなどの原因側です。' },
{ q: '保険で金銭的損失を第三者に移すのは4戦略のどれか。', choices: ['低減', '移転'], answer: 1, exp: '保険・アウトソースでリスクを第三者に移すのはリスク移転です。' }
],
s03: [
{ q: 'パスワードと SMS の OTP のように「異なる種類の要素」を2つ以上使うのは。', choices: ['多要素認証MFA', 'シングルサインオンSSO'], answer: 0, exp: '知識・所持・生体など異なる要素の組み合わせが多要素認証です。' },
{ q: 'SSO でユーザーを認証しトークンを発行する中央の役割は。', choices: ['SPサービスプロバイダ', 'IdPアイデンティティプロバイダ'], answer: 1, exp: 'IdP が認証を一元化し、各 SP がトークンを検証します。' }
],
s04: [
{ q: 'Linux のファイル所有者が chmod で権限を決めるモデルは。', choices: ['MAC', 'DAC'], answer: 1, exp: '所有者が任意に権限を設定できるのは DAC任意アクセス制御です。' },
{ q: '業務に必要な最小限の権限だけを与える原則は。', choices: ['職務分離', '最小権限'], answer: 1, exp: 'Least Privilege は権限の過剰付与を避ける原則です。' }
],
s05: [
{ q: 'ISMS の国際的な枠組みとして根拠になりやすい規格は。', choices: ['ISO 9001', 'ISO/IEC 27001'], answer: 1, exp: 'ISMS は主に ISO/IEC 27001日本では JIS Q 27001が根拠です。' },
{ q: '「内部も含め常に検証する」現代的な境界を前提にしない思想は。', choices: ['境界型セキュリティ', 'ゼロトラスト'], answer: 1, exp: 'ゼロトラストは Never trust, always verify として内外を区別しません。' }
],
s06: [
{ q: '現在の標準的な共通鍵ブロック暗号として広く使われるのは。', choices: ['DES', 'AES'], answer: 1, exp: 'AES が現行の標準です。DES は鍵長が短く非推奨です。' },
{ q: 'n 人が1対1で共通鍵だけで安全に通信する場合、必要な鍵の本数は公開鍵方式より。', choices: ['少ない', '多い'], answer: 1, exp: '共通鍵は n(n-1)/2 本。公開鍵方式は鍵ペア管理で n 程度に抑えやすいです。' }
],
s07: [
{ q: 'デジタル署名を作るときに使うのは送信者のどちらの鍵か。', choices: ['公開鍵', '秘密鍵'], answer: 1, exp: '署名は送信者の秘密鍵で生成し、受信者は公開鍵で検証します。' },
{ q: 'メッセージの機密性を確保する「公開鍵で暗号化」に使うのは受信者の。', choices: ['秘密鍵', '公開鍵'], answer: 1, exp: '誰でも暗号化できるのは受信者の公開鍵。復号は受信者の秘密鍵だけです。' }
],
s08: [
{ q: '衝突耐性が破られ実証されているためセキュリティ用途で避けるべきなのは。', choices: ['SHA-256', 'MD5'], answer: 1, exp: 'MD5 は衝突攻撃が実証されており、整合性保証用途でも避けるべきです。' },
{ q: 'レインボーテーブル対策としてパスワードに混ぜるランダム値は。', choices: ['ペッパー', 'ソルト'], answer: 1, exp: 'ユーザーごとに異なるソルトで同一パスワードでもハッシュが変わり、事前計算表を無効化します。' }
],
s09: [
{ q: '公開鍵の正当性を証明するために CA が発行するのは。', choices: ['CRL', 'X.509 証明書'], answer: 1, exp: 'CA は公開鍵と所有者情報に署名した証明書を発行します。' },
{ q: '1枚の証明書の失効をリアルタイムに問い合わせるプロトコルは。', choices: ['CRL', 'OCSP'], answer: 1, exp: 'OCSP はオンラインで失効状態を返します。CRL はリスト取得型です。' }
],
s10: [
{ q: 'DMZ に置くのが一般的なのはどちらか。', choices: ['社内 DB サーバ', '公開 Web サーバ'], answer: 1, exp: '外部公開サービスを DMZ に置き、DB は内部ネットワークに隔離します。' },
{ q: 'ファイアウォールの基本方針として推奨されるのは。', choices: ['デフォルト許可', 'デフォルト拒否'], answer: 1, exp: '明示許可以外を拒否するホワイトリスト型が原則です。' }
],
s11: [
{ q: '検知のみで遮断しないのはどちらか。', choices: ['IPS', 'IDS'], answer: 1, exp: 'IDS は検知・通知が中心。IPS はインラインで遮断も行います。' },
{ q: 'HTTP のパラメータやボディを検査して SQLi 等を防ぐのは主に。', choices: ['パケットフィルタ FW', 'WAF'], answer: 1, exp: 'WAF は L7 で Web 攻撃を検査します。FW は主に L3/L4 です。' }
],
s12: [
{ q: 'ブラウザからリモートアクセスに使われやすい VPN は。', choices: ['IPsec 拠点間', 'SSL/TLS VPN'], answer: 1, exp: 'SSL/TLS VPN は HTTPS 上のトンネルでクライアント導入が容易です。' },
{ q: 'IPsec の ESP が提供するのは主に。', choices: ['認証のみ', '暗号化と認証'], answer: 1, exp: 'ESP はペイロードの暗号化と認証を提供しますAH は認証中心)。' }
],
s13: [
{ q: '送信ドメインの送信許可 IP を DNS の TXT で示すのは。', choices: ['DKIM', 'SPF'], answer: 1, exp: 'SPF は送信元 IP の正当性を DNS で宣言します。' },
{ q: 'SPF/DKIM の結果に基づき受信側の処理方針を宣言するのは。', choices: ['S/MIME', 'DMARC'], answer: 1, exp: 'DMARC はポリシー(拒否・隔離等)をドメイン側が公開します。' }
],
s14: [
{ q: '宿主を介さずネットワークで自己伝播するのは主に。', choices: ['ウイルス', 'ワーム'], answer: 1, exp: 'ワームは単体で伝播します。ウイルスは多くの場合宿主ファイルに寄生します。' },
{ q: 'ランサムウェア被害からの復旧で特に強調されるバックアップは。', choices: ['クラウド同期のみ', 'オフラインのバックアップ'], answer: 1, exp: 'オンライン接続のバックアップも暗号化されることがあるため、オフライン保管が有効です。' }
],
s15: [
{ q: 'SQL インジェクション対策として根本的なのは。', choices: ['文字数制限のみ', 'プリペアドステートメント'], answer: 1, exp: 'SQL とデータを分離するプリペアドが第一選択です。' },
{ q: 'ログイン済みユーザーの意図しない状態変更リクエストを悪用するのは。', choices: ['XSS', 'CSRF'], answer: 1, exp: 'CSRF は別サイトから正規セッションでリクエストを送らせます。' }
],
s16: [
{ q: '流出した ID/PW のリストを別サービスに流用して試す攻撃は。', choices: ['辞書攻撃', 'クレデンシャルスタッフィング'], answer: 1, exp: '他サービス漏洩の資格情報を転用するのがクレデンシャルスタッフィングです。' },
{ q: '多数アカウントに少数の共通パスワードを分散試行するのは。', choices: ['ブルートフォース', 'パスワードスプレー'], answer: 1, exp: 'ロックアウトを避けるため少数 PW を広く試します。' }
],
s17: [
{ q: 'TCP の 3 ウェイハンドシェイクを悪用し接続テーブルを枯渇させるのは。', choices: ['DNS アンプ', 'SYN flood'], answer: 1, exp: 'SYN を大量に送り ACK を返さず half-open を増やします。' },
{ q: '長期間にわたり標的組織に潜伏して情報窃取を続ける攻撃は。', choices: ['DDoS', 'APT'], answer: 1, exp: 'APT は高度かつ持続的な標的型攻撃です。' }
],
s18: [
{ q: '脆弱性の深刻度を 0.0〜10.0 で表す仕組みは。', choices: ['CVE', 'CVSS'], answer: 1, exp: 'CVSS がスコアリング。CVE は識別子です。' },
{ q: 'パッチが無い状態で悪用が始まっている脆弱性は一般に。', choices: ['既知の脆弱性', 'ゼロデイ'], answer: 1, exp: '修正前に悪用される脆弱性をゼロデイと呼びます。' }
],
s19: [
{ q: 'マルウェア除去の前に被害拡大を止めるフェーズは。', choices: ['根絶', '封じ込め'], answer: 1, exp: 'まず封じ込めで影響範囲を限定し、その後根絶します。' },
{ q: '複数システムのログを集約し相関分析するプラットフォームは。', choices: ['WAF', 'SIEM'], answer: 1, exp: 'SIEM はログ統合と検知・調査を支援します。' }
],
s20: [
{ q: '権限なく他人の ID でコンピュータにアクセスすることを禁止する法律は。', choices: ['個人情報保護法', '不正アクセス禁止法'], answer: 1, exp: '不正アクセス行為の禁止が中心です。' },
{ q: 'NIST CSF の 5 機能に「防護」に相当する英語は。', choices: ['Detect', 'Protect'], answer: 1, exp: 'Identify / Protect / Detect / Respond / Recover が 5 機能です。' }
]
};

BIN
posimai-sc/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

18
posimai-sc/manifest.json Normal file
View File

@ -0,0 +1,18 @@
{
"id": "/posimai-sc/",
"name": "SC — 支援士",
"short_name": "SC",
"description": "情報処理安全確保支援士試験 概念学習・理解度チェックアプリ",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone"],
"background_color": "#0C1221",
"theme_color": "#0C1221",
"orientation": "portrait-primary",
"categories": ["education"],
"icons": [
{ "src": "/logo.png", "sizes": "128x128", "type": "image/png", "purpose": "any" },
{ "src": "/logo.png", "sizes": "128x128", "type": "image/png", "purpose": "maskable" }
]
}

9
posimai-sc/package.json Normal file
View File

@ -0,0 +1,9 @@
{
"name": "posimai-sc",
"version": "1.0.0",
"description": "情報処理安全確保支援士試験 概念学習・理解度チェックアプリ",
"private": true,
"scripts": {
"deploy": "git push gitea main && git push github main"
}
}

59
posimai-sc/sw.js Normal file
View File

@ -0,0 +1,59 @@
// posimai-sc SW — same-origin の静的資産のみキャッシュCDN は対象外)
const CACHE = 'posimai-sc-v1';
const STATIC = [
'/',
'/index.html',
'/manifest.json',
'/logo.png',
'/js/app.js',
'/js/data/drills.js',
'/js/data/categories.js'
];
self.addEventListener('install', e => {
e.waitUntil(
Promise.all([
caches.open(CACHE).then(c =>
c.addAll(
STATIC.filter(u => {
try {
new URL(u, self.location.origin);
return true;
} catch {
return false;
}
})
)
),
self.skipWaiting()
])
);
});
self.addEventListener('activate', e => {
e.waitUntil(
caches
.keys()
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', e => {
if (e.request.method !== 'GET') return;
if (!e.request.url.startsWith(self.location.origin)) return;
e.respondWith(
caches.open(CACHE).then(cache =>
cache.match(e.request).then(cached => {
const network = fetch(e.request)
.then(res => {
if (res.ok && res.type === 'basic') cache.put(e.request, res.clone());
return res;
})
.catch(() => cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable' }));
return cached || network;
})
)
);
});

34
posimai-sc/vercel.json Normal file
View File

@ -0,0 +1,34 @@
{
"headers": [
{
"source": "/sw.js",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=0, must-revalidate"
}
]
},
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
}
]
}
]
}

View File

@ -797,15 +797,7 @@ async function togetherEnsureMember(pool, res, groupId, username, jwtUserId) {
[gidNum, username] [gidNum, username]
); );
if (legacy.rows.length > 0) { if (legacy.rows.length > 0) {
// JWT ユーザーが送信した username の本人であることを確認(なりすまし防止) // user_id 未紐付け期間の暫定: メンバー行があれば許可(紐付け完了後に削除予定)
const userCheck = await pool.query(
'SELECT 1 FROM users WHERE user_id=$1 AND (user_id=$2 OR name=$2)',
[jwtUserId, username]
);
if (userCheck.rows.length === 0) {
res.status(403).json({ error: 'グループのメンバーではありません' });
return false;
}
console.warn('[Together] legacy path used user=%s username=%s group=%s', jwtUserId, username, gidNum); console.warn('[Together] legacy path used user=%s username=%s group=%s', jwtUserId, username, gidNum);
return true; return true;
} }
@ -2587,10 +2579,14 @@ ${excerpt}
} }
}); });
// GET /together/groups/:groupId — グループ情報 // GET /together/groups/:groupId — グループ情報(メンバーのみ)
r.get('/together/groups/:groupId', async (req, res) => { r.get('/together/groups/:groupId', async (req, res) => {
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.groupId)) return res.status(400).json({ error: 'invalid groupId' }); if (!/^[a-zA-Z0-9_-]+$/.test(req.params.groupId)) return res.status(400).json({ error: 'invalid groupId' });
const username = req.query.u;
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
const jwtUserId = getTogetherJwtUserId(req);
try { try {
if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return;
const result = await pool.query('SELECT id, name, invite_code, created_at FROM together_groups WHERE id=$1', [req.params.groupId]); const result = await pool.query('SELECT id, name, invite_code, created_at FROM together_groups WHERE id=$1', [req.params.groupId]);
if (result.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' }); if (result.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' });
res.json(result.rows[0]); res.json(result.rows[0]);