Compare commits
11 Commits
6d0df5faae
...
4bb35568ab
| Author | SHA1 | Date |
|---|---|---|
|
|
4bb35568ab | |
|
|
e7f2a3a3ad | |
|
|
fb90a7b328 | |
|
|
377a118273 | |
|
|
35bb426e84 | |
|
|
54e50fabf1 | |
|
|
251e5316eb | |
|
|
15257dfc71 | |
|
|
db0fd6a88e | |
|
|
43c650c2d8 | |
|
|
9e6178791f |
|
|
@ -0,0 +1,73 @@
|
|||
name: Build Guard Linux
|
||||
|
||||
on:
|
||||
workflow_dispatch: # 手動実行
|
||||
push:
|
||||
tags:
|
||||
- 'guard-v*' # guard-v0.1.0 など tag push で自動実行
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: posimai-guard-app
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libssl-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: posimai-guard-app/src-tauri
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build Tauri (Linux)
|
||||
run: npm run build
|
||||
|
||||
- name: Upload .deb
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: posimai-guard-linux-deb
|
||||
path: posimai-guard-app/src-tauri/target/release/bundle/deb/*.deb
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload .AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: posimai-guard-linux-appimage
|
||||
path: posimai-guard-app/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create GitHub Release (on tag)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
posimai-guard-app/src-tauri/target/release/bundle/deb/*.deb
|
||||
posimai-guard-app/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -21,11 +21,17 @@ out/
|
|||
dist/
|
||||
build/
|
||||
|
||||
# VS Code 拡張バイナリ(ローカル成果物、git管理外)
|
||||
*.vsix
|
||||
|
||||
# 環境変数
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# MCP 設定(DB 認証情報を含む — git 管理外)
|
||||
.mcp.json
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
35
DESIGN.md
35
DESIGN.md
|
|
@ -14,6 +14,14 @@
|
|||
| `ponshu-room` / `ponshu_room_lite` | 独自テーマ(和紙 x 墨 x 琥珀)。Posimai デザインシステム適用外 |
|
||||
| `posimai-analytics` | BtoB ダッシュボード。TailwindCSS + React + ライトテーマで構築 |
|
||||
|
||||
## 背景スタイル(Station / Guard 系)
|
||||
|
||||
Station・Guard・その他サイバー系アプリで使うダークブルー背景 + バイナリオーロラのコードは **`docs/posimai-bg.md`** にコピペ用で完備。
|
||||
|
||||
新規アプリに適用する場合は AI に「`docs/posimai-bg.md` の背景スタイルを適用して」と指示するだけでよい。
|
||||
|
||||
---
|
||||
|
||||
## `docs/design-system.md` との関係
|
||||
|
||||
`docs/design-system.md` は **このファイルの補足・詳細版**として残存しているが、**AI が参照する正はこの DESIGN.md のみ**。二重参照した場合はこのファイルを優先すること。`docs/design-system.md` は将来的に廃止または統合予定。
|
||||
|
|
@ -69,6 +77,7 @@
|
|||
[data-app-id="posimai-atlas"] { --accent: #22D3EE; } /* Cyan */
|
||||
[data-app-id="posimai-dev"] { --accent: #A78BFA; } /* Violet */
|
||||
[data-app-id="posimai-journal"]{ --accent: #80CAEE; } /* Sky-Blue */
|
||||
[data-app-id="posimai-guard"] { --accent: #F97316; } /* Amber-Orange */
|
||||
|
||||
/* NG: ルートへの直書き上書き */
|
||||
:root { --accent: #22D3EE; }
|
||||
|
|
@ -80,6 +89,7 @@
|
|||
| posimai-journal / posimai-site | `#80CAEE` Sky-Blue | 静かで知的な印象 |
|
||||
| posimai-atlas | `#22D3EE` Cyan | サイバー・ターミナル感。背景も `#0C1221` navy |
|
||||
| posimai-dev | `#A78BFA` Violet | コード・AI・ターミナルの融合 |
|
||||
| posimai-guard | `#F97316` Amber-Orange | セキュリティ・警告の視覚的コンテキスト |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -265,6 +275,31 @@
|
|||
|
||||
## 9. Do's and Don'ts(コードレベル)
|
||||
|
||||
### カードの左縦罫線(禁止)
|
||||
|
||||
カード型リスト(IssueCard, CVECard, LicenseCard, IacCard など)の左辺に severity/状態カラーを使った縦罫線を付けてはいけない。
|
||||
severity は内部のアイコンと dim-bg バッジで表現する。縦罫線はノイズになり "AIっぽいダサさ" の原因になる。
|
||||
|
||||
```tsx
|
||||
// NG: 左罫線で severity を表現
|
||||
<div style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: `2px solid ${cfg.color}`, // ← 禁止
|
||||
}}>
|
||||
|
||||
// OK: 左罫線なし。アイコン + dim-bg バッジで severity を示す
|
||||
<div style={{
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ background: cfg.dim }}>
|
||||
<Icon style={{ color: cfg.color }} />
|
||||
</div>
|
||||
```
|
||||
|
||||
例外: ファイルツリーのアクティブ選択状態(`borderLeft: '2px solid var(--accent)'`)は許可。ナビゲーション状態の表示であり、カードではないため。
|
||||
|
||||
---
|
||||
|
||||
### 色の使い方
|
||||
|
||||
```html
|
||||
|
|
|
|||
20
STATUS.md
20
STATUS.md
|
|
@ -8,12 +8,28 @@
|
|||
|
||||
## 次にやること(優先順)
|
||||
|
||||
### Guard / Tauri
|
||||
1. **Tauri アプリ動作確認** — デスクトップショートカットから起動、CodeViewer が WebView でも動くか確認
|
||||
2. 将来: 絶対パス追跡 → 「VS Code で開く」ボタン実装(tauriOpenPath 準備済み)
|
||||
3. 将来: diff preview → apply → git commit フロー
|
||||
|
||||
### Guard ビルド方法メモ(次回再ビルド時)
|
||||
```bash
|
||||
cd /c/Users/maita/posimai-project/posimai-guard-app
|
||||
export PATH="$PATH:/c/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/VC/Tools/MSVC/14.44.35207/bin/Hostx64/x64:/c/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x64"
|
||||
export LIB="C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.44.35207\lib\x64;..."
|
||||
export INCLUDE="..."
|
||||
node_modules/.bin/tauri build
|
||||
# → target/release/bundle/nsis/Posimai Guard_0.1.0_x64-setup.exe
|
||||
# → target/release/bundle/msi/Posimai Guard_0.1.0_x64_en-US.msi
|
||||
```
|
||||
|
||||
### ビジネス化
|
||||
1. **Eiji に Stripe sandbox テストをお願いする**(購入フロー確認)
|
||||
2. **どのアプリに premium 機能を実装するか決める**(未決定・最重要)
|
||||
2. **日本酒アプリを完成させて展開**(mai 最優先)
|
||||
3. 特商法ページ記入(mai 作業 — 事業者名・住所・電話番号)
|
||||
4. Store デザイン確定(Eiji と A/B/C/D から選定)
|
||||
5. Stripe 本番モード切り替え(上記完了後)
|
||||
6. 各アプリへの `purchased` フラグ実装(対象決定後・1アプリ30分)
|
||||
|
||||
## ブロック中
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
# Posimai 背景スタイル — コピペ用リファレンス
|
||||
|
||||
新規アプリで Station/Guard と同じ背景を使いたい時はここからコピーする。
|
||||
AI への指示は「`docs/posimai-bg.md` の背景スタイルを適用して」だけで OK。
|
||||
|
||||
---
|
||||
|
||||
## 何が入っているか
|
||||
|
||||
| 要素 | 内容 |
|
||||
|------|------|
|
||||
| 背景色 | `#0C1221`(Termius 系ダークブルー) |
|
||||
| グリッド線 | 48px 格子、極薄白 |
|
||||
| 上部グロー | シアン+アクセントカラーのグラデーション |
|
||||
| バイナリオーロラ | 0/1 が降るキャンバスアニメーション(Station と同仕様) |
|
||||
| フォント | Inter(UI) + JetBrains Mono(コード・キャンバス) |
|
||||
|
||||
アクセントカラーだけ各アプリで差し替える(Guard は `#F97316`、Station は `#22D3EE` など)。
|
||||
|
||||
---
|
||||
|
||||
## 1. layout.tsx — フォント読み込み
|
||||
|
||||
```tsx
|
||||
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '500', '600'],
|
||||
display: 'swap',
|
||||
variable: '--font-sans',
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500'],
|
||||
display: 'swap',
|
||||
variable: '--font-mono',
|
||||
});
|
||||
|
||||
// body の className に両方渡す
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable}`}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. globals.css — 背景スタイル一式
|
||||
|
||||
```css
|
||||
@theme inline {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0C1221;
|
||||
--surface: #111827;
|
||||
--surface2: #1A2332;
|
||||
--border: #1F2D40;
|
||||
/* アクセントカラーはアプリごとに変える */
|
||||
--accent: #22D3EE; /* 例: Station はシアン */
|
||||
--grid-line: rgba(255, 255, 255, 0.028);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* グリッド線 */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 上部グロー(アクセントカラーを参照) */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 480px;
|
||||
background: radial-gradient(
|
||||
ellipse 70% 45% at 50% -5%,
|
||||
color-mix(in srgb, var(--accent) 8%, transparent) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* コンテンツをオーロラの上に */
|
||||
body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. BinaryAurora.tsx — コピペ用完全版
|
||||
|
||||
色帯(BANDS)の hue を変えるとアプリのカラーに合わせられる。
|
||||
現在値は Station と同じシアン(185) / パープル(265) / グリーン(150)。
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function BinaryAurora() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const FONT_SIZE = 14;
|
||||
|
||||
// 色帯: hue を変えてアプリのカラーに合わせる
|
||||
// シアン/パープル/グリーン(Station・Guard 共通)
|
||||
const BANDS = [
|
||||
{ hue: 185, sat: 90, x: 0.15, speed: 0.00018, phase: 0 },
|
||||
{ hue: 265, sat: 80, x: 0.38, speed: 0.00013, phase: 1.5 },
|
||||
{ hue: 185, sat: 85, x: 0.62, speed: 0.00020, phase: 3.0 },
|
||||
{ hue: 150, sat: 70, x: 0.80, speed: 0.00015, phase: 4.2 },
|
||||
];
|
||||
|
||||
type Col = { y: number; speed: number; len: number; chars: string[]; opacity: number };
|
||||
let cols: Col[] = [];
|
||||
let t = 0;
|
||||
let raf: number;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const numCols = Math.ceil(canvas.width / FONT_SIZE);
|
||||
while (cols.length < numCols) {
|
||||
cols.push({
|
||||
y: Math.random() * canvas.height,
|
||||
speed: 1.0 + Math.random() * 3.2,
|
||||
len: 8 + Math.floor(Math.random() * 22),
|
||||
chars: [],
|
||||
opacity: 0.28 + Math.random() * 0.50,
|
||||
});
|
||||
}
|
||||
if (cols.length > numCols) cols.length = numCols;
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
function getBandColor(x: number, now: number): { hue: number; sat: number; alpha: number } {
|
||||
const xf = x / (canvas?.width ?? 1);
|
||||
let best = BANDS[0];
|
||||
let bestDist = Infinity;
|
||||
for (const b of BANDS) {
|
||||
const bx = b.x + Math.sin(now * b.speed + b.phase) * 0.12;
|
||||
const dist = Math.abs(xf - bx);
|
||||
if (dist < bestDist) { bestDist = dist; best = b; }
|
||||
}
|
||||
const bx = best.x + Math.sin(now * best.speed + best.phase) * 0.12;
|
||||
const alpha = Math.max(0, 1 - Math.abs(xf - bx) / 0.22);
|
||||
return { hue: best.hue, sat: best.sat, alpha };
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
t++;
|
||||
raf = requestAnimationFrame(draw);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = `${FONT_SIZE}px 'JetBrains Mono', monospace`;
|
||||
|
||||
cols.forEach((col, i) => {
|
||||
const x = i * FONT_SIZE;
|
||||
const band = getBandColor(x, t);
|
||||
|
||||
for (let j = 0; j < col.len; j++) {
|
||||
const cy = col.y - j * FONT_SIZE;
|
||||
if (cy < -FONT_SIZE || cy > canvas.height + FONT_SIZE) continue;
|
||||
|
||||
if (!col.chars[j] || (t % 8 === 0 && Math.random() < 0.05)) {
|
||||
col.chars[j] = Math.random() < 0.5 ? '1' : '0';
|
||||
}
|
||||
const ch = col.chars[j];
|
||||
const trailAlpha = (1 - j / col.len) * col.opacity;
|
||||
const finalAlpha = trailAlpha * (band.alpha * 0.7 + 0.15);
|
||||
|
||||
if (j === 0) {
|
||||
ctx.fillStyle = `hsla(${band.hue},${band.sat}%,94%,${Math.min(1, finalAlpha * 2.2)})`;
|
||||
} else if (ch === '1') {
|
||||
ctx.fillStyle = `hsla(${band.hue},${band.sat}%,65%,${finalAlpha})`;
|
||||
} else {
|
||||
ctx.fillStyle = `hsla(${(band.hue + 30) % 360},${Math.round(band.sat * 0.6)}%,45%,${finalAlpha * 0.55})`;
|
||||
}
|
||||
ctx.fillText(ch, x, cy);
|
||||
}
|
||||
|
||||
col.y += col.speed;
|
||||
if (col.y - col.len * FONT_SIZE > canvas.height) {
|
||||
col.y = -FONT_SIZE * 2;
|
||||
col.speed = 1.0 + Math.random() * 3.2;
|
||||
col.len = 8 + Math.floor(Math.random() * 22);
|
||||
col.chars = [];
|
||||
col.opacity = 0.28 + Math.random() * 0.50;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
opacity: 1,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 使い方
|
||||
|
||||
```tsx
|
||||
// layout.tsx または page.tsx に追加するだけ
|
||||
import { BinaryAurora } from '@/components/BinaryAurora';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<BinaryAurora />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. カスタマイズチートシート
|
||||
|
||||
| 変えたいもの | 場所 | 変更内容 |
|
||||
|-------------|------|---------|
|
||||
| 雨の色 | `BANDS[*].hue` | オレンジ系は 18〜42、シアンは 185、パープルは 265 |
|
||||
| 雨の速さ | `speed: 1.0 + Math.random() * 3.2` | 数値を大きくすると速い |
|
||||
| 雨の長さ | `len: 8 + Math.floor(Math.random() * 22)` | 数値を大きくすると長いトレイル |
|
||||
| 背景色 | `--bg` CSS 変数 | `#0C1221` 標準。より暗くしたければ `#080E1A` など |
|
||||
| 上部グロー | `body::after` の `--accent` | アクセントカラーに追従する |
|
||||
| キャンバス全体の明るさ | `canvas opacity` | `0.5〜1.0` の範囲で調整 |
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 26cacc8cec858ba8d71124ed93440f8601979d6e
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Posimai Guard</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=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "posimai-guard-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.3.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-fs": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.3.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
[package]
|
||||
name = "posimai-guard-app"
|
||||
version = "0.1.0"
|
||||
description = "Posimai Guard — AI Code Security Scanner"
|
||||
authors = ["posimai"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "posimai_guard_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/// ファイル一覧を取得(除外ディレクトリをスキップ)
|
||||
#[tauri::command]
|
||||
fn read_dir_recursive(
|
||||
path: String,
|
||||
exclude_dirs: Vec<String>,
|
||||
max_files: usize,
|
||||
) -> Result<Vec<FileEntry>, String> {
|
||||
let mut results = Vec::new();
|
||||
collect_files(
|
||||
std::path::Path::new(&path),
|
||||
&path,
|
||||
&exclude_dirs,
|
||||
&mut results,
|
||||
max_files,
|
||||
);
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct FileEntry {
|
||||
path: String, // ルートからの相対パス
|
||||
abs_path: String, // 絶対パス(ファイル読み込み用)
|
||||
size: u64,
|
||||
}
|
||||
|
||||
fn collect_files(
|
||||
dir: &std::path::Path,
|
||||
root: &str,
|
||||
exclude_dirs: &[String],
|
||||
results: &mut Vec<FileEntry>,
|
||||
max_files: usize,
|
||||
) {
|
||||
if results.len() >= max_files {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if results.len() >= max_files {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
if path.is_dir() {
|
||||
if exclude_dirs.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
collect_files(&path, root, exclude_dirs, results, max_files);
|
||||
} else if path.is_file() {
|
||||
let abs = path.to_string_lossy().to_string();
|
||||
let rel = abs.strip_prefix(root).unwrap_or(&abs).trim_start_matches(['/', '\\']).to_string();
|
||||
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
results.push(FileEntry { path: rel, abs_path: abs, size });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ファイル内容を読み込む
|
||||
#[tauri::command]
|
||||
fn read_file(abs_path: String) -> Result<String, String> {
|
||||
std::fs::read_to_string(&abs_path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 修正済み内容をファイルに書き戻す
|
||||
#[tauri::command]
|
||||
fn write_file(abs_path: String, content: String) -> Result<(), String> {
|
||||
std::fs::write(&abs_path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// git status / diff / commit を実行
|
||||
#[tauri::command]
|
||||
fn git_command(cwd: String, args: Vec<String>) -> Result<String, String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.current_dir(&cwd)
|
||||
.args(&args)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
if output.status.success() {
|
||||
Ok(stdout)
|
||||
} else {
|
||||
Err(if stderr.is_empty() { stdout } else { stderr })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
read_dir_recursive,
|
||||
read_file,
|
||||
write_file,
|
||||
git_command,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
posimai_guard_app_lib::run()
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"productName": "Posimai Guard",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.posimai.guard",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Posimai Guard",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,383 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
abs_path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ScanIssue {
|
||||
severity: 'danger' | 'warning' | 'info';
|
||||
title: string;
|
||||
description: string;
|
||||
file: string;
|
||||
line: number | null;
|
||||
fix: string | null;
|
||||
}
|
||||
|
||||
type AppState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'scanning'; progress: string }
|
||||
| { status: 'results'; issues: ScanIssue[]; rootDir: string; files: FileEntry[] };
|
||||
|
||||
const EXCLUDE_DIRS = ['node_modules', '.next', '.git', 'dist', 'build', 'out', '.turbo', 'coverage', '__pycache__'];
|
||||
const SCAN_EXTS = new Set(['.ts','.tsx','.js','.jsx','.py','.go','.rb','.php','.java','.cs','.env','.yaml','.yml','.json','.toml','.tf']);
|
||||
const MAX_FILES = 80;
|
||||
const MAX_CHARS = 6000;
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
export default function App() {
|
||||
const [state, setState] = useState<AppState>({ status: 'idle' });
|
||||
const [geminiKey, setGeminiKey] = useState(() => localStorage.getItem('guard-gemini-key') ?? '');
|
||||
const [showKeyInput, setShowKeyInput] = useState(false);
|
||||
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
const dir = await open({ directory: true, multiple: false, title: 'スキャンするフォルダを選択' });
|
||||
if (!dir || typeof dir !== 'string') return;
|
||||
await runScan(dir);
|
||||
}, [geminiKey]);
|
||||
|
||||
const runScan = async (rootDir: string) => {
|
||||
if (!geminiKey.trim()) {
|
||||
setShowKeyInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ status: 'scanning', progress: 'ファイルを収集中...' });
|
||||
|
||||
// 1. ファイル一覧取得(Rust コマンド)
|
||||
const allFiles: FileEntry[] = await invoke('read_dir_recursive', {
|
||||
path: rootDir,
|
||||
excludeDirs: EXCLUDE_DIRS,
|
||||
maxFiles: MAX_FILES * 3,
|
||||
});
|
||||
|
||||
// 2. スキャン対象フィルタ
|
||||
const targets = allFiles
|
||||
.filter(f => SCAN_EXTS.has(f.path.substring(f.path.lastIndexOf('.'))))
|
||||
.sort((a, b) => {
|
||||
const priority = (p: string) => {
|
||||
if (p.includes('.env')) return 0;
|
||||
if (p.endsWith('.ts') || p.endsWith('.tsx')) return 1;
|
||||
return 2;
|
||||
};
|
||||
return priority(a.path) - priority(b.path);
|
||||
})
|
||||
.slice(0, MAX_FILES);
|
||||
|
||||
setState({ status: 'scanning', progress: `スキャン中... (${targets.length} ファイル)` });
|
||||
|
||||
// 3. ファイル内容を読み込み、チャンク化
|
||||
const chunks: string[] = [];
|
||||
let chunk = '';
|
||||
for (const f of targets) {
|
||||
try {
|
||||
const content: string = await invoke('read_file', { absPath: f.abs_path });
|
||||
const trimmed = content.slice(0, MAX_CHARS);
|
||||
const entry = `\n\n### FILE: ${f.path}\n\`\`\`\n${trimmed}\n\`\`\``;
|
||||
if (chunk.length + entry.length > 28000 && chunk.length > 0) {
|
||||
chunks.push(chunk);
|
||||
chunk = entry;
|
||||
} else {
|
||||
chunk += entry;
|
||||
}
|
||||
} catch { /* skip unreadable */ }
|
||||
}
|
||||
if (chunk) chunks.push(chunk);
|
||||
|
||||
// 4. Gemini API でスキャン
|
||||
const allIssues: ScanIssue[] = [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
setState({ status: 'scanning', progress: `AI スキャン中... (${i + 1}/${chunks.length})` });
|
||||
try {
|
||||
const issues = await callGemini(geminiKey, chunks[i]);
|
||||
allIssues.push(...issues);
|
||||
} catch (e) {
|
||||
console.error('Gemini error', e);
|
||||
}
|
||||
}
|
||||
|
||||
setState({ status: 'results', issues: allIssues, rootDir, files: targets });
|
||||
};
|
||||
|
||||
const handleApplyFix = async (issue: ScanIssue, rootDir: string) => {
|
||||
if (!issue.fix) return;
|
||||
const confirmed = window.confirm(
|
||||
`以下の修正を適用しますか?\n\nファイル: ${issue.file}\n修正内容: ${issue.fix.slice(0, 200)}`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const absPath = `${rootDir}/${issue.file}`.replace(/\\/g, '/');
|
||||
const content: string = await invoke('read_file', { absPath });
|
||||
// 簡易パッチ: fix の内容をコメントとしてファイル先頭に追記(実際の差分適用は後続で)
|
||||
const patched = `// [Guard fix] ${issue.title}\n// ${issue.fix}\n` + content;
|
||||
await invoke('write_file', { absPath, content: patched });
|
||||
alert(`適用しました: ${issue.file}`);
|
||||
} catch (e) {
|
||||
alert(`エラー: ${String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitDiff = async (rootDir: string) => {
|
||||
try {
|
||||
const diff: string = await invoke('git_command', { cwd: rootDir, args: ['diff', '--stat'] });
|
||||
alert(diff || '変更なし');
|
||||
} catch (e) {
|
||||
alert(`git error: ${String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
height: 52, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 16px', borderBottom: '1px solid var(--border)',
|
||||
background: 'rgba(12,18,33,0.9)', backdropFilter: 'blur(12px)',
|
||||
position: 'sticky', top: 0, zIndex: 100,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--accent)' }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.02em' }}>posimai guard</span>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--accent)',
|
||||
background: 'var(--accent-dim)', padding: '1px 8px', borderRadius: 99,
|
||||
}}>desktop</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{state.status === 'results' && (
|
||||
<button
|
||||
onClick={() => handleGitDiff(state.rootDir)}
|
||||
style={btnStyle}
|
||||
>
|
||||
git diff
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowKeyInput(v => !v)} style={btnStyle}>
|
||||
API キー
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* API Key input (collapsible) */}
|
||||
{showKeyInput && (
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--surface)', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Gemini API キー (AIza...)"
|
||||
value={geminiKey}
|
||||
onChange={e => {
|
||||
setGeminiKey(e.target.value);
|
||||
localStorage.setItem('guard-gemini-key', e.target.value);
|
||||
}}
|
||||
style={{ flex: 1, background: 'var(--surface2)', border: '1px solid var(--border)', borderRadius: 8, padding: '6px 12px', color: 'var(--text)', fontSize: 13, outline: 'none' }}
|
||||
/>
|
||||
<button onClick={() => setShowKeyInput(false)} style={{ ...btnStyle, color: 'var(--ok)' }}>保存</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main */}
|
||||
<main style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{state.status === 'idle' && (
|
||||
<IdleView onSelect={handleSelectFolder} />
|
||||
)}
|
||||
{state.status === 'scanning' && (
|
||||
<ScanningView progress={state.progress} />
|
||||
)}
|
||||
{state.status === 'results' && (
|
||||
<ResultsView
|
||||
issues={state.issues}
|
||||
rootDir={state.rootDir}
|
||||
onApplyFix={handleApplyFix}
|
||||
onRescan={() => runScan(state.rootDir)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub views ────────────────────────────────────────────────────────────────
|
||||
function IdleView({ onSelect }: { onSelect: () => void }) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20, padding: 40 }}>
|
||||
<div style={{ fontSize: 40 }}>🛡</div>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.03em', textAlign: 'center', lineHeight: 1.3 }}>
|
||||
AI生成コードのリスクを<br />瞬時に発見
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', textAlign: 'center', maxWidth: 380, lineHeight: 1.7 }}>
|
||||
スキャンするプロジェクトフォルダを選択してください。ファイルはローカルで処理され、コード内容はGemini APIにのみ送信されます。
|
||||
</p>
|
||||
<button
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
padding: '12px 28px', borderRadius: 'var(--radius)', border: 'none',
|
||||
background: 'var(--accent)', color: '#fff', fontSize: 14, fontWeight: 600,
|
||||
cursor: 'pointer', letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
フォルダを選択
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanningView({ progress }: { progress: string }) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div style={{ width: 36, height: 36, border: '3px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)' }}>{progress}</p>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsView({
|
||||
issues, rootDir, onApplyFix, onRescan,
|
||||
}: {
|
||||
issues: ScanIssue[];
|
||||
rootDir: string;
|
||||
onApplyFix: (issue: ScanIssue, rootDir: string) => void;
|
||||
onRescan: () => void;
|
||||
}) {
|
||||
const danger = issues.filter(i => i.severity === 'danger').length;
|
||||
const warning = issues.filter(i => i.severity === 'warning').length;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, width: '100%', margin: '0 auto', padding: '28px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{danger > 0 && <Chip count={danger} label="危険" color="var(--danger)" dim="var(--danger-dim)" />}
|
||||
{warning > 0 && <Chip count={warning} label="注意" color="var(--warning)" dim="var(--warning-dim)" />}
|
||||
{issues.length === 0 && <span style={{ fontSize: 13, color: 'var(--ok)' }}>問題は見つかりませんでした</span>}
|
||||
</div>
|
||||
<button onClick={onRescan} style={btnStyle}>再スキャン</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{issues.map((issue, idx) => (
|
||||
<IssueCard key={idx} issue={issue} onApplyFix={() => onApplyFix(issue, rootDir)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueCard({ issue, onApplyFix }: { issue: ScanIssue; onApplyFix: () => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const color = issue.severity === 'danger' ? 'var(--danger)' : issue.severity === 'warning' ? 'var(--warning)' : 'var(--info)';
|
||||
const dim = issue.severity === 'danger' ? 'var(--danger-dim)' : issue.severity === 'warning' ? 'var(--warning-dim)' : 'var(--info-dim)';
|
||||
const label = issue.severity === 'danger' ? '危険' : issue.severity === 'warning' ? '注意' : '改善';
|
||||
|
||||
const copyPrompt = async () => {
|
||||
const loc = issue.line ? `${issue.file}(${issue.line}行目付近)` : issue.file;
|
||||
const text = [
|
||||
'以下のセキュリティ問題を修正してください。',
|
||||
'',
|
||||
`ファイル: ${loc}`,
|
||||
`問題: ${issue.title}`,
|
||||
`詳細: ${issue.description}`,
|
||||
...(issue.fix ? [`修正案: ${issue.fix}`] : []),
|
||||
'',
|
||||
'該当箇所のコードを貼り付けて、上記の問題を安全に修正してください。',
|
||||
].join('\n');
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: 'rgba(17,24,39,0.7)', border: `1px solid var(--border)`, borderLeft: `2px solid ${color}`, borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
style={{ width: '100%', padding: '12px 14px', display: 'flex', alignItems: 'flex-start', gap: 10, background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left' }}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 2 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{issue.title}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color, background: dim, padding: '1px 7px', borderRadius: 99, letterSpacing: '0.06em' }}>{label}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text3)', fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{issue.file}{issue.line ? `:${issue.line}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ color: 'var(--text3)', fontSize: 13 }}>{expanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.7 }}>{issue.description}</p>
|
||||
{issue.fix && (
|
||||
<pre style={{ background: 'var(--surface2)', border: '1px solid var(--border)', borderRadius: 8, padding: '10px 12px', fontSize: 12, color: 'var(--text)', overflowX: 'auto', lineHeight: 1.6, fontFamily: "'JetBrains Mono', monospace", whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{issue.fix}
|
||||
</pre>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button onClick={copyPrompt} style={btnStyle}>
|
||||
{copied ? '✓ コピー済み' : 'AIプロンプトをコピー'}
|
||||
</button>
|
||||
{issue.fix && (
|
||||
<button
|
||||
onClick={onApplyFix}
|
||||
style={{ ...btnStyle, color: 'var(--ok)', borderColor: 'rgba(50,215,75,0.3)' }}
|
||||
>
|
||||
ファイルに適用
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ count, label, color, dim }: { count: number; label: string; color: string; dim: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 99, background: dim }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color }}>{count}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ───────────────────────────────────────────────────────────────────
|
||||
const btnStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px solid var(--border)', background: 'transparent',
|
||||
color: 'var(--text2)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||
};
|
||||
|
||||
// ── Gemini API call ──────────────────────────────────────────────────────────
|
||||
async function callGemini(apiKey: string, codeBlock: string): Promise<ScanIssue[]> {
|
||||
const SYSTEM = `あなたはセキュリティ専門家です。提供されたコードを解析し、セキュリティリスクを JSON 配列で返してください。
|
||||
各要素: { severity: "danger"|"warning"|"info", title: string, description: string, file: string, line: number|null, fix: string|null }
|
||||
問題がなければ空配列 [] を返してください。JSON のみ返し、説明文は不要です。`;
|
||||
|
||||
const resp = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
system_instruction: { parts: [{ text: SYSTEM }] },
|
||||
contents: [{ role: 'user', parts: [{ text: `以下のコードを解析してください:\n${codeBlock}` }] }],
|
||||
generationConfig: { temperature: 0.1, responseMimeType: 'application/json' },
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!resp.ok) throw new Error(`Gemini ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '[]';
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0C1221;
|
||||
--surface: #111827;
|
||||
--surface2: #1A2332;
|
||||
--border: #1F2D40;
|
||||
--text: #F3F4F6;
|
||||
--text2: #9CA3AF;
|
||||
--text3: #4B5563;
|
||||
--accent: #F97316;
|
||||
--accent-dim: rgba(249,115,22,0.10);
|
||||
--accent-border: rgba(249,115,22,0.28);
|
||||
--danger: #FF453A;
|
||||
--danger-dim: rgba(255,69,58,0.10);
|
||||
--warning: #F97316;
|
||||
--warning-dim: rgba(249,115,22,0.10);
|
||||
--info: #A78BFA;
|
||||
--info-dim: rgba(167,139,250,0.10);
|
||||
--ok: #32D74B;
|
||||
--ok-dim: rgba(50,215,75,0.10);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* grid lines */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.028) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.028) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#root { position: relative; z-index: 1; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// Tauri expects a fixed port during dev
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
target: ['es2021', 'chrome105', 'safari15'],
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.vscode/**
|
||||
node_modules/**
|
||||
src/**
|
||||
tsconfig.json
|
||||
**/*.map
|
||||
**/*.ts
|
||||
!dist/**
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 posimai
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"name": "posimai-guard",
|
||||
"displayName": "Guard — AIコードセキュリティスキャナー",
|
||||
"description": "AIが生成したコードのセキュリティリスクをワークスペース内で直接検出します",
|
||||
"version": "0.1.0",
|
||||
"publisher": "posimai",
|
||||
"engines": { "vscode": "^1.85.0" },
|
||||
"categories": ["Linters", "Other"],
|
||||
"icon": "media/icon.png",
|
||||
"license": "MIT",
|
||||
"repository": { "type": "git", "url": "https://github.com/posimai/posimai-guard-ext" },
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": [],
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "guard.scanWorkspace",
|
||||
"title": "Guard: ワークスペースをスキャン",
|
||||
"icon": "$(shield)"
|
||||
},
|
||||
{
|
||||
"command": "guard.scanFile",
|
||||
"title": "Guard: このファイルをスキャン"
|
||||
},
|
||||
{
|
||||
"command": "guard.setApiKeys",
|
||||
"title": "Guard: APIキーを設定"
|
||||
},
|
||||
{
|
||||
"command": "guard.clearDiagnostics",
|
||||
"title": "Guard: 診断をクリア"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "guard.scanFile",
|
||||
"group": "navigation"
|
||||
}
|
||||
],
|
||||
"commandPalette": [
|
||||
{ "command": "guard.scanWorkspace" },
|
||||
{ "command": "guard.scanFile" },
|
||||
{ "command": "guard.setApiKeys" },
|
||||
{ "command": "guard.clearDiagnostics" }
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Guard",
|
||||
"properties": {
|
||||
"guard.excludeDirs": {
|
||||
"type": "array",
|
||||
"default": ["node_modules", ".next", ".git", "dist", "build", "out", ".turbo", ".cache", "coverage", "__pycache__", ".venv", "venv"],
|
||||
"description": "スキャンから除外するディレクトリ名"
|
||||
},
|
||||
"guard.maxFiles": {
|
||||
"type": "number",
|
||||
"default": 80,
|
||||
"description": "一度にスキャンする最大ファイル数"
|
||||
},
|
||||
"guard.model": {
|
||||
"type": "string",
|
||||
"enum": ["gemini", "claude", "both"],
|
||||
"default": "gemini",
|
||||
"description": "使用するAIモデル(bothは両方使って精度向上)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --platform=node --target=node18 --sourcemap",
|
||||
"watch": "npm run build -- --watch",
|
||||
"package": "vsce package --no-dependencies",
|
||||
"vscode:prepublish": "npm run build -- --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@vscode/vsce": "^2.24.0",
|
||||
"esbuild": "^0.20.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { readSingleFile } from '../scanner/fileReader';
|
||||
import { scanWithGemini } from '../scanner/geminiClient';
|
||||
import { scanWithClaude } from '../scanner/claudeClient';
|
||||
import { issuesToDiagnostics } from '../ui/diagnostics';
|
||||
import { ResultsPanel } from '../ui/resultsPanel';
|
||||
import { ScanIssue } from '../scanner/prompt';
|
||||
|
||||
export async function scanFile(
|
||||
context: vscode.ExtensionContext,
|
||||
collection: vscode.DiagnosticCollection,
|
||||
): Promise<void> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage('Guard: アクティブなエディタがありません');
|
||||
return;
|
||||
}
|
||||
|
||||
const geminiKey = await context.secrets.get('guard.geminiKey');
|
||||
if (!geminiKey) {
|
||||
const action = await vscode.window.showErrorMessage(
|
||||
'Guard: Gemini API キーが未設定です',
|
||||
'APIキーを設定',
|
||||
);
|
||||
if (action === 'APIキーを設定') {
|
||||
await vscode.commands.executeCommand('guard.setApiKeys');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration('guard');
|
||||
const model: string = config.get('model') ?? 'gemini';
|
||||
|
||||
const files = readSingleFile(editor.document);
|
||||
const fileName = files[0].name;
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: `Guard: ${fileName} をスキャン中...`,
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
let issues: ScanIssue[] = [];
|
||||
|
||||
try {
|
||||
issues = await scanWithGemini(files, geminiKey);
|
||||
} catch (err) {
|
||||
vscode.window.showErrorMessage(`Guard: Gemini スキャンに失敗しました: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (model === 'claude' || model === 'both') {
|
||||
const claudeKey = await context.secrets.get('guard.claudeKey');
|
||||
if (claudeKey) {
|
||||
try {
|
||||
const claudeIssues = await scanWithClaude(files, claudeKey);
|
||||
const seen = new Set(issues.map(i => `${i.file}::${i.title}`));
|
||||
for (const ci of claudeIssues) {
|
||||
if (!seen.has(`${ci.file}::${ci.title}`)) {
|
||||
issues.push(ci);
|
||||
seen.add(`${ci.file}::${ci.title}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// optional
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issuesToDiagnostics(issues, collection);
|
||||
ResultsPanel.show(context, issues, fileName);
|
||||
|
||||
const danger = issues.filter(i => i.severity === 'danger').length;
|
||||
const warning = issues.filter(i => i.severity === 'warning').length;
|
||||
const msg = `Guard: ${issues.length} 件検出 (危険 ${danger} / 警告 ${warning})`;
|
||||
|
||||
if (danger > 0) {
|
||||
vscode.window.showErrorMessage(msg);
|
||||
} else if (warning > 0) {
|
||||
vscode.window.showWarningMessage(msg);
|
||||
} else {
|
||||
vscode.window.showInformationMessage(msg);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { readWorkspaceFiles } from '../scanner/fileReader';
|
||||
import { scanWithGemini } from '../scanner/geminiClient';
|
||||
import { scanWithClaude } from '../scanner/claudeClient';
|
||||
import { issuesToDiagnostics } from '../ui/diagnostics';
|
||||
import { ResultsPanel } from '../ui/resultsPanel';
|
||||
import { ScanIssue } from '../scanner/prompt';
|
||||
|
||||
export async function scanWorkspace(
|
||||
context: vscode.ExtensionContext,
|
||||
collection: vscode.DiagnosticCollection,
|
||||
): Promise<void> {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) {
|
||||
vscode.window.showWarningMessage('Guard: ワークスペースが開かれていません');
|
||||
return;
|
||||
}
|
||||
|
||||
const geminiKey = await context.secrets.get('guard.geminiKey');
|
||||
if (!geminiKey) {
|
||||
const action = await vscode.window.showErrorMessage(
|
||||
'Guard: Gemini API キーが未設定です',
|
||||
'APIキーを設定',
|
||||
);
|
||||
if (action === 'APIキーを設定') {
|
||||
await vscode.commands.executeCommand('guard.setApiKeys');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration('guard');
|
||||
const maxFiles: number = config.get('maxFiles') ?? 80;
|
||||
const model: string = config.get('model') ?? 'gemini';
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'Guard: スキャン中...',
|
||||
cancellable: false,
|
||||
},
|
||||
async progress => {
|
||||
progress.report({ message: 'ファイルを読み込んでいます...' });
|
||||
const files = readWorkspaceFiles(maxFiles);
|
||||
|
||||
if (files.length === 0) {
|
||||
vscode.window.showInformationMessage('Guard: スキャン対象のファイルが見つかりませんでした');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceName = folders[0].name;
|
||||
let issues: ScanIssue[] = [];
|
||||
|
||||
try {
|
||||
progress.report({ message: `Gemini でスキャン中... (${files.length} ファイル)` });
|
||||
issues = await scanWithGemini(files, geminiKey);
|
||||
} catch (err) {
|
||||
vscode.window.showErrorMessage(`Guard: Gemini スキャンに失敗しました: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (model === 'claude' || model === 'both') {
|
||||
const claudeKey = await context.secrets.get('guard.claudeKey');
|
||||
if (claudeKey) {
|
||||
try {
|
||||
progress.report({ message: 'Claude でスキャン中...' });
|
||||
const claudeIssues = await scanWithClaude(files, claudeKey);
|
||||
// Merge: dedup by file + title
|
||||
const seen = new Set(issues.map(i => `${i.file}::${i.title}`));
|
||||
for (const ci of claudeIssues) {
|
||||
if (!seen.has(`${ci.file}::${ci.title}`)) {
|
||||
issues.push(ci);
|
||||
seen.add(`${ci.file}::${ci.title}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Claude is optional — silent fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issuesToDiagnostics(issues, collection);
|
||||
ResultsPanel.show(context, issues, sourceName);
|
||||
|
||||
const danger = issues.filter(i => i.severity === 'danger').length;
|
||||
const warning = issues.filter(i => i.severity === 'warning').length;
|
||||
const msg = `Guard: ${issues.length} 件検出 (危険 ${danger} / 警告 ${warning})`;
|
||||
|
||||
if (danger > 0) {
|
||||
vscode.window.showErrorMessage(msg);
|
||||
} else if (warning > 0) {
|
||||
vscode.window.showWarningMessage(msg);
|
||||
} else {
|
||||
vscode.window.showInformationMessage(msg);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { scanWorkspace } from './commands/scanWorkspace';
|
||||
import { scanFile } from './commands/scanFile';
|
||||
|
||||
let diagnosticCollection: vscode.DiagnosticCollection;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
diagnosticCollection = vscode.languages.createDiagnosticCollection('guard');
|
||||
context.subscriptions.push(diagnosticCollection);
|
||||
|
||||
// Status bar item
|
||||
const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||
statusBar.text = '$(shield) Guard';
|
||||
statusBar.tooltip = 'Guard: ワークスペースをスキャン';
|
||||
statusBar.command = 'guard.scanWorkspace';
|
||||
statusBar.show();
|
||||
context.subscriptions.push(statusBar);
|
||||
|
||||
// Commands
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('guard.scanWorkspace', () =>
|
||||
scanWorkspace(context, diagnosticCollection),
|
||||
),
|
||||
|
||||
vscode.commands.registerCommand('guard.scanFile', () =>
|
||||
scanFile(context, diagnosticCollection),
|
||||
),
|
||||
|
||||
vscode.commands.registerCommand('guard.setApiKeys', async () => {
|
||||
const geminiKey = await vscode.window.showInputBox({
|
||||
title: 'Guard: Gemini API キー',
|
||||
prompt: 'Google AI Studio で取得した Gemini API キーを入力してください',
|
||||
password: true,
|
||||
placeHolder: 'AIza...',
|
||||
ignoreFocusOut: true,
|
||||
});
|
||||
|
||||
if (geminiKey !== undefined) {
|
||||
if (geminiKey.trim()) {
|
||||
await context.secrets.store('guard.geminiKey', geminiKey.trim());
|
||||
vscode.window.showInformationMessage('Guard: Gemini API キーを保存しました');
|
||||
} else {
|
||||
await context.secrets.delete('guard.geminiKey');
|
||||
vscode.window.showInformationMessage('Guard: Gemini API キーを削除しました');
|
||||
}
|
||||
}
|
||||
|
||||
const claudeKey = await vscode.window.showInputBox({
|
||||
title: 'Guard: Anthropic API キー(任意)',
|
||||
prompt: '設定するとGeminiに加えてClaudeでもスキャンし、見落とし率が下がります(任意)',
|
||||
password: true,
|
||||
placeHolder: 'sk-ant-...',
|
||||
ignoreFocusOut: true,
|
||||
});
|
||||
|
||||
if (claudeKey !== undefined) {
|
||||
if (claudeKey.trim()) {
|
||||
await context.secrets.store('guard.claudeKey', claudeKey.trim());
|
||||
vscode.window.showInformationMessage('Guard: Anthropic API キーを保存しました');
|
||||
} else {
|
||||
await context.secrets.delete('guard.claudeKey');
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('guard.clearDiagnostics', () => {
|
||||
diagnosticCollection.clear();
|
||||
vscode.window.showInformationMessage('Guard: 診断をクリアしました');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
diagnosticCollection?.dispose();
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import * as https from 'https';
|
||||
import { SYSTEM_PROMPT, buildFileContext, parseIssues, ScanIssue } from './prompt';
|
||||
|
||||
const MODEL = 'claude-sonnet-4-6';
|
||||
|
||||
function httpsPost(url: string, body: string, headers: Record<string, string>): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
};
|
||||
const req = https.request(options, res => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', d => chunks.push(d));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf-8');
|
||||
if (res.statusCode === 401) {
|
||||
reject(new Error('INVALID_KEY'));
|
||||
} else if (res.statusCode && res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
|
||||
} else {
|
||||
resolve(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function scanWithClaude(
|
||||
files: { name: string; content: string }[],
|
||||
apiKey: string,
|
||||
): Promise<ScanIssue[]> {
|
||||
const fileContext = buildFileContext(files);
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: MODEL,
|
||||
max_tokens: 4096,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: fileContext }],
|
||||
});
|
||||
|
||||
const raw = await httpsPost('https://api.anthropic.com/v1/messages', body, {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
});
|
||||
|
||||
const json = JSON.parse(raw);
|
||||
const text: string = json?.content?.[0]?.text ?? '{"issues":[]}';
|
||||
return parseIssues(text);
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export type ScannableFile = { name: string; content: string };
|
||||
|
||||
const SCAN_EXTENSIONS = new Set([
|
||||
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
||||
'.py', '.go', '.rs', '.php', '.rb', '.java', '.kt', '.swift',
|
||||
'.json', '.yaml', '.yml', '.toml', '.env',
|
||||
'.sh', '.bash', '.zsh',
|
||||
'.dockerfile', '.conf', '.nginx',
|
||||
]);
|
||||
|
||||
const EXCLUDE_FILES = new Set([
|
||||
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb',
|
||||
'composer.lock', 'Cargo.lock', 'poetry.lock', 'Pipfile.lock',
|
||||
]);
|
||||
|
||||
const EXACT_NAMES = new Set([
|
||||
'Dockerfile', 'dockerfile', '.gitignore', '.dockerignore',
|
||||
'Makefile', 'Procfile', 'nginx.conf', 'Caddyfile',
|
||||
]);
|
||||
|
||||
const MAX_FILE_SIZE = 100_000;
|
||||
const MAX_BUCKET = 320;
|
||||
|
||||
function shouldInclude(fileName: string): boolean {
|
||||
if (EXCLUDE_FILES.has(fileName)) return false;
|
||||
if (EXACT_NAMES.has(fileName)) return true;
|
||||
if (fileName.startsWith('.env')) return true;
|
||||
if (fileName.startsWith('docker-compose')) return true;
|
||||
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase();
|
||||
return SCAN_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function scanPriority(relativePath: string): number {
|
||||
const p = relativePath.replace(/\\/g, '/').toLowerCase();
|
||||
if (p.includes('route.ts') || p.includes('route.js')) return 100;
|
||||
if (p.includes('middleware.ts') || p.includes('middleware.js')) return 95;
|
||||
if (p.includes('next.config')) return 92;
|
||||
if (p.includes('/.env') || p.endsWith('.env') || p.includes('.env.')) return 88;
|
||||
if (p.includes('docker-compose') || p.endsWith('/dockerfile')) return 82;
|
||||
if (p.includes('vercel.json')) return 78;
|
||||
if (p.includes('/api/')) return 72;
|
||||
if (p.includes('server.ts') || p.includes('server.js')) return 68;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function collectFiles(
|
||||
dir: string,
|
||||
root: string,
|
||||
excludeDirs: Set<string>,
|
||||
results: ScannableFile[],
|
||||
): void {
|
||||
if (results.length >= MAX_BUCKET) return;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (results.length >= MAX_BUCKET) break;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (excludeDirs.has(entry.name)) continue;
|
||||
collectFiles(path.join(dir, entry.name), root, excludeDirs, results);
|
||||
} else if (entry.isFile()) {
|
||||
if (!shouldInclude(entry.name)) continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.size > MAX_FILE_SIZE) continue;
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const relativePath = path.relative(root, fullPath).replace(/\\/g, '/');
|
||||
results.push({ name: relativePath, content });
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorkspaceFiles(maxFiles: number): ScannableFile[] {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return [];
|
||||
|
||||
const config = vscode.workspace.getConfiguration('guard');
|
||||
const excludeDirsArr: string[] = config.get('excludeDirs') ?? [
|
||||
'node_modules', '.next', '.git', 'dist', 'build', 'out',
|
||||
'.turbo', '.cache', 'coverage', '__pycache__', '.venv', 'venv',
|
||||
];
|
||||
const excludeDirs = new Set(excludeDirsArr);
|
||||
|
||||
const all: ScannableFile[] = [];
|
||||
for (const folder of folders) {
|
||||
collectFiles(folder.uri.fsPath, folder.uri.fsPath, excludeDirs, all);
|
||||
if (all.length >= MAX_BUCKET) break;
|
||||
}
|
||||
|
||||
const sorted = [...all].sort((a, b) => {
|
||||
const d = scanPriority(b.name) - scanPriority(a.name);
|
||||
return d !== 0 ? d : a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return sorted.slice(0, maxFiles);
|
||||
}
|
||||
|
||||
export function readSingleFile(document: vscode.TextDocument): ScannableFile[] {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
const root = folders?.[0]?.uri.fsPath ?? '';
|
||||
const relativePath = root
|
||||
? path.relative(root, document.uri.fsPath).replace(/\\/g, '/')
|
||||
: path.basename(document.uri.fsPath);
|
||||
return [{ name: relativePath, content: document.getText() }];
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import * as https from 'https';
|
||||
import { buildFileContext, parseIssues, ScanIssue } from './prompt';
|
||||
|
||||
const MODEL = 'gemini-2.5-flash';
|
||||
const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
|
||||
|
||||
const RESPONSE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
issues: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
severity: { type: 'string', enum: ['danger', 'warning', 'info'] },
|
||||
title: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
file: { type: 'string' },
|
||||
line: { type: 'integer', nullable: true },
|
||||
fix: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['severity', 'title', 'description', 'file', 'line', 'fix'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['issues'],
|
||||
};
|
||||
|
||||
function httpsPost(url: string, body: string, headers: Record<string, string>): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
};
|
||||
const req = https.request(options, res => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', d => chunks.push(d));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf-8');
|
||||
if (res.statusCode && res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
|
||||
} else {
|
||||
resolve(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function scanWithGemini(
|
||||
files: { name: string; content: string }[],
|
||||
apiKey: string,
|
||||
): Promise<ScanIssue[]> {
|
||||
const { SYSTEM_PROMPT } = await import('./prompt');
|
||||
const fileContext = buildFileContext(files);
|
||||
|
||||
const body = JSON.stringify({
|
||||
system_instruction: { parts: [{ text: SYSTEM_PROMPT }] },
|
||||
contents: [{ role: 'user', parts: [{ text: fileContext }] }],
|
||||
generationConfig: {
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: RESPONSE_SCHEMA,
|
||||
temperature: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
const raw = await httpsPost(`${API_URL}?key=${apiKey}`, body, {});
|
||||
const json = JSON.parse(raw);
|
||||
const text: string = json?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{"issues":[]}';
|
||||
return parseIssues(text);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
export const SYSTEM_PROMPT = `あなたはAI生成コードのセキュリティとコード品質の専門家です。
|
||||
提供されたファイル群を横断的に解析し、AI生成コードに特有のリスク・設定ミス・外部連携の穴を発見してください。
|
||||
|
||||
【A: コード品質・セキュリティ(ソースコード全般)】
|
||||
1. ハードコードされたシークレット(APIキー・パスワード・トークン・認証情報)
|
||||
2. 文字列結合によるSQLインジェクションリスク
|
||||
3. 過度に許可されたCORS(Access-Control-Allow-Origin: * など)
|
||||
4. 認証チェックの欠如・TODOプレースホルダーで認証をスキップしている箇所
|
||||
5. APIエンドポイントのレートリミット未実装
|
||||
6. target="_blank" に rel="noopener" がない
|
||||
7. エラーレスポンスでの情報漏洩(error.message をそのままクライアントに返す等)
|
||||
8. 本番コードにハードコードされた localhost・開発用URL
|
||||
9. 入力バリデーションの欠如(ユーザー入力をSQLや外部APIにそのまま渡す箇所)
|
||||
10. 依存パッケージの問題(package.json: 既知の脆弱バージョン・@latest固定なし)
|
||||
|
||||
【B: 設定ファイル・インフラ(vercel.json / next.config.*)】
|
||||
11. セキュリティヘッダー未設定(X-Frame-Options・X-Content-Type-Options・CSP の欠落)
|
||||
12. next.config.* の危険設定(dangerouslyAllowBrowser・ignoreBuildErrors 等)
|
||||
13. 過度に広い rewrites/redirects
|
||||
|
||||
【C: コンテナ・CI/CD(docker-compose / Dockerfile / .github/workflows)】
|
||||
14. docker-compose.yml の問題(ポートのホスト公開・root実行・hardcodedパスワード)
|
||||
15. Dockerfile のセキュリティ問題(rootで実行・latest タグ)
|
||||
16. GitHub Actions の問題(シークレットのログ出力・過剰な permissions)
|
||||
|
||||
【D: 認証・セッション設計】
|
||||
17. JWT をlocalStorage に保存(XSS リスク)
|
||||
18. セッショントークンの有効期限未設定
|
||||
19. 認証ミドルウェアの保護漏れ
|
||||
20. パスワードを bcrypt/argon2 以外でハッシュ化
|
||||
|
||||
【E: ファイルアップロード・外部リクエスト】
|
||||
21. ファイルアップロードのバリデーション未実施
|
||||
22. アップロードファイルを実行可能ディレクトリに保存
|
||||
23. SSRF: ユーザー指定URLを検証なしでfetch
|
||||
24. XML/SVG/HTMLファイルのアップロード許可
|
||||
|
||||
【F: 環境変数・シークレット管理】
|
||||
25. .env ファイルがリポジトリに含まれている
|
||||
26. NEXT_PUBLIC_ でシークレットを露出
|
||||
27. 本番・開発環境で同一シークレットを使いまわし
|
||||
|
||||
【出力ルール】
|
||||
- コードブロックなし・JSONのみ・余分なテキストなし
|
||||
- severity: danger / warning / info
|
||||
- title: 日本語・25文字以内
|
||||
- description: 120文字以内・何が起きると困るかを明示
|
||||
- fix: 修正後のコードスニペット(15行以内)または具体的な対処法(なければnull)
|
||||
- file: 該当ファイル名
|
||||
- line: 該当行番号(特定できない場合はnull)
|
||||
|
||||
必ず以下の形式で返すこと:
|
||||
{"issues":[{"severity":"danger","title":"...","description":"...","file":"...","line":12,"fix":"..."}]}`;
|
||||
|
||||
const CONFIG_PATTERNS = /\.(ya?ml|toml|json|conf|nginx|dockerfile)$|^Dockerfile$|^docker-compose|^\.env|^\.gitignore|^Caddyfile/i;
|
||||
|
||||
export function buildFileContext(files: { name: string; content: string }[]): string {
|
||||
return files
|
||||
.slice(0, 50)
|
||||
.map(f => {
|
||||
const limit = CONFIG_PATTERNS.test(f.name) ? 6000 : 4000;
|
||||
return `=== ${f.name} ===\n${f.content.slice(0, limit)}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function parseIssues(raw: string): ScanIssue[] {
|
||||
try {
|
||||
const cleaned = raw.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
||||
return JSON.parse(cleaned).issues ?? [];
|
||||
} catch {
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
try { return JSON.parse(match[0]).issues ?? []; } catch { return []; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScanIssue {
|
||||
severity: 'danger' | 'warning' | 'info';
|
||||
title: string;
|
||||
description: string;
|
||||
file: string;
|
||||
line: number | null;
|
||||
fix: string | null;
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { ScanIssue } from '../scanner/prompt';
|
||||
|
||||
export function issuesToDiagnostics(
|
||||
issues: ScanIssue[],
|
||||
collection: vscode.DiagnosticCollection,
|
||||
): void {
|
||||
collection.clear();
|
||||
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
const root = folders?.[0]?.uri.fsPath ?? '';
|
||||
|
||||
// Group by file
|
||||
const byFile = new Map<string, ScanIssue[]>();
|
||||
for (const issue of issues) {
|
||||
const key = issue.file;
|
||||
if (!byFile.has(key)) byFile.set(key, []);
|
||||
byFile.get(key)!.push(issue);
|
||||
}
|
||||
|
||||
for (const [relativePath, fileIssues] of byFile) {
|
||||
const absPath = root ? path.join(root, relativePath) : relativePath;
|
||||
const uri = vscode.Uri.file(absPath);
|
||||
|
||||
const diagnostics: vscode.Diagnostic[] = fileIssues.map(issue => {
|
||||
const lineNum = issue.line != null ? Math.max(0, issue.line - 1) : 0;
|
||||
const range = new vscode.Range(lineNum, 0, lineNum, 999);
|
||||
|
||||
const severity =
|
||||
issue.severity === 'danger'
|
||||
? vscode.DiagnosticSeverity.Error
|
||||
: issue.severity === 'warning'
|
||||
? vscode.DiagnosticSeverity.Warning
|
||||
: vscode.DiagnosticSeverity.Information;
|
||||
|
||||
const diag = new vscode.Diagnostic(range, `[Guard] ${issue.title}: ${issue.description}`, severity);
|
||||
diag.source = 'Guard';
|
||||
if (issue.fix) {
|
||||
diag.code = { value: 'view-fix', target: vscode.Uri.parse('https://github.com') };
|
||||
}
|
||||
return diag;
|
||||
});
|
||||
|
||||
collection.set(uri, diagnostics);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { ScanIssue } from '../scanner/prompt';
|
||||
|
||||
export class ResultsPanel {
|
||||
private static _current: ResultsPanel | undefined;
|
||||
private readonly _panel: vscode.WebviewPanel;
|
||||
private _disposables: vscode.Disposable[] = [];
|
||||
|
||||
static show(context: vscode.ExtensionContext, issues: ScanIssue[], source: string): void {
|
||||
if (ResultsPanel._current) {
|
||||
ResultsPanel._current._panel.reveal(vscode.ViewColumn.Beside);
|
||||
ResultsPanel._current._update(issues, source);
|
||||
return;
|
||||
}
|
||||
ResultsPanel._current = new ResultsPanel(context, issues, source);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
context: vscode.ExtensionContext,
|
||||
issues: ScanIssue[],
|
||||
source: string,
|
||||
) {
|
||||
this._panel = vscode.window.createWebviewPanel(
|
||||
'guardResults',
|
||||
'Guard スキャン結果',
|
||||
vscode.ViewColumn.Beside,
|
||||
{ enableScripts: false, retainContextWhenHidden: true },
|
||||
);
|
||||
|
||||
this._panel.onDidDispose(() => {
|
||||
ResultsPanel._current = undefined;
|
||||
this._disposables.forEach(d => d.dispose());
|
||||
}, null, this._disposables);
|
||||
|
||||
this._update(issues, source);
|
||||
}
|
||||
|
||||
private _update(issues: ScanIssue[], source: string): void {
|
||||
this._panel.title = `Guard: ${source}`;
|
||||
this._panel.webview.html = buildHtml(issues, source);
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildHtml(issues: ScanIssue[], source: string): string {
|
||||
const danger = issues.filter(i => i.severity === 'danger');
|
||||
const warning = issues.filter(i => i.severity === 'warning');
|
||||
const info = issues.filter(i => i.severity === 'info');
|
||||
|
||||
const badge = (label: string, count: number, color: string) =>
|
||||
count > 0
|
||||
? `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:${color};color:#fff;margin-right:6px">${label} ${count}</span>`
|
||||
: '';
|
||||
|
||||
const issueHtml = (issue: ScanIssue) => {
|
||||
const color =
|
||||
issue.severity === 'danger' ? '#e53e3e' :
|
||||
issue.severity === 'warning' ? '#d69e2e' : '#3182ce';
|
||||
const bg =
|
||||
issue.severity === 'danger' ? '#fff5f5' :
|
||||
issue.severity === 'warning' ? '#fffff0' : '#ebf8ff';
|
||||
const fixSection = issue.fix
|
||||
? `<pre style="margin:8px 0 0;padding:10px;background:#1a1a2e;color:#e2e8f0;border-radius:4px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all">${esc(issue.fix)}</pre>`
|
||||
: '';
|
||||
const location = issue.line != null ? `${esc(issue.file)}:${issue.line}` : esc(issue.file);
|
||||
return `
|
||||
<div style="margin-bottom:10px;border:1px solid ${color}33;border-left:3px solid ${color};border-radius:4px;padding:12px 14px;background:${bg}">
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:6px">
|
||||
<span style="color:${color};font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;flex-shrink:0;padding-top:1px">${esc(issue.severity)}</span>
|
||||
<span style="font-weight:600;font-size:13px;color:#1a202c">${esc(issue.title)}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#4a5568;margin-bottom:6px;line-height:1.5">${esc(issue.description)}</div>
|
||||
<div style="font-size:11px;color:#718096;font-family:monospace">${location}</div>
|
||||
${fixSection}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const allHtml = [...danger, ...warning, ...info].map(issueHtml).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f7fafc; color: #2d3748; padding: 20px; }
|
||||
h1 { font-size: 15px; font-weight: 700; margin-bottom: 4px; color: #1a202c; }
|
||||
.meta { font-size: 11px; color: #718096; margin-bottom: 16px; }
|
||||
.summary { margin-bottom: 20px; }
|
||||
.empty { text-align: center; padding: 40px; color: #718096; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Guard スキャン結果</h1>
|
||||
<div class="meta">${esc(source)} — ${issues.length} 件の指摘</div>
|
||||
<div class="summary">
|
||||
${badge('危険', danger.length, '#e53e3e')}
|
||||
${badge('警告', warning.length, '#d69e2e')}
|
||||
${badge('情報', info.length, '#3182ce')}
|
||||
</div>
|
||||
${issues.length === 0
|
||||
? '<div class="empty">問題は検出されませんでした</div>'
|
||||
: allHtml
|
||||
}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Reference in New Issue