feat: VS Code拡張 posimai-guard-ext v0.1.0 を追加
Gemini直接呼び出し(Vercel経由なし)でAIコードセキュリティスキャンを実行。 APIキーはOS keychain(SecretStorage)に安全保存。Claudeオプション対応。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6d0df5faae
commit
9e6178791f
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
|
@ -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