penthera 1.4.0 → 1.6.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 +227 -0
- package/README.md +148 -575
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +11 -2
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +101 -0
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +314 -30
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +29 -3
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +54 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +345 -0
- package/lib/hook/policy.js +62 -0
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +27 -7
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +31 -8
- package/package.json +8 -3
- package/skills/penthera/SKILL.md +55 -8
- package/src/cli/deps.js +81 -6
- package/src/cli/evidence.js +14 -4
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +81 -11
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's data source for a real machine: report JSON files in the
|
|
3
|
+
* chosen reports dir, evidence bundles below it, and the local learning store.
|
|
4
|
+
*
|
|
5
|
+
* File access rule: only paths inside the chosen roots (project dirs, reports
|
|
6
|
+
* dir, Penthera home) are ever read. Symlinks are skipped, every path is
|
|
7
|
+
* realpath'd and re-checked, and no API takes a file path from the browser:
|
|
8
|
+
* findings and bundles are addressed by ids this module hands out.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync, lstatSync, realpathSync, existsSync, statSync, mkdirSync, openSync, writeSync, closeSync, constants as FS } from "node:fs";
|
|
11
|
+
import { join, resolve, relative, sep, isAbsolute, dirname } from "node:path";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { openMemory, buildViews, buildHistory } from "../../../lib/memory/index.js";
|
|
14
|
+
import { referencesFor } from "../../../lib/references.js";
|
|
15
|
+
import { planFixes, redactPlan } from "../../../lib/fix/index.js";
|
|
16
|
+
import { verifyEvidence, MANIFEST, SIGNATURE, SCHEMA as EVIDENCE_SCHEMA } from "../../report/evidence.js";
|
|
17
|
+
import { writeEvidence } from "../../cli/report-outputs.js";
|
|
18
|
+
import { formatAudienceReport, AUDIENCES } from "../../report/audience.js";
|
|
19
|
+
import {
|
|
20
|
+
classify, buildOverview, buildFindings, locateFinding, findingDetail, buildDeps, buildAi, memoryStats, loopSummary,
|
|
21
|
+
explainFinding, buildScanHistory, latestScan, slug,
|
|
22
|
+
} from "./summarize.js";
|
|
23
|
+
|
|
24
|
+
const MAX_REPORT_BYTES = 25 * 1024 * 1024;
|
|
25
|
+
const MAX_FILES = 400;
|
|
26
|
+
const CACHE_MS = 1500;
|
|
27
|
+
|
|
28
|
+
/** True when `p` is `root` or inside it (both absolute, already resolved). */
|
|
29
|
+
export function isInside(root, p) {
|
|
30
|
+
const rel = relative(root, p);
|
|
31
|
+
return rel === "" || (!!rel && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve real paths for the allowed roots (missing roots are kept as given). */
|
|
35
|
+
export function realRoots(paths) {
|
|
36
|
+
return [...new Set(paths.filter(Boolean).map((p) => {
|
|
37
|
+
const abs = resolve(p);
|
|
38
|
+
try { return realpathSync(abs); } catch { return abs; }
|
|
39
|
+
}))];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a user-supplied directory against the allowed roots. Returns the
|
|
44
|
+
* real path, or throws with a message that names the fix.
|
|
45
|
+
*/
|
|
46
|
+
export function confineDir(input, roots) {
|
|
47
|
+
if (typeof input !== "string" || !input.trim() || input.length > 4096 || input.includes("\0")) {
|
|
48
|
+
throw new Error("Enter a project folder path.");
|
|
49
|
+
}
|
|
50
|
+
let real;
|
|
51
|
+
try {
|
|
52
|
+
real = realpathSync(resolve(roots[0] || ".", input.trim()));
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error(`Folder not found: ${input}`);
|
|
55
|
+
}
|
|
56
|
+
if (!statSync(real).isDirectory()) throw new Error(`Not a folder: ${input}`);
|
|
57
|
+
if (!roots.some((r) => isInside(r, real))) {
|
|
58
|
+
throw new Error("That folder is outside the project folders this dashboard was started with. Restart with `penthera ui --project <dir>`.");
|
|
59
|
+
}
|
|
60
|
+
return real;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Throws unless no existing component of `target` below `root` is a symlink.
|
|
65
|
+
* The same rule lib/fix/apply.js applies to fix diffs: a planted symlink
|
|
66
|
+
* (reports/evidence -> ~/.ssh) must never redirect a write out of the folder.
|
|
67
|
+
*/
|
|
68
|
+
export function assertNoSymlinks(root, target) {
|
|
69
|
+
const rel = relative(root, target);
|
|
70
|
+
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`Refusing to write ${target}: it is outside ${root}`);
|
|
71
|
+
let cur = root;
|
|
72
|
+
for (const seg of rel.split(sep).filter(Boolean)) {
|
|
73
|
+
cur = join(cur, seg);
|
|
74
|
+
let st;
|
|
75
|
+
try { st = lstatSync(cur); } catch (e) { if (e.code === "ENOENT") return; throw e; }
|
|
76
|
+
if (st.isSymbolicLink()) throw new Error(`Refusing to write through a symlink: ${relative(root, cur)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write a new file in `dir` (which must be `root` or below it): exclusive
|
|
82
|
+
* create, never through a symlink, never over an existing file. A name is a
|
|
83
|
+
* single path segment.
|
|
84
|
+
*/
|
|
85
|
+
export function writeNewFile(root, dir, name, content) {
|
|
86
|
+
if (typeof name !== "string" || !name || name.includes("/") || name.includes("\\") || name.includes("\0") || name === "." || name === "..") {
|
|
87
|
+
throw new Error(`Bad file name ${JSON.stringify(name)}`);
|
|
88
|
+
}
|
|
89
|
+
const target = join(dir, name);
|
|
90
|
+
assertNoSymlinks(root, target);
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
assertNoSymlinks(root, target); // again, after mkdir
|
|
93
|
+
const fd = openSync(target, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | (FS.O_NOFOLLOW || 0), 0o644);
|
|
94
|
+
try {
|
|
95
|
+
const buf = Buffer.from(content, "utf-8");
|
|
96
|
+
let off = 0;
|
|
97
|
+
while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
|
|
98
|
+
} finally {
|
|
99
|
+
closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
return target;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const idFor = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
105
|
+
|
|
106
|
+
export function createDataSource({ projects, reportsDir, home, memoryRoot, settings, version = "dev" }) {
|
|
107
|
+
const projectRoots = realRoots(projects);
|
|
108
|
+
const reportsReal = realRoots([reportsDir])[0];
|
|
109
|
+
const roots = realRoots([...projects, reportsDir, home]);
|
|
110
|
+
let cache = null;
|
|
111
|
+
|
|
112
|
+
function safeRead(path) {
|
|
113
|
+
let real;
|
|
114
|
+
try { real = realpathSync(path); } catch { return null; }
|
|
115
|
+
if (!roots.some((r) => isInside(r, real))) return null;
|
|
116
|
+
const st = lstatSync(real);
|
|
117
|
+
if (!st.isFile() || st.size > MAX_REPORT_BYTES) return null;
|
|
118
|
+
return readFileSync(real);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Report files (top level of the reports dir) and evidence bundles (up to two levels down). */
|
|
122
|
+
function scan() {
|
|
123
|
+
if (cache && Date.now() - cache.at < CACHE_MS) return cache;
|
|
124
|
+
const out = { scans: [], deps: [], agents: [], installs: [], loops: [], bundles: [], at: Date.now() };
|
|
125
|
+
if (!existsSync(reportsReal)) { cache = out; return out; }
|
|
126
|
+
let seen = 0;
|
|
127
|
+
const walk = (dir, depth) => {
|
|
128
|
+
let entries;
|
|
129
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
130
|
+
if (entries.some((e) => e.isFile() && e.name === MANIFEST) && depth > 0) {
|
|
131
|
+
out.bundles.push(dir);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const e of entries) {
|
|
135
|
+
if (seen >= MAX_FILES) return;
|
|
136
|
+
if (e.isSymbolicLink()) continue;
|
|
137
|
+
const p = join(dir, e.name);
|
|
138
|
+
if (e.isDirectory() && depth < 2 && !e.name.startsWith(".") && e.name !== "node_modules") walk(p, depth + 1);
|
|
139
|
+
else if (e.isFile() && depth === 0 && e.name.endsWith(".json")) {
|
|
140
|
+
seen++;
|
|
141
|
+
const buf = safeRead(p);
|
|
142
|
+
if (!buf) continue;
|
|
143
|
+
let data;
|
|
144
|
+
try { data = JSON.parse(buf.toString("utf-8")); } catch { continue; }
|
|
145
|
+
const kind = classify(data);
|
|
146
|
+
if (!kind) continue;
|
|
147
|
+
const rec = { name: e.name, mtime: statSync(p).mtimeMs, data };
|
|
148
|
+
({ scan: out.scans, deps: out.deps, agents: out.agents, install: out.installs, loop: out.loops })[kind].push(rec);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
walk(reportsReal, 0);
|
|
153
|
+
const byTime = (a, b) => (Date.parse(a.data.timestamp || a.data.loopRun?.startedAt) || a.mtime) - (Date.parse(b.data.timestamp || b.data.loopRun?.startedAt) || b.mtime);
|
|
154
|
+
for (const k of ["scans", "deps", "agents", "installs", "loops"]) out[k].sort(byTime);
|
|
155
|
+
cache = out;
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Every memory project under the memory root, verified; a broken chain shows as broken, never as data. */
|
|
160
|
+
function memory() {
|
|
161
|
+
if (!existsSync(memoryRoot)) return [];
|
|
162
|
+
const list = [];
|
|
163
|
+
for (const e of readdirSync(memoryRoot, { withFileTypes: true })) {
|
|
164
|
+
if (!e.isDirectory() || !/^[0-9a-f]{64}$/.test(e.name)) continue;
|
|
165
|
+
const mem = openMemory(e.name, { root: memoryRoot });
|
|
166
|
+
const chain = mem.verify();
|
|
167
|
+
const events = chain.ok ? mem.events() : [];
|
|
168
|
+
list.push(memoryStats({
|
|
169
|
+
id: e.name,
|
|
170
|
+
label: mem.meta()?.label,
|
|
171
|
+
chain,
|
|
172
|
+
views: chain.ok ? buildViews(events) : null,
|
|
173
|
+
history: chain.ok ? buildHistory(events) : null,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
return list;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
demo: false,
|
|
181
|
+
roots,
|
|
182
|
+
projectRoots,
|
|
183
|
+
|
|
184
|
+
meta() {
|
|
185
|
+
return { reportsDir: reportsReal, projects: projectRoots, memoryRoot };
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
overview() {
|
|
189
|
+
const s = scan();
|
|
190
|
+
return buildOverview({ scans: s.scans, memory: memory(), runs: [...s.loops].reverse() });
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
findings() {
|
|
194
|
+
return buildFindings(scan().scans);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async finding(id, project) {
|
|
198
|
+
const hit = locateFinding(scan().scans, id, project);
|
|
199
|
+
if (!hit) return null;
|
|
200
|
+
const detail = findingDetail(hit.finding);
|
|
201
|
+
const sources = referencesFor(hit.finding).map((r) => ({ cite: r.cite, url: r.url }));
|
|
202
|
+
let fix = null;
|
|
203
|
+
// A fix preview needs the repo the finding came from, and only inside a chosen project dir.
|
|
204
|
+
const target = typeof hit.report.data.target === "string" ? hit.report.data.target : null;
|
|
205
|
+
const repo = hit.report.data.repo || (target && !/^https?:/i.test(target) ? target : null);
|
|
206
|
+
if (repo) {
|
|
207
|
+
try {
|
|
208
|
+
const dir = confineDir(repo, projectRoots);
|
|
209
|
+
const { plans } = await planFixes(dir, [hit.finding]);
|
|
210
|
+
if (plans[0]) {
|
|
211
|
+
const p = redactPlan(plans[0]);
|
|
212
|
+
fix = { recipe: p.recipe, title: p.title, confidence: p.confidence, files: p.files, diff: p.diff.slice(0, 20000) };
|
|
213
|
+
}
|
|
214
|
+
} catch { /* no preview outside the project dirs */ }
|
|
215
|
+
}
|
|
216
|
+
return { ...detail, id, project, sources, fix, file: hit.report.name, explain: explainFinding(hit.finding, hit.report.data) };
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
history(project, a, b) {
|
|
220
|
+
return buildScanHistory(scan().scans, project, a, b);
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/** "Export evidence for an auditor": a bundle from the latest report of a project, into <reports>/evidence/. */
|
|
224
|
+
createEvidence(project) {
|
|
225
|
+
const latest = latestScan(scan().scans, project);
|
|
226
|
+
if (!latest) return null;
|
|
227
|
+
const dir = join(reportsReal, "evidence", `${slug(project)}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}`);
|
|
228
|
+
if (dirname(dirname(dir)) !== reportsReal) return null;
|
|
229
|
+
assertNoSymlinks(reportsReal, dir); // reports/evidence must not be a planted symlink
|
|
230
|
+
writeEvidence(latest.data, { evidence: dir }, { version, mdOpts: { version }, argv: ["ui", "evidence", "create"] });
|
|
231
|
+
cache = null;
|
|
232
|
+
return { id: idFor(dir), path: relative(reportsReal, dir) };
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
/** The developer, owner or auditor report for the latest scan of a project, as Markdown. */
|
|
236
|
+
audienceReport(project, audience) {
|
|
237
|
+
if (!AUDIENCES.includes(audience)) return null;
|
|
238
|
+
const latest = latestScan(scan().scans, project);
|
|
239
|
+
return latest ? formatAudienceReport(latest.data, audience, { version }) : null;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
deps() {
|
|
243
|
+
const s = scan();
|
|
244
|
+
return buildDeps({ deps: s.deps, installs: s.installs });
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
ai() {
|
|
248
|
+
return buildAi(scan().agents);
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
memory,
|
|
252
|
+
|
|
253
|
+
runs() {
|
|
254
|
+
return [...scan().loops].reverse().slice(0, 20).map((r) => loopSummary(r.data));
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
evidence() {
|
|
258
|
+
return scan().bundles.map((dir) => {
|
|
259
|
+
let manifest = null;
|
|
260
|
+
try { manifest = JSON.parse(safeRead(join(dir, MANIFEST))?.toString("utf-8") || "null"); } catch { /* shown as unreadable */ }
|
|
261
|
+
const ok = manifest?.schema === EVIDENCE_SCHEMA;
|
|
262
|
+
return {
|
|
263
|
+
id: idFor(dir),
|
|
264
|
+
path: relative(reportsReal, dir) || ".",
|
|
265
|
+
target: ok ? String(manifest.target ?? "").slice(0, 500) : null,
|
|
266
|
+
generatedAt: ok ? manifest.generated_at || null : null,
|
|
267
|
+
tool: ok ? `${manifest.tool?.name || "penthera"} ${manifest.tool?.version || ""}`.trim() : null,
|
|
268
|
+
artifacts: ok && Array.isArray(manifest.artifacts) ? manifest.artifacts.length : 0,
|
|
269
|
+
signed: existsSync(join(dir, SIGNATURE)),
|
|
270
|
+
readable: ok,
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
verifyEvidence(id) {
|
|
276
|
+
const dir = scan().bundles.find((d) => idFor(d) === id);
|
|
277
|
+
if (!dir) return null;
|
|
278
|
+
const r = verifyEvidence(dir);
|
|
279
|
+
return {
|
|
280
|
+
id,
|
|
281
|
+
ok: r.ok,
|
|
282
|
+
problems: r.problems.slice(0, 50),
|
|
283
|
+
files: r.files.slice(0, 200).map((f) => ({ path: f.path, ok: f.ok, reason: f.reason || null })),
|
|
284
|
+
signature: r.signature ? { ok: r.signature.ok, status: r.signature.status, detail: r.signature.detail } : null,
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
/** Persist a finished loop run next to the other reports. */
|
|
289
|
+
saveLoopRun(loopRun) {
|
|
290
|
+
try {
|
|
291
|
+
const name = `loop-${String(loopRun.startedAt || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
|
|
292
|
+
writeNewFile(reportsReal, reportsReal, name, JSON.stringify({ loopRun }, null, 2) + "\n");
|
|
293
|
+
cache = null;
|
|
294
|
+
return name;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/** A scan, deps or agents report from a run started here: a new file, never through a symlink. */
|
|
301
|
+
writeReport(name, result) {
|
|
302
|
+
writeNewFile(reportsReal, reportsReal, name, JSON.stringify(result, null, 2) + "\n");
|
|
303
|
+
cache = null;
|
|
304
|
+
return name;
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
settings,
|
|
308
|
+
};
|
|
309
|
+
}
|