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
package/lib/fix/diff.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal unified-diff support for fix recipes: create a patch from two
|
|
3
|
+
* versions of a file, parse one back, and apply its hunks to text.
|
|
4
|
+
*
|
|
5
|
+
* Recipes compute the new file content in memory and turn it into a diff here;
|
|
6
|
+
* nothing in this module touches the filesystem.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CONTEXT = 3;
|
|
10
|
+
// Marks a final line that has no trailing newline, so "x" and "x\n" compare as
|
|
11
|
+
// different lines (as they do in git).
|
|
12
|
+
const NOEOL = "\u0000";
|
|
13
|
+
|
|
14
|
+
function toLines(text) {
|
|
15
|
+
if (!text) return [];
|
|
16
|
+
const lines = text.split("\n");
|
|
17
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
18
|
+
else lines[lines.length - 1] += NOEOL;
|
|
19
|
+
return lines;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fromLines(lines) {
|
|
23
|
+
return lines.map((l, i) => (l.endsWith(NOEOL) && i === lines.length - 1 ? l.slice(0, -1) : l.replace(NOEOL, "") + "\n")).join("");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Line diff as a list of ops ({ t: " " | "-" | "+", line }). */
|
|
27
|
+
function diffLines(a, b) {
|
|
28
|
+
let start = 0;
|
|
29
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
30
|
+
let endA = a.length;
|
|
31
|
+
let endB = b.length;
|
|
32
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
|
|
33
|
+
|
|
34
|
+
const midA = a.slice(start, endA);
|
|
35
|
+
const midB = b.slice(start, endB);
|
|
36
|
+
const n = midA.length;
|
|
37
|
+
const m = midB.length;
|
|
38
|
+
// LCS table over the changed middle only; recipe edits are small and local.
|
|
39
|
+
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
40
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
41
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
42
|
+
dp[i][j] = midA[i] === midB[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const mid = [];
|
|
46
|
+
let i = 0;
|
|
47
|
+
let j = 0;
|
|
48
|
+
while (i < n && j < m) {
|
|
49
|
+
if (midA[i] === midB[j]) { mid.push({ t: " ", line: midA[i] }); i++; j++; }
|
|
50
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) mid.push({ t: "-", line: midA[i++] });
|
|
51
|
+
else mid.push({ t: "+", line: midB[j++] });
|
|
52
|
+
}
|
|
53
|
+
while (i < n) mid.push({ t: "-", line: midA[i++] });
|
|
54
|
+
while (j < m) mid.push({ t: "+", line: midB[j++] });
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
...a.slice(0, start).map((line) => ({ t: " ", line })),
|
|
58
|
+
...mid,
|
|
59
|
+
...a.slice(endA).map((line) => ({ t: " ", line })),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderLine(op) {
|
|
64
|
+
const noeol = op.line.endsWith(NOEOL);
|
|
65
|
+
const text = `${op.t}${noeol ? op.line.slice(0, -1) : op.line}`;
|
|
66
|
+
return noeol ? `${text}\n\` : text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Unified diff for one file. `before` is null for a new file. Returns "" when
|
|
71
|
+
* nothing changed.
|
|
72
|
+
*/
|
|
73
|
+
export function createPatch(file, before, after) {
|
|
74
|
+
const a = toLines(before);
|
|
75
|
+
const b = toLines(after);
|
|
76
|
+
const ops = diffLines(a, b);
|
|
77
|
+
if (!ops.some((o) => o.t !== " ")) return "";
|
|
78
|
+
|
|
79
|
+
// Group changes into hunks with CONTEXT lines around each.
|
|
80
|
+
const hunks = [];
|
|
81
|
+
let current = null;
|
|
82
|
+
ops.forEach((op, idx) => {
|
|
83
|
+
if (op.t === " ") return;
|
|
84
|
+
const lo = Math.max(0, idx - CONTEXT);
|
|
85
|
+
const hi = Math.min(ops.length - 1, idx + CONTEXT);
|
|
86
|
+
if (current && lo <= current.hi + 1) current.hi = Math.max(current.hi, hi);
|
|
87
|
+
else hunks.push((current = { lo, hi }));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const out = [`--- ${before == null ? "/dev/null" : `a/${file}`}`, `+++ b/${file}`];
|
|
91
|
+
let oldNo = 1;
|
|
92
|
+
let newNo = 1;
|
|
93
|
+
let cursor = 0;
|
|
94
|
+
for (const h of hunks) {
|
|
95
|
+
for (; cursor < h.lo; cursor++) {
|
|
96
|
+
if (ops[cursor].t !== "+") oldNo++;
|
|
97
|
+
if (ops[cursor].t !== "-") newNo++;
|
|
98
|
+
}
|
|
99
|
+
const slice = ops.slice(h.lo, h.hi + 1);
|
|
100
|
+
const oldCount = slice.filter((o) => o.t !== "+").length;
|
|
101
|
+
const newCount = slice.filter((o) => o.t !== "-").length;
|
|
102
|
+
out.push(`@@ -${oldCount ? oldNo : oldNo - 1},${oldCount} +${newCount ? newNo : newNo - 1},${newCount} @@`);
|
|
103
|
+
for (const op of slice) out.push(renderLine(op));
|
|
104
|
+
for (; cursor <= h.hi; cursor++) {
|
|
105
|
+
if (ops[cursor].t !== "+") oldNo++;
|
|
106
|
+
if (ops[cursor].t !== "-") newNo++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out.join("\n") + "\n";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stripPrefix(p) {
|
|
113
|
+
if (p === "/dev/null") return null;
|
|
114
|
+
return p.replace(/^[ab]\//, "").replace(/\t.*$/, "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Parse a (multi-file) unified diff into [{ oldPath, newPath, hunks }]. */
|
|
118
|
+
export function parsePatch(text) {
|
|
119
|
+
const files = [];
|
|
120
|
+
const lines = String(text || "").split("\n");
|
|
121
|
+
let file = null;
|
|
122
|
+
let hunk = null;
|
|
123
|
+
for (let i = 0; i < lines.length; i++) {
|
|
124
|
+
const line = lines[i];
|
|
125
|
+
if (line.startsWith("--- ") && lines[i + 1]?.startsWith("+++ ")) {
|
|
126
|
+
file = { oldPath: stripPrefix(line.slice(4).trim()), newPath: stripPrefix(lines[i + 1].slice(4).trim()), hunks: [] };
|
|
127
|
+
files.push(file);
|
|
128
|
+
hunk = null;
|
|
129
|
+
i++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const header = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
133
|
+
if (header) {
|
|
134
|
+
if (!file) throw new Error("Malformed diff: hunk before file header");
|
|
135
|
+
hunk = {
|
|
136
|
+
oldStart: Number(header[1]),
|
|
137
|
+
oldLines: header[2] == null ? 1 : Number(header[2]),
|
|
138
|
+
newStart: Number(header[3]),
|
|
139
|
+
newLines: header[4] == null ? 1 : Number(header[4]),
|
|
140
|
+
ops: [],
|
|
141
|
+
};
|
|
142
|
+
file.hunks.push(hunk);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!hunk) continue;
|
|
146
|
+
if (line.startsWith("\\")) {
|
|
147
|
+
const last = hunk.ops[hunk.ops.length - 1];
|
|
148
|
+
if (last) last.line += NOEOL;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const oldSeen = hunk.ops.filter((o) => o.t !== "+").length;
|
|
152
|
+
const newSeen = hunk.ops.filter((o) => o.t !== "-").length;
|
|
153
|
+
if (oldSeen >= hunk.oldLines && newSeen >= hunk.newLines) continue; // past the hunk
|
|
154
|
+
const t = line[0];
|
|
155
|
+
if (t === " " || t === "-" || t === "+") hunk.ops.push({ t, line: line.slice(1) });
|
|
156
|
+
// Some editors strip the single space of an empty context line.
|
|
157
|
+
else if (line === "") hunk.ops.push({ t: " ", line: "" });
|
|
158
|
+
}
|
|
159
|
+
for (const f of files) {
|
|
160
|
+
for (const h of f.hunks) {
|
|
161
|
+
const oldCount = h.ops.filter((o) => o.t !== "+").length;
|
|
162
|
+
const newCount = h.ops.filter((o) => o.t !== "-").length;
|
|
163
|
+
if (oldCount !== h.oldLines || newCount !== h.newLines) {
|
|
164
|
+
throw new Error(`Malformed diff: hunk line counts do not match its header in ${f.newPath || f.oldPath}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return files;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function matchesAt(lines, expected, pos) {
|
|
172
|
+
if (pos < 0 || pos + expected.length > lines.length) return false;
|
|
173
|
+
for (let k = 0; k < expected.length; k++) if (lines[pos + k] !== expected[k]) return false;
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Apply one parsed file's hunks to `text` (null for a new file). Context must
|
|
179
|
+
* match exactly; a hunk may move by a few lines but is never fuzzed. Throws if
|
|
180
|
+
* any hunk does not apply.
|
|
181
|
+
*/
|
|
182
|
+
export function applyHunks(text, hunks) {
|
|
183
|
+
const lines = toLines(text);
|
|
184
|
+
let offset = 0;
|
|
185
|
+
for (const h of hunks) {
|
|
186
|
+
const expected = h.ops.filter((o) => o.t !== "+").map((o) => o.line);
|
|
187
|
+
const replacement = h.ops.filter((o) => o.t !== "-").map((o) => o.line);
|
|
188
|
+
const want = (expected.length ? h.oldStart - 1 : h.oldStart) + offset;
|
|
189
|
+
let pos = -1;
|
|
190
|
+
for (let d = 0; d <= 50 && pos < 0; d++) {
|
|
191
|
+
if (matchesAt(lines, expected, want - d)) pos = want - d;
|
|
192
|
+
else if (matchesAt(lines, expected, want + d)) pos = want + d;
|
|
193
|
+
}
|
|
194
|
+
if (pos < 0) throw new Error(`Hunk @@ -${h.oldStart},${h.oldLines} @@ does not apply: the file changed since the plan was made`);
|
|
195
|
+
lines.splice(pos, expected.length, ...replacement);
|
|
196
|
+
offset += replacement.length - expected.length;
|
|
197
|
+
}
|
|
198
|
+
return fromLines(lines);
|
|
199
|
+
}
|
package/lib/fix/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix recipes: deterministic, local fixes for common findings.
|
|
3
|
+
*
|
|
4
|
+
* A recipe is { id, matches(finding), detect(repoPath), plan(repoPath, finding,
|
|
5
|
+
* framework) } and may add probe(repoPath), a static check that reports the
|
|
6
|
+
* finding from source so a fix can be verified without a live server. plan()
|
|
7
|
+
* returns { diff, files, ownerActions, confidence } or null when it cannot find
|
|
8
|
+
* a safe anchor. Recipes never write: applyDiff() is the separate step that does.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import headers from "./recipes/headers.js";
|
|
12
|
+
import cookies from "./recipes/cookies.js";
|
|
13
|
+
import cors from "./recipes/cors.js";
|
|
14
|
+
import rateLimit from "./recipes/rate-limit.js";
|
|
15
|
+
import secrets from "./recipes/secrets.js";
|
|
16
|
+
|
|
17
|
+
export { applyDiff, assertCleanTree, FixError } from "./apply.js";
|
|
18
|
+
export { createPatch, parsePatch, applyHunks } from "./diff.js";
|
|
19
|
+
export { createModelFixer } from "./model.js";
|
|
20
|
+
|
|
21
|
+
export const RECIPES = [headers, cookies, cors, rateLimit, secrets];
|
|
22
|
+
|
|
23
|
+
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
24
|
+
|
|
25
|
+
/** Findings from every recipe's static probe (headers, cookies, CORS, auth rate limits). */
|
|
26
|
+
export function probeRepo(repoPath, recipes = RECIPES) {
|
|
27
|
+
return recipes.flatMap((r) => {
|
|
28
|
+
if (!r.probe) return [];
|
|
29
|
+
try {
|
|
30
|
+
return r.probe(repoPath);
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function findingKey(f) {
|
|
38
|
+
return `${f.category || ""}|${f.title || ""}|${f.url || ""}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function planId(recipeId, diff) {
|
|
42
|
+
return `${recipeId}-${createHash("sha256").update(diff).digest("hex").slice(0, 8)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const brief = (f) => ({ title: f.title, severity: f.severity, category: f.category, url: f.url || null });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Plan fixes for findings. Findings that resolve to the same diff (say, five
|
|
49
|
+
* missing headers fixed by one middleware) share one plan.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} repoPath
|
|
52
|
+
* @param {object[]} findings
|
|
53
|
+
* @param {{ model?: { plan(repoPath, finding): Promise<object|null> }, recipes?: object[] }} [opts]
|
|
54
|
+
* @returns {Promise<{ plans: object[], unmatched: object[] }>}
|
|
55
|
+
*/
|
|
56
|
+
export async function planFixes(repoPath, findings, opts = {}) {
|
|
57
|
+
const recipes = opts.recipes || RECIPES;
|
|
58
|
+
const plans = new Map();
|
|
59
|
+
const unmatched = [];
|
|
60
|
+
const frameworks = new Map();
|
|
61
|
+
const sorted = [...(findings || [])].sort((a, b) => (SEV_ORDER[a.severity] ?? 5) - (SEV_ORDER[b.severity] ?? 5));
|
|
62
|
+
|
|
63
|
+
const add = (plan, finding) => {
|
|
64
|
+
const id = planId(plan.recipe, plan.diff);
|
|
65
|
+
const existing = plans.get(id);
|
|
66
|
+
if (existing) {
|
|
67
|
+
if (!existing.findings.some((f) => findingKey(f) === findingKey(finding))) existing.findings.push(brief(finding));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
plans.set(id, { id, ...plan, findings: [brief(finding)] });
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
for (const finding of sorted) {
|
|
74
|
+
let planned = false;
|
|
75
|
+
for (const recipe of recipes) {
|
|
76
|
+
if (!recipe.matches(finding)) continue;
|
|
77
|
+
if (!frameworks.has(recipe.id)) frameworks.set(recipe.id, recipe.detect(repoPath));
|
|
78
|
+
const framework = frameworks.get(recipe.id);
|
|
79
|
+
if (!framework) continue;
|
|
80
|
+
let p = null;
|
|
81
|
+
try {
|
|
82
|
+
p = recipe.plan(repoPath, finding, framework);
|
|
83
|
+
} catch {
|
|
84
|
+
p = null; // a recipe that trips over unexpected code declines rather than guessing
|
|
85
|
+
}
|
|
86
|
+
if (!p?.diff) continue;
|
|
87
|
+
add({
|
|
88
|
+
recipe: recipe.id,
|
|
89
|
+
title: recipe.title,
|
|
90
|
+
framework,
|
|
91
|
+
source: "recipe",
|
|
92
|
+
confidence: p.confidence || "medium",
|
|
93
|
+
diff: p.diff,
|
|
94
|
+
files: p.files,
|
|
95
|
+
ownerActions: p.ownerActions || [],
|
|
96
|
+
redactions: p.redactions || [],
|
|
97
|
+
}, finding);
|
|
98
|
+
planned = true;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (planned) continue;
|
|
102
|
+
if (opts.model && finding.severity !== "info") {
|
|
103
|
+
let p = null;
|
|
104
|
+
try {
|
|
105
|
+
p = await opts.model.plan(repoPath, finding);
|
|
106
|
+
} catch {
|
|
107
|
+
p = null;
|
|
108
|
+
}
|
|
109
|
+
if (p?.diff) {
|
|
110
|
+
add({ recipe: "local-model", title: "Model-suggested fix", framework: null, redactions: [], ...p }, finding);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
unmatched.push(finding);
|
|
115
|
+
}
|
|
116
|
+
return { plans: [...plans.values()], unmatched };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Mask redacted values (hardcoded secrets) so a plan is safe to print or store. */
|
|
120
|
+
export function redactPlan(plan) {
|
|
121
|
+
let diff = plan.diff;
|
|
122
|
+
for (const value of plan.redactions || []) {
|
|
123
|
+
if (!value) continue;
|
|
124
|
+
const mask = value.length <= 8 ? "***" : `${value.slice(0, 4)}…${value.slice(-4)}`;
|
|
125
|
+
diff = diff.split(value).join(mask);
|
|
126
|
+
}
|
|
127
|
+
const { redactions, ...rest } = plan;
|
|
128
|
+
return { ...rest, diff, redacted: Boolean(redactions?.length) };
|
|
129
|
+
}
|
package/lib/fix/model.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional local-model fallback for findings no recipe matches.
|
|
3
|
+
*
|
|
4
|
+
* Talks to Ollama on this machine only (http://127.0.0.1:11434), with a model
|
|
5
|
+
* the user names via --fix-model. The host is fixed: there is no option to point
|
|
6
|
+
* it anywhere else, redirects are refused, and the URL is checked before every
|
|
7
|
+
* request. Off by default. Its output is always labelled
|
|
8
|
+
* confidence "needs-human-review" and source "local-model", and a diff that
|
|
9
|
+
* does not parse, leaves the repo, or fails to apply is dropped.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { applyDiff } from "./apply.js";
|
|
14
|
+
import { parsePatch } from "./diff.js";
|
|
15
|
+
import { detectFramework, walkCodeFiles, findEntryFile } from "./util.js";
|
|
16
|
+
|
|
17
|
+
export const OLLAMA_ORIGIN = "http://127.0.0.1:11434";
|
|
18
|
+
const MAX_CONTEXT_CHARS = 12_000;
|
|
19
|
+
|
|
20
|
+
/** Throws unless `url` is the local Ollama endpoint. Exported for tests. */
|
|
21
|
+
export function assertLocalModelUrl(url) {
|
|
22
|
+
const u = new URL(url);
|
|
23
|
+
if (u.origin !== OLLAMA_ORIGIN) throw new Error(`Refusing to contact ${u.origin}: the fix model may only use ${OLLAMA_ORIGIN}`);
|
|
24
|
+
return u;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function contextFile(repoPath, finding) {
|
|
28
|
+
const files = walkCodeFiles(repoPath);
|
|
29
|
+
if (finding.url && files.includes(finding.url)) return finding.url;
|
|
30
|
+
const fw = detectFramework(repoPath);
|
|
31
|
+
if (fw === "next") {
|
|
32
|
+
for (const f of ["next.config.mjs", "next.config.js", "next.config.ts"]) if (existsSync(join(repoPath, f))) return f;
|
|
33
|
+
}
|
|
34
|
+
return findEntryFile(repoPath, /\S/)?.file || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildPrompt(finding, file, source) {
|
|
38
|
+
return [
|
|
39
|
+
"You fix one security finding in a web app. Reply with a single unified diff and nothing else.",
|
|
40
|
+
"Rules: change as little as possible; edit only the file shown unless a new file is unavoidable;",
|
|
41
|
+
"use paths relative to the repo root with a/ and b/ prefixes; add no dependencies; never invent secrets.",
|
|
42
|
+
"If you cannot fix it safely, reply with NO_FIX.",
|
|
43
|
+
"",
|
|
44
|
+
`Finding: ${JSON.stringify({ title: finding.title, category: finding.category, severity: finding.severity, description: finding.description })}`,
|
|
45
|
+
"",
|
|
46
|
+
`File: ${file}`,
|
|
47
|
+
"```",
|
|
48
|
+
source.slice(0, MAX_CONTEXT_CHARS),
|
|
49
|
+
"```",
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Pull a unified diff out of a model reply (fenced or bare). */
|
|
54
|
+
export function extractDiff(text) {
|
|
55
|
+
if (!text || /^\s*NO_FIX\b/.test(text)) return null;
|
|
56
|
+
const fenced = text.match(/```(?:diff|patch)?\s*\n([\s\S]*?)```/);
|
|
57
|
+
const body = fenced ? fenced[1] : text;
|
|
58
|
+
const start = body.search(/^--- /m);
|
|
59
|
+
if (start < 0) return null;
|
|
60
|
+
const diff = body.slice(start).trimEnd() + "\n";
|
|
61
|
+
return diff;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {{ model: string, fetch?: typeof fetch, timeout?: number }} opts
|
|
66
|
+
* @returns {{ model: string, plan(repoPath: string, finding: object): Promise<object|null> }}
|
|
67
|
+
*/
|
|
68
|
+
export function createModelFixer({ model, fetch: fetchImpl = globalThis.fetch, timeout = 120_000 } = {}) {
|
|
69
|
+
if (!model || typeof model !== "string") throw new Error("A local model name is required (--fix-model <name>)");
|
|
70
|
+
const url = assertLocalModelUrl(`${OLLAMA_ORIGIN}/api/generate`).href;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
model,
|
|
74
|
+
async plan(repoPath, finding) {
|
|
75
|
+
const file = contextFile(repoPath, finding);
|
|
76
|
+
if (!file) return null;
|
|
77
|
+
const source = readFileSync(join(repoPath, file), "utf-8");
|
|
78
|
+
assertLocalModelUrl(url);
|
|
79
|
+
const res = await fetchImpl(url, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ model, prompt: buildPrompt(finding, file, source), stream: false, options: { temperature: 0 } }),
|
|
83
|
+
redirect: "error",
|
|
84
|
+
signal: AbortSignal.timeout(timeout),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) return null;
|
|
87
|
+
const data = await res.json();
|
|
88
|
+
const diff = extractDiff(data?.response);
|
|
89
|
+
if (!diff) return null;
|
|
90
|
+
let files;
|
|
91
|
+
try {
|
|
92
|
+
files = parsePatch(diff).map((f) => f.newPath);
|
|
93
|
+
// Validates paths stay inside the repo and every hunk applies; writes nothing.
|
|
94
|
+
applyDiff(repoPath, diff, { dryRun: true, allowDirty: true });
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
diff,
|
|
100
|
+
files,
|
|
101
|
+
ownerActions: ["A local model wrote this diff, not a deterministic recipe. Read every line before applying, then re-scan."],
|
|
102
|
+
confidence: "needs-human-review",
|
|
103
|
+
source: "local-model",
|
|
104
|
+
model,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe: Secure, HttpOnly and SameSite on cookies set in code (Express
|
|
3
|
+
* res.cookie, Fastify reply.setCookie, Hono setCookie, Next.js cookies().set).
|
|
4
|
+
* Guidance: remediation rule "cookie", references/remediation.md#insecure-cookies.
|
|
5
|
+
*
|
|
6
|
+
* Only single-line calls with a literal cookie name and either no options or an
|
|
7
|
+
* inline options object are edited. Explicit `httpOnly: false` and the like are
|
|
8
|
+
* left alone: someone chose them, and a human should revisit that choice.
|
|
9
|
+
*/
|
|
10
|
+
import { detectFramework, readText, walkCodeFiles, splitCallArgs, toDiff } from "../util.js";
|
|
11
|
+
|
|
12
|
+
const CALLS = [
|
|
13
|
+
{ re: /\b\w+\.cookie\s*\(/g, name: 0, options: 2 }, // Express
|
|
14
|
+
{ re: /\b\w+\.setCookie\s*\(/g, name: 0, options: 2 }, // Fastify (@fastify/cookie)
|
|
15
|
+
{ re: /(?<![\w.$])setCookie\s*\(/g, name: 1, options: 3, sameSite: "Lax" }, // Hono (hono/cookie)
|
|
16
|
+
{ re: /\b(?:cookies\(\)\)?|cookies|cookieStore)\.set\s*\(/g, name: 0, options: 2 }, // Next.js
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const FLAGS = [
|
|
20
|
+
{ key: "httpOnly", label: "HttpOnly" },
|
|
21
|
+
{ key: "secure", label: "Secure" },
|
|
22
|
+
{ key: "sameSite", label: "SameSite" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const literal = (s) => s.match(/^(['"`])([^'"`$]+)\1$/)?.[2] ?? null;
|
|
26
|
+
|
|
27
|
+
/** Every cookie-setting call in `source`, with which flags are missing. */
|
|
28
|
+
function cookieCalls(source) {
|
|
29
|
+
const calls = [];
|
|
30
|
+
// Next.js: `const store = await cookies(); store.set(...)`.
|
|
31
|
+
const stores = [...source.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?cookies\(\)/g)].map((m) => m[1]);
|
|
32
|
+
const specs = stores.length ? [...CALLS, { re: new RegExp(`\\b(?:${stores.join("|")})\\.set\\s*\\(`, "g"), name: 0, options: 2 }] : CALLS;
|
|
33
|
+
for (const spec of specs) {
|
|
34
|
+
spec.re.lastIndex = 0;
|
|
35
|
+
for (const m of source.matchAll(spec.re)) {
|
|
36
|
+
const open = m.index + m[0].length - 1;
|
|
37
|
+
const parsed = splitCallArgs(source, open);
|
|
38
|
+
if (!parsed) continue;
|
|
39
|
+
const name = parsed.args[spec.name] && literal(parsed.args[spec.name].text);
|
|
40
|
+
if (!name) continue;
|
|
41
|
+
const opt = parsed.args[spec.options];
|
|
42
|
+
if (parsed.args.length > spec.options + 1) continue;
|
|
43
|
+
if (opt && !/^\{[\s\S]*\}$/.test(opt.text)) {
|
|
44
|
+
calls.push({ name, index: m.index, fixable: false, missing: [] }); // options in a variable: can't see them
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const text = opt?.text || "";
|
|
48
|
+
const missing = FLAGS.filter((f) => !new RegExp(`\\b${f.key}\\s*:`).test(text) || new RegExp(`\\b${f.key}\\s*:\\s*false\\b`).test(text));
|
|
49
|
+
const absent = FLAGS.filter((f) => !new RegExp(`\\b${f.key}\\s*:`).test(text));
|
|
50
|
+
calls.push({ name, index: m.index, spec, parsed, opt, missing, absent, fixable: parsed.args.length >= spec.options });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return calls.sort((a, b) => a.index - b.index);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function lineOf(source, index) {
|
|
57
|
+
return source.slice(0, index).split("\n").length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fixCall(source, call) {
|
|
61
|
+
const sameSite = call.spec.sameSite || "lax";
|
|
62
|
+
const adds = call.absent.map((f) => (f.key === "sameSite" ? `sameSite: "${sameSite}"` : `${f.key}: true`));
|
|
63
|
+
if (adds.length === 0) return source;
|
|
64
|
+
if (!call.opt) {
|
|
65
|
+
const last = call.parsed.args[call.parsed.args.length - 1];
|
|
66
|
+
return source.slice(0, last.end) + `, { ${adds.join(", ")} }` + source.slice(last.end);
|
|
67
|
+
}
|
|
68
|
+
const start = source.indexOf("{", call.opt.start);
|
|
69
|
+
const end = source.lastIndexOf("}", call.opt.end);
|
|
70
|
+
const inner = source.slice(start + 1, end).trim().replace(/,\s*$/, "");
|
|
71
|
+
const obj = inner ? `{ ${inner}, ${adds.join(", ")} }` : `{ ${adds.join(", ")} }`;
|
|
72
|
+
return source.slice(0, start) + obj + source.slice(end + 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function filesSettingCookies(repoPath) {
|
|
76
|
+
return walkCodeFiles(repoPath)
|
|
77
|
+
.map((file) => ({ file, source: readText(repoPath, file) }))
|
|
78
|
+
.filter((f) => f.source && /cookie|cookies/i.test(f.source));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default {
|
|
82
|
+
id: "cookie-flags",
|
|
83
|
+
title: "Set Secure, HttpOnly and SameSite on cookies",
|
|
84
|
+
matches(finding) {
|
|
85
|
+
return finding.category === "cookie";
|
|
86
|
+
},
|
|
87
|
+
detect(repoPath) {
|
|
88
|
+
return detectFramework(repoPath) || "node";
|
|
89
|
+
},
|
|
90
|
+
plan(repoPath, finding) {
|
|
91
|
+
const name = (finding.title || "").match(/Cookie "([^"]+)"/)?.[1];
|
|
92
|
+
if (!name) return null;
|
|
93
|
+
const changes = [];
|
|
94
|
+
for (const { file, source } of filesSettingCookies(repoPath)) {
|
|
95
|
+
const targets = cookieCalls(source).filter((c) => c.name === name && c.fixable && c.absent.length);
|
|
96
|
+
if (targets.length === 0) continue;
|
|
97
|
+
let after = source;
|
|
98
|
+
// Edit from the end so earlier offsets stay valid.
|
|
99
|
+
for (const call of [...targets].reverse()) after = fixCall(after, call);
|
|
100
|
+
changes.push({ file, before: source, after });
|
|
101
|
+
}
|
|
102
|
+
const change = toDiff(changes);
|
|
103
|
+
if (!change) return null;
|
|
104
|
+
return {
|
|
105
|
+
...change,
|
|
106
|
+
ownerActions: [
|
|
107
|
+
`If "${name}" must be readable from browser JavaScript or sent cross-site, revisit HttpOnly or SameSite for it; the defaults here are the safe choice for session cookies.`,
|
|
108
|
+
"Secure cookies are not sent over plain http, so local development needs https or a NODE_ENV check.",
|
|
109
|
+
],
|
|
110
|
+
confidence: "high",
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
probe(repoPath) {
|
|
114
|
+
const findings = [];
|
|
115
|
+
for (const { file, source } of filesSettingCookies(repoPath)) {
|
|
116
|
+
for (const call of cookieCalls(source)) {
|
|
117
|
+
if (call.missing.length === 0) continue;
|
|
118
|
+
findings.push({
|
|
119
|
+
severity: /sess|token|auth|jwt|sid/i.test(call.name) ? "medium" : "low",
|
|
120
|
+
title: `Cookie "${call.name}" ${call.missing.map((f) => `missing ${f.label}`).join(", ")}`,
|
|
121
|
+
description: `Set in ${file}:${lineOf(source, call.index)}`,
|
|
122
|
+
url: file,
|
|
123
|
+
category: "cookie",
|
|
124
|
+
source: "fix-probe:cookies",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return findings;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe: replace a wildcard or reflect-any CORS policy with an explicit
|
|
3
|
+
* allowlist read from CORS_ALLOWED_ORIGINS. Guidance: remediation rule "cors",
|
|
4
|
+
* references/remediation.md#cors.
|
|
5
|
+
*
|
|
6
|
+
* Handles cors() with no options (Express `cors`, Hono `hono/cors`, Fastify
|
|
7
|
+
* `@fastify/cors` registered bare), `origin: "*"` / `origin: true` inside those
|
|
8
|
+
* calls, and the `origin || "*"` fallback the route scanner flags.
|
|
9
|
+
*/
|
|
10
|
+
import { detectFramework, readText, walkCodeFiles, insertAfterImports, envExampleChange, toDiff, isTypeScript } from "../util.js";
|
|
11
|
+
|
|
12
|
+
const ENV = "CORS_ALLOWED_ORIGINS";
|
|
13
|
+
|
|
14
|
+
function corsModule(source) {
|
|
15
|
+
return /['"](?:cors|hono\/cors|@fastify\/cors)['"]/.test(source);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** [{ index, length, replacement, line }] for each wildcard CORS site in `source`. */
|
|
19
|
+
function wildcardSites(source) {
|
|
20
|
+
const sites = [];
|
|
21
|
+
if (corsModule(source)) {
|
|
22
|
+
const name = source.match(/import\s+(?:\{\s*)?(\w+)(?:\s*\})?\s+from\s+['"](?:cors|hono\/cors|@fastify\/cors)['"]/)?.[1]
|
|
23
|
+
|| source.match(/(?:const|let|var)\s+(?:\{\s*)?(\w+)(?:\s*\})?\s*=\s*require\(\s*['"](?:cors|@fastify\/cors)['"]\s*\)/)?.[1]
|
|
24
|
+
|| "cors";
|
|
25
|
+
// cors() with no options defaults to Access-Control-Allow-Origin: *.
|
|
26
|
+
for (const m of source.matchAll(new RegExp(`\\b${name}\\(\\s*\\)`, "g"))) {
|
|
27
|
+
sites.push({ index: m.index, length: m[0].length, replacement: `${name}({ origin: allowedOrigins })` });
|
|
28
|
+
}
|
|
29
|
+
// Fastify: app.register(cors) with no options.
|
|
30
|
+
for (const m of source.matchAll(new RegExp(`\\.register\\(\\s*${name}\\s*\\)`, "g"))) {
|
|
31
|
+
sites.push({ index: m.index, length: m[0].length, replacement: `.register(${name}, { origin: allowedOrigins })` });
|
|
32
|
+
}
|
|
33
|
+
// origin: "*" or origin: true (reflects any origin) on a line that configures CORS.
|
|
34
|
+
for (const m of source.matchAll(/\borigin\s*:\s*(?:(['"])\*\1|true\b)/g)) {
|
|
35
|
+
const lineStart = source.lastIndexOf("\n", m.index) + 1;
|
|
36
|
+
const lineEnd = source.indexOf("\n", m.index);
|
|
37
|
+
const line = source.slice(lineStart, lineEnd < 0 ? undefined : lineEnd);
|
|
38
|
+
if (!new RegExp(`\\b${name}\\b`).test(line)) continue;
|
|
39
|
+
sites.push({ index: m.index, length: m[0].length, replacement: "origin: allowedOrigins" });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// The `origin || "*"` fallback (lib/attack-surface.js RISK:cors-wildcard).
|
|
43
|
+
for (const m of source.matchAll(/([\w$.]+(?:\.get\(\s*['"]origin['"]\s*\))?)\s*\|\|\s*(['"])\*\2/g)) {
|
|
44
|
+
const expr = m[1];
|
|
45
|
+
sites.push({ index: m.index, length: m[0].length, replacement: `(allowedOrigins.find((o) => o === ${expr}) ?? "")` });
|
|
46
|
+
}
|
|
47
|
+
return sites.sort((a, b) => a.index - b.index);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function helperLines(file) {
|
|
51
|
+
const ts = isTypeScript(file) ? ": string[]" : "";
|
|
52
|
+
return [
|
|
53
|
+
`// Allowed CORS origins, comma-separated in ${ENV} (added by penthera fix).`,
|
|
54
|
+
`const allowedOrigins${ts} = (process.env.${ENV} || "")`,
|
|
55
|
+
' .split(",")',
|
|
56
|
+
" .map((o) => o.trim())",
|
|
57
|
+
" .filter(Boolean);",
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function candidateFiles(repoPath, finding) {
|
|
62
|
+
const files = walkCodeFiles(repoPath);
|
|
63
|
+
const named = finding.url && files.includes(finding.url) ? [finding.url] : files;
|
|
64
|
+
return named.map((file) => ({ file, source: readText(repoPath, file) })).filter((f) => f.source);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default {
|
|
68
|
+
id: "cors-allowlist",
|
|
69
|
+
title: "Restrict CORS to an allowlist from the environment",
|
|
70
|
+
matches(finding) {
|
|
71
|
+
return finding.category === "cors" || finding.category === "cors-misconfiguration" || /cors-wildcard/i.test(finding.title || "");
|
|
72
|
+
},
|
|
73
|
+
detect(repoPath) {
|
|
74
|
+
return detectFramework(repoPath) || "node";
|
|
75
|
+
},
|
|
76
|
+
plan(repoPath, finding) {
|
|
77
|
+
const changes = [];
|
|
78
|
+
for (const { file, source } of candidateFiles(repoPath, finding)) {
|
|
79
|
+
const sites = wildcardSites(source);
|
|
80
|
+
if (sites.length === 0) continue;
|
|
81
|
+
let after = source;
|
|
82
|
+
for (const s of [...sites].reverse()) after = after.slice(0, s.index) + s.replacement + after.slice(s.index + s.length);
|
|
83
|
+
if (!/\ballowedOrigins\s*=/.test(source)) after = insertAfterImports(after, helperLines(file));
|
|
84
|
+
changes.push({ file, before: source, after });
|
|
85
|
+
}
|
|
86
|
+
if (changes.length === 0) return null;
|
|
87
|
+
changes.push(envExampleChange(repoPath, ENV, "https://app.example.com", "Comma-separated origins allowed to call this API cross-origin"));
|
|
88
|
+
const change = toDiff(changes);
|
|
89
|
+
if (!change) return null;
|
|
90
|
+
return {
|
|
91
|
+
...change,
|
|
92
|
+
ownerActions: [
|
|
93
|
+
`Set ${ENV} in every environment to the exact origins that call this API (for example https://app.example.com). With it unset, all cross-origin requests are refused.`,
|
|
94
|
+
],
|
|
95
|
+
confidence: "high",
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
probe(repoPath) {
|
|
99
|
+
const findings = [];
|
|
100
|
+
for (const file of walkCodeFiles(repoPath)) {
|
|
101
|
+
const source = readText(repoPath, file);
|
|
102
|
+
if (!source) continue;
|
|
103
|
+
for (const s of wildcardSites(source)) {
|
|
104
|
+
findings.push({
|
|
105
|
+
severity: "medium",
|
|
106
|
+
title: "CORS allows any origin (cors-wildcard)",
|
|
107
|
+
description: `Set in ${file}:${source.slice(0, s.index).split("\n").length}`,
|
|
108
|
+
url: file,
|
|
109
|
+
category: "cors",
|
|
110
|
+
source: "fix-probe:cors",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return findings;
|
|
115
|
+
},
|
|
116
|
+
};
|