@forwardimpact/libwiki 0.2.25 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -201
- package/README.md +5 -3
- package/bin/fit-wiki.js +1 -1
- package/package.json +1 -1
- package/src/audit/admission.js +53 -0
- package/src/audit/conflict-markers-rule.js +24 -0
- package/src/audit/grammar.js +97 -0
- package/src/audit/rules.js +96 -5
- package/src/audit/scopes.js +108 -3
- package/src/block-renderer.js +19 -5
- package/src/boot.js +39 -1
- package/src/cli-definition.js +17 -16
- package/src/commands/audit.js +6 -1
- package/src/commands/boot.js +7 -2
- package/src/commands/claim.js +138 -32
- package/src/commands/fix.js +13 -5
- package/src/commands/inbox.js +7 -8
- package/src/commands/init.js +6 -0
- package/src/commands/log.js +47 -25
- package/src/commands/memo.js +10 -9
- package/src/commands/refresh.js +1 -0
- package/src/commands/rotate.js +40 -19
- package/src/commands/sync.js +54 -6
- package/src/conflict-markers.js +78 -0
- package/src/constants.js +31 -1
- package/src/gitattributes.js +40 -0
- package/src/index.js +2 -1
- package/src/integrity.js +288 -0
- package/src/lane-files.js +62 -0
- package/src/marker-scanner.js +2 -0
- package/src/secret-gate.js +177 -0
- package/src/util/agent-flag.js +31 -0
- package/src/weekly-log.js +146 -37
- package/src/wiki-sync.js +528 -11
package/src/integrity.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { isoTimestamp } from "@forwardimpact/libutil";
|
|
2
|
+
import { SESSION_GAP_MS } from "./constants.js";
|
|
3
|
+
import { isLaneFile, enumerateLaneFiles } from "./lane-files.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a content line for content-keyed presence: strip a trailing CR and
|
|
7
|
+
* trailing whitespace. Blank lines normalize to "" and are dropped by callers.
|
|
8
|
+
* @param {string} line
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function normLine(line) {
|
|
12
|
+
return line.replace(/\r$/, "").replace(/\s+$/, "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse `git diff --unified=0` text into per-file change records, attributing
|
|
17
|
+
* each `+`/`-` line to the file named by the most recent `+++ b/<path>` header.
|
|
18
|
+
* The `+++`/`---`/`@@` framing lines are not content. `/dev/null` targets
|
|
19
|
+
* (pure deletions) are kept as the home for their removed lines.
|
|
20
|
+
* @param {string} diffText
|
|
21
|
+
* @returns {Array<{home: string, added: string[], removed: string[]}>}
|
|
22
|
+
*/
|
|
23
|
+
export function parseDiff(diffText) {
|
|
24
|
+
const byHome = new Map();
|
|
25
|
+
let home = null;
|
|
26
|
+
for (const raw of diffText.split("\n")) {
|
|
27
|
+
if (raw.startsWith("+++ ")) {
|
|
28
|
+
home = stripDiffTarget(raw.slice(4));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (home === null || isDiffFraming(raw)) continue;
|
|
32
|
+
const rec = byHome.get(home) ?? { home, added: [], removed: [] };
|
|
33
|
+
if (raw.startsWith("+")) rec.added.push(raw.slice(1));
|
|
34
|
+
else if (raw.startsWith("-")) rec.removed.push(raw.slice(1));
|
|
35
|
+
else continue;
|
|
36
|
+
byHome.set(home, rec);
|
|
37
|
+
}
|
|
38
|
+
return [...byHome.values()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether a diff line is framing (`---`, `@@`, `diff`, `index`) rather than content. */
|
|
42
|
+
function isDiffFraming(raw) {
|
|
43
|
+
return (
|
|
44
|
+
raw.startsWith("--- ") ||
|
|
45
|
+
raw.startsWith("@@") ||
|
|
46
|
+
raw.startsWith("diff ") ||
|
|
47
|
+
raw.startsWith("index ")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Strip a diff target's `a/`/`b/` prefix; `/dev/null` stays as-is. */
|
|
52
|
+
function stripDiffTarget(target) {
|
|
53
|
+
const t = target.trim();
|
|
54
|
+
if (t === "/dev/null") return t;
|
|
55
|
+
return t.replace(/^[ab]\//, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compose a window of change records (oldest→newest) into the surviving
|
|
60
|
+
* addition assertions, then return those absent from the tip. Content-keyed on
|
|
61
|
+
* `norm(line)`: a later own-lane deletion of an earlier-added line cancels its
|
|
62
|
+
* assertion (additions-only, own-deletions cancel). A surviving assertion is
|
|
63
|
+
* present iff its normalized line appears anywhere in `tipText`.
|
|
64
|
+
*
|
|
65
|
+
* @param {Array<{home: string, added: string[], removed: string[]}>} changes
|
|
66
|
+
* Window changes, oldest→newest.
|
|
67
|
+
* @param {string} tipText - Concatenated text of the tip's in-scope files.
|
|
68
|
+
* @param {(line: string) => string} norm - Line normalizer.
|
|
69
|
+
* @returns {Array<{contentId: string, pushHome: string}>} Absent assertions.
|
|
70
|
+
*/
|
|
71
|
+
export function findAbsent(changes, tipText, norm) {
|
|
72
|
+
const asserted = composeAssertions(changes, norm);
|
|
73
|
+
const present = normalizedKeySet(tipText, norm);
|
|
74
|
+
const absent = [];
|
|
75
|
+
for (const [key, a] of asserted) {
|
|
76
|
+
if (!present.has(key)) absent.push(a);
|
|
77
|
+
}
|
|
78
|
+
return absent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compose window changes into surviving additions; a later removal cancels its key. */
|
|
82
|
+
function composeAssertions(changes, norm) {
|
|
83
|
+
const asserted = new Map(); // norm(line) -> { contentId, pushHome }
|
|
84
|
+
for (const change of changes) {
|
|
85
|
+
for (const line of change.added) {
|
|
86
|
+
const key = norm(line);
|
|
87
|
+
if (key !== "" && !asserted.has(key)) {
|
|
88
|
+
asserted.set(key, { contentId: key, pushHome: change.home });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const line of change.removed) {
|
|
92
|
+
const key = norm(line);
|
|
93
|
+
if (key !== "") asserted.delete(key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return asserted;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The set of non-blank normalized lines in `text`. */
|
|
100
|
+
function normalizedKeySet(text, norm) {
|
|
101
|
+
const set = new Set();
|
|
102
|
+
for (const line of text.split("\n")) {
|
|
103
|
+
const key = norm(line);
|
|
104
|
+
if (key !== "") set.add(key);
|
|
105
|
+
}
|
|
106
|
+
return set;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the previous-session push set from lane-authored commits (newest
|
|
111
|
+
* first) by idle-gap. Tier 2 runs at boot before the current session has
|
|
112
|
+
* pushed, so the most recent contiguous run is the previous session. Vacuous
|
|
113
|
+
* only for empty history; the degenerate (content-unresolvable) case is raised
|
|
114
|
+
* by {@link sweepTier2}, not here.
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<{sha: string, when: number}>} commits - Newest first.
|
|
117
|
+
* @param {number} gapMs - Idle-gap threshold (ms).
|
|
118
|
+
* @returns {{kind: "vacuous"} | {kind: "window", commits: Array<{sha: string, when: number}>}}
|
|
119
|
+
*/
|
|
120
|
+
export function previousSessionWindow(commits, gapMs) {
|
|
121
|
+
if (commits.length === 0) return { kind: "vacuous" };
|
|
122
|
+
const tipRun = [commits[0]];
|
|
123
|
+
for (let i = 1; i < commits.length; i++) {
|
|
124
|
+
// commits are newest-first; `when` is seconds, gap threshold is ms.
|
|
125
|
+
const gap = (commits[i - 1].when - commits[i].when) * 1000;
|
|
126
|
+
if (gap > gapMs) break;
|
|
127
|
+
tipRun.push(commits[i]);
|
|
128
|
+
}
|
|
129
|
+
return { kind: "window", commits: tipRun };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build a detection record. `detectedAt` is the binding wall-clock stamp (ISO);
|
|
134
|
+
* an exposure figure derived from commit timestamps carries the labeled
|
|
135
|
+
* `commit-timestamp` fallback basis.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} d
|
|
138
|
+
* @param {1|2} d.tier
|
|
139
|
+
* @param {string} d.contentId - The absent content's identity (normalized line).
|
|
140
|
+
* @param {string} d.pushHome - The content's push-time home path.
|
|
141
|
+
* @param {number|Date} d.now - Wall-clock (e.g. `runtime.clock.now()` ms).
|
|
142
|
+
* @param {number} [d.exposureSeconds] - Commit-timestamp-derived exposure.
|
|
143
|
+
* @returns {object}
|
|
144
|
+
*/
|
|
145
|
+
export function makeDetection({
|
|
146
|
+
tier,
|
|
147
|
+
contentId,
|
|
148
|
+
pushHome,
|
|
149
|
+
now,
|
|
150
|
+
exposureSeconds,
|
|
151
|
+
}) {
|
|
152
|
+
const detection = {
|
|
153
|
+
tier,
|
|
154
|
+
contentId,
|
|
155
|
+
pushHome,
|
|
156
|
+
detectedAt: isoTimestamp(now),
|
|
157
|
+
};
|
|
158
|
+
if (exposureSeconds != null) {
|
|
159
|
+
detection.exposure = {
|
|
160
|
+
seconds: exposureSeconds,
|
|
161
|
+
basis: "commit-timestamp",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return detection;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render detections to flow output text. Empty input renders the empty string
|
|
169
|
+
* (clean-path silence). One line per detection naming tier, push-time home, and
|
|
170
|
+
* content identity; exposure (when present) is labeled with its fallback basis.
|
|
171
|
+
* @param {object[]} detections
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
export function renderDetections(detections) {
|
|
175
|
+
if (detections.length === 0) return "";
|
|
176
|
+
return (
|
|
177
|
+
detections
|
|
178
|
+
.map((d) => {
|
|
179
|
+
const exposure = d.exposure
|
|
180
|
+
? ` exposure=${d.exposure.seconds}s (basis: ${d.exposure.basis})`
|
|
181
|
+
: "";
|
|
182
|
+
return `integrity[tier ${d.tier}]: absent content in ${d.pushHome} — "${d.contentId}" detected ${d.detectedAt}${exposure}`;
|
|
183
|
+
})
|
|
184
|
+
.join("\n") + "\n"
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Tier-2 boot sweep: verify the lane's previous-session push set is still
|
|
190
|
+
* content-present at the fetched (rebased) origin tip, returning detections for
|
|
191
|
+
* any absence. Reads git and lane files from `wikiDir` (the rebased tree).
|
|
192
|
+
* Never writes, never throws into the flow (the caller wraps it).
|
|
193
|
+
*
|
|
194
|
+
* @param {object} ctx
|
|
195
|
+
* @param {import('@forwardimpact/libutil/runtime').Runtime} ctx.runtime
|
|
196
|
+
* @param {import('@forwardimpact/libutil').GitClient} ctx.gitClient
|
|
197
|
+
* @param {string} ctx.wikiDir - The wiki clone dir (git cwd and fs read-root).
|
|
198
|
+
* @param {string} ctx.agent - Lane agent id.
|
|
199
|
+
* @param {number} ctx.now - Wall-clock (ms).
|
|
200
|
+
* @returns {Promise<object[]>} Detections (possibly empty).
|
|
201
|
+
*/
|
|
202
|
+
export async function sweepTier2({ runtime, gitClient, wikiDir, agent, now }) {
|
|
203
|
+
const email = await gitClient.configGet("user.email", { cwd: wikiDir });
|
|
204
|
+
if (!email) {
|
|
205
|
+
// Lane identity unresolvable — never a silent vacuous pass.
|
|
206
|
+
return [
|
|
207
|
+
makeDetection({
|
|
208
|
+
tier: 2,
|
|
209
|
+
contentId: "<unresolvable: no author identity>",
|
|
210
|
+
pushHome: "-",
|
|
211
|
+
now,
|
|
212
|
+
}),
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
const commits = await gitClient.logByAuthor(email, {
|
|
216
|
+
cwd: wikiDir,
|
|
217
|
+
ref: "HEAD",
|
|
218
|
+
});
|
|
219
|
+
const window = previousSessionWindow(commits, SESSION_GAP_MS);
|
|
220
|
+
if (window.kind === "vacuous") return [];
|
|
221
|
+
|
|
222
|
+
const detections = [];
|
|
223
|
+
const changes = [];
|
|
224
|
+
// Oldest→newest so own-deletion cancellation composes in commit order.
|
|
225
|
+
const ordered = [...window.commits].reverse();
|
|
226
|
+
for (const commit of ordered) {
|
|
227
|
+
const diff = await gitClient.diffRange(`${commit.sha}~1 ${commit.sha}`, {
|
|
228
|
+
cwd: wikiDir,
|
|
229
|
+
});
|
|
230
|
+
if (diff === null) {
|
|
231
|
+
detections.push(
|
|
232
|
+
makeDetection({
|
|
233
|
+
tier: 2,
|
|
234
|
+
contentId: `<unresolvable content: ${commit.sha}>`,
|
|
235
|
+
pushHome: "-",
|
|
236
|
+
now,
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
for (const rec of parseDiff(diff)) {
|
|
242
|
+
if (isLaneFile(rec.home, agent))
|
|
243
|
+
changes.push({ ...rec, when: commit.when });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const tipText = enumerateLaneFiles(wikiDir, agent, runtime.fsSync)
|
|
248
|
+
.map((rel) => readFileOrEmpty(runtime.fsSync, wikiDir, rel))
|
|
249
|
+
.join("\n");
|
|
250
|
+
|
|
251
|
+
for (const absent of findAbsent(changes, tipText, normLine)) {
|
|
252
|
+
// Exposure runs from the LAST window commit that added this exact line
|
|
253
|
+
// (its most recent assertion at origin), not merely a same-home commit.
|
|
254
|
+
const when = lastAssertionTime(changes, absent.contentId);
|
|
255
|
+
const exposureSeconds =
|
|
256
|
+
when != null ? Math.round(now / 1000 - when) : undefined;
|
|
257
|
+
detections.push(
|
|
258
|
+
makeDetection({
|
|
259
|
+
tier: 2,
|
|
260
|
+
contentId: absent.contentId,
|
|
261
|
+
pushHome: absent.pushHome,
|
|
262
|
+
now,
|
|
263
|
+
exposureSeconds,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return detections;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The `when` of the latest window change whose normalized additions include `contentId`. */
|
|
271
|
+
function lastAssertionTime(changes, contentId) {
|
|
272
|
+
let when;
|
|
273
|
+
for (const change of changes) {
|
|
274
|
+
if (change.added.some((line) => normLine(line) === contentId)) {
|
|
275
|
+
when = change.when;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return when;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readFileOrEmpty(fsSync, wikiDir, rel) {
|
|
282
|
+
const abs = `${wikiDir}/${rel}`;
|
|
283
|
+
try {
|
|
284
|
+
return fsSync.readFileSync(abs, "utf-8");
|
|
285
|
+
} catch {
|
|
286
|
+
return "";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { WEEKLY_LOG_NAME_RE, WEEKLY_LOG_PART_NAME_RE } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
const METRICS_CSV_RE = /^metrics\/[^/]+\/\d{4}\.csv$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether a wiki-root-relative path is one of the lane's own files: the
|
|
8
|
+
* agent's summary (`<agent>.md`), a weekly log or sealed part
|
|
9
|
+
* (`<agent>-YYYY-Www.md`, `<agent>-YYYY-Www-partN.md`, matched on the captured
|
|
10
|
+
* agent token), or a metrics CSV (`metrics/<skill>/<year>.csv`). Metrics CSVs
|
|
11
|
+
* match by path for every agent; lane ownership of a metrics CSV is enforced by
|
|
12
|
+
* the tier-2 sweep's author filter at the commit level, not here.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} relPath - Path relative to the wiki root (POSIX or native).
|
|
15
|
+
* @param {string} agent - Agent profile id (e.g. "staff-engineer").
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
export function isLaneFile(relPath, agent) {
|
|
19
|
+
const rel = relPath.replace(/\\/g, "/");
|
|
20
|
+
const base = path.posix.basename(rel);
|
|
21
|
+
if (base === `${agent}.md`) return true;
|
|
22
|
+
for (const re of [WEEKLY_LOG_NAME_RE, WEEKLY_LOG_PART_NAME_RE]) {
|
|
23
|
+
const m = base.match(re);
|
|
24
|
+
if (m && m[1] === agent) return true;
|
|
25
|
+
}
|
|
26
|
+
return METRICS_CSV_RE.test(rel);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Enumerate the lane's own files present under `wikiRoot`: matching top-level
|
|
31
|
+
* summary and weekly-log files, plus every `metrics/<skill>/<year>.csv`.
|
|
32
|
+
* Returns wiki-root-relative POSIX paths.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} wikiRoot
|
|
35
|
+
* @param {string} agent
|
|
36
|
+
* @param {object} fsSync - Sync filesystem surface (`runtime.fsSync`).
|
|
37
|
+
* @returns {string[]} Relative paths, in directory-read order.
|
|
38
|
+
*/
|
|
39
|
+
export function enumerateLaneFiles(wikiRoot, agent, fsSync) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const entry of fsSync.readdirSync(wikiRoot)) {
|
|
42
|
+
if (entry !== "metrics" && isLaneFile(entry, agent)) out.push(entry);
|
|
43
|
+
}
|
|
44
|
+
out.push(...enumerateMetricsCsvs(wikiRoot, agent, fsSync));
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Wiki-root-relative `metrics/<skill>/<year>.csv` paths matching the lane. */
|
|
49
|
+
function enumerateMetricsCsvs(wikiRoot, agent, fsSync) {
|
|
50
|
+
const metricsDir = path.join(wikiRoot, "metrics");
|
|
51
|
+
if (!fsSync.existsSync(metricsDir)) return [];
|
|
52
|
+
const found = [];
|
|
53
|
+
for (const skill of fsSync.readdirSync(metricsDir)) {
|
|
54
|
+
const skillDir = path.join(metricsDir, skill);
|
|
55
|
+
if (!fsSync.statSync(skillDir).isDirectory()) continue;
|
|
56
|
+
for (const file of fsSync.readdirSync(skillDir)) {
|
|
57
|
+
const rel = `metrics/${skill}/${file}`;
|
|
58
|
+
if (isLaneFile(rel, agent)) found.push(rel);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}
|
package/src/marker-scanner.js
CHANGED
|
@@ -20,6 +20,7 @@ function tryOpen(line, i) {
|
|
|
20
20
|
kind: "xmr",
|
|
21
21
|
metric: xmrMatch[1],
|
|
22
22
|
csvPath: xmrMatch[2],
|
|
23
|
+
priorReadAnchor: xmrMatch[3] || null,
|
|
23
24
|
openLine: i,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
@@ -42,6 +43,7 @@ function closePair(open, i) {
|
|
|
42
43
|
kind: "xmr",
|
|
43
44
|
metric: open.metric,
|
|
44
45
|
csvPath: open.csvPath,
|
|
46
|
+
priorReadAnchor: open.priorReadAnchor,
|
|
45
47
|
openLine: open.openLine,
|
|
46
48
|
closeLine: i,
|
|
47
49
|
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed secret gate for the wiki push path. Runs gitleaks over the
|
|
3
|
+
* commit range a push introduces and reports a clean / finding /
|
|
4
|
+
* scanner-absent verdict. The wiki has no destination-side secret control (a
|
|
5
|
+
* GitHub Wiki repo runs no Actions and is excluded from GitHub
|
|
6
|
+
* secret-scanning), so this is the only place a content backstop can live.
|
|
7
|
+
*
|
|
8
|
+
* The module never throws on a scanner result: a missing or erroring scanner
|
|
9
|
+
* resolves to `scanner-absent` so the caller fails closed rather than treating
|
|
10
|
+
* an error as clean. Findings carry only a location (`file:line:rule`) — never
|
|
11
|
+
* the matched secret value, so an audit record built from them cannot itself
|
|
12
|
+
* leak.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isoTimestamp } from "@forwardimpact/libutil";
|
|
16
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
17
|
+
|
|
18
|
+
/** The gitleaks binary name resolved on PATH; provisioning is an operator concern (see wiki-operations guide). */
|
|
19
|
+
const GITLEAKS = "gitleaks";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Scan the commit range a push introduces for secrets, fail closed.
|
|
23
|
+
*
|
|
24
|
+
* Probes `gitleaks version` first; an unresolvable binary short-circuits to
|
|
25
|
+
* `scanner-absent`. Then runs `gitleaks detect` over `range` expressed as
|
|
26
|
+
* `git log` options, reading the JSON report from stdout. Exit codes follow
|
|
27
|
+
* gitleaks' documented contract: `0` clean, `1` leaks found, any other
|
|
28
|
+
* non-zero an invocation error (treated as `scanner-absent` — fail closed, an
|
|
29
|
+
* error is never reported as clean).
|
|
30
|
+
*
|
|
31
|
+
* @param {object} args
|
|
32
|
+
* @param {import('@forwardimpact/libutil/runtime').Runtime} args.runtime - Provides `subprocess.run`.
|
|
33
|
+
* @param {string} args.wikiDir - The wiki clone directory to scan.
|
|
34
|
+
* @param {string} args.range - A `git log` range (e.g. `origin/master..HEAD`).
|
|
35
|
+
* @returns {Promise<{status: "clean"|"finding"|"scanner-absent", findings?: Array<{file: string, line: number, rule: string}>}>}
|
|
36
|
+
*/
|
|
37
|
+
export async function scanPushWindow({ runtime, wikiDir, range }) {
|
|
38
|
+
const probe = await runtime.subprocess.run(GITLEAKS, ["version"], {
|
|
39
|
+
cwd: wikiDir,
|
|
40
|
+
});
|
|
41
|
+
if (probe.exitCode !== 0) return { status: "scanner-absent" };
|
|
42
|
+
|
|
43
|
+
const scan = await runtime.subprocess.run(
|
|
44
|
+
GITLEAKS,
|
|
45
|
+
[
|
|
46
|
+
"detect",
|
|
47
|
+
"--source",
|
|
48
|
+
wikiDir,
|
|
49
|
+
"--log-opts",
|
|
50
|
+
range,
|
|
51
|
+
"--report-format",
|
|
52
|
+
"json",
|
|
53
|
+
"--report-path",
|
|
54
|
+
"-",
|
|
55
|
+
],
|
|
56
|
+
{ cwd: wikiDir },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (scan.exitCode === 0) return { status: "clean" };
|
|
60
|
+
if (scan.exitCode === 1) {
|
|
61
|
+
return { status: "finding", findings: parseFindings(scan.stdout) };
|
|
62
|
+
}
|
|
63
|
+
// Any other non-zero is an invocation/usage error, not a leak verdict:
|
|
64
|
+
// fail closed rather than risk reporting a broken scan as clean.
|
|
65
|
+
return { status: "scanner-absent" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a gitleaks JSON report into location-only findings. Reads only the
|
|
70
|
+
* file, line, and rule of each entry — never the matched secret value — so a
|
|
71
|
+
* record built from the result is secret-free by construction. A malformed or
|
|
72
|
+
* empty report yields an empty list.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} stdout - The gitleaks JSON report.
|
|
75
|
+
* @returns {Array<{file: string, line: number, rule: string}>}
|
|
76
|
+
*/
|
|
77
|
+
function parseFindings(stdout) {
|
|
78
|
+
let report;
|
|
79
|
+
try {
|
|
80
|
+
report = JSON.parse(stdout);
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(report)) return [];
|
|
85
|
+
return report.map((entry) => ({
|
|
86
|
+
file: entry.File ?? "",
|
|
87
|
+
line: entry.StartLine ?? 0,
|
|
88
|
+
rule: entry.RuleID ?? "",
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Append one secret-free line to the wiki tree's `secret-overrides.log` and
|
|
94
|
+
* stage it (path-scoped) so it lands in the same push as the overridden
|
|
95
|
+
* content. The line records the override as a durable, inspectable audit
|
|
96
|
+
* trail: an ISO timestamp, the asserted operator identity (`git config
|
|
97
|
+
* user.email` — attribution of intent, NOT an authenticated identity), the
|
|
98
|
+
* override class, the reason, and for a finding its location. It never carries
|
|
99
|
+
* a matched secret value.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} args
|
|
102
|
+
* @param {import('@forwardimpact/libutil/runtime').Runtime} args.runtime - Provides `fs` and `clock`.
|
|
103
|
+
* @param {import('@forwardimpact/libutil').GitClient} args.gitClient - Stages the log into the push.
|
|
104
|
+
* @param {string} args.wikiDir - The wiki clone directory.
|
|
105
|
+
* @param {"finding"|"scanner-absent"} args.klass - The override class.
|
|
106
|
+
* @param {string} args.reason - The operator-supplied reason for the override.
|
|
107
|
+
* @param {Array<{file: string, line: number, rule: string}>} [args.findings] - Locations for a finding override.
|
|
108
|
+
* @returns {Promise<{path: string}>} The relative path staged into the push.
|
|
109
|
+
*/
|
|
110
|
+
export async function appendOverrideRecord({
|
|
111
|
+
runtime,
|
|
112
|
+
gitClient,
|
|
113
|
+
wikiDir,
|
|
114
|
+
klass,
|
|
115
|
+
reason,
|
|
116
|
+
findings = [],
|
|
117
|
+
}) {
|
|
118
|
+
const email =
|
|
119
|
+
(await gitClient.configGet("user.email", { cwd: wikiDir })) || "unknown";
|
|
120
|
+
const where =
|
|
121
|
+
klass === "finding"
|
|
122
|
+
? findings.map((f) => `${f.file}:${f.line}:${f.rule}`).join(",") ||
|
|
123
|
+
"unspecified"
|
|
124
|
+
: "scanner-absent";
|
|
125
|
+
const ts = isoTimestamp(runtime.clock.now());
|
|
126
|
+
// Tab-separated, single line; the reason is collapsed so the record stays
|
|
127
|
+
// one inspectable row per override.
|
|
128
|
+
const line = `${ts}\t${email}\t${klass}\t${reason.replace(/\s+/g, " ").trim()}\t${where}\n`;
|
|
129
|
+
const logPath = `${wikiDir}/${OVERRIDE_LOG}`;
|
|
130
|
+
await runtime.fs.appendFile(logPath, line);
|
|
131
|
+
await gitClient.commitPaths(
|
|
132
|
+
`wiki: secret-gate override (${klass})`,
|
|
133
|
+
[OVERRIDE_LOG],
|
|
134
|
+
{ cwd: wikiDir },
|
|
135
|
+
);
|
|
136
|
+
return { path: OVERRIDE_LOG };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The append-only audit log of break-glass overrides, in the wiki tree root. */
|
|
140
|
+
export const OVERRIDE_LOG = "secret-overrides.log";
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Translate a `commitAndPush` security refusal into a command envelope,
|
|
144
|
+
* logging the cause and its break-glass procedure at error level (always
|
|
145
|
+
* surfaced, regardless of LOG_LEVEL). Returns `null` for any non-refusal
|
|
146
|
+
* result (clean / pushed / network "saved locally"), so a caller can fall
|
|
147
|
+
* through to its normal success handling. Shared by every command surface so
|
|
148
|
+
* the refusal message and exit code live in one place.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} runtime - The runtime bag (the logger writes to `proc.stderr`).
|
|
151
|
+
* @param {{reason?: string, findings?: Array<{file: string, line: number, rule: string}>}} result - A `commitAndPush` result.
|
|
152
|
+
* @returns {{ok: false, code: 1}|null}
|
|
153
|
+
*/
|
|
154
|
+
export function refusalEnvelope(runtime, result) {
|
|
155
|
+
if (result.reason === "secret-detected") {
|
|
156
|
+
const where = (result.findings ?? [])
|
|
157
|
+
.map((f) => `${f.file}:${f.line}:${f.rule}`)
|
|
158
|
+
.join(", ");
|
|
159
|
+
createLogger("wiki", runtime).error(
|
|
160
|
+
"push",
|
|
161
|
+
`push blocked: secret detected in wiki content${where ? ` (${where})` : ""}; ` +
|
|
162
|
+
"the push was not attempted. After confirming a false positive, set " +
|
|
163
|
+
"FIT_WIKI_SECRET_OVERRIDE to a reason to override (audited).",
|
|
164
|
+
);
|
|
165
|
+
return { ok: false, code: 1 };
|
|
166
|
+
}
|
|
167
|
+
if (result.reason === "scanner-unavailable") {
|
|
168
|
+
createLogger("wiki", runtime).error(
|
|
169
|
+
"push",
|
|
170
|
+
"push blocked: the secret scanner (gitleaks) is unavailable; the push " +
|
|
171
|
+
"was not attempted. Install gitleaks, or set FIT_WIKI_SCANNER_ABSENT_OK " +
|
|
172
|
+
"to a reason to override (audited).",
|
|
173
|
+
);
|
|
174
|
+
return { ok: false, code: 1 };
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the required agent flag from frozen CLI options. Pure — reads no
|
|
3
|
+
* filesystem and no environment, so it runs before any state change. Returns
|
|
4
|
+
* `{ ok: true, agent }` when the flag is present, or
|
|
5
|
+
* `{ ok: false, code: 2, error }` when it is missing, where `error` names the
|
|
6
|
+
* missing flag and shows a corrected example invocation. The error never
|
|
7
|
+
* mentions an environment variable: `libwiki` carries no ambient agent
|
|
8
|
+
* identity, so there is no fallback to offer.
|
|
9
|
+
*
|
|
10
|
+
* @param {Record<string, unknown>} options - The frozen `ctx.options`.
|
|
11
|
+
* @param {{ command: string, flag?: string, example: string }} spec
|
|
12
|
+
* `command` names the failing subcommand; `flag` is the option key prefix
|
|
13
|
+
* (`--agent` by default, `--from` for `memo`); `example` is a correct
|
|
14
|
+
* invocation shown verbatim in the error.
|
|
15
|
+
* @returns {{ ok: true, agent: string } | { ok: false, code: 2, error: string }}
|
|
16
|
+
*/
|
|
17
|
+
export function requireAgentFlag(
|
|
18
|
+
options,
|
|
19
|
+
{ command, flag = "--agent", example },
|
|
20
|
+
) {
|
|
21
|
+
const key = flag === "--from" ? "from" : "agent";
|
|
22
|
+
const agent = options[key];
|
|
23
|
+
if (!agent) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
code: 2,
|
|
27
|
+
error: `${command} requires ${flag} <name>; e.g. ${example}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, agent };
|
|
31
|
+
}
|