ai-project-maintainer 0.3.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/LICENSE +21 -0
- package/README.md +175 -0
- package/ai-project-maintainer/SKILL.md +62 -0
- package/ai-project-maintainer/agents/openai.yaml +6 -0
- package/ai-project-maintainer/references/ci-guardrails.md +55 -0
- package/ai-project-maintainer/references/database.md +60 -0
- package/ai-project-maintainer/references/electron-desktop.md +43 -0
- package/ai-project-maintainer/references/incident-response.md +52 -0
- package/ai-project-maintainer/references/local-gate.md +117 -0
- package/ai-project-maintainer/references/security.md +48 -0
- package/ai-project-maintainer/references/tool-router.md +53 -0
- package/ai-project-maintainer/scripts/audit-plan.mjs +155 -0
- package/ai-project-maintainer/scripts/bootstrap-local-tools.ps1 +109 -0
- package/ai-project-maintainer/scripts/check-syntax.mjs +41 -0
- package/ai-project-maintainer/scripts/ci-smoke-gate.mjs +26 -0
- package/ai-project-maintainer/scripts/cli.mjs +165 -0
- package/ai-project-maintainer/scripts/doctor.mjs +80 -0
- package/ai-project-maintainer/scripts/init-audit.mjs +105 -0
- package/ai-project-maintainer/scripts/init-project.mjs +229 -0
- package/ai-project-maintainer/scripts/lib/check-registry.mjs +68 -0
- package/ai-project-maintainer/scripts/lib/checks.mjs +337 -0
- package/ai-project-maintainer/scripts/lib/command-runner.mjs +130 -0
- package/ai-project-maintainer/scripts/lib/intake.mjs +172 -0
- package/ai-project-maintainer/scripts/lib/policy.mjs +150 -0
- package/ai-project-maintainer/scripts/lib/project-detect.mjs +111 -0
- package/ai-project-maintainer/scripts/lib/report.mjs +227 -0
- package/ai-project-maintainer/scripts/probe-project.mjs +218 -0
- package/ai-project-maintainer/scripts/report-summary.mjs +25 -0
- package/ai-project-maintainer/scripts/run-local-gate.mjs +147 -0
- package/docs/CI-GITHUB-ACTIONS.zh-CN.md +83 -0
- package/docs/DEMO.md +81 -0
- package/docs/DEMO.zh-CN.md +81 -0
- package/docs/GITHUB-LAUNCH-CHECKLIST.md +77 -0
- package/docs/INSTALL.zh-CN.md +112 -0
- package/docs/INTAKE-SCHEMA.zh-CN.md +105 -0
- package/docs/POLICY-AND-EXCEPTIONS.zh-CN.md +96 -0
- package/docs/PRODUCTION-AUDIT.zh-CN.md +89 -0
- package/docs/PROMOTION.md +116 -0
- package/docs/UPGRADE-ROADMAP.zh-CN.md +47 -0
- package/docs/demo-output/security-report.md +57 -0
- package/docs/superpowers/plans/2026-06-29-ci-dogfooding.md +200 -0
- package/package.json +21 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exists, runCommand } from "./command-runner.mjs";
|
|
4
|
+
|
|
5
|
+
function packageManager(project) {
|
|
6
|
+
const root = project.root;
|
|
7
|
+
if (exists(path.join(root, "pnpm-lock.yaml"))) return { name: "pnpm", audit: ["pnpm", ["audit", "--prod", "--json"]] };
|
|
8
|
+
if (exists(path.join(root, "yarn.lock"))) return { name: "yarn", audit: ["yarn", ["npm", "audit", "--environment", "production", "--json"]] };
|
|
9
|
+
if (exists(path.join(root, "bun.lock")) || exists(path.join(root, "bun.lockb"))) return { name: "bun", audit: null };
|
|
10
|
+
if (exists(path.join(root, "package-lock.json")) || exists(path.join(root, "package.json"))) return { name: "npm", audit: ["npm", ["audit", "--omit=dev", "--json"]] };
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function makeCheck(name, group, result, blocking, summary, extra = {}) {
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
group,
|
|
18
|
+
status: result.status,
|
|
19
|
+
blocking: Boolean(blocking),
|
|
20
|
+
summary,
|
|
21
|
+
command: result.command,
|
|
22
|
+
code: result.code,
|
|
23
|
+
durationMs: result.durationMs,
|
|
24
|
+
stdout: result.stdout,
|
|
25
|
+
stderr: result.stderr,
|
|
26
|
+
...extra,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeToolResult(toolName, result) {
|
|
31
|
+
if (
|
|
32
|
+
toolName === "trivy" &&
|
|
33
|
+
result.status === "fail" &&
|
|
34
|
+
/failed to download vulnerability DB|DB error|download artifact|unable to download|timeout|deadline exceeded/i.test(`${result.stderr}\n${result.stdout}`)
|
|
35
|
+
) {
|
|
36
|
+
return { ...result, status: "error" };
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runProjectScript(project, pm, scriptName, options) {
|
|
42
|
+
let commandArgs;
|
|
43
|
+
if (scriptName === "test" && (pm.name === "npm" || pm.name === "pnpm")) {
|
|
44
|
+
commandArgs = ["test"];
|
|
45
|
+
} else if (pm.name === "npm" || pm.name === "pnpm") {
|
|
46
|
+
commandArgs = ["run", scriptName];
|
|
47
|
+
} else if (pm.name === "yarn") {
|
|
48
|
+
commandArgs = [scriptName];
|
|
49
|
+
} else {
|
|
50
|
+
commandArgs = ["run", scriptName];
|
|
51
|
+
}
|
|
52
|
+
return runCommand(pm.name, commandArgs, { ...options.runnerOptions, cwd: project.root, timeoutMs: options.timeoutMs });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function runTestChecks(project, options = {}) {
|
|
56
|
+
const checks = [];
|
|
57
|
+
const pkg = project.packageJson;
|
|
58
|
+
if (!pkg) return checks;
|
|
59
|
+
const pm = packageManager(project);
|
|
60
|
+
if (!pm) return checks;
|
|
61
|
+
const scripts = pkg.scripts || {};
|
|
62
|
+
const noTests = Boolean(options.noTests);
|
|
63
|
+
|
|
64
|
+
if (!noTests && scripts.test) {
|
|
65
|
+
const result = runProjectScript(project, pm, "test", { ...options, timeoutMs: 15 * 60 * 1000 });
|
|
66
|
+
checks.push(makeCheck("package test", "tests", result, result.status === "fail", "Project test script must pass.", { checkId: "tests" }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!noTests && scripts["test:e2e"]) {
|
|
70
|
+
const result = runProjectScript(project, pm, "test:e2e", { ...options, timeoutMs: 20 * 60 * 1000 });
|
|
71
|
+
checks.push(makeCheck("e2e test", "tests", result, result.status === "fail", "End-to-end tests must pass when present.", { checkId: "tests" }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.release) {
|
|
75
|
+
for (const scriptName of ["build", "dist"]) {
|
|
76
|
+
if (!scripts[scriptName]) continue;
|
|
77
|
+
const result = runProjectScript(project, pm, scriptName, { ...options, timeoutMs: 25 * 60 * 1000 });
|
|
78
|
+
checks.push(makeCheck(`release ${scriptName}`, "tests", result, result.status === "fail", `Release script '${scriptName}' must pass.`, { checkId: "tests" }));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return checks;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function runPackageAuditChecks(project, options = {}) {
|
|
86
|
+
const checks = [];
|
|
87
|
+
if (!project.packageJson) return checks;
|
|
88
|
+
const pm = packageManager(project);
|
|
89
|
+
if (!pm?.audit) return checks;
|
|
90
|
+
const [cmd, commandArgs] = pm.audit;
|
|
91
|
+
const result = runCommand(cmd, commandArgs, { ...options.runnerOptions, cwd: project.root, timeoutMs: 10 * 60 * 1000 });
|
|
92
|
+
checks.push(makeCheck(`${pm.name} production audit`, "dependencies", result, result.status === "fail", "Production dependency audit must pass or have a documented exception.", { checkId: "package-audit" }));
|
|
93
|
+
return checks;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function runNodeChecks(project, options = {}) {
|
|
97
|
+
return [...runTestChecks(project, options), ...runPackageAuditChecks(project, options)];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function runSecretChecks(project, options = {}) {
|
|
101
|
+
const strict = Boolean(options.strict);
|
|
102
|
+
const runner = options.runnerOptions || {};
|
|
103
|
+
const result = runCommand("gitleaks", ["detect", "--source", project.root, "--redact", "--no-git"], { ...runner, cwd: project.root });
|
|
104
|
+
return [
|
|
105
|
+
makeCheck("gitleaks secret scan", "secrets", result, result.status === "fail" || (strict && result.status === "missing"), "Committed secrets block release.", { checkId: "gitleaks" }),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function runDependencyScannerChecks(project, options = {}) {
|
|
110
|
+
return [...runTrivyFilesystemChecks(project, options), ...runOsvScannerChecks(project, options)];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function runTrivyFilesystemChecks(project, options = {}) {
|
|
114
|
+
const checks = [];
|
|
115
|
+
const strict = Boolean(options.strict);
|
|
116
|
+
const runner = options.runnerOptions || {};
|
|
117
|
+
const root = project.root;
|
|
118
|
+
const trivy = normalizeToolResult("trivy", runCommand(
|
|
119
|
+
"trivy",
|
|
120
|
+
[
|
|
121
|
+
"fs",
|
|
122
|
+
"--db-repository",
|
|
123
|
+
process.env.TRIVY_DB_REPOSITORY || "ghcr.io/aquasecurity/trivy-db:2",
|
|
124
|
+
"--java-db-repository",
|
|
125
|
+
process.env.TRIVY_JAVA_DB_REPOSITORY || "ghcr.io/aquasecurity/trivy-java-db:1",
|
|
126
|
+
"--timeout",
|
|
127
|
+
process.env.TRIVY_TIMEOUT || "90s",
|
|
128
|
+
"--scanners",
|
|
129
|
+
"vuln,secret,misconfig",
|
|
130
|
+
"--severity",
|
|
131
|
+
"HIGH,CRITICAL",
|
|
132
|
+
"--exit-code",
|
|
133
|
+
"1",
|
|
134
|
+
"--quiet",
|
|
135
|
+
root,
|
|
136
|
+
],
|
|
137
|
+
{ ...runner, cwd: root, timeoutMs: 150 * 1000 },
|
|
138
|
+
));
|
|
139
|
+
checks.push(makeCheck(
|
|
140
|
+
"trivy filesystem scan",
|
|
141
|
+
"dependencies",
|
|
142
|
+
trivy,
|
|
143
|
+
trivy.status === "fail" || (strict && (trivy.status === "error" || trivy.status === "missing")),
|
|
144
|
+
trivy.status === "error"
|
|
145
|
+
? "Trivy is installed but its vulnerability database was unavailable; release coverage is incomplete."
|
|
146
|
+
: "High/critical vulnerabilities, secrets, and misconfigurations block release.",
|
|
147
|
+
{ checkId: "trivy", coverageGap: trivy.status === "error" || trivy.status === "missing" },
|
|
148
|
+
));
|
|
149
|
+
|
|
150
|
+
return checks;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function runOsvScannerChecks(project, options = {}) {
|
|
154
|
+
const files = project.files || [];
|
|
155
|
+
const hasLockfile = files.some((file) => /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|go\.sum|requirements.*\.txt|poetry\.lock|Cargo\.lock|Gemfile\.lock)$/i.test(file));
|
|
156
|
+
if (!hasLockfile) return [];
|
|
157
|
+
const runner = options.runnerOptions || {};
|
|
158
|
+
const osv = runCommand("osv-scanner", ["--recursive", project.root], { ...runner, cwd: project.root, timeoutMs: 10 * 60 * 1000 });
|
|
159
|
+
return [
|
|
160
|
+
makeCheck("osv-scanner dependency scan", "dependencies", osv, osv.status === "fail", "Known vulnerable dependencies should be fixed or documented.", { checkId: "osv-scanner" }),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function runSastChecks(project, options = {}) {
|
|
165
|
+
const strict = Boolean(options.strict);
|
|
166
|
+
const runner = options.runnerOptions || {};
|
|
167
|
+
const result = runCommand("semgrep", ["scan", "--config", "auto", "--error", project.root], { ...runner, cwd: project.root, timeoutMs: 20 * 60 * 1000 });
|
|
168
|
+
return [
|
|
169
|
+
makeCheck("semgrep static scan", "sast", result, result.status === "fail" || (strict && result.status === "missing"), "High-confidence static analysis findings block release.", { checkId: "semgrep" }),
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function runSupplyChainChecks(project, options = {}) {
|
|
174
|
+
return [...runSyftChecks(project, options), ...runGrypeChecks(project, options)];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function runSyftChecks(project, options = {}) {
|
|
178
|
+
const runner = options.runnerOptions || {};
|
|
179
|
+
const syftOutputArg = options.sbomOutputPath ? `cyclonedx-json=${options.sbomOutputPath}` : "cyclonedx-json";
|
|
180
|
+
const syft = runCommand("syft", [project.root, "-o", syftOutputArg], { ...runner, cwd: project.root, timeoutMs: 10 * 60 * 1000, maxBuffer: 50 * 1024 * 1024 });
|
|
181
|
+
return [
|
|
182
|
+
makeCheck("syft SBOM", "supply-chain", syft, false, "SBOM generation improves release evidence.", { checkId: "syft" }),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function runGrypeChecks(project, options = {}) {
|
|
187
|
+
const runner = options.runnerOptions || {};
|
|
188
|
+
const grype = runCommand("grype", [project.root, "--fail-on", "high"], { ...runner, cwd: project.root, timeoutMs: 10 * 60 * 1000 });
|
|
189
|
+
return [
|
|
190
|
+
makeCheck("grype vulnerability scan", "supply-chain", grype, grype.status === "fail", "High/critical supply-chain vulnerabilities should be fixed.", { checkId: "grype" }),
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function runCiSecurityChecks(project, options = {}) {
|
|
195
|
+
if (!(project.riskSurfaces?.ci || []).length) return [];
|
|
196
|
+
const runner = options.runnerOptions || {};
|
|
197
|
+
const actionlint = runCommand("actionlint", [], { ...runner, cwd: project.root, timeoutMs: 5 * 60 * 1000 });
|
|
198
|
+
const zizmor = runCommand("zizmor", [".github/workflows"], { ...runner, cwd: project.root, timeoutMs: 5 * 60 * 1000 });
|
|
199
|
+
return [
|
|
200
|
+
makeCheck("actionlint workflow lint", "ci-security", actionlint, actionlint.status === "fail", "GitHub Actions workflow syntax and common mistakes must be fixed.", { checkId: "actionlint" }),
|
|
201
|
+
makeCheck("zizmor workflow security", "ci-security", zizmor, zizmor.status === "fail", "High-risk GitHub Actions patterns must be fixed.", { checkId: "zizmor" }),
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function runIacChecks(project, options = {}) {
|
|
206
|
+
if (!(project.riskSurfaces?.infra || []).length) return [];
|
|
207
|
+
const strict = Boolean(options.strict);
|
|
208
|
+
const runner = options.runnerOptions || {};
|
|
209
|
+
const checkov = runCommand("checkov", ["-d", project.root, "--quiet", "--compact"], { ...runner, cwd: project.root, timeoutMs: 15 * 60 * 1000 });
|
|
210
|
+
const trivyConfig = runCommand("trivy", ["config", "--severity", "HIGH,CRITICAL", "--exit-code", "1", "--quiet", project.root], { ...runner, cwd: project.root, timeoutMs: 10 * 60 * 1000 });
|
|
211
|
+
return [
|
|
212
|
+
makeCheck("checkov IaC scan", "iac", checkov, checkov.status === "fail" || (strict && checkov.status === "missing"), "IaC security failures block release when infra files exist.", { checkId: "checkov" }),
|
|
213
|
+
makeCheck("trivy config scan", "iac", trivyConfig, trivyConfig.status === "fail", "IaC misconfigurations should be fixed before release.", { checkId: "trivy-config" }),
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function runDatabaseChecks(project, options = {}) {
|
|
218
|
+
const runner = options.runnerOptions || {};
|
|
219
|
+
const strict = Boolean(options.strict);
|
|
220
|
+
const sqlFiles = (project.files || []).filter((file) => /\.(sql)$/i.test(file) && /migrations?|db\/migrate|schema/i.test(file)).slice(0, 100);
|
|
221
|
+
|
|
222
|
+
if (sqlFiles.length > 0) {
|
|
223
|
+
const squawk = runCommand("squawk", sqlFiles.map((file) => path.join(project.root, file)), { ...runner, cwd: project.root, timeoutMs: 10 * 60 * 1000 });
|
|
224
|
+
return [
|
|
225
|
+
makeCheck("squawk SQL migration lint", "database", squawk, squawk.status === "fail" || (strict && squawk.status === "missing"), "Unsafe SQL migration patterns block release.", { checkId: "squawk" }),
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if ((project.riskSurfaces?.database || []).length > 0) {
|
|
230
|
+
return [
|
|
231
|
+
{
|
|
232
|
+
checkId: "database-review",
|
|
233
|
+
name: "database migration review",
|
|
234
|
+
group: "database",
|
|
235
|
+
status: "skipped",
|
|
236
|
+
blocking: false,
|
|
237
|
+
summary: "Database surface detected; route schema changes through Squawk, Atlas, or Bytebase review when migrations exist.",
|
|
238
|
+
},
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function runOssHygieneChecks(project, options = {}) {
|
|
246
|
+
return [...runScorecardChecks(project, options), ...runPreCommitChecks(project, options), ...runMegaLinterChecks(project, options)];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function runScorecardChecks(project, options = {}) {
|
|
250
|
+
const runner = options.runnerOptions || {};
|
|
251
|
+
const scorecard = runCommand("scorecard", ["--local", project.root, "--format", "json"], { ...runner, cwd: project.root, timeoutMs: 10 * 60 * 1000 });
|
|
252
|
+
return [
|
|
253
|
+
makeCheck("OpenSSF Scorecard", "oss-hygiene", scorecard, false, "Open source repository health score improves contributor trust.", { checkId: "scorecard" }),
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function runPreCommitChecks(project, options = {}) {
|
|
258
|
+
const runner = options.runnerOptions || {};
|
|
259
|
+
const preCommit = runCommand("pre-commit", ["run", "--all-files"], { ...runner, cwd: project.root, timeoutMs: 10 * 60 * 1000 });
|
|
260
|
+
return [
|
|
261
|
+
makeCheck("pre-commit hooks", "oss-hygiene", preCommit, false, "pre-commit keeps common checks close to contributors.", { checkId: "pre-commit" }),
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function runMegaLinterChecks(project, options = {}) {
|
|
266
|
+
const runner = options.runnerOptions || {};
|
|
267
|
+
const megalinter = runCommand("mega-linter-runner", ["--flavor", "security", "--env", "VALIDATE_ALL_CODEBASE=true"], { ...runner, cwd: project.root, timeoutMs: 20 * 60 * 1000 });
|
|
268
|
+
return [
|
|
269
|
+
makeCheck("MegaLinter security profile", "oss-hygiene", megalinter, false, "MegaLinter can aggregate additional open source hygiene checks.", { checkId: "megalinter" }),
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function runExternalChecks(project, options = {}) {
|
|
274
|
+
return [
|
|
275
|
+
...runSecretChecks(project, options),
|
|
276
|
+
...runDependencyScannerChecks(project, options),
|
|
277
|
+
...runSastChecks(project, options),
|
|
278
|
+
...runSupplyChainChecks(project, options),
|
|
279
|
+
...runCiSecurityChecks(project, options),
|
|
280
|
+
...runIacChecks(project, options),
|
|
281
|
+
...runDatabaseChecks(project, options),
|
|
282
|
+
...runOssHygieneChecks(project, options),
|
|
283
|
+
];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function runElectronChecks(project) {
|
|
287
|
+
if (!project.electron?.detected) return [];
|
|
288
|
+
|
|
289
|
+
const dangerous = [];
|
|
290
|
+
const suspiciousIpc = [];
|
|
291
|
+
for (const file of project.electron.candidateFiles || []) {
|
|
292
|
+
const full = path.join(project.root, file);
|
|
293
|
+
let text = "";
|
|
294
|
+
try {
|
|
295
|
+
text = fs.readFileSync(full, "utf8");
|
|
296
|
+
} catch {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const pattern of [
|
|
301
|
+
{ re: /nodeIntegration\s*:\s*true/g, label: "nodeIntegration: true" },
|
|
302
|
+
{ re: /contextIsolation\s*:\s*false/g, label: "contextIsolation: false" },
|
|
303
|
+
{ re: /webSecurity\s*:\s*false/g, label: "webSecurity: false" },
|
|
304
|
+
{ re: /allowRunningInsecureContent\s*:\s*true/g, label: "allowRunningInsecureContent: true" },
|
|
305
|
+
]) {
|
|
306
|
+
if (pattern.re.test(text)) dangerous.push(`${file}: ${pattern.label}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (/ipcMain\.handle|contextBridge\.exposeInMainWorld/.test(text) && /fs\.(read|write|rm|unlink|copy)|shell\.openExternal|child_process|execFile|spawn/.test(text)) {
|
|
310
|
+
suspiciousIpc.push(file);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return [
|
|
315
|
+
{
|
|
316
|
+
checkId: "electron",
|
|
317
|
+
name: "electron baseline",
|
|
318
|
+
group: "electron",
|
|
319
|
+
status: dangerous.length ? "fail" : "pass",
|
|
320
|
+
blocking: dangerous.length > 0,
|
|
321
|
+
summary: dangerous.length
|
|
322
|
+
? "Dangerous Electron webPreferences detected."
|
|
323
|
+
: suspiciousIpc.length
|
|
324
|
+
? "Electron detected; privileged IPC/file/shell patterns need manual review."
|
|
325
|
+
: "Electron baseline did not find known-dangerous webPreferences.",
|
|
326
|
+
evidence: { dangerous, suspiciousIpc: suspiciousIpc.slice(0, 30) },
|
|
327
|
+
},
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function runAllChecks(project, options = {}) {
|
|
332
|
+
return [
|
|
333
|
+
...runNodeChecks(project, options),
|
|
334
|
+
...runExternalChecks(project, options),
|
|
335
|
+
...runElectronChecks(project, options),
|
|
336
|
+
];
|
|
337
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const isWindows = process.platform === "win32";
|
|
7
|
+
const maxOutput = 6000;
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_ALLOWED_COMMANDS = new Set([
|
|
10
|
+
"actionlint",
|
|
11
|
+
"bun",
|
|
12
|
+
"checkov",
|
|
13
|
+
"git",
|
|
14
|
+
"gitleaks",
|
|
15
|
+
"grype",
|
|
16
|
+
"mega-linter-runner",
|
|
17
|
+
"npm",
|
|
18
|
+
"osv-scanner",
|
|
19
|
+
"pre-commit",
|
|
20
|
+
"pnpm",
|
|
21
|
+
"scorecard",
|
|
22
|
+
"semgrep",
|
|
23
|
+
"squawk",
|
|
24
|
+
"syft",
|
|
25
|
+
"trivy",
|
|
26
|
+
"yarn",
|
|
27
|
+
"zizmor",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function exists(filePath) {
|
|
31
|
+
try {
|
|
32
|
+
fs.accessSync(filePath);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function tail(value) {
|
|
40
|
+
const text = String(value || "").trim();
|
|
41
|
+
if (text.length <= maxOutput) return text;
|
|
42
|
+
return text.slice(text.length - maxOutput);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function commandPath(command, options = {}) {
|
|
46
|
+
const allowedCommands = options.allowedCommands || DEFAULT_ALLOWED_COMMANDS;
|
|
47
|
+
if (!allowedCommands.has(command)) return null;
|
|
48
|
+
|
|
49
|
+
const pathValue = options.envPath ?? process.env.PATH ?? "";
|
|
50
|
+
const preferredBins =
|
|
51
|
+
options.envPath === undefined
|
|
52
|
+
? [
|
|
53
|
+
process.env.AI_PROJECT_MAINTAINER_TOOLS_BIN,
|
|
54
|
+
path.join(os.homedir(), ".codex", "security-tools", "bin"),
|
|
55
|
+
].filter(Boolean)
|
|
56
|
+
: [];
|
|
57
|
+
const exts = isWindows ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";") : [""];
|
|
58
|
+
|
|
59
|
+
for (const dir of [...preferredBins, ...pathValue.split(path.delimiter)]) {
|
|
60
|
+
if (!dir) continue;
|
|
61
|
+
for (const ext of exts) {
|
|
62
|
+
const candidate = path.join(dir, command + ext);
|
|
63
|
+
if (exists(candidate)) return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isWindowsScriptShim(filePath) {
|
|
70
|
+
return isWindows && /\.(cmd|bat)$/i.test(filePath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function spawnTarget(resolved, args) {
|
|
74
|
+
if (!isWindowsScriptShim(resolved)) return { file: resolved, args };
|
|
75
|
+
return {
|
|
76
|
+
file: process.env.ComSpec || "cmd.exe",
|
|
77
|
+
args: ["/d", "/s", "/c", "call", resolved, ...args],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function runCommand(command, commandArgs = [], options = {}) {
|
|
82
|
+
const resolved = commandPath(command, options);
|
|
83
|
+
const commandText = [command, ...commandArgs].join(" ");
|
|
84
|
+
if (!resolved) {
|
|
85
|
+
return {
|
|
86
|
+
status: "missing",
|
|
87
|
+
command: commandText,
|
|
88
|
+
stdout: "",
|
|
89
|
+
stderr: `${command} is not installed or not on PATH`,
|
|
90
|
+
code: null,
|
|
91
|
+
durationMs: 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const started = Date.now();
|
|
96
|
+
// Commands are resolved from an allowlist; Windows .cmd/.bat shims are routed through cmd.exe call.
|
|
97
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
98
|
+
const target = spawnTarget(resolved, commandArgs);
|
|
99
|
+
const result = spawnSync(target.file, target.args, {
|
|
100
|
+
cwd: options.cwd || process.cwd(),
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
timeout: options.timeoutMs || 10 * 60 * 1000,
|
|
103
|
+
maxBuffer: options.maxBuffer || 20 * 1024 * 1024,
|
|
104
|
+
env: options.env || process.env,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
status: result.status === 0 ? "pass" : "fail",
|
|
109
|
+
command: commandText,
|
|
110
|
+
stdout: tail(result.stdout),
|
|
111
|
+
stderr: tail(result.stderr || result.error?.message || ""),
|
|
112
|
+
code: result.status,
|
|
113
|
+
durationMs: Date.now() - started,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getToolVersions(commands, options = {}) {
|
|
118
|
+
const versions = { node: process.version };
|
|
119
|
+
for (const command of commands) {
|
|
120
|
+
const result = runCommand(command, ["--version"], {
|
|
121
|
+
...options,
|
|
122
|
+
timeoutMs: options.timeoutMs || 30 * 1000,
|
|
123
|
+
});
|
|
124
|
+
versions[command] =
|
|
125
|
+
result.status === "missing"
|
|
126
|
+
? "missing"
|
|
127
|
+
: tail(`${result.stdout}\n${result.stderr}`).split(/\r?\n/).find(Boolean) || `exit ${result.code}`;
|
|
128
|
+
}
|
|
129
|
+
return versions;
|
|
130
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
|
|
5
|
+
export const intakeRelativePaths = {
|
|
6
|
+
profile: ".ai-maintainer/project-profile.yml",
|
|
7
|
+
evidence: ".ai-maintainer/evidence-sources.yml",
|
|
8
|
+
businessFlows: ".ai-maintainer/business-flows.yml",
|
|
9
|
+
riskPolicy: ".ai-maintainer/risk-policy.yml",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const defaultProjectProfile = {
|
|
13
|
+
schema_version: 1,
|
|
14
|
+
project: {
|
|
15
|
+
name: "",
|
|
16
|
+
type: "auto",
|
|
17
|
+
lifecycle: "development",
|
|
18
|
+
production: false,
|
|
19
|
+
},
|
|
20
|
+
risk: {
|
|
21
|
+
handles_auth: false,
|
|
22
|
+
handles_sensitive_data: false,
|
|
23
|
+
handles_payments: false,
|
|
24
|
+
handles_financial_data: false,
|
|
25
|
+
handles_health_data: false,
|
|
26
|
+
has_database: "auto",
|
|
27
|
+
has_deployment: false,
|
|
28
|
+
has_user_generated_content: false,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const defaultEvidenceSources = {
|
|
33
|
+
schema_version: 1,
|
|
34
|
+
evidence: {
|
|
35
|
+
github_actions: "auto",
|
|
36
|
+
deployment: {
|
|
37
|
+
provider: "none",
|
|
38
|
+
has_staging: false,
|
|
39
|
+
has_production: false,
|
|
40
|
+
production_requires_approval: false,
|
|
41
|
+
},
|
|
42
|
+
observability: {
|
|
43
|
+
errors: "none",
|
|
44
|
+
logs: "none",
|
|
45
|
+
metrics: "none",
|
|
46
|
+
alerts: "none",
|
|
47
|
+
},
|
|
48
|
+
database: {
|
|
49
|
+
migrations: "auto",
|
|
50
|
+
review_tool: "none",
|
|
51
|
+
backup_policy: "none",
|
|
52
|
+
rollback_plan: "none",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const defaultBusinessFlows = {
|
|
58
|
+
schema_version: 1,
|
|
59
|
+
business_flows: [
|
|
60
|
+
{
|
|
61
|
+
id: "example-critical-flow",
|
|
62
|
+
name: "Replace with a real critical user flow",
|
|
63
|
+
criticality: "high",
|
|
64
|
+
expected_behavior: "Describe what must never break.",
|
|
65
|
+
tests: [],
|
|
66
|
+
template: true,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const defaultRiskPolicy = {
|
|
72
|
+
schema_version: 1,
|
|
73
|
+
production: {
|
|
74
|
+
block_on_coverage_gaps: false,
|
|
75
|
+
block_on_user_decisions: false,
|
|
76
|
+
require_intake: false,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function isPlainObject(value) {
|
|
81
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function deepMerge(base, custom) {
|
|
85
|
+
if (!isPlainObject(base) || !isPlainObject(custom)) return custom ?? base;
|
|
86
|
+
const merged = { ...base };
|
|
87
|
+
for (const [key, value] of Object.entries(custom)) {
|
|
88
|
+
merged[key] = isPlainObject(value) && isPlainObject(base[key]) ? deepMerge(base[key], value) : value;
|
|
89
|
+
}
|
|
90
|
+
return merged;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readYaml(root, relativePath, fallback, parseErrors) {
|
|
94
|
+
const fullPath = path.join(root, ...relativePath.split("/"));
|
|
95
|
+
if (!fs.existsSync(fullPath)) return { exists: false, value: fallback };
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const text = fs.readFileSync(fullPath, "utf8").replace(/^\uFEFF/, "");
|
|
99
|
+
return { exists: true, value: YAML.parse(text) || fallback };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
parseErrors.push({ path: relativePath, reason: error.message });
|
|
102
|
+
return { exists: true, value: fallback };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function booleanFrom(value, fallback) {
|
|
107
|
+
if (value === "auto" || value === undefined || value === null) return fallback;
|
|
108
|
+
if (typeof value === "boolean") return value;
|
|
109
|
+
if (typeof value === "string") return ["true", "yes", "present"].includes(value.toLowerCase());
|
|
110
|
+
return Boolean(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasExternalEvidence(value) {
|
|
114
|
+
if (value === true) return true;
|
|
115
|
+
if (!value || value === "auto") return false;
|
|
116
|
+
if (typeof value === "string") return !["none", "missing", "unknown", "false", "no"].includes(value.toLowerCase());
|
|
117
|
+
return Boolean(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function inferProjectType(project, configuredType) {
|
|
121
|
+
if (configuredType && configuredType !== "auto") return configuredType;
|
|
122
|
+
if (project?.electron?.detected) return "electron";
|
|
123
|
+
const deps = { ...(project?.packageJson?.dependencies || {}), ...(project?.packageJson?.devDependencies || {}) };
|
|
124
|
+
if (deps.next || deps.react || deps.vue || deps.svelte || deps.vite) return "web";
|
|
125
|
+
if (deps.express || deps.fastify || deps["@nestjs/core"] || deps.koa) return "api";
|
|
126
|
+
if (project?.packageJson) return "node";
|
|
127
|
+
return "generic";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function loadIntake(projectRoot, project = {}) {
|
|
131
|
+
const root = path.resolve(projectRoot);
|
|
132
|
+
const parseErrors = [];
|
|
133
|
+
const profileDocument = readYaml(root, intakeRelativePaths.profile, defaultProjectProfile, parseErrors);
|
|
134
|
+
const evidenceDocument = readYaml(root, intakeRelativePaths.evidence, defaultEvidenceSources, parseErrors);
|
|
135
|
+
const businessDocument = readYaml(root, intakeRelativePaths.businessFlows, defaultBusinessFlows, parseErrors);
|
|
136
|
+
const riskDocument = readYaml(root, intakeRelativePaths.riskPolicy, defaultRiskPolicy, parseErrors);
|
|
137
|
+
|
|
138
|
+
const profile = deepMerge(defaultProjectProfile, profileDocument.value);
|
|
139
|
+
const evidence = deepMerge(defaultEvidenceSources, evidenceDocument.value);
|
|
140
|
+
const businessFlows = deepMerge(defaultBusinessFlows, businessDocument.value);
|
|
141
|
+
const riskPolicy = deepMerge(defaultRiskPolicy, riskDocument.value);
|
|
142
|
+
|
|
143
|
+
const detectedDatabase = Boolean(project?.riskSurfaces?.database?.length);
|
|
144
|
+
const detectedCi = Boolean(project?.riskSurfaces?.ci?.length);
|
|
145
|
+
const projectType = inferProjectType(project, profile.project?.type);
|
|
146
|
+
const hasDatabase = booleanFrom(profile.risk?.has_database, detectedDatabase);
|
|
147
|
+
const hasCi = booleanFrom(evidence.evidence?.github_actions, detectedCi);
|
|
148
|
+
const deployment = evidence.evidence?.deployment || {};
|
|
149
|
+
|
|
150
|
+
profile.derived = {
|
|
151
|
+
projectType,
|
|
152
|
+
hasDatabase,
|
|
153
|
+
hasCi,
|
|
154
|
+
hasElectron: Boolean(project?.electron?.detected),
|
|
155
|
+
hasDeployment: Boolean(profile.risk?.has_deployment || deployment.has_staging || deployment.has_production),
|
|
156
|
+
hasObservability: ["errors", "logs", "metrics", "alerts"].some((key) => hasExternalEvidence(evidence.evidence?.observability?.[key])),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
initialized: profileDocument.exists,
|
|
161
|
+
profile,
|
|
162
|
+
evidence,
|
|
163
|
+
businessFlows,
|
|
164
|
+
riskPolicy,
|
|
165
|
+
parseErrors,
|
|
166
|
+
paths: Object.fromEntries(Object.entries(intakeRelativePaths).map(([key, relativePath]) => [key, path.join(root, ...relativePath.split("/"))])),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function hasConfiguredEvidence(value) {
|
|
171
|
+
return hasExternalEvidence(value);
|
|
172
|
+
}
|