penthera 1.2.0 → 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 +212 -0
- package/README.md +379 -39
- package/docs/METHODOLOGY.md +321 -0
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +1 -1
- 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 +12 -1
- 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/package.json +16 -4
- package/skills/penthera/SKILL.md +103 -22
- 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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only views over memory events, plus the two pure helpers scans use:
|
|
3
|
+
* rankFindings() re-orders, isSuppressed() hides. Neither can add a finding,
|
|
4
|
+
* change a severity, or run anything: memory content is only ever compared and
|
|
5
|
+
* counted. Every field read from an event is type-checked, so a malformed or
|
|
6
|
+
* hostile event degrades to "ignored", never to behaviour.
|
|
7
|
+
*/
|
|
8
|
+
import { fingerprintFinding } from "../audit-loop.js";
|
|
9
|
+
import { sha256 } from "./store.js";
|
|
10
|
+
|
|
11
|
+
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
12
|
+
const MAX_STR = 500;
|
|
13
|
+
|
|
14
|
+
/** Short, stable id for a finding: sha256 of the audit-loop fingerprint. */
|
|
15
|
+
export function findingId(f) {
|
|
16
|
+
return sha256(fingerprintFinding(f)).slice(0, 16);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const str = (x) => (typeof x === "string" && x.length > 0 && x.length <= MAX_STR ? x : null);
|
|
20
|
+
const int = (x) => (Number.isInteger(x) && x >= 0 ? x : null);
|
|
21
|
+
const time = (x) => { const t = Date.parse(x); return Number.isNaN(t) ? null : t; };
|
|
22
|
+
const key = (x) => (str(x) ? x.toLowerCase() : "unknown");
|
|
23
|
+
|
|
24
|
+
function median(nums) {
|
|
25
|
+
if (!nums.length) return null;
|
|
26
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
27
|
+
const mid = Math.floor(s.length / 2);
|
|
28
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the views from a verified event list.
|
|
33
|
+
* @param {object[]} events parsed events from openMemory().events()
|
|
34
|
+
* @param {{ now?: string }} opts `now` decides which suppressions have expired
|
|
35
|
+
*/
|
|
36
|
+
export function buildViews(events, { now = new Date().toISOString() } = {}) {
|
|
37
|
+
const nowMs = time(now) ?? Date.now();
|
|
38
|
+
const recipes = new Map(); // `${framework}\0${recipe}` -> stats
|
|
39
|
+
const lastApplied = new Map(); // finding id -> { recipe, framework, category, ts }
|
|
40
|
+
const firstSeen = new Map(); // finding id -> ms
|
|
41
|
+
const seenCount = new Map(); // finding id -> scans it appeared in
|
|
42
|
+
const fixDurations = [];
|
|
43
|
+
const suppressions = new Map(); // finding id -> latest suppression
|
|
44
|
+
const probes = new Map(); // `${stack}\0${probe}` -> stats
|
|
45
|
+
let scans = 0;
|
|
46
|
+
|
|
47
|
+
const recipeStats = (framework, recipe) => {
|
|
48
|
+
const k = `${framework}\0${recipe}`;
|
|
49
|
+
if (!recipes.has(k)) recipes.set(k, { framework, recipe, applied: 0, verified: 0, failed: 0, categories: new Set() });
|
|
50
|
+
return recipes.get(k);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (const ev of events || []) {
|
|
54
|
+
const d = ev?.data;
|
|
55
|
+
if (!d || typeof d !== "object") continue;
|
|
56
|
+
const ts = time(ev.ts);
|
|
57
|
+
switch (ev.type) {
|
|
58
|
+
case "scan": {
|
|
59
|
+
scans++;
|
|
60
|
+
const ids = Array.isArray(d.ids) ? d.ids.filter(str) : [];
|
|
61
|
+
for (const id of ids) {
|
|
62
|
+
if (!firstSeen.has(id) && ts != null) firstSeen.set(id, ts);
|
|
63
|
+
seenCount.set(id, (seenCount.get(id) || 0) + 1);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "fix_applied": {
|
|
68
|
+
const id = str(d.id) || str(d.fingerprint);
|
|
69
|
+
const recipe = str(d.recipe);
|
|
70
|
+
if (!recipe) break;
|
|
71
|
+
const framework = key(d.framework);
|
|
72
|
+
const s = recipeStats(framework, recipe);
|
|
73
|
+
s.applied++;
|
|
74
|
+
if (str(d.category)) s.categories.add(d.category);
|
|
75
|
+
if (id) lastApplied.set(id, { recipe, framework, category: str(d.category), ts });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "fix_verified":
|
|
79
|
+
case "fix_failed": {
|
|
80
|
+
const id = str(d.id) || str(d.fingerprint);
|
|
81
|
+
const prior = id ? lastApplied.get(id) : null;
|
|
82
|
+
const recipe = str(d.recipe) || prior?.recipe;
|
|
83
|
+
if (!recipe) break;
|
|
84
|
+
const framework = str(d.framework) ? key(d.framework) : (prior?.framework || "unknown");
|
|
85
|
+
const s = recipeStats(framework, recipe);
|
|
86
|
+
if (ev.type === "fix_verified") {
|
|
87
|
+
s.verified++;
|
|
88
|
+
if (id && ts != null && firstSeen.has(id)) fixDurations.push(Math.max(0, ts - firstSeen.get(id)));
|
|
89
|
+
} else {
|
|
90
|
+
s.failed++;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "fp_suppressed": {
|
|
95
|
+
const id = str(d.id) || (str(d.fingerprint) ? sha256(d.fingerprint).slice(0, 16) : null);
|
|
96
|
+
const reason = str(d.reason);
|
|
97
|
+
if (!id || !reason) break; // a suppression without a reason is ignored
|
|
98
|
+
const expires = str(d.expires);
|
|
99
|
+
if (expires && (time(expires) == null || time(expires) <= nowMs)) { suppressions.delete(id); break; }
|
|
100
|
+
suppressions.set(id, {
|
|
101
|
+
id, reason, actor: str(d.actor) || "unknown", since: ev.ts, expires: expires || null, source: "memory",
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case "probe_yield": {
|
|
106
|
+
const probe = str(d.probe);
|
|
107
|
+
const findings = int(d.findings);
|
|
108
|
+
if (!probe || findings == null) break;
|
|
109
|
+
const stack = key(d.stack);
|
|
110
|
+
const k = `${stack}\0${probe}`;
|
|
111
|
+
if (!probes.has(k)) probes.set(k, { stack, probe, runs: 0, findings: 0 });
|
|
112
|
+
const p = probes.get(k);
|
|
113
|
+
p.runs++;
|
|
114
|
+
p.findings += findings;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
default:
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const recipeSuccess = [...recipes.values()].map((s) => {
|
|
123
|
+
const decided = s.verified + s.failed;
|
|
124
|
+
return {
|
|
125
|
+
framework: s.framework, recipe: s.recipe, applied: s.applied, verified: s.verified, failed: s.failed,
|
|
126
|
+
rate: decided ? s.verified / decided : null, categories: [...s.categories].sort(),
|
|
127
|
+
};
|
|
128
|
+
}).sort((a, b) => a.framework.localeCompare(b.framework) || (b.rate ?? -1) - (a.rate ?? -1) || a.recipe.localeCompare(b.recipe));
|
|
129
|
+
|
|
130
|
+
const probeRanking = {};
|
|
131
|
+
for (const p of probes.values()) {
|
|
132
|
+
(probeRanking[p.stack] ||= []).push({ probe: p.probe, runs: p.runs, findings: p.findings, yield: p.findings / p.runs });
|
|
133
|
+
}
|
|
134
|
+
for (const list of Object.values(probeRanking)) {
|
|
135
|
+
list.sort((a, b) => b.yield - a.yield || b.findings - a.findings || a.probe.localeCompare(b.probe));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const med = median(fixDurations);
|
|
139
|
+
return {
|
|
140
|
+
scans,
|
|
141
|
+
recipeSuccess,
|
|
142
|
+
suppressions: [...suppressions.values()],
|
|
143
|
+
probeRanking,
|
|
144
|
+
medianTimeToFixMs: med,
|
|
145
|
+
medianTimeToFixDays: med == null ? null : Math.round((med / 86_400_000) * 10) / 10,
|
|
146
|
+
seen: Object.fromEntries(seenCount),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Add repo-file suppressions to a views object. They keep their own source so
|
|
152
|
+
* reports can say "suppressed by repo file". A local-memory suppression for the
|
|
153
|
+
* same id wins (the user decided it, not the repo).
|
|
154
|
+
*/
|
|
155
|
+
export function withRepoSuppressions(views, repoEntries) {
|
|
156
|
+
const have = new Set((views?.suppressions || []).map((s) => s.id));
|
|
157
|
+
const extra = (repoEntries || []).filter((s) => !have.has(s.id)).map((s) => ({ ...s, source: "repo-file" }));
|
|
158
|
+
return { ...(views || {}), suppressions: [...(views?.suppressions || []), ...extra] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Is this finding suppressed? Returns the suppression record or null.
|
|
163
|
+
* Matches on the finding's id (or its full fingerprint, for repo files).
|
|
164
|
+
*/
|
|
165
|
+
export function isSuppressed(finding, views) {
|
|
166
|
+
const list = views?.suppressions;
|
|
167
|
+
if (!list?.length) return null;
|
|
168
|
+
const fp = fingerprintFinding(finding);
|
|
169
|
+
const id = sha256(fp).slice(0, 16);
|
|
170
|
+
return list.find((s) => s.id === id || (s.fingerprint && s.fingerprint === fp)) || null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Re-order findings using what memory learned. Severity always comes first and
|
|
175
|
+
* is never changed. Within a severity:
|
|
176
|
+
* 1. findings whose category has a recipe with a proven fix rate (quick wins),
|
|
177
|
+
* 2. findings seen in more past scans (persistent problems),
|
|
178
|
+
* 3. then the original order (the sort is stable).
|
|
179
|
+
* Returns a new array with exactly the same findings.
|
|
180
|
+
*/
|
|
181
|
+
export function rankFindings(findings, views, { framework = null } = {}) {
|
|
182
|
+
const fw = framework ? String(framework).toLowerCase() : null;
|
|
183
|
+
const bestRate = new Map(); // category -> best verified rate
|
|
184
|
+
for (const r of views?.recipeSuccess || []) {
|
|
185
|
+
if (r.rate == null || (fw && r.framework !== fw && r.framework !== "unknown")) continue;
|
|
186
|
+
for (const c of r.categories || []) bestRate.set(c, Math.max(bestRate.get(c) ?? 0, r.rate));
|
|
187
|
+
}
|
|
188
|
+
const seen = views?.seen || {};
|
|
189
|
+
return (findings || [])
|
|
190
|
+
.map((f, i) => ({
|
|
191
|
+
f, i,
|
|
192
|
+
sev: SEV_ORDER[f.severity] ?? 5,
|
|
193
|
+
fix: bestRate.get(f.category) ?? 0,
|
|
194
|
+
seen: seen[findingId(f)] || 0,
|
|
195
|
+
}))
|
|
196
|
+
.sort((a, b) => a.sev - b.sev || b.fix - a.fix || b.seen - a.seen || a.i - b.i)
|
|
197
|
+
.map((x) => x.f);
|
|
198
|
+
}
|
package/lib/params.js
CHANGED
|
@@ -14,20 +14,12 @@
|
|
|
14
14
|
* Inspired by: Arjun (6.2k stars), ParamSpider
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { fetchText } from "../src/utils/http.js";
|
|
18
|
+
|
|
17
19
|
const PARAM_TIMEOUT = 8_000;
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const timeout = setTimeout(() => controller.abort(), opts.timeout || PARAM_TIMEOUT);
|
|
22
|
-
try {
|
|
23
|
-
const res = await fetch(url, { ...opts, signal: controller.signal, redirect: "manual" });
|
|
24
|
-
const body = await res.text().catch(() => "");
|
|
25
|
-
return { status: res.status, body, size: body.length, ok: true };
|
|
26
|
-
} catch {
|
|
27
|
-
return { status: 0, body: "", size: 0, ok: false };
|
|
28
|
-
} finally {
|
|
29
|
-
clearTimeout(timeout);
|
|
30
|
-
}
|
|
21
|
+
function safeFetch(url, opts = {}) {
|
|
22
|
+
return fetchText(url, { timeout: PARAM_TIMEOUT, probe: "param-discovery", ...opts });
|
|
31
23
|
}
|
|
32
24
|
|
|
33
25
|
function simpleHash(str) {
|
package/lib/provenance.js
CHANGED
|
@@ -26,6 +26,7 @@ const SOURCE_CONFIDENCE = {
|
|
|
26
26
|
"openapi-scan": "confirmed",
|
|
27
27
|
"email-dns-probe": "confirmed",
|
|
28
28
|
"framework-check": "likely",
|
|
29
|
+
"deps-scan": "likely",
|
|
29
30
|
"static-analysis": "likely",
|
|
30
31
|
"trust-boundary": "likely",
|
|
31
32
|
"auth-probe": "likely",
|
|
@@ -59,6 +60,7 @@ const CATEGORY_CONFIDENCE = {
|
|
|
59
60
|
ssrf: "likely",
|
|
60
61
|
"open-redirect": "likely",
|
|
61
62
|
fuzzing: "potential",
|
|
63
|
+
"dependency-risk": "likely",
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
/** Honest-confidence label for a finding. An explicit finding.confidence wins. */
|
package/lib/recon.js
CHANGED
|
@@ -12,19 +12,16 @@
|
|
|
12
12
|
* Inspired by: subfinder, gau, waybackurls, ParamSpider
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { safeFetch } from "../src/utils/http.js";
|
|
16
|
+
import { registrableDomain } from "../src/utils/url.js";
|
|
17
|
+
|
|
15
18
|
const RECON_TIMEOUT = 15_000;
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} catch {
|
|
23
|
-
return null;
|
|
24
|
-
} finally {
|
|
25
|
-
clearTimeout(timeout);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
20
|
+
/**
|
|
21
|
+
* Third-party hosts this module queries. Under --offline the scan skips recon
|
|
22
|
+
* entirely, and the HTTP client would refuse these hosts anyway.
|
|
23
|
+
*/
|
|
24
|
+
export const RECON_HOSTS = ["crt.sh", "web.archive.org", "otx.alienvault.com"];
|
|
28
25
|
|
|
29
26
|
// ── Subdomain Discovery via Certificate Transparency ─────────────────────
|
|
30
27
|
|
|
@@ -38,7 +35,7 @@ async function safeFetch(url, opts = {}) {
|
|
|
38
35
|
export async function discoverSubdomains(domain) {
|
|
39
36
|
const res = await safeFetch(
|
|
40
37
|
`https://crt.sh/?q=%25.${encodeURIComponent(domain)}&output=json`,
|
|
41
|
-
{ timeout: 20_000 },
|
|
38
|
+
{ timeout: 20_000, probe: "recon", redirect: "follow" },
|
|
42
39
|
);
|
|
43
40
|
if (!res || !res.ok) return [];
|
|
44
41
|
|
|
@@ -77,7 +74,7 @@ export async function fetchWaybackUrls(domain, opts = {}) {
|
|
|
77
74
|
const limit = opts.limit || 1000;
|
|
78
75
|
const res = await safeFetch(
|
|
79
76
|
`https://web.archive.org/cdx/search/cdx?url=*.${encodeURIComponent(domain)}/*&output=json&fl=original&collapse=urlkey&limit=${limit}`,
|
|
80
|
-
{ timeout: 20_000 },
|
|
77
|
+
{ timeout: 20_000, probe: "recon", redirect: "follow" },
|
|
81
78
|
);
|
|
82
79
|
if (!res || !res.ok) return [];
|
|
83
80
|
|
|
@@ -103,6 +100,7 @@ export async function fetchOtxUrls(domain, opts = {}) {
|
|
|
103
100
|
const limit = opts.limit || 200;
|
|
104
101
|
const res = await safeFetch(
|
|
105
102
|
`https://otx.alienvault.com/api/v1/indicators/domain/${encodeURIComponent(domain)}/url_list?limit=${limit}`,
|
|
103
|
+
{ timeout: RECON_TIMEOUT, probe: "recon", redirect: "follow" },
|
|
106
104
|
);
|
|
107
105
|
if (!res || !res.ok) return [];
|
|
108
106
|
|
|
@@ -180,13 +178,7 @@ export async function runRecon(domain, opts = {}) {
|
|
|
180
178
|
*/
|
|
181
179
|
export function extractDomain(urlString) {
|
|
182
180
|
try {
|
|
183
|
-
|
|
184
|
-
const parts = hostname.split(".");
|
|
185
|
-
// Handle two-part TLDs like .co.uk, .com.au
|
|
186
|
-
if (parts.length >= 3 && parts[parts.length - 2].length <= 3) {
|
|
187
|
-
return parts.slice(-3).join(".");
|
|
188
|
-
}
|
|
189
|
-
return parts.slice(-2).join(".");
|
|
181
|
+
return registrableDomain(new URL(urlString).hostname);
|
|
190
182
|
} catch {
|
|
191
183
|
return urlString;
|
|
192
184
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research and standards references for findings.
|
|
3
|
+
*
|
|
4
|
+
* lib/references.json maps each finding category to 1-3 keys of
|
|
5
|
+
* docs/references.bib, and carries a short record for each key so reports can
|
|
6
|
+
* cite without parsing BibTeX. Every entry was checked against the publisher or
|
|
7
|
+
* standards body before it was added; see docs/references.md.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
const DATA = JSON.parse(readFileSync(new URL("./references.json", import.meta.url), "utf-8"));
|
|
12
|
+
|
|
13
|
+
export const SOURCES = DATA.sources;
|
|
14
|
+
export const CATEGORY_REFERENCES = DATA.categories;
|
|
15
|
+
|
|
16
|
+
/** Reference keys for a finding, by category. Unknown categories get []. */
|
|
17
|
+
export function referenceKeysFor(finding) {
|
|
18
|
+
return CATEGORY_REFERENCES[finding?.category] || [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Resolved source records ({ key, cite, title, url }) for a finding. */
|
|
22
|
+
export function referencesFor(finding) {
|
|
23
|
+
return referenceKeysFor(finding)
|
|
24
|
+
.map((key) => (SOURCES[key] ? { key, ...SOURCES[key] } : null))
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One-line Markdown list of sources, e.g. "[Weichselbaum et al. 2016](https://doi.org/...)". */
|
|
29
|
+
export function formatSourcesMarkdown(refs) {
|
|
30
|
+
return refs.map((r) => `[${r.cite}](${r.url})`).join(" · ");
|
|
31
|
+
}
|