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