omakit 0.4.2 → 0.4.3

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.
@@ -0,0 +1,170 @@
1
+ // `omakit inspect <plugin-dir>`: what a plugin tree does, as observations.
2
+ // Resolve the subject the way `submit` does, read its installable tree at
3
+ // the commit, run the four extractors over every file inspect reads, run
4
+ // the marketplace's own baseline through `verify` for the capabilities,
5
+ // and build the document of docs/INSPECT.md. No verdict, and the one score
6
+ // a position among listed plugins, never a grade: every
7
+ // row is a fact the text shows, labelled observed, and the document ends
8
+ // with what the method cannot see.
9
+ //
10
+ // Runs nothing from the tree, resolves no host, opens no socket, writes
11
+ // nothing into the tree. The one thing it fetches is what `verify` fetches,
12
+ // which under the local transport is nothing.
13
+
14
+ import { join } from "node:path"
15
+ import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
16
+ import { marketplaceBaselineSection } from "../marketplace/verify.mjs"
17
+ import { consequence } from "../marketplace/preflight.mjs"
18
+ import { requirePin } from "../marketplace/pin.mjs"
19
+ import { omakitCacheDir } from "../marketplace/paths.mjs"
20
+ import { walkSubject } from "./walk.mjs"
21
+ import { extractProcesses } from "./processes.mjs"
22
+ import { extractHosts } from "./hosts.mjs"
23
+ import { extractWrites } from "./writes.mjs"
24
+ import { extractTimers } from "./timers.mjs"
25
+ import { extractFunctions } from "./functions.mjs"
26
+ import { evaluatePatterns, overSize, PATTERNS, rankOf, SIZE, sizeScore } from "./patterns.mjs"
27
+
28
+ export const METHOD = "static extraction, regular expressions over qml and shell; observed, not executed"
29
+
30
+ /**
31
+ * What regular expressions over QML and shell cannot see, printed at the
32
+ * end of every report and carried verbatim in the document. A tree that
33
+ * shows none of the facts is "observed nothing of this kind", never clean,
34
+ * because of this list.
35
+ */
36
+ export const NOT_VISIBLE = Object.freeze([
37
+ "commands built at run time",
38
+ "hosts and paths from variables, properties, config or the environment",
39
+ "scripts a command calls that inspect does not follow",
40
+ "components loaded from outside the tree",
41
+ "encoded or obfuscated content, and what a sh -c or eval string runs",
42
+ ])
43
+
44
+ /** The failure codes that mean the target could not be read at all: exit 2, the contract's second status. */
45
+ export const NOT_READABLE = Object.freeze(["subject-not-found", "not-a-git-repository", "commit-not-found", "nothing-to-inspect", "usage"])
46
+
47
+ export class InspectError extends Error {
48
+ constructor(code, message, remedy = null) {
49
+ super(message)
50
+ this.name = "InspectError"
51
+ this.code = code
52
+ this.remedy = remedy
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {{ repoRoot: string, target: string, offline?: boolean, allowDirty?: boolean, omakitVersion: string, onPhase?: (text: string) => void, cacheRoot?: string }} options
58
+ * @returns {Promise<object>} the document of docs/INSPECT.md
59
+ */
60
+ export async function inspectPlugin({ repoRoot, target, offline = false, allowDirty = false, omakitVersion, onPhase = () => {}, cacheRoot = omakitCacheDir() }) {
61
+ let subject
62
+ try {
63
+ subject = resolveSubject(target, { cacheRoot, allowDirty })
64
+ } catch (error) {
65
+ if (error instanceof SubjectError) throw new InspectError(error.code, error.message)
66
+ throw error
67
+ }
68
+ onPhase("reading the installable tree")
69
+ const tree = walkSubject(subject)
70
+ const dir = subject.subdir ? join(subject.dir, subject.subdir) : subject.dir
71
+ if (!tree.manifest && !tree.manifestError) {
72
+ throw new InspectError("nothing-to-inspect", `no manifest.json at the root of ${dir}, so there is no plugin to inspect`, "Pass the directory that holds the plugin's manifest.json.")
73
+ }
74
+ const pluginId = typeof tree.manifest?.id === "string" ? tree.manifest.id.trim() : null
75
+
76
+ onPhase("reading processes, hosts, writes and timers")
77
+ const processes = []
78
+ const hosts = []
79
+ const writes = []
80
+ const timers = []
81
+ const functions = []
82
+ const notResolvable = []
83
+ for (const file of tree.files) {
84
+ // Each function carries its rank among the listed ones (M12).
85
+ functions.push(...extractFunctions(file).map((entry) => ({ ...entry, percentile: rankOf(entry) })))
86
+ const rows = extractProcesses(file)
87
+ processes.push(...rows)
88
+ for (const row of rows) {
89
+ if (row.argvForm === "computed") notResolvable.push({ file: row.file, line: row.line, kind: "command", text: `command: ${row.commandText}` })
90
+ }
91
+ const found = extractHosts(file, rows)
92
+ hosts.push(...found.hosts)
93
+ notResolvable.push(...found.notResolvable)
94
+ writes.push(...extractWrites(file, { pluginId }))
95
+ const ticking = extractTimers(file)
96
+ timers.push(...ticking.timers)
97
+ notResolvable.push(...ticking.notResolvable)
98
+ }
99
+
100
+ let marketplaceBaseline
101
+ let blockingRules = []
102
+ if (offline) {
103
+ marketplaceBaseline = { skipped: true, reason: "--offline" }
104
+ } else {
105
+ onPhase("running the official security baseline over a local snapshot")
106
+ // The baseline sees the plugin's tree and nothing around it: for a plugin
107
+ // kept below the root of a larger repository, the local transport serves
108
+ // that directory's tree as the whole tree. Without this the baseline
109
+ // scanned the repository root and reported the root's evidence as the
110
+ // plugin's, which is the 0.1 Passport's first failure.
111
+ marketplaceBaseline = await marketplaceBaselineSection({ repoRoot, subject, subdir: subject.subdir })
112
+ if (marketplaceBaseline.invoked && marketplaceBaseline.official && !marketplaceBaseline.official.error) {
113
+ blockingRules = (await consequence(requirePin(repoRoot).dir, marketplaceBaseline.official)).selectivelyBlockingRules
114
+ }
115
+ }
116
+
117
+ const facts = { processes, hosts, writes, timers, notResolvable, files: tree.files, readme: tree.readme, baseline: marketplaceBaseline, blockingRules }
118
+ const { patterns, lookedFor } = evaluatePatterns(facts)
119
+
120
+ return {
121
+ omakit: omakitVersion,
122
+ command: "inspect",
123
+ method: METHOD,
124
+ subject: {
125
+ dir,
126
+ commit: subject.commit,
127
+ repository: { url: subject.repository.url },
128
+ mode: subject.mode,
129
+ pluginId,
130
+ filesRead: tree.filesRead,
131
+ },
132
+ observed: { processes, hosts, writes, timers, functions },
133
+ // Size: the functions over the M12 thresholds, longest first. A count of
134
+ // lines, branches and nesting over the text, compared with what 90 of
135
+ // 100 functions in listed trees stay under; never a judgement.
136
+ size: {
137
+ measurement: SIZE.measurement,
138
+ sample: { trees: SIZE.trees, functions: SIZE.functions },
139
+ thresholds: { lines: SIZE.lines, branches: SIZE.branches, depth: SIZE.depth },
140
+ // 10 minus the mean rank of this tree's functions among the listed
141
+ // ones: where the tree sits, never whether it is good; null with no
142
+ // function to rank.
143
+ score: sizeScore(functions),
144
+ over: overSize(functions),
145
+ },
146
+ // The headline split: a shell script contributes one site per command
147
+ // segment, so a tree with a few scripts carries hundreds of process
148
+ // sites beside a handful of QML Process blocks, and the two are said
149
+ // apart wherever the count is printed.
150
+ counts: {
151
+ processes: {
152
+ total: processes.length,
153
+ qml: processes.filter((row) => row.declaredIn === "qml").length,
154
+ shell: processes.filter((row) => row.declaredIn === "shell").length,
155
+ },
156
+ hosts: hosts.length,
157
+ writes: writes.length,
158
+ timers: timers.length,
159
+ functions: functions.length,
160
+ notResolvable: notResolvable.length,
161
+ },
162
+ notResolvable,
163
+ patterns,
164
+ lookedFor,
165
+ notVisible: [...NOT_VISIBLE],
166
+ marketplaceBaseline,
167
+ }
168
+ }
169
+
170
+ export { PATTERNS }
@@ -0,0 +1,326 @@
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, 6040 functions",
93
+ trees: 50,
94
+ functions: 6040,
95
+ lines: 22,
96
+ branches: 6,
97
+ depth: 3,
98
+ // The histogram of each measure over the 6040 functions, value to count,
99
+ // from which a function's percentile rank among listed functions is read.
100
+ distribution: Object.freeze({
101
+ lines: Object.freeze({ 1: 330, 2: 185, 3: 682, 4: 659, 5: 629, 6: 514, 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 }),
102
+ branches: Object.freeze({ 0: 2336, 1: 1096, 2: 807, 3: 454, 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 }),
103
+ depth: Object.freeze({ 0: 2606, 1: 1938, 2: 859, 3: 327, 4: 133, 5: 71, 6: 60, 7: 26, 8: 8, 9: 6, 10: 2, 13: 2, 15: 1, 18: 1 }),
104
+ }),
105
+ })
106
+
107
+
108
+ /**
109
+ * Where a value sits among the sample's: the share of listed functions
110
+ * with a smaller value, as a percentage, so the smallest listed value ranks
111
+ * 0 and a value over every listed one ranks 100. A function's rank is the
112
+ * largest of its three, since one long measure is what a reader sees.
113
+ */
114
+ export function percentile(measure, value) {
115
+ const histogram = SIZE.distribution[measure]
116
+ let below = 0
117
+ for (const [key, count] of Object.entries(histogram)) if (Number(key) < value) below += count
118
+ return Math.round((below / SIZE.functions) * 1000) / 10
119
+ }
120
+
121
+ export function rankOf(entry) {
122
+ return Math.max(percentile("lines", entry.lines), percentile("branches", entry.branches), percentile("depth", entry.depth))
123
+ }
124
+
125
+ /**
126
+ * The size score of a tree: 10 minus the mean rank of its functions among
127
+ * the 6040 listed ones, on 0 to 10 with two decimals. A tree of median
128
+ * functions scores 5.00; every function made shorter, flatter or less
129
+ * branched raises it. It says where the tree sits among listed plugins,
130
+ * never whether it is good, and a tree with no function has no score.
131
+ */
132
+ export function sizeScore(functions) {
133
+ if (!functions.length) return null
134
+ const mean = functions.reduce((sum, entry) => sum + rankOf(entry), 0) / functions.length
135
+ return Math.round((10 - mean / 10) * 100) / 100
136
+ }
137
+
138
+ /** The functions over any of the thresholds, longest first, then most branched. */
139
+ export function overSize(functions) {
140
+ return functions
141
+ .filter((entry) => entry.lines > SIZE.lines || entry.branches > SIZE.branches || entry.depth > SIZE.depth)
142
+ .sort((a, b) => b.lines - a.lines || b.branches - a.branches || b.depth - a.depth || a.file.localeCompare(b.file) || a.line - b.line)
143
+ }
144
+
145
+ export const PATTERNS = Object.freeze([
146
+ {
147
+ id: "process-lifecycle",
148
+ label: "process lifecycle",
149
+ notObserved: "no process without a deadline",
150
+ measurement: "M11",
151
+ share: 0.20,
152
+ sample: SAMPLE,
153
+ precondition: ({ processes }) => {
154
+ const rows = processes.filter((row) => row.declaredIn === "qml" && !row.detached && !row.deadline.observed)
155
+ return { sites: rows.map(site), observation: `observed ${plural(rows.length, "process", "processes")} with no deadline (${sites(rows)})` }
156
+ },
157
+ },
158
+ {
159
+ id: "unbounded-buffering",
160
+ label: "unbounded buffering",
161
+ notObserved: "no collector without a cap",
162
+ measurement: "M11",
163
+ share: 0.19,
164
+ sample: SAMPLE,
165
+ precondition: ({ processes }) => {
166
+ const rows = processes.filter((row) => (row.output.collector === "StdioCollector" || row.output.collector === "SplitParser") && !row.output.capObserved)
167
+ return { sites: rows.map(site), observation: `observed ${plural(rows.length, "collector")} with no cap (${sites(rows)})` }
168
+ },
169
+ },
170
+ {
171
+ id: "file-and-state-boundary",
172
+ label: "file and state boundary",
173
+ notObserved: "no write outside a controlled directory",
174
+ measurement: "M11",
175
+ share: 0.15,
176
+ sample: SAMPLE,
177
+ precondition: ({ writes }) => {
178
+ const outside = writes.filter((row) => row.controlledDirectory === "not-observed" && row.via !== "mktemp")
179
+ const unmoded = writes.filter((row) => row.via === "mkdir" && row.mode === null)
180
+ const parts = []
181
+ if (outside.length) parts.push(`${plural(outside.length, "write")} outside a controlled directory (${sites(outside)})`)
182
+ if (unmoded.length) parts.push(`${plural(unmoded.length, "mkdir")} with no mode (${sites(unmoded)})`)
183
+ return { sites: [...outside, ...unmoded].map(site), observation: `observed ${parts.join("; ")}` }
184
+ },
185
+ },
186
+ {
187
+ id: "environment-trust",
188
+ label: "environment trust",
189
+ notObserved: "no tool resolved from PATH, no curl without -q",
190
+ measurement: "M11",
191
+ share: 0.07,
192
+ sample: SAMPLE,
193
+ precondition: ({ processes, hosts }) => {
194
+ const resolved = processes.map((row) => ({ row, tool: pathResolved(row) })).filter((entry) => entry.tool)
195
+ const curls = hosts.filter((row) => row.tool === "curl" && !hasFlag(row.flags, "q", "--disable"))
196
+ const parts = []
197
+ const names = [...new Set(resolved.map((entry) => entry.tool))]
198
+ const named = names.length > 6 ? `${names.slice(0, 6).join(", ")} and ${names.length - 6} more` : names.join(", ")
199
+ if (resolved.length) parts.push(`${plural(resolved.length, "tool")} resolved from PATH (${named}; ${sites(resolved.map((entry) => entry.row))})`)
200
+ if (curls.length) parts.push(`curl without -q (${sites(curls)})`)
201
+ return { sites: [...resolved.map((entry) => entry.row), ...curls].map(site), observation: `observed ${parts.join("; ")}` }
202
+ },
203
+ },
204
+ {
205
+ id: "secrets",
206
+ label: "secrets",
207
+ notObserved: "no secret-shaped argv or log line",
208
+ measurement: "M11",
209
+ share: 0.07,
210
+ sample: SAMPLE,
211
+ precondition: ({ processes, files }) => {
212
+ const argv = processes.filter((row) => Array.isArray(row.argv) && row.argv.some((word) => SECRET.test(word)))
213
+ const copies = processes.filter((row) => Array.isArray(row.argv) && basename(toolOf(row.argv).tool || "") === "wl-copy" && row.expressions.length > 0)
214
+ const logs = secretLogs(files)
215
+ const parts = []
216
+ if (argv.length) parts.push(`secret-shaped argv (${sites(argv)})`)
217
+ if (logs.length) parts.push(`secret-shaped log line (${sites(logs)})`)
218
+ if (copies.length) parts.push(`wl-copy with a computed value (${sites(copies)})`)
219
+ return { sites: [...argv, ...logs, ...copies].map(site), observation: `observed ${parts.join("; ")}` }
220
+ },
221
+ },
222
+ {
223
+ id: "supply-chain",
224
+ label: "supply chain",
225
+ notObserved: "no unpinned remote source in the baseline",
226
+ measurement: "M11",
227
+ also: ["M4"],
228
+ share: 0.07,
229
+ sample: SAMPLE,
230
+ precondition: ({ baseline, blockingRules }) => {
231
+ const findings = (baseline?.official?.findings || []).filter((finding) => !blockingRules.includes(finding.ruleId))
232
+ const evidence = findings.flatMap((finding) => (finding.evidence || []).map((entry) => ({ file: entry.path, line: entry.line })))
233
+ return {
234
+ sites: evidence,
235
+ observation: `observed ${plural(findings.length, "baseline finding")} the pin does not block on (${findings.map((finding) => finding.ruleId).join(", ")}; ${sites(evidence)})`,
236
+ }
237
+ },
238
+ },
239
+ {
240
+ id: "network-egress",
241
+ label: "network egress",
242
+ notObserved: "no http scheme, no -L without --proto, no private address",
243
+ measurement: "M11",
244
+ share: 0.05,
245
+ sample: SAMPLE,
246
+ precondition: ({ hosts }) => {
247
+ const plain = hosts.filter((row) => row.scheme === "http")
248
+ const redirects = hosts.filter((row) => row.tool === "curl" && hasFlag(row.flags, "L", "--location") && !row.flags.some((flag) => flag.startsWith("--proto")))
249
+ const local = hosts.filter((row) => row.privateAddress)
250
+ const parts = []
251
+ if (plain.length) parts.push(`http scheme (${sites(plain)})`)
252
+ if (redirects.length) parts.push(`curl -L without --proto (${sites(redirects)})`)
253
+ if (local.length) parts.push(`private or loopback address (${sites(local)})`)
254
+ return { sites: [...plain, ...redirects, ...local].map(site), observation: `observed ${parts.join("; ")}` }
255
+ },
256
+ },
257
+ {
258
+ id: "untrusted-text-to-display",
259
+ label: "untrusted text to display",
260
+ notObserved: "no Text bound to output without Text.PlainText",
261
+ measurement: "M11",
262
+ share: 0.05,
263
+ sample: SAMPLE,
264
+ precondition: ({ files }) => {
265
+ const found = richTextSinks(files)
266
+ return { sites: found, observation: `observed ${plural(found.length, "Text")} bound to a collector's output without Text.PlainText (${sites(found)})` }
267
+ },
268
+ },
269
+ {
270
+ id: "argument-grammar",
271
+ label: "argument grammar",
272
+ notObserved: "no expression inside an argv element",
273
+ measurement: "M11",
274
+ share: 0.04,
275
+ sample: SAMPLE,
276
+ precondition: ({ processes }) => {
277
+ const rows = processes.filter((row) => row.expressions.length > 0)
278
+ return { sites: rows.map(site), observation: `observed ${plural(rows.length, "argv")} with a computed element (${sites(rows)})` }
279
+ },
280
+ },
281
+ {
282
+ id: "privilege-disclosure",
283
+ label: "privilege disclosure",
284
+ notObserved: "no sudo, pkexec, docker or /dev/input in argv",
285
+ measurement: "M11",
286
+ also: ["M4"],
287
+ share: 0.03,
288
+ sample: SAMPLE,
289
+ precondition: ({ processes, readme }) => {
290
+ const found = []
291
+ for (const row of processes) {
292
+ if (!Array.isArray(row.argv)) continue
293
+ const names = [...new Set(row.argv.flatMap((word) => (PRIVILEGED.has(basename(word)) ? [basename(word)] : word.includes("/dev/input") ? ["/dev/input"] : [])))]
294
+ if (names.length) found.push({ row, names })
295
+ }
296
+ const names = [...new Set(found.flatMap((entry) => entry.names))]
297
+ const named = names.filter((name) => readme && new RegExp(name.replace(/[/]/g, "\\/"), "i").test(readme))
298
+ 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(", ")}`
299
+ return { sites: found.map((entry) => site(entry.row)), observation: `observed ${names.join(", ")} in argv (${sites(found.map((entry) => entry.row))}); ${readmeNote}` }
300
+ },
301
+ },
302
+ ])
303
+
304
+ /** The observation without its site lists and without the leading word, for a row whose sites are marked elsewhere. */
305
+ export function summaryOf(observation) {
306
+ const SITE = "[\\w./-]+:\\d+"
307
+ return observation
308
+ .replace(new RegExp(`;\\s*${SITE}(?:, ${SITE})*(?=\\))`, "g"), "")
309
+ .replace(new RegExp(`\\s*\\(${SITE}(?:, ${SITE})*\\)`, "g"), "")
310
+ .replace(/^observed /, "")
311
+ }
312
+
313
+ /**
314
+ * @param {{ processes, hosts, writes, timers, files, readme, baseline, blockingRules }} facts the observed facts of one document
315
+ * @returns {{ patterns: Array, lookedFor: string[] }}
316
+ */
317
+ export function evaluatePatterns(facts) {
318
+ const patterns = []
319
+ const lookedFor = []
320
+ for (const pattern of PATTERNS) {
321
+ const found = pattern.precondition(facts)
322
+ 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 })
323
+ else lookedFor.push(pattern.id)
324
+ }
325
+ return { patterns, lookedFor }
326
+ }