akm-cli 0.3.1 → 0.4.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/dist/cli.js +332 -7
- package/dist/common.js +5 -0
- package/dist/config-cli.js +87 -0
- package/dist/config.js +197 -25
- package/dist/init.js +2 -2
- package/dist/install-audit.js +324 -0
- package/dist/installed-kits.js +2 -2
- package/dist/matchers.js +25 -22
- package/dist/registry-install.js +46 -7
- package/dist/setup.js +2 -2
- package/dist/stash-add.js +4 -3
- package/dist/stash-source-manage.js +3 -3
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { filterNonEmptyStrings } from "./common";
|
|
3
4
|
import { getConfigDir as _getConfigDir, getConfigPath as _getConfigPath } from "./paths";
|
|
4
5
|
// ── Defaults ────────────────────────────────────────────────────────────────
|
|
5
6
|
export const DEFAULT_CONFIG = {
|
|
@@ -21,42 +22,47 @@ export function getConfigPath() {
|
|
|
21
22
|
return _getConfigPath();
|
|
22
23
|
}
|
|
23
24
|
// ── Load / Save / Update ────────────────────────────────────────────────────
|
|
25
|
+
const PROJECT_CONFIG_RELATIVE_PATH = path.join(".akm", "config.json");
|
|
24
26
|
let cachedConfig;
|
|
27
|
+
let cachedUserConfig;
|
|
25
28
|
export function resetConfigCache() {
|
|
26
29
|
cachedConfig = undefined;
|
|
30
|
+
cachedUserConfig = undefined;
|
|
27
31
|
}
|
|
28
|
-
export function
|
|
32
|
+
export function loadUserConfig() {
|
|
29
33
|
const configPath = getConfigPath();
|
|
30
34
|
let stat;
|
|
31
35
|
try {
|
|
32
36
|
stat = fs.statSync(configPath);
|
|
33
|
-
if (
|
|
34
|
-
return
|
|
37
|
+
if (cachedUserConfig && cachedUserConfig.path === configPath && cachedUserConfig.mtime === stat.mtimeMs) {
|
|
38
|
+
return cachedUserConfig.config;
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
41
|
catch {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return { ...DEFAULT_CONFIG };
|
|
41
|
-
}
|
|
42
|
-
const raw = readConfigObject(configPath);
|
|
43
|
-
const expanded = raw ? expandEnvVars(raw) : undefined;
|
|
44
|
-
const config = expanded ? pickKnownKeys(expanded) : { ...DEFAULT_CONFIG };
|
|
45
|
-
// Legacy: inject API keys from well-known env vars when not set via ${} substitution
|
|
46
|
-
if (config.embedding && !config.embedding.apiKey) {
|
|
47
|
-
const envKey = process.env.AKM_EMBED_API_KEY?.trim();
|
|
48
|
-
if (envKey)
|
|
49
|
-
config.embedding.apiKey = envKey;
|
|
42
|
+
cachedUserConfig = undefined;
|
|
43
|
+
return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
|
|
50
44
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfig(configPath));
|
|
46
|
+
const finalConfig = applyRuntimeEnvApiKeys(config);
|
|
47
|
+
cachedUserConfig = { config: finalConfig, path: configPath, mtime: stat.mtimeMs };
|
|
48
|
+
return finalConfig;
|
|
49
|
+
}
|
|
50
|
+
export function loadConfig() {
|
|
51
|
+
const configPaths = getEffectiveConfigPaths();
|
|
52
|
+
const signature = getConfigSignature(configPaths);
|
|
53
|
+
if (cachedConfig && cachedConfig.signature === signature) {
|
|
54
|
+
return cachedConfig.config;
|
|
55
|
+
}
|
|
56
|
+
let config = loadUserConfig();
|
|
57
|
+
const userConfigPath = getConfigPath();
|
|
58
|
+
for (const configPath of configPaths) {
|
|
59
|
+
if (configPath === userConfigPath)
|
|
60
|
+
continue;
|
|
61
|
+
config = mergeLoadedConfig(config, readNormalizedConfig(configPath));
|
|
55
62
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return config;
|
|
63
|
+
const finalConfig = applyRuntimeEnvApiKeys(config);
|
|
64
|
+
cachedConfig = { config: finalConfig, signature };
|
|
65
|
+
return finalConfig;
|
|
60
66
|
}
|
|
61
67
|
export function saveConfig(config) {
|
|
62
68
|
cachedConfig = undefined;
|
|
@@ -98,7 +104,7 @@ function sanitizeConfigForWrite(config) {
|
|
|
98
104
|
return sanitized;
|
|
99
105
|
}
|
|
100
106
|
export function updateConfig(partial) {
|
|
101
|
-
const current =
|
|
107
|
+
const current = loadUserConfig();
|
|
102
108
|
// Shallow-merge for top-level scalar fields; deep-merge known object-type config keys.
|
|
103
109
|
const merged = { ...current, ...partial };
|
|
104
110
|
// Deep-merge output — partial update should not wipe sibling keys
|
|
@@ -113,12 +119,22 @@ export function updateConfig(partial) {
|
|
|
113
119
|
if (current.llm && partial.llm && partial.llm !== current.llm) {
|
|
114
120
|
merged.llm = { ...current.llm, ...partial.llm };
|
|
115
121
|
}
|
|
122
|
+
if (current.security && partial.security && partial.security !== current.security) {
|
|
123
|
+
merged.security = mergeSecurityConfig(current.security, partial.security);
|
|
124
|
+
}
|
|
116
125
|
saveConfig(merged);
|
|
117
126
|
return merged;
|
|
118
127
|
}
|
|
119
128
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Normalize a raw config object into a sparse config layer containing only
|
|
131
|
+
* recognized keys that were valid in the source object. This function does not
|
|
132
|
+
* merge with DEFAULT_CONFIG; callers are responsible for layering defaults and
|
|
133
|
+
* combining multiple config sources so project config files only override what
|
|
134
|
+
* they set.
|
|
135
|
+
*/
|
|
120
136
|
function pickKnownKeys(raw) {
|
|
121
|
-
const config = {
|
|
137
|
+
const config = {};
|
|
122
138
|
if (typeof raw.stashDir === "string" && raw.stashDir.trim()) {
|
|
123
139
|
config.stashDir = raw.stashDir.trim();
|
|
124
140
|
}
|
|
@@ -159,14 +175,25 @@ function pickKnownKeys(raw) {
|
|
|
159
175
|
const registries = parseRegistriesConfig(raw.registries);
|
|
160
176
|
if (registries)
|
|
161
177
|
config.registries = registries;
|
|
178
|
+
if (typeof raw.disableGlobalStashes === "boolean") {
|
|
179
|
+
config.disableGlobalStashes = raw.disableGlobalStashes;
|
|
180
|
+
}
|
|
162
181
|
const stashes = parseStashesConfig(raw.stashes);
|
|
163
182
|
if (stashes)
|
|
164
183
|
config.stashes = stashes;
|
|
184
|
+
const security = parseSecurityConfig(raw.security);
|
|
185
|
+
if (security)
|
|
186
|
+
config.security = security;
|
|
165
187
|
const output = parseOutputConfig(raw.output);
|
|
166
188
|
if (output)
|
|
167
189
|
config.output = output;
|
|
168
190
|
return config;
|
|
169
191
|
}
|
|
192
|
+
function readNormalizedConfig(configPath) {
|
|
193
|
+
const raw = readConfigObject(configPath);
|
|
194
|
+
const expanded = raw ? expandEnvVars(raw) : undefined;
|
|
195
|
+
return expanded ? pickKnownKeys(expanded) : undefined;
|
|
196
|
+
}
|
|
170
197
|
function parseOutputConfig(value) {
|
|
171
198
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
172
199
|
return undefined;
|
|
@@ -440,6 +467,32 @@ function parseStashesConfig(value) {
|
|
|
440
467
|
.filter((entry) => entry !== undefined);
|
|
441
468
|
return entries;
|
|
442
469
|
}
|
|
470
|
+
function parseSecurityConfig(value) {
|
|
471
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
472
|
+
return undefined;
|
|
473
|
+
const obj = value;
|
|
474
|
+
const installAudit = parseInstallAuditConfig(obj.installAudit);
|
|
475
|
+
if (!installAudit)
|
|
476
|
+
return undefined;
|
|
477
|
+
return { installAudit };
|
|
478
|
+
}
|
|
479
|
+
function parseInstallAuditConfig(value) {
|
|
480
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
481
|
+
return undefined;
|
|
482
|
+
const obj = value;
|
|
483
|
+
const config = {};
|
|
484
|
+
if (typeof obj.enabled === "boolean")
|
|
485
|
+
config.enabled = obj.enabled;
|
|
486
|
+
if (typeof obj.blockOnCritical === "boolean")
|
|
487
|
+
config.blockOnCritical = obj.blockOnCritical;
|
|
488
|
+
if (typeof obj.blockUnlistedRegistries === "boolean")
|
|
489
|
+
config.blockUnlistedRegistries = obj.blockUnlistedRegistries;
|
|
490
|
+
const rawAllowlist = filterNonEmptyStrings(obj.registryAllowlist) ?? filterNonEmptyStrings(obj.registryWhitelist);
|
|
491
|
+
if (rawAllowlist) {
|
|
492
|
+
config.registryAllowlist = rawAllowlist;
|
|
493
|
+
}
|
|
494
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
495
|
+
}
|
|
443
496
|
function parseStashConfigEntry(value) {
|
|
444
497
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
445
498
|
return undefined;
|
|
@@ -485,3 +538,122 @@ function parseRegistryConfigEntry(value) {
|
|
|
485
538
|
}
|
|
486
539
|
return entry;
|
|
487
540
|
}
|
|
541
|
+
function mergeSecurityConfig(base, override) {
|
|
542
|
+
if (!base && !override)
|
|
543
|
+
return undefined;
|
|
544
|
+
const installAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
|
|
545
|
+
return installAudit ? { installAudit } : undefined;
|
|
546
|
+
}
|
|
547
|
+
function mergeInstallAuditConfig(base, override) {
|
|
548
|
+
if (!base && !override)
|
|
549
|
+
return undefined;
|
|
550
|
+
const merged = {
|
|
551
|
+
...(base ?? {}),
|
|
552
|
+
...(override ?? {}),
|
|
553
|
+
};
|
|
554
|
+
return Object.values(merged).some((value) => value !== undefined) ? merged : undefined;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Merge a normalized config layer into an accumulated config.
|
|
558
|
+
*
|
|
559
|
+
* Scalar fields follow normal override semantics. Known nested objects are
|
|
560
|
+
* deep-merged so project config files can override individual fields without
|
|
561
|
+
* clobbering sibling settings. `stashes` are additive by default, but a later
|
|
562
|
+
* layer can set `disableGlobalStashes: true` to drop inherited stashes first.
|
|
563
|
+
*/
|
|
564
|
+
function mergeLoadedConfig(base, override) {
|
|
565
|
+
if (!override)
|
|
566
|
+
return { ...base };
|
|
567
|
+
const merged = {
|
|
568
|
+
...base,
|
|
569
|
+
...override,
|
|
570
|
+
};
|
|
571
|
+
if (base.output && override.output) {
|
|
572
|
+
merged.output = { ...base.output, ...override.output };
|
|
573
|
+
}
|
|
574
|
+
if (base.embedding && override.embedding) {
|
|
575
|
+
merged.embedding = { ...base.embedding, ...override.embedding };
|
|
576
|
+
}
|
|
577
|
+
if (base.llm && override.llm) {
|
|
578
|
+
merged.llm = { ...base.llm, ...override.llm };
|
|
579
|
+
}
|
|
580
|
+
if (base.security && override.security) {
|
|
581
|
+
merged.security = mergeSecurityConfig(base.security, override.security);
|
|
582
|
+
}
|
|
583
|
+
if (override.disableGlobalStashes) {
|
|
584
|
+
merged.stashes = [...(override.stashes ?? [])];
|
|
585
|
+
}
|
|
586
|
+
else if (override.stashes) {
|
|
587
|
+
merged.stashes = [...(base.stashes ?? []), ...override.stashes];
|
|
588
|
+
}
|
|
589
|
+
return merged;
|
|
590
|
+
}
|
|
591
|
+
function applyRuntimeEnvApiKeys(config) {
|
|
592
|
+
const next = { ...config };
|
|
593
|
+
if (next.embedding && !next.embedding.apiKey) {
|
|
594
|
+
const envKey = process.env.AKM_EMBED_API_KEY?.trim();
|
|
595
|
+
if (envKey)
|
|
596
|
+
next.embedding = { ...next.embedding, apiKey: envKey };
|
|
597
|
+
}
|
|
598
|
+
if (next.llm && !next.llm.apiKey) {
|
|
599
|
+
const envKey = process.env.AKM_LLM_API_KEY?.trim();
|
|
600
|
+
if (envKey)
|
|
601
|
+
next.llm = { ...next.llm, apiKey: envKey };
|
|
602
|
+
}
|
|
603
|
+
return next;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Return config file paths in merge order: user config first, then project
|
|
607
|
+
* config files from the outermost parent directory down to the current working
|
|
608
|
+
* directory. Later entries have higher precedence when merged.
|
|
609
|
+
*/
|
|
610
|
+
function getEffectiveConfigPaths() {
|
|
611
|
+
const configPath = getConfigPath();
|
|
612
|
+
const paths = [];
|
|
613
|
+
if (isFile(configPath)) {
|
|
614
|
+
paths.push(configPath);
|
|
615
|
+
}
|
|
616
|
+
return [...paths, ...discoverProjectConfigPaths(process.cwd())];
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Walk from `startDir` up to the filesystem root and collect `.akm/config.json`
|
|
620
|
+
* files. Paths are returned from outermost parent to innermost directory so
|
|
621
|
+
* nearer project directories override broader project settings.
|
|
622
|
+
*/
|
|
623
|
+
function discoverProjectConfigPaths(startDir) {
|
|
624
|
+
const paths = [];
|
|
625
|
+
let currentDir = path.resolve(startDir);
|
|
626
|
+
while (true) {
|
|
627
|
+
const configPath = path.join(currentDir, PROJECT_CONFIG_RELATIVE_PATH);
|
|
628
|
+
if (isFile(configPath)) {
|
|
629
|
+
paths.unshift(configPath);
|
|
630
|
+
}
|
|
631
|
+
const parentDir = path.dirname(currentDir);
|
|
632
|
+
if (parentDir === currentDir) {
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
currentDir = parentDir;
|
|
636
|
+
}
|
|
637
|
+
return paths;
|
|
638
|
+
}
|
|
639
|
+
function getConfigSignature(configPaths) {
|
|
640
|
+
if (configPaths.length === 0)
|
|
641
|
+
return "defaults";
|
|
642
|
+
return configPaths.map((configPath) => `${configPath}:${getFileSignatureToken(configPath)}`).join("|");
|
|
643
|
+
}
|
|
644
|
+
function isFile(filePath) {
|
|
645
|
+
try {
|
|
646
|
+
return fs.statSync(filePath).isFile();
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function getFileSignatureToken(filePath) {
|
|
653
|
+
try {
|
|
654
|
+
return String(fs.statSync(filePath).mtimeMs);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
return "missing";
|
|
658
|
+
}
|
|
659
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { TYPE_DIRS } from "./asset-spec";
|
|
10
|
-
import { getConfigPath,
|
|
10
|
+
import { getConfigPath, loadUserConfig, saveConfig } from "./config";
|
|
11
11
|
import { getBinDir, getDefaultStashDir } from "./paths";
|
|
12
12
|
import { ensureRg } from "./ripgrep-install";
|
|
13
13
|
export async function akmInit(options) {
|
|
@@ -25,7 +25,7 @@ export async function akmInit(options) {
|
|
|
25
25
|
}
|
|
26
26
|
// Persist stashDir in config.json
|
|
27
27
|
const configPath = getConfigPath();
|
|
28
|
-
const existing =
|
|
28
|
+
const existing = loadUserConfig();
|
|
29
29
|
if (!existing.stashDir || existing.stashDir !== stashDir) {
|
|
30
30
|
saveConfig({ ...existing, stashDir });
|
|
31
31
|
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { filterNonEmptyStrings } from "./common";
|
|
4
|
+
const DEFAULT_INSTALL_AUDIT_CONFIG = {
|
|
5
|
+
enabled: true,
|
|
6
|
+
blockOnCritical: true,
|
|
7
|
+
blockUnlistedRegistries: false,
|
|
8
|
+
registryAllowlist: [],
|
|
9
|
+
};
|
|
10
|
+
const MAX_SCANNED_FILE_BYTES = 256 * 1024;
|
|
11
|
+
const LIFECYCLE_SCRIPT_NAMES = new Set([
|
|
12
|
+
"preinstall",
|
|
13
|
+
"install",
|
|
14
|
+
"postinstall",
|
|
15
|
+
"prepublish",
|
|
16
|
+
"prepublishOnly",
|
|
17
|
+
"prepare",
|
|
18
|
+
]);
|
|
19
|
+
const TEXT_FILE_EXTENSIONS = new Set([
|
|
20
|
+
".cjs",
|
|
21
|
+
".cts",
|
|
22
|
+
".js",
|
|
23
|
+
".json",
|
|
24
|
+
".jsonc",
|
|
25
|
+
".jsx",
|
|
26
|
+
".mjs",
|
|
27
|
+
".md",
|
|
28
|
+
".ps1",
|
|
29
|
+
".py",
|
|
30
|
+
".rb",
|
|
31
|
+
".sh",
|
|
32
|
+
".toml",
|
|
33
|
+
".ts",
|
|
34
|
+
".tsx",
|
|
35
|
+
".txt",
|
|
36
|
+
".yaml",
|
|
37
|
+
".yml",
|
|
38
|
+
]);
|
|
39
|
+
const CONTENT_RULES = [
|
|
40
|
+
{
|
|
41
|
+
id: "prompt-ignore-previous-instructions",
|
|
42
|
+
severity: "high",
|
|
43
|
+
category: "prompt-injection",
|
|
44
|
+
message: "Contains instructions to ignore prior prompts or instructions.",
|
|
45
|
+
pattern: /\b(ignore|disregard|forget)\b[^.\n]{0,100}\b(previous|prior|earlier)\b[^.\n]{0,100}\b(instructions?|prompts?|messages?)\b/i,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "prompt-reveal-hidden-secrets",
|
|
49
|
+
severity: "critical",
|
|
50
|
+
category: "prompt-injection",
|
|
51
|
+
message: "Contains instructions to reveal hidden prompts or secrets.",
|
|
52
|
+
pattern: /\b(reveal|print|dump|show|exfiltrat(?:e|ion))\b[^.\n]{0,120}\b(system prompt|hidden instructions?|developer message|api key|token|secret|password)\b/i,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "prompt-bypass-guardrails",
|
|
56
|
+
severity: "high",
|
|
57
|
+
category: "prompt-injection",
|
|
58
|
+
message: "Contains instructions to bypass safety or security controls.",
|
|
59
|
+
pattern: /\b(bypass|disable|ignore)\b[^.\n]{0,100}\b(safety|security|guardrails|restrictions|policies)\b/i,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "remote-shell-pipe",
|
|
63
|
+
severity: "critical",
|
|
64
|
+
category: "malicious-code",
|
|
65
|
+
message: "Downloads remote content and pipes it directly into a shell.",
|
|
66
|
+
pattern: /\b(curl|wget)\b[^\n|]{0,200}\|\s*(sh|bash|zsh)\b/i,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "powershell-download-exec",
|
|
70
|
+
severity: "critical",
|
|
71
|
+
category: "malicious-code",
|
|
72
|
+
message: "Downloads remote content and executes it in PowerShell.",
|
|
73
|
+
pattern: /\b(Invoke-WebRequest|iwr|curl)\b[^\n|]{0,200}\|\s*(iex|Invoke-Expression)\b/i,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "powershell-encoded-command",
|
|
77
|
+
severity: "critical",
|
|
78
|
+
category: "malicious-code",
|
|
79
|
+
message: "Uses an encoded PowerShell command.",
|
|
80
|
+
pattern: /\bpowershell(?:\.exe)?\b[^\n]{0,120}\s-(?:enc|encodedcommand)\b/i,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "credential-exfiltration-language",
|
|
84
|
+
severity: "high",
|
|
85
|
+
category: "malicious-code",
|
|
86
|
+
message: "Contains language associated with credential or secret exfiltration.",
|
|
87
|
+
pattern: /\b(exfiltrat(?:e|ion)|harvest|steal)\b[^.\n]{0,120}\b(credentials?|tokens?|secrets?|ssh keys?|passwords?|cookies?)\b/i,
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
export function resolveInstallAuditConfig(config) {
|
|
91
|
+
const installAudit = config?.security?.installAudit;
|
|
92
|
+
const allowlist = filterNonEmptyStrings(installAudit?.registryAllowlist) ??
|
|
93
|
+
filterNonEmptyStrings(installAudit?.registryWhitelist) ??
|
|
94
|
+
[];
|
|
95
|
+
return {
|
|
96
|
+
enabled: installAudit?.enabled ?? DEFAULT_INSTALL_AUDIT_CONFIG.enabled,
|
|
97
|
+
blockOnCritical: installAudit?.blockOnCritical ?? DEFAULT_INSTALL_AUDIT_CONFIG.blockOnCritical,
|
|
98
|
+
blockUnlistedRegistries: installAudit?.blockUnlistedRegistries ?? DEFAULT_INSTALL_AUDIT_CONFIG.blockUnlistedRegistries,
|
|
99
|
+
registryAllowlist: allowlist.map((entry) => entry.trim().toLowerCase()),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function enforceRegistryInstallPolicy(registryLabels, config, ref) {
|
|
103
|
+
const resolved = resolveInstallAuditConfig(config);
|
|
104
|
+
if (!resolved.blockUnlistedRegistries)
|
|
105
|
+
return;
|
|
106
|
+
if (resolved.registryAllowlist.length === 0) {
|
|
107
|
+
throw new Error(`Install blocked for ${ref}: no registries are allowlisted. Configure security.installAudit.registryAllowlist or disable security.installAudit.blockUnlistedRegistries.`);
|
|
108
|
+
}
|
|
109
|
+
const matched = registryLabels.some((label) => resolved.registryAllowlist.includes(label.toLowerCase()));
|
|
110
|
+
if (matched)
|
|
111
|
+
return;
|
|
112
|
+
throw new Error(`Install blocked for ${ref}: registry is not allowlisted. Allowed: ${resolved.registryAllowlist.join(", ")}. Seen: ${registryLabels.join(", ")}.`);
|
|
113
|
+
}
|
|
114
|
+
export function auditInstallCandidate(input) {
|
|
115
|
+
const resolved = resolveInstallAuditConfig(input.config);
|
|
116
|
+
if (!resolved.enabled) {
|
|
117
|
+
return {
|
|
118
|
+
enabled: false,
|
|
119
|
+
passed: true,
|
|
120
|
+
blocked: false,
|
|
121
|
+
registryLabels: [...input.registryLabels],
|
|
122
|
+
findings: [],
|
|
123
|
+
scannedFiles: 0,
|
|
124
|
+
scannedBytes: 0,
|
|
125
|
+
summary: buildSummary([]),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const findings = [];
|
|
129
|
+
const counters = { scannedFiles: 0, scannedBytes: 0 };
|
|
130
|
+
scanDirectory(input.rootDir, input.rootDir, findings, counters);
|
|
131
|
+
const summary = buildSummary(findings);
|
|
132
|
+
const blocked = resolved.blockOnCritical && summary.critical > 0;
|
|
133
|
+
return {
|
|
134
|
+
enabled: true,
|
|
135
|
+
passed: findings.length === 0,
|
|
136
|
+
blocked,
|
|
137
|
+
registryLabels: [...input.registryLabels],
|
|
138
|
+
findings,
|
|
139
|
+
scannedFiles: counters.scannedFiles,
|
|
140
|
+
scannedBytes: counters.scannedBytes,
|
|
141
|
+
summary,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export function formatInstallAuditFailure(ref, report) {
|
|
145
|
+
const lines = [`Security audit failed for ${ref}.`, formatInstallAuditSummary(report)];
|
|
146
|
+
for (const finding of report.findings.slice(0, 5)) {
|
|
147
|
+
lines.push(`- [${finding.severity}] ${finding.message}${finding.file ? ` (${finding.file})` : ""}`);
|
|
148
|
+
}
|
|
149
|
+
if (report.findings.length > 5) {
|
|
150
|
+
lines.push(`- ${report.findings.length - 5} more finding(s) omitted`);
|
|
151
|
+
}
|
|
152
|
+
lines.push("Disable blocking with `security.installAudit.blockOnCritical = false`, or disable audits with `security.installAudit.enabled = false`.");
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
export function formatInstallAuditSummary(report) {
|
|
156
|
+
if (!report.enabled)
|
|
157
|
+
return "Audit: disabled";
|
|
158
|
+
const severitySummary = [];
|
|
159
|
+
if (report.summary.critical > 0)
|
|
160
|
+
severitySummary.push(`${report.summary.critical} critical`);
|
|
161
|
+
if (report.summary.high > 0)
|
|
162
|
+
severitySummary.push(`${report.summary.high} high`);
|
|
163
|
+
if (report.summary.moderate > 0)
|
|
164
|
+
severitySummary.push(`${report.summary.moderate} moderate`);
|
|
165
|
+
if (report.summary.low > 0)
|
|
166
|
+
severitySummary.push(`${report.summary.low} low`);
|
|
167
|
+
const detail = severitySummary.length > 0 ? severitySummary.join(", ") : "no findings";
|
|
168
|
+
return `Audit: ${report.blocked ? "blocked" : report.passed ? "passed" : "warnings"} (${detail}; scanned ${report.scannedFiles} file${report.scannedFiles === 1 ? "" : "s"})`;
|
|
169
|
+
}
|
|
170
|
+
export function deriveRegistryLabels(input) {
|
|
171
|
+
const labels = new Set();
|
|
172
|
+
labels.add(input.source);
|
|
173
|
+
if (input.source === "github")
|
|
174
|
+
labels.add("github.com");
|
|
175
|
+
if (input.source === "npm")
|
|
176
|
+
labels.add("npm");
|
|
177
|
+
addUrlLabels(labels, input.artifactUrl);
|
|
178
|
+
addUrlLabels(labels, input.gitUrl);
|
|
179
|
+
if (input.source === "github" && input.ref.startsWith("github:")) {
|
|
180
|
+
labels.add("github");
|
|
181
|
+
}
|
|
182
|
+
return [...labels];
|
|
183
|
+
}
|
|
184
|
+
function scanDirectory(dir, rootDir, findings, counters) {
|
|
185
|
+
let entries;
|
|
186
|
+
try {
|
|
187
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (entry.name === ".git" || entry.name === "node_modules")
|
|
194
|
+
continue;
|
|
195
|
+
const fullPath = path.join(dir, entry.name);
|
|
196
|
+
if (entry.isDirectory()) {
|
|
197
|
+
scanDirectory(fullPath, rootDir, findings, counters);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!entry.isFile())
|
|
201
|
+
continue;
|
|
202
|
+
scanFile(fullPath, rootDir, findings, counters);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function scanFile(filePath, rootDir, findings, counters) {
|
|
206
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
207
|
+
const basename = path.basename(filePath).toLowerCase();
|
|
208
|
+
if (basename !== "package.json" && !TEXT_FILE_EXTENSIONS.has(ext))
|
|
209
|
+
return;
|
|
210
|
+
let fileSize;
|
|
211
|
+
try {
|
|
212
|
+
fileSize = fs.statSync(filePath).size;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const readSize = Math.min(fileSize, MAX_SCANNED_FILE_BYTES);
|
|
218
|
+
const buf = Buffer.alloc(readSize);
|
|
219
|
+
let bytesRead;
|
|
220
|
+
try {
|
|
221
|
+
const fd = fs.openSync(filePath, "r");
|
|
222
|
+
try {
|
|
223
|
+
bytesRead = fs.readSync(fd, buf, 0, readSize, 0);
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
fs.closeSync(fd);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (bytesRead === 0)
|
|
233
|
+
return;
|
|
234
|
+
const bytes = buf.subarray(0, bytesRead);
|
|
235
|
+
if (bytes.includes(0))
|
|
236
|
+
return;
|
|
237
|
+
counters.scannedFiles += 1;
|
|
238
|
+
counters.scannedBytes += bytesRead;
|
|
239
|
+
const content = bytes.toString("utf8");
|
|
240
|
+
const relativePath = path.relative(rootDir, filePath) || path.basename(filePath);
|
|
241
|
+
const genericContent = basename === "package.json" ? stripPackageJsonScripts(content) : content;
|
|
242
|
+
for (const rule of CONTENT_RULES) {
|
|
243
|
+
const match = genericContent.match(rule.pattern);
|
|
244
|
+
if (!match)
|
|
245
|
+
continue;
|
|
246
|
+
findings.push({
|
|
247
|
+
id: rule.id,
|
|
248
|
+
severity: rule.severity,
|
|
249
|
+
category: rule.category,
|
|
250
|
+
message: rule.message,
|
|
251
|
+
file: relativePath,
|
|
252
|
+
snippet: clipSnippet(match[0]),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (basename === "package.json") {
|
|
256
|
+
scanPackageJson(content, relativePath, findings);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function stripPackageJsonScripts(content) {
|
|
260
|
+
let parsed;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(content);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return content;
|
|
266
|
+
}
|
|
267
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
268
|
+
return content;
|
|
269
|
+
const packageJson = { ...parsed };
|
|
270
|
+
delete packageJson.scripts;
|
|
271
|
+
return JSON.stringify(packageJson, null, 2);
|
|
272
|
+
}
|
|
273
|
+
function scanPackageJson(content, relativePath, findings) {
|
|
274
|
+
let parsed;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(content);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
282
|
+
return;
|
|
283
|
+
const scripts = parsed.scripts;
|
|
284
|
+
if (typeof scripts !== "object" || scripts === null || Array.isArray(scripts))
|
|
285
|
+
return;
|
|
286
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
287
|
+
if (!LIFECYCLE_SCRIPT_NAMES.has(name) || typeof command !== "string")
|
|
288
|
+
continue;
|
|
289
|
+
for (const rule of CONTENT_RULES) {
|
|
290
|
+
if (!rule.pattern.test(command))
|
|
291
|
+
continue;
|
|
292
|
+
findings.push({
|
|
293
|
+
id: `lifecycle-${name}-${rule.id}`,
|
|
294
|
+
severity: rule.severity,
|
|
295
|
+
category: "install-script",
|
|
296
|
+
message: `Lifecycle script "${name}" is suspicious: ${rule.message.toLowerCase()}`,
|
|
297
|
+
file: relativePath,
|
|
298
|
+
snippet: clipSnippet(command),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function clipSnippet(value) {
|
|
304
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
305
|
+
return normalized.length <= 140 ? normalized : `${normalized.slice(0, 137)}...`;
|
|
306
|
+
}
|
|
307
|
+
function buildSummary(findings) {
|
|
308
|
+
const summary = { low: 0, moderate: 0, high: 0, critical: 0, total: findings.length };
|
|
309
|
+
for (const finding of findings) {
|
|
310
|
+
summary[finding.severity] += 1;
|
|
311
|
+
}
|
|
312
|
+
return summary;
|
|
313
|
+
}
|
|
314
|
+
function addUrlLabels(labels, rawUrl) {
|
|
315
|
+
if (!rawUrl)
|
|
316
|
+
return;
|
|
317
|
+
try {
|
|
318
|
+
const parsed = new URL(rawUrl);
|
|
319
|
+
labels.add(parsed.hostname.toLowerCase());
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// Ignore non-URL refs (for example git@host:path)
|
|
323
|
+
}
|
|
324
|
+
}
|
package/dist/installed-kits.js
CHANGED
|
@@ -247,8 +247,8 @@ function tryResolveInstalledTarget(installed, target) {
|
|
|
247
247
|
return undefined;
|
|
248
248
|
}
|
|
249
249
|
function toInstalledEntry(status) {
|
|
250
|
-
// KitInstallStatus extends InstalledKitEntry; omit
|
|
251
|
-
const { extractedDir: _extractedDir, ...base } = status;
|
|
250
|
+
// KitInstallStatus extends InstalledKitEntry; omit transient install-only fields.
|
|
251
|
+
const { extractedDir: _extractedDir, audit: _audit, ...base } = status;
|
|
252
252
|
return base;
|
|
253
253
|
}
|
|
254
254
|
function toInstallStatus(status) {
|