omakit 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +1 -1
- package/skills/omarchy-plugin-check/SKILL.md +63 -1
- package/tools/inspect/contract.mjs +256 -0
- package/tools/inspect/functions.mjs +171 -0
- package/tools/inspect/hosts.mjs +141 -0
- package/tools/inspect/inspect.mjs +182 -0
- package/tools/inspect/measure-functions.mjs +150 -0
- package/tools/inspect/patterns.mjs +355 -0
- package/tools/inspect/processes.mjs +298 -0
- package/tools/inspect/report.mjs +366 -0
- package/tools/inspect/text.mjs +496 -0
- package/tools/inspect/timers.mjs +65 -0
- package/tools/inspect/walk.mjs +102 -0
- package/tools/inspect/writes.mjs +194 -0
- package/tools/marketplace/README.md +21 -1
- package/tools/marketplace/cli.mjs +49 -1
- package/tools/marketplace/local-transport.mjs +16 -4
- package/tools/marketplace/options.mjs +1 -0
- package/tools/marketplace/pin.mjs +18 -10
- package/tools/marketplace/run-baseline.mjs +2 -1
- package/tools/marketplace/style.mjs +3 -0
- package/tools/marketplace/usage.mjs +22 -0
- package/tools/marketplace/verify.mjs +15 -4
- package/tools/subject/resolve.mjs +10 -3
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// The review patterns of docs/INSPECT.md as data: for each class the
|
|
2
|
+
// marketplace's human review raised in the M11 sample, a precondition over
|
|
3
|
+
// the observed facts, the measurement it cites and the share it prints. A
|
|
4
|
+
// pattern row prints only where the precondition is met, in two lines: the
|
|
5
|
+
// observation with its sites, then "about N of every 100 review findings in
|
|
6
|
+
// the sample (M11)". Never "missing", "should" or "fix": the row names the
|
|
7
|
+
// observation and the frequency and stops. A pattern without a number does
|
|
8
|
+
// not ship; tests/unit/submit.test.mjs holds every entry here to a
|
|
9
|
+
// measurement that names a section of docs/MEASUREMENTS.md and a numeric
|
|
10
|
+
// share, the way it holds every submit check to a why.
|
|
11
|
+
//
|
|
12
|
+
// The shares are the maintainer's review findings by class in a fixed
|
|
13
|
+
// 30-issue sample, one reader's classification, dated 2026-09-12
|
|
14
|
+
// (docs/evidence/inspect/2026-09-12-review-classes.json). They say how
|
|
15
|
+
// often reviewers raised a class, never how likely this plugin is to be
|
|
16
|
+
// blocked, and nothing here turns a share into a verdict.
|
|
17
|
+
//
|
|
18
|
+
// Nothing here restates a marketplace rule (AGENTS.md, rule 2). The
|
|
19
|
+
// `supply-chain` pattern detects nothing itself: it cites the findings the
|
|
20
|
+
// pinned baseline recorded and does not list as selectively blocking, which
|
|
21
|
+
// at the current pin are its three unpinned-remote-source rules, read
|
|
22
|
+
// through verify and the pinned policy rather than named here.
|
|
23
|
+
|
|
24
|
+
import { basename, blankComments, blocks, lineOf, propertyValue } from "./text.mjs"
|
|
25
|
+
import { toolOf } from "./processes.mjs"
|
|
26
|
+
|
|
27
|
+
const SAMPLE = "30-issue sample"
|
|
28
|
+
const site = (row) => ({ file: row.file, line: row.line })
|
|
29
|
+
const sites = (rows) => rows.map((row) => `${row.file}:${row.line}`).join(", ")
|
|
30
|
+
const plural = (count, word, words = `${word}s`) => `${count} ${count === 1 ? word : words}`
|
|
31
|
+
|
|
32
|
+
/** A short flag, alone or inside a cluster: `-q`, `-fsq`; or its long form. */
|
|
33
|
+
function hasFlag(flags, letter, long = null) {
|
|
34
|
+
return flags.some((flag) => flag === `-${letter}` || (long && (flag === long || flag.startsWith(`${long}=`))) || (/^-[A-Za-z]+$/.test(flag) && flag.includes(letter)))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The tool a process resolves through PATH: its first non-wrapper word when that word has no slash. */
|
|
38
|
+
function pathResolved(process) {
|
|
39
|
+
if (!Array.isArray(process.argv) || !process.argv.length) return null
|
|
40
|
+
const { tool } = toolOf(process.argv)
|
|
41
|
+
if (!tool || tool.includes("/")) return null
|
|
42
|
+
return tool
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SECRET = /authorization|bearer|token=|api[_-]?key|password|secret=/i
|
|
46
|
+
const PRIVILEGED = new Set(["sudo", "pkexec", "doas", "docker"])
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `Text` blocks whose `text:` binds to a collector's `.text` and whose
|
|
50
|
+
* `textFormat` is not `Text.PlainText`, over the QML files.
|
|
51
|
+
*/
|
|
52
|
+
function richTextSinks(files) {
|
|
53
|
+
const found = []
|
|
54
|
+
for (const file of files.filter((entry) => entry.kind === "qml")) {
|
|
55
|
+
const text = blankComments(file.text)
|
|
56
|
+
const collectors = [...blocks(text, "StdioCollector"), ...blocks(text, "SplitParser")].map((block) => block.id).filter(Boolean)
|
|
57
|
+
if (!collectors.length) continue
|
|
58
|
+
for (const block of [...blocks(text, "Text"), ...blocks(text, "Label"), ...blocks(text, "TextEdit")]) {
|
|
59
|
+
const bound = propertyValue(block.body, "text")
|
|
60
|
+
if (!bound || !collectors.some((id) => new RegExp(`(?<![\\w.])${id}\\.(?:text|data)\\b`).test(bound.text))) continue
|
|
61
|
+
const format = propertyValue(block.body, "textFormat")?.text
|
|
62
|
+
if (format === "Text.PlainText") continue
|
|
63
|
+
found.push({ file: file.path, line: lineOf(text, block.open + 1 + bound.offset) })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return found
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `console.log(...)` and `console.warn(...)` lines whose argument text is secret-shaped, over QML and JavaScript. */
|
|
70
|
+
function secretLogs(files) {
|
|
71
|
+
const found = []
|
|
72
|
+
for (const file of files.filter((entry) => entry.kind === "qml" || entry.kind === "js")) {
|
|
73
|
+
const text = blankComments(file.text)
|
|
74
|
+
for (const match of text.matchAll(/console\.(?:log|warn|error|info|debug)\s*\(([^\n]*)/g)) {
|
|
75
|
+
if (SECRET.test(match[1])) found.push({ file: file.path, line: lineOf(text, match.index) })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return found
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The size thresholds: a function is listed as long when it is longer, more
|
|
83
|
+
* branched or deeper than 90 of 100 functions in the 50 listed trees of
|
|
84
|
+
* docs/evidence/inspect/2026-09-16-function-lengths.json (M12). The numbers
|
|
85
|
+
* are that record's p90 quantiles and tests/unit/submit.test.mjs holds them
|
|
86
|
+
* to it. Length is what the person asked for first, so the default view
|
|
87
|
+
* lists long functions before the review classes and says that this order
|
|
88
|
+
* is a preference and not a share.
|
|
89
|
+
*/
|
|
90
|
+
export const SIZE = Object.freeze({
|
|
91
|
+
measurement: "M12",
|
|
92
|
+
sample: "50 listed trees, 6041 functions",
|
|
93
|
+
trees: 50,
|
|
94
|
+
functions: 6041,
|
|
95
|
+
lines: 22,
|
|
96
|
+
branches: 6,
|
|
97
|
+
depth: 2,
|
|
98
|
+
// The histogram of each measure over the 6041 functions, value to count,
|
|
99
|
+
// from which a function's percentile rank among listed functions is read;
|
|
100
|
+
// and each listed tree's heavyShare, the share of its function lines in
|
|
101
|
+
// functions over the thresholds, in the record's row order, from which
|
|
102
|
+
// a tree's position among listed trees is read.
|
|
103
|
+
distribution: Object.freeze({
|
|
104
|
+
lines: Object.freeze({ 1: 330, 2: 185, 3: 682, 4: 659, 5: 629, 6: 515, 7: 420, 8: 339, 9: 301, 10: 235, 11: 192, 12: 138, 13: 150, 14: 129, 15: 104, 16: 75, 17: 84, 18: 64, 19: 70, 20: 48, 21: 51, 22: 46, 23: 45, 24: 42, 25: 27, 26: 25, 27: 16, 28: 24, 29: 24, 30: 17, 31: 18, 32: 23, 33: 18, 34: 19, 35: 10, 36: 5, 37: 11, 38: 12, 39: 12, 40: 9, 41: 10, 42: 12, 43: 9, 44: 8, 45: 7, 46: 14, 47: 8, 48: 11, 49: 8, 50: 6, 51: 2, 52: 2, 53: 6, 54: 2, 55: 7, 56: 4, 57: 3, 58: 5, 60: 6, 61: 2, 62: 3, 63: 6, 64: 1, 65: 3, 66: 1, 67: 3, 68: 4, 69: 8, 70: 5, 71: 6, 72: 2, 73: 2, 74: 1, 75: 1, 76: 1, 77: 1, 78: 1, 79: 3, 80: 1, 81: 3, 82: 1, 83: 1, 85: 1, 87: 1, 89: 1, 90: 2, 91: 2, 92: 1, 94: 1, 96: 2, 97: 2, 98: 1, 99: 1, 100: 1, 101: 1, 102: 2, 104: 1, 108: 1, 110: 2, 111: 1, 112: 1, 113: 1, 115: 2, 116: 1, 117: 1, 119: 1, 120: 1, 121: 1, 128: 2, 129: 1, 133: 1, 134: 1, 144: 1, 145: 1, 154: 1, 161: 1, 173: 1, 253: 1, 277: 1, 292: 1, 365: 1, 495: 1 }),
|
|
105
|
+
branches: Object.freeze({ 0: 2336, 1: 1096, 2: 807, 3: 455, 4: 339, 5: 233, 6: 171, 7: 125, 8: 90, 9: 68, 10: 47, 11: 53, 12: 24, 13: 26, 14: 18, 15: 11, 16: 17, 17: 18, 18: 12, 19: 12, 20: 11, 21: 3, 22: 5, 23: 7, 24: 8, 25: 4, 26: 1, 27: 6, 28: 4, 29: 5, 31: 1, 32: 3, 33: 3, 34: 1, 35: 1, 36: 4, 37: 1, 38: 1, 40: 1, 41: 1, 43: 2, 44: 1, 45: 2, 46: 1, 64: 1, 75: 1, 78: 1, 85: 1, 87: 2 }),
|
|
106
|
+
depth: Object.freeze({ 0: 3794, 1: 1421, 2: 481, 3: 155, 4: 80, 5: 62, 6: 27, 7: 10, 8: 5, 9: 2, 12: 1, 13: 1, 15: 1, 18: 1 }),
|
|
107
|
+
heavyShare: Object.freeze([0.421, 0.3361, 0.4049, 0, 0.3687, 0.4469, 0.4107, 0.6167, 0.2635, 0.8434, 0, 0, 0.193, 0.7036, 0.4506, 0.4846, 0.5047, 0.5337, 0.3628, 0.682, 0.0554, 0.7775, 0.3548, 0.3552, 0, 0.7172, 0.2542, 0.3747, 0.4275, 0.6607, 0.4322, 0.6941, 0.3247, 0.5225, 0.2545, 0.1462, 0, 0.5817, 0.5891, 0.4353, 0, 0.3479, 0.6109, 0.296, 0.3882, 0.5624, 0.6783, 0.4385, 0.6078, 0.5826]),
|
|
108
|
+
}),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Where a value sits among the sample's: the share of listed functions
|
|
114
|
+
* with a smaller value, as a percentage, so the smallest listed value ranks
|
|
115
|
+
* 0 and a value over every listed one ranks 100. A function's rank is the
|
|
116
|
+
* largest of its three, since one long measure is what a reader sees.
|
|
117
|
+
*/
|
|
118
|
+
export function percentile(measure, value) {
|
|
119
|
+
const histogram = SIZE.distribution[measure]
|
|
120
|
+
let below = 0
|
|
121
|
+
for (const [key, count] of Object.entries(histogram)) if (Number(key) < value) below += count
|
|
122
|
+
return Math.round((below / SIZE.functions) * 1000) / 10
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function rankOf(entry) {
|
|
126
|
+
return Math.max(percentile("lines", entry.lines), percentile("branches", entry.branches), percentile("depth", entry.depth))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The share of a tree's function lines that sit in functions over any of
|
|
131
|
+
* the M12 thresholds: lines inside `overSize` functions over lines inside
|
|
132
|
+
* every function, 0 with no function. Line-weighted on purpose: splitting
|
|
133
|
+
* one long function into short ones moves its lines out of the heavy set,
|
|
134
|
+
* and padding a tree with small functions barely moves the ratio.
|
|
135
|
+
*/
|
|
136
|
+
export function heavyShare(functions) {
|
|
137
|
+
const total = functions.reduce((sum, entry) => sum + entry.lines, 0)
|
|
138
|
+
if (!total) return 0
|
|
139
|
+
const heavy = overSize(functions).reduce((sum, entry) => sum + entry.lines, 0)
|
|
140
|
+
return heavy / total
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The share of listed trees whose heavyShare is strictly smaller than this
|
|
145
|
+
* one, as a percentage over the trees of the M12 record: 0 for a tree with
|
|
146
|
+
* no function over the thresholds, 100 for one heavier than every listed
|
|
147
|
+
* tree.
|
|
148
|
+
*/
|
|
149
|
+
export function treeRank(share) {
|
|
150
|
+
const below = SIZE.distribution.heavyShare.filter((listed) => listed < share).length
|
|
151
|
+
return Math.round((below / SIZE.trees) * 1000) / 10
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The size score of a tree: 10 minus its rank among the listed trees
|
|
156
|
+
* divided by 10, on 0 to 10 with two decimals. A tree with no function
|
|
157
|
+
* over the thresholds scores 10.00, a tree heavier than every listed tree
|
|
158
|
+
* 0.00. It says where the tree sits among listed plugins by how much of
|
|
159
|
+
* its function text is in long functions, never whether it is good, and a
|
|
160
|
+
* tree with no function has no score.
|
|
161
|
+
*/
|
|
162
|
+
export function sizeScore(functions) {
|
|
163
|
+
if (!functions.length) return null
|
|
164
|
+
return Math.round((10 - treeRank(heavyShare(functions)) / 10) * 100) / 100
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The functions over any of the thresholds, longest first, then most branched. */
|
|
168
|
+
export function overSize(functions) {
|
|
169
|
+
return functions
|
|
170
|
+
.filter((entry) => entry.lines > SIZE.lines || entry.branches > SIZE.branches || entry.depth > SIZE.depth)
|
|
171
|
+
.sort((a, b) => b.lines - a.lines || b.branches - a.branches || b.depth - a.depth || a.file.localeCompare(b.file) || a.line - b.line)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const PATTERNS = Object.freeze([
|
|
175
|
+
{
|
|
176
|
+
id: "process-lifecycle",
|
|
177
|
+
label: "process lifecycle",
|
|
178
|
+
notObserved: "no process without a deadline",
|
|
179
|
+
measurement: "M11",
|
|
180
|
+
share: 0.20,
|
|
181
|
+
sample: SAMPLE,
|
|
182
|
+
precondition: ({ processes }) => {
|
|
183
|
+
const rows = processes.filter((row) => row.declaredIn === "qml" && !row.detached && !row.deadline.observed)
|
|
184
|
+
return { sites: rows.map(site), observation: `observed ${plural(rows.length, "process", "processes")} with no deadline (${sites(rows)})` }
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "unbounded-buffering",
|
|
189
|
+
label: "unbounded buffering",
|
|
190
|
+
notObserved: "no collector without a cap",
|
|
191
|
+
measurement: "M11",
|
|
192
|
+
share: 0.19,
|
|
193
|
+
sample: SAMPLE,
|
|
194
|
+
precondition: ({ processes }) => {
|
|
195
|
+
const rows = processes.filter((row) => (row.output.collector === "StdioCollector" || row.output.collector === "SplitParser") && !row.output.capObserved)
|
|
196
|
+
return { sites: rows.map(site), observation: `observed ${plural(rows.length, "collector")} with no cap (${sites(rows)})` }
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: "file-and-state-boundary",
|
|
201
|
+
label: "file and state boundary",
|
|
202
|
+
notObserved: "no write outside a controlled directory",
|
|
203
|
+
measurement: "M11",
|
|
204
|
+
share: 0.15,
|
|
205
|
+
sample: SAMPLE,
|
|
206
|
+
precondition: ({ writes }) => {
|
|
207
|
+
const outside = writes.filter((row) => row.controlledDirectory === "not-observed" && row.via !== "mktemp")
|
|
208
|
+
const unmoded = writes.filter((row) => row.via === "mkdir" && row.mode === null)
|
|
209
|
+
const parts = []
|
|
210
|
+
if (outside.length) parts.push(`${plural(outside.length, "write")} outside a controlled directory (${sites(outside)})`)
|
|
211
|
+
if (unmoded.length) parts.push(`${plural(unmoded.length, "mkdir")} with no mode (${sites(unmoded)})`)
|
|
212
|
+
return { sites: [...outside, ...unmoded].map(site), observation: `observed ${parts.join("; ")}` }
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: "environment-trust",
|
|
217
|
+
label: "environment trust",
|
|
218
|
+
notObserved: "no tool resolved from PATH, no curl without -q",
|
|
219
|
+
measurement: "M11",
|
|
220
|
+
share: 0.07,
|
|
221
|
+
sample: SAMPLE,
|
|
222
|
+
precondition: ({ processes, hosts }) => {
|
|
223
|
+
const resolved = processes.map((row) => ({ row, tool: pathResolved(row) })).filter((entry) => entry.tool)
|
|
224
|
+
const curls = hosts.filter((row) => row.tool === "curl" && !hasFlag(row.flags, "q", "--disable"))
|
|
225
|
+
const parts = []
|
|
226
|
+
const names = [...new Set(resolved.map((entry) => entry.tool))]
|
|
227
|
+
const named = names.length > 6 ? `${names.slice(0, 6).join(", ")} and ${names.length - 6} more` : names.join(", ")
|
|
228
|
+
if (resolved.length) parts.push(`${plural(resolved.length, "tool")} resolved from PATH (${named}; ${sites(resolved.map((entry) => entry.row))})`)
|
|
229
|
+
if (curls.length) parts.push(`curl without -q (${sites(curls)})`)
|
|
230
|
+
return { sites: [...resolved.map((entry) => entry.row), ...curls].map(site), observation: `observed ${parts.join("; ")}` }
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: "secrets",
|
|
235
|
+
label: "secrets",
|
|
236
|
+
notObserved: "no secret-shaped argv or log line",
|
|
237
|
+
measurement: "M11",
|
|
238
|
+
share: 0.07,
|
|
239
|
+
sample: SAMPLE,
|
|
240
|
+
precondition: ({ processes, files }) => {
|
|
241
|
+
const argv = processes.filter((row) => Array.isArray(row.argv) && row.argv.some((word) => SECRET.test(word)))
|
|
242
|
+
const copies = processes.filter((row) => Array.isArray(row.argv) && basename(toolOf(row.argv).tool || "") === "wl-copy" && row.expressions.length > 0)
|
|
243
|
+
const logs = secretLogs(files)
|
|
244
|
+
const parts = []
|
|
245
|
+
if (argv.length) parts.push(`secret-shaped argv (${sites(argv)})`)
|
|
246
|
+
if (logs.length) parts.push(`secret-shaped log line (${sites(logs)})`)
|
|
247
|
+
if (copies.length) parts.push(`wl-copy with a computed value (${sites(copies)})`)
|
|
248
|
+
return { sites: [...argv, ...logs, ...copies].map(site), observation: `observed ${parts.join("; ")}` }
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: "supply-chain",
|
|
253
|
+
label: "supply chain",
|
|
254
|
+
notObserved: "no unpinned remote source in the baseline",
|
|
255
|
+
measurement: "M11",
|
|
256
|
+
also: ["M4"],
|
|
257
|
+
share: 0.07,
|
|
258
|
+
sample: SAMPLE,
|
|
259
|
+
precondition: ({ baseline, blockingRules }) => {
|
|
260
|
+
const findings = (baseline?.official?.findings || []).filter((finding) => !blockingRules.includes(finding.ruleId))
|
|
261
|
+
const evidence = findings.flatMap((finding) => (finding.evidence || []).map((entry) => ({ file: entry.path, line: entry.line })))
|
|
262
|
+
return {
|
|
263
|
+
sites: evidence,
|
|
264
|
+
observation: `observed ${plural(findings.length, "baseline finding")} the pin does not block on (${findings.map((finding) => finding.ruleId).join(", ")}; ${sites(evidence)})`,
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "network-egress",
|
|
270
|
+
label: "network egress",
|
|
271
|
+
notObserved: "no http scheme, no -L without --proto, no private address",
|
|
272
|
+
measurement: "M11",
|
|
273
|
+
share: 0.05,
|
|
274
|
+
sample: SAMPLE,
|
|
275
|
+
precondition: ({ hosts }) => {
|
|
276
|
+
const plain = hosts.filter((row) => row.scheme === "http")
|
|
277
|
+
const redirects = hosts.filter((row) => row.tool === "curl" && hasFlag(row.flags, "L", "--location") && !row.flags.some((flag) => flag.startsWith("--proto")))
|
|
278
|
+
const local = hosts.filter((row) => row.privateAddress)
|
|
279
|
+
const parts = []
|
|
280
|
+
if (plain.length) parts.push(`http scheme (${sites(plain)})`)
|
|
281
|
+
if (redirects.length) parts.push(`curl -L without --proto (${sites(redirects)})`)
|
|
282
|
+
if (local.length) parts.push(`private or loopback address (${sites(local)})`)
|
|
283
|
+
return { sites: [...plain, ...redirects, ...local].map(site), observation: `observed ${parts.join("; ")}` }
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: "untrusted-text-to-display",
|
|
288
|
+
label: "untrusted text to display",
|
|
289
|
+
notObserved: "no Text bound to output without Text.PlainText",
|
|
290
|
+
measurement: "M11",
|
|
291
|
+
share: 0.05,
|
|
292
|
+
sample: SAMPLE,
|
|
293
|
+
precondition: ({ files }) => {
|
|
294
|
+
const found = richTextSinks(files)
|
|
295
|
+
return { sites: found, observation: `observed ${plural(found.length, "Text")} bound to a collector's output without Text.PlainText (${sites(found)})` }
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
id: "argument-grammar",
|
|
300
|
+
label: "argument grammar",
|
|
301
|
+
notObserved: "no expression inside an argv element",
|
|
302
|
+
measurement: "M11",
|
|
303
|
+
share: 0.04,
|
|
304
|
+
sample: SAMPLE,
|
|
305
|
+
precondition: ({ processes }) => {
|
|
306
|
+
const rows = processes.filter((row) => row.expressions.length > 0)
|
|
307
|
+
return { sites: rows.map(site), observation: `observed ${plural(rows.length, "argv")} with a computed element (${sites(rows)})` }
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: "privilege-disclosure",
|
|
312
|
+
label: "privilege disclosure",
|
|
313
|
+
notObserved: "no sudo, pkexec, docker or /dev/input in argv",
|
|
314
|
+
measurement: "M11",
|
|
315
|
+
also: ["M4"],
|
|
316
|
+
share: 0.03,
|
|
317
|
+
sample: SAMPLE,
|
|
318
|
+
precondition: ({ processes, readme }) => {
|
|
319
|
+
const found = []
|
|
320
|
+
for (const row of processes) {
|
|
321
|
+
if (!Array.isArray(row.argv)) continue
|
|
322
|
+
const names = [...new Set(row.argv.flatMap((word) => (PRIVILEGED.has(basename(word)) ? [basename(word)] : word.includes("/dev/input") ? ["/dev/input"] : [])))]
|
|
323
|
+
if (names.length) found.push({ row, names })
|
|
324
|
+
}
|
|
325
|
+
const names = [...new Set(found.flatMap((entry) => entry.names))]
|
|
326
|
+
const named = names.filter((name) => readme && new RegExp(name.replace(/[/]/g, "\\/"), "i").test(readme))
|
|
327
|
+
const readmeNote = readme === null ? "no README to name it" : named.length === names.length ? "named in the README: observed" : `named in the README: not observed for ${names.filter((name) => !named.includes(name)).join(", ")}`
|
|
328
|
+
return { sites: found.map((entry) => site(entry.row)), observation: `observed ${names.join(", ")} in argv (${sites(found.map((entry) => entry.row))}); ${readmeNote}` }
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
])
|
|
332
|
+
|
|
333
|
+
/** The observation without its site lists and without the leading word, for a row whose sites are marked elsewhere. */
|
|
334
|
+
export function summaryOf(observation) {
|
|
335
|
+
const SITE = "[\\w./-]+:\\d+"
|
|
336
|
+
return observation
|
|
337
|
+
.replace(new RegExp(`;\\s*${SITE}(?:, ${SITE})*(?=\\))`, "g"), "")
|
|
338
|
+
.replace(new RegExp(`\\s*\\(${SITE}(?:, ${SITE})*\\)`, "g"), "")
|
|
339
|
+
.replace(/^observed /, "")
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @param {{ processes, hosts, writes, timers, files, readme, baseline, blockingRules }} facts the observed facts of one document
|
|
344
|
+
* @returns {{ patterns: Array, lookedFor: string[] }}
|
|
345
|
+
*/
|
|
346
|
+
export function evaluatePatterns(facts) {
|
|
347
|
+
const patterns = []
|
|
348
|
+
const lookedFor = []
|
|
349
|
+
for (const pattern of PATTERNS) {
|
|
350
|
+
const found = pattern.precondition(facts)
|
|
351
|
+
if (found.sites.length) patterns.push({ id: pattern.id, observedCount: found.sites.length, sites: found.sites, observation: found.observation, summary: summaryOf(found.observation), measurement: pattern.measurement, share: pattern.share })
|
|
352
|
+
else lookedFor.push(pattern.id)
|
|
353
|
+
}
|
|
354
|
+
return { patterns, lookedFor }
|
|
355
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Process sites: every `Process {` block in QML (its `command:` inside the
|
|
2
|
+
// block or assigned to its id elsewhere in the file), every
|
|
3
|
+
// `Quickshell.execDetached([...])`, and every command line of a shell
|
|
4
|
+
// script. Each row carries the argv the text shows, whether a deadline is
|
|
5
|
+
// observed for it, what collects its output and whether a producer-side
|
|
6
|
+
// cap is observed in the argv. A command that is not one literal array or
|
|
7
|
+
// string is `argvForm: "computed"` with `argv: null`, never a guess.
|
|
8
|
+
|
|
9
|
+
import { arrayLiteral, basename, blankComments, blocks, closingBracket, lineOf, propertyValue, blankShellExpressions, shellLogicalLines, shellPieces, shellWords, stringLiteral, withoutRedirections } from "./text.mjs"
|
|
10
|
+
|
|
11
|
+
/** Shells whose `-c` turns the next element into a script the extraction does not follow. */
|
|
12
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "fish", "ksh"])
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wrappers that run the word after them: the tool a host or a pattern is
|
|
16
|
+
* attributed to is the first argv word that is not one of these, with the
|
|
17
|
+
* wrapper's own options skipped. `timeout` is also the deadline the argv
|
|
18
|
+
* carries.
|
|
19
|
+
*/
|
|
20
|
+
const WRAPPERS = new Set(["sudo", "doas", "pkexec", "env", "nohup", "nice", "ionice", "command", "exec", "timeout", "stdbuf", "chronic"])
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shell builtins and keywords that start no process. A line whose command
|
|
24
|
+
* is one of these is not a process site; `eval` is kept because it is the
|
|
25
|
+
* one builtin that runs computed text.
|
|
26
|
+
*/
|
|
27
|
+
const BUILTINS = new Set([
|
|
28
|
+
"cd", "set", "export", "local", "declare", "readonly", "typeset", "unset", "shift", "return", "exit", "break", "continue",
|
|
29
|
+
"echo", "printf", "read", "source", ".", "trap", "true", "false", ":", "test", "[", "[[", "wait", "umask", "alias", "unalias",
|
|
30
|
+
"pushd", "popd", "let", "type", "hash", "builtin", "getopts", "ulimit", "times", "jobs", "fg", "bg", "kill", "help", "exec",
|
|
31
|
+
])
|
|
32
|
+
const KEYWORDS = new Set(["if", "then", "else", "elif", "fi", "do", "done", "while", "until", "for", "in", "case", "esac", "select", "function", "time", "!", "{", "}", "(", ")", "coproc"])
|
|
33
|
+
|
|
34
|
+
/** The first argv word that is a tool, wrappers and their options skipped, with its index. */
|
|
35
|
+
export function toolOf(argv) {
|
|
36
|
+
let index = 0
|
|
37
|
+
while (index < argv.length) {
|
|
38
|
+
const word = basename(argv[index])
|
|
39
|
+
if (!WRAPPERS.has(word)) break
|
|
40
|
+
index += 1
|
|
41
|
+
if (word === "env") while (index < argv.length && (/^[A-Za-z_]\w*=/.test(argv[index]) || argv[index].startsWith("-"))) index += 1
|
|
42
|
+
else if (word === "timeout") {
|
|
43
|
+
while (index < argv.length && argv[index].startsWith("-")) index += /^(?:-s|-k|--signal|--kill-after)$/.test(argv[index]) ? 2 : 1
|
|
44
|
+
if (index < argv.length && /^\d/.test(argv[index])) index += 1
|
|
45
|
+
} else while (index < argv.length && argv[index].startsWith("-")) index += 1
|
|
46
|
+
}
|
|
47
|
+
return index < argv.length ? { tool: argv[index], index } : { tool: null, index: -1 }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The `timeout` duration in an argv, in milliseconds, or null: `timeout 5`, `timeout 2m`, `timeout -k 1 5s`. */
|
|
51
|
+
export function timeoutMs(argv) {
|
|
52
|
+
const at = argv.findIndex((word) => basename(word) === "timeout")
|
|
53
|
+
if (at < 0) return null
|
|
54
|
+
let index = at + 1
|
|
55
|
+
while (index < argv.length && argv[index].startsWith("-")) index += /^(?:-s|-k|--signal|--kill-after)$/.test(argv[index]) ? 2 : 1
|
|
56
|
+
const match = String(argv[index] || "").match(/^(\d+(?:\.\d+)?)([smhd]?)$/)
|
|
57
|
+
if (!match) return null
|
|
58
|
+
const unit = { "": 1000, s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2]]
|
|
59
|
+
return Math.round(Number(match[1]) * unit)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether a joined argv shows a producer-side cap, and which. */
|
|
63
|
+
function capIn(argv) {
|
|
64
|
+
if (!argv) return { observed: false, via: null }
|
|
65
|
+
const joined = argv.join(" ")
|
|
66
|
+
if (argv.some((word) => word.startsWith("--max-filesize"))) return { observed: true, via: "--max-filesize" }
|
|
67
|
+
if (/(?:^|[\s|/])head\s+(?:-\S+\s+)*-c\b/.test(joined) || argv.some((word, index) => basename(word) === "head" && argv[index + 1] === "-c")) return { observed: true, via: "head -c" }
|
|
68
|
+
if (argv.some((word) => basename(word) === "timeout")) return { observed: true, via: "timeout" }
|
|
69
|
+
return { observed: false, via: null }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isShellWrapper(argv) {
|
|
73
|
+
if (!argv || argv.length < 2) return false
|
|
74
|
+
const { tool, index } = toolOf(argv)
|
|
75
|
+
if (!tool) return false
|
|
76
|
+
if (basename(tool) === "eval") return true
|
|
77
|
+
// `-c` alone or inside a cluster: `sh -lc`, `bash -ec`.
|
|
78
|
+
return SHELLS.has(basename(tool)) && /^-[a-z]*c[a-z]*$/.test(argv[index + 1] || "")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The argv an expression resolves to, or the computed form. */
|
|
82
|
+
function resolveCommand(valueText) {
|
|
83
|
+
const elements = arrayLiteral(valueText)
|
|
84
|
+
if (elements) {
|
|
85
|
+
const expressions = elements.map((element, index) => (element.literal === null ? { index, text: element.text } : null)).filter(Boolean)
|
|
86
|
+
return { argv: elements.map((element) => element.literal ?? element.text), argvForm: "array", expressions }
|
|
87
|
+
}
|
|
88
|
+
const literal = stringLiteral(valueText)
|
|
89
|
+
if (literal !== null) {
|
|
90
|
+
const words = shellWords(literal)
|
|
91
|
+
return { argv: words, argvForm: "string", expressions: [] }
|
|
92
|
+
}
|
|
93
|
+
return { argv: null, argvForm: "computed", expressions: [], text: valueText.trim() }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The value assigned to `<id>.command =` somewhere in the text, with its offset. */
|
|
97
|
+
function assignedCommand(text, id) {
|
|
98
|
+
if (!id) return null
|
|
99
|
+
const pattern = new RegExp(`(?<![\\w.])${id}\\.command\\s*=(?!=)\\s*`, "g")
|
|
100
|
+
const match = pattern.exec(text)
|
|
101
|
+
if (!match) return null
|
|
102
|
+
const start = match.index + match[0].length
|
|
103
|
+
if (text[start] === "[") {
|
|
104
|
+
const end = closingBracket(text, start)
|
|
105
|
+
if (end > 0) return { text: text.slice(start, end + 1), offset: match.index }
|
|
106
|
+
}
|
|
107
|
+
let end = text.indexOf("\n", start)
|
|
108
|
+
if (end < 0) end = text.length
|
|
109
|
+
return { text: text.slice(start, end).replace(/;\s*$/, ""), offset: match.index }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a handler body stops the process with the given id. */
|
|
113
|
+
function stops(body, id) {
|
|
114
|
+
if (!id) return false
|
|
115
|
+
return new RegExp(`(?<![\\w.])${id}\\.(?:kill\\s*\\(|signal\\s*\\(|running\\s*=\\s*false)`).test(body)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function numeric(valueText) {
|
|
119
|
+
return valueText !== undefined && valueText !== null && /^\d+$/.test(String(valueText).trim()) ? Number(valueText) : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The deadline the file shows for a process: a killing Timer, `timeout` in argv, or a destruction handler. */
|
|
123
|
+
function deadlineFor(text, id, argv) {
|
|
124
|
+
for (const timer of blocks(text, "Timer")) {
|
|
125
|
+
const handler = propertyValue(timer.body, "onTriggered")
|
|
126
|
+
if (handler && stops(handler.text, id)) {
|
|
127
|
+
return { observed: true, via: "timer-kill", ms: numeric(propertyValue(timer.body, "interval")?.text) }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const ms = argv ? timeoutMs(argv) : null
|
|
131
|
+
if (argv && argv.some((word) => basename(word) === "timeout")) return { observed: true, via: "timeout-argv", ms }
|
|
132
|
+
const destruction = text.match(/Component\.onDestruction\s*:/)
|
|
133
|
+
if (destruction) {
|
|
134
|
+
const start = destruction.index + destruction[0].length
|
|
135
|
+
const trimmed = text.slice(start).replace(/^\s+/, "")
|
|
136
|
+
const at = start + (text.slice(start).length - trimmed.length)
|
|
137
|
+
const end = text[at] === "{" ? closingBracket(text, at) : text.indexOf("\n", at)
|
|
138
|
+
if (end > 0 && stops(text.slice(at, end + 1), id)) return { observed: true, via: "destruction", ms: null }
|
|
139
|
+
}
|
|
140
|
+
return { observed: false, via: null, ms: null }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function outputOf(body, argv) {
|
|
144
|
+
const collector = /(?<![\w.])StdioCollector\s*\{/.test(body) ? "StdioCollector"
|
|
145
|
+
: /(?<![\w.])SplitParser\s*\{/.test(body) ? "SplitParser"
|
|
146
|
+
: propertyValue(body, "stdout") ? "unknown" : "none"
|
|
147
|
+
const cap = capIn(argv)
|
|
148
|
+
return { collector, capObserved: cap.observed, via: cap.via }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function qmlRow(file, text, block, command, commandOffset) {
|
|
152
|
+
const resolved = command ? resolveCommand(command.text) : { argv: null, argvForm: "computed", expressions: [], text: null }
|
|
153
|
+
const id = block.id
|
|
154
|
+
const running = Boolean(propertyValue(block.body, "running")?.text === "true"
|
|
155
|
+
|| (id && new RegExp(`(?<![\\w.])${id}\\.(?:running\\s*=\\s*true|start\\s*\\(|startDetached\\s*\\()`).test(text)))
|
|
156
|
+
return {
|
|
157
|
+
file: file.path,
|
|
158
|
+
line: lineOf(text, commandOffset ?? block.start),
|
|
159
|
+
declaredIn: "qml",
|
|
160
|
+
id,
|
|
161
|
+
argv: resolved.argv,
|
|
162
|
+
argvForm: resolved.argvForm,
|
|
163
|
+
expressions: resolved.expressions,
|
|
164
|
+
commandText: resolved.argvForm === "computed" ? (resolved.text || "command not declared in the block") : null,
|
|
165
|
+
running,
|
|
166
|
+
detached: false,
|
|
167
|
+
deadline: deadlineFor(text, id, resolved.argv),
|
|
168
|
+
output: outputOf(block.body, resolved.argv),
|
|
169
|
+
shellWrapper: isShellWrapper(resolved.argv),
|
|
170
|
+
pipedFrom: null,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function detachedRows(file, text) {
|
|
175
|
+
const rows = []
|
|
176
|
+
for (const match of text.matchAll(/(?<![\w.])(?:Quickshell\.execDetached|Process\.exec)\s*\(/g)) {
|
|
177
|
+
const open = match.index + match[0].length - 1
|
|
178
|
+
const end = closingBracket(text, open)
|
|
179
|
+
if (end < 0) continue
|
|
180
|
+
const inner = text.slice(open + 1, end).trim()
|
|
181
|
+
// execDetached takes an argv array, or an object whose `command` is one.
|
|
182
|
+
const value = inner.startsWith("{") ? propertyValue(inner.slice(1, -1), "command")?.text || inner : inner
|
|
183
|
+
const resolved = resolveCommand(value)
|
|
184
|
+
rows.push({
|
|
185
|
+
file: file.path,
|
|
186
|
+
line: lineOf(text, match.index),
|
|
187
|
+
declaredIn: "qml",
|
|
188
|
+
id: null,
|
|
189
|
+
argv: resolved.argv,
|
|
190
|
+
argvForm: resolved.argvForm,
|
|
191
|
+
expressions: resolved.expressions,
|
|
192
|
+
commandText: resolved.argvForm === "computed" ? resolved.text : null,
|
|
193
|
+
running: true,
|
|
194
|
+
detached: true,
|
|
195
|
+
deadline: { observed: false, via: null, ms: null },
|
|
196
|
+
output: { collector: "none", capObserved: false, via: null },
|
|
197
|
+
shellWrapper: isShellWrapper(resolved.argv),
|
|
198
|
+
pipedFrom: null,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
return rows
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Every `Process {` block and detached exec in a QML file. */
|
|
205
|
+
export function qmlProcesses(file) {
|
|
206
|
+
const text = blankComments(file.text)
|
|
207
|
+
const rows = []
|
|
208
|
+
for (const block of blocks(text, "Process")) {
|
|
209
|
+
const inside = propertyValue(block.body, "command")
|
|
210
|
+
// `command: []` is a placeholder for a value set later: the assignment to
|
|
211
|
+
// the block's id is the command, and without one the command is computed.
|
|
212
|
+
if (inside && !/^\[\s*\]$/.test(inside.text)) {
|
|
213
|
+
rows.push(qmlRow(file, text, block, inside, block.open + 1 + inside.offset))
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
const assigned = assignedCommand(text, block.id)
|
|
217
|
+
if (!assigned && inside) {
|
|
218
|
+
rows.push({ ...qmlRow(file, text, block, null, block.open + 1 + inside.offset), commandText: "[] (set at run time, no assignment to the id observed)" })
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
rows.push(qmlRow(file, text, block, assigned, assigned ? assigned.offset : null))
|
|
222
|
+
}
|
|
223
|
+
rows.push(...detachedRows(file, text))
|
|
224
|
+
return rows.sort((a, b) => a.line - b.line)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The command words of a shell segment, leading assignments and keywords dropped; null when nothing runs. */
|
|
228
|
+
function commandWords(segment) {
|
|
229
|
+
let words = shellWords(segment)
|
|
230
|
+
// A case pattern (`*)`, `[0-9]*)`) and the blanked shells of `(( ))` and
|
|
231
|
+
// `[[ ]]` run nothing; what follows a pattern on the same line does.
|
|
232
|
+
if (words.length && /\)$/.test(words[0]) && !/^\$?\(/.test(words[0])) words = words.slice(1)
|
|
233
|
+
words = words.filter((word) => !/^(?:\(\(\s*\)\)|\(\(|\)\)|\[\[|\]\])$/.test(word))
|
|
234
|
+
while (words.length && (KEYWORDS.has(words[0]) || /^[A-Za-z_]\w*\+?=/.test(words[0]))) {
|
|
235
|
+
if (words[0] === "for" || words[0] === "case" || words[0] === "select" || words[0] === "function") return null
|
|
236
|
+
words = words.slice(1)
|
|
237
|
+
}
|
|
238
|
+
if (!words.length) return null
|
|
239
|
+
if (/^[A-Za-z_]\w*\(\)$/.test(words[0])) return null
|
|
240
|
+
if (BUILTINS.has(basename(words[0])) && basename(words[0]) !== "eval") return null
|
|
241
|
+
return words
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Every command site of a shell script, per line, pipes as separate sites. */
|
|
245
|
+
export function shellProcesses(file) {
|
|
246
|
+
const rows = []
|
|
247
|
+
for (const { line, text } of shellLogicalLines(file.text)) {
|
|
248
|
+
const trimmed = blankShellExpressions(text).trim()
|
|
249
|
+
if (!trimmed || trimmed.startsWith("#")) continue
|
|
250
|
+
// A command substitution runs too: `t=$(mktemp)` is a `mktemp` site.
|
|
251
|
+
const segments = shellPieces(trimmed)
|
|
252
|
+
const lineCap = capIn(shellWords(trimmed))
|
|
253
|
+
let previous = null
|
|
254
|
+
for (const segment of segments) {
|
|
255
|
+
const argv = commandWords(segment.text)
|
|
256
|
+
if (!argv) {
|
|
257
|
+
previous = null
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
const stripped = withoutRedirections(argv)
|
|
261
|
+
// A line that is only a redirection (`exec > log`, `> file`) runs nothing.
|
|
262
|
+
if (!stripped.length) {
|
|
263
|
+
previous = null
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
const deadlineMs = timeoutMs(stripped)
|
|
267
|
+
const row = {
|
|
268
|
+
file: file.path,
|
|
269
|
+
line,
|
|
270
|
+
declaredIn: "shell",
|
|
271
|
+
id: null,
|
|
272
|
+
argv: stripped,
|
|
273
|
+
argvForm: "string",
|
|
274
|
+
expressions: [],
|
|
275
|
+
commandText: null,
|
|
276
|
+
running: true,
|
|
277
|
+
detached: false,
|
|
278
|
+
deadline: stripped.some((word) => basename(word) === "timeout") ? { observed: true, via: "timeout-argv", ms: deadlineMs } : { observed: false, via: null, ms: null },
|
|
279
|
+
output: { collector: "none", capObserved: lineCap.observed, via: lineCap.via },
|
|
280
|
+
shellWrapper: isShellWrapper(stripped),
|
|
281
|
+
pipedFrom: segment.operator === "|" || segment.operator === "|&" ? previous : null,
|
|
282
|
+
}
|
|
283
|
+
rows.push(row)
|
|
284
|
+
previous = { line, argv0: stripped[0] }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return rows
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @param {{ path: string, kind: string, text: string }} file
|
|
292
|
+
* @returns {Array} process rows, in file order
|
|
293
|
+
*/
|
|
294
|
+
export function extractProcesses(file) {
|
|
295
|
+
if (file.kind === "qml") return qmlProcesses(file)
|
|
296
|
+
if (file.kind === "shell") return shellProcesses(file)
|
|
297
|
+
return []
|
|
298
|
+
}
|