@reneturcios/hookcheck 0.1.7 → 0.1.8
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 +3 -2
- package/dist/core.d.ts +43 -36
- package/dist/core.js +456 -309
- 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.8";
|
|
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 },
|
|
@@ -1763,21 +1763,54 @@ function scanOnePackageJson(root, rel, findings, warnings) {
|
|
|
1763
1763
|
}
|
|
1764
1764
|
}
|
|
1765
1765
|
/**
|
|
1766
|
-
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
*
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1766
|
+
* Unified nested discovery (P-032, P-060, P-065). Every surface the
|
|
1767
|
+
* tool inventories at the scan root is also inventoried when it
|
|
1768
|
+
* appears inside a subdirectory: before v0.1.8 only nested package.json
|
|
1769
|
+
* files (P-032) and plugin hooks files (P-060) were recursed, so a
|
|
1770
|
+
* parent scan of a monorepo or workspace silently missed nested
|
|
1771
|
+
* .claude/settings.json, .vscode/tasks.json, workflows, husky and
|
|
1772
|
+
* githooks directories, lefthook and pre-commit configs, and MCP
|
|
1773
|
+
* configs, and a tree holding only nested surfaces scanned CLEAN with
|
|
1774
|
+
* the empty estate note attached (P-065).
|
|
1775
|
+
*
|
|
1776
|
+
* One bounded walk enumerates every project root (each directory at
|
|
1777
|
+
* most NESTED_MAX_DEPTH levels below the scan root, the scan root
|
|
1778
|
+
* itself included) plus the nested package.json and plugin hooks file
|
|
1779
|
+
* lists. Per surface scanners then run once per project root with the
|
|
1780
|
+
* root prefix carried into every finding file path, so findings carry
|
|
1781
|
+
* their real relative paths. The walk is bounded so a hostile or
|
|
1782
|
+
* giant tree cannot explode the report: directories are visited in
|
|
1783
|
+
* sorted order, node_modules, .git, and dot directories are never
|
|
1784
|
+
* entered, symlinked directories are never followed (a symlink loop
|
|
1785
|
+
* cannot hang the scan), and each surface family inventories at most
|
|
1786
|
+
* NESTED_MAX_FILES_PER_SURFACE nested files, with the cap reported as
|
|
1787
|
+
* a warning when it is hit. When directories remain below the depth
|
|
1788
|
+
* bound the walk says so in a warning naming the bound, and it probes
|
|
1789
|
+
* the directories just below the bound (names only, no file reads) so
|
|
1790
|
+
* surfaces sitting there are counted in the warning instead of being
|
|
1791
|
+
* silently dropped.
|
|
1776
1792
|
*/
|
|
1777
|
-
export const
|
|
1778
|
-
export const
|
|
1779
|
-
|
|
1780
|
-
|
|
1793
|
+
export const NESTED_MAX_DEPTH = 4;
|
|
1794
|
+
export const NESTED_MAX_FILES_PER_SURFACE = 200;
|
|
1795
|
+
export const PACKAGE_MAX_DEPTH = NESTED_MAX_DEPTH;
|
|
1796
|
+
export const PACKAGE_MAX_NESTED = NESTED_MAX_FILES_PER_SURFACE;
|
|
1797
|
+
export const PLUGIN_HOOKS_MAX_DEPTH = NESTED_MAX_DEPTH;
|
|
1798
|
+
export const PLUGIN_HOOKS_MAX_FILES = NESTED_MAX_FILES_PER_SURFACE;
|
|
1799
|
+
const SURFACE_FILE_INDICATORS = new Set([
|
|
1800
|
+
"package.json", "lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml",
|
|
1801
|
+
".pre-commit-config.yaml", ".pre-commit-config.yml", ".mcp.json", "mcp.json",
|
|
1802
|
+
]);
|
|
1803
|
+
const SURFACE_DIR_INDICATORS = new Set([
|
|
1804
|
+
".claude", "claude", ".vscode", ".github", ".husky", ".githooks",
|
|
1805
|
+
"hooks", ".cursor", ".windsurf", ".codex",
|
|
1806
|
+
]);
|
|
1807
|
+
function discoverSurfaces(root, warnings) {
|
|
1808
|
+
const projectRoots = [];
|
|
1809
|
+
const packageFiles = [];
|
|
1810
|
+
const pluginSet = new Set();
|
|
1811
|
+
let frontierDirs = 0;
|
|
1812
|
+
let surfacesBelowBound = 0;
|
|
1813
|
+
let probes = 0;
|
|
1781
1814
|
const skipDirs = new Set(["node_modules", ".git"]);
|
|
1782
1815
|
const stack = [{ abs: root, rel: "", depth: 0 }];
|
|
1783
1816
|
while (stack.length > 0) {
|
|
@@ -1792,14 +1825,38 @@ function discoverNestedPackageJson(root, warnings) {
|
|
|
1792
1825
|
continue;
|
|
1793
1826
|
}
|
|
1794
1827
|
entries.sort();
|
|
1828
|
+
projectRoots.push(cur.rel);
|
|
1795
1829
|
if (cur.depth >= 1 && entries.includes("package.json")) {
|
|
1796
|
-
|
|
1830
|
+
packageFiles.push(`${cur.rel}/package.json`);
|
|
1831
|
+
}
|
|
1832
|
+
// Plugin hooks files sit in a hooks directory one level below the
|
|
1833
|
+
// plugin root, so they are spotted from the plugin root listing
|
|
1834
|
+
// (the hooks directory itself can sit at depth bound plus one).
|
|
1835
|
+
if (entries.includes("hooks")) {
|
|
1836
|
+
const hooksAbs = join(cur.abs, "hooks");
|
|
1837
|
+
let lst = null;
|
|
1838
|
+
try {
|
|
1839
|
+
lst = lstatSync(hooksAbs);
|
|
1840
|
+
}
|
|
1841
|
+
catch {
|
|
1842
|
+
lst = null;
|
|
1843
|
+
}
|
|
1844
|
+
if (lst && lst.isDirectory() && !lst.isSymbolicLink()) {
|
|
1845
|
+
let hookEntries = [];
|
|
1846
|
+
try {
|
|
1847
|
+
hookEntries = readdirSync(hooksAbs);
|
|
1848
|
+
}
|
|
1849
|
+
catch {
|
|
1850
|
+
hookEntries = [];
|
|
1851
|
+
}
|
|
1852
|
+
for (const name of ["hooks.json", "hooks-cursor.json"]) {
|
|
1853
|
+
if (hookEntries.includes(name))
|
|
1854
|
+
pluginSet.add(cur.rel === "" ? `hooks/${name}` : `${cur.rel}/hooks/${name}`);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1797
1857
|
}
|
|
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];
|
|
1858
|
+
const children = [];
|
|
1859
|
+
for (const name of entries) {
|
|
1803
1860
|
if (skipDirs.has(name) || name.startsWith("."))
|
|
1804
1861
|
continue;
|
|
1805
1862
|
const childAbs = join(cur.abs, name);
|
|
@@ -1812,19 +1869,84 @@ function discoverNestedPackageJson(root, warnings) {
|
|
|
1812
1869
|
}
|
|
1813
1870
|
if (lst.isSymbolicLink() || !lst.isDirectory())
|
|
1814
1871
|
continue;
|
|
1815
|
-
|
|
1872
|
+
children.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
|
|
1816
1873
|
}
|
|
1874
|
+
if (cur.depth >= NESTED_MAX_DEPTH) {
|
|
1875
|
+
frontierDirs += children.length;
|
|
1876
|
+
for (const child of children) {
|
|
1877
|
+
if (probes >= 1000)
|
|
1878
|
+
break;
|
|
1879
|
+
probes++;
|
|
1880
|
+
let sub = [];
|
|
1881
|
+
try {
|
|
1882
|
+
sub = readdirSync(child.abs);
|
|
1883
|
+
}
|
|
1884
|
+
catch {
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
if (sub.some((n) => SURFACE_FILE_INDICATORS.has(n) || SURFACE_DIR_INDICATORS.has(n)))
|
|
1888
|
+
surfacesBelowBound++;
|
|
1889
|
+
}
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
// Push in reverse so the stack pops in sorted order.
|
|
1893
|
+
for (let i = children.length - 1; i >= 0; i--)
|
|
1894
|
+
stack.push(children[i]);
|
|
1817
1895
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1896
|
+
projectRoots.sort((a, b) => (a === b ? 0 : a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1));
|
|
1897
|
+
packageFiles.sort();
|
|
1898
|
+
return {
|
|
1899
|
+
projectRoots,
|
|
1900
|
+
packageFiles,
|
|
1901
|
+
pluginHookFiles: Array.from(pluginSet).sort(),
|
|
1902
|
+
frontierDirs,
|
|
1903
|
+
surfacesBelowBound,
|
|
1904
|
+
depthBoundHit: frontierDirs > 0,
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
const FAMILY_LABELS = {
|
|
1908
|
+
settings: "Claude settings",
|
|
1909
|
+
tasks: "VS Code tasks",
|
|
1910
|
+
workflows: "workflow",
|
|
1911
|
+
husky: "husky hook",
|
|
1912
|
+
githooks: "githooks hook",
|
|
1913
|
+
lefthook: "lefthook config",
|
|
1914
|
+
"pre-commit": "pre-commit config",
|
|
1915
|
+
mcp: "MCP config",
|
|
1916
|
+
agent: "agent config",
|
|
1917
|
+
};
|
|
1918
|
+
/**
|
|
1919
|
+
* Claim one surface file for inventory. Returns false when the file
|
|
1920
|
+
* was already inventoried through another discovery path, or when the
|
|
1921
|
+
* nested file cap for its family is already spent (the skip is
|
|
1922
|
+
* counted, and scanHooks warns once per capped family).
|
|
1923
|
+
*/
|
|
1924
|
+
function claimFile(state, family, rel, isNested) {
|
|
1925
|
+
if (state.inventoried.has(rel))
|
|
1926
|
+
return false;
|
|
1927
|
+
if (isNested) {
|
|
1928
|
+
const found = (state.familyFound.get(family) ?? 0) + 1;
|
|
1929
|
+
state.familyFound.set(family, found);
|
|
1930
|
+
if (found > NESTED_MAX_FILES_PER_SURFACE) {
|
|
1931
|
+
state.familyCapped.set(family, (state.familyCapped.get(family) ?? 0) + 1);
|
|
1932
|
+
state.partial = true;
|
|
1933
|
+
return false;
|
|
1934
|
+
}
|
|
1822
1935
|
}
|
|
1823
|
-
|
|
1936
|
+
state.inventoried.add(rel);
|
|
1937
|
+
return true;
|
|
1824
1938
|
}
|
|
1825
|
-
function scanPackageJson(root, findings, warnings) {
|
|
1939
|
+
function scanPackageJson(root, findings, warnings, state) {
|
|
1826
1940
|
scanOnePackageJson(root, "package.json", findings, warnings);
|
|
1827
|
-
|
|
1941
|
+
state.inventoried.add("package.json");
|
|
1942
|
+
let nested = state.disc.packageFiles;
|
|
1943
|
+
if (nested.length > PACKAGE_MAX_NESTED) {
|
|
1944
|
+
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`);
|
|
1945
|
+
state.partial = true;
|
|
1946
|
+
nested = nested.slice(0, PACKAGE_MAX_NESTED);
|
|
1947
|
+
}
|
|
1948
|
+
for (const rel of nested) {
|
|
1949
|
+
state.inventoried.add(rel);
|
|
1828
1950
|
scanOnePackageJson(root, rel, findings, warnings);
|
|
1829
1951
|
}
|
|
1830
1952
|
}
|
|
@@ -1850,16 +1972,22 @@ function collectClaudeHooks(node, file, root, findings, prefix, source = "claude
|
|
|
1850
1972
|
* the GNU stow layout used by real dotfiles repos (claude/settings.json,
|
|
1851
1973
|
* no dot), and a settings file nested one level under a config dir
|
|
1852
1974
|
* (config/.claude/settings.json). A settings path that exists as a
|
|
1853
|
-
* directory is reported as a warning, never silently skipped.
|
|
1975
|
+
* directory is reported as a warning, never silently skipped. Since
|
|
1976
|
+
* v0.1.8 (P-065) the candidates are computed per project root, so the
|
|
1977
|
+
* same locations are inventoried in every subdirectory within the
|
|
1978
|
+
* discovery bound; the inventoried set in ScanState keeps a file
|
|
1979
|
+
* reachable by two paths (a parent root one level candidate and its
|
|
1980
|
+
* own project root) counted exactly once.
|
|
1854
1981
|
*/
|
|
1855
|
-
function claudeSettingsCandidates(root) {
|
|
1982
|
+
function claudeSettingsCandidates(root, prefix) {
|
|
1983
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
1856
1984
|
const rels = [
|
|
1857
|
-
|
|
1858
|
-
|
|
1985
|
+
`${base}.claude/settings.json`, `${base}.claude/settings.local.json`,
|
|
1986
|
+
`${base}claude/settings.json`, `${base}claude/settings.local.json`,
|
|
1859
1987
|
];
|
|
1860
1988
|
let entries = [];
|
|
1861
1989
|
try {
|
|
1862
|
-
entries = readdirSync(root);
|
|
1990
|
+
entries = readdirSync(join(root, prefix));
|
|
1863
1991
|
}
|
|
1864
1992
|
catch {
|
|
1865
1993
|
entries = [];
|
|
@@ -1869,7 +1997,7 @@ function claudeSettingsCandidates(root) {
|
|
|
1869
1997
|
continue;
|
|
1870
1998
|
let st;
|
|
1871
1999
|
try {
|
|
1872
|
-
st = statSync(join(root, name));
|
|
2000
|
+
st = statSync(join(root, prefix, name));
|
|
1873
2001
|
}
|
|
1874
2002
|
catch {
|
|
1875
2003
|
continue;
|
|
@@ -1877,42 +2005,50 @@ function claudeSettingsCandidates(root) {
|
|
|
1877
2005
|
if (!st.isDirectory())
|
|
1878
2006
|
continue;
|
|
1879
2007
|
for (const f of ["settings.json", "settings.local.json"]) {
|
|
1880
|
-
rels.push(`${name}/.claude/${f}`);
|
|
2008
|
+
rels.push(`${base}${name}/.claude/${f}`);
|
|
1881
2009
|
}
|
|
1882
2010
|
}
|
|
1883
2011
|
return Array.from(new Set(rels));
|
|
1884
2012
|
}
|
|
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
|
-
|
|
2013
|
+
function scanClaude(root, findings, warnings, state) {
|
|
2014
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2015
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2016
|
+
for (const rel of claudeSettingsCandidates(root, prefix)) {
|
|
2017
|
+
const abs = join(root, rel);
|
|
2018
|
+
if (!existsSync(abs))
|
|
2019
|
+
continue;
|
|
2020
|
+
let st;
|
|
2021
|
+
try {
|
|
2022
|
+
st = statSync(abs);
|
|
2023
|
+
}
|
|
2024
|
+
catch {
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
if (st.isDirectory()) {
|
|
2028
|
+
if (!state.inventoried.has(rel)) {
|
|
2029
|
+
state.inventoried.add(rel);
|
|
2030
|
+
warnings.push(`${rel} is a directory, not a settings file: any hooks inside it cannot be parsed as Claude settings`);
|
|
2031
|
+
}
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
if (!st.isFile())
|
|
2035
|
+
continue;
|
|
2036
|
+
if (!claimFile(state, "settings", rel, prefix !== ""))
|
|
2037
|
+
continue;
|
|
2038
|
+
const data = readJson(root, rel, warnings);
|
|
2039
|
+
if (!data || typeof data !== "object")
|
|
2040
|
+
continue;
|
|
2041
|
+
if (!("hooks" in data)) {
|
|
2042
|
+
warnings.push(`No hooks key in ${rel}`);
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
const hooks = data.hooks;
|
|
2046
|
+
if (!hooks || typeof hooks !== "object") {
|
|
2047
|
+
warnings.push(`hooks key in ${rel} is not an object: hooks skipped`);
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
collectClaudeHooks(hooks, rel, root, findings, "", "claude-settings", baseAbs);
|
|
1914
2051
|
}
|
|
1915
|
-
collectClaudeHooks(hooks, rel, root, findings, "");
|
|
1916
2052
|
}
|
|
1917
2053
|
}
|
|
1918
2054
|
/**
|
|
@@ -1933,67 +2069,21 @@ function scanClaude(root, findings, warnings) {
|
|
|
1933
2069
|
* command object shape under Cursor event names, so it is inventoried
|
|
1934
2070
|
* the same way.
|
|
1935
2071
|
*
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
1941
|
-
* level below its plugin root.
|
|
2072
|
+
* Discovery is part of the unified bounded walk (P-065): the file
|
|
2073
|
+
* list comes from discoverSurfaces, with the plugin root at most
|
|
2074
|
+
* PLUGIN_HOOKS_MAX_DEPTH directories below the scan root (the hooks
|
|
2075
|
+
* directory itself sits one level below its plugin root), and the
|
|
2076
|
+
* same cap with a warning applies.
|
|
1942
2077
|
*/
|
|
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)) {
|
|
2078
|
+
function scanPluginHooks(root, findings, warnings, state) {
|
|
2079
|
+
let files = state.disc.pluginHookFiles;
|
|
2080
|
+
if (files.length > PLUGIN_HOOKS_MAX_FILES) {
|
|
2081
|
+
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`);
|
|
2082
|
+
state.partial = true;
|
|
2083
|
+
files = files.slice(0, PLUGIN_HOOKS_MAX_FILES);
|
|
2084
|
+
}
|
|
2085
|
+
for (const rel of files) {
|
|
2086
|
+
state.inventoried.add(rel);
|
|
1997
2087
|
const data = readJson(root, rel, warnings);
|
|
1998
2088
|
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
1999
2089
|
continue;
|
|
@@ -2009,129 +2099,48 @@ function scanPluginHooks(root, findings, warnings) {
|
|
|
2009
2099
|
collectClaudeHooks(eventsNode, rel, root, findings, "", "claude-plugin", pluginRootAbs);
|
|
2010
2100
|
}
|
|
2011
2101
|
}
|
|
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)));
|
|
2041
|
-
}
|
|
2042
|
-
else {
|
|
2043
|
-
// Inventory non auto-run tasks at lower weight: still record them so
|
|
2044
|
-
// explain can find them, classified by their command.
|
|
2045
|
-
const f = makeFinding(root, rel, "vscode-tasks", label, full, String(idx));
|
|
2046
|
-
findings.push(f);
|
|
2102
|
+
function scanVscode(root, findings, warnings, state) {
|
|
2103
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2104
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2105
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2106
|
+
const rel = `${base}.vscode/tasks.json`;
|
|
2107
|
+
if (existsSync(join(root, rel)) && claimFile(state, "tasks", rel, prefix !== "")) {
|
|
2108
|
+
const data = readJson(root, rel, warnings);
|
|
2109
|
+
if (data && data.tasks !== undefined && !Array.isArray(data.tasks)) {
|
|
2110
|
+
warnings.push(`tasks key in ${rel} is not an array: tasks skipped`);
|
|
2047
2111
|
}
|
|
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
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
const
|
|
2079
|
-
|
|
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);
|
|
2112
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
2113
|
+
tasks.forEach((rawTask, idx) => {
|
|
2114
|
+
// Malformed entries (null, strings, numbers) are skipped, never fatal:
|
|
2115
|
+
// one bad task must not hide every other surface in the repo.
|
|
2116
|
+
if (!rawTask || typeof rawTask !== "object")
|
|
2117
|
+
return;
|
|
2118
|
+
const task = rawTask;
|
|
2119
|
+
const runOptions = (task.runOptions ?? {});
|
|
2120
|
+
const options = (task.options ?? {});
|
|
2121
|
+
const optionsRunOptions = (options.runOptions ?? {});
|
|
2122
|
+
// folderOpen lives in runOptions in the standard schema, but real
|
|
2123
|
+
// world files also nest it under options or put runOn on the task.
|
|
2124
|
+
const runOn = runOptions.runOn ?? optionsRunOptions.runOn ?? task.runOn;
|
|
2125
|
+
const isFolderOpen = runOn === "folderOpen" || (Array.isArray(runOn) && runOn.includes("folderOpen"));
|
|
2126
|
+
const command = typeof task.command === "string" ? task.command : "";
|
|
2127
|
+
const args = Array.isArray(task.args) ? task.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
2128
|
+
const full = [command, args].filter(Boolean).join(" ").trim();
|
|
2129
|
+
if (!full)
|
|
2130
|
+
return;
|
|
2131
|
+
const label = typeof task.label === "string" ? task.label : `task-${idx}`;
|
|
2132
|
+
findings.push(makeFinding(root, rel, "vscode-tasks", isFolderOpen ? `folderOpen:${label}` : label, full, String(idx), undefined, baseAbs));
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
// MCP config inside .vscode
|
|
2136
|
+
scanMcpFile(root, `${base}.vscode/mcp.json`, findings, warnings, state, baseAbs, prefix !== "");
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
function scanDirFiles(root, dirName, source, family, findings, warnings, state) {
|
|
2140
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2141
|
+
const dirRel = prefix === "" ? dirName : `${prefix}/${dirName}`;
|
|
2142
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2143
|
+
const abs = join(root, dirRel);
|
|
2135
2144
|
if (!existsSync(abs))
|
|
2136
2145
|
continue;
|
|
2137
2146
|
let entries = [];
|
|
@@ -2139,6 +2148,7 @@ function scanAgentDirs(root, findings, warnings) {
|
|
|
2139
2148
|
entries = readdirSync(abs);
|
|
2140
2149
|
}
|
|
2141
2150
|
catch {
|
|
2151
|
+
warnings.push(`Unreadable directory skipped: ${dirRel} could not be listed; the scan is partial`);
|
|
2142
2152
|
continue;
|
|
2143
2153
|
}
|
|
2144
2154
|
for (const name of entries.sort()) {
|
|
@@ -2152,43 +2162,144 @@ function scanAgentDirs(root, findings, warnings) {
|
|
|
2152
2162
|
}
|
|
2153
2163
|
if (!st.isFile())
|
|
2154
2164
|
continue;
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2165
|
+
if (name === "_" || name.startsWith("."))
|
|
2166
|
+
continue;
|
|
2167
|
+
const fileRel = `${dirRel}/${name}`;
|
|
2168
|
+
if (!claimFile(state, family, fileRel, prefix !== ""))
|
|
2169
|
+
continue;
|
|
2170
|
+
const content = readTextWarn(fileAbs, fileRel, warnings);
|
|
2171
|
+
if (content === null)
|
|
2172
|
+
continue;
|
|
2173
|
+
// Entire file content is the command surface for husky style hooks.
|
|
2174
|
+
// Classify generously deep into the file: a payload hidden behind
|
|
2175
|
+
// padding must still be seen. Display code truncates separately.
|
|
2176
|
+
findings.push(makeFinding(root, fileRel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
function scanLefthook(root, findings, warnings, state) {
|
|
2181
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2182
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2183
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2184
|
+
for (const name of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"]) {
|
|
2185
|
+
const rel = `${base}${name}`;
|
|
2186
|
+
if (!existsSync(join(root, rel)))
|
|
2187
|
+
continue;
|
|
2188
|
+
if (!claimFile(state, "lefthook", rel, prefix !== ""))
|
|
2189
|
+
continue;
|
|
2190
|
+
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2191
|
+
const runRe = /^\s*run:\s*(.+)$/gm;
|
|
2192
|
+
let m;
|
|
2193
|
+
let i = 0;
|
|
2194
|
+
while ((m = runRe.exec(raw)) !== null) {
|
|
2195
|
+
findings.push(makeFinding(root, rel, "lefthook", "run", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
function scanPreCommit(root, findings, warnings, state) {
|
|
2201
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2202
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2203
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2204
|
+
const rel = `${base}.pre-commit-config.yaml`;
|
|
2205
|
+
if (!existsSync(join(root, rel)))
|
|
2206
|
+
continue;
|
|
2207
|
+
if (!claimFile(state, "pre-commit", rel, prefix !== ""))
|
|
2208
|
+
continue;
|
|
2209
|
+
const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2210
|
+
const entryRe = /^\s*entry:\s*(.+)$/gm;
|
|
2211
|
+
let m;
|
|
2212
|
+
let i = 0;
|
|
2213
|
+
while ((m = entryRe.exec(raw)) !== null) {
|
|
2214
|
+
findings.push(makeFinding(root, rel, "pre-commit", "entry", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
|
|
2215
|
+
}
|
|
2216
|
+
const repoRe = /^\s*-\s*repo:\s*(https?:\/\/\S+)$/gm;
|
|
2217
|
+
while ((m = repoRe.exec(raw)) !== null) {
|
|
2218
|
+
// A remote hook repo is the standard pre-commit pattern, not a finding
|
|
2219
|
+
// of compromise: the URL alone is inventoried as UNKNOWN so a human can
|
|
2220
|
+
// review the pinned rev. Classifying the bare URL with the command
|
|
2221
|
+
// rules would verdict every normal pre-commit config COMPROMISED.
|
|
2222
|
+
findings.push({
|
|
2223
|
+
id: `${rel}:remote-repo:${i++}`,
|
|
2224
|
+
file: rel,
|
|
2225
|
+
source: "pre-commit",
|
|
2226
|
+
hook: "remote-repo",
|
|
2227
|
+
command: m[1].trim(),
|
|
2228
|
+
classification: "UNKNOWN",
|
|
2229
|
+
rules: [],
|
|
2230
|
+
evidence: "remote pre-commit hook repo (standard pattern): its hooks run locally at commit time; hookcheck cannot verify remote contents, review the pinned rev",
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
function scanAgentDirs(root, findings, warnings, state) {
|
|
2236
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2237
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2238
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2239
|
+
for (const [dir, source] of [[".cursor", "cursor"], [".codex", "codex"]]) {
|
|
2240
|
+
const abs = join(root, base, dir);
|
|
2241
|
+
if (!existsSync(abs))
|
|
2242
|
+
continue;
|
|
2243
|
+
let entries = [];
|
|
2244
|
+
try {
|
|
2245
|
+
entries = readdirSync(abs);
|
|
2246
|
+
}
|
|
2247
|
+
catch {
|
|
2161
2248
|
continue;
|
|
2162
|
-
if (name.endsWith(".json")) {
|
|
2163
|
-
const data = readJson(root, rel, warnings);
|
|
2164
|
-
if (data)
|
|
2165
|
-
collectGenericCommands(data, rel, root, findings, source);
|
|
2166
2249
|
}
|
|
2167
|
-
|
|
2168
|
-
const
|
|
2169
|
-
|
|
2170
|
-
|
|
2250
|
+
for (const name of entries.sort()) {
|
|
2251
|
+
const fileAbs = join(abs, name);
|
|
2252
|
+
let st;
|
|
2253
|
+
try {
|
|
2254
|
+
st = statSync(fileAbs);
|
|
2255
|
+
}
|
|
2256
|
+
catch {
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
if (!st.isFile())
|
|
2260
|
+
continue;
|
|
2261
|
+
const rel = `${base}${dir}/${name}`;
|
|
2262
|
+
// mcp.json is an MCP config surface, inventoried exactly once by
|
|
2263
|
+
// the central scanMcpFile pass in scanHooks (P-035). Running it
|
|
2264
|
+
// through the generic command walk here as well counted every
|
|
2265
|
+
// .cursor/mcp.json server twice.
|
|
2266
|
+
if (name === "mcp.json")
|
|
2267
|
+
continue;
|
|
2268
|
+
if (!claimFile(state, "agent", rel, prefix !== ""))
|
|
2269
|
+
continue;
|
|
2270
|
+
if (name.endsWith(".json")) {
|
|
2271
|
+
const data = readJson(root, rel, warnings);
|
|
2272
|
+
if (data)
|
|
2273
|
+
collectGenericCommands(data, rel, root, findings, source, "", baseAbs);
|
|
2274
|
+
}
|
|
2275
|
+
else {
|
|
2276
|
+
const content = readTextWarn(fileAbs, rel, warnings);
|
|
2277
|
+
if (content && content.trim()) {
|
|
2278
|
+
findings.push(makeFinding(root, rel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
|
|
2279
|
+
}
|
|
2171
2280
|
}
|
|
2172
2281
|
}
|
|
2173
2282
|
}
|
|
2174
2283
|
}
|
|
2175
2284
|
}
|
|
2176
|
-
function collectGenericCommands(node, file, root, findings, source, prefix = "") {
|
|
2285
|
+
function collectGenericCommands(node, file, root, findings, source, prefix = "", baseAbs = root) {
|
|
2177
2286
|
if (!node || typeof node !== "object")
|
|
2178
2287
|
return;
|
|
2179
2288
|
const obj = node;
|
|
2180
2289
|
if (typeof obj.command === "string" && obj.command.trim()) {
|
|
2181
2290
|
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)));
|
|
2291
|
+
findings.push(makeFinding(root, file, source, prefix || "command", [obj.command, args].filter(Boolean).join(" "), String(findings.length), undefined, baseAbs));
|
|
2183
2292
|
}
|
|
2184
2293
|
for (const [key, value] of Object.entries(obj)) {
|
|
2185
2294
|
if (value && typeof value === "object")
|
|
2186
|
-
collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key);
|
|
2295
|
+
collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key, baseAbs);
|
|
2187
2296
|
}
|
|
2188
2297
|
}
|
|
2189
|
-
function scanMcpFile(root, rel, findings, warnings) {
|
|
2298
|
+
function scanMcpFile(root, rel, findings, warnings, state, baseAbs, isNested) {
|
|
2190
2299
|
if (!existsSync(join(root, rel)))
|
|
2191
2300
|
return;
|
|
2301
|
+
if (!claimFile(state, "mcp", rel, isNested))
|
|
2302
|
+
return;
|
|
2192
2303
|
const data = readJson(root, rel, warnings);
|
|
2193
2304
|
if (!data)
|
|
2194
2305
|
return;
|
|
@@ -2203,9 +2314,9 @@ function scanMcpFile(root, rel, findings, warnings) {
|
|
|
2203
2314
|
const args = Array.isArray(c.args) ? c.args.filter((a) => typeof a === "string").join(" ") : "";
|
|
2204
2315
|
const full = [command, args].filter(Boolean).join(" ").trim();
|
|
2205
2316
|
if (full)
|
|
2206
|
-
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full));
|
|
2317
|
+
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full, undefined, undefined, baseAbs));
|
|
2207
2318
|
if (typeof c.url === "string" && c.url) {
|
|
2208
|
-
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url));
|
|
2319
|
+
findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url, undefined, undefined, baseAbs));
|
|
2209
2320
|
}
|
|
2210
2321
|
}
|
|
2211
2322
|
}
|
|
@@ -2233,26 +2344,37 @@ function scanMcpFile(root, rel, findings, warnings) {
|
|
|
2233
2344
|
* resolved is still inventoried, as the literal alias text at UNKNOWN,
|
|
2234
2345
|
* so no executable step ever yields zero findings.
|
|
2235
2346
|
*/
|
|
2236
|
-
function scanWorkflows(root, findings, warnings) {
|
|
2237
|
-
const
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
if (!/\.(yml|yaml)$/.test(name))
|
|
2347
|
+
function scanWorkflows(root, findings, warnings, state) {
|
|
2348
|
+
for (const prefix of state.disc.projectRoots) {
|
|
2349
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2350
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2351
|
+
const dir = `${base}.github/workflows`;
|
|
2352
|
+
const abs = join(root, dir);
|
|
2353
|
+
if (!existsSync(abs))
|
|
2354
|
+
continue;
|
|
2355
|
+
let entries = [];
|
|
2356
|
+
try {
|
|
2357
|
+
entries = readdirSync(abs);
|
|
2358
|
+
}
|
|
2359
|
+
catch {
|
|
2360
|
+
warnings.push(`Unreadable directory skipped: ${dir} could not be listed; the scan is partial`);
|
|
2251
2361
|
continue;
|
|
2252
|
-
|
|
2362
|
+
}
|
|
2363
|
+
for (const name of entries.sort()) {
|
|
2364
|
+
if (!/\.(yml|yaml)$/.test(name))
|
|
2365
|
+
continue;
|
|
2366
|
+
const rel = `${dir}/${name}`;
|
|
2367
|
+
if (!claimFile(state, "workflows", rel, prefix !== ""))
|
|
2368
|
+
continue;
|
|
2369
|
+
scanOneWorkflow(root, rel, baseAbs, findings, warnings);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
function scanOneWorkflow(root, rel, baseAbs, findings, warnings) {
|
|
2374
|
+
{
|
|
2253
2375
|
const rawFile = readTextWarn(join(root, rel), rel, warnings);
|
|
2254
2376
|
if (rawFile === null)
|
|
2255
|
-
|
|
2377
|
+
return;
|
|
2256
2378
|
const raw = rawFile.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2257
2379
|
const lines = raw.split("\n");
|
|
2258
2380
|
const indentOf = (l) => l.length - l.trimStart().length;
|
|
@@ -2344,14 +2466,14 @@ function scanWorkflows(root, findings, warnings) {
|
|
|
2344
2466
|
if (aliasStep) {
|
|
2345
2467
|
const name = aliasStep[1];
|
|
2346
2468
|
const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
|
|
2347
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
|
|
2469
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
|
|
2348
2470
|
continue;
|
|
2349
2471
|
}
|
|
2350
2472
|
const mergeKey = line.match(/^\s*<<:\s*\*([A-Za-z0-9_-]+)\s*(#.*)?$/);
|
|
2351
2473
|
if (mergeKey) {
|
|
2352
2474
|
const name = mergeKey[1];
|
|
2353
2475
|
const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
|
|
2354
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
|
|
2476
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
|
|
2355
2477
|
continue;
|
|
2356
2478
|
}
|
|
2357
2479
|
const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
|
|
@@ -2374,19 +2496,19 @@ function scanWorkflows(root, findings, warnings) {
|
|
|
2374
2496
|
if (blockHeader) {
|
|
2375
2497
|
const { body, end } = collectBlock(idx, runIndent);
|
|
2376
2498
|
if (body) {
|
|
2377
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun));
|
|
2499
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun, baseAbs));
|
|
2378
2500
|
}
|
|
2379
2501
|
idx = end - 1;
|
|
2380
2502
|
continue;
|
|
2381
2503
|
}
|
|
2382
2504
|
if (rest === "")
|
|
2383
2505
|
continue;
|
|
2384
|
-
findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun));
|
|
2506
|
+
findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun, baseAbs));
|
|
2385
2507
|
}
|
|
2386
2508
|
}
|
|
2387
2509
|
}
|
|
2388
|
-
function scanGitHooks(root, findings, warnings) {
|
|
2389
|
-
scanDirFiles(root, ".githooks", "githooks", findings, warnings);
|
|
2510
|
+
function scanGitHooks(root, findings, warnings, state) {
|
|
2511
|
+
scanDirFiles(root, ".githooks", "githooks", "githooks", findings, warnings, state);
|
|
2390
2512
|
}
|
|
2391
2513
|
/**
|
|
2392
2514
|
* Empty estate note (P-014, hookcheck half). A scan that inventories
|
|
@@ -2423,21 +2545,44 @@ export function scanHooks(root) {
|
|
|
2423
2545
|
note: EMPTY_ESTATE_NOTE,
|
|
2424
2546
|
};
|
|
2425
2547
|
}
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2548
|
+
const disc = discoverSurfaces(root, warnings);
|
|
2549
|
+
const state = { disc, inventoried: new Set(), familyFound: new Map(), familyCapped: new Map(), partial: false };
|
|
2550
|
+
scanPackageJson(root, findings, warnings, state);
|
|
2551
|
+
scanClaude(root, findings, warnings, state);
|
|
2552
|
+
scanPluginHooks(root, findings, warnings, state);
|
|
2553
|
+
scanVscode(root, findings, warnings, state);
|
|
2554
|
+
scanDirFiles(root, ".husky", "husky", "husky", findings, warnings, state);
|
|
2555
|
+
scanLefthook(root, findings, warnings, state);
|
|
2556
|
+
scanPreCommit(root, findings, warnings, state);
|
|
2557
|
+
scanAgentDirs(root, findings, warnings, state);
|
|
2558
|
+
// MCP config surfaces, each inventoried exactly once (P-035), at the
|
|
2559
|
+
// scan root and at every nested project root (P-065).
|
|
2435
2560
|
// .windsurf/mcp.json joined the covered surfaces in v0.1.6 (P-034);
|
|
2436
2561
|
// .vscode/mcp.json is handled inside scanVscode.
|
|
2437
|
-
for (const
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2562
|
+
for (const prefix of disc.projectRoots) {
|
|
2563
|
+
const base = prefix === "" ? "" : `${prefix}/`;
|
|
2564
|
+
const baseAbs = prefix === "" ? root : join(root, prefix);
|
|
2565
|
+
for (const name of [".mcp.json", "mcp.json", ".cursor/mcp.json", ".windsurf/mcp.json", ".codex/mcp.json"]) {
|
|
2566
|
+
scanMcpFile(root, `${base}${name}`, findings, warnings, state, baseAbs, prefix !== "");
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
scanWorkflows(root, findings, warnings, state);
|
|
2570
|
+
scanGitHooks(root, findings, warnings, state);
|
|
2571
|
+
// Bounded scan honesty (P-065): a scan cut short by the discovery
|
|
2572
|
+
// bounds must say so, naming the bound, instead of presenting as
|
|
2573
|
+
// complete. Surfaces spotted just below the depth bound are counted
|
|
2574
|
+
// in the warning, never silently dropped.
|
|
2575
|
+
if (disc.depthBoundHit) {
|
|
2576
|
+
const below = disc.surfacesBelowBound > 0
|
|
2577
|
+
? `; at least ${disc.surfacesBelowBound} of the directories just below the bound contain hook surface files or config directories that were not inventoried`
|
|
2578
|
+
: "";
|
|
2579
|
+
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)`);
|
|
2580
|
+
}
|
|
2581
|
+
for (const [family, capped] of state.familyCapped) {
|
|
2582
|
+
const label = FAMILY_LABELS[family] ?? family;
|
|
2583
|
+
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`);
|
|
2584
|
+
void capped;
|
|
2585
|
+
}
|
|
2441
2586
|
// Dedupe identical ids (agent dir scan may double count mcp.json).
|
|
2442
2587
|
const seen = new Set();
|
|
2443
2588
|
const deduped = findings.filter((f) => {
|
|
@@ -2453,12 +2598,14 @@ export function scanHooks(root) {
|
|
|
2453
2598
|
risky: deduped.filter((f) => f.classification === "RISKY").length,
|
|
2454
2599
|
unknown: deduped.filter((f) => f.classification === "UNKNOWN").length,
|
|
2455
2600
|
};
|
|
2456
|
-
// Partial scan floor (P-049): when any inventoried
|
|
2457
|
-
// could not be read,
|
|
2458
|
-
//
|
|
2459
|
-
//
|
|
2601
|
+
// Partial scan floor (P-049, extended by P-065): when any inventoried
|
|
2602
|
+
// file or directory could not be read, when a file cap left surface
|
|
2603
|
+
// files uninventoried, or when hook surfaces were spotted just below
|
|
2604
|
+
// the discovery depth bound, the scan did not see the whole estate,
|
|
2605
|
+
// so a bare CLEAN is not an honest verdict. Floor at REVIEW; the
|
|
2606
|
+
// warnings name exactly what was missed.
|
|
2460
2607
|
let verdict = computeVerdict(deduped);
|
|
2461
|
-
if (verdict === "CLEAN" && warnings.some((w) => w.startsWith("Unreadable"))) {
|
|
2608
|
+
if (verdict === "CLEAN" && (warnings.some((w) => w.startsWith("Unreadable")) || state.partial || disc.surfacesBelowBound > 0)) {
|
|
2462
2609
|
verdict = "REVIEW";
|
|
2463
2610
|
}
|
|
2464
2611
|
return {
|