codehere 0.8.0 → 1.0.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/README.md +22 -13
- package/dist/application/services/verdict-service.js +154 -0
- package/dist/application/use-cases/assess-cross-vendor.js +126 -0
- package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
- package/dist/application/use-cases/export-aat.js +107 -0
- package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
- package/dist/application/use-cases/generate-final-report.js +140 -15
- package/dist/application/use-cases/generate-narration.js +5 -3
- package/dist/application/use-cases/generate-thread-title.js +5 -2
- package/dist/application/use-cases/plan-auto-review.js +57 -0
- package/dist/application/use-cases/reconcile-audit.js +100 -0
- package/dist/application/use-cases/rerun-tests.js +226 -0
- package/dist/application/use-cases/search-sessions.js +70 -0
- package/dist/application/use-cases/should-auto-review.js +71 -0
- package/dist/application/use-cases/thread-export.js +90 -0
- package/dist/application/use-cases/verify-claim.js +187 -5
- package/dist/domain/audit.js +11 -0
- package/dist/domain/services/codebase-detector.js +0 -6
- package/dist/domain/task.js +91 -0
- package/dist/error-handler.js +3 -115
- package/dist/index.js +269 -25
- package/dist/infrastructure/completion/bash-completion.js +7 -3
- package/dist/infrastructure/completion/fish-completion.js +7 -12
- package/dist/infrastructure/completion/zsh-completion.js +7 -12
- package/dist/infrastructure/config/config-manager.js +4 -3
- package/dist/infrastructure/config/product-context.js +1 -19
- package/dist/infrastructure/context/adapters/figma-source.js +37 -2
- package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
- package/dist/infrastructure/context/github-api.js +110 -0
- package/dist/infrastructure/context/github-device-flow.js +65 -0
- package/dist/infrastructure/context/oauth-store.js +5 -2
- package/dist/infrastructure/context/registry-bootstrap.js +0 -4
- package/dist/infrastructure/cost/budget-config.js +3 -2
- package/dist/infrastructure/cost/cost-router.js +16 -23
- package/dist/infrastructure/cost/spend-aggregator.js +90 -15
- package/dist/infrastructure/cost/team-ledger.js +355 -0
- package/dist/infrastructure/discovery/discovery-log.js +85 -0
- package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +133 -3
- package/dist/infrastructure/hooks/hook-mode.js +20 -4
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
- package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
- package/dist/infrastructure/observability/execution-tracer.js +1 -1
- package/dist/infrastructure/orchestration/agents-config-seed.js +22 -10
- package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
- package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
- package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
- package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
- package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
- package/dist/infrastructure/orchestration/context-handoff.js +32 -14
- package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
- package/dist/infrastructure/orchestration/failover-config.js +0 -10
- package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +132 -1
- package/dist/infrastructure/orchestration/graphify-context.js +14 -59
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +164 -9
- package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
- package/dist/infrastructure/orchestration/progress-aggregator.js +238 -21
- package/dist/infrastructure/orchestration/session-manager.js +593 -94
- package/dist/infrastructure/orchestration/session-persistence.js +177 -41
- package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
- package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
- package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
- package/dist/infrastructure/protocols/mcp-server-impl.js +1462 -380
- package/dist/infrastructure/protocols/mcp-tools.js +401 -0
- package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
- package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
- package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
- package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
- package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
- package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
- package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
- package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
- package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
- package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
- package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
- package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
- package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
- package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
- package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
- package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
- package/dist/infrastructure/protocols/ui/styles.js +2147 -0
- package/dist/infrastructure/protocols/ui/tokens.js +96 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +116 -4214
- package/dist/infrastructure/protocols/verify-page.js +849 -155
- package/dist/infrastructure/security/fix-code.js +144 -0
- package/dist/infrastructure/security/fix-secret.js +89 -0
- package/dist/infrastructure/security/permissions-config.js +3 -2
- package/dist/infrastructure/security/scan-types.js +10 -0
- package/dist/infrastructure/security/ship-scan.js +195 -0
- package/dist/infrastructure/security/trust-scorer.js +70 -3
- package/dist/infrastructure/security/vibe-checks.js +99 -0
- package/dist/infrastructure/storage/atomic-write.js +86 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +150 -3
- package/dist/infrastructure/storage/project-thread-reader.js +261 -24
- package/dist/monitoring.js +2 -2
- package/dist/presentation/cli/commands/audit-command.js +79 -4
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/check-command.js +96 -0
- package/dist/presentation/cli/commands/context-source-command.js +20 -1
- package/dist/presentation/cli/commands/help-command.js +5 -5
- package/dist/presentation/cli/commands/hook-command.js +5 -5
- package/dist/presentation/cli/commands/init-command.js +26 -14
- package/dist/presentation/cli/commands/memory-command.js +28 -28
- package/dist/presentation/cli/commands/permission-command.js +1 -12
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/protocols-command.js +4 -4
- package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
- package/dist/presentation/cli/commands/report-command.js +48 -32
- package/dist/presentation/cli/commands/scan-command.js +52 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
- package/dist/presentation/cli/commands/search-command.js +51 -0
- package/dist/presentation/cli/commands/setup-command.js +31 -8
- package/dist/presentation/cli/commands/status-command.js +39 -86
- package/dist/presentation/cli/commands/team-command.js +256 -0
- package/dist/presentation/cli/commands/thread-command.js +87 -5
- package/dist/presentation/cli/commands/trust-command.js +53 -31
- package/dist/presentation/cli/commands/verify-command.js +74 -9
- package/dist/presentation/cli/error-display.js +0 -8
- package/dist/presentation/cli/repl.js +15 -7
- package/dist/presentation/cli/task-deliverable.js +36 -10
- package/dist/testing/replay-adapter.js +0 -19
- package/dist/ui.js +51 -239
- package/dist/utils/version.js +0 -6
- package/package.json +30 -10
- package/dist/domain/entities/task.js +0 -61
- package/dist/infrastructure/protocols/ui-page.js +0 -2377
- package/dist/infrastructure/security/input-validator.js +0 -152
- package/dist/infrastructure/storage/embeddings-path.js +0 -37
- package/dist/infrastructure/storage/task-repository.js +0 -206
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI fix + prove-closed for a CODE danger (not a leaked secret) — browser-auth, injection, unsafe HTML.
|
|
3
|
+
* Spawns claude-code (subscription, async so it never blocks the daemon) to fix the specific issue,
|
|
4
|
+
* then RE-SCANS to prove that exact danger no longer fires. Honest if it can't. Uses model spend.
|
|
5
|
+
* The verify-loop (validated 0% false-positive) applied to the scan. docs/EXECUTION_PLAN step 2.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { shipScan } from './ship-scan.js';
|
|
9
|
+
// Same pre-granted edit tools the daemon's claude-code adapter uses for headless -p mode.
|
|
10
|
+
const CLAUDE_FLAGS = [
|
|
11
|
+
'--output-format',
|
|
12
|
+
'stream-json',
|
|
13
|
+
'--verbose',
|
|
14
|
+
'--allowedTools',
|
|
15
|
+
'Edit',
|
|
16
|
+
'Write',
|
|
17
|
+
'Read',
|
|
18
|
+
'Grep',
|
|
19
|
+
'Glob',
|
|
20
|
+
'MultiEdit',
|
|
21
|
+
];
|
|
22
|
+
const baseName = (p) => {
|
|
23
|
+
const s = String(p || '');
|
|
24
|
+
return s.split('/').pop() || s;
|
|
25
|
+
};
|
|
26
|
+
/** Turn one claude stream-json event into a plain-language step ('' = nothing worth showing). */
|
|
27
|
+
function stepLabel(ev) {
|
|
28
|
+
if (!ev || ev.type !== 'assistant')
|
|
29
|
+
return '';
|
|
30
|
+
const content = ev.message?.content;
|
|
31
|
+
if (!Array.isArray(content))
|
|
32
|
+
return '';
|
|
33
|
+
for (const c of content) {
|
|
34
|
+
if (c?.type !== 'tool_use')
|
|
35
|
+
continue;
|
|
36
|
+
const name = String(c.name || '');
|
|
37
|
+
const input = c.input || {};
|
|
38
|
+
const f = baseName(input.file_path || input.path);
|
|
39
|
+
if (name === 'Edit' || name === 'MultiEdit' || name === 'Write')
|
|
40
|
+
return `Editing ${f}`;
|
|
41
|
+
if (name === 'Read')
|
|
42
|
+
return `Reading ${f}`;
|
|
43
|
+
if (name === 'Grep' || name === 'Glob')
|
|
44
|
+
return 'Searching the code';
|
|
45
|
+
return `Working (${name})`;
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
export async function fixCodeVuln(dir, top, onStep) {
|
|
50
|
+
const prompt = `There is a real security vulnerability in ${top.file} (around line ${top.line}): ${top.what} ` +
|
|
51
|
+
`Fix it properly so the app still works — change the code, don't just comment it. Edit only the file(s) needed in this project.`;
|
|
52
|
+
const env = { ...process.env };
|
|
53
|
+
delete env.ANTHROPIC_API_KEY; // use the Claude Code subscription, not pay-as-you-go
|
|
54
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
55
|
+
// Don't repeat the same step twice in a row — keeps the live feed readable.
|
|
56
|
+
let lastLabel = '';
|
|
57
|
+
const step = (label) => {
|
|
58
|
+
if (!label || label === lastLabel)
|
|
59
|
+
return;
|
|
60
|
+
lastLabel = label;
|
|
61
|
+
try {
|
|
62
|
+
onStep?.(label);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* a dead client must never crash the fix */
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
step('Starting the AI fix');
|
|
69
|
+
await new Promise((resolve) => {
|
|
70
|
+
let child;
|
|
71
|
+
try {
|
|
72
|
+
// Capture stdout (the stream-json feed) so we can surface live steps; stderr stays quiet.
|
|
73
|
+
child = spawn('claude', ['-p', prompt, ...CLAUDE_FLAGS], {
|
|
74
|
+
cwd: dir,
|
|
75
|
+
env: env,
|
|
76
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
resolve();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let buf = '';
|
|
84
|
+
child.stdout?.on('data', (chunk) => {
|
|
85
|
+
buf += chunk.toString();
|
|
86
|
+
let nl;
|
|
87
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
88
|
+
const line = buf.slice(0, nl).trim();
|
|
89
|
+
buf = buf.slice(nl + 1);
|
|
90
|
+
if (!line)
|
|
91
|
+
continue;
|
|
92
|
+
let ev;
|
|
93
|
+
try {
|
|
94
|
+
ev = JSON.parse(line);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
step(stepLabel(ev));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
try {
|
|
104
|
+
child.kill('SIGKILL');
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* best effort */
|
|
108
|
+
}
|
|
109
|
+
resolve();
|
|
110
|
+
}, 180_000);
|
|
111
|
+
child.on('close', () => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
resolve();
|
|
114
|
+
});
|
|
115
|
+
child.on('error', () => {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
resolve();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
step('Re-scanning to prove the danger is gone');
|
|
121
|
+
// PROVE: re-scan and check the SAME danger (file + ruleId) is gone.
|
|
122
|
+
const after = shipScan(dir);
|
|
123
|
+
const stillThere = after.findings.some((f) => f.file === top.file && f.ruleId === top.ruleId);
|
|
124
|
+
// A finding can only be proven closed by the scanner that FOUND it. If that scanner failed on the
|
|
125
|
+
// re-scan, the danger "disappears" for the same reason a switched-off smoke alarm reports no fire.
|
|
126
|
+
// Absence of a finding from a scanner that never ran is not proof, and this loop's whole value is
|
|
127
|
+
// that its proof means something. (codehere's own vibeChecks run in-process, so they always ran.)
|
|
128
|
+
const proverRan = top.tool === 'codehere' || after.ran[top.tool];
|
|
129
|
+
if (!stillThere && !proverRan) {
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
provenClosed: false,
|
|
133
|
+
message: `The AI rewrote ${top.file}, but codehere could not prove the fix: ${top.tool} failed to complete the re-scan, so the "${top.ruleId}" danger is unverified rather than gone. Re-run the scan once ${top.tool} is working.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
provenClosed: !stillThere,
|
|
139
|
+
message: !stillThere
|
|
140
|
+
? `Proven closed: the AI rewrote ${top.file} and codehere re-scanned it — the "${top.ruleId}" danger is gone. Give the change a quick look before you ship.`
|
|
141
|
+
: `The AI tried, but codehere re-scanned and the "${top.ruleId}" danger is STILL in ${top.file}. Not fixed — this one needs your eyes (or a different fix).`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=fix-code.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic "fix + prove closed" for an exposed secret — the #1 vibe-coder ship-blocker.
|
|
3
|
+
* ZERO model tokens. Moves the hardcoded secret out of your shippable code (→ process.env + a
|
|
4
|
+
* gitignored .env), then PROVES it by re-reading the source. Honest: you must still ROTATE the key
|
|
5
|
+
* (moving it doesn't undo the exposure that already happened). See docs/PRODUCT_10X.md (R3).
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
11
|
+
function topSecret(dir) {
|
|
12
|
+
const tmp = join(mkdtempSync(join(tmpdir(), 'fixsec-')), 'gl.json');
|
|
13
|
+
spawnSync('gitleaks', ['detect', '--no-git', '--source', dir, '-f', 'json', '-r', tmp], { stdio: 'ignore' });
|
|
14
|
+
if (!existsSync(tmp))
|
|
15
|
+
return null;
|
|
16
|
+
let arr;
|
|
17
|
+
try {
|
|
18
|
+
arr = JSON.parse(readFileSync(tmp, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (!arr || !arr.length)
|
|
24
|
+
return null;
|
|
25
|
+
// Prefer a secret still living in shippable source (not one already moved into a .env).
|
|
26
|
+
const f = arr.find((x) => !String(x.File || '').endsWith('.env')) || arr[0];
|
|
27
|
+
return {
|
|
28
|
+
file: String(f.File),
|
|
29
|
+
secret: String(f.Secret),
|
|
30
|
+
line: Number(f.StartLine || 0),
|
|
31
|
+
ruleId: String(f.RuleID || 'secret'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function deriveVarName(line, ruleId) {
|
|
35
|
+
const m = line.match(/(?:const|let|var)?\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*[:=]/);
|
|
36
|
+
let name = m ? m[1] : `SECRET_${ruleId}`;
|
|
37
|
+
name = name
|
|
38
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
39
|
+
.toUpperCase()
|
|
40
|
+
.replace(/[^A-Z0-9_]/g, '_');
|
|
41
|
+
return name || 'APP_SECRET';
|
|
42
|
+
}
|
|
43
|
+
export function fixExposedSecret(dir) {
|
|
44
|
+
const t = topSecret(dir);
|
|
45
|
+
if (!t)
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: 'no_secret',
|
|
49
|
+
message: 'The top issue here is a code change, not a leaked key — auto-fix for code vulnerabilities is the next step (it needs an agent).',
|
|
50
|
+
};
|
|
51
|
+
if (!existsSync(t.file))
|
|
52
|
+
return { ok: false, reason: 'file_gone', message: 'The flagged file is no longer there.' };
|
|
53
|
+
let src = readFileSync(t.file, 'utf8');
|
|
54
|
+
if (!src.includes(t.secret))
|
|
55
|
+
return { ok: false, reason: 'not_found', message: 'Could not locate the secret in the file to replace.' };
|
|
56
|
+
const lineText = src.split('\n').find((l) => l.includes(t.secret)) || '';
|
|
57
|
+
const varName = deriveVarName(lineText, t.ruleId);
|
|
58
|
+
// Replace the literal with process.env.VARNAME (quoted forms first, bare as a fallback).
|
|
59
|
+
src = src
|
|
60
|
+
.split(`"${t.secret}"`)
|
|
61
|
+
.join(`process.env.${varName}`)
|
|
62
|
+
.split(`'${t.secret}'`)
|
|
63
|
+
.join(`process.env.${varName}`)
|
|
64
|
+
.split(t.secret)
|
|
65
|
+
.join(`process.env.${varName}`);
|
|
66
|
+
writeFileSync(t.file, src);
|
|
67
|
+
// Move the value to a gitignored .env.
|
|
68
|
+
const env = join(dir, '.env');
|
|
69
|
+
if (!existsSync(env) || !readFileSync(env, 'utf8').includes(`${varName}=`))
|
|
70
|
+
appendFileSync(env, `${varName}=${t.secret}\n`);
|
|
71
|
+
const gi = join(dir, '.gitignore');
|
|
72
|
+
const giTxt = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
|
73
|
+
if (!/^\.env\s*$/m.test(giTxt))
|
|
74
|
+
writeFileSync(gi, (giTxt && !giTxt.endsWith('\n') ? `${giTxt}\n` : giTxt) + '.env\n');
|
|
75
|
+
// PROVE CLOSED: the secret must be gone from shippable source.
|
|
76
|
+
const provenClosed = !readFileSync(t.file, 'utf8').includes(t.secret);
|
|
77
|
+
const file = isAbsolute(t.file) ? relative(dir, t.file) : t.file;
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
file,
|
|
81
|
+
varName,
|
|
82
|
+
provenClosed,
|
|
83
|
+
rotate: true,
|
|
84
|
+
message: provenClosed
|
|
85
|
+
? `Proven closed: the secret is gone from ${file} — it now reads process.env.${varName}, and the value moved to .env (now gitignored, so it won't ship or get committed). ⚠ You MUST still rotate this key now — it was already exposed, so revoke the old value.`
|
|
86
|
+
: `Moved the secret to .env, but couldn't fully verify removal from ${file} — please check it by hand.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=fix-secret.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { writeFileAtomic } from '../storage/atomic-write.js';
|
|
1
2
|
/**
|
|
2
3
|
* Per-cwd permissions config — P18 slice 1 primitive.
|
|
3
4
|
*
|
|
@@ -151,7 +152,7 @@ export function filterAllowed(findings, cwd) {
|
|
|
151
152
|
// Shared by the CLI (`codehere permission allow ...`) and the HTTP write
|
|
152
153
|
// endpoint (POST /api/permissions). Single source of truth for the
|
|
153
154
|
// idempotent-add semantics + the YAML serializer.
|
|
154
|
-
import { mkdirSync
|
|
155
|
+
import { mkdirSync } from 'fs';
|
|
155
156
|
/**
|
|
156
157
|
* Idempotent add — if the pattern already exists in the bucket, returns
|
|
157
158
|
* status='already_present' with the existing entry. Otherwise appends +
|
|
@@ -200,7 +201,7 @@ function writePermissionsConfig(cwd, cfg) {
|
|
|
200
201
|
if (!existsSync(dir))
|
|
201
202
|
mkdirSync(dir, { recursive: true });
|
|
202
203
|
const filePath = join(dir, 'permissions.yaml');
|
|
203
|
-
|
|
204
|
+
writeFileAtomic(filePath, serializePermissionsYaml(cfg));
|
|
204
205
|
return filePath;
|
|
205
206
|
}
|
|
206
207
|
export function serializePermissionsYaml(cfg) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape a scan finding takes, shared by everything that produces or consumes one.
|
|
3
|
+
*
|
|
4
|
+
* These lived in ship-scan.ts, which made vibe-checks.ts import from the module that imports
|
|
5
|
+
* it — a type-only cycle, erased at compile time and harmless at runtime, but the release
|
|
6
|
+
* checklist requires zero circular dependencies and a type-only cycle is still a cycle a
|
|
7
|
+
* later value import can turn into a real one.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=scan-types.js.map
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ship-readiness scan — orchestrates the installed OSS scanners (gitleaks + semgrep) and adds
|
|
3
|
+
* codehere's value: PRIORITIZE by CONSEQUENCE and surface the ONE blocker that gets you hacked,
|
|
4
|
+
* in plain language for a non-expert.
|
|
5
|
+
*
|
|
6
|
+
* Deterministic, ZERO model tokens — the detection is OSS (battle-tested, better than hand-rolled);
|
|
7
|
+
* codehere is the non-expert layer on top. See docs/PRODUCT_10X.md (R1 detect via OSS, R2 prioritize).
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync, execFileSync } from 'node:child_process';
|
|
10
|
+
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
13
|
+
import { vibeChecks } from './vibe-checks.js';
|
|
14
|
+
const TIER_RANK = { critical: 0, high: 1, medium: 2 };
|
|
15
|
+
function installed(tool) {
|
|
16
|
+
try {
|
|
17
|
+
execFileSync('which', [tool], { stdio: 'ignore' });
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function rel(dir, file) {
|
|
25
|
+
if (!file)
|
|
26
|
+
return '';
|
|
27
|
+
return isAbsolute(file) ? relative(dir, file) : file;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A secret parked in a gitignored file (the .env our own fix moves it to) is AT REST — it won't be
|
|
31
|
+
* committed or shipped, so it's not a ship-blocker. Filter those out, otherwise the scan re-flags the
|
|
32
|
+
* very file the fix created and "prove it's closed" contradicts itself. Honors full .gitignore
|
|
33
|
+
* semantics via git; falls back to a literal .gitignore read for the common .env case in a non-git dir.
|
|
34
|
+
*/
|
|
35
|
+
function filterAtRest(dir, findings) {
|
|
36
|
+
if (findings.length === 0)
|
|
37
|
+
return findings;
|
|
38
|
+
const files = [...new Set(findings.map((f) => f.file))];
|
|
39
|
+
const ignored = new Set();
|
|
40
|
+
const res = spawnSync('git', ['-C', dir, 'check-ignore', '--stdin'], { input: files.join('\n'), encoding: 'utf8' });
|
|
41
|
+
if (res.status === 0 && res.stdout) {
|
|
42
|
+
for (const p of res.stdout
|
|
43
|
+
.split('\n')
|
|
44
|
+
.map((s) => s.trim())
|
|
45
|
+
.filter(Boolean))
|
|
46
|
+
ignored.add(p);
|
|
47
|
+
}
|
|
48
|
+
else if (res.status === 128) {
|
|
49
|
+
// Not a git repo yet — minimal fallback so the fix→prove loop is honest even pre-`git init`.
|
|
50
|
+
const giPath = join(dir, '.gitignore');
|
|
51
|
+
const gi = existsSync(giPath) ? readFileSync(giPath, 'utf8') : '';
|
|
52
|
+
const patterns = gi
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((l) => l.trim())
|
|
55
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
56
|
+
for (const f of files) {
|
|
57
|
+
const base = f.split('/').pop() || f;
|
|
58
|
+
if (patterns.some((p) => p === f || p === base || (p.startsWith('*') && base.endsWith(p.slice(1)))))
|
|
59
|
+
ignored.add(f);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return findings.filter((f) => !ignored.has(f.file));
|
|
63
|
+
}
|
|
64
|
+
/** gitleaks = exposed secrets/keys — the #1 way vibe-coded apps get owned. Always critical. */
|
|
65
|
+
function runGitleaks(dir) {
|
|
66
|
+
const tmp = join(mkdtempSync(join(tmpdir(), 'sscan-')), 'gl.json');
|
|
67
|
+
// gitleaks exits non-zero when leaks are found — spawnSync (no throw), read the report regardless.
|
|
68
|
+
spawnSync('gitleaks', ['detect', '--no-git', '--source', dir, '-f', 'json', '-r', tmp], { stdio: 'ignore' });
|
|
69
|
+
// No report file = the run died before writing one. Zero findings here means "we don't know",
|
|
70
|
+
// not "nothing found", so ok:false — the surface must not spend it as coverage.
|
|
71
|
+
if (!existsSync(tmp))
|
|
72
|
+
return { ok: false, findings: [] };
|
|
73
|
+
let arr;
|
|
74
|
+
try {
|
|
75
|
+
arr = JSON.parse(readFileSync(tmp, 'utf8'));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return { ok: false, findings: [] };
|
|
79
|
+
}
|
|
80
|
+
const findings = (arr || []).map((f) => {
|
|
81
|
+
const ruleId = String(f.RuleID || 'secret');
|
|
82
|
+
const file = rel(dir, String(f.File || ''));
|
|
83
|
+
return {
|
|
84
|
+
tool: 'gitleaks',
|
|
85
|
+
ruleId,
|
|
86
|
+
file,
|
|
87
|
+
line: Number(f.StartLine || 0),
|
|
88
|
+
tier: 'critical',
|
|
89
|
+
what: `A live secret (${ruleId}) is hardcoded in ${file}. Anyone who can read your code — in your repo, or shipped to your users — has it, and can use it to reach whatever it unlocks: your database, your users' data, your paid APIs.`,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
return { ok: true, findings };
|
|
93
|
+
}
|
|
94
|
+
/** Map a semgrep check to a consequence tier + plain-language "what happens". */
|
|
95
|
+
function classifySemgrep(checkId, severity) {
|
|
96
|
+
const id = checkId.toLowerCase();
|
|
97
|
+
const danger = (kind) => (file) => `Untrusted input reaches ${kind} in ${file}. An attacker can ${{
|
|
98
|
+
command: 'run any command on your server',
|
|
99
|
+
sql: 'read or wipe your entire database',
|
|
100
|
+
code: 'run their own code in your app',
|
|
101
|
+
path: "read files outside your app (like other users' data or secrets)",
|
|
102
|
+
ssrf: 'make your server hit internal/private systems',
|
|
103
|
+
xss: "run scripts in your users' browsers (steal sessions, deface the page)",
|
|
104
|
+
}[kind] || 'do something you did not intend'}.`;
|
|
105
|
+
if (/(command|child.?process|os.?command|exec)/.test(id))
|
|
106
|
+
return { tier: 'critical', what: danger('command') };
|
|
107
|
+
if (/(sql|nosql|query.?injection)/.test(id))
|
|
108
|
+
return { tier: 'critical', what: danger('sql') };
|
|
109
|
+
if (/(eval|code.?injection|deserial)/.test(id))
|
|
110
|
+
return { tier: 'critical', what: danger('code') };
|
|
111
|
+
if (/(path.?traversal|directory.?traversal|zip.?slip)/.test(id))
|
|
112
|
+
return { tier: 'critical', what: danger('path') };
|
|
113
|
+
if (/ssrf/.test(id))
|
|
114
|
+
return { tier: 'critical', what: danger('ssrf') };
|
|
115
|
+
if (/(jwt|secret|hardcoded|api.?key|password)/.test(id))
|
|
116
|
+
return {
|
|
117
|
+
tier: 'critical',
|
|
118
|
+
what: (file) => `A secret or weak credential handling was found in ${file} — it can let an attacker into your data.`,
|
|
119
|
+
};
|
|
120
|
+
if (/xss/.test(id))
|
|
121
|
+
return { tier: 'high', what: danger('xss') };
|
|
122
|
+
const sev = String(severity || '').toUpperCase();
|
|
123
|
+
const tier = sev === 'ERROR' ? 'high' : 'medium';
|
|
124
|
+
return {
|
|
125
|
+
tier,
|
|
126
|
+
what: (file) => `A security issue (${checkId.split('.').pop()}) was found in ${file} that could be exploited before you ship.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function runSemgrep(dir) {
|
|
130
|
+
const tmp = join(mkdtempSync(join(tmpdir(), 'sscan-')), 'sg.json');
|
|
131
|
+
// p/default is the curated default registry ruleset; --quiet keeps output clean. It is NOT
|
|
132
|
+
// offline and NOT fast — measured 2026-08-08, `semgrep scan --config p/default` on a cold
|
|
133
|
+
// registry cache blew past 200s on a developer laptop and hit the timeout below. The earlier
|
|
134
|
+
// comment here claimed "offline-capable, fast", which is why the zero findings it returns on
|
|
135
|
+
// timeout were being read as a clean scan.
|
|
136
|
+
spawnSync('semgrep', ['scan', '--config', 'p/default', '--json', '--quiet', '--no-git-ignore', dir, '-o', tmp], {
|
|
137
|
+
stdio: 'ignore',
|
|
138
|
+
timeout: 180_000,
|
|
139
|
+
});
|
|
140
|
+
// Timeout, network failure, or crash: no report file. Zero findings is absence of evidence.
|
|
141
|
+
if (!existsSync(tmp))
|
|
142
|
+
return { ok: false, findings: [] };
|
|
143
|
+
let json;
|
|
144
|
+
try {
|
|
145
|
+
json = JSON.parse(readFileSync(tmp, 'utf8'));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { ok: false, findings: [] };
|
|
149
|
+
}
|
|
150
|
+
const findings = (json.results || []).map((r) => {
|
|
151
|
+
const checkId = String(r.check_id || 'rule');
|
|
152
|
+
const file = rel(dir, String(r.path || ''));
|
|
153
|
+
const severity = String(r.extra?.severity || '');
|
|
154
|
+
const c = classifySemgrep(checkId, severity);
|
|
155
|
+
return {
|
|
156
|
+
tool: 'semgrep',
|
|
157
|
+
ruleId: checkId.split('.').pop() || checkId,
|
|
158
|
+
file,
|
|
159
|
+
line: Number(r.start?.line || 0),
|
|
160
|
+
tier: c.tier,
|
|
161
|
+
what: c.what(file),
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
return { ok: true, findings };
|
|
165
|
+
}
|
|
166
|
+
/** Run the OSS scanners on `dir`, normalize + prioritize by consequence, surface the ONE top blocker. */
|
|
167
|
+
export function shipScan(dir) {
|
|
168
|
+
const hasGl = installed('gitleaks');
|
|
169
|
+
const hasSg = installed('semgrep');
|
|
170
|
+
const missing = [];
|
|
171
|
+
if (!hasGl)
|
|
172
|
+
missing.push('gitleaks');
|
|
173
|
+
if (!hasSg)
|
|
174
|
+
missing.push('semgrep');
|
|
175
|
+
const all = [];
|
|
176
|
+
const gl = hasGl ? runGitleaks(dir) : { ok: false, findings: [] };
|
|
177
|
+
const sg = hasSg ? runSemgrep(dir) : { ok: false, findings: [] };
|
|
178
|
+
all.push(...gl.findings, ...sg.findings);
|
|
179
|
+
// Installed but produced nothing readable — a coverage hole the surface has to name out loud.
|
|
180
|
+
const failed = [];
|
|
181
|
+
if (hasGl && !gl.ok)
|
|
182
|
+
failed.push('gitleaks');
|
|
183
|
+
if (hasSg && !sg.ok)
|
|
184
|
+
failed.push('semgrep');
|
|
185
|
+
// codehere's own vibe-coder-specific checks — the dangers gitleaks/semgrep miss (always runs, free).
|
|
186
|
+
all.push(...vibeChecks(dir));
|
|
187
|
+
// Drop anything that lives in a gitignored file — it's at rest, won't ship or get committed, so it's
|
|
188
|
+
// not a ship-blocker. This is what makes the fix→prove loop honest: the fix parks a secret in a
|
|
189
|
+
// gitignored .env, and the re-scan must come back clean instead of re-flagging the file it just created.
|
|
190
|
+
const findings = filterAtRest(dir, all);
|
|
191
|
+
// Prioritize: consequence tier first, then secrets (gitleaks) ahead of code patterns at the same tier.
|
|
192
|
+
findings.sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier] || (a.tool === 'gitleaks' ? -1 : 1));
|
|
193
|
+
return { ran: { gitleaks: gl.ok, semgrep: sg.ok }, missing, failed, findings, top: findings[0] || null };
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=ship-scan.js.map
|
|
@@ -52,10 +52,31 @@ export const CODE_PATTERNS = [
|
|
|
52
52
|
scanRaw: true,
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
|
-
|
|
55
|
+
// Provider key formats a dev actually hardcodes. Anthropic (sk-ant-) FIRST:
|
|
56
|
+
// it carries hyphens, so the generic sk-[alnum]{32,} branch never matched —
|
|
57
|
+
// and codehere is an Anthropic-SDK product, so it's the most likely leak.
|
|
58
|
+
// A dogfood sweep (cycle 9) then caught more misses: Stripe (sk_live_ has an
|
|
59
|
+
// underscore, not a hyphen), AWS access keys (only the .aws/credentials FILE
|
|
60
|
+
// read was flagged, never a hardcoded AKIA…), and GitHub fine-grained PATs
|
|
61
|
+
// (github_pat_, distinct from the classic ghp_).
|
|
62
|
+
// /g so a file with multiple DISTINCT hardcoded keys reports each one — a
|
|
63
|
+
// trust scan should surface every secret in one pass, not just the first
|
|
64
|
+
// (the engine's exec-loop breaks after match #1 unless the regex is global).
|
|
65
|
+
regex: /sk-ant-[a-zA-Z0-9_-]{20,}|sk-[a-zA-Z0-9]{32,}|(?:sk|rk)_(?:live|test)_[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}|github_pat_[A-Za-z0-9_]{22,}|xoxb-[a-zA-Z0-9-]+|ghp_[a-zA-Z0-9]{36,}/gi,
|
|
56
66
|
severity: 'critical',
|
|
57
67
|
category: 'Secrets',
|
|
58
|
-
description: 'API key
|
|
68
|
+
description: 'Hardcoded API key / secret (Anthropic, OpenAI, Stripe, AWS, GitHub, Slack)',
|
|
69
|
+
cwe: 'CWE-798',
|
|
70
|
+
scanRaw: true,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
// A hardcoded PRIVATE KEY block in source is among the worst possible leaks,
|
|
74
|
+
// and trust was blind to it (cycle 10 dogfood) — the existing key rule only
|
|
75
|
+
// flags READING a .pem/.ssh file, not an inline -----BEGIN … PRIVATE KEY-----.
|
|
76
|
+
regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/gi,
|
|
77
|
+
severity: 'critical',
|
|
78
|
+
category: 'Secrets',
|
|
79
|
+
description: 'Hardcoded private key (PEM block)',
|
|
59
80
|
cwe: 'CWE-798',
|
|
60
81
|
scanRaw: true,
|
|
61
82
|
},
|
|
@@ -94,6 +115,17 @@ export const CODE_PATTERNS = [
|
|
|
94
115
|
description: 'String concatenation in SQL query call',
|
|
95
116
|
cwe: 'CWE-89',
|
|
96
117
|
},
|
|
118
|
+
// SQL built in a template literal — catches the case the `query(...)` pattern above misses:
|
|
119
|
+
// the query is assigned to a variable first (`const q = `SELECT...${x}``) then passed to query(q).
|
|
120
|
+
// Anchored on real SQL shape (SELECT…FROM / INSERT…INTO / UPDATE…SET / DELETE…FROM) to stay low-noise.
|
|
121
|
+
{
|
|
122
|
+
regex: /`[^`]*\b(?:SELECT\b[^`]*\bFROM|INSERT\b[^`]*\bINTO|UPDATE\b[^`]*\bSET|DELETE\b[^`]*\bFROM)\b[^`]*\$\{/gi,
|
|
123
|
+
severity: 'critical',
|
|
124
|
+
category: 'SQL Injection',
|
|
125
|
+
description: 'SQL query built in a template literal with interpolation',
|
|
126
|
+
cwe: 'CWE-89',
|
|
127
|
+
scanRaw: true,
|
|
128
|
+
},
|
|
97
129
|
// Command injection with user input (scan with strings visible)
|
|
98
130
|
// Catches require('child_process').execSync(req.body.cmd) — child_process is in a string
|
|
99
131
|
// but the method call and req. reference are in plain code
|
|
@@ -639,9 +671,17 @@ export function scoreFile(filepath, code) {
|
|
|
639
671
|
if (complexity.todos > 5)
|
|
640
672
|
penalty += 3;
|
|
641
673
|
const score = Math.max(0, Math.min(100, 100 - penalty));
|
|
642
|
-
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
|
643
674
|
const critical = allFindings.filter((f) => f.severity === 'critical').length;
|
|
644
675
|
const high = allFindings.filter((f) => f.severity === 'high').length;
|
|
676
|
+
// Honesty floor (It-0 finding): a file with a CRITICAL finding cannot read as
|
|
677
|
+
// a passing grade — eval(userInput) + a hardcoded key scoring "B 75/100" is
|
|
678
|
+
// the optimistic-average lie. A critical caps the grade at F; a high (no
|
|
679
|
+
// critical) caps it at C. The numeric score still shows the gradient below.
|
|
680
|
+
let grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
|
681
|
+
if (critical > 0)
|
|
682
|
+
grade = 'F';
|
|
683
|
+
else if (high > 0 && (grade === 'A' || grade === 'B'))
|
|
684
|
+
grade = 'C';
|
|
645
685
|
const summary = critical > 0
|
|
646
686
|
? `${critical} critical issue${critical > 1 ? 's' : ''} require immediate attention`
|
|
647
687
|
: high > 0
|
|
@@ -654,4 +694,31 @@ export function scoreFile(filepath, code) {
|
|
|
654
694
|
export function gradeFromScore(score) {
|
|
655
695
|
return score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
|
656
696
|
}
|
|
697
|
+
/**
|
|
698
|
+
* S8 · the ONE project-level aggregation. `trust`, `trust --badge`, and
|
|
699
|
+
* `report` all derive the headline score + grade from here — each command
|
|
700
|
+
* used to re-implement this reduce-and-cap block (the same drift that
|
|
701
|
+
* historically hit the file-collection rules), and the badge could disagree
|
|
702
|
+
* with the report for the same target. Same inputs → same number,
|
|
703
|
+
* structurally.
|
|
704
|
+
*/
|
|
705
|
+
export function aggregateTrust(results) {
|
|
706
|
+
const count = (sev) => results.reduce((s, r) => s + r.findings.filter((f) => f.severity === sev).length, 0);
|
|
707
|
+
const criticalCount = count('critical');
|
|
708
|
+
const highCount = count('high');
|
|
709
|
+
const mediumCount = count('medium');
|
|
710
|
+
const lowCount = count('low');
|
|
711
|
+
const totalFindings = results.reduce((s, r) => s + r.findings.length, 0);
|
|
712
|
+
const overallScore = results.length > 0 ? Math.round(results.reduce((s, r) => s + r.score, 0) / results.length) : 100;
|
|
713
|
+
// Honesty floor (It-0): a scan with any CRITICAL finding can't read as a
|
|
714
|
+
// passing grade — eval()/hardcoded-key scoring "B" is the optimistic-average
|
|
715
|
+
// lie. Critical → F; high (no critical) → no better than C. Score still
|
|
716
|
+
// shows the gradient.
|
|
717
|
+
let grade = overallScore >= 90 ? 'A' : overallScore >= 75 ? 'B' : overallScore >= 60 ? 'C' : overallScore >= 40 ? 'D' : 'F';
|
|
718
|
+
if (criticalCount > 0)
|
|
719
|
+
grade = 'F';
|
|
720
|
+
else if (highCount > 0 && (grade === 'A' || grade === 'B'))
|
|
721
|
+
grade = 'C';
|
|
722
|
+
return { overallScore, grade, criticalCount, highCount, mediumCount, lowCount, totalFindings };
|
|
723
|
+
}
|
|
657
724
|
//# sourceMappingURL=trust-scorer.js.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vibe-coder-specific danger checks — the high-consequence things gitleaks + semgrep DON'T catch by
|
|
3
|
+
* default but that the research named as the #1 ways AI-built apps get owned. Deterministic, zero
|
|
4
|
+
* model tokens. Pattern-based on the source files. See docs/PRODUCT_10X.md (step 1 of the plan).
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join, extname, relative } from 'node:path';
|
|
8
|
+
const SRC_EXT = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte', '.html', '.astro']);
|
|
9
|
+
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'vendor', 'coverage', '.turbo']);
|
|
10
|
+
function walk(dir, files, depth = 0) {
|
|
11
|
+
if (depth > 8)
|
|
12
|
+
return;
|
|
13
|
+
let ents;
|
|
14
|
+
try {
|
|
15
|
+
ents = readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const e of ents) {
|
|
21
|
+
if (SKIP_DIR.has(e.name))
|
|
22
|
+
continue;
|
|
23
|
+
const p = join(dir, e.name);
|
|
24
|
+
if (e.isDirectory())
|
|
25
|
+
walk(p, files, depth + 1);
|
|
26
|
+
else if (SRC_EXT.has(extname(e.name)))
|
|
27
|
+
files.push(p);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const CHECKS = [
|
|
31
|
+
{
|
|
32
|
+
// The real escalation danger: a PRIVILEGE flag in browser storage (not a token, which is standard).
|
|
33
|
+
id: 'privilege-flag-in-browser-storage',
|
|
34
|
+
re: /(localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*['"`](role|is_?admin|admin|is_?staff|is_?paid|paid|premium|pro|plan|tier|permission|access_?level|can_|entitlement)/i,
|
|
35
|
+
tier: 'critical',
|
|
36
|
+
what: (f) => `A permission flag (admin/role/plan) is stored in the browser in ${f}. A user can open dev-tools, change it, and give themselves admin or paid access — your app trusts a value the user controls. Decide permissions on the server, never from localStorage.`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'supabase-service-role-key',
|
|
40
|
+
re: /service_role|SUPABASE_SERVICE_ROLE/i,
|
|
41
|
+
tier: 'critical',
|
|
42
|
+
what: (f) => `A Supabase service_role key is referenced in ${f}. That key bypasses ALL your database rules — if it reaches the browser, anyone can read and write your entire database. It must live only on your server, never in client code.`,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
// User input string-built into a SQL query — concat ("...WHERE id="+x) or interpolation (`...${x}`).
|
|
46
|
+
// Parameterized queries ($1 / ? placeholders) don't match, so this stays low-noise.
|
|
47
|
+
id: 'sql-injection-string-building',
|
|
48
|
+
re: /\b(SELECT\b[^;\n'"`]*\bFROM\b|INSERT\b[^;\n'"`]*\bINTO\b|UPDATE\b[^;\n'"`]*\bSET\b|DELETE\b[^;\n'"`]*\bFROM\b)[^;\n]*?(\$\{|['"`]\s*\+\s*\w)/i,
|
|
49
|
+
tier: 'critical',
|
|
50
|
+
what: (f) => `User input is being string-built into a SQL query in ${f}. An attacker can put SQL inside that input and read, change, or wipe your entire database. Use parameterized queries (placeholders like $1 or ?), never glue the query together with + or \${}.`,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'unsafe-html-injection',
|
|
54
|
+
re: /dangerouslySetInnerHTML|v-html\s*=|\.innerHTML\s*=\s*[^'"`;\s]/,
|
|
55
|
+
tier: 'high',
|
|
56
|
+
what: (f) => `Raw HTML is written into the page in ${f}. If any of it comes from a user, an attacker can run scripts in your visitors' browsers — steal their logins, deface the page, or act as them.`,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'auth-disabled-or-todo',
|
|
60
|
+
re: /\/\/\s*(TODO|FIXME).*(auth|login|permission|secur)|auth\s*[:=]\s*false|requireAuth\s*[:=]\s*false|skipAuth\s*[:=]\s*true/i,
|
|
61
|
+
tier: 'high',
|
|
62
|
+
what: (f) => `Authentication looks turned off or left as a TODO in ${f}. AI agents often stub auth out to "make it work" and never finish it — anyone could reach pages or data meant to be protected.`,
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
/** Scan the source files in `dir` for vibe-coder-specific dangers. One hit per check per file. */
|
|
66
|
+
export function vibeChecks(dir) {
|
|
67
|
+
const files = [];
|
|
68
|
+
walk(dir, files);
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
let txt;
|
|
72
|
+
try {
|
|
73
|
+
txt = readFileSync(f, 'utf8');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (txt.length > 500_000)
|
|
79
|
+
continue; // skip huge/minified files
|
|
80
|
+
const lines = txt.split('\n');
|
|
81
|
+
for (const c of CHECKS) {
|
|
82
|
+
for (let i = 0; i < lines.length; i++) {
|
|
83
|
+
if (c.re.test(lines[i])) {
|
|
84
|
+
out.push({
|
|
85
|
+
tool: 'codehere',
|
|
86
|
+
ruleId: c.id,
|
|
87
|
+
file: relative(dir, f),
|
|
88
|
+
line: i + 1,
|
|
89
|
+
tier: c.tier,
|
|
90
|
+
what: c.what(relative(dir, f)),
|
|
91
|
+
});
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=vibe-checks.js.map
|