codeproof 1.0.0
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/bin/codeproof.js +43 -0
- package/commands/init.js +72 -0
- package/commands/moveSecret.js +220 -0
- package/commands/reportDashboard.js +78 -0
- package/commands/run.js +196 -0
- package/core/boundaries.md +26 -0
- package/core/featureFlags.js +25 -0
- package/core/safetyGuards.js +51 -0
- package/engine/aiAnalyzer.js +51 -0
- package/engine/aiEscalation.js +6 -0
- package/engine/contextBuilder.js +65 -0
- package/engine/decisionMerger.js +30 -0
- package/engine/ruleEngine.js +52 -0
- package/hooks/preCommit.js +67 -0
- package/package.json +13 -0
- package/reporting/reportBuilder.js +100 -0
- package/reporting/reportWriter.js +19 -0
- package/rules/dangerousUsageRule.js +11 -0
- package/rules/insecureConfigRule.js +11 -0
- package/rules/regexPatterns.js +53 -0
- package/rules/ruleUtils.js +58 -0
- package/rules/secretRule.js +21 -0
- package/ui/welcomeScreen.js +42 -0
- package/utils/apiClient.js +56 -0
- package/utils/envManager.js +48 -0
- package/utils/fileRewriter.js +145 -0
- package/utils/files.js +50 -0
- package/utils/git.js +46 -0
- package/utils/logger.js +25 -0
- package/utils/projectType.js +20 -0
package/bin/codeproof.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runInit } from "../commands/init.js";
|
|
3
|
+
import { runCli } from "../commands/run.js";
|
|
4
|
+
import { runReportDashboard } from "../commands/reportDashboard.js";
|
|
5
|
+
import { runMoveSecret } from "../commands/moveSecret.js";
|
|
6
|
+
import { logError, logInfo } from "../utils/logger.js";
|
|
7
|
+
|
|
8
|
+
const [, , command, ...args] = process.argv;
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
if (!command || command === "-h" || command === "--help") {
|
|
12
|
+
logInfo("Usage: codeproof <command>\n\nCommands:\n init Initialize CodeProof in a Git repository\n run Run CodeProof checks (stub)\n report@dashboard Send latest report and show dashboard link\n move-secret Move high-confidence secrets to .env");
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (command === "init") {
|
|
17
|
+
await runInit({ args, cwd: process.cwd() });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (command === "run") {
|
|
22
|
+
await runCli({ args, cwd: process.cwd() });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (command === "report@dashboard") {
|
|
27
|
+
await runReportDashboard({ args, cwd: process.cwd() });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (command === "move-secret") {
|
|
32
|
+
await runMoveSecret({ args, cwd: process.cwd() });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
logError(`Unknown command: ${command}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
main().catch((error) => {
|
|
41
|
+
logError(error?.message || String(error));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
});
|
package/commands/init.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { ensureGitRepo, getGitRoot } from "../utils/git.js";
|
|
4
|
+
import { logInfo, logSuccess, logWarn } from "../utils/logger.js";
|
|
5
|
+
import { detectProjectType } from "../utils/projectType.js";
|
|
6
|
+
import { installPreCommitHook } from "../hooks/preCommit.js";
|
|
7
|
+
import { showWelcomeScreen } from "../ui/welcomeScreen.js";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export async function runInit({ cwd }) {
|
|
12
|
+
logInfo("Initializing CodeProof...");
|
|
13
|
+
|
|
14
|
+
ensureGitRepo(cwd);
|
|
15
|
+
logSuccess("Git repository detected.");
|
|
16
|
+
|
|
17
|
+
const gitRoot = getGitRoot(cwd);
|
|
18
|
+
logInfo(`Project root: ${gitRoot}`);
|
|
19
|
+
|
|
20
|
+
const projectType = detectProjectType(gitRoot);
|
|
21
|
+
logInfo(`Detected project type: ${projectType}`);
|
|
22
|
+
|
|
23
|
+
const configPath = path.join(gitRoot, "codeproof.config.json");
|
|
24
|
+
// Avoid overwriting user configuration to keep init idempotent.
|
|
25
|
+
if (fs.existsSync(configPath)) {
|
|
26
|
+
logWarn("Config already exists. Skipping creation.");
|
|
27
|
+
} else {
|
|
28
|
+
const config = {
|
|
29
|
+
projectType,
|
|
30
|
+
scanMode: "staged",
|
|
31
|
+
features: {
|
|
32
|
+
reporting: true,
|
|
33
|
+
integration: false,
|
|
34
|
+
aiEscalation: false,
|
|
35
|
+
secretRemediation: false
|
|
36
|
+
},
|
|
37
|
+
integration: {
|
|
38
|
+
enabled: false,
|
|
39
|
+
endpointUrl: ""
|
|
40
|
+
},
|
|
41
|
+
severityRules: {
|
|
42
|
+
block: [],
|
|
43
|
+
warn: [],
|
|
44
|
+
allow: []
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
48
|
+
logSuccess("Created codeproof.config.json");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
installPreCommitHook(gitRoot);
|
|
52
|
+
logSuccess("Pre-commit hook installed.");
|
|
53
|
+
|
|
54
|
+
logSuccess("CodeProof initialization complete.");
|
|
55
|
+
|
|
56
|
+
let scanMode = "staged";
|
|
57
|
+
try {
|
|
58
|
+
const configRaw = fs.readFileSync(configPath, "utf8");
|
|
59
|
+
const parsed = JSON.parse(configRaw);
|
|
60
|
+
if (parsed?.scanMode) {
|
|
61
|
+
scanMode = String(parsed.scanMode).toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// UX: welcome message should never fail init; fall back to defaults for display.
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
showWelcomeScreen({
|
|
68
|
+
projectType,
|
|
69
|
+
scanMode,
|
|
70
|
+
configPath
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import readline from "readline";
|
|
4
|
+
import { ensureGitRepo, getGitRoot } from "../utils/git.js";
|
|
5
|
+
import { getDefaultExcludes } from "../utils/files.js";
|
|
6
|
+
import { logInfo, logWarn } from "../utils/logger.js";
|
|
7
|
+
import { ensureEnvFile, readEnvKeys, appendEnvEntries } from "../utils/envManager.js";
|
|
8
|
+
import { backupFileOnce, extractSecretValueFromLine, replaceSecretInFile } from "../utils/fileRewriter.js";
|
|
9
|
+
import { resolveFeatureFlags, isVerbose } from "../core/featureFlags.js";
|
|
10
|
+
import { reportFeatureDisabled, warnExperimentalOnce } from "../core/safetyGuards.js";
|
|
11
|
+
|
|
12
|
+
const TEST_PATH_HINTS = [
|
|
13
|
+
"test",
|
|
14
|
+
"tests",
|
|
15
|
+
"__tests__",
|
|
16
|
+
"spec",
|
|
17
|
+
"example",
|
|
18
|
+
"examples",
|
|
19
|
+
"sample",
|
|
20
|
+
"samples",
|
|
21
|
+
"mock",
|
|
22
|
+
"mocks"
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function isTestLike(filePath) {
|
|
26
|
+
const normalized = filePath.toLowerCase();
|
|
27
|
+
return TEST_PATH_HINTS.some((hint) => normalized.includes(path.sep + hint));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isIgnoredPath(filePath, excludes) {
|
|
31
|
+
const segments = filePath.split(path.sep).map((segment) => segment.toLowerCase());
|
|
32
|
+
for (const segment of segments) {
|
|
33
|
+
if (excludes.has(segment)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readLatestReport(reportPath) {
|
|
41
|
+
if (!fs.existsSync(reportPath)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const content = fs.readFileSync(reportPath, "utf8");
|
|
46
|
+
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
47
|
+
if (lines.length === 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(lines[lines.length - 1]);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function confirmProceed(message) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const rl = readline.createInterface({
|
|
61
|
+
input: process.stdin,
|
|
62
|
+
output: process.stdout
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
rl.question(message, (answer) => {
|
|
66
|
+
rl.close();
|
|
67
|
+
resolve(String(answer).trim().toLowerCase() === "y");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function runMoveSecret({ cwd }) {
|
|
73
|
+
// Boundary: remediation reads reports only and must not depend on analysis state.
|
|
74
|
+
ensureGitRepo(cwd);
|
|
75
|
+
const gitRoot = getGitRoot(cwd);
|
|
76
|
+
const reportPath = path.join(gitRoot, "codeproof-report.log");
|
|
77
|
+
const configPath = path.join(gitRoot, "codeproof.config.json");
|
|
78
|
+
let config = {};
|
|
79
|
+
try {
|
|
80
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
81
|
+
config = JSON.parse(raw);
|
|
82
|
+
} catch {
|
|
83
|
+
config = {};
|
|
84
|
+
logWarn("Unable to read codeproof.config.json. Using safe defaults.");
|
|
85
|
+
}
|
|
86
|
+
const features = resolveFeatureFlags(config);
|
|
87
|
+
const verbose = isVerbose(config);
|
|
88
|
+
|
|
89
|
+
if (!features.secretRemediation) {
|
|
90
|
+
reportFeatureDisabled("Secret remediation", verbose, logInfo);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
warnExperimentalOnce("Experimental feature enabled: move-secret.", logWarn);
|
|
95
|
+
const latestReport = readLatestReport(reportPath);
|
|
96
|
+
|
|
97
|
+
if (!latestReport || !Array.isArray(latestReport.findings)) {
|
|
98
|
+
logWarn("No reports found. Run 'codeproof run' first.");
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const excludes = getDefaultExcludes();
|
|
103
|
+
|
|
104
|
+
const eligible = latestReport.findings.filter((finding) => {
|
|
105
|
+
if (finding.ruleId?.startsWith("secret.") !== true) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
if (finding.severity !== "block" || finding.confidence !== "high") {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (!finding.filePath || !finding.lineNumber) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!finding.codeSnippet) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const absolutePath = path.isAbsolute(finding.filePath)
|
|
120
|
+
? finding.filePath
|
|
121
|
+
: path.join(gitRoot, finding.filePath);
|
|
122
|
+
|
|
123
|
+
if (isTestLike(absolutePath)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (isIgnoredPath(absolutePath, excludes)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (eligible.length === 0) {
|
|
135
|
+
logInfo("No eligible high-confidence secrets to move.");
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Safety: secrets are never auto-fixed silently; users must confirm every change.
|
|
140
|
+
logInfo("Eligible secrets preview:");
|
|
141
|
+
for (const finding of eligible) {
|
|
142
|
+
const relative = path.relative(gitRoot, finding.filePath) || finding.filePath;
|
|
143
|
+
logInfo(`- ${relative}:${finding.lineNumber}`);
|
|
144
|
+
}
|
|
145
|
+
logInfo(`Secrets to move: ${eligible.length}`);
|
|
146
|
+
|
|
147
|
+
const confirmed = await confirmProceed("Proceed with moving these secrets? (y/N): ");
|
|
148
|
+
if (!confirmed) {
|
|
149
|
+
logInfo("No changes made.");
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const envPath = ensureEnvFile(gitRoot);
|
|
154
|
+
const existingKeys = readEnvKeys(envPath);
|
|
155
|
+
const newEntries = [];
|
|
156
|
+
const backedUp = new Set();
|
|
157
|
+
let secretIndex = 1;
|
|
158
|
+
let secretsMoved = 0;
|
|
159
|
+
const modifiedFiles = new Set();
|
|
160
|
+
|
|
161
|
+
for (const finding of eligible) {
|
|
162
|
+
const absolutePath = path.isAbsolute(finding.filePath)
|
|
163
|
+
? finding.filePath
|
|
164
|
+
: path.join(gitRoot, finding.filePath);
|
|
165
|
+
|
|
166
|
+
let lineContent = "";
|
|
167
|
+
try {
|
|
168
|
+
const content = fs.readFileSync(absolutePath, "utf8");
|
|
169
|
+
const lines = content.split(/\r?\n/);
|
|
170
|
+
lineContent = lines[finding.lineNumber - 1] || "";
|
|
171
|
+
} catch {
|
|
172
|
+
logWarn(`Skipped ${finding.filePath}:${finding.lineNumber} (unable to read file).`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const expectedSecretValue = extractSecretValueFromLine(lineContent);
|
|
177
|
+
if (!expectedSecretValue) {
|
|
178
|
+
logWarn(`Skipped ${finding.filePath}:${finding.lineNumber} (unable to validate secret value).`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
while (existingKeys.has(`CODEPROOF_SECRET_${secretIndex}`)) {
|
|
183
|
+
secretIndex += 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const envKey = `CODEPROOF_SECRET_${secretIndex}`;
|
|
187
|
+
|
|
188
|
+
// Safety: keep an original copy before any rewrite.
|
|
189
|
+
backupFileOnce(gitRoot, absolutePath, backedUp);
|
|
190
|
+
|
|
191
|
+
const result = replaceSecretInFile({
|
|
192
|
+
filePath: absolutePath,
|
|
193
|
+
lineNumber: finding.lineNumber,
|
|
194
|
+
envKey,
|
|
195
|
+
expectedSnippet: finding.codeSnippet,
|
|
196
|
+
expectedSecretValue
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!result.updated) {
|
|
200
|
+
logWarn(`Skipped ${finding.filePath}:${finding.lineNumber} (${result.reason}).`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
newEntries.push({ key: envKey, value: result.secretValue });
|
|
205
|
+
existingKeys.add(envKey);
|
|
206
|
+
secretsMoved += 1;
|
|
207
|
+
secretIndex += 1;
|
|
208
|
+
modifiedFiles.add(absolutePath);
|
|
209
|
+
|
|
210
|
+
const relative = path.relative(gitRoot, absolutePath) || absolutePath;
|
|
211
|
+
logInfo(`Updated ${relative}:${finding.lineNumber} → process.env.${envKey}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
appendEnvEntries(envPath, newEntries);
|
|
215
|
+
|
|
216
|
+
logInfo("Secret move summary:");
|
|
217
|
+
logInfo(`Secrets moved: ${secretsMoved}`);
|
|
218
|
+
logInfo(`Files modified: ${modifiedFiles.size}`);
|
|
219
|
+
logInfo(`Backup location: ${path.join(gitRoot, ".codeproof-backup")}`);
|
|
220
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { ensureGitRepo, getGitRoot } from "../utils/git.js";
|
|
4
|
+
import { logError, logInfo, logWarn } from "../utils/logger.js";
|
|
5
|
+
import { sendReportToServer } from "../utils/apiClient.js";
|
|
6
|
+
import { resolveFeatureFlags, isVerbose } from "../core/featureFlags.js";
|
|
7
|
+
import { reportFeatureDisabled, withFailOpenIntegration } from "../core/safetyGuards.js";
|
|
8
|
+
|
|
9
|
+
function readConfig(configPath) {
|
|
10
|
+
if (!fs.existsSync(configPath)) {
|
|
11
|
+
logError("Missing codeproof.config.json. Run codeproof init first.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
17
|
+
return JSON.parse(raw);
|
|
18
|
+
} catch {
|
|
19
|
+
logError("Invalid codeproof.config.json. Please fix the file.");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readLatestReport(reportPath) {
|
|
25
|
+
if (!fs.existsSync(reportPath)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const content = fs.readFileSync(reportPath, "utf8");
|
|
30
|
+
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
31
|
+
if (lines.length === 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(lines[lines.length - 1]);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runReportDashboard({ cwd }) {
|
|
43
|
+
// Boundary: CLI orchestration only. Avoid importing this module in lower layers.
|
|
44
|
+
ensureGitRepo(cwd);
|
|
45
|
+
const gitRoot = getGitRoot(cwd);
|
|
46
|
+
const configPath = path.join(gitRoot, "codeproof.config.json");
|
|
47
|
+
const config = readConfig(configPath);
|
|
48
|
+
const features = resolveFeatureFlags(config);
|
|
49
|
+
const verbose = isVerbose(config);
|
|
50
|
+
|
|
51
|
+
if (features.reporting) {
|
|
52
|
+
const reportPath = path.join(gitRoot, "codeproof-report.log");
|
|
53
|
+
const latestReport = readLatestReport(reportPath);
|
|
54
|
+
|
|
55
|
+
if (!latestReport) {
|
|
56
|
+
logWarn("No reports found. Run 'codeproof run' first.");
|
|
57
|
+
} else {
|
|
58
|
+
const integration = config?.integration || {};
|
|
59
|
+
const integrationEnabled = features.integration && Boolean(integration.enabled);
|
|
60
|
+
if (integrationEnabled) {
|
|
61
|
+
// Integrations are fail-open: skip silently when disabled, never throw on network errors.
|
|
62
|
+
withFailOpenIntegration(() => {
|
|
63
|
+
sendReportToServer(latestReport, {
|
|
64
|
+
enabled: true,
|
|
65
|
+
endpointUrl: integration.endpointUrl
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
reportFeatureDisabled("Integration", verbose, logInfo);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
reportFeatureDisabled("Reporting", verbose, logInfo);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const projectId = encodeURIComponent(path.basename(gitRoot) || "project");
|
|
77
|
+
logInfo(`Dashboard: https://dashboard.codeproof.dev/project/${projectId}`);
|
|
78
|
+
}
|
package/commands/run.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { ensureGitRepo, getGitRoot, getStagedFiles } from "../utils/git.js";
|
|
4
|
+
import { logError, logInfo, logSuccess, logWarn } from "../utils/logger.js";
|
|
5
|
+
import { getDefaultExcludes, isBinaryFile, listFilesRecursive } from "../utils/files.js";
|
|
6
|
+
import { runRuleEngine } from "../engine/ruleEngine.js";
|
|
7
|
+
import { analyze } from "../engine/aiAnalyzer.js";
|
|
8
|
+
import { mergeDecisions } from "../engine/decisionMerger.js";
|
|
9
|
+
import { buildAiInputs, buildProjectContext } from "../engine/contextBuilder.js";
|
|
10
|
+
import { buildReport } from "../reporting/reportBuilder.js";
|
|
11
|
+
import { appendReport } from "../reporting/reportWriter.js";
|
|
12
|
+
import { sendReportToServer } from "../utils/apiClient.js";
|
|
13
|
+
import { resolveFeatureFlags, isVerbose } from "../core/featureFlags.js";
|
|
14
|
+
import {
|
|
15
|
+
reportFeatureDisabled,
|
|
16
|
+
warnExperimentalOnce,
|
|
17
|
+
withFailOpenAiEscalation,
|
|
18
|
+
withFailOpenIntegration,
|
|
19
|
+
withFailOpenReporting
|
|
20
|
+
} from "../core/safetyGuards.js";
|
|
21
|
+
|
|
22
|
+
function readConfig(configPath) {
|
|
23
|
+
if (!fs.existsSync(configPath)) {
|
|
24
|
+
logError("Missing codeproof.config.json. Run codeproof init first.");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
logError("Invalid codeproof.config.json. Please fix the file.");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeScopeFiles(files, gitRoot) {
|
|
38
|
+
return files
|
|
39
|
+
.map((filePath) => (path.isAbsolute(filePath) ? filePath : path.join(gitRoot, filePath)))
|
|
40
|
+
.filter((filePath) => {
|
|
41
|
+
try {
|
|
42
|
+
return fs.statSync(filePath).isFile();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function filterBinaryFiles(files) {
|
|
50
|
+
return files.filter((filePath) => !isBinaryFile(filePath));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runCli({ cwd }) {
|
|
54
|
+
// Boundary: CLI orchestration only. Avoid importing this module in lower layers.
|
|
55
|
+
logInfo("CodeProof run started.");
|
|
56
|
+
|
|
57
|
+
ensureGitRepo(cwd);
|
|
58
|
+
const gitRoot = getGitRoot(cwd);
|
|
59
|
+
const configPath = path.join(gitRoot, "codeproof.config.json");
|
|
60
|
+
const config = readConfig(configPath);
|
|
61
|
+
const features = resolveFeatureFlags(config);
|
|
62
|
+
const verbose = isVerbose(config);
|
|
63
|
+
|
|
64
|
+
if (!config.scanMode) {
|
|
65
|
+
logError("Config missing scanMode. Expected 'staged' or 'full'.");
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const scanMode = String(config.scanMode).toLowerCase();
|
|
70
|
+
let targets = [];
|
|
71
|
+
|
|
72
|
+
if (scanMode === "staged") {
|
|
73
|
+
logInfo("Scan mode: staged");
|
|
74
|
+
const staged = getStagedFiles(gitRoot);
|
|
75
|
+
targets = normalizeScopeFiles(staged, gitRoot);
|
|
76
|
+
} else if (scanMode === "full") {
|
|
77
|
+
logInfo("Scan mode: full");
|
|
78
|
+
const excludes = getDefaultExcludes();
|
|
79
|
+
const allFiles = listFilesRecursive(gitRoot, excludes);
|
|
80
|
+
targets = normalizeScopeFiles(allFiles, gitRoot);
|
|
81
|
+
} else {
|
|
82
|
+
logError("Invalid scanMode. Expected 'staged' or 'full'.");
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const filtered = filterBinaryFiles(targets);
|
|
87
|
+
|
|
88
|
+
if (filtered.length === 0) {
|
|
89
|
+
logWarn("No relevant files found. Exiting.");
|
|
90
|
+
// Exit code 0 allows the Git commit to continue.
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { findings, escalations } = runRuleEngine({ files: filtered });
|
|
95
|
+
const projectContext = buildProjectContext({ gitRoot, config });
|
|
96
|
+
let aiInputs = [];
|
|
97
|
+
if (features.aiEscalation) {
|
|
98
|
+
warnExperimentalOnce("Experimental feature enabled: AI escalation.", logWarn);
|
|
99
|
+
aiInputs = buildAiInputs(escalations, projectContext);
|
|
100
|
+
if (aiInputs.length > 0) {
|
|
101
|
+
// Regex-first keeps scans fast; only ambiguous findings go to AI.
|
|
102
|
+
logWarn(`Escalating ${aiInputs.length} findings to AI for contextual analysis`);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
reportFeatureDisabled("AI escalation", verbose, logInfo);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const aiDecisions = aiInputs.length > 0
|
|
109
|
+
? withFailOpenAiEscalation(features.aiEscalation, () => analyze(aiInputs, projectContext))
|
|
110
|
+
: [];
|
|
111
|
+
const { blockFindings, warnFindings, aiReviewed, exitCode } = mergeDecisions({
|
|
112
|
+
baselineFindings: [...findings, ...escalations],
|
|
113
|
+
aiDecisions
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (features.reporting) {
|
|
117
|
+
withFailOpenReporting(() => {
|
|
118
|
+
const timestamp = new Date().toISOString();
|
|
119
|
+
const runId = `${Date.now()}-${process.pid}`;
|
|
120
|
+
const report = buildReport({
|
|
121
|
+
projectRoot: gitRoot,
|
|
122
|
+
scanMode,
|
|
123
|
+
filesScannedCount: filtered.length,
|
|
124
|
+
baselineFindings: [...findings, ...escalations],
|
|
125
|
+
aiReviewed,
|
|
126
|
+
runId,
|
|
127
|
+
timestamp
|
|
128
|
+
});
|
|
129
|
+
// Reporting is fail-open: never block commits if logging fails.
|
|
130
|
+
appendReport({ projectRoot: gitRoot, report });
|
|
131
|
+
|
|
132
|
+
const integration = config?.integration || {};
|
|
133
|
+
const integrationEnabled = features.integration && Boolean(integration.enabled);
|
|
134
|
+
if (integrationEnabled) {
|
|
135
|
+
withFailOpenIntegration(() => {
|
|
136
|
+
// Network calls are fail-open; never affect exit codes.
|
|
137
|
+
sendReportToServer(report, {
|
|
138
|
+
enabled: true,
|
|
139
|
+
endpointUrl: integration.endpointUrl
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
reportFeatureDisabled("Integration", verbose, logInfo);
|
|
144
|
+
}
|
|
145
|
+
}, () => {
|
|
146
|
+
logWarn("Failed to write CodeProof report. Continuing without blocking.");
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
reportFeatureDisabled("Reporting", verbose, logInfo);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (blockFindings.length > 0) {
|
|
153
|
+
logError(`Baseline rule violations (${blockFindings.length}):`);
|
|
154
|
+
for (const finding of blockFindings) {
|
|
155
|
+
const relative = path.relative(gitRoot, finding.filePath) || finding.filePath;
|
|
156
|
+
logError(
|
|
157
|
+
`${finding.ruleId} [${finding.severity}/${finding.confidence}] ${relative}:${finding.line} ${finding.message}`
|
|
158
|
+
);
|
|
159
|
+
logError(` ${finding.snippet}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (warnFindings.length > 0) {
|
|
164
|
+
logWarn(`Baseline warnings (${warnFindings.length}):`);
|
|
165
|
+
for (const finding of warnFindings) {
|
|
166
|
+
const relative = path.relative(gitRoot, finding.filePath) || finding.filePath;
|
|
167
|
+
logWarn(
|
|
168
|
+
`${finding.ruleId} [${finding.severity}/${finding.confidence}] ${relative}:${finding.line} ${finding.message}`
|
|
169
|
+
);
|
|
170
|
+
logWarn(` ${finding.snippet}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (aiReviewed.length > 0) {
|
|
175
|
+
logWarn(`AI-reviewed findings (${aiReviewed.length}):`);
|
|
176
|
+
for (const entry of aiReviewed) {
|
|
177
|
+
const { finding, decision } = entry;
|
|
178
|
+
const relative = path.relative(gitRoot, finding.filePath) || finding.filePath;
|
|
179
|
+
logWarn(
|
|
180
|
+
`${finding.ruleId} [${decision.verdict}/${decision.confidence.toFixed(2)}] ${relative}:${finding.line} ${decision.explanation}`
|
|
181
|
+
);
|
|
182
|
+
if (decision.suggestedFix) {
|
|
183
|
+
logWarn(` Suggested fix: ${decision.suggestedFix}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (exitCode === 1) {
|
|
189
|
+
// Exit code 1 blocks the Git commit via the pre-commit hook.
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
logSuccess("CodeProof run complete.");
|
|
194
|
+
// Exit code 0 allows the Git commit to continue.
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# CodeProof Internal Boundaries
|
|
2
|
+
|
|
3
|
+
This is a short internal guide to prevent accidental coupling between subsystems.
|
|
4
|
+
|
|
5
|
+
## Allowed Dependencies
|
|
6
|
+
- Rule engine → May depend on rules and utilities only.
|
|
7
|
+
- Reporting → May depend on report input data and file I/O only.
|
|
8
|
+
- Integration → May depend on HTTP/HTTPS only.
|
|
9
|
+
- Secret remediation → May depend on reports, env helpers, and file I/O only.
|
|
10
|
+
- CLI commands → Orchestrate flows and call feature guards.
|
|
11
|
+
|
|
12
|
+
## Forbidden Dependencies
|
|
13
|
+
- Rule engine must never import reporting, integration, or remediation.
|
|
14
|
+
- Reporting must never import rule logic or AI logic.
|
|
15
|
+
- Integration must never import CLI or rule logic.
|
|
16
|
+
- Secret remediation must never import analysis state (only reports).
|
|
17
|
+
|
|
18
|
+
## Fail-Open Guarantees
|
|
19
|
+
- Reporting failures never affect exit codes.
|
|
20
|
+
- Integration failures never affect commits.
|
|
21
|
+
- AI failures always downgrade to warn and never block.
|
|
22
|
+
|
|
23
|
+
## Safe Extension Points
|
|
24
|
+
- Add new features to core/featureFlags.js with default-off behavior.
|
|
25
|
+
- Use core/safetyGuards.js for all fail-open or experimental flows.
|
|
26
|
+
- Keep experimental features opt-in and guarded by feature flags.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const DEFAULT_FEATURES = {
|
|
2
|
+
reporting: true,
|
|
3
|
+
integration: false,
|
|
4
|
+
aiEscalation: false,
|
|
5
|
+
secretRemediation: false
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// Boundary: feature flags are configuration only and must not import runtime systems.
|
|
9
|
+
// Future features should be added here with safe defaults and explicit gating.
|
|
10
|
+
export function resolveFeatureFlags(config) {
|
|
11
|
+
const features = config?.features || {};
|
|
12
|
+
return {
|
|
13
|
+
reporting: typeof features.reporting === "boolean" ? features.reporting : DEFAULT_FEATURES.reporting,
|
|
14
|
+
integration: typeof features.integration === "boolean" ? features.integration : DEFAULT_FEATURES.integration,
|
|
15
|
+
aiEscalation: typeof features.aiEscalation === "boolean" ? features.aiEscalation : DEFAULT_FEATURES.aiEscalation,
|
|
16
|
+
secretRemediation:
|
|
17
|
+
typeof features.secretRemediation === "boolean"
|
|
18
|
+
? features.secretRemediation
|
|
19
|
+
: DEFAULT_FEATURES.secretRemediation
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isVerbose(config) {
|
|
24
|
+
return Boolean(config?.verbose) || process.env.CODEPROOF_VERBOSE === "1";
|
|
25
|
+
}
|