omakit 0.4.1 → 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,129 @@
1
+ // Function sites: every named function, QML handler, shell function and
2
+ // Python def, with its length in lines, its deepest nesting and its branch
3
+ // count. Size is the one thing a person asks about a tree that regular
4
+ // expressions can answer without a parser: where does the logic pile up.
5
+ // The numbers are counts over the text; the threshold they are compared
6
+ // with is measured over listed trees (M12), never chosen.
7
+
8
+ import { blankComments, closingBracket, lineOf } from "./text.mjs"
9
+
10
+ const JS_BRANCH = /\b(?:if|else if|for|while|do|switch|case|catch)\b|&&|\|\||\?[^.:]/g
11
+ const SHELL_OPEN = /^\s*(?:if|for|while|until|case|select)\b/
12
+ const SHELL_CLOSE = /^\s*(?:fi|done|esac)\b/
13
+ const SHELL_BRANCH = /\b(?:if|elif|for|while|until|case)\b|\|\||&&|^\s*[^)]*\)\s*(?!\s*$)/
14
+ const PY_BRANCH = /^\s*(?:if|elif|for|while|except|with)\b|\band\b|\bor\b/
15
+
16
+ /** Deepest brace nesting inside a body, relative to the body itself. */
17
+ function braceDepth(body) {
18
+ let depth = 0
19
+ let deepest = 0
20
+ let quote = null
21
+ for (let i = 0; i < body.length; i += 1) {
22
+ const ch = body[i]
23
+ if (quote) {
24
+ if (ch === "\\") i += 1
25
+ else if (ch === quote) quote = null
26
+ continue
27
+ }
28
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch
29
+ else if (ch === "{") {
30
+ depth += 1
31
+ if (depth > deepest) deepest = depth
32
+ } else if (ch === "}") depth -= 1
33
+ }
34
+ return deepest
35
+ }
36
+
37
+ function jsFunctions(file) {
38
+ const text = blankComments(file.text)
39
+ const rows = []
40
+ // `function name(` and, in QML, `onSomething: {` handlers with a body block.
41
+ const pattern = /(?<![\w.])function\s+([A-Za-z_$][\w$]*)\s*\(|(?<![\w.])(on[A-Z]\w*)\s*:\s*(?=\{)/g
42
+ for (const match of text.matchAll(pattern)) {
43
+ const open = text.indexOf("{", match.index + match[0].length - (match[2] ? 0 : 0))
44
+ if (open < 0) continue
45
+ const end = closingBracket(text, open)
46
+ if (end < 0) continue
47
+ const body = text.slice(open + 1, end)
48
+ const start = lineOf(text, match.index)
49
+ rows.push({
50
+ file: file.path,
51
+ line: start,
52
+ name: match[1] || match[2],
53
+ kind: match[1] ? "function" : "handler",
54
+ lines: lineOf(text, end) - start + 1,
55
+ depth: braceDepth(body),
56
+ branches: (body.match(JS_BRANCH) || []).length,
57
+ })
58
+ }
59
+ return rows
60
+ }
61
+
62
+ function shellFunctions(file) {
63
+ const lines = file.text.split("\n")
64
+ const rows = []
65
+ for (let index = 0; index < lines.length; index += 1) {
66
+ const head = lines[index].match(/^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\)\s*\{?\s*$|^\s*function\s+([A-Za-z_][\w-]*)\s*\{?\s*$/)
67
+ if (!head) continue
68
+ const name = head[1] || head[2]
69
+ // The body runs to the line that is only `}` at the function's own indent.
70
+ const indent = lines[index].match(/^\s*/)[0]
71
+ let end = index
72
+ let depth = 0
73
+ let deepest = 0
74
+ let branches = 0
75
+ for (let at = index + 1; at < lines.length; at += 1) {
76
+ const line = lines[at]
77
+ if (line.trim() === "}" && line.startsWith(indent) && line.match(/^\s*/)[0].length === indent.length) {
78
+ end = at
79
+ break
80
+ }
81
+ if (SHELL_OPEN.test(line)) {
82
+ depth += 1
83
+ if (depth > deepest) deepest = depth
84
+ }
85
+ if (SHELL_CLOSE.test(line)) depth -= 1
86
+ if (SHELL_BRANCH.test(line.split("#")[0])) branches += 1
87
+ end = at
88
+ }
89
+ rows.push({ file: file.path, line: index + 1, name, kind: "function", lines: end - index + 1, depth: deepest, branches })
90
+ index = end
91
+ }
92
+ return rows
93
+ }
94
+
95
+ function pythonFunctions(file) {
96
+ const lines = file.text.split("\n")
97
+ const rows = []
98
+ for (let index = 0; index < lines.length; index += 1) {
99
+ const head = lines[index].match(/^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/)
100
+ if (!head) continue
101
+ const base = head[1].length
102
+ let end = index
103
+ let deepest = 0
104
+ let branches = 0
105
+ for (let at = index + 1; at < lines.length; at += 1) {
106
+ const line = lines[at]
107
+ if (!line.trim()) continue
108
+ const indent = line.match(/^\s*/)[0].length
109
+ if (indent <= base) break
110
+ const level = Math.floor((indent - base) / 4)
111
+ if (level > deepest) deepest = level
112
+ if (PY_BRANCH.test(line)) branches += 1
113
+ end = at
114
+ }
115
+ rows.push({ file: file.path, line: index + 1, name: head[2], kind: "function", lines: end - index + 1, depth: deepest, branches })
116
+ }
117
+ return rows
118
+ }
119
+
120
+ /**
121
+ * @param {{ path: string, kind: string, text: string }} file
122
+ * @returns {Array<{ file, line, name, kind, lines, depth, branches }>}
123
+ */
124
+ export function extractFunctions(file) {
125
+ if (file.kind === "qml" || file.kind === "js") return jsFunctions(file)
126
+ if (file.kind === "shell") return shellFunctions(file)
127
+ if (file.kind === "python") return pythonFunctions(file)
128
+ return []
129
+ }
@@ -0,0 +1,141 @@
1
+ // Host sites: every `http://` or `https://` literal in code or argv, with
2
+ // the host and scheme it names, the tool the literal reaches (the argv it
3
+ // sits in, or the call on its line), and the timeout and size-cap flags
4
+ // that argv shows. A host built from a variable is not a host: a literal
5
+ // whose host part holds `${...}` or stops at `://` is recorded as not
6
+ // resolvable and never guessed.
7
+
8
+ import { basename, blankComments, lineOf, shellSegments, shellWords } from "./text.mjs"
9
+ import { toolOf } from "./processes.mjs"
10
+
11
+ const URL = /https?:\/\/[^\s"'`)\]}>,;]*/g
12
+ const TIMEOUT_FLAGS = ["--max-time", "-m", "--connect-timeout", "--timeout", "-T"]
13
+ const SIZE_FLAGS = ["--max-filesize", "--quota", "-Q"]
14
+
15
+ /** Loopback, link-local and RFC 1918 literals, and the names that resolve there. */
16
+ export function privateAddress(host) {
17
+ const h = String(host).toLowerCase().replace(/^\[|\]$/g, "")
18
+ if (["localhost", "0.0.0.0", "::1", "::"].includes(h)) return true
19
+ const v4 = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)
20
+ if (!v4) return /^f[cd][0-9a-f]{2}:|^fe80:/.test(h)
21
+ const [a, b] = [Number(v4[1]), Number(v4[2])]
22
+ return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)
23
+ }
24
+
25
+ /** The host of a URL literal, or null when the text shows an expression where the host would be. */
26
+ export function hostOf(url) {
27
+ const rest = url.replace(/^https?:\/\//, "")
28
+ const authority = rest.split(/[/?#]/)[0]
29
+ if (!authority || /\$\{|\$\(|["'`+]/.test(authority)) return null
30
+ const host = authority.includes("@") ? authority.slice(authority.lastIndexOf("@") + 1) : authority
31
+ const bare = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0]
32
+ return bare && /^[\w.\-[\]:]+$/.test(bare) ? bare : null
33
+ }
34
+
35
+ /** The flag with its value: `--max-time 5`, `--max-time=5`, `-m5`. */
36
+ function flagValue(words, names) {
37
+ for (const [index, word] of words.entries()) {
38
+ for (const name of names) {
39
+ if (word === name) return `${name} ${words[index + 1] ?? ""}`.trim()
40
+ if (word.startsWith(`${name}=`)) return word
41
+ if (name.length === 2 && word.startsWith(name) && word.length > 2 && /\d/.test(word[2])) return word
42
+ }
43
+ }
44
+ return null
45
+ }
46
+
47
+ /** The words a URL is invoked with: a `-c` script's own words when the URL sits inside one, otherwise the argv. */
48
+ function wordsAround(process, url) {
49
+ const argv = process.argv
50
+ const at = argv.findIndex((word) => word.includes(url))
51
+ if (at < 0 || !process.shellWrapper) return argv
52
+ const script = toolOf(argv).index + 2
53
+ if (at !== script) return argv
54
+ // The URL is a word inside the script string: the words of its own segment.
55
+ for (const segment of shellSegments(argv[at])) {
56
+ if (segment.text.includes(url)) return shellWords(segment.text)
57
+ }
58
+ return shellWords(argv[at])
59
+ }
60
+
61
+ function caps(words, wholeArgv) {
62
+ const joined = wholeArgv.join(" ")
63
+ const timeout = flagValue(words, TIMEOUT_FLAGS)
64
+ || (wholeArgv.some((word) => basename(word) === "timeout") ? `timeout ${wholeArgv[wholeArgv.findIndex((word) => basename(word) === "timeout") + 1] ?? ""}`.trim() : null)
65
+ const head = joined.match(/(?:^|[\s|/])head\s+(?:-\S+\s+)*-c\s+(\S+)/)
66
+ const size = flagValue(words, SIZE_FLAGS) || (head ? `head -c ${head[1]}` : null)
67
+ return {
68
+ timeout: { observed: Boolean(timeout), via: timeout },
69
+ sizeCap: { observed: Boolean(size), via: size },
70
+ }
71
+ }
72
+
73
+ /** Shell and Python comments blanked, quotes respected, line count kept. */
74
+ function blankHashComments(text) {
75
+ return text.split("\n").map((line) => {
76
+ let quote = null
77
+ for (let i = 0; i < line.length; i += 1) {
78
+ const ch = line[i]
79
+ if (quote) {
80
+ if (ch === "\\") i += 1
81
+ else if (ch === quote) quote = null
82
+ } else if (ch === '"' || ch === "'") quote = ch
83
+ else if (ch === "#" && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
84
+ }
85
+ return line
86
+ }).join("\n")
87
+ }
88
+
89
+ function callOnLine(line, text) {
90
+ if (/(?<![\w.])fetch\s*\(/.test(line)) return "fetch"
91
+ if (/XMLHttpRequest/.test(line) || /XMLHttpRequest/.test(text)) return "XMLHttpRequest"
92
+ const call = line.match(/([A-Za-z_][\w.]*)\s*\([^()]*https?:\/\//)
93
+ return call ? call[1] : null
94
+ }
95
+
96
+ /**
97
+ * @param {{ path: string, kind: string, text: string }} file
98
+ * @param {Array} processes the process rows of the same file
99
+ * @returns {{ hosts: Array, notResolvable: Array }}
100
+ */
101
+ export function extractHosts(file, processes = []) {
102
+ const text = file.kind === "qml" || file.kind === "js" ? blankComments(file.text) : file.kind === "shell" || file.kind === "python" ? blankHashComments(file.text) : file.text
103
+ const hosts = []
104
+ const notResolvable = []
105
+ for (const match of text.matchAll(URL)) {
106
+ const url = match[0]
107
+ const line = lineOf(text, match.index)
108
+ const scheme = url.startsWith("https") ? "https" : "http"
109
+ const host = hostOf(url)
110
+ if (!host) {
111
+ notResolvable.push({ file: file.path, line, kind: "host", text: url })
112
+ continue
113
+ }
114
+ const process = processes.find((row) => row.file === file.path && Array.isArray(row.argv) && row.argv.some((word) => word.includes(url)) && (row.declaredIn !== "shell" || row.line === line))
115
+ let tool = null
116
+ let words = []
117
+ let argv = []
118
+ if (process) {
119
+ argv = process.argv
120
+ words = wordsAround(process, url)
121
+ tool = toolOf(words).tool
122
+ if (tool) tool = basename(tool)
123
+ } else {
124
+ const lineText = text.split("\n")[line - 1] || ""
125
+ tool = callOnLine(lineText, text)
126
+ }
127
+ const { timeout, sizeCap } = caps(words, argv)
128
+ hosts.push({
129
+ host,
130
+ scheme,
131
+ file: file.path,
132
+ line,
133
+ tool,
134
+ timeout,
135
+ sizeCap,
136
+ flags: words.filter((word) => word.startsWith("-") && word !== "-"),
137
+ privateAddress: privateAddress(host),
138
+ })
139
+ }
140
+ return { hosts, notResolvable }
141
+ }
@@ -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 }