@codyswann/lisa 4.70.3 → 4.70.4
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/all/copy-overwrite/scripts/lib/history-secret-evidence-shape.mjs +188 -0
- package/all/copy-overwrite/scripts/lib/history-secret-evidence.mjs +271 -0
- package/all/copy-overwrite/scripts/lib/history-secret-scanner.mjs +6 -296
- package/all/copy-overwrite/scripts/lisa-history-secrets.mjs +8 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +22 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +9 -3
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/two-channel-couplings.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
/** Bounded lossless evidence syntax; no digest is authenticated by syntax alone. */
|
|
4
|
+
const credentialField =
|
|
5
|
+
/^(?:api[_-]?key|access[_-]?token|password|passwd|secret)$/iu;
|
|
6
|
+
const credentialToken =
|
|
7
|
+
/^(?:client[_-]?secret|auth[_-]?token|token|credentials?)$/iu;
|
|
8
|
+
export const safeSourcePath = value =>
|
|
9
|
+
typeof value === "string" &&
|
|
10
|
+
value.length > 0 &&
|
|
11
|
+
!credentialField.test(value) &&
|
|
12
|
+
!credentialToken.test(value) &&
|
|
13
|
+
!/[\\\u0000-\u001f\u007f]/u.test(value) &&
|
|
14
|
+
!/^(?:\/|[A-Za-z]:)/u.test(value) &&
|
|
15
|
+
value.split("/").every(part => part && part !== "." && part !== "..");
|
|
16
|
+
const proofLabel = value =>
|
|
17
|
+
safeSourcePath(value) ||
|
|
18
|
+
(typeof value === "string" &&
|
|
19
|
+
value.startsWith("/") &&
|
|
20
|
+
safeSourcePath(value.slice(1)));
|
|
21
|
+
const sources = new Set([
|
|
22
|
+
"source_sha256",
|
|
23
|
+
"source_hashes",
|
|
24
|
+
"source_hashes_after",
|
|
25
|
+
"current_source_sha256",
|
|
26
|
+
"prior66_source_sha256",
|
|
27
|
+
"protected_sha256",
|
|
28
|
+
"selected_artifact_hashes",
|
|
29
|
+
"files",
|
|
30
|
+
]);
|
|
31
|
+
const proofs = new Set(["proof_sha256", "logs"]);
|
|
32
|
+
const isHash = value =>
|
|
33
|
+
typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
34
|
+
|
|
35
|
+
/** Native validation and duplicate-aware lexical spans share the exact same text. */
|
|
36
|
+
const lexical = text => {
|
|
37
|
+
const value = JSON.parse(text);
|
|
38
|
+
const tokens = [
|
|
39
|
+
...text.matchAll(/"(?:\\[\s\S]|[^"\\])*"|[{}[\],:]|[^{}[\],:\s]+/gu),
|
|
40
|
+
];
|
|
41
|
+
if (tokens.length > 8192) throw new Error("Bounded JSON tokens");
|
|
42
|
+
const state = { cursor: 0, spans: [] };
|
|
43
|
+
visit(tokens, state, [], 0, text);
|
|
44
|
+
if (state.cursor !== tokens.length) throw new Error("Trailing JSON tokens");
|
|
45
|
+
return { value, spans: state.spans };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Duplicate decoded keys and escaped attribution spellings always fail closed. */
|
|
49
|
+
const visit = (tokens, state, path, depth, text) => {
|
|
50
|
+
if (depth > 32) throw new Error("Bounded JSON depth");
|
|
51
|
+
const token = tokens[state.cursor++];
|
|
52
|
+
if (token[0] === "{") {
|
|
53
|
+
const keys = new Set();
|
|
54
|
+
while (tokens[state.cursor][0] !== "}") {
|
|
55
|
+
const keyToken = tokens[state.cursor++][0];
|
|
56
|
+
const key = JSON.parse(keyToken);
|
|
57
|
+
if (keys.has(key) || keyToken !== JSON.stringify(key))
|
|
58
|
+
throw new Error("Ambiguous JSON key");
|
|
59
|
+
keys.add(key);
|
|
60
|
+
state.cursor++;
|
|
61
|
+
visit(tokens, state, [...path, key], depth + 1, text);
|
|
62
|
+
if (tokens[state.cursor][0] === ",") state.cursor++;
|
|
63
|
+
}
|
|
64
|
+
state.cursor++;
|
|
65
|
+
} else if (token[0] === "[") {
|
|
66
|
+
let index = 0;
|
|
67
|
+
while (tokens[state.cursor][0] !== "]") {
|
|
68
|
+
visit(tokens, state, [...path, index++], depth + 1, text);
|
|
69
|
+
if (tokens[state.cursor][0] === ",") state.cursor++;
|
|
70
|
+
}
|
|
71
|
+
state.cursor++;
|
|
72
|
+
} else if (token[0][0] === '"') {
|
|
73
|
+
const hash = JSON.parse(token[0]);
|
|
74
|
+
if (!isHash(hash)) return;
|
|
75
|
+
if (token[0] !== JSON.stringify(hash))
|
|
76
|
+
throw new Error("Ambiguous digest spelling");
|
|
77
|
+
state.spans.push({
|
|
78
|
+
path,
|
|
79
|
+
hash,
|
|
80
|
+
start: Buffer.byteLength(text.slice(0, token.index + 1)),
|
|
81
|
+
end: Buffer.byteLength(text.slice(0, token.index + token[0].length - 1)),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Source maps keep whole-map verification; proof entries attest only themselves. */
|
|
87
|
+
const maps = (text, offset) => {
|
|
88
|
+
const parsed = lexical(text);
|
|
89
|
+
if (
|
|
90
|
+
!parsed.value ||
|
|
91
|
+
typeof parsed.value !== "object" ||
|
|
92
|
+
Array.isArray(parsed.value)
|
|
93
|
+
)
|
|
94
|
+
return [];
|
|
95
|
+
const bare = Object.entries(parsed.value).every(
|
|
96
|
+
([file, hash]) => safeSourcePath(file) && isHash(hash)
|
|
97
|
+
);
|
|
98
|
+
const roles = bare
|
|
99
|
+
? [[null, parsed.value]]
|
|
100
|
+
: Object.entries(parsed.value).filter(
|
|
101
|
+
([role]) => sources.has(role) || proofs.has(role)
|
|
102
|
+
);
|
|
103
|
+
const result = [];
|
|
104
|
+
for (const [role, fields] of roles) {
|
|
105
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields))
|
|
106
|
+
return [];
|
|
107
|
+
const kind = proofs.has(role)
|
|
108
|
+
? "proof"
|
|
109
|
+
: role === "source_sha256"
|
|
110
|
+
? "strict"
|
|
111
|
+
: "source";
|
|
112
|
+
const entries = Object.entries(fields);
|
|
113
|
+
const validPath = kind === "proof" ? proofLabel : safeSourcePath;
|
|
114
|
+
if (
|
|
115
|
+
!entries.length ||
|
|
116
|
+
entries.length > 256 ||
|
|
117
|
+
entries.some(([file, hash]) => !validPath(file) || !isHash(hash))
|
|
118
|
+
)
|
|
119
|
+
return [];
|
|
120
|
+
const spans = parsed.spans
|
|
121
|
+
.filter(span =>
|
|
122
|
+
role === null
|
|
123
|
+
? span.path.length === 1
|
|
124
|
+
: span.path.length === 2 && span.path[0] === role
|
|
125
|
+
)
|
|
126
|
+
.map(span => ({
|
|
127
|
+
file: span.path.at(-1),
|
|
128
|
+
hash: span.hash,
|
|
129
|
+
start: span.start + offset,
|
|
130
|
+
end: span.end + offset,
|
|
131
|
+
}));
|
|
132
|
+
if (spans.length !== entries.length) return [];
|
|
133
|
+
result.push({ kind, value: parsed.value, spans });
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Fenced JSON keeps original whole-file byte offsets for actual detector attribution. */
|
|
139
|
+
export const evidenceMaps = bytes => {
|
|
140
|
+
if (bytes.length > 1024 * 1024) return [];
|
|
141
|
+
const text = bytes.toString("utf8");
|
|
142
|
+
if (!Buffer.from(text).equals(bytes)) return [];
|
|
143
|
+
try {
|
|
144
|
+
return maps(text, 0);
|
|
145
|
+
} catch {
|
|
146
|
+
/* Ordinary Markdown can contain exact JSON fences. */
|
|
147
|
+
}
|
|
148
|
+
const result = [];
|
|
149
|
+
for (const block of text.matchAll(
|
|
150
|
+
/(?:^|\n)```json\n([\s\S]*?)\n```(?=\n|$)/gu
|
|
151
|
+
)) {
|
|
152
|
+
const offset = Buffer.byteLength(
|
|
153
|
+
text.slice(0, block.index + block[0].indexOf(block[1]))
|
|
154
|
+
);
|
|
155
|
+
try {
|
|
156
|
+
result.push(...maps(block[1], offset));
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Only the fixed taxonomy in ordinary prose, outside Markdown code, is a candidate. */
|
|
165
|
+
export const narrativeSpan = (bytes, lineStart, lineEnd) => {
|
|
166
|
+
const prior = bytes.subarray(0, lineStart).toString("utf8");
|
|
167
|
+
const fences = prior.split(/[\r\n\u2028\u2029]/u).filter(part => {
|
|
168
|
+
const trimmed = part.trimStart();
|
|
169
|
+
return trimmed.startsWith("```") || trimmed.startsWith("~~~");
|
|
170
|
+
});
|
|
171
|
+
if (fences.length % 2) return null;
|
|
172
|
+
const line = bytes.subarray(lineStart, lineEnd).toString("utf8");
|
|
173
|
+
const prose = line.replace(/`[^`\r\n]{1,256}`/gu, "identifier");
|
|
174
|
+
if (!/^(?:[-*] )?[A-Za-z][A-Za-z0-9 ,./():-]*\.$/u.test(prose)) return null;
|
|
175
|
+
const taxonomy = "Disk/missing/corrupt";
|
|
176
|
+
const phrase = `AccessDenied boundaries, ${taxonomy} falsifications`;
|
|
177
|
+
const index = line.indexOf(phrase);
|
|
178
|
+
if (index < 0 || line.indexOf(phrase, index + 1) >= 0) return null;
|
|
179
|
+
if (
|
|
180
|
+
!/[,.]/u.test(line[index + phrase.length] ?? "") ||
|
|
181
|
+
line.slice(0, index).split("`").length % 2 === 0
|
|
182
|
+
)
|
|
183
|
+
return null;
|
|
184
|
+
const start =
|
|
185
|
+
lineStart +
|
|
186
|
+
Buffer.byteLength(line.slice(0, index + phrase.indexOf(taxonomy)));
|
|
187
|
+
return { hash: taxonomy, start, end: start + taxonomy.length };
|
|
188
|
+
};
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
/** Exact Git preimages and detector spans, independent of caller suppressions. */
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
gitEnvironment,
|
|
8
|
+
gitRead,
|
|
9
|
+
HistorySecretError,
|
|
10
|
+
} from "./history-secret-git.mjs";
|
|
11
|
+
import {
|
|
12
|
+
evidenceMaps,
|
|
13
|
+
narrativeSpan,
|
|
14
|
+
safeSourcePath,
|
|
15
|
+
} from "./history-secret-evidence-shape.mjs";
|
|
16
|
+
const BUDGET = 64 * 1024 * 1024;
|
|
17
|
+
const NO_REPLACEMENTS = "--no-replace-objects";
|
|
18
|
+
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
19
|
+
const options = cwd => ({
|
|
20
|
+
cwd,
|
|
21
|
+
env: gitEnvironment(),
|
|
22
|
+
timeout: 120000,
|
|
23
|
+
maxBuffer: 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Authentic regular Git blobs; deduplication charges each actual blob once. */
|
|
27
|
+
const readBlob = (file, commit, cwd, state) => {
|
|
28
|
+
if (!safeSourcePath(file)) return null;
|
|
29
|
+
const entries = gitRead(
|
|
30
|
+
["--literal-pathspecs", "ls-tree", "-z", commit, "--", file],
|
|
31
|
+
cwd
|
|
32
|
+
)
|
|
33
|
+
.split("\0")
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
if (entries.length !== 1) return null;
|
|
36
|
+
const entry = entries[0].match(
|
|
37
|
+
/^(100644|100755) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]*)$/u
|
|
38
|
+
);
|
|
39
|
+
if (!entry || entry[3] !== file || entry[2].length !== commit.length)
|
|
40
|
+
return null;
|
|
41
|
+
if (state.blobs.has(entry[2])) return state.blobs.get(entry[2]);
|
|
42
|
+
if (state.blobs.size >= 256) return null;
|
|
43
|
+
const sizeText = gitRead(["cat-file", "-s", entry[2]], cwd);
|
|
44
|
+
const size = Number(sizeText);
|
|
45
|
+
if (!/^\d+$/u.test(sizeText) || !Number.isSafeInteger(size))
|
|
46
|
+
throw new HistorySecretError(
|
|
47
|
+
"Git blob size is invalid. Repair complete Git objects and retry; safety is unproved."
|
|
48
|
+
);
|
|
49
|
+
if (size > state.remaining) return null;
|
|
50
|
+
const result = spawnSync(
|
|
51
|
+
"git",
|
|
52
|
+
[NO_REPLACEMENTS, "cat-file", "blob", entry[2]],
|
|
53
|
+
{
|
|
54
|
+
cwd,
|
|
55
|
+
env: gitEnvironment(),
|
|
56
|
+
timeout: 120000,
|
|
57
|
+
maxBuffer: BUDGET,
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
if (
|
|
61
|
+
result.error ||
|
|
62
|
+
result.signal ||
|
|
63
|
+
result.status !== 0 ||
|
|
64
|
+
result.stdout.length !== size ||
|
|
65
|
+
createHash(commit.length === 40 ? "sha1" : "sha256")
|
|
66
|
+
.update(`blob ${size}\0`)
|
|
67
|
+
.update(result.stdout)
|
|
68
|
+
.digest("hex") !== entry[2]
|
|
69
|
+
)
|
|
70
|
+
throw new HistorySecretError(
|
|
71
|
+
"Git blob bytes cannot be verified. Repair complete Git objects and retry; no raw source is published."
|
|
72
|
+
);
|
|
73
|
+
state.remaining -= size;
|
|
74
|
+
state.blobs.set(entry[2], result.stdout);
|
|
75
|
+
return result.stdout;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Explicit revisions retain exact original commit-type and ancestry requirements. */
|
|
79
|
+
const revisionFor = (map, row, cwd) => {
|
|
80
|
+
if (!Object.hasOwn(map.value, "source_revision")) return row.Commit;
|
|
81
|
+
const revision = map.value.source_revision;
|
|
82
|
+
if (
|
|
83
|
+
typeof revision !== "string" ||
|
|
84
|
+
!new RegExp(`^[a-f0-9]{${row.Commit.length}}$`, "u").test(revision)
|
|
85
|
+
)
|
|
86
|
+
return null;
|
|
87
|
+
const type = spawnSync(
|
|
88
|
+
"git",
|
|
89
|
+
[NO_REPLACEMENTS, "cat-file", "-t", revision],
|
|
90
|
+
options(cwd)
|
|
91
|
+
);
|
|
92
|
+
const ancestor = spawnSync(
|
|
93
|
+
"git",
|
|
94
|
+
[NO_REPLACEMENTS, "merge-base", "--is-ancestor", revision, row.Commit],
|
|
95
|
+
options(cwd)
|
|
96
|
+
);
|
|
97
|
+
return !type.error &&
|
|
98
|
+
!type.signal &&
|
|
99
|
+
type.status === 0 &&
|
|
100
|
+
type.stdout.toString() === "commit\n" &&
|
|
101
|
+
!type.stderr.length &&
|
|
102
|
+
!ancestor.error &&
|
|
103
|
+
!ancestor.signal &&
|
|
104
|
+
ancestor.status === 0 &&
|
|
105
|
+
!ancestor.stdout.length &&
|
|
106
|
+
!ancestor.stderr.length
|
|
107
|
+
? revision
|
|
108
|
+
: null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Only explicitly selected introduced commits can supply additional source versions. */
|
|
112
|
+
const sourceVersions = (file, row, cwd, commits, cache) => {
|
|
113
|
+
const key = `${row.Commit}\0${file}`;
|
|
114
|
+
if (cache.has(key)) return cache.get(key);
|
|
115
|
+
const candidates = new Set();
|
|
116
|
+
for (let offset = 0; offset < commits.length; offset += 100) {
|
|
117
|
+
const list = gitRead(
|
|
118
|
+
[
|
|
119
|
+
"--literal-pathspecs",
|
|
120
|
+
"log",
|
|
121
|
+
"--no-walk=unsorted",
|
|
122
|
+
"--format=%H",
|
|
123
|
+
...commits.slice(offset, offset + 100),
|
|
124
|
+
"--",
|
|
125
|
+
file,
|
|
126
|
+
],
|
|
127
|
+
cwd
|
|
128
|
+
);
|
|
129
|
+
for (const candidate of list.split("\n").filter(Boolean)) {
|
|
130
|
+
if (!commits.includes(candidate))
|
|
131
|
+
throw new HistorySecretError(
|
|
132
|
+
"Source version attribution is invalid. Supply original Git input and retry."
|
|
133
|
+
);
|
|
134
|
+
const ancestor = spawnSync(
|
|
135
|
+
"git",
|
|
136
|
+
[NO_REPLACEMENTS, "merge-base", "--is-ancestor", candidate, row.Commit],
|
|
137
|
+
options(cwd)
|
|
138
|
+
);
|
|
139
|
+
if (
|
|
140
|
+
ancestor.error ||
|
|
141
|
+
ancestor.signal ||
|
|
142
|
+
![0, 1].includes(ancestor.status) ||
|
|
143
|
+
ancestor.stdout.length ||
|
|
144
|
+
ancestor.stderr.length
|
|
145
|
+
)
|
|
146
|
+
throw new HistorySecretError(
|
|
147
|
+
"Source ancestry cannot be verified. Repair complete Git objects and retry."
|
|
148
|
+
);
|
|
149
|
+
if (ancestor.status === 0) candidates.add(candidate);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const versions = [...candidates];
|
|
153
|
+
cache.set(key, versions);
|
|
154
|
+
return versions;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** One reconstructed redacted capture must coincide with one actual Git byte span. */
|
|
158
|
+
const attribution = (row, bytes) => {
|
|
159
|
+
let lineStart = 0;
|
|
160
|
+
for (let line = 1; line < row.StartLine; line++) {
|
|
161
|
+
const next = bytes.indexOf(10, lineStart);
|
|
162
|
+
if (next < 0) return null;
|
|
163
|
+
lineStart = next + 1;
|
|
164
|
+
}
|
|
165
|
+
const newline = bytes.indexOf(10, lineStart);
|
|
166
|
+
const lineEnd = newline < 0 ? bytes.length : newline;
|
|
167
|
+
const replacements = row.Match.split("REDACTED");
|
|
168
|
+
if (replacements.length !== 2) return null;
|
|
169
|
+
const line = bytes.subarray(lineStart, lineEnd);
|
|
170
|
+
const matches = span => {
|
|
171
|
+
const template = replacements.join(span.hash);
|
|
172
|
+
if (template.split(span.hash).length !== 2) return false;
|
|
173
|
+
const original = Buffer.from(template);
|
|
174
|
+
const offset = line.indexOf(original);
|
|
175
|
+
if (offset < 0 || line.indexOf(original, offset + 1) >= 0) return false;
|
|
176
|
+
const start = lineStart + offset;
|
|
177
|
+
const end = start + original.length;
|
|
178
|
+
if (
|
|
179
|
+
span.start !== start + Buffer.byteLength(replacements[0]) ||
|
|
180
|
+
span.end !== span.start + Buffer.byteLength(span.hash)
|
|
181
|
+
)
|
|
182
|
+
return false;
|
|
183
|
+
// The pinned vendor can count one preceding fragment newline in columns.
|
|
184
|
+
return (
|
|
185
|
+
[0, 1].filter(
|
|
186
|
+
extra =>
|
|
187
|
+
start === lineStart + row.StartColumn - 1 - extra &&
|
|
188
|
+
end === lineStart + row.EndColumn - extra
|
|
189
|
+
).length === 1
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
return { lineStart, lineEnd, matches };
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** Catalogue labels are never dereferenced; only actual introduced head Git bytes count. */
|
|
196
|
+
const proofMatches = (span, heads, blob) =>
|
|
197
|
+
heads.some(head => {
|
|
198
|
+
const bytes = blob(
|
|
199
|
+
`.lisa/history-secret-preimages/sha256/${span.hash}`,
|
|
200
|
+
head
|
|
201
|
+
);
|
|
202
|
+
return bytes !== null && digest(bytes) === span.hash;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/** Source mappings retain whole-map verification and exact path-specific preimages. */
|
|
206
|
+
const verifiedMap = (map, row, cwd, commits, heads, blob, versions) => {
|
|
207
|
+
if (map.kind === "proof")
|
|
208
|
+
return map.spans.filter(span => proofMatches(span, heads, blob));
|
|
209
|
+
const revision = revisionFor(map, row, cwd);
|
|
210
|
+
if (revision === null) return [];
|
|
211
|
+
for (const span of map.spans) {
|
|
212
|
+
const bytes = blob(span.file, revision);
|
|
213
|
+
if (bytes !== null && digest(bytes) === span.hash) continue;
|
|
214
|
+
if (map.kind === "strict" || Object.hasOwn(map.value, "source_revision"))
|
|
215
|
+
return [];
|
|
216
|
+
if (
|
|
217
|
+
!sourceVersions(span.file, row, cwd, commits, versions).some(commit => {
|
|
218
|
+
const historical = blob(span.file, commit);
|
|
219
|
+
return historical !== null && digest(historical) === span.hash;
|
|
220
|
+
})
|
|
221
|
+
)
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
return map.spans;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Clear a finding only after syntax, original span and independently verified bytes agree. */
|
|
228
|
+
export const classifyEvidence = (cwd, commits, heads) => {
|
|
229
|
+
const state = { remaining: BUDGET, blobs: new Map() };
|
|
230
|
+
const refs = new Map();
|
|
231
|
+
const maps = new Map();
|
|
232
|
+
const versions = new Map();
|
|
233
|
+
const blob = (file, commit) => {
|
|
234
|
+
const key = `${commit}\0${file}`;
|
|
235
|
+
if (!refs.has(key)) {
|
|
236
|
+
if (refs.size >= 4096) return null;
|
|
237
|
+
refs.set(key, readBlob(file, commit, cwd, state));
|
|
238
|
+
}
|
|
239
|
+
return refs.get(key);
|
|
240
|
+
};
|
|
241
|
+
return row => {
|
|
242
|
+
if (
|
|
243
|
+
row.RuleID !== "generic-api-key" ||
|
|
244
|
+
!safeSourcePath(row.File) ||
|
|
245
|
+
row.SymlinkFile !== "" ||
|
|
246
|
+
row.Secret !== "REDACTED" ||
|
|
247
|
+
typeof row.Match !== "string" ||
|
|
248
|
+
row.EndLine !== row.StartLine ||
|
|
249
|
+
!Number.isSafeInteger(row.StartColumn) ||
|
|
250
|
+
!Number.isSafeInteger(row.EndColumn) ||
|
|
251
|
+
row.StartColumn < 1 ||
|
|
252
|
+
row.EndColumn < row.StartColumn
|
|
253
|
+
)
|
|
254
|
+
return false;
|
|
255
|
+
const bytes = blob(row.File, row.Commit);
|
|
256
|
+
if (!bytes) return false;
|
|
257
|
+
const span = attribution(row, bytes);
|
|
258
|
+
if (!span) return false;
|
|
259
|
+
const narrative = narrativeSpan(bytes, span.lineStart, span.lineEnd);
|
|
260
|
+
if (narrative !== null && span.matches(narrative)) return true;
|
|
261
|
+
const key = `${row.Commit}\0${row.File}`;
|
|
262
|
+
if (!maps.has(key))
|
|
263
|
+
maps.set(
|
|
264
|
+
key,
|
|
265
|
+
evidenceMaps(bytes).flatMap(map =>
|
|
266
|
+
verifiedMap(map, row, cwd, commits, heads, blob, versions)
|
|
267
|
+
)
|
|
268
|
+
);
|
|
269
|
+
return maps.get(key).filter(span.matches).length === 1;
|
|
270
|
+
};
|
|
271
|
+
};
|