penthera 1.1.1 → 1.4.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/CHANGELOG.md +269 -3
- package/README.md +489 -286
- package/SECURITY.md +28 -6
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-nis2.md +166 -0
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +55 -0
- package/docs/examples/pr-dependency-review.yml +56 -0
- package/docs/funding/due-diligence-checklist.md +79 -0
- package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
- package/docs/funding/partner-one-pager.md +84 -0
- package/docs/funding/sponsor-tiers.md +81 -0
- package/docs/index.md +15 -3
- package/docs/local-loop.md +276 -0
- package/docs/references.bib +539 -0
- package/docs/references.md +77 -0
- package/docs/release-plan.md +228 -0
- package/lib/adaptive-probes.js +8 -5
- package/lib/agents/discover.js +384 -0
- package/lib/agents/hidden-content.js +387 -0
- package/lib/agents/index.js +286 -0
- package/lib/agents/lock.js +115 -0
- package/lib/agents/owasp.js +83 -0
- package/lib/agents/rules.js +376 -0
- package/lib/crawler.js +5 -15
- package/lib/data/retire-jsrepository.json.gz +0 -0
- package/lib/data/retire-jsrepository.meta.json +7 -0
- package/lib/deps/capabilities.js +523 -0
- package/lib/deps/diff.js +84 -0
- package/lib/deps/index.js +416 -0
- package/lib/deps/lockfile.js +106 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +173 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/typosquat.js +113 -0
- package/lib/fix/apply.js +95 -0
- package/lib/fix/diff.js +199 -0
- package/lib/fix/index.js +129 -0
- package/lib/fix/model.js +108 -0
- package/lib/fix/recipes/cookies.js +130 -0
- package/lib/fix/recipes/cors.js +116 -0
- package/lib/fix/recipes/headers.js +250 -0
- package/lib/fix/recipes/rate-limit.js +211 -0
- package/lib/fix/recipes/secrets.js +110 -0
- package/lib/fix/util.js +231 -0
- package/lib/fuzzer.js +14 -8
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +117 -0
- package/lib/memory/history.js +52 -0
- package/lib/memory/index.js +111 -0
- package/lib/memory/repo-suppress.js +64 -0
- package/lib/memory/store.js +325 -0
- package/lib/memory/views.js +198 -0
- package/lib/params.js +4 -12
- package/lib/provenance.js +2 -0
- package/lib/recon.js +12 -20
- package/lib/references.js +31 -0
- package/lib/references.json +374 -0
- package/lib/remediation/index.js +28 -0
- package/lib/retirejs.js +50 -22
- package/lib/templates.js +7 -10
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +49 -19
- package/package.json +19 -9
- package/skills/penthera/SKILL.md +104 -23
- package/skills/penthera/references/output-and-ci.md +12 -50
- package/skills/penthera/references/troubleshooting.md +1 -1
- package/skills/penthera/scripts/preflight.sh +4 -4
- package/src/agent/git.js +87 -0
- package/src/agent/loop.js +519 -0
- package/src/agent/tests.js +48 -0
- package/src/cli/agent.js +183 -0
- package/src/cli/deps.js +84 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/install.js +225 -0
- package/src/cli/memory.js +146 -0
- package/src/cli/merge-results.js +2 -0
- package/src/cli/report-outputs.js +72 -0
- package/src/cli/run-scan.js +2 -0
- package/src/cli.js +152 -6
- package/src/report/audience.js +270 -0
- package/src/report/evidence.js +253 -0
- package/src/report/markdown.js +65 -2
- package/src/report/model.js +212 -0
- package/src/report/template.js +175 -0
- package/src/report/timeline.js +145 -0
- package/src/reporter.js +109 -5
- package/src/scan-url.js +31 -7
- package/src/utils/http.js +252 -11
- package/src/utils/url.js +23 -0
- package/scripts/package-skill.sh +0 -16
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera agent --loop`: scan → triage → plan → patch → verify → learn, locally.
|
|
3
|
+
*
|
|
4
|
+
* One iteration scans the repo (and the URL, if given), drops suppressed
|
|
5
|
+
* findings, ranks the rest with memory, plans fixes (recipes first, a local
|
|
6
|
+
* model only with --fix-model), then takes the plans one at a time: show the
|
|
7
|
+
* diff and ask, apply it on the fix branch, re-scan (plus the project's tests
|
|
8
|
+
* with --run-tests), commit it if it verified, revert it if it did not, and
|
|
9
|
+
* write what happened to memory. It repeats until nothing fixable is left,
|
|
10
|
+
* --max-iterations is reached, or the user stops it.
|
|
11
|
+
*
|
|
12
|
+
* Guarantees (each has a test in tests/agent.test.js):
|
|
13
|
+
* - Git goes through ./git.js only: local subcommands, no hooks, no transports.
|
|
14
|
+
* Nothing is pushed, fetched or opened as a pull request.
|
|
15
|
+
* - Everything runs in one egress context limited to the URL target, always
|
|
16
|
+
* offline. Loopback is added only with --fix-model, for Ollama.
|
|
17
|
+
* - A dirty tree or a detached HEAD is refused unless allowDirty is set.
|
|
18
|
+
* - Every diff is approved by `ask` unless --auto low and memory shows the
|
|
19
|
+
* recipe verified at least 3 times on this framework with no failures.
|
|
20
|
+
* - When `signal` aborts, the fix in flight is verified and committed or
|
|
21
|
+
* reverted before the loop returns; the branch holds only verified commits.
|
|
22
|
+
*/
|
|
23
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
24
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
25
|
+
import { join, resolve } from "node:path";
|
|
26
|
+
import { scanRepo as defaultScanRepo } from "../scan-repo.js";
|
|
27
|
+
import { scanUrl as defaultScanUrl } from "../scan-url.js";
|
|
28
|
+
import { createEgressContext, runWithEgress, request } from "../utils/http.js";
|
|
29
|
+
import {
|
|
30
|
+
planFixes, probeRepo, applyDiff, redactPlan, findingKey, createModelFixer,
|
|
31
|
+
} from "../../lib/fix/index.js";
|
|
32
|
+
import { assertLocalModelUrl } from "../../lib/fix/model.js";
|
|
33
|
+
import { detectFramework } from "../../lib/fix/util.js";
|
|
34
|
+
import {
|
|
35
|
+
openMemory, projectIdFor, buildViews, rankFindings, isSuppressed, withRepoSuppressions,
|
|
36
|
+
findingId, loadRepoSuppressions,
|
|
37
|
+
} from "../../lib/memory/index.js";
|
|
38
|
+
import { createGit } from "./git.js";
|
|
39
|
+
import { hasTestScript, runProjectTests } from "./tests.js";
|
|
40
|
+
|
|
41
|
+
export const LOOP_RUN_SCHEMA = "penthera.loopRun/1";
|
|
42
|
+
export const AUTO_MIN_VERIFIED = 3;
|
|
43
|
+
|
|
44
|
+
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
45
|
+
const OLLAMA_HOST = "127.0.0.1";
|
|
46
|
+
|
|
47
|
+
export class LoopError extends Error {
|
|
48
|
+
constructor(message, code) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "LoopError";
|
|
51
|
+
this.code = code;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `penthera/fix-<yyyymmdd-hhmm>` in local time. */
|
|
56
|
+
export function fixBranchName(date = new Date()) {
|
|
57
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
58
|
+
return `penthera/fix-${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The loop's egress context: only the URL target, always offline. Loopback is
|
|
63
|
+
* allowed only when a fix model is set (Ollama listens on 127.0.0.1:11434).
|
|
64
|
+
*/
|
|
65
|
+
export function loopEgressContext({ url = null, fixModel = null } = {}) {
|
|
66
|
+
return createEgressContext({ target: url || undefined, offline: true, allowHosts: fixModel ? [OLLAMA_HOST] : [] });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** fetch for lib/fix/model.js that goes through the egress-guarded client. */
|
|
70
|
+
export function localModelFetch(url, init = {}) {
|
|
71
|
+
assertLocalModelUrl(url);
|
|
72
|
+
const { redirect: _ignored, ...rest } = init;
|
|
73
|
+
return request(url, { ...rest, probe: "fix-model", timeout: 120_000 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sevOf = (f) => SEV_ORDER[f?.severity] ?? 5;
|
|
77
|
+
const brief = (f) => ({ fid: findingId(f), title: f.title, severity: f.severity, category: f.category || null });
|
|
78
|
+
const keysOf = (list) => new Set(list.map(findingKey));
|
|
79
|
+
const actionable = (list) => list.filter((f) => f.severity !== "info");
|
|
80
|
+
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
81
|
+
|
|
82
|
+
function dedupe(findings) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
return findings.filter((f) => {
|
|
85
|
+
if (!f || (f.severity === "info" && f.category === "config")) return false;
|
|
86
|
+
const k = findingKey(f);
|
|
87
|
+
if (seen.has(k)) return false;
|
|
88
|
+
seen.add(k);
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Record a timed step on `list`. */
|
|
94
|
+
async function timed(list, step, fn) {
|
|
95
|
+
const startedAt = new Date().toISOString();
|
|
96
|
+
const t0 = Date.now();
|
|
97
|
+
try {
|
|
98
|
+
return await fn();
|
|
99
|
+
} finally {
|
|
100
|
+
list.push({ step, startedAt, durationMs: Date.now() - t0 });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function clean(obj) {
|
|
105
|
+
return JSON.parse(JSON.stringify(obj)); // drops undefined so memory's canonical JSON accepts it
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run the loop.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @param {string} opts.repo - repo to fix (required)
|
|
113
|
+
* @param {string} [opts.url] - live target to scan as well
|
|
114
|
+
* @param {number} [opts.maxIterations=5]
|
|
115
|
+
* @param {"off"|"low"} [opts.auto="off"]
|
|
116
|
+
* @param {boolean} [opts.runTests=false]
|
|
117
|
+
* @param {string} [opts.fixModel] - local Ollama model name
|
|
118
|
+
* @param {boolean} [opts.allowDirty=false]
|
|
119
|
+
* @param {(req: object) => Promise<"y"|"n"|"skip"|"quit">} [opts.ask] - approval prompt; required unless every plan is auto-approved
|
|
120
|
+
* @param {(line: string) => void} [opts.log]
|
|
121
|
+
* @param {AbortSignal} [opts.signal]
|
|
122
|
+
* @param {string} [opts.memoryRoot] - memory root (default ~/.penthera/memory)
|
|
123
|
+
* @param {{ scanRepo?: Function, scanUrl?: Function, runTests?: Function, model?: object }} [opts.deps] - injectable for tests
|
|
124
|
+
* @param {{ revertPending?: () => void }} [opts.handle] - filled with an emergency revert for a second Ctrl-C
|
|
125
|
+
* @returns {Promise<object>} loopRun
|
|
126
|
+
*/
|
|
127
|
+
export async function runLoop(opts = {}) {
|
|
128
|
+
if (!opts.repo) throw new LoopError("agent --loop needs a repo to fix (--repo <path>)", "NO_REPO");
|
|
129
|
+
const repo = resolve(opts.repo);
|
|
130
|
+
const url = opts.url || null;
|
|
131
|
+
const maxIterations = Math.max(1, Number.isInteger(opts.maxIterations) ? opts.maxIterations : 5);
|
|
132
|
+
const auto = opts.auto === "low" ? "low" : "off";
|
|
133
|
+
const log = opts.log || (() => {});
|
|
134
|
+
const deps = opts.deps || {};
|
|
135
|
+
const scanRepoFn = deps.scanRepo || defaultScanRepo;
|
|
136
|
+
const scanUrlFn = deps.scanUrl || defaultScanUrl;
|
|
137
|
+
const testsFn = deps.runTests || runProjectTests;
|
|
138
|
+
const stopped = () => Boolean(opts.signal?.aborted);
|
|
139
|
+
|
|
140
|
+
// ── Preflight: a git work tree on a branch, clean unless --allow-dirty ──
|
|
141
|
+
const gitExec = deps.gitExec ? { exec: deps.gitExec } : {};
|
|
142
|
+
let git = createGit(repo, gitExec);
|
|
143
|
+
if (git.tryRun("rev-parse", "--is-inside-work-tree")?.trim() !== "true") {
|
|
144
|
+
throw new LoopError(`${repo} is not a git work tree. The loop commits each fix, so it needs git.`, "NOT_GIT");
|
|
145
|
+
}
|
|
146
|
+
const baseSha = git.tryRun("rev-parse", "--verify", "--quiet", "HEAD")?.trim();
|
|
147
|
+
if (!baseSha) throw new LoopError(`${repo} has no commits yet. Commit once, then run the loop.`, "NO_HEAD");
|
|
148
|
+
const base = git.tryRun("symbolic-ref", "--quiet", "--short", "HEAD")?.trim() || null;
|
|
149
|
+
if (!base && !opts.allowDirty) {
|
|
150
|
+
throw new LoopError("HEAD is detached. Check out a branch first (the fix branch starts from it), or pass --allow-dirty.", "DETACHED");
|
|
151
|
+
}
|
|
152
|
+
const prefix = git.run("rev-parse", "--show-prefix").trim();
|
|
153
|
+
const dirtyFiles = new Set(
|
|
154
|
+
git.run("status", "--porcelain", "--untracked-files=all", "--", ".").split("\n").filter(Boolean)
|
|
155
|
+
.map((l) => l.slice(3).replace(/^"|"$/g, "").split(" -> ").pop())
|
|
156
|
+
.map((p) => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p)),
|
|
157
|
+
);
|
|
158
|
+
if (dirtyFiles.size && !opts.allowDirty) {
|
|
159
|
+
throw new LoopError(`${repo} has uncommitted changes (${[...dirtyFiles].slice(0, 5).join(", ")}${dirtyFiles.size > 5 ? ", ..." : ""}). Commit or stash them first, or pass --allow-dirty.`, "DIRTY");
|
|
160
|
+
}
|
|
161
|
+
if (!git.tryRun("config", "--get", "user.email")?.trim()) {
|
|
162
|
+
// No identity configured: commit as the agent rather than failing after a verified fix.
|
|
163
|
+
git = createGit(repo, {
|
|
164
|
+
...gitExec,
|
|
165
|
+
env: {
|
|
166
|
+
...process.env,
|
|
167
|
+
GIT_AUTHOR_NAME: "penthera agent", GIT_AUTHOR_EMAIL: "penthera-agent@localhost",
|
|
168
|
+
GIT_COMMITTER_NAME: "penthera agent", GIT_COMMITTER_EMAIL: "penthera-agent@localhost",
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Memory: fail closed on a broken chain (no ranking, no auto, no writes) ──
|
|
174
|
+
const { id: projectId, label } = projectIdFor({ repo });
|
|
175
|
+
const mem = openMemory(projectId, { root: opts.memoryRoot, label });
|
|
176
|
+
const chainOk = mem.verify().ok;
|
|
177
|
+
let views = chainOk ? buildViews(mem.events()) : { suppressions: [], recipeSuccess: [], seen: {} };
|
|
178
|
+
const repoSupp = loadRepoSuppressions(repo);
|
|
179
|
+
views = withRepoSuppressions(views, repoSupp.entries);
|
|
180
|
+
let eventsAppended = 0;
|
|
181
|
+
const remember = (type, data) => {
|
|
182
|
+
if (!chainOk) return;
|
|
183
|
+
try {
|
|
184
|
+
mem.append({ type, data: clean(data) });
|
|
185
|
+
eventsAppended++;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
log(` memory: could not record ${type} (${e.message})`);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const recipeStats = (framework, recipe) =>
|
|
191
|
+
views.recipeSuccess?.find((r) => r.framework === String(framework || "unknown").toLowerCase() && r.recipe === recipe) || null;
|
|
192
|
+
const stack = detectFramework(repo) || "unknown";
|
|
193
|
+
|
|
194
|
+
let model = null;
|
|
195
|
+
if (opts.fixModel) model = deps.model || createModelFixer({ model: opts.fixModel, fetch: localModelFetch });
|
|
196
|
+
|
|
197
|
+
const startedAt = new Date().toISOString();
|
|
198
|
+
const t0 = Date.now();
|
|
199
|
+
const loopRun = {
|
|
200
|
+
schema: LOOP_RUN_SCHEMA,
|
|
201
|
+
id: randomUUID(),
|
|
202
|
+
startedAt,
|
|
203
|
+
finishedAt: null,
|
|
204
|
+
durationMs: 0,
|
|
205
|
+
target: { repo, url },
|
|
206
|
+
branch: { base, baseSha, fix: null },
|
|
207
|
+
options: {
|
|
208
|
+
maxIterations, auto, runTests: Boolean(opts.runTests), fixModel: opts.fixModel || null,
|
|
209
|
+
offline: true, allowDirty: Boolean(opts.allowDirty),
|
|
210
|
+
},
|
|
211
|
+
stopReason: null,
|
|
212
|
+
error: null,
|
|
213
|
+
findings: { initial: null, final: null },
|
|
214
|
+
totals: { iterations: 0, planned: 0, applied: 0, verified: 0, failed: 0, declined: 0, skipped: 0, commits: [] },
|
|
215
|
+
memory: { projectId, chain: chainOk ? "ok" : "broken", eventsAppended: 0 },
|
|
216
|
+
iterations: [],
|
|
217
|
+
};
|
|
218
|
+
if (!chainOk) log(" memory chain is broken: ranking and --auto are off, nothing is recorded. Run `penthera memory verify`.");
|
|
219
|
+
for (const w of repoSupp.warnings || []) log(` ${w}`);
|
|
220
|
+
|
|
221
|
+
// ── The fix branch, from the current HEAD ──
|
|
222
|
+
let branch = fixBranchName();
|
|
223
|
+
for (let i = 2; git.tryRun("show-ref", "--verify", "--quiet", `refs/heads/${branch}`) !== null; i++) {
|
|
224
|
+
branch = `${fixBranchName()}-${i}`;
|
|
225
|
+
}
|
|
226
|
+
git.run("checkout", "-b", branch);
|
|
227
|
+
loopRun.branch.fix = branch;
|
|
228
|
+
log(` on ${branch} (from ${base || baseSha.slice(0, 12)})`);
|
|
229
|
+
|
|
230
|
+
// The fix in flight, so a second Ctrl-C can put the files back.
|
|
231
|
+
let pending = null;
|
|
232
|
+
const revert = (files, created) => {
|
|
233
|
+
const tracked = files.filter((f) => !created.includes(f));
|
|
234
|
+
if (tracked.length) git.run("checkout", "--", ...tracked);
|
|
235
|
+
for (const f of created) {
|
|
236
|
+
const p = join(repo, f);
|
|
237
|
+
if (existsSync(p)) unlinkSync(p);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
if (opts.handle) {
|
|
241
|
+
opts.handle.revertPending = () => {
|
|
242
|
+
if (pending) revert(pending.files, pending.created);
|
|
243
|
+
pending = null;
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const ctx = loopEgressContext({ url, fixModel: opts.fixModel });
|
|
248
|
+
const scanOpts = { offline: true, allowHosts: [...ctx.allowHosts], timeout: opts.scanTimeout || 10_000 };
|
|
249
|
+
|
|
250
|
+
async function scan() {
|
|
251
|
+
const repoResult = await scanRepoFn(repo, {});
|
|
252
|
+
const stat = dedupe([...(repoResult?.findings || []), ...probeRepo(repo)]);
|
|
253
|
+
let live = [];
|
|
254
|
+
if (url) {
|
|
255
|
+
const r = await scanUrlFn(url, scanOpts);
|
|
256
|
+
live = dedupe(r?.findings || []);
|
|
257
|
+
}
|
|
258
|
+
return { static: stat, live, all: dedupe([...stat, ...live]) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function recordScan(s) {
|
|
262
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
263
|
+
for (const f of s.all) if (counts[f.severity] != null) counts[f.severity]++;
|
|
264
|
+
remember("scan", {
|
|
265
|
+
stack, modes: url ? ["repo", "url", "agent"] : ["repo", "agent"],
|
|
266
|
+
counts: { ...counts, total: s.all.length, suppressed: 0 },
|
|
267
|
+
ids: actionable(s.all).map(findingId),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function triage(s) {
|
|
272
|
+
const kept = [];
|
|
273
|
+
let suppressed = 0;
|
|
274
|
+
for (const f of s.all) {
|
|
275
|
+
if (isSuppressed(f, views)) suppressed++;
|
|
276
|
+
else kept.push(f);
|
|
277
|
+
}
|
|
278
|
+
return { ranked: chainOk ? rankFindings(kept, views, { framework: stack }) : kept, suppressed };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let heldBack = 0; // plans left out of the last plan() because they were declined or failed
|
|
282
|
+
const tried = new Set(); // plan keys declined or failed in this run: never proposed again
|
|
283
|
+
const planKey = (p) => (p.source === "recipe" ? `recipe:${p.recipe}` : `model:${p.findings.map(findingKey).sort().join("|")}`);
|
|
284
|
+
|
|
285
|
+
async function plan(ranked) {
|
|
286
|
+
const byKey = new Map(ranked.map((f) => [findingKey(f), f]));
|
|
287
|
+
const { plans } = await planFixes(repo, ranked, { model });
|
|
288
|
+
const order = new Map(ranked.map((f, i) => [findingKey(f), i]));
|
|
289
|
+
heldBack = plans.filter((p) => tried.has(planKey(p))).length;
|
|
290
|
+
return plans
|
|
291
|
+
.filter((p) => !tried.has(planKey(p)))
|
|
292
|
+
.map((p) => {
|
|
293
|
+
const stats = p.source === "recipe" ? recipeStats(p.framework, p.recipe) : null;
|
|
294
|
+
return {
|
|
295
|
+
plan: p,
|
|
296
|
+
originals: p.findings.map((b) => byKey.get(findingKey(b)) || b),
|
|
297
|
+
sev: Math.min(...p.findings.map(sevOf)),
|
|
298
|
+
rate: stats?.rate ?? null,
|
|
299
|
+
rank: Math.min(...p.findings.map((b) => order.get(findingKey(b)) ?? Infinity)),
|
|
300
|
+
stats,
|
|
301
|
+
};
|
|
302
|
+
})
|
|
303
|
+
// Severity first (memory never changes it), then the recipe's verified rate, then memory's finding rank.
|
|
304
|
+
.sort((a, b) => a.sev - b.sev || (b.rate ?? 0.5) - (a.rate ?? 0.5) || a.rank - b.rank);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const autoOk = (p, stats) => auto === "low" && chainOk && p.source === "recipe" && p.confidence !== "needs-human-review"
|
|
308
|
+
&& stats && stats.verified >= AUTO_MIN_VERIFIED && stats.failed === 0;
|
|
309
|
+
|
|
310
|
+
function commitMessage(p, entry, decision) {
|
|
311
|
+
const lines = p.findings.map((f) => `- [${f.severity || "?"}] ${f.title}${f.url ? ` (${f.url})` : ""}`);
|
|
312
|
+
const tests = entry.verify?.tests ? (entry.verify.tests.ok ? "project tests passed" : "project tests failed") : "project tests not run";
|
|
313
|
+
return [
|
|
314
|
+
`fix(security): ${p.title}${p.framework ? ` (${p.framework})` : ""}`,
|
|
315
|
+
[
|
|
316
|
+
"Fixes:", ...lines, "",
|
|
317
|
+
`Recipe: ${p.recipe} (${p.source}, confidence ${p.confidence}), plan ${p.id}`,
|
|
318
|
+
`Verified: re-scan no longer reports the fixed findings and shows no new ones; ${tests}.`,
|
|
319
|
+
`Applied by penthera agent --loop (${decision === "auto" ? "auto-approved from memory" : "approved"}). Nothing was pushed.`,
|
|
320
|
+
].join("\n"),
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let current = null; // last scan, kept in sync as fixes land
|
|
325
|
+
let stopReason = null;
|
|
326
|
+
|
|
327
|
+
const body = async () => {
|
|
328
|
+
for (let n = 1; n <= maxIterations && !stopReason; n++) {
|
|
329
|
+
if (stopped()) { stopReason = "interrupted"; break; }
|
|
330
|
+
const it = { n, startedAt: new Date().toISOString(), durationMs: 0, findings: null, steps: [], fixes: [] };
|
|
331
|
+
loopRun.iterations.push(it);
|
|
332
|
+
const itStart = Date.now();
|
|
333
|
+
log(`\n iteration ${n}/${maxIterations}`);
|
|
334
|
+
|
|
335
|
+
current = await timed(it.steps, "scan", scan);
|
|
336
|
+
recordScan(current);
|
|
337
|
+
if (loopRun.findings.initial == null) loopRun.findings.initial = actionable(current.all).length;
|
|
338
|
+
const { ranked, suppressed } = await timed(it.steps, "triage", async () => triage(current));
|
|
339
|
+
const candidates = await timed(it.steps, "plan", () => plan(ranked));
|
|
340
|
+
it.findings = { total: current.all.length, suppressed, fixable: candidates.length };
|
|
341
|
+
loopRun.totals.planned += candidates.length;
|
|
342
|
+
log(` scan: ${actionable(current.all).length} findings (${suppressed} suppressed), ${candidates.length} fixable`);
|
|
343
|
+
|
|
344
|
+
if (!candidates.length) {
|
|
345
|
+
stopReason = heldBack ? "declined-or-failed-left" : "no-fixable-findings";
|
|
346
|
+
it.durationMs = Date.now() - itStart;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let appliedHere = 0;
|
|
351
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
352
|
+
if (stopped()) { stopReason = "interrupted"; break; }
|
|
353
|
+
let { plan: p, originals, stats } = candidates[i];
|
|
354
|
+
const entry = {
|
|
355
|
+
planId: p.id, recipe: p.recipe, title: p.title, framework: p.framework || null, source: p.source,
|
|
356
|
+
confidence: p.confidence, files: p.files, findings: originals.map(brief),
|
|
357
|
+
memory: stats ? { verified: stats.verified, failed: stats.failed } : null,
|
|
358
|
+
decision: null, status: "not-applied", reason: null, commit: null, verify: null, steps: [],
|
|
359
|
+
};
|
|
360
|
+
it.fixes.push(entry);
|
|
361
|
+
|
|
362
|
+
// Earlier fixes in this iteration may have changed the same files: re-plan against the tree.
|
|
363
|
+
if (i > 0) {
|
|
364
|
+
const fresh = (await planFixes(repo, originals, { model })).plans.find((q) => planKey(q) === planKey(p));
|
|
365
|
+
if (!fresh) {
|
|
366
|
+
entry.decision = "obsolete";
|
|
367
|
+
entry.reason = "an earlier fix in this iteration already covered it";
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
p = fresh;
|
|
371
|
+
Object.assign(entry, { planId: p.id, files: p.files });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const shown = redactPlan(p);
|
|
375
|
+
let answer;
|
|
376
|
+
if (autoOk(p, stats)) {
|
|
377
|
+
answer = "auto";
|
|
378
|
+
log(` [${i + 1}/${candidates.length}] ${p.title}: auto-approved (memory: ${stats.verified} verified, 0 failed on ${p.framework})`);
|
|
379
|
+
} else {
|
|
380
|
+
if (!opts.ask) throw new LoopError("No approval prompt: every diff needs a y/n answer unless --auto low applies", "NO_PROMPT");
|
|
381
|
+
answer = await timed(entry.steps, "approve", () => opts.ask({
|
|
382
|
+
plan: shown, iteration: n, index: i + 1, total: candidates.length,
|
|
383
|
+
memory: entry.memory, signal: opts.signal,
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
if (stopped()) { entry.decision = "quit"; stopReason = "interrupted"; break; }
|
|
387
|
+
if (answer === "quit") { entry.decision = "quit"; stopReason = "user-quit"; break; }
|
|
388
|
+
if (answer === "n") { entry.decision = "declined"; tried.add(planKey(p)); loopRun.totals.declined++; continue; }
|
|
389
|
+
if (answer !== "y" && answer !== "auto") { entry.decision = "skipped"; loopRun.totals.skipped++; continue; }
|
|
390
|
+
entry.decision = answer === "auto" ? "auto" : "approved";
|
|
391
|
+
|
|
392
|
+
const touchesDirty = p.files.filter((f) => dirtyFiles.has(f));
|
|
393
|
+
if (touchesDirty.length) {
|
|
394
|
+
entry.reason = `touches files with uncommitted changes (${touchesDirty.join(", ")}); fix them by hand`;
|
|
395
|
+
tried.add(planKey(p));
|
|
396
|
+
log(` not applied: ${entry.reason}`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── Patch ──
|
|
401
|
+
let applied;
|
|
402
|
+
try {
|
|
403
|
+
applied = await timed(entry.steps, "patch", async () => applyDiff(repo, p.diff, { allowDirty: Boolean(opts.allowDirty) }));
|
|
404
|
+
} catch (e) {
|
|
405
|
+
entry.reason = `did not apply: ${e.message}`;
|
|
406
|
+
tried.add(planKey(p));
|
|
407
|
+
log(` not applied: ${e.message}`);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const files = applied.files.map((f) => f.path);
|
|
411
|
+
const created = applied.files.filter((f) => f.created).map((f) => f.path);
|
|
412
|
+
pending = { files, created };
|
|
413
|
+
loopRun.totals.applied++;
|
|
414
|
+
appliedHere++;
|
|
415
|
+
const primary = originals[0];
|
|
416
|
+
const learnData = {
|
|
417
|
+
id: findingId(primary), recipe: p.recipe, framework: p.framework || stack, category: primary.category || null,
|
|
418
|
+
diff_sha256: sha256(p.diff), plan: p.id, ids: originals.map(findingId), source: p.source, loop: loopRun.id,
|
|
419
|
+
};
|
|
420
|
+
await timed(entry.steps, "learn", async () => remember("fix_applied", learnData));
|
|
421
|
+
|
|
422
|
+
// ── Verify: re-scan, then the project's tests ──
|
|
423
|
+
const before = current;
|
|
424
|
+
const verdict = await timed(entry.steps, "verify", async () => {
|
|
425
|
+
const after = await scan();
|
|
426
|
+
const staticNow = keysOf(after.static);
|
|
427
|
+
const liveNow = keysOf(after.live);
|
|
428
|
+
const beforeKeys = keysOf(before.all);
|
|
429
|
+
const targetedRemaining = p.findings.filter((f) => staticNow.has(findingKey(f))).length;
|
|
430
|
+
const liveRemaining = p.findings.filter((f) => liveNow.has(findingKey(f))).length;
|
|
431
|
+
const introduced = actionable(after.all).filter((f) => !beforeKeys.has(findingKey(f)));
|
|
432
|
+
let tests = null;
|
|
433
|
+
if (opts.runTests && hasTestScript(repo)) {
|
|
434
|
+
const r = await testsFn(repo);
|
|
435
|
+
tests = { ok: r.ok, exitCode: r.exitCode, durationMs: r.durationMs };
|
|
436
|
+
if (!r.ok) entry.testOutput = r.output?.slice(-2_000);
|
|
437
|
+
}
|
|
438
|
+
const reasons = [];
|
|
439
|
+
if (targetedRemaining) reasons.push(`${targetedRemaining} targeted finding(s) still reported`);
|
|
440
|
+
if (introduced.length) reasons.push(`new finding(s): ${introduced.slice(0, 3).map((f) => f.title).join("; ")}`);
|
|
441
|
+
if (tests && !tests.ok) reasons.push(`project tests failed (exit ${tests.exitCode})`);
|
|
442
|
+
return { after, ok: reasons.length === 0, reasons, summary: { targetedRemaining, newFindings: introduced.length, liveRemaining, tests } };
|
|
443
|
+
});
|
|
444
|
+
entry.verify = verdict.summary;
|
|
445
|
+
|
|
446
|
+
if (verdict.ok) {
|
|
447
|
+
const [subject, msg] = commitMessage(p, entry, entry.decision);
|
|
448
|
+
await timed(entry.steps, "commit", async () => {
|
|
449
|
+
git.run("add", "--", ...files);
|
|
450
|
+
git.run("commit", "-q", "-m", subject, "-m", msg, "--", ...files);
|
|
451
|
+
});
|
|
452
|
+
pending = null;
|
|
453
|
+
entry.commit = git.run("rev-parse", "HEAD").trim();
|
|
454
|
+
entry.status = "verified";
|
|
455
|
+
loopRun.totals.verified++;
|
|
456
|
+
loopRun.totals.commits.push(entry.commit);
|
|
457
|
+
current = verdict.after;
|
|
458
|
+
await timed(entry.steps, "learn", async () => remember("fix_verified", { ...learnData, commit: entry.commit }));
|
|
459
|
+
const live = entry.verify.liveRemaining ? `; ${entry.verify.liveRemaining} still served by the running app until it restarts` : "";
|
|
460
|
+
log(` verified and committed ${entry.commit.slice(0, 10)}${live}`);
|
|
461
|
+
} else {
|
|
462
|
+
await timed(entry.steps, "revert", async () => revert(files, created));
|
|
463
|
+
pending = null;
|
|
464
|
+
entry.status = "failed";
|
|
465
|
+
entry.reason = verdict.reasons.join("; ");
|
|
466
|
+
tried.add(planKey(p));
|
|
467
|
+
loopRun.totals.failed++;
|
|
468
|
+
await timed(entry.steps, "learn", async () => remember("fix_failed", { ...learnData, reason: entry.reason }));
|
|
469
|
+
log(` failed verification, reverted: ${entry.reason}`);
|
|
470
|
+
}
|
|
471
|
+
if (stopped()) { stopReason = "interrupted"; break; }
|
|
472
|
+
}
|
|
473
|
+
it.durationMs = Date.now() - itStart;
|
|
474
|
+
if (!stopReason && appliedHere === 0 && it.fixes.every((f) => f.decision !== "skipped")) {
|
|
475
|
+
stopReason = "declined-or-failed-left";
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!stopReason) {
|
|
480
|
+
// The last iteration may have fixed everything: check before saying we ran out of iterations.
|
|
481
|
+
const s = await scan();
|
|
482
|
+
current = s;
|
|
483
|
+
const left = await plan(triage(s).ranked);
|
|
484
|
+
stopReason = left.length ? "max-iterations" : heldBack ? "declined-or-failed-left" : "no-fixable-findings";
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
await runWithEgress(ctx, body);
|
|
490
|
+
} catch (e) {
|
|
491
|
+
if (pending) {
|
|
492
|
+
try { revert(pending.files, pending.created); } catch { /* reported below */ }
|
|
493
|
+
pending = null;
|
|
494
|
+
}
|
|
495
|
+
stopReason = "error";
|
|
496
|
+
loopRun.error = e.message;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
loopRun.stopReason = stopReason;
|
|
500
|
+
loopRun.findings.final = current ? actionable(triage(current).ranked).length : null;
|
|
501
|
+
loopRun.totals.iterations = loopRun.iterations.length;
|
|
502
|
+
loopRun.finishedAt = new Date().toISOString();
|
|
503
|
+
loopRun.durationMs = Date.now() - t0;
|
|
504
|
+
remember("loop_run", summarizeForMemory(loopRun));
|
|
505
|
+
loopRun.memory.eventsAppended = eventsAppended;
|
|
506
|
+
return loopRun;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** The loopRun as stored in memory: no test output, bounded size. */
|
|
510
|
+
export function summarizeForMemory(loopRun) {
|
|
511
|
+
const { iterations, ...rest } = loopRun;
|
|
512
|
+
return clean({
|
|
513
|
+
...rest,
|
|
514
|
+
iterations: iterations.map((it) => ({
|
|
515
|
+
...it,
|
|
516
|
+
fixes: it.fixes.map(({ testOutput, findings, ...f }) => ({ ...f, findings: findings.map((x) => x.fid) })),
|
|
517
|
+
})),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the project's own test script during verification (--run-tests only).
|
|
3
|
+
*
|
|
4
|
+
* Uses `node --run test` rather than `npm test`: it runs the package.json script
|
|
5
|
+
* directly, without npm's update check or registry lookups. The script itself is
|
|
6
|
+
* the project's code, so the user opts in to running it with --run-tests.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { readPackageJson } from "../../lib/fix/util.js";
|
|
10
|
+
|
|
11
|
+
const NPM_PLACEHOLDER = /no test specified/i;
|
|
12
|
+
|
|
13
|
+
/** True when package.json has a real `test` script. */
|
|
14
|
+
export function hasTestScript(repoPath) {
|
|
15
|
+
const script = readPackageJson(repoPath)?.scripts?.test;
|
|
16
|
+
return typeof script === "string" && script.trim() !== "" && !NPM_PLACEHOLDER.test(script);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} repoPath
|
|
21
|
+
* @param {{ timeout?: number }} [opts]
|
|
22
|
+
* @returns {Promise<{ ok: boolean, exitCode: number|null, durationMs: number, output: string }>}
|
|
23
|
+
*/
|
|
24
|
+
export function runProjectTests(repoPath, { timeout = 10 * 60_000 } = {}) {
|
|
25
|
+
const start = Date.now();
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const child = spawn(process.execPath, ["--run", "test"], {
|
|
28
|
+
cwd: repoPath,
|
|
29
|
+
env: { ...process.env, CI: "1", NO_UPDATE_NOTIFIER: "1" },
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
});
|
|
32
|
+
let output = "";
|
|
33
|
+
const keep = (chunk) => {
|
|
34
|
+
output = (output + chunk).slice(-8_000); // the tail is enough to say why it failed
|
|
35
|
+
};
|
|
36
|
+
child.stdout.on("data", keep);
|
|
37
|
+
child.stderr.on("data", keep);
|
|
38
|
+
const timer = setTimeout(() => child.kill("SIGTERM"), timeout);
|
|
39
|
+
child.on("error", (e) => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolve({ ok: false, exitCode: null, durationMs: Date.now() - start, output: String(e.message) });
|
|
42
|
+
});
|
|
43
|
+
child.on("close", (code) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
resolve({ ok: code === 0, exitCode: code, durationMs: Date.now() - start, output });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|