@reneturcios/hookcheck 0.1.7 → 0.1.9
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/README.md +7 -6
- package/dist/cli.js +44 -16
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +56 -38
- package/dist/core.js +691 -349
- package/dist/core.js.map +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/core.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
9
|
-
export const VERSION = "0.1.
|
|
9
|
+
export const VERSION = "0.1.9";
|
|
10
10
|
export const RISKY_RULES = [
|
|
11
11
|
{ id: "DECODE-PIPE-SH", pattern: /\bbase64\s+(-d|--decode|-D)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "decodes a base64 payload and pipes it straight into an interpreter", severe: true, remote: true },
|
|
12
12
|
{ id: "CURL-PIPE-SH", pattern: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "pipes a network download straight into an interpreter", severe: true, remote: true },
|
|
@@ -713,6 +713,17 @@ const CRED_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|ACCES
|
|
|
713
713
|
function detectEnvAccess(cmd, dataRanges) {
|
|
714
714
|
let cred = false;
|
|
715
715
|
let bulk = false;
|
|
716
|
+
// Bulk form tracking (P-091): the evidence sentence must describe
|
|
717
|
+
// the form that actually fired. A spread ({**os.environ, ...}), a
|
|
718
|
+
// dict() or list() copy, or an os.environ.copy() handed on (the
|
|
719
|
+
// environment inheritance idiom, typically as a subprocess env=) is
|
|
720
|
+
// bulk access, but it is not a serialization or a dump, and the
|
|
721
|
+
// v0.1.8 evidence called every bulk form one. bulkInheritanceOnly
|
|
722
|
+
// is true when every bulk hit is an inheritance or copy form and no
|
|
723
|
+
// serialization or dump form (JSON.stringify, printenv, a bare env
|
|
724
|
+
// statement, str(os.environ)) fired.
|
|
725
|
+
let bulkDump = false;
|
|
726
|
+
let bulkCopy = false;
|
|
716
727
|
const skipSpans = [
|
|
717
728
|
...Array.from(cmd.matchAll(/\benv(?:\s+-u\s+[A-Za-z_][A-Za-z0-9_]*)+/g), (m) => ({ start: m.index, end: m.index + m[0].length })),
|
|
718
729
|
...Array.from(cmd.matchAll(/\[\[?\s+-[nz]\s+"?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"?\s*\]\]?/g), (m) => ({ start: m.index, end: m.index + m[0].length })),
|
|
@@ -739,14 +750,26 @@ function detectEnvAccess(cmd, dataRanges) {
|
|
|
739
750
|
cred = true;
|
|
740
751
|
}
|
|
741
752
|
}
|
|
742
|
-
if (/(JSON\.stringify|Object\.(keys|values|entries|assign)|util\.inspect)\s*\([^)]*(process\.env|os\.environ)/.test(cmd))
|
|
753
|
+
if (/(JSON\.stringify|Object\.(keys|values|entries|assign)|util\.inspect)\s*\([^)]*(process\.env|os\.environ)/.test(cmd)) {
|
|
743
754
|
bulk = true;
|
|
744
|
-
|
|
755
|
+
bulkDump = true;
|
|
756
|
+
}
|
|
757
|
+
if (/\bprintenv\b/.test(cmd) && !inRanges(cmd.search(/\bprintenv\b/), dataRanges)) {
|
|
745
758
|
bulk = true;
|
|
746
|
-
|
|
759
|
+
bulkDump = true;
|
|
760
|
+
}
|
|
761
|
+
if (/(^|[;&|]\s*)env\s*($|[|>;])/m.test(cmd)) {
|
|
747
762
|
bulk = true;
|
|
748
|
-
|
|
763
|
+
bulkDump = true;
|
|
764
|
+
}
|
|
765
|
+
if (/\bstr\s*\(\s*os\.environ\s*\)/.test(cmd)) {
|
|
766
|
+
bulk = true;
|
|
767
|
+
bulkDump = true;
|
|
768
|
+
}
|
|
769
|
+
if (/\b(?:dict|list)\s*\(\s*os\.environ\s*\)/.test(cmd)) {
|
|
749
770
|
bulk = true;
|
|
771
|
+
bulkCopy = true;
|
|
772
|
+
}
|
|
750
773
|
// Whole os.environ use (P-020): scan every occurrence and judge the
|
|
751
774
|
// operation that follows it, instead of one negative lookahead.
|
|
752
775
|
const envRe = /\bos\.environ\b/g;
|
|
@@ -763,14 +786,90 @@ function detectEnvAccess(cmd, dataRanges) {
|
|
|
763
786
|
continue; // write or removal, not a read
|
|
764
787
|
if (/^\s*\.copy\s*\(/.test(after)) {
|
|
765
788
|
bulk = true;
|
|
789
|
+
bulkCopy = true;
|
|
766
790
|
continue;
|
|
767
791
|
} // whole copy is a bulk read
|
|
768
792
|
const before = cmd.slice(Math.max(0, em.index - 24), em.index);
|
|
769
793
|
if (/env\s*=\s*$/.test(before))
|
|
770
794
|
continue; // subprocess env inheritance
|
|
771
795
|
bulk = true;
|
|
796
|
+
// A spread occurrence ({**os.environ, ...}) is the inheritance
|
|
797
|
+
// idiom in dict form; anything else here is an unqualified whole
|
|
798
|
+
// environment use, counted with the dump forms for wording.
|
|
799
|
+
if (/\*\*\s*$/.test(before))
|
|
800
|
+
bulkCopy = true;
|
|
801
|
+
else
|
|
802
|
+
bulkDump = true;
|
|
772
803
|
}
|
|
773
|
-
return { cred, bulk };
|
|
804
|
+
return { cred, bulk, bulkInheritanceOnly: bulk && bulkCopy && !bulkDump };
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Text ranges of environment inheritance expressions that are passed
|
|
808
|
+
* as a subprocess environment (P-091). Two shapes count: a value used
|
|
809
|
+
* directly as env= or env: (env={**os.environ, ...},
|
|
810
|
+
* env=os.environ.copy()), and a variable assigned from an inheritance
|
|
811
|
+
* expression that is itself passed as env= somewhere in the same
|
|
812
|
+
* text (ENV = {**os.environ, ...}; subprocess.Popen(..., env=ENV)).
|
|
813
|
+
* The same forms in any other position (a request body, a printed
|
|
814
|
+
* value) are NOT inheritance ranges: dict(os.environ) handed to
|
|
815
|
+
* requests.post as data is serialization into a payload and keeps
|
|
816
|
+
* full exfil weight.
|
|
817
|
+
*/
|
|
818
|
+
function isInheritanceExpr(expr) {
|
|
819
|
+
const t = expr.trim();
|
|
820
|
+
if (/^(?:os\.environ|process\.env)$/.test(t))
|
|
821
|
+
return true;
|
|
822
|
+
if (/^(?:os\.environ|process\.env)\.copy\(\s*\)$/.test(t))
|
|
823
|
+
return true;
|
|
824
|
+
if (/^dict\(\s*(?:os\.environ|process\.env)\s*\)$/.test(t))
|
|
825
|
+
return true;
|
|
826
|
+
if (/^Object\.assign\(\s*\{\s*\}\s*,\s*(?:process\.env|os\.environ)\b/.test(t))
|
|
827
|
+
return true;
|
|
828
|
+
if (t.startsWith("{") && (/\*\*\s*(?:os\.environ|process\.env)\b/.test(t) || /\.\.\.\s*(?:process\.env|os\.environ)\b/.test(t)))
|
|
829
|
+
return true;
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
function inheritanceRanges(text) {
|
|
833
|
+
const ranges = [];
|
|
834
|
+
for (const m of text.matchAll(/\benv\s*[:=]\s*/g)) {
|
|
835
|
+
const start = m.index + m[0].length;
|
|
836
|
+
const end = consumeValueExpr(text, start);
|
|
837
|
+
if (end > start && isInheritanceExpr(text.slice(start, end)))
|
|
838
|
+
ranges.push({ start, end });
|
|
839
|
+
}
|
|
840
|
+
const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
|
|
841
|
+
let m;
|
|
842
|
+
while ((m = assignRe.exec(text)) !== null) {
|
|
843
|
+
const rhsStart = m.index + m[0].length;
|
|
844
|
+
const end = consumeValueExpr(text, rhsStart);
|
|
845
|
+
if (end <= rhsStart)
|
|
846
|
+
continue;
|
|
847
|
+
if (!isInheritanceExpr(text.slice(rhsStart, end)))
|
|
848
|
+
continue;
|
|
849
|
+
const name = m[1].replace(/\$/g, "\\$");
|
|
850
|
+
if (new RegExp(`\\benv\\s*[:=]\\s*(?:${name}\\b|\\{[^}]*\\b${name}\\b)`).test(text)) {
|
|
851
|
+
ranges.push({ start: rhsStart, end });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return ranges;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* True when every network destination visible in the text is
|
|
858
|
+
* loopback and at least one destination is visible at all (P-091).
|
|
859
|
+
* Destinations are URL hosts plus netcat target hosts. An unknown
|
|
860
|
+
* destination (a URL held in a variable) is not loopback-only: the
|
|
861
|
+
* discount applies only when the flow's destinations are provably
|
|
862
|
+
* all local, the same standard as the P-004 loopback downgrade.
|
|
863
|
+
*/
|
|
864
|
+
function loopbackOnlyDestinations(text) {
|
|
865
|
+
const hosts = [];
|
|
866
|
+
for (const m of text.matchAll(/https?:\/\/([^\s"'`\/:?#]+)/g))
|
|
867
|
+
hosts.push(m[1]);
|
|
868
|
+
for (const m of text.matchAll(/\b(?:nc|netcat)\s+(?:-\S+\s+)*([A-Za-z0-9._-]+)/g))
|
|
869
|
+
hosts.push(m[1]);
|
|
870
|
+
if (hosts.length === 0)
|
|
871
|
+
return false;
|
|
872
|
+
return hosts.every((h) => isLoopbackHost(h));
|
|
774
873
|
}
|
|
775
874
|
/**
|
|
776
875
|
* Credential value atoms: text positions where a credential shaped env
|
|
@@ -1076,14 +1175,19 @@ function authRanges(text) {
|
|
|
1076
1175
|
return ranges;
|
|
1077
1176
|
}
|
|
1078
1177
|
/**
|
|
1079
|
-
* Variables assigned (transitively) from a credential value expression
|
|
1178
|
+
* Variables assigned (transitively) from a credential value expression,
|
|
1179
|
+
* mapped to their inheritance purity (P-091): a variable is pure when
|
|
1180
|
+
* every taint source in its assignment chain is an inheritance atom
|
|
1181
|
+
* or another pure variable (ENV = {**os.environ, ...} is pure; a
|
|
1182
|
+
* payload built by serializing that same environment is not, because
|
|
1183
|
+
* the serializing atom in its own right-hand side is impure).
|
|
1080
1184
|
* References inside authentication positions do not propagate taint
|
|
1081
1185
|
* (P-061): a headers dict whose only credential use is its
|
|
1082
1186
|
* Authorization value is an authentication container, not a tainted
|
|
1083
1187
|
* payload, and passing it to fetch does not become exfil.
|
|
1084
1188
|
*/
|
|
1085
1189
|
function taintedVars(text, atoms, dataRanges, auth = []) {
|
|
1086
|
-
const tainted = new
|
|
1190
|
+
const tainted = new Map();
|
|
1087
1191
|
const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
|
|
1088
1192
|
const assigns = [];
|
|
1089
1193
|
let m;
|
|
@@ -1099,17 +1203,18 @@ function taintedVars(text, atoms, dataRanges, auth = []) {
|
|
|
1099
1203
|
for (const a of assigns) {
|
|
1100
1204
|
if (tainted.has(a.name))
|
|
1101
1205
|
continue;
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1206
|
+
const sources = [];
|
|
1207
|
+
for (const x of live) {
|
|
1208
|
+
if (x.start >= a.rhsStart && x.start < a.rhsEnd)
|
|
1209
|
+
sources.push(x.pure === true);
|
|
1105
1210
|
}
|
|
1106
1211
|
const rhs = text.slice(a.rhsStart, a.rhsEnd);
|
|
1107
|
-
for (const v of tainted) {
|
|
1108
|
-
if (refOutsideRanges(rhs, v, a.rhsStart, [...dataRanges, ...auth]))
|
|
1109
|
-
|
|
1110
|
-
break;
|
|
1111
|
-
}
|
|
1212
|
+
for (const [v, vPure] of tainted) {
|
|
1213
|
+
if (refOutsideRanges(rhs, v, a.rhsStart, [...dataRanges, ...auth]))
|
|
1214
|
+
sources.push(vPure);
|
|
1112
1215
|
}
|
|
1216
|
+
if (sources.length > 0)
|
|
1217
|
+
tainted.set(a.name, sources.every(Boolean));
|
|
1113
1218
|
}
|
|
1114
1219
|
}
|
|
1115
1220
|
return tainted;
|
|
@@ -1143,17 +1248,33 @@ function callSpan(text, openIdx) {
|
|
|
1143
1248
|
}
|
|
1144
1249
|
return { start: openIdx, end: text.length };
|
|
1145
1250
|
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Taint inside one span, with inheritance purity (P-091): pure is
|
|
1253
|
+
* true only when every taint hit in the span is inheritance purity.
|
|
1254
|
+
* A span carrying even one impure hit (a credential shaped read, a
|
|
1255
|
+
* serialization of the whole environment) is an impure flow.
|
|
1256
|
+
*/
|
|
1146
1257
|
function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1258
|
+
let found = false;
|
|
1259
|
+
let pure = true;
|
|
1260
|
+
for (const a of atoms) {
|
|
1261
|
+
if (!a.inData && a.start >= span.start && a.start < span.end) {
|
|
1262
|
+
found = true;
|
|
1263
|
+
if (a.pure !== true)
|
|
1264
|
+
pure = false;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (tainted.size > 0) {
|
|
1268
|
+
const seg = text.slice(span.start, span.end);
|
|
1269
|
+
for (const [v, vPure] of tainted) {
|
|
1270
|
+
if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth])) {
|
|
1271
|
+
found = true;
|
|
1272
|
+
if (!vPure)
|
|
1273
|
+
pure = false;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1155
1276
|
}
|
|
1156
|
-
return
|
|
1277
|
+
return { found, pure };
|
|
1157
1278
|
}
|
|
1158
1279
|
/**
|
|
1159
1280
|
* Exfil value flow (Round 2, P-018). ENV-EXFIL used to fire on mere
|
|
@@ -1181,8 +1302,34 @@ function analyzeExfil(text, views, lang) {
|
|
|
1181
1302
|
const dataRanges = views.dataRanges;
|
|
1182
1303
|
const auth = authRanges(text);
|
|
1183
1304
|
const atoms = findAtoms(text, dataRanges).filter((a) => !inRanges(a.start, auth));
|
|
1305
|
+
// Inheritance purity (P-091): a bulk atom inside an inheritance
|
|
1306
|
+
// expression passed as a subprocess env= is marked pure. Flows are
|
|
1307
|
+
// accumulated with their purity instead of returning at the first
|
|
1308
|
+
// hit: an impure flow fires ENV-EXFIL unconditionally (the v0.1.4
|
|
1309
|
+
// rule), while a flow whose taint is exclusively inheritance purity
|
|
1310
|
+
// fires only when the flow has a non-loopback destination. The
|
|
1311
|
+
// distilled failure was CloudCorpRecords/xod: a test script builds
|
|
1312
|
+
// ENV = {**os.environ, ...}, passes it as env= to the project's own
|
|
1313
|
+
// server and CLI, and every network call is loopback to the server
|
|
1314
|
+
// it just started; the inherited env tainted the subprocess result
|
|
1315
|
+
// variable, whose print next to the loopback urlopen read as exfil.
|
|
1316
|
+
const inhRanges = inheritanceRanges(text);
|
|
1317
|
+
for (const a of atoms) {
|
|
1318
|
+
if (a.kind === "bulk" && inRanges(a.start, inhRanges))
|
|
1319
|
+
a.pure = true;
|
|
1320
|
+
}
|
|
1184
1321
|
const tainted = taintedVars(text, atoms, dataRanges, auth);
|
|
1185
|
-
|
|
1322
|
+
let sawPureFlow = false;
|
|
1323
|
+
let sawImpureFlow = false;
|
|
1324
|
+
const note = (r) => {
|
|
1325
|
+
if (!r.found)
|
|
1326
|
+
return;
|
|
1327
|
+
if (r.pure)
|
|
1328
|
+
sawPureFlow = true;
|
|
1329
|
+
else
|
|
1330
|
+
sawImpureFlow = true;
|
|
1331
|
+
};
|
|
1332
|
+
const has = (span) => note(spanHasTaint(text, span, atoms, tainted, dataRanges, auth));
|
|
1186
1333
|
const callRes = [
|
|
1187
1334
|
/\bfetch\s*\(/g,
|
|
1188
1335
|
/\baxios(?:\s*\.\s*(?:post|put|patch|get|request|delete))?\s*\(/g,
|
|
@@ -1195,8 +1342,7 @@ function analyzeExfil(text, views, lang) {
|
|
|
1195
1342
|
for (const re of callRes) {
|
|
1196
1343
|
let m;
|
|
1197
1344
|
while ((m = re.exec(text)) !== null) {
|
|
1198
|
-
|
|
1199
|
-
return true;
|
|
1345
|
+
has(callSpan(text, m.index + m[0].length - 1));
|
|
1200
1346
|
}
|
|
1201
1347
|
}
|
|
1202
1348
|
// Fetch positions run on the code view with data blanked (indices
|
|
@@ -1215,8 +1361,7 @@ function analyzeExfil(text, views, lang) {
|
|
|
1215
1361
|
}
|
|
1216
1362
|
if (!isFetchStmt)
|
|
1217
1363
|
continue;
|
|
1218
|
-
|
|
1219
|
-
return true;
|
|
1364
|
+
has(stmt);
|
|
1220
1365
|
}
|
|
1221
1366
|
for (const stmt of statements) {
|
|
1222
1367
|
const stmtText = text.slice(stmt.start, stmt.end);
|
|
@@ -1232,8 +1377,7 @@ function analyzeExfil(text, views, lang) {
|
|
|
1232
1377
|
continue;
|
|
1233
1378
|
for (let e = 0; e < k; e++) {
|
|
1234
1379
|
const early = { start: stmt.start + segments[e].start, end: stmt.start + segments[e].end };
|
|
1235
|
-
|
|
1236
|
-
return true;
|
|
1380
|
+
has(early);
|
|
1237
1381
|
}
|
|
1238
1382
|
}
|
|
1239
1383
|
}
|
|
@@ -1249,27 +1393,35 @@ function analyzeExfil(text, views, lang) {
|
|
|
1249
1393
|
if (hasSendConstruct) {
|
|
1250
1394
|
for (const m of text.matchAll(/(?:^|[;\n|&]\s*)(?:echo|printf)\s+([^\n;|]*)/g)) {
|
|
1251
1395
|
const span = { start: m.index, end: m.index + m[0].length };
|
|
1252
|
-
|
|
1253
|
-
|
|
1396
|
+
for (const a of atoms) {
|
|
1397
|
+
if (a.start >= span.start && a.start < span.end)
|
|
1398
|
+
note({ found: true, pure: a.pure === true });
|
|
1399
|
+
}
|
|
1254
1400
|
const argText = m[1] ?? "";
|
|
1255
1401
|
const argStart = m.index + m[0].length - argText.length;
|
|
1256
|
-
for (const v of tainted) {
|
|
1402
|
+
for (const [v, vPure] of tainted) {
|
|
1257
1403
|
if (lang === "shell" || refOutsideRanges(argText, v, argStart, [...dataRanges, ...auth]))
|
|
1258
|
-
|
|
1404
|
+
note({ found: true, pure: vPure });
|
|
1259
1405
|
}
|
|
1260
1406
|
}
|
|
1261
1407
|
for (const m of text.matchAll(/\b(?:console\.(?:log|info|debug)|print)\s*\(([^)]*)\)/g)) {
|
|
1262
1408
|
const span = { start: m.index, end: m.index + m[0].length };
|
|
1263
|
-
|
|
1264
|
-
|
|
1409
|
+
for (const a of atoms) {
|
|
1410
|
+
if (a.start >= span.start && a.start < span.end && (lang === "shell" || !a.inData))
|
|
1411
|
+
note({ found: true, pure: a.pure === true });
|
|
1412
|
+
}
|
|
1265
1413
|
const argText = m[1] ?? "";
|
|
1266
1414
|
const argStart = m.index + m[0].indexOf(argText);
|
|
1267
|
-
for (const v of tainted) {
|
|
1415
|
+
for (const [v, vPure] of tainted) {
|
|
1268
1416
|
if (lang === "shell" || refOutsideRanges(argText, v, argStart, [...dataRanges, ...auth]))
|
|
1269
|
-
|
|
1417
|
+
note({ found: true, pure: vPure });
|
|
1270
1418
|
}
|
|
1271
1419
|
}
|
|
1272
1420
|
}
|
|
1421
|
+
if (sawImpureFlow)
|
|
1422
|
+
return true;
|
|
1423
|
+
if (sawPureFlow)
|
|
1424
|
+
return !loopbackOnlyDestinations(text);
|
|
1273
1425
|
return false;
|
|
1274
1426
|
}
|
|
1275
1427
|
function isLoopbackHost(host) {
|
|
@@ -1388,7 +1540,12 @@ export function classifyCommand(command, lang = "shell") {
|
|
|
1388
1540
|
fired.push({ id: "ENV-ACCESS", evidence: "reads an environment variable whose name is credential shaped (token, secret, password, api key)" });
|
|
1389
1541
|
}
|
|
1390
1542
|
if (env.bulk) {
|
|
1391
|
-
fired.push({
|
|
1543
|
+
fired.push({
|
|
1544
|
+
id: "ENV-BULK",
|
|
1545
|
+
evidence: env.bulkInheritanceOnly
|
|
1546
|
+
? "accesses the whole environment at once in inheritance or copy form (a spread, dict(), list(), or copy() of the whole environment handed on, for example as a subprocess environment); bulk access, not by itself a serialization or dump"
|
|
1547
|
+
: "accesses the whole environment at once (serialization, printenv, or env dump)",
|
|
1548
|
+
});
|
|
1392
1549
|
}
|
|
1393
1550
|
// Exfil is a value flow, not a co-occurrence (P-018): the credential
|
|
1394
1551
|
// value must reach a send. Token as authentication does not count.
|
|
@@ -1684,7 +1841,30 @@ function makeFinding(root, file, source, hook, command, idSuffix, line, baseAbs
|
|
|
1684
1841
|
};
|
|
1685
1842
|
return escalateWithLocalFile(root, base, baseAbs);
|
|
1686
1843
|
}
|
|
1844
|
+
/**
|
|
1845
|
+
* Stat-type guard (P-069). A file the scanner has decided to read is
|
|
1846
|
+
* checked by stat type BEFORE it is opened, and only regular files are
|
|
1847
|
+
* ever opened. Opening a FIFO (named pipe) blocks until a writer
|
|
1848
|
+
* appears, so a pipe planted at a surface path (.github/workflows/
|
|
1849
|
+
* pipe.yml, a package.json, an .mcp.json) hung the scan forever with
|
|
1850
|
+
* no internal timeout to catch it; sockets and device files are the
|
|
1851
|
+
* same hazard. injectcheck already stats before opening; every read
|
|
1852
|
+
* path in hookcheck now goes through readText or readTextWarn, which
|
|
1853
|
+
* enforce the check centrally, and the per surface scanners that stat
|
|
1854
|
+
* first (Claude settings, husky and githooks files, agent dirs, the
|
|
1855
|
+
* one hop follower) keep their own isFile checks as well.
|
|
1856
|
+
*/
|
|
1857
|
+
function statIsRegularFile(abs) {
|
|
1858
|
+
try {
|
|
1859
|
+
return statSync(abs).isFile();
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
return null;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1687
1865
|
function readText(abs) {
|
|
1866
|
+
if (statIsRegularFile(abs) === false)
|
|
1867
|
+
return null;
|
|
1688
1868
|
try {
|
|
1689
1869
|
return readFileSync(abs, "utf8");
|
|
1690
1870
|
}
|
|
@@ -1701,6 +1881,10 @@ function readText(abs) {
|
|
|
1701
1881
|
* presents as a complete all-clear.
|
|
1702
1882
|
*/
|
|
1703
1883
|
function readTextWarn(abs, rel, warnings) {
|
|
1884
|
+
if (statIsRegularFile(abs) === false) {
|
|
1885
|
+
warnings.push(`Non-regular file skipped: ${rel} is not a regular file (FIFO, socket, or device); it was not opened or inventoried`);
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
1704
1888
|
try {
|
|
1705
1889
|
return readFileSync(abs, "utf8");
|
|
1706
1890
|
}
|
|
@@ -1763,21 +1947,65 @@ function scanOnePackageJson(root, rel, findings, warnings) {
|
|
|
1763
1947
|
}
|
|
1764
1948
|
}
|
|
1765
1949
|
/**
|
|
1766
|
-
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
*
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1950
|
+
* Unified nested discovery (P-032, P-060, P-065). Every surface the
|
|
1951
|
+
* tool inventories at the scan root is also inventoried when it
|
|
1952
|
+
* appears inside a subdirectory: before v0.1.8 only nested package.json
|
|
1953
|
+
* files (P-032) and plugin hooks files (P-060) were recursed, so a
|
|
1954
|
+
* parent scan of a monorepo or workspace silently missed nested
|
|
1955
|
+
* .claude/settings.json, .vscode/tasks.json, workflows, husky and
|
|
1956
|
+
* githooks directories, lefthook and pre-commit configs, and MCP
|
|
1957
|
+
* configs, and a tree holding only nested surfaces scanned CLEAN with
|
|
1958
|
+
* the empty estate note attached (P-065).
|
|
1959
|
+
*
|
|
1960
|
+
* One bounded walk enumerates every project root (each directory at
|
|
1961
|
+
* most NESTED_MAX_DEPTH levels below the scan root, the scan root
|
|
1962
|
+
* itself included) plus the nested package.json and plugin hooks file
|
|
1963
|
+
* lists. Per surface scanners then run once per project root with the
|
|
1964
|
+
* root prefix carried into every finding file path, so findings carry
|
|
1965
|
+
* their real relative paths. The walk is bounded so a hostile or
|
|
1966
|
+
* giant tree cannot explode the report: directories are visited in
|
|
1967
|
+
* sorted order, node_modules, .git, and dot directories are never
|
|
1968
|
+
* entered, symlinked directories are never followed (a symlink loop
|
|
1969
|
+
* cannot hang the scan), and each surface family inventories at most
|
|
1970
|
+
* NESTED_MAX_FILES_PER_SURFACE nested files, with the cap reported as
|
|
1971
|
+
* a warning when it is hit. When directories remain below the depth
|
|
1972
|
+
* bound the walk says so in a warning naming the bound, and it probes
|
|
1973
|
+
* the directories just below the bound (names only, no file reads) so
|
|
1974
|
+
* surfaces sitting there are counted in the warning instead of being
|
|
1975
|
+
* silently dropped.
|
|
1976
|
+
*
|
|
1977
|
+
* The bound itself was raised from 4 to 8 in v0.1.9 (P-072): at depth
|
|
1978
|
+
* 4, realistic monorepo nesting (apps/<team>/<app>, packages grouped
|
|
1979
|
+
* by area, fixture trees) sat below the bound, so a package.json
|
|
1980
|
+
* dropper at depth 5 floored at REVIEW via the probe and a depth 8
|
|
1981
|
+
* chain verdicted CLEAN with only the warning. Depth 8 covers those
|
|
1982
|
+
* layouts; the walk cost stays linear in the number of directories
|
|
1983
|
+
* actually present, and the 2,000 file tree and 5 MB workflow perf
|
|
1984
|
+
* spot checks for v0.1.9 showed no material regression against
|
|
1985
|
+
* v0.1.8. Whatever bound remains keeps the warning and the
|
|
1986
|
+
* never-bare-CLEAN rule when the probe detects surface below it.
|
|
1776
1987
|
*/
|
|
1777
|
-
export const
|
|
1778
|
-
export const
|
|
1779
|
-
|
|
1780
|
-
|
|
1988
|
+
export const NESTED_MAX_DEPTH = 8;
|
|
1989
|
+
export const NESTED_MAX_FILES_PER_SURFACE = 200;
|
|
1990
|
+
export const PACKAGE_MAX_DEPTH = NESTED_MAX_DEPTH;
|
|
1991
|
+
export const PACKAGE_MAX_NESTED = NESTED_MAX_FILES_PER_SURFACE;
|
|
1992
|
+
export const PLUGIN_HOOKS_MAX_DEPTH = NESTED_MAX_DEPTH;
|
|
1993
|
+
export const PLUGIN_HOOKS_MAX_FILES = NESTED_MAX_FILES_PER_SURFACE;
|
|
1994
|
+
const SURFACE_FILE_INDICATORS = new Set([
|
|
1995
|
+
"package.json", "lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml",
|
|
1996
|
+
".pre-commit-config.yaml", ".pre-commit-config.yml", ".mcp.json", "mcp.json",
|
|
1997
|
+
]);
|
|
1998
|
+
const SURFACE_DIR_INDICATORS = new Set([
|
|
1999
|
+
".claude", "claude", ".vscode", ".github", ".husky", ".githooks",
|
|
2000
|
+
"hooks", ".cursor", ".windsurf", ".codex",
|
|
2001
|
+
]);
|
|
2002
|
+
function discoverSurfaces(root, warnings) {
|
|
2003
|
+
const projectRoots = [];
|
|
2004
|
+
const packageFiles = [];
|
|
2005
|
+
const pluginSet = new Set();
|
|
2006
|
+
let frontierDirs = 0;
|
|
2007
|
+
let surfacesBelowBound = 0;
|
|
2008
|
+
let probes = 0;
|
|
1781
2009
|
const skipDirs = new Set(["node_modules", ".git"]);
|
|
1782
2010
|
const stack = [{ abs: root, rel: "", depth: 0 }];
|
|
1783
2011
|
while (stack.length > 0) {
|
|
@@ -1792,14 +2020,38 @@ function discoverNestedPackageJson(root, warnings) {
|
|
|
1792
2020
|
continue;
|
|
1793
2021
|
}
|
|
1794
2022
|
entries.sort();
|
|
2023
|
+
projectRoots.push(cur.rel);
|
|
1795
2024
|
if (cur.depth >= 1 && entries.includes("package.json")) {
|
|
1796
|
-
|
|
2025
|
+
packageFiles.push(`${cur.rel}/package.json`);
|
|
2026
|
+
}
|
|
2027
|
+
// Plugin hooks files sit in a hooks directory one level below the
|
|
2028
|
+
// plugin root, so they are spotted from the plugin root listing
|
|
2029
|
+
// (the hooks directory itself can sit at depth bound plus one).
|
|
2030
|
+
if (entries.includes("hooks")) {
|
|
2031
|
+
const hooksAbs = join(cur.abs, "hooks");
|
|
2032
|
+
let lst = null;
|
|
2033
|
+
try {
|
|
2034
|
+
lst = lstatSync(hooksAbs);
|
|
2035
|
+
}
|
|
2036
|
+
catch {
|
|
2037
|
+
lst = null;
|
|
2038
|
+
}
|
|
2039
|
+
if (lst && lst.isDirectory() && !lst.isSymbolicLink()) {
|
|
2040
|
+
let hookEntries = [];
|
|
2041
|
+
try {
|
|
2042
|
+
hookEntries = readdirSync(hooksAbs);
|
|
2043
|
+
}
|
|
2044
|
+
catch {
|
|
2045
|
+
hookEntries = [];
|
|
2046
|
+
}
|
|
2047
|
+
for (const name of ["hooks.json", "hooks-cursor.json"]) {
|
|
2048
|
+
if (hookEntries.includes(name))
|
|
2049
|
+
pluginSet.add(cur.rel === "" ? `hooks/${name}` : `${cur.rel}/hooks/${name}`);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
1797
2052
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
// Push in reverse so the stack pops in sorted order.
|
|
1801
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1802
|
-
const name = entries[i];
|
|
2053
|
+
const children = [];
|
|
2054
|
+
for (const name of entries) {
|
|
1803
2055
|
if (skipDirs.has(name) || name.startsWith("."))
|
|
1804
2056
|
continue;
|
|
1805
2057
|
const childAbs = join(cur.abs, name);
|
|
@@ -1812,19 +2064,84 @@ function discoverNestedPackageJson(root, warnings) {
|
|
|
1812
2064
|
}
|
|
1813
2065
|
if (lst.isSymbolicLink() || !lst.isDirectory())
|
|
1814
2066
|
continue;
|
|
1815
|
-
|
|
2067
|
+
children.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
|
|
2068
|
+
}
|
|
2069
|
+
if (cur.depth >= NESTED_MAX_DEPTH) {
|
|
2070
|
+
frontierDirs += children.length;
|
|
2071
|
+
for (const child of children) {
|
|
2072
|
+
if (probes >= 1000)
|
|
2073
|
+
break;
|
|
2074
|
+
probes++;
|
|
2075
|
+
let sub = [];
|
|
2076
|
+
try {
|
|
2077
|
+
sub = readdirSync(child.abs);
|
|
2078
|
+
}
|
|
2079
|
+
catch {
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
if (sub.some((n) => SURFACE_FILE_INDICATORS.has(n) || SURFACE_DIR_INDICATORS.has(n)))
|
|
2083
|
+
surfacesBelowBound++;
|
|
2084
|
+
}
|
|
2085
|
+
continue;
|
|
1816
2086
|
}
|
|
2087
|
+
// Push in reverse so the stack pops in sorted order.
|
|
2088
|
+
for (let i = children.length - 1; i >= 0; i--)
|
|
2089
|
+
stack.push(children[i]);
|
|
1817
2090
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
2091
|
+
projectRoots.sort((a, b) => (a === b ? 0 : a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1));
|
|
2092
|
+
packageFiles.sort();
|
|
2093
|
+
return {
|
|
2094
|
+
projectRoots,
|
|
2095
|
+
packageFiles,
|
|
2096
|
+
pluginHookFiles: Array.from(pluginSet).sort(),
|
|
2097
|
+
frontierDirs,
|
|
2098
|
+
surfacesBelowBound,
|
|
2099
|
+
depthBoundHit: frontierDirs > 0,
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
const FAMILY_LABELS = {
|
|
2103
|
+
settings: "Claude settings",
|
|
2104
|
+
tasks: "VS Code tasks",
|
|
2105
|
+
workflows: "workflow",
|
|
2106
|
+
husky: "husky hook",
|
|
2107
|
+
githooks: "githooks hook",
|
|
2108
|
+
lefthook: "lefthook config",
|
|
2109
|
+
"pre-commit": "pre-commit config",
|
|
2110
|
+
mcp: "MCP config",
|
|
2111
|
+
agent: "agent config",
|
|
2112
|
+
};
|
|
2113
|
+
/**
|
|
2114
|
+
* Claim one surface file for inventory. Returns false when the file
|
|
2115
|
+
* was already inventoried through another discovery path, or when the
|
|
2116
|
+
* nested file cap for its family is already spent (the skip is
|
|
2117
|
+
* counted, and scanHooks warns once per capped family).
|
|
2118
|
+
*/
|
|
2119
|
+
function claimFile(state, family, rel, isNested) {
|
|
2120
|
+
if (state.inventoried.has(rel))
|
|
2121
|
+
return false;
|
|
2122
|
+
if (isNested) {
|
|
2123
|
+
const found = (state.familyFound.get(family) ?? 0) + 1;
|
|
2124
|
+
state.familyFound.set(family, found);
|
|
2125
|
+
if (found > NESTED_MAX_FILES_PER_SURFACE) {
|
|
2126
|
+
state.familyCapped.set(family, (state.familyCapped.get(family) ?? 0) + 1);
|
|
2127
|
+
state.partial = true;
|
|
2128
|
+
return false;
|
|
2129
|
+
}
|
|
1822
2130
|
}
|
|
1823
|
-
|
|
2131
|
+
state.inventoried.add(rel);
|
|
2132
|
+
return true;
|
|
1824
2133
|
}
|
|
1825
|
-
function scanPackageJson(root, findings, warnings) {
|
|
2134
|
+
function scanPackageJson(root, findings, warnings, state) {
|
|
1826
2135
|
scanOnePackageJson(root, "package.json", findings, warnings);
|
|
1827
|
-
|
|
2136
|
+
state.inventoried.add("package.json");
|
|
2137
|
+
let nested = state.disc.packageFiles;
|
|
2138
|
+
if (nested.length > PACKAGE_MAX_NESTED) {
|
|
2139
|
+
warnings.push(`Nested package.json inventory capped at ${PACKAGE_MAX_NESTED} files (${nested.length} found); the remaining nested package.json files were not inventoried and the scan is partial`);
|
|
2140
|
+
state.partial = true;
|
|
2141
|
+
nested = nested.slice(0, PACKAGE_MAX_NESTED);
|
|
2142
|
+
}
|
|
2143
|
+
for (const rel of nested) {
|
|
2144
|
+
state.inventoried.add(rel);
|
|
1828
2145
|
scanOnePackageJson(root, rel, findings, warnings);
|
|
1829
2146
|
}
|
|
1830
2147
|
}
|
|
@@ -1850,16 +2167,22 @@ function collectClaudeHooks(node, file, root, findings, prefix, source = "claude
|
|
|
1850
2167
|
* the GNU stow layout used by real dotfiles repos (claude/settings.json,
|
|
1851
2168
|
* no dot), and a settings file nested one level under a config dir
|
|
1852
2169
|
* (config/.claude/settings.json). A settings path that exists as a
|
|
1853
|
-
* directory is reported as a warning, never silently skipped.
|
|
2170
|
+
* directory is reported as a warning, never silently skipped. Since
|
|
2171
|
+
* v0.1.8 (P-065) the candidates are computed per project root, so the
|
|
2172
|
+
* same locations are inventoried in every subdirectory within the
|
|
2173
|
+
* discovery bound; the inventoried set in ScanState keeps a file
|
|
2174
|
+
* reachable by two paths (a parent root one level candidate and its
|
|
2175
|
+
* own project root) counted exactly once.
|
|
1854
2176
|
*/
|
|
1855
|
-
function claudeSettingsCandidates(root) {
|
|
2177
|
+
function claudeSettingsCandidates(root, prefix) {
|
|
2178
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
1856
2179
|
const rels = [
|
|
1857
|
-
|
|
1858
|
-
|
|
2180
|
+
`${base}.claude/settings.json`, `${base}.claude/settings.local.json`,
|
|
2181
|
+
`${base}claude/settings.json`, `${base}claude/settings.local.json`,
|
|
1859
2182
|
];
|
|
1860
2183
|
let entries = [];
|
|
1861
2184
|
try {
|
|
1862
|
-
entries = readdirSync(root);
|
|
2185
|
+
entries = readdirSync(join(root, prefix));
|
|
1863
2186
|
}
|
|
1864
2187
|
catch {
|
|
1865
2188
|
entries = [];
|
|
@@ -1869,7 +2192,7 @@ function claudeSettingsCandidates(root) {
|
|
|
1869
2192
|
continue;
|
|
1870
2193
|
let st;
|
|
1871
2194
|
try {
|
|
1872
|
-
st = statSync(join(root, name));
|
|
2195
|
+
st = statSync(join(root, prefix, name));
|
|
1873
2196
|
}
|
|
1874
2197
|
catch {
|
|
1875
2198
|
continue;
|
|
@@ -1877,42 +2200,50 @@ function claudeSettingsCandidates(root) {
|
|
|
1877
2200
|
if (!st.isDirectory())
|
|
1878
2201
|
continue;
|
|
1879
2202
|
for (const f of ["settings.json", "settings.local.json"]) {
|
|
1880
|
-
rels.push(`${name}/.claude/${f}`);
|
|
2203
|
+
rels.push(`${base}${name}/.claude/${f}`);
|
|
1881
2204
|
}
|
|
1882
2205
|
}
|
|
1883
2206
|
return Array.from(new Set(rels));
|
|
1884
2207
|
}
|
|
1885
|
-
function scanClaude(root, findings, warnings) {
|
|
1886
|
-
for (const
|
|
1887
|
-
const
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
st
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
2208
|
+
function scanClaude(root, findings, warnings, state) {
|
|
2209
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2210
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2211
|
+
for (const rel of claudeSettingsCandidates(root, prefix)) {
|
|
2212
|
+
const abs = join(root, rel);
|
|
2213
|
+
if (!existsSync(abs))
|
|
2214
|
+
continue;
|
|
2215
|
+
let st;
|
|
2216
|
+
try {
|
|
2217
|
+
st = statSync(abs);
|
|
2218
|
+
}
|
|
2219
|
+
catch {
|
|
2220
|
+
continue;
|
|
2221
|
+
}
|
|
2222
|
+
if (st.isDirectory()) {
|
|
2223
|
+
if (!state.inventoried.has(rel)) {
|
|
2224
|
+
state.inventoried.add(rel);
|
|
2225
|
+
warnings.push(`${rel} is a directory, not a settings file: any hooks inside it cannot be parsed as Claude settings`);
|
|
2226
|
+
}
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
if (!st.isFile())
|
|
2230
|
+
continue;
|
|
2231
|
+
if (!claimFile(state, "settings", rel, prefix !== ""))
|
|
2232
|
+
continue;
|
|
2233
|
+
const data = readJson(root, rel, warnings);
|
|
2234
|
+
if (!data || typeof data !== "object")
|
|
2235
|
+
continue;
|
|
2236
|
+
if (!("hooks" in data)) {
|
|
2237
|
+
warnings.push(`No hooks key in ${rel}`);
|
|
2238
|
+
continue;
|
|
2239
|
+
}
|
|
2240
|
+
const hooks = data.hooks;
|
|
2241
|
+
if (!hooks || typeof hooks !== "object") {
|
|
2242
|
+
warnings.push(`hooks key in ${rel} is not an object: hooks skipped`);
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
collectClaudeHooks(hooks, rel, root, findings, "", "claude-settings", baseAbs);
|
|
1914
2246
|
}
|
|
1915
|
-
collectClaudeHooks(hooks, rel, root, findings, "");
|
|
1916
2247
|
}
|
|
1917
2248
|
}
|
|
1918
2249
|
/**
|
|
@@ -1933,67 +2264,21 @@ function scanClaude(root, findings, warnings) {
|
|
|
1933
2264
|
* command object shape under Cursor event names, so it is inventoried
|
|
1934
2265
|
* the same way.
|
|
1935
2266
|
*
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
1941
|
-
* level below its plugin root.
|
|
2267
|
+
* Discovery is part of the unified bounded walk (P-065): the file
|
|
2268
|
+
* list comes from discoverSurfaces, with the plugin root at most
|
|
2269
|
+
* PLUGIN_HOOKS_MAX_DEPTH directories below the scan root (the hooks
|
|
2270
|
+
* directory itself sits one level below its plugin root), and the
|
|
2271
|
+
* same cap with a warning applies.
|
|
1942
2272
|
*/
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
let entries = [];
|
|
1953
|
-
try {
|
|
1954
|
-
entries = readdirSync(cur.abs);
|
|
1955
|
-
}
|
|
1956
|
-
catch {
|
|
1957
|
-
if (cur.rel)
|
|
1958
|
-
warnings.push(`Unreadable directory skipped: ${cur.rel} could not be listed; the scan is partial`);
|
|
1959
|
-
continue;
|
|
1960
|
-
}
|
|
1961
|
-
entries.sort();
|
|
1962
|
-
const base = cur.rel === "" ? "" : (cur.rel.split("/").pop() ?? "");
|
|
1963
|
-
if (cur.depth >= 1 && base === "hooks") {
|
|
1964
|
-
for (const name of ["hooks.json", "hooks-cursor.json"]) {
|
|
1965
|
-
if (entries.includes(name))
|
|
1966
|
-
found.push(cur.rel === "" ? name : `${cur.rel}/${name}`);
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
if (cur.depth >= maxHooksDirDepth)
|
|
1970
|
-
continue;
|
|
1971
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1972
|
-
const name = entries[i];
|
|
1973
|
-
if (skipDirs.has(name) || name.startsWith("."))
|
|
1974
|
-
continue;
|
|
1975
|
-
const childAbs = join(cur.abs, name);
|
|
1976
|
-
let lst;
|
|
1977
|
-
try {
|
|
1978
|
-
lst = lstatSync(childAbs);
|
|
1979
|
-
}
|
|
1980
|
-
catch {
|
|
1981
|
-
continue;
|
|
1982
|
-
}
|
|
1983
|
-
if (lst.isSymbolicLink() || !lst.isDirectory())
|
|
1984
|
-
continue;
|
|
1985
|
-
stack.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
found.sort();
|
|
1989
|
-
if (found.length > PLUGIN_HOOKS_MAX_FILES) {
|
|
1990
|
-
warnings.push(`Plugin hooks inventory capped at ${PLUGIN_HOOKS_MAX_FILES} files (${found.length} found); the remaining plugin hook files were not inventoried and the scan is partial`);
|
|
1991
|
-
return found.slice(0, PLUGIN_HOOKS_MAX_FILES);
|
|
1992
|
-
}
|
|
1993
|
-
return found;
|
|
1994
|
-
}
|
|
1995
|
-
function scanPluginHooks(root, findings, warnings) {
|
|
1996
|
-
for (const rel of discoverPluginHookFiles(root, warnings)) {
|
|
2273
|
+
function scanPluginHooks(root, findings, warnings, state) {
|
|
2274
|
+
let files = state.disc.pluginHookFiles;
|
|
2275
|
+
if (files.length > PLUGIN_HOOKS_MAX_FILES) {
|
|
2276
|
+
warnings.push(`Plugin hooks inventory capped at ${PLUGIN_HOOKS_MAX_FILES} files (${files.length} found); the remaining plugin hook files were not inventoried and the scan is partial`);
|
|
2277
|
+
state.partial = true;
|
|
2278
|
+
files = files.slice(0, PLUGIN_HOOKS_MAX_FILES);
|
|
2279
|
+
}
|
|
2280
|
+
for (const rel of files) {
|
|
2281
|
+
state.inventoried.add(rel);
|
|
1997
2282
|
const data = readJson(root, rel, warnings);
|
|
1998
2283
|
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
1999
2284
|
continue;
|
|
@@ -2009,129 +2294,48 @@ function scanPluginHooks(root, findings, warnings) {
|
|
|
2009
2294
|
collectClaudeHooks(eventsNode, rel, root, findings, "", "claude-plugin", pluginRootAbs);
|
|
2010
2295
|
}
|
|
2011
2296
|
}
|
|
2012
|
-
function scanVscode(root, findings, warnings) {
|
|
2013
|
-
const
|
|
2014
|
-
|
|
2015
|
-
const
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
// Malformed entries (null, strings, numbers) are skipped, never fatal:
|
|
2022
|
-
// one bad task must not hide every other surface in the repo.
|
|
2023
|
-
if (!rawTask || typeof rawTask !== "object")
|
|
2024
|
-
return;
|
|
2025
|
-
const task = rawTask;
|
|
2026
|
-
const runOptions = (task.runOptions ?? {});
|
|
2027
|
-
const options = (task.options ?? {});
|
|
2028
|
-
const optionsRunOptions = (options.runOptions ?? {});
|
|
2029
|
-
// folderOpen lives in runOptions in the standard schema, but real
|
|
2030
|
-
// world files also nest it under options or put runOn on the task.
|
|
2031
|
-
const runOn = runOptions.runOn ?? optionsRunOptions.runOn ?? task.runOn;
|
|
2032
|
-
const isFolderOpen = runOn === "folderOpen" || (Array.isArray(runOn) && runOn.includes("folderOpen"));
|
|
2033
|
-
const command = typeof task.command === "string" ? task.command : "";
|
|
2034
|
-
const args = Array.isArray(task.args) ? task.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
2035
|
-
const full = [command, args].filter(Boolean).join(" ").trim();
|
|
2036
|
-
if (!full)
|
|
2037
|
-
return;
|
|
2038
|
-
const label = typeof task.label === "string" ? task.label : `task-${idx}`;
|
|
2039
|
-
if (isFolderOpen) {
|
|
2040
|
-
findings.push(makeFinding(root, rel, "vscode-tasks", `folderOpen:${label}`, full, String(idx)));
|
|
2297
|
+
function scanVscode(root, findings, warnings, state) {
|
|
2298
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2299
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2300
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2301
|
+
const rel = `${base}.vscode/tasks.json`;
|
|
2302
|
+
if (existsSync(join(root, rel)) && claimFile(state, "tasks", rel, prefix !== "")) {
|
|
2303
|
+
const data = readJson(root, rel, warnings);
|
|
2304
|
+
if (data && data.tasks !== undefined && !Array.isArray(data.tasks)) {
|
|
2305
|
+
warnings.push(`tasks key in ${rel} is not an array: tasks skipped`);
|
|
2041
2306
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
//
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
if (!st.isFile())
|
|
2075
|
-
continue;
|
|
2076
|
-
if (name === "_" || name.startsWith("."))
|
|
2077
|
-
continue;
|
|
2078
|
-
const content = readTextWarn(fileAbs, `${dirRel}/${name}`, warnings);
|
|
2079
|
-
if (content === null)
|
|
2080
|
-
continue;
|
|
2081
|
-
const firstMeaningful = content.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("set -")).join(" ");
|
|
2082
|
-
const fileRel = `${dirRel}/${name}`;
|
|
2083
|
-
// Entire file content is the command surface for husky style hooks.
|
|
2084
|
-
// Classify generously deep into the file: a payload hidden behind
|
|
2085
|
-
// padding must still be seen. Display code truncates separately.
|
|
2086
|
-
findings.push(makeFinding(root, fileRel, source, name, content.trim().slice(0, 200000)));
|
|
2087
|
-
void firstMeaningful;
|
|
2088
|
-
}
|
|
2089
|
-
}
|
|
2090
|
-
function scanLefthook(root, findings, warnings) {
|
|
2091
|
-
for (const rel of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"]) {
|
|
2092
|
-
if (!existsSync(join(root, rel)))
|
|
2093
|
-
continue;
|
|
2094
|
-
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2095
|
-
const runRe = /^\s*run:\s*(.+)$/gm;
|
|
2096
|
-
let m;
|
|
2097
|
-
let i = 0;
|
|
2098
|
-
while ((m = runRe.exec(raw)) !== null) {
|
|
2099
|
-
findings.push(makeFinding(root, rel, "lefthook", "run", m[1].trim().replace(/^["']|["']$/g, ""), String(i++)));
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2102
|
-
}
|
|
2103
|
-
function scanPreCommit(root, findings, warnings) {
|
|
2104
|
-
const rel = ".pre-commit-config.yaml";
|
|
2105
|
-
if (!existsSync(join(root, rel)))
|
|
2106
|
-
return;
|
|
2107
|
-
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2108
|
-
const entryRe = /^\s*entry:\s*(.+)$/gm;
|
|
2109
|
-
let m;
|
|
2110
|
-
let i = 0;
|
|
2111
|
-
while ((m = entryRe.exec(raw)) !== null) {
|
|
2112
|
-
findings.push(makeFinding(root, rel, "pre-commit", "entry", m[1].trim().replace(/^["']|["']$/g, ""), String(i++)));
|
|
2113
|
-
}
|
|
2114
|
-
const repoRe = /^\s*-\s*repo:\s*(https?:\/\/\S+)$/gm;
|
|
2115
|
-
while ((m = repoRe.exec(raw)) !== null) {
|
|
2116
|
-
// A remote hook repo is the standard pre-commit pattern, not a finding
|
|
2117
|
-
// of compromise: the URL alone is inventoried as UNKNOWN so a human can
|
|
2118
|
-
// review the pinned rev. Classifying the bare URL with the command
|
|
2119
|
-
// rules would verdict every normal pre-commit config COMPROMISED.
|
|
2120
|
-
findings.push({
|
|
2121
|
-
id: `${rel}:remote-repo:${i++}`,
|
|
2122
|
-
file: rel,
|
|
2123
|
-
source: "pre-commit",
|
|
2124
|
-
hook: "remote-repo",
|
|
2125
|
-
command: m[1].trim(),
|
|
2126
|
-
classification: "UNKNOWN",
|
|
2127
|
-
rules: [],
|
|
2128
|
-
evidence: "remote pre-commit hook repo (standard pattern): its hooks run locally at commit time; hookcheck cannot verify remote contents, review the pinned rev",
|
|
2129
|
-
});
|
|
2130
|
-
}
|
|
2131
|
-
}
|
|
2132
|
-
function scanAgentDirs(root, findings, warnings) {
|
|
2133
|
-
for (const [dir, source] of [[".cursor", "cursor"], [".codex", "codex"]]) {
|
|
2134
|
-
const abs = join(root, dir);
|
|
2307
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
2308
|
+
tasks.forEach((rawTask, idx) => {
|
|
2309
|
+
// Malformed entries (null, strings, numbers) are skipped, never fatal:
|
|
2310
|
+
// one bad task must not hide every other surface in the repo.
|
|
2311
|
+
if (!rawTask || typeof rawTask !== "object")
|
|
2312
|
+
return;
|
|
2313
|
+
const task = rawTask;
|
|
2314
|
+
const runOptions = (task.runOptions ?? {});
|
|
2315
|
+
const options = (task.options ?? {});
|
|
2316
|
+
const optionsRunOptions = (options.runOptions ?? {});
|
|
2317
|
+
// folderOpen lives in runOptions in the standard schema, but real
|
|
2318
|
+
// world files also nest it under options or put runOn on the task.
|
|
2319
|
+
const runOn = runOptions.runOn ?? optionsRunOptions.runOn ?? task.runOn;
|
|
2320
|
+
const isFolderOpen = runOn === "folderOpen" || (Array.isArray(runOn) && runOn.includes("folderOpen"));
|
|
2321
|
+
const command = typeof task.command === "string" ? task.command : "";
|
|
2322
|
+
const args = Array.isArray(task.args) ? task.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
2323
|
+
const full = [command, args].filter(Boolean).join(" ").trim();
|
|
2324
|
+
if (!full)
|
|
2325
|
+
return;
|
|
2326
|
+
const label = typeof task.label === "string" ? task.label : `task-${idx}`;
|
|
2327
|
+
findings.push(makeFinding(root, rel, "vscode-tasks", isFolderOpen ? `folderOpen:${label}` : label, full, String(idx), undefined, baseAbs));
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
// MCP config inside .vscode
|
|
2331
|
+
scanMcpFile(root, `${base}.vscode/mcp.json`, findings, warnings, state, baseAbs, prefix !== "");
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
function scanDirFiles(root, dirName, source, family, findings, warnings, state) {
|
|
2335
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2336
|
+
const dirRel = prefix === "" ? dirName : `${prefix}/${dirName}`;
|
|
2337
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2338
|
+
const abs = join(root, dirRel);
|
|
2135
2339
|
if (!existsSync(abs))
|
|
2136
2340
|
continue;
|
|
2137
2341
|
let entries = [];
|
|
@@ -2139,6 +2343,7 @@ function scanAgentDirs(root, findings, warnings) {
|
|
|
2139
2343
|
entries = readdirSync(abs);
|
|
2140
2344
|
}
|
|
2141
2345
|
catch {
|
|
2346
|
+
warnings.push(`Unreadable directory skipped: ${dirRel} could not be listed; the scan is partial`);
|
|
2142
2347
|
continue;
|
|
2143
2348
|
}
|
|
2144
2349
|
for (const name of entries.sort()) {
|
|
@@ -2152,43 +2357,144 @@ function scanAgentDirs(root, findings, warnings) {
|
|
|
2152
2357
|
}
|
|
2153
2358
|
if (!st.isFile())
|
|
2154
2359
|
continue;
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2360
|
+
if (name === "_" || name.startsWith("."))
|
|
2361
|
+
continue;
|
|
2362
|
+
const fileRel = `${dirRel}/${name}`;
|
|
2363
|
+
if (!claimFile(state, family, fileRel, prefix !== ""))
|
|
2364
|
+
continue;
|
|
2365
|
+
const content = readTextWarn(fileAbs, fileRel, warnings);
|
|
2366
|
+
if (content === null)
|
|
2367
|
+
continue;
|
|
2368
|
+
// Entire file content is the command surface for husky style hooks.
|
|
2369
|
+
// Classify generously deep into the file: a payload hidden behind
|
|
2370
|
+
// padding must still be seen. Display code truncates separately.
|
|
2371
|
+
findings.push(makeFinding(root, fileRel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
function scanLefthook(root, findings, warnings, state) {
|
|
2376
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2377
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2378
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2379
|
+
for (const name of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"]) {
|
|
2380
|
+
const rel = `${base}${name}`;
|
|
2381
|
+
if (!existsSync(join(root, rel)))
|
|
2382
|
+
continue;
|
|
2383
|
+
if (!claimFile(state, "lefthook", rel, prefix !== ""))
|
|
2384
|
+
continue;
|
|
2385
|
+
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2386
|
+
const runRe = /^\s*run:\s*(.+)$/gm;
|
|
2387
|
+
let m;
|
|
2388
|
+
let i = 0;
|
|
2389
|
+
while ((m = runRe.exec(raw)) !== null) {
|
|
2390
|
+
findings.push(makeFinding(root, rel, "lefthook", "run", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
function scanPreCommit(root, findings, warnings, state) {
|
|
2396
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2397
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2398
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2399
|
+
const rel = `${base}.pre-commit-config.yaml`;
|
|
2400
|
+
if (!existsSync(join(root, rel)))
|
|
2401
|
+
continue;
|
|
2402
|
+
if (!claimFile(state, "pre-commit", rel, prefix !== ""))
|
|
2403
|
+
continue;
|
|
2404
|
+
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2405
|
+
const entryRe = /^\s*entry:\s*(.+)$/gm;
|
|
2406
|
+
let m;
|
|
2407
|
+
let i = 0;
|
|
2408
|
+
while ((m = entryRe.exec(raw)) !== null) {
|
|
2409
|
+
findings.push(makeFinding(root, rel, "pre-commit", "entry", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
|
|
2410
|
+
}
|
|
2411
|
+
const repoRe = /^\s*-\s*repo:\s*(https?:\/\/\S+)$/gm;
|
|
2412
|
+
while ((m = repoRe.exec(raw)) !== null) {
|
|
2413
|
+
// A remote hook repo is the standard pre-commit pattern, not a finding
|
|
2414
|
+
// of compromise: the URL alone is inventoried as UNKNOWN so a human can
|
|
2415
|
+
// review the pinned rev. Classifying the bare URL with the command
|
|
2416
|
+
// rules would verdict every normal pre-commit config COMPROMISED.
|
|
2417
|
+
findings.push({
|
|
2418
|
+
id: `${rel}:remote-repo:${i++}`,
|
|
2419
|
+
file: rel,
|
|
2420
|
+
source: "pre-commit",
|
|
2421
|
+
hook: "remote-repo",
|
|
2422
|
+
command: m[1].trim(),
|
|
2423
|
+
classification: "UNKNOWN",
|
|
2424
|
+
rules: [],
|
|
2425
|
+
evidence: "remote pre-commit hook repo (standard pattern): its hooks run locally at commit time; hookcheck cannot verify remote contents, review the pinned rev",
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
function scanAgentDirs(root, findings, warnings, state) {
|
|
2431
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2432
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2433
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2434
|
+
for (const [dir, source] of [[".cursor", "cursor"], [".codex", "codex"]]) {
|
|
2435
|
+
const abs = join(root, base, dir);
|
|
2436
|
+
if (!existsSync(abs))
|
|
2437
|
+
continue;
|
|
2438
|
+
let entries = [];
|
|
2439
|
+
try {
|
|
2440
|
+
entries = readdirSync(abs);
|
|
2441
|
+
}
|
|
2442
|
+
catch {
|
|
2161
2443
|
continue;
|
|
2162
|
-
if (name.endsWith(".json")) {
|
|
2163
|
-
const data = readJson(root, rel, warnings);
|
|
2164
|
-
if (data)
|
|
2165
|
-
collectGenericCommands(data, rel, root, findings, source);
|
|
2166
2444
|
}
|
|
2167
|
-
|
|
2168
|
-
const
|
|
2169
|
-
|
|
2170
|
-
|
|
2445
|
+
for (const name of entries.sort()) {
|
|
2446
|
+
const fileAbs = join(abs, name);
|
|
2447
|
+
let st;
|
|
2448
|
+
try {
|
|
2449
|
+
st = statSync(fileAbs);
|
|
2450
|
+
}
|
|
2451
|
+
catch {
|
|
2452
|
+
continue;
|
|
2453
|
+
}
|
|
2454
|
+
if (!st.isFile())
|
|
2455
|
+
continue;
|
|
2456
|
+
const rel = `${base}${dir}/${name}`;
|
|
2457
|
+
// mcp.json is an MCP config surface, inventoried exactly once by
|
|
2458
|
+
// the central scanMcpFile pass in scanHooks (P-035). Running it
|
|
2459
|
+
// through the generic command walk here as well counted every
|
|
2460
|
+
// .cursor/mcp.json server twice.
|
|
2461
|
+
if (name === "mcp.json")
|
|
2462
|
+
continue;
|
|
2463
|
+
if (!claimFile(state, "agent", rel, prefix !== ""))
|
|
2464
|
+
continue;
|
|
2465
|
+
if (name.endsWith(".json")) {
|
|
2466
|
+
const data = readJson(root, rel, warnings);
|
|
2467
|
+
if (data)
|
|
2468
|
+
collectGenericCommands(data, rel, root, findings, source, "", baseAbs);
|
|
2469
|
+
}
|
|
2470
|
+
else {
|
|
2471
|
+
const content = readTextWarn(fileAbs, rel, warnings);
|
|
2472
|
+
if (content && content.trim()) {
|
|
2473
|
+
findings.push(makeFinding(root, rel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
|
|
2474
|
+
}
|
|
2171
2475
|
}
|
|
2172
2476
|
}
|
|
2173
2477
|
}
|
|
2174
2478
|
}
|
|
2175
2479
|
}
|
|
2176
|
-
function collectGenericCommands(node, file, root, findings, source, prefix = "") {
|
|
2480
|
+
function collectGenericCommands(node, file, root, findings, source, prefix = "", baseAbs = root) {
|
|
2177
2481
|
if (!node || typeof node !== "object")
|
|
2178
2482
|
return;
|
|
2179
2483
|
const obj = node;
|
|
2180
2484
|
if (typeof obj.command === "string" && obj.command.trim()) {
|
|
2181
2485
|
const args = Array.isArray(obj.args) ? obj.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
2182
|
-
findings.push(makeFinding(root, file, source, prefix || "command", [obj.command, args].filter(Boolean).join(" "), String(findings.length)));
|
|
2486
|
+
findings.push(makeFinding(root, file, source, prefix || "command", [obj.command, args].filter(Boolean).join(" "), String(findings.length), undefined, baseAbs));
|
|
2183
2487
|
}
|
|
2184
2488
|
for (const [key, value] of Object.entries(obj)) {
|
|
2185
2489
|
if (value && typeof value === "object")
|
|
2186
|
-
collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key);
|
|
2490
|
+
collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key, baseAbs);
|
|
2187
2491
|
}
|
|
2188
2492
|
}
|
|
2189
|
-
function scanMcpFile(root, rel, findings, warnings) {
|
|
2493
|
+
function scanMcpFile(root, rel, findings, warnings, state, baseAbs, isNested) {
|
|
2190
2494
|
if (!existsSync(join(root, rel)))
|
|
2191
2495
|
return;
|
|
2496
|
+
if (!claimFile(state, "mcp", rel, isNested))
|
|
2497
|
+
return;
|
|
2192
2498
|
const data = readJson(root, rel, warnings);
|
|
2193
2499
|
if (!data)
|
|
2194
2500
|
return;
|
|
@@ -2203,9 +2509,9 @@ function scanMcpFile(root, rel, findings, warnings) {
|
|
|
2203
2509
|
const args = Array.isArray(c.args) ? c.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
2204
2510
|
const full = [command, args].filter(Boolean).join(" ").trim();
|
|
2205
2511
|
if (full)
|
|
2206
|
-
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full));
|
|
2512
|
+
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full, undefined, undefined, baseAbs));
|
|
2207
2513
|
if (typeof c.url === "string" && c.url) {
|
|
2208
|
-
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url));
|
|
2514
|
+
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url, undefined, undefined, baseAbs));
|
|
2209
2515
|
}
|
|
2210
2516
|
}
|
|
2211
2517
|
}
|
|
@@ -2233,26 +2539,37 @@ function scanMcpFile(root, rel, findings, warnings) {
|
|
|
2233
2539
|
* resolved is still inventoried, as the literal alias text at UNKNOWN,
|
|
2234
2540
|
* so no executable step ever yields zero findings.
|
|
2235
2541
|
*/
|
|
2236
|
-
function scanWorkflows(root, findings, warnings) {
|
|
2237
|
-
const
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
entries = readdirSync(abs);
|
|
2244
|
-
}
|
|
2245
|
-
catch {
|
|
2246
|
-
warnings.push(`Unreadable directory skipped: ${dir} could not be listed; the scan is partial`);
|
|
2247
|
-
return;
|
|
2248
|
-
}
|
|
2249
|
-
for (const name of entries.sort()) {
|
|
2250
|
-
if (!/\.(yml|yaml)$/.test(name))
|
|
2542
|
+
function scanWorkflows(root, findings, warnings, state) {
|
|
2543
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2544
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2545
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2546
|
+
const dir = `${base}.github/workflows`;
|
|
2547
|
+
const abs = join(root, dir);
|
|
2548
|
+
if (!existsSync(abs))
|
|
2251
2549
|
continue;
|
|
2252
|
-
|
|
2550
|
+
let entries = [];
|
|
2551
|
+
try {
|
|
2552
|
+
entries = readdirSync(abs);
|
|
2553
|
+
}
|
|
2554
|
+
catch {
|
|
2555
|
+
warnings.push(`Unreadable directory skipped: ${dir} could not be listed; the scan is partial`);
|
|
2556
|
+
continue;
|
|
2557
|
+
}
|
|
2558
|
+
for (const name of entries.sort()) {
|
|
2559
|
+
if (!/\.(yml|yaml)$/.test(name))
|
|
2560
|
+
continue;
|
|
2561
|
+
const rel = `${dir}/${name}`;
|
|
2562
|
+
if (!claimFile(state, "workflows", rel, prefix !== ""))
|
|
2563
|
+
continue;
|
|
2564
|
+
scanOneWorkflow(root, rel, baseAbs, findings, warnings);
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
function scanOneWorkflow(root, rel, baseAbs, findings, warnings) {
|
|
2569
|
+
{
|
|
2253
2570
|
const rawFile = readTextWarn(join(root, rel), rel, warnings);
|
|
2254
2571
|
if (rawFile === null)
|
|
2255
|
-
|
|
2572
|
+
return;
|
|
2256
2573
|
const raw = rawFile.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2257
2574
|
const lines = raw.split("\n");
|
|
2258
2575
|
const indentOf = (l) => l.length - l.trimStart().length;
|
|
@@ -2344,14 +2661,14 @@ function scanWorkflows(root, findings, warnings) {
|
|
|
2344
2661
|
if (aliasStep) {
|
|
2345
2662
|
const name = aliasStep[1];
|
|
2346
2663
|
const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
|
|
2347
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
|
|
2664
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
|
|
2348
2665
|
continue;
|
|
2349
2666
|
}
|
|
2350
2667
|
const mergeKey = line.match(/^\s*<<:\s*\*([A-Za-z0-9_-]+)\s*(#.*)?$/);
|
|
2351
2668
|
if (mergeKey) {
|
|
2352
2669
|
const name = mergeKey[1];
|
|
2353
2670
|
const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
|
|
2354
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
|
|
2671
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
|
|
2355
2672
|
continue;
|
|
2356
2673
|
}
|
|
2357
2674
|
const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
|
|
@@ -2374,19 +2691,19 @@ function scanWorkflows(root, findings, warnings) {
|
|
|
2374
2691
|
if (blockHeader) {
|
|
2375
2692
|
const { body, end } = collectBlock(idx, runIndent);
|
|
2376
2693
|
if (body) {
|
|
2377
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun));
|
|
2694
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun, baseAbs));
|
|
2378
2695
|
}
|
|
2379
2696
|
idx = end - 1;
|
|
2380
2697
|
continue;
|
|
2381
2698
|
}
|
|
2382
2699
|
if (rest === "")
|
|
2383
2700
|
continue;
|
|
2384
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun));
|
|
2701
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun, baseAbs));
|
|
2385
2702
|
}
|
|
2386
2703
|
}
|
|
2387
2704
|
}
|
|
2388
|
-
function scanGitHooks(root, findings, warnings) {
|
|
2389
|
-
scanDirFiles(root, ".githooks", "githooks", findings, warnings);
|
|
2705
|
+
function scanGitHooks(root, findings, warnings, state) {
|
|
2706
|
+
scanDirFiles(root, ".githooks", "githooks", "githooks", findings, warnings, state);
|
|
2390
2707
|
}
|
|
2391
2708
|
/**
|
|
2392
2709
|
* Empty estate note (P-014, hookcheck half). A scan that inventories
|
|
@@ -2423,21 +2740,44 @@ export function scanHooks(root) {
|
|
|
2423
2740
|
note: EMPTY_ESTATE_NOTE,
|
|
2424
2741
|
};
|
|
2425
2742
|
}
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2743
|
+
const disc = discoverSurfaces(root, warnings);
|
|
2744
|
+
const state = { disc, inventoried: new Set(), familyFound: new Map(), familyCapped: new Map(), partial: false };
|
|
2745
|
+
scanPackageJson(root, findings, warnings, state);
|
|
2746
|
+
scanClaude(root, findings, warnings, state);
|
|
2747
|
+
scanPluginHooks(root, findings, warnings, state);
|
|
2748
|
+
scanVscode(root, findings, warnings, state);
|
|
2749
|
+
scanDirFiles(root, ".husky", "husky", "husky", findings, warnings, state);
|
|
2750
|
+
scanLefthook(root, findings, warnings, state);
|
|
2751
|
+
scanPreCommit(root, findings, warnings, state);
|
|
2752
|
+
scanAgentDirs(root, findings, warnings, state);
|
|
2753
|
+
// MCP config surfaces, each inventoried exactly once (P-035), at the
|
|
2754
|
+
// scan root and at every nested project root (P-065).
|
|
2435
2755
|
// .windsurf/mcp.json joined the covered surfaces in v0.1.6 (P-034);
|
|
2436
2756
|
// .vscode/mcp.json is handled inside scanVscode.
|
|
2437
|
-
for (const
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2757
|
+
for (const prefix of disc.projectRoots) {
|
|
2758
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2759
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2760
|
+
for (const name of [".mcp.json", "mcp.json", ".cursor/mcp.json", ".windsurf/mcp.json", ".codex/mcp.json"]) {
|
|
2761
|
+
scanMcpFile(root, `${base}${name}`, findings, warnings, state, baseAbs, prefix !== "");
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
scanWorkflows(root, findings, warnings, state);
|
|
2765
|
+
scanGitHooks(root, findings, warnings, state);
|
|
2766
|
+
// Bounded scan honesty (P-065): a scan cut short by the discovery
|
|
2767
|
+
// bounds must say so, naming the bound, instead of presenting as
|
|
2768
|
+
// complete. Surfaces spotted just below the depth bound are counted
|
|
2769
|
+
// in the warning, never silently dropped.
|
|
2770
|
+
if (disc.depthBoundHit) {
|
|
2771
|
+
const below = disc.surfacesBelowBound > 0
|
|
2772
|
+
? `; at least ${disc.surfacesBelowBound} of the directories just below the bound contain hook surface files or config directories that were not inventoried`
|
|
2773
|
+
: "";
|
|
2774
|
+
warnings.push(`Nested discovery bounded at depth ${NESTED_MAX_DEPTH}: project directories more than ${NESTED_MAX_DEPTH} levels below the scan root were not scanned (${disc.frontierDirs} ${disc.frontierDirs === 1 ? "directory" : "directories"} below the bound not entered${below}); the scan is partial (bounded)`);
|
|
2775
|
+
}
|
|
2776
|
+
for (const [family, capped] of state.familyCapped) {
|
|
2777
|
+
const label = FAMILY_LABELS[family] ?? family;
|
|
2778
|
+
warnings.push(`Nested ${label} inventory capped at ${NESTED_MAX_FILES_PER_SURFACE} files (${state.familyFound.get(family) ?? 0} found); the remaining nested ${label} files were not inventoried and the scan is partial`);
|
|
2779
|
+
void capped;
|
|
2780
|
+
}
|
|
2441
2781
|
// Dedupe identical ids (agent dir scan may double count mcp.json).
|
|
2442
2782
|
const seen = new Set();
|
|
2443
2783
|
const deduped = findings.filter((f) => {
|
|
@@ -2453,12 +2793,14 @@ export function scanHooks(root) {
|
|
|
2453
2793
|
risky: deduped.filter((f) => f.classification === "RISKY").length,
|
|
2454
2794
|
unknown: deduped.filter((f) => f.classification === "UNKNOWN").length,
|
|
2455
2795
|
};
|
|
2456
|
-
// Partial scan floor (P-049): when any inventoried
|
|
2457
|
-
// could not be read,
|
|
2458
|
-
//
|
|
2459
|
-
//
|
|
2796
|
+
// Partial scan floor (P-049, extended by P-065): when any inventoried
|
|
2797
|
+
// file or directory could not be read, when a file cap left surface
|
|
2798
|
+
// files uninventoried, or when hook surfaces were spotted just below
|
|
2799
|
+
// the discovery depth bound, the scan did not see the whole estate,
|
|
2800
|
+
// so a bare CLEAN is not an honest verdict. Floor at REVIEW; the
|
|
2801
|
+
// warnings name exactly what was missed.
|
|
2460
2802
|
let verdict = computeVerdict(deduped);
|
|
2461
|
-
if (verdict === "CLEAN" && warnings.some((w) => w.startsWith("Unreadable"))) {
|
|
2803
|
+
if (verdict === "CLEAN" && (warnings.some((w) => w.startsWith("Unreadable")) || state.partial || disc.surfacesBelowBound > 0)) {
|
|
2462
2804
|
verdict = "REVIEW";
|
|
2463
2805
|
}
|
|
2464
2806
|
return {
|