@delegance/claude-autopilot 2.5.0 → 5.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +164 -106
- package/bin/_launcher.js +77 -0
- package/bin/claude-autopilot.js +3 -0
- package/bin/guardrail.js +3 -0
- package/package.json +15 -9
- package/presets/generic/guardrail.config.yaml +35 -0
- package/presets/generic/stack.md +40 -0
- package/presets/nextjs-supabase/{autopilot.config.yaml → guardrail.config.yaml} +7 -0
- package/scripts/autoregress.ts +27 -11
- package/skills/autopilot/SKILL.md +170 -0
- package/skills/claude-autopilot.md +80 -0
- package/skills/guardrail.md +39 -0
- package/skills/migrate/SKILL.md +83 -0
- package/src/adapters/council/claude.ts +41 -0
- package/src/adapters/council/openai.ts +40 -0
- package/src/adapters/council/types.ts +7 -0
- package/src/adapters/loader.ts +7 -7
- package/src/adapters/review-engine/auto.ts +2 -2
- package/src/adapters/review-engine/claude.ts +9 -11
- package/src/adapters/review-engine/codex.ts +9 -11
- package/src/adapters/review-engine/gemini.ts +9 -11
- package/src/adapters/review-engine/openai-compatible.ts +10 -12
- package/src/adapters/review-engine/parse-output.ts +32 -6
- package/src/adapters/review-engine/prompt-builder.ts +19 -0
- package/src/adapters/review-engine/types.ts +1 -1
- package/src/adapters/vcs-host/commit-status.ts +39 -0
- package/src/adapters/vcs-host/github.ts +2 -2
- package/src/cli/baseline.ts +125 -0
- package/src/cli/ci.ts +11 -8
- package/src/cli/costs.ts +2 -2
- package/src/cli/council.ts +96 -0
- package/src/cli/detector.ts +21 -5
- package/src/cli/explain.ts +197 -0
- package/src/cli/fix.ts +173 -111
- package/src/cli/hook.ts +72 -27
- package/src/cli/ignore-helper.ts +116 -0
- package/src/cli/index.ts +272 -31
- package/src/cli/init.ts +12 -12
- package/src/cli/lsp.ts +200 -0
- package/src/cli/mcp.ts +206 -0
- package/src/cli/pr-comment.ts +5 -5
- package/src/cli/pr-desc.ts +168 -0
- package/src/cli/pr-review-comments.ts +3 -3
- package/src/cli/pr.ts +76 -0
- package/src/cli/preflight.ts +15 -32
- package/src/cli/report.ts +186 -0
- package/src/cli/run.ts +140 -36
- package/src/cli/scan.ts +233 -0
- package/src/cli/setup.ts +121 -15
- package/src/cli/test-gen.ts +125 -0
- package/src/cli/triage.ts +137 -0
- package/src/cli/watch.ts +52 -31
- package/src/cli/worker.ts +109 -0
- package/src/core/cache/review-cache.ts +2 -2
- package/src/core/chunking/index.ts +2 -2
- package/src/core/config/loader.ts +10 -10
- package/src/core/config/preset-resolver.ts +6 -6
- package/src/core/config/schema.ts +103 -2
- package/src/core/config/types.ts +57 -2
- package/src/core/council/config.ts +71 -0
- package/src/core/council/context.ts +17 -0
- package/src/core/council/runner.ts +83 -0
- package/src/core/council/types.ts +45 -0
- package/src/core/detect/llm-key.ts +89 -0
- package/src/core/detect/workspaces.ts +103 -0
- package/src/core/errors.ts +4 -4
- package/src/core/fix/generator.ts +149 -0
- package/src/core/ignore/index.ts +4 -4
- package/src/core/mcp/concurrency.ts +16 -0
- package/src/core/mcp/handlers/fix-finding.ts +126 -0
- package/src/core/mcp/handlers/get-capabilities.ts +62 -0
- package/src/core/mcp/handlers/get-findings.ts +36 -0
- package/src/core/mcp/handlers/review-diff.ts +65 -0
- package/src/core/mcp/handlers/scan-files.ts +65 -0
- package/src/core/mcp/handlers/validate-fix.ts +41 -0
- package/src/core/mcp/run-store.ts +85 -0
- package/src/core/mcp/workspace.ts +35 -0
- package/src/core/persist/baseline.ts +112 -0
- package/src/core/persist/cost-log.ts +1 -1
- package/src/core/persist/findings-cache.ts +1 -1
- package/src/core/persist/triage.ts +112 -0
- package/src/core/phases/static-rules.ts +18 -5
- package/src/core/pipeline/review-phase.ts +65 -26
- package/src/core/pipeline/run.ts +42 -10
- package/src/core/runtime/lock.ts +2 -2
- package/src/core/runtime/state.ts +2 -2
- package/src/core/schema-alignment/detector.ts +59 -0
- package/src/core/schema-alignment/extractor/index.ts +24 -0
- package/src/core/schema-alignment/extractor/prisma.ts +21 -0
- package/src/core/schema-alignment/extractor/sql.ts +99 -0
- package/src/core/schema-alignment/llm-check.ts +91 -0
- package/src/core/schema-alignment/scanner.ts +107 -0
- package/src/core/schema-alignment/types.ts +43 -0
- package/src/core/shell.ts +3 -3
- package/src/core/static-rules/registry.ts +17 -8
- package/src/core/static-rules/rules/brand-tokens.ts +145 -0
- package/src/core/static-rules/rules/hardcoded-secrets.ts +27 -1
- package/src/core/static-rules/rules/insecure-redirect.ts +67 -0
- package/src/core/static-rules/rules/missing-auth.ts +70 -0
- package/src/core/static-rules/rules/schema-alignment.ts +132 -0
- package/src/core/static-rules/rules/sql-injection.ts +71 -0
- package/src/core/static-rules/rules/ssrf.ts +63 -0
- package/src/core/static-rules/tailwind-extractor.ts +38 -0
- package/src/core/test-gen/coverage-analyzer.ts +93 -0
- package/src/core/test-gen/framework-detector.ts +21 -0
- package/src/core/test-gen/test-writer.ts +33 -0
- package/src/core/ui/design-context-loader.ts +87 -0
- package/src/core/worker/client.ts +46 -0
- package/src/core/worker/lockfile.ts +38 -0
- package/src/core/worker/server.ts +81 -0
- package/src/formatters/junit.ts +52 -0
- package/src/formatters/sarif.ts +2 -2
- package/src/index.ts +1 -2
- package/tests/snapshots/baselines/src-formatters-sarif.json +4 -4
- package/tests/snapshots/index.json +3 -3
- package/tests/snapshots/src-formatters-sarif.snap.ts +1 -1
- package/tests/snapshots/src-snapshots-impact-selector.snap.ts +3 -3
- package/tests/snapshots/src-snapshots-import-scanner.snap.ts +3 -3
- package/tests/snapshots/src-snapshots-serializer.snap.ts +2 -2
- package/bin/autopilot.js +0 -20
- package/skills/autopilot.md +0 -157
- /package/presets/go/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/python-fastapi/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/rails-postgres/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/t3/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/{src → scripts}/snapshots/impact-selector.ts +0 -0
- /package/{src → scripts}/snapshots/import-scanner.ts +0 -0
- /package/{src → scripts}/snapshots/serializer.ts +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/core/schema-alignment/extractor/sql.ts
|
|
2
|
+
import type { SchemaEntity } from '../types.ts';
|
|
3
|
+
|
|
4
|
+
function unquote(s: string | undefined): string {
|
|
5
|
+
if (!s) return '';
|
|
6
|
+
return s.replace(/^["'`]|["'`]$/g, '');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Identifier: quoted or unquoted word (no schema prefix captured)
|
|
10
|
+
const ID = /(?:"([^"]+)"|`([^`]+)`|(\w+))/;
|
|
11
|
+
const SCHEMA_OPT = /(?:\w+\.)?/;
|
|
12
|
+
|
|
13
|
+
// Keywords that can follow ADD/DROP but are NOT columns
|
|
14
|
+
const NON_COLUMN_KEYWORDS = new Set([
|
|
15
|
+
'CONSTRAINT', 'INDEX', 'PRIMARY', 'FOREIGN', 'UNIQUE', 'CHECK',
|
|
16
|
+
'KEY', 'EXCLUDE', 'REFERENCES',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export function extractFromSql(content: string): SchemaEntity[] {
|
|
20
|
+
// Strip comments before processing
|
|
21
|
+
const normalized = content
|
|
22
|
+
.replace(/--[^\n]*/g, ' ')
|
|
23
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
24
|
+
.replace(/\s+/g, ' ');
|
|
25
|
+
|
|
26
|
+
const entities: SchemaEntity[] = [];
|
|
27
|
+
|
|
28
|
+
// CREATE TABLE [IF NOT EXISTS] [schema.]name
|
|
29
|
+
const createTableRe = new RegExp(
|
|
30
|
+
`CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${SCHEMA_OPT.source}${ID.source}`,
|
|
31
|
+
'gi',
|
|
32
|
+
);
|
|
33
|
+
for (const m of normalized.matchAll(createTableRe)) {
|
|
34
|
+
const table = unquote(m[1] ?? m[2] ?? m[3]);
|
|
35
|
+
if (table && table.toUpperCase() !== 'EXISTS') entities.push({ table, operation: 'create_table' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ALTER TABLE [schema.]name ADD [COLUMN] [IF NOT EXISTS] col
|
|
39
|
+
const addColRe = new RegExp(
|
|
40
|
+
`ALTER\\s+TABLE\\s+(?:ONLY\\s+)?${SCHEMA_OPT.source}${ID.source}\\s+ADD\\s+(?:COLUMN\\s+)?(?:IF\\s+NOT\\s+EXISTS\\s+)?${ID.source}`,
|
|
41
|
+
'gi',
|
|
42
|
+
);
|
|
43
|
+
for (const m of normalized.matchAll(addColRe)) {
|
|
44
|
+
const table = unquote(m[1] ?? m[2] ?? m[3]);
|
|
45
|
+
const column = unquote(m[4] ?? m[5] ?? m[6]);
|
|
46
|
+
if (!table || !column) continue;
|
|
47
|
+
// Skip non-column ADD operations (CONSTRAINT, INDEX, PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK)
|
|
48
|
+
if (NON_COLUMN_KEYWORDS.has(column.toUpperCase())) continue;
|
|
49
|
+
entities.push({ table, column, operation: 'add_column' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ALTER TABLE [schema.]name DROP [COLUMN] [IF EXISTS] col
|
|
53
|
+
const dropColRe = new RegExp(
|
|
54
|
+
`ALTER\\s+TABLE\\s+(?:ONLY\\s+)?${SCHEMA_OPT.source}${ID.source}\\s+DROP\\s+(?:COLUMN\\s+)?(?:IF\\s+EXISTS\\s+)?${ID.source}`,
|
|
55
|
+
'gi',
|
|
56
|
+
);
|
|
57
|
+
for (const m of normalized.matchAll(dropColRe)) {
|
|
58
|
+
const table = unquote(m[1] ?? m[2] ?? m[3]);
|
|
59
|
+
const column = unquote(m[4] ?? m[5] ?? m[6]);
|
|
60
|
+
if (!table || !column) continue;
|
|
61
|
+
// Skip non-column DROP operations (CONSTRAINT, INDEX, PRIMARY KEY, FOREIGN KEY)
|
|
62
|
+
if (NON_COLUMN_KEYWORDS.has(column.toUpperCase())) continue;
|
|
63
|
+
entities.push({ table, column, operation: 'drop_column' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ALTER TABLE [schema.]name RENAME [COLUMN] old TO new
|
|
67
|
+
const renameColRe = new RegExp(
|
|
68
|
+
`ALTER\\s+TABLE\\s+(?:ONLY\\s+)?${SCHEMA_OPT.source}${ID.source}\\s+RENAME\\s+(?:COLUMN\\s+)?${ID.source}\\s+TO\\s+${ID.source}`,
|
|
69
|
+
'gi',
|
|
70
|
+
);
|
|
71
|
+
for (const m of normalized.matchAll(renameColRe)) {
|
|
72
|
+
const table = unquote(m[1] ?? m[2] ?? m[3]);
|
|
73
|
+
const oldName = unquote(m[4] ?? m[5] ?? m[6]);
|
|
74
|
+
const column = unquote(m[7] ?? m[8] ?? m[9]);
|
|
75
|
+
if (table && column) entities.push({ table, column, operation: 'rename_column', oldName });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// CREATE TYPE [schema.]name
|
|
79
|
+
const createTypeRe = new RegExp(
|
|
80
|
+
`CREATE\\s+TYPE\\s+${SCHEMA_OPT.source}${ID.source}`,
|
|
81
|
+
'gi',
|
|
82
|
+
);
|
|
83
|
+
for (const m of normalized.matchAll(createTypeRe)) {
|
|
84
|
+
const table = unquote(m[1] ?? m[2] ?? m[3]);
|
|
85
|
+
if (table) entities.push({ table, operation: 'create_type' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ALTER TYPE [schema.]name ADD VALUE ...
|
|
89
|
+
const alterTypeRe = new RegExp(
|
|
90
|
+
`ALTER\\s+TYPE\\s+${SCHEMA_OPT.source}${ID.source}\\s+ADD\\s+VALUE`,
|
|
91
|
+
'gi',
|
|
92
|
+
);
|
|
93
|
+
for (const m of normalized.matchAll(alterTypeRe)) {
|
|
94
|
+
const table = unquote(m[1] ?? m[2] ?? m[3]);
|
|
95
|
+
if (table) entities.push({ table, operation: 'create_type' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return entities;
|
|
99
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/core/schema-alignment/llm-check.ts
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
|
|
5
|
+
import type { SchemaEntity, LayerScanResult, AlignmentFinding } from './types.ts';
|
|
6
|
+
|
|
7
|
+
const TOTAL_CHAR_BUDGET = 6000;
|
|
8
|
+
|
|
9
|
+
function truncateTop(text: string, maxChars: number): string {
|
|
10
|
+
if (text.length <= maxChars) return text;
|
|
11
|
+
const dropped = text.length - maxChars;
|
|
12
|
+
return `<!-- [schema-alignment: truncated ${dropped} chars] -->\n` + text.slice(dropped);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runLlmCheck(
|
|
16
|
+
migrationFiles: string[],
|
|
17
|
+
gapResults: LayerScanResult[],
|
|
18
|
+
engine: ReviewEngine,
|
|
19
|
+
): Promise<AlignmentFinding[]> {
|
|
20
|
+
let budget = TOTAL_CHAR_BUDGET;
|
|
21
|
+
const migrationSnippets: string[] = [];
|
|
22
|
+
|
|
23
|
+
for (const f of migrationFiles) {
|
|
24
|
+
if (budget <= 0) break;
|
|
25
|
+
let content: string;
|
|
26
|
+
try { content = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
27
|
+
const snippet = truncateTop(content, Math.floor(budget * 0.6));
|
|
28
|
+
migrationSnippets.push(`### Migration: ${path.basename(f)}\n\`\`\`sql\n${snippet}\n\`\`\``);
|
|
29
|
+
budget -= snippet.length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const entitySummary = gapResults.map(r => {
|
|
33
|
+
const isDestructive = r.entity.operation === 'drop_column' || r.entity.operation === 'rename_column';
|
|
34
|
+
const gaps = isDestructive
|
|
35
|
+
? [r.typeLayer, r.apiLayer, r.uiLayer]
|
|
36
|
+
.map((e, i) => e !== null ? (['type', 'api', 'ui'][i]) : null)
|
|
37
|
+
.filter(Boolean).join(', ')
|
|
38
|
+
: [r.typeLayer === null ? 'type' : null, r.apiLayer === null ? 'api' : null, r.uiLayer === null ? 'ui' : null]
|
|
39
|
+
.filter(Boolean).join(', ');
|
|
40
|
+
return `- ${r.entity.operation} ${r.entity.table}${r.entity.column ? '.' + r.entity.column : ''}: ${isDestructive ? 'stale ref in' : 'missing in'} [${gaps}]`;
|
|
41
|
+
}).join('\n');
|
|
42
|
+
|
|
43
|
+
const prompt = [
|
|
44
|
+
'You are reviewing schema-layer alignment for a software project.',
|
|
45
|
+
'',
|
|
46
|
+
migrationSnippets.length > 0
|
|
47
|
+
? `The following migration files were changed:\n\n${migrationSnippets.join('\n\n')}`
|
|
48
|
+
: '(no readable migration files)',
|
|
49
|
+
'',
|
|
50
|
+
`The structural scan found these potential alignment gaps:\n${entitySummary || '(none)'}`,
|
|
51
|
+
'',
|
|
52
|
+
'For each gap, determine if it is a real problem. Return findings as a JSON array:',
|
|
53
|
+
'[{ "table": "name", "column": "name_or_null", "operation": "add_column", "layer": "type", "file": "path/to/relevant/file.ts (optional)", "message": "explanation", "severity": "warning", "confidence": "high" }]',
|
|
54
|
+
'Return only valid JSON, no prose.',
|
|
55
|
+
].join('\n');
|
|
56
|
+
|
|
57
|
+
let rawOutput: string;
|
|
58
|
+
try {
|
|
59
|
+
const result = await engine.review({ content: prompt, kind: 'file-batch' });
|
|
60
|
+
rawOutput = result.rawOutput;
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const jsonMatch = rawOutput.match(/\[[\s\S]*\]/);
|
|
66
|
+
if (!jsonMatch) return [];
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(jsonMatch[0]) as Array<{
|
|
70
|
+
table: string; column?: string; operation: string;
|
|
71
|
+
layer: string; message: string; severity: string; confidence: string;
|
|
72
|
+
file?: string;
|
|
73
|
+
}>;
|
|
74
|
+
return parsed
|
|
75
|
+
.filter(item => item.table && item.layer && item.message)
|
|
76
|
+
.map(item => ({
|
|
77
|
+
entity: {
|
|
78
|
+
table: item.table,
|
|
79
|
+
column: item.column,
|
|
80
|
+
operation: item.operation as SchemaEntity['operation'],
|
|
81
|
+
},
|
|
82
|
+
layer: item.layer as AlignmentFinding['layer'],
|
|
83
|
+
message: item.message,
|
|
84
|
+
file: item.file,
|
|
85
|
+
severity: (item.severity === 'error' ? 'error' : 'warning') as AlignmentFinding['severity'],
|
|
86
|
+
confidence: (['high', 'medium', 'low'].includes(item.confidence) ? item.confidence : 'medium') as AlignmentFinding['confidence'],
|
|
87
|
+
}));
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { SchemaEntity, Evidence, LayerScanResult, SchemaAlignmentConfig } from './types.ts';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_ROOTS = {
|
|
6
|
+
types: ['types/', 'src/types/', 'lib/types/'],
|
|
7
|
+
api: ['app/api/', 'lib/', 'services/', 'src/routes/'],
|
|
8
|
+
ui: ['app/', 'src/', 'components/'],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function* walkFiles(dir: string): Generator<string> {
|
|
12
|
+
if (!fs.existsSync(dir)) return;
|
|
13
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
14
|
+
if (entry.isDirectory()) {
|
|
15
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
16
|
+
yield* walkFiles(path.join(dir, entry.name));
|
|
17
|
+
} else if (entry.isFile()) {
|
|
18
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
19
|
+
if (CODE_EXTS.has(ext)) yield path.join(dir, entry.name);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const IGNORED_DIRS = new Set([
|
|
25
|
+
'node_modules', '.next', 'dist', 'build', 'coverage', '.git',
|
|
26
|
+
'.turbo', '.cache', '.vercel', 'out', '.nuxt', 'target',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const CODE_EXTS = new Set([
|
|
30
|
+
'.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs',
|
|
31
|
+
'.vue', '.svelte', '.astro', '.py', '.rb', '.go', '.rs',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function resolveRoots(roots: string[], cwd: string): string[] {
|
|
35
|
+
return roots.map(r => path.isAbsolute(r) ? r : path.join(cwd, r));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isUnder(filePath: string, dir: string): boolean {
|
|
39
|
+
const rel = path.relative(dir, filePath);
|
|
40
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function searchLayer(
|
|
44
|
+
roots: string[],
|
|
45
|
+
pattern: RegExp,
|
|
46
|
+
cwd: string,
|
|
47
|
+
excludedDirs: string[] = [],
|
|
48
|
+
): Evidence | null {
|
|
49
|
+
for (const root of roots) {
|
|
50
|
+
const dir = path.isAbsolute(root) ? root : path.join(cwd, root);
|
|
51
|
+
for (const filePath of walkFiles(dir)) {
|
|
52
|
+
// Skip files that belong to an excluded layer's root (avoids UI root
|
|
53
|
+
// `app/` reporting API evidence from `app/api/`, etc.)
|
|
54
|
+
if (excludedDirs.some(excl => isUnder(filePath, excl))) continue;
|
|
55
|
+
let content: string;
|
|
56
|
+
try { content = fs.readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
57
|
+
const lines = content.split('\n');
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
if (pattern.test(lines[i]!)) {
|
|
60
|
+
return {
|
|
61
|
+
file: filePath,
|
|
62
|
+
line: i + 1,
|
|
63
|
+
snippet: lines[i]!.trim().slice(0, 120),
|
|
64
|
+
confidence: 'high',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function escapeRe(s: string): string {
|
|
74
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function scanLayers(
|
|
78
|
+
entities: SchemaEntity[],
|
|
79
|
+
cwd: string,
|
|
80
|
+
config?: SchemaAlignmentConfig,
|
|
81
|
+
): LayerScanResult[] {
|
|
82
|
+
const roots = {
|
|
83
|
+
types: config?.layerRoots?.types ?? DEFAULT_ROOTS.types,
|
|
84
|
+
api: config?.layerRoots?.api ?? DEFAULT_ROOTS.api,
|
|
85
|
+
ui: config?.layerRoots?.ui ?? DEFAULT_ROOTS.ui,
|
|
86
|
+
};
|
|
87
|
+
const resolvedTypeRoots = resolveRoots(roots.types, cwd);
|
|
88
|
+
const resolvedApiRoots = resolveRoots(roots.api, cwd);
|
|
89
|
+
|
|
90
|
+
return entities.map(entity => {
|
|
91
|
+
const isDestructive = entity.operation === 'drop_column' || entity.operation === 'rename_column';
|
|
92
|
+
const searchName = isDestructive
|
|
93
|
+
? (entity.oldName ?? entity.column ?? entity.table)
|
|
94
|
+
: (entity.column ?? entity.table);
|
|
95
|
+
|
|
96
|
+
const pattern = new RegExp(`\\b${escapeRe(searchName)}\\b`);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
entity,
|
|
100
|
+
typeLayer: searchLayer(roots.types, pattern, cwd),
|
|
101
|
+
// API search excludes type roots (prevents lib/ from picking up lib/types/ matches)
|
|
102
|
+
apiLayer: searchLayer(roots.api, pattern, cwd, resolvedTypeRoots),
|
|
103
|
+
// UI search excludes both type and API roots (prevents app/ from picking up app/api/)
|
|
104
|
+
uiLayer: searchLayer(roots.ui, pattern, cwd, [...resolvedTypeRoots, ...resolvedApiRoots]),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/core/schema-alignment/types.ts
|
|
2
|
+
|
|
3
|
+
export interface SchemaEntity {
|
|
4
|
+
table: string;
|
|
5
|
+
column?: string;
|
|
6
|
+
operation: 'create_table' | 'add_column' | 'drop_column' | 'rename_column' | 'create_type';
|
|
7
|
+
oldName?: string; // rename_column only: the previous column name
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Evidence {
|
|
11
|
+
file: string;
|
|
12
|
+
line: number;
|
|
13
|
+
snippet: string;
|
|
14
|
+
confidence: 'high' | 'medium' | 'low';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LayerScanResult {
|
|
18
|
+
entity: SchemaEntity;
|
|
19
|
+
typeLayer: Evidence | null;
|
|
20
|
+
apiLayer: Evidence | null;
|
|
21
|
+
uiLayer: Evidence | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AlignmentFinding {
|
|
25
|
+
entity: SchemaEntity;
|
|
26
|
+
layer: 'type' | 'api' | 'ui';
|
|
27
|
+
message: string;
|
|
28
|
+
file?: string;
|
|
29
|
+
severity: 'warning' | 'error';
|
|
30
|
+
confidence: 'high' | 'medium' | 'low';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SchemaAlignmentConfig {
|
|
34
|
+
enabled?: boolean;
|
|
35
|
+
migrationGlobs?: string[];
|
|
36
|
+
layerRoots?: {
|
|
37
|
+
types?: string[];
|
|
38
|
+
api?: string[];
|
|
39
|
+
ui?: string[];
|
|
40
|
+
};
|
|
41
|
+
llmCheck?: boolean;
|
|
42
|
+
severity?: 'warning' | 'error';
|
|
43
|
+
}
|
package/src/core/shell.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/core/shell.ts
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import {
|
|
4
|
+
import { GuardrailError, type ErrorCode } from './errors.ts';
|
|
5
5
|
|
|
6
6
|
export interface RunOptions {
|
|
7
7
|
timeout?: number;
|
|
@@ -27,7 +27,7 @@ export function runSafe(cmd: string, args: string[], options: RunOptions = {}):
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Run a command; throw
|
|
30
|
+
/** Run a command; throw GuardrailError on failure. */
|
|
31
31
|
export function runThrowing(cmd: string, args: string[], options: RunOptions & { errorCode?: ErrorCode; provider?: string } = {}): string {
|
|
32
32
|
try {
|
|
33
33
|
return execFileSync(cmd, args, {
|
|
@@ -39,7 +39,7 @@ export function runThrowing(cmd: string, args: string[], options: RunOptions & {
|
|
|
39
39
|
env: options.env,
|
|
40
40
|
}).toString();
|
|
41
41
|
} catch (err) {
|
|
42
|
-
throw new
|
|
42
|
+
throw new GuardrailError(`Command failed: ${cmd} ${args.join(' ')}`, {
|
|
43
43
|
code: options.errorCode ?? 'transient_network',
|
|
44
44
|
provider: options.provider,
|
|
45
45
|
details: { cmd, args, cause: err instanceof Error ? err.message : String(err) },
|
|
@@ -3,13 +3,22 @@ import type { StaticRuleReference } from '../config/types.ts';
|
|
|
3
3
|
|
|
4
4
|
// Built-in cross-stack rules
|
|
5
5
|
const BUILTIN: Record<string, () => Promise<StaticRule>> = {
|
|
6
|
-
'hardcoded-secrets':
|
|
7
|
-
'npm-audit':
|
|
8
|
-
'package-lock-sync':
|
|
9
|
-
'console-log':
|
|
10
|
-
'todo-fixme':
|
|
11
|
-
'large-file':
|
|
12
|
-
'missing-tests':
|
|
6
|
+
'hardcoded-secrets': () => import('./rules/hardcoded-secrets.ts').then(m => m.hardcodedSecretsRule),
|
|
7
|
+
'npm-audit': () => import('./rules/npm-audit.ts').then(m => m.npmAuditRule),
|
|
8
|
+
'package-lock-sync': () => import('./rules/package-lock-sync.ts').then(m => m.packageLockSyncRule),
|
|
9
|
+
'console-log': () => import('./rules/console-log.ts').then(m => m.consoleLogRule),
|
|
10
|
+
'todo-fixme': () => import('./rules/todo-fixme.ts').then(m => m.todoFixmeRule),
|
|
11
|
+
'large-file': () => import('./rules/large-file.ts').then(m => m.largeFileRule),
|
|
12
|
+
'missing-tests': () => import('./rules/missing-tests.ts').then(m => m.missingTestsRule),
|
|
13
|
+
// Security rules
|
|
14
|
+
'sql-injection': () => import('./rules/sql-injection.ts').then(m => m.sqlInjectionRule),
|
|
15
|
+
'missing-auth': () => import('./rules/missing-auth.ts').then(m => m.missingAuthRule),
|
|
16
|
+
'ssrf': () => import('./rules/ssrf.ts').then(m => m.ssrfRule),
|
|
17
|
+
'insecure-redirect': () => import('./rules/insecure-redirect.ts').then(m => m.insecureRedirectRule),
|
|
18
|
+
// Brand rules
|
|
19
|
+
'brand-tokens': () => import('./rules/brand-tokens.ts').then(m => m.brandTokensRule),
|
|
20
|
+
// Schema alignment
|
|
21
|
+
'schema-alignment': () => import('./rules/schema-alignment.ts').then(m => m.schemaAlignmentRule),
|
|
13
22
|
};
|
|
14
23
|
|
|
15
24
|
// Preset-specific rules registered by name
|
|
@@ -31,7 +40,7 @@ export async function loadRulesFromConfig(refs: StaticRuleReference[]): Promise<
|
|
|
31
40
|
if (loader) {
|
|
32
41
|
rules.push(await loader());
|
|
33
42
|
} else {
|
|
34
|
-
process.stderr.write(`[
|
|
43
|
+
process.stderr.write(`[guardrail] Unknown static rule: "${name}" — skipping\n`);
|
|
35
44
|
}
|
|
36
45
|
}
|
|
37
46
|
return rules;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
4
|
+
import type { Finding } from '../../findings/types.ts';
|
|
5
|
+
import { extractTailwindColors } from '../tailwind-extractor.ts';
|
|
6
|
+
|
|
7
|
+
const UI_EXTS = new Set(['.tsx', '.jsx', '.ts', '.js', '.css', '.scss', '.sass', '.less', '.html', '.vue', '.svelte']);
|
|
8
|
+
const HEX_RE = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
|
|
9
|
+
const TAILWIND_ARBITRARY_HEX = /(?:bg|text|border|ring|fill|stroke|from|to|via)-\[#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\]/g;
|
|
10
|
+
// Matches the hex portion inside a Tailwind arbitrary color bracket so we can strip it before plain HEX_RE scan
|
|
11
|
+
const TAILWIND_ARBITRARY_HEX_STRIP = /(?:bg|text|border|ring|fill|stroke|from|to|via)-\[#[0-9a-fA-F]{3,6}\]/g;
|
|
12
|
+
const FONT_FAMILY_RE = /font-family\s*:\s*([^;}\n]+)/g;
|
|
13
|
+
const CSS_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
14
|
+
|
|
15
|
+
function normalizeHex(hex: string): string {
|
|
16
|
+
const h = hex.toLowerCase();
|
|
17
|
+
if (h.length === 4) {
|
|
18
|
+
const r = h[1]!, g = h[2]!, b = h[3]!;
|
|
19
|
+
return `#${r}${r}${g}${g}${b}${b}`;
|
|
20
|
+
}
|
|
21
|
+
return h;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildPalette(
|
|
25
|
+
brandCfg: { colorsFrom?: string; colors?: string[] },
|
|
26
|
+
cwd: string,
|
|
27
|
+
): Set<string> | null {
|
|
28
|
+
const hasColorsFrom = !!brandCfg.colorsFrom;
|
|
29
|
+
const hasColors = Array.isArray(brandCfg.colors) && brandCfg.colors.length > 0;
|
|
30
|
+
if (!hasColorsFrom && !hasColors) return null;
|
|
31
|
+
|
|
32
|
+
const palette = new Set<string>();
|
|
33
|
+
if (hasColorsFrom) {
|
|
34
|
+
const cfgPath = path.isAbsolute(brandCfg.colorsFrom!)
|
|
35
|
+
? brandCfg.colorsFrom!
|
|
36
|
+
: path.resolve(cwd, brandCfg.colorsFrom!);
|
|
37
|
+
for (const c of extractTailwindColors(cfgPath)) palette.add(normalizeHex(c));
|
|
38
|
+
}
|
|
39
|
+
for (const c of brandCfg.colors ?? []) palette.add(normalizeHex(c));
|
|
40
|
+
return palette;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const brandTokensRule: StaticRule = {
|
|
44
|
+
name: 'brand-tokens',
|
|
45
|
+
severity: 'warning',
|
|
46
|
+
|
|
47
|
+
async check(touchedFiles: string[], config: Record<string, unknown> = {}): Promise<Finding[]> {
|
|
48
|
+
const brandCfg = config.brand as
|
|
49
|
+
| { colorsFrom?: string; colors?: string[]; fonts?: string[] }
|
|
50
|
+
| undefined;
|
|
51
|
+
|
|
52
|
+
if (!brandCfg) return [];
|
|
53
|
+
|
|
54
|
+
const cwd = process.cwd();
|
|
55
|
+
const palette = buildPalette(brandCfg, cwd);
|
|
56
|
+
const canonicalFonts = brandCfg.fonts?.map(f => f.toLowerCase()) ?? [];
|
|
57
|
+
const findings: Finding[] = [];
|
|
58
|
+
|
|
59
|
+
for (const file of touchedFiles) {
|
|
60
|
+
const ext = path.extname(file);
|
|
61
|
+
if (!UI_EXTS.has(ext)) continue;
|
|
62
|
+
|
|
63
|
+
let content: string;
|
|
64
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
65
|
+
|
|
66
|
+
const lines = content.split('\n');
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const line = lines[i]!;
|
|
69
|
+
const trimmed = line.trim();
|
|
70
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
|
|
71
|
+
|
|
72
|
+
if (palette && palette.size > 0) {
|
|
73
|
+
// Check Tailwind arbitrary color classes first, then scan remaining line for plain hex
|
|
74
|
+
TAILWIND_ARBITRARY_HEX.lastIndex = 0;
|
|
75
|
+
let m: RegExpExecArray | null;
|
|
76
|
+
while ((m = TAILWIND_ARBITRARY_HEX.exec(line)) !== null) {
|
|
77
|
+
const hex = normalizeHex(`#${m[1]!}`);
|
|
78
|
+
if (!palette.has(hex)) {
|
|
79
|
+
findings.push({
|
|
80
|
+
id: `brand-tokens:tailwind:${file}:${i + 1}`,
|
|
81
|
+
source: 'static-rules',
|
|
82
|
+
severity: 'warning',
|
|
83
|
+
category: 'brand-tokens',
|
|
84
|
+
file,
|
|
85
|
+
line: i + 1,
|
|
86
|
+
message: `Off-brand Tailwind arbitrary color ${hex} is not in the canonical palette`,
|
|
87
|
+
suggestion: `Replace with a Tailwind token from your brand palette (e.g. bg-primary, text-brand)`,
|
|
88
|
+
protectedPath: false,
|
|
89
|
+
createdAt: new Date().toISOString(),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Strip Tailwind arbitrary color brackets before plain hex scan to avoid double-reporting
|
|
95
|
+
const lineWithoutTailwindArbitrary = line.replace(TAILWIND_ARBITRARY_HEX_STRIP, '');
|
|
96
|
+
HEX_RE.lastIndex = 0;
|
|
97
|
+
while ((m = HEX_RE.exec(lineWithoutTailwindArbitrary)) !== null) {
|
|
98
|
+
const hex = normalizeHex(m[0]!);
|
|
99
|
+
if (!palette.has(hex)) {
|
|
100
|
+
const palettePreview = [...palette].slice(0, 5).join(', ');
|
|
101
|
+
findings.push({
|
|
102
|
+
id: `brand-tokens:${file}:${i + 1}`,
|
|
103
|
+
source: 'static-rules',
|
|
104
|
+
severity: 'warning',
|
|
105
|
+
category: 'brand-tokens',
|
|
106
|
+
file,
|
|
107
|
+
line: i + 1,
|
|
108
|
+
message: `Off-brand color ${hex} is not in the canonical palette`,
|
|
109
|
+
suggestion: `Use a brand token. Canonical colors: ${palettePreview}${palette.size > 5 ? ` (+${palette.size - 5} more)` : ''}`,
|
|
110
|
+
protectedPath: false,
|
|
111
|
+
createdAt: new Date().toISOString(),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (canonicalFonts.length > 0 && CSS_EXTS.has(ext)) {
|
|
118
|
+
FONT_FAMILY_RE.lastIndex = 0;
|
|
119
|
+
let fm: RegExpExecArray | null;
|
|
120
|
+
while ((fm = FONT_FAMILY_RE.exec(line)) !== null) {
|
|
121
|
+
const declaration = fm[1]!;
|
|
122
|
+
const declared = declaration.split(',').map(f => f.trim().replace(/['"]/g, '').toLowerCase());
|
|
123
|
+
const hasCanonical = declared.some(f => canonicalFonts.some(cf => f.includes(cf)));
|
|
124
|
+
if (!hasCanonical) {
|
|
125
|
+
findings.push({
|
|
126
|
+
id: `brand-tokens:font:${file}:${i + 1}`,
|
|
127
|
+
source: 'static-rules',
|
|
128
|
+
severity: 'warning',
|
|
129
|
+
category: 'brand-tokens',
|
|
130
|
+
file,
|
|
131
|
+
line: i + 1,
|
|
132
|
+
message: `Off-brand font-family "${declaration.trim()}" — not in canonical fonts list`,
|
|
133
|
+
suggestion: `Use one of the canonical fonts: ${canonicalFonts.join(', ')}`,
|
|
134
|
+
protectedPath: false,
|
|
135
|
+
createdAt: new Date().toISOString(),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return findings;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
@@ -3,7 +3,33 @@ import type { StaticRule } from '../../phases/static-rules.ts';
|
|
|
3
3
|
import type { Finding } from '../../findings/types.ts';
|
|
4
4
|
|
|
5
5
|
const SECRET_PATTERNS: { regex: RegExp; label: string }[] = [
|
|
6
|
+
// Cloud provider keys
|
|
6
7
|
{ regex: /\bAKIA[0-9A-Z]{16}\b/, label: 'AWS Access Key ID' },
|
|
8
|
+
{ regex: /\b(?:aws|AWS)_?(?:secret|SECRET)_?(?:key|KEY|access|ACCESS)\s*[:=]\s*['"][A-Za-z0-9/+]{40}['"]/, label: 'AWS Secret Access Key' },
|
|
9
|
+
|
|
10
|
+
// LLM / AI providers
|
|
11
|
+
{ regex: /\bsk-ant-[a-zA-Z0-9\-_]{20,}\b/, label: 'Anthropic API key' },
|
|
12
|
+
{ regex: /\bsk-[a-zA-Z0-9]{20,}\b(?!.*placeholder)/, label: 'OpenAI API key' },
|
|
13
|
+
{ regex: /\bgsk_[a-zA-Z0-9]{20,}\b/, label: 'Groq API key' },
|
|
14
|
+
|
|
15
|
+
// Payment
|
|
16
|
+
{ regex: /\bsk_live_[a-zA-Z0-9]{24,}\b/, label: 'Stripe secret key (live)' },
|
|
17
|
+
{ regex: /\brk_live_[a-zA-Z0-9]{24,}\b/, label: 'Stripe restricted key (live)' },
|
|
18
|
+
|
|
19
|
+
// Source control / CI
|
|
20
|
+
{ regex: /\bghp_[a-zA-Z0-9]{36}\b/, label: 'GitHub personal access token' },
|
|
21
|
+
{ regex: /\bghs_[a-zA-Z0-9]{36}\b/, label: 'GitHub Actions token' },
|
|
22
|
+
{ regex: /\bgithub_pat_[a-zA-Z0-9_]{82}\b/, label: 'GitHub fine-grained PAT' },
|
|
23
|
+
|
|
24
|
+
// Communication
|
|
25
|
+
{ regex: /\bSG\.[a-zA-Z0-9\-_]{22,}\.[a-zA-Z0-9\-_]{43,}\b/, label: 'SendGrid API key' },
|
|
26
|
+
{ regex: /\bAC[a-f0-9]{32}\b/, label: 'Twilio Account SID' },
|
|
27
|
+
|
|
28
|
+
// Database / BaaS
|
|
29
|
+
{ regex: /\bservice_role\b.*\beyJ[a-zA-Z0-9._-]{100,}\b/, label: 'Supabase service role JWT' },
|
|
30
|
+
{ regex: /\beyJ[a-zA-Z0-9._-]{150,}\b/, label: 'Long JWT (possible service key)' },
|
|
31
|
+
|
|
32
|
+
// Generic patterns
|
|
7
33
|
{ regex: /(?:^|[^a-z])(?:password|passwd|pwd)\s*[:=]\s*['"](?!\/)[^'"]{6,}['"]/, label: 'Hardcoded password' },
|
|
8
34
|
{ regex: /(?:api_key|apikey|api-key)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded API key' },
|
|
9
35
|
{ regex: /(?:secret|secret_key|secretkey)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded secret' },
|
|
@@ -13,7 +39,7 @@ const SECRET_PATTERNS: { regex: RegExp; label: string }[] = [
|
|
|
13
39
|
];
|
|
14
40
|
|
|
15
41
|
// Patterns that indicate a placeholder, not a real secret
|
|
16
|
-
const PLACEHOLDER = /(?:your[-_]?|xxx|placeholder|example|test|fake|dummy|changeme|<[^>]
|
|
42
|
+
const PLACEHOLDER = /(?:your[-_]?|xxx|placeholder|example|test|fake|dummy|changeme|<[^>]+>|process\.env|import\.meta\.env|\$\{)/i;
|
|
17
43
|
const SKIP_EXTS = new Set(['.md', '.txt', '.yaml', '.yml', '.json', '.lock', '.snap']);
|
|
18
44
|
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
|
|
19
45
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
3
|
+
import type { Finding } from '../../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
// Redirect calls in Next.js / Express / Node
|
|
6
|
+
const REDIRECT_CALL = /(?:\bredirect\s*\(|NextResponse\.redirect\s*\(|res\.redirect\s*\(|router\.push\s*\()/;
|
|
7
|
+
|
|
8
|
+
// User-controlled input sources
|
|
9
|
+
const USER_INPUT_SOURCES = /(?:req\.|request\.|params\.|query\.|body\.|searchParams\.|headers\.|getParam|getQuery|getSearchParam)/;
|
|
10
|
+
|
|
11
|
+
// Template interpolation of user input into redirect target
|
|
12
|
+
const TAINTED_REDIRECT_TEMPLATE = /`[^`]*\$\{[^}]*(?:url|redirect|return|next|callback|target|to|from|path|href|location)[^}]*\}`/i;
|
|
13
|
+
const TAINTED_REDIRECT_VAR = /(?:url|redirect|returnUrl|returnTo|next|callbackUrl|target|to|from|path|href|location)\b/;
|
|
14
|
+
|
|
15
|
+
const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']);
|
|
16
|
+
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
|
|
17
|
+
|
|
18
|
+
export const insecureRedirectRule: StaticRule = {
|
|
19
|
+
name: 'insecure-redirect',
|
|
20
|
+
severity: 'warning',
|
|
21
|
+
|
|
22
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
23
|
+
const findings: Finding[] = [];
|
|
24
|
+
for (const file of touchedFiles) {
|
|
25
|
+
const ext = file.slice(file.lastIndexOf('.'));
|
|
26
|
+
if (!CODE_EXTS.has(ext) || TEST_PATH.test(file)) continue;
|
|
27
|
+
let content: string;
|
|
28
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
29
|
+
const lines = content.split('\n');
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
const line = lines[i]!;
|
|
33
|
+
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
|
|
34
|
+
|
|
35
|
+
if (!REDIRECT_CALL.test(line)) continue;
|
|
36
|
+
|
|
37
|
+
// Check if the redirect target is user-controlled
|
|
38
|
+
const hasTaint = TAINTED_REDIRECT_TEMPLATE.test(line) || USER_INPUT_SOURCES.test(line);
|
|
39
|
+
if (!hasTaint) {
|
|
40
|
+
// Check if a variable with a suspicious name is passed
|
|
41
|
+
const argStr = line.replace(REDIRECT_CALL, '');
|
|
42
|
+
const hasRedirectVar = TAINTED_REDIRECT_VAR.test(line) && !/['"`]/.test(argStr);
|
|
43
|
+
if (!hasRedirectVar) continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Skip if there's obvious validation nearby (startsWith, URL constructor, allowlist)
|
|
47
|
+
const context = lines.slice(Math.max(0, i - 5), i + 1).join('\n');
|
|
48
|
+
const hasValidation = /(?:startsWith\s*\(\s*['"]\/|new\s+URL|allowedRedirects|trustedOrigins|encodeURIComponent)/.test(context);
|
|
49
|
+
if (hasValidation) continue;
|
|
50
|
+
|
|
51
|
+
findings.push({
|
|
52
|
+
id: `insecure-redirect:${file}:${i + 1}`,
|
|
53
|
+
source: 'static-rules',
|
|
54
|
+
severity: 'warning',
|
|
55
|
+
category: 'insecure-redirect',
|
|
56
|
+
file,
|
|
57
|
+
line: i + 1,
|
|
58
|
+
message: 'Possible open redirect: redirect target may be user-controlled',
|
|
59
|
+
suggestion: 'Validate redirect targets — allow only relative paths (startsWith("/")) or an explicit allowlist of trusted origins',
|
|
60
|
+
protectedPath: false,
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return findings;
|
|
66
|
+
},
|
|
67
|
+
};
|