shell-aliasforge 0.1.0 → 0.1.1
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/dist/analyze/normalize.js +63 -0
- package/dist/analyze/stats.js +44 -10
- package/dist/analyze/suggest.js +4 -6
- package/dist/risks/engine.js +17 -19
- package/dist/risks/packs/core.js +12 -1
- package/package.json +1 -1
|
@@ -95,3 +95,66 @@ export function isBalanced(command) {
|
|
|
95
95
|
}
|
|
96
96
|
return quote === "" && parens === 0 && braces === 0 && backticks % 2 === 0;
|
|
97
97
|
}
|
|
98
|
+
function isLineBreak(raw, index) {
|
|
99
|
+
return raw[index] === "\n" || (raw[index] === "\r" && raw[index + 1] === "\n");
|
|
100
|
+
}
|
|
101
|
+
/** Index of the end of a comment that starts at `index`, i.e. of the next newline. */
|
|
102
|
+
function commentEnd(raw, index) {
|
|
103
|
+
const end = raw.indexOf("\n", index);
|
|
104
|
+
return end === -1 ? raw.length : end;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Split one stored history entry into the command lines it contains.
|
|
108
|
+
*
|
|
109
|
+
* - A backslash before a newline is a continuation: the lines are joined with a space.
|
|
110
|
+
* - Any other newline outside quotes ends a command (pasted scripts).
|
|
111
|
+
* - A newline inside quotes stays part of that command.
|
|
112
|
+
* - A `#` that starts a word begins a comment, so an apostrophe in a comment
|
|
113
|
+
* cannot swallow the lines after it.
|
|
114
|
+
*
|
|
115
|
+
* Each line is normalized and empty ones are dropped.
|
|
116
|
+
*/
|
|
117
|
+
export function commandLines(raw) {
|
|
118
|
+
const lines = [];
|
|
119
|
+
let current = "";
|
|
120
|
+
let quote = "";
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < raw.length) {
|
|
123
|
+
const char = raw[i];
|
|
124
|
+
if (char === "\\" && quote !== "'" && isLineBreak(raw, i + 1)) {
|
|
125
|
+
current += " ";
|
|
126
|
+
i += raw[i + 1] === "\r" ? 3 : 2;
|
|
127
|
+
}
|
|
128
|
+
else if (char === "\\" && quote !== "'" && i + 1 < raw.length) {
|
|
129
|
+
current += char + raw[i + 1];
|
|
130
|
+
i += 2;
|
|
131
|
+
}
|
|
132
|
+
else if (quote) {
|
|
133
|
+
if (char === quote)
|
|
134
|
+
quote = "";
|
|
135
|
+
current += char;
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
else if (char === "#" && (current === "" || /\s$/.test(current))) {
|
|
139
|
+
const end = commentEnd(raw, i);
|
|
140
|
+
current += raw.slice(i, end);
|
|
141
|
+
i = end;
|
|
142
|
+
}
|
|
143
|
+
else if (char === "'" || char === '"') {
|
|
144
|
+
quote = char;
|
|
145
|
+
current += char;
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
else if (isLineBreak(raw, i)) {
|
|
149
|
+
lines.push(current);
|
|
150
|
+
current = "";
|
|
151
|
+
i += char === "\r" ? 2 : 1;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
current += char;
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
lines.push(current);
|
|
159
|
+
return lines.map(normalizeCommand).filter((line) => line !== "");
|
|
160
|
+
}
|
package/dist/analyze/stats.js
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { commandLines, splitSubcommands, tokenize } from "./normalize.js";
|
|
2
2
|
const ASSIGNMENT = /^[A-Za-z_]\w*=/;
|
|
3
3
|
/** Words that run another command, so the real program is the next word. */
|
|
4
4
|
const WRAPPERS = new Set(["sudo", "doas", "time", "nohup", "command", "env", "exec", "nice", "builtin"]);
|
|
5
5
|
/** Shell syntax that can start a sub-command without being a program. */
|
|
6
6
|
const SYNTAX = new Set(["do", "then", "else", "elif", "!", "{", "(", "if", "while", "until"]);
|
|
7
7
|
const DAY_SECONDS = 86_400;
|
|
8
|
+
/** Options of each wrapper that consume the next word, so it is not mistaken for the program. */
|
|
9
|
+
const WRAPPER_OPTIONS = {
|
|
10
|
+
sudo: {
|
|
11
|
+
short: "ughpCDRTUrt",
|
|
12
|
+
long: ["--user", "--group", "--host", "--prompt", "--close-from", "--chdir", "--chroot", "--command-timeout", "--other-user", "--role", "--type"],
|
|
13
|
+
},
|
|
14
|
+
doas: { short: "uC", long: [] },
|
|
15
|
+
env: { short: "uCS", long: ["--unset", "--chdir", "--split-string"] },
|
|
16
|
+
nice: { short: "n", long: ["--adjustment"] },
|
|
17
|
+
time: { short: "fo", long: ["--format", "--output"] },
|
|
18
|
+
exec: { short: "a", long: [] },
|
|
19
|
+
};
|
|
20
|
+
/** Number of words an option word uses: 1 alone, 2 when its value is the next word. */
|
|
21
|
+
function optionWidth(option, wrapper) {
|
|
22
|
+
if (option.startsWith("--")) {
|
|
23
|
+
return !option.includes("=") && wrapper.long.includes(option) ? 2 : 1;
|
|
24
|
+
}
|
|
25
|
+
const letters = option.slice(1);
|
|
26
|
+
if (letters === "")
|
|
27
|
+
return 1;
|
|
28
|
+
const at = [...letters].findIndex((letter) => wrapper.short.includes(letter));
|
|
29
|
+
// `-uroot` carries its value; `-Eu` and `-u` take the next word.
|
|
30
|
+
return at === letters.length - 1 ? 2 : 1;
|
|
31
|
+
}
|
|
32
|
+
function skipWrapperOptions(tokens, start, wrapperName) {
|
|
33
|
+
const wrapper = WRAPPER_OPTIONS[wrapperName] ?? { short: "", long: [] };
|
|
34
|
+
let i = start;
|
|
35
|
+
while (i < tokens.length && tokens[i].startsWith("-")) {
|
|
36
|
+
const token = tokens[i];
|
|
37
|
+
if (token === "--")
|
|
38
|
+
return i + 1;
|
|
39
|
+
i += optionWidth(token, wrapper);
|
|
40
|
+
}
|
|
41
|
+
return i;
|
|
42
|
+
}
|
|
8
43
|
/** The program a sub-command runs, skipping `VAR=x`, `sudo`, `time` and similar. */
|
|
9
44
|
export function programOf(unit) {
|
|
10
45
|
const tokens = tokenize(unit);
|
|
@@ -15,9 +50,7 @@ export function programOf(unit) {
|
|
|
15
50
|
i++;
|
|
16
51
|
}
|
|
17
52
|
else if (WRAPPERS.has(token)) {
|
|
18
|
-
i
|
|
19
|
-
while (i < tokens.length && tokens[i].startsWith("-"))
|
|
20
|
-
i++;
|
|
53
|
+
i = skipWrapperOptions(tokens, i + 1, token);
|
|
21
54
|
}
|
|
22
55
|
else {
|
|
23
56
|
break;
|
|
@@ -40,10 +73,12 @@ function validTimestamps(entries) {
|
|
|
40
73
|
function countPrograms(entries) {
|
|
41
74
|
const counts = new Map();
|
|
42
75
|
for (const entry of entries) {
|
|
43
|
-
for (const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
76
|
+
for (const line of commandLines(entry.command)) {
|
|
77
|
+
for (const unit of splitSubcommands(line)) {
|
|
78
|
+
const program = programOf(unit);
|
|
79
|
+
if (program !== undefined)
|
|
80
|
+
counts.set(program, (counts.get(program) ?? 0) + 1);
|
|
81
|
+
}
|
|
47
82
|
}
|
|
48
83
|
}
|
|
49
84
|
return [...counts]
|
|
@@ -52,8 +87,7 @@ function countPrograms(entries) {
|
|
|
52
87
|
}
|
|
53
88
|
/** Summarize parsed history: size, most-used programs and time span. */
|
|
54
89
|
export function computeStats(entries, top) {
|
|
55
|
-
const unique = new Set(entries.
|
|
56
|
-
unique.delete("");
|
|
90
|
+
const unique = new Set(entries.flatMap((entry) => commandLines(entry.command)));
|
|
57
91
|
const times = validTimestamps(entries);
|
|
58
92
|
const stats = {
|
|
59
93
|
historyEntries: entries.length,
|
package/dist/analyze/suggest.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { proposeNames } from "./naming.js";
|
|
2
|
-
import {
|
|
2
|
+
import { commandLines, splitSubcommands } from "./normalize.js";
|
|
3
3
|
import { renderDefinition } from "./render.js";
|
|
4
4
|
import { aliasScore } from "./score.js";
|
|
5
5
|
import { buildCandidates, kindOf } from "./templates.js";
|
|
6
|
-
/**
|
|
6
|
+
/** Each command line plus its sub-commands, counted once per entry. */
|
|
7
7
|
function unitsOf(entry) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
return [];
|
|
11
|
-
return [...new Set([line, ...splitSubcommands(line)])];
|
|
8
|
+
const lines = commandLines(entry.command).filter((line) => !line.startsWith("#"));
|
|
9
|
+
return [...new Set(lines.flatMap((line) => [line, ...splitSubcommands(line)]))];
|
|
12
10
|
}
|
|
13
11
|
function candidatePool(entries, options) {
|
|
14
12
|
const units = entries.map(unitsOf).filter((list) => list.length > 0);
|
package/dist/risks/engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { commandLines } from "../analyze/normalize.js";
|
|
2
2
|
import { maskRanges, shorten } from "./mask.js";
|
|
3
3
|
import { packs as defaultPacks } from "./packs/index.js";
|
|
4
4
|
/** Longer command lines are scanned only up to this many characters. */
|
|
@@ -50,27 +50,25 @@ function hasMatch(text, rule) {
|
|
|
50
50
|
rule.regex.lastIndex = 0;
|
|
51
51
|
return rule.regex.test(text);
|
|
52
52
|
}
|
|
53
|
+
function scanLine(text, entry, timestamp, rules) {
|
|
54
|
+
const secrets = findSecrets(text, rules.secrets);
|
|
55
|
+
const preview = shorten(maskRanges(text, secrets.map((hit) => hit.range)));
|
|
56
|
+
const matched = [
|
|
57
|
+
...secrets.map((hit) => hit.pattern),
|
|
58
|
+
...rules.dangers.filter((rule) => hasMatch(text, rule)).map((rule) => rule.pattern),
|
|
59
|
+
];
|
|
60
|
+
return matched.map((pattern) => toFinding(pattern, entry, timestamp, preview));
|
|
61
|
+
}
|
|
53
62
|
/**
|
|
54
63
|
* Scan history entries for dangerous commands and secrets. Pure text matching:
|
|
55
64
|
* nothing from history is ever executed. Every preview has secrets masked.
|
|
65
|
+
* An entry with several commands is scanned one command at a time.
|
|
56
66
|
*/
|
|
57
67
|
export function scanHistory(entries, packList = defaultPacks) {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (text === "")
|
|
65
|
-
return;
|
|
66
|
-
const secrets = findSecrets(text, secretRules);
|
|
67
|
-
const preview = shorten(maskRanges(text, secrets.map((hit) => hit.range)));
|
|
68
|
-
const matched = [
|
|
69
|
-
...secrets.map((hit) => hit.pattern),
|
|
70
|
-
...dangerRules.filter((rule) => hasMatch(text, rule)).map((rule) => rule.pattern),
|
|
71
|
-
];
|
|
72
|
-
for (const pattern of matched)
|
|
73
|
-
findings.push(toFinding(pattern, index + 1, entry.timestamp, preview));
|
|
74
|
-
});
|
|
75
|
-
return findings;
|
|
68
|
+
const compiled = packList.flatMap((pack) => pack.patterns).map(compile);
|
|
69
|
+
const rules = {
|
|
70
|
+
secrets: compiled.filter((rule) => rule.pattern.category === "secret"),
|
|
71
|
+
dangers: compiled.filter((rule) => rule.pattern.category === "danger"),
|
|
72
|
+
};
|
|
73
|
+
return entries.flatMap((entry, index) => commandLines(entry.command).flatMap((line) => scanLine(line.slice(0, MAX_SCAN_LENGTH), index + 1, entry.timestamp, rules)));
|
|
76
74
|
}
|
package/dist/risks/packs/core.js
CHANGED
|
@@ -4,6 +4,8 @@ const RECURSIVE = R `(?=[^;&|]*\s(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(?=\s|$)
|
|
|
4
4
|
const FORCE = R `(?=[^;&|]*\s(?:-[a-zA-Z]*f[a-zA-Z]*|--force)(?=\s|$))`;
|
|
5
5
|
const SECRET_NAME = R `[A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY)`;
|
|
6
6
|
const VALUE = R `(?<secret>[^\s"'$-][^\s"']*)`;
|
|
7
|
+
// A fish value must not be "=": that is SQL or config syntax, not a fish `set`.
|
|
8
|
+
const FISH_VALUE = R `(?<secret>[^\s"'$=-][^\s"']*)`;
|
|
7
9
|
export const core = [
|
|
8
10
|
{
|
|
9
11
|
id: "core/chmod-recursive-777",
|
|
@@ -143,7 +145,16 @@ export const core = [
|
|
|
143
145
|
severity: "high",
|
|
144
146
|
title: "Secret set in a fish command",
|
|
145
147
|
advice: "Rotate the value. Load secrets from a file or a secret manager instead of typing them.",
|
|
146
|
-
pattern: R
|
|
148
|
+
pattern: R `(?:^|[;&|]\s*)set\s+(?:-\S+\s+)*${SECRET_NAME}\s+["']?${FISH_VALUE}`,
|
|
149
|
+
flags: "i",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "core/quoted-secret-assignment",
|
|
153
|
+
category: "secret",
|
|
154
|
+
severity: "high",
|
|
155
|
+
title: "Quoted secret assigned to a token, password or key",
|
|
156
|
+
advice: "Rotate the value. Keep secrets out of commands, SQL and JSON typed in the shell.",
|
|
157
|
+
pattern: R `[A-Za-z0-9_]*(?:token|secret|passw(?:or)?d|api_?key|access_?key|private_?key)["']?\s*[=:]\s*["'](?<secret>[^"'\s$<{][^"']{5,})["']`,
|
|
147
158
|
flags: "i",
|
|
148
159
|
},
|
|
149
160
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shell-aliasforge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Mine your shell history to suggest aliases and flag risky or secret-leaking commands. Local-first, offline, zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Pratik Surve",
|