@shomra/agent 0.3.16 → 0.3.18
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/NOTICE +1 -1
- package/README.md +57 -57
- package/package.json +3 -9
- package/shomra.mjs +9 -7168
- package/src/agents/hook-command.mjs +19 -0
- package/src/agents/hook-files.mjs +41 -0
- package/src/agents/installers.mjs +203 -0
- package/src/artifacts/matchers.mjs +59 -0
- package/src/artifacts/report.mjs +50 -0
- package/src/cli/flags.mjs +68 -0
- package/src/cli/help-sections.mjs +309 -0
- package/src/cli/help.mjs +27 -0
- package/src/cli/main.mjs +55 -0
- package/src/cli/registry.mjs +80 -0
- package/src/cli/suggestions.mjs +33 -0
- package/src/commands/add.mjs +149 -0
- package/src/commands/agent-identity.mjs +46 -0
- package/src/commands/check.mjs +194 -0
- package/src/commands/corpus.mjs +126 -0
- package/src/commands/design.mjs +168 -0
- package/src/commands/doctor.mjs +209 -0
- package/src/commands/fix.mjs +115 -0
- package/src/commands/gate.mjs +154 -0
- package/src/commands/git-hooks.mjs +163 -0
- package/src/commands/init.mjs +36 -0
- package/src/commands/install-hook.mjs +51 -0
- package/src/commands/llm-proxy.mjs +153 -0
- package/src/commands/mcp-add.mjs +185 -0
- package/src/commands/mcp.mjs +143 -0
- package/src/commands/memory-scan.mjs +181 -0
- package/src/commands/model-scan.mjs +99 -0
- package/src/commands/models.mjs +145 -0
- package/src/commands/new.mjs +64 -0
- package/src/commands/plan.mjs +87 -0
- package/src/commands/pr.mjs +249 -0
- package/src/commands/protect.mjs +38 -0
- package/src/commands/provenance.mjs +91 -0
- package/src/commands/redteam.mjs +166 -0
- package/src/commands/rules.mjs +220 -0
- package/src/commands/run.mjs +128 -0
- package/src/commands/scan-zip.mjs +118 -0
- package/src/commands/scan.mjs +102 -0
- package/src/commands/secrets.mjs +99 -0
- package/src/commands/status.mjs +50 -0
- package/src/commands/why.mjs +88 -0
- package/src/core/api-client.mjs +66 -0
- package/src/core/api-key.mjs +6 -0
- package/src/core/circuit-breaker.mjs +42 -0
- package/src/core/config.mjs +37 -0
- package/src/core/exit-codes.mjs +9 -0
- package/src/core/json-file.mjs +13 -0
- package/src/core/numbers.mjs +4 -0
- package/src/core/package-root.mjs +10 -0
- package/src/core/terminal.mjs +16 -0
- package/src/core/version.mjs +14 -0
- package/src/core/wire-limits.mjs +53 -0
- package/src/corpus/screening.mjs +127 -0
- package/{ai-usage.mjs → src/detect/ai-usage.mjs} +0 -27
- package/src/detect/code-sast.mjs +2 -0
- package/{design.mjs → src/detect/design.mjs} +18 -107
- package/src/detect/guard-signals.mjs +18 -0
- package/{model-refs.mjs → src/detect/model-refs.mjs} +18 -77
- package/src/detect/sast/chains.mjs +30 -0
- package/src/detect/sast/path-expressions.mjs +76 -0
- package/src/detect/sast/rules-chains.mjs +33 -0
- package/src/detect/sast/rules-config.mjs +51 -0
- package/src/detect/sast/rules-javascript.mjs +109 -0
- package/src/detect/sast/rules-python.mjs +292 -0
- package/src/detect/sast/scanner.mjs +104 -0
- package/src/detect/sast/source-lines.mjs +115 -0
- package/src/detect/sast/taint.mjs +71 -0
- package/src/detect/signals/artifacts.mjs +113 -0
- package/src/detect/signals/autonomy.mjs +55 -0
- package/src/detect/signals/config-markers.mjs +28 -0
- package/src/detect/signals/credential-harvest.mjs +64 -0
- package/src/detect/signals/durable-claims.mjs +73 -0
- package/src/detect/signals/egress.mjs +56 -0
- package/src/detect/signals/execution-hijack.mjs +128 -0
- package/src/detect/signals/gate.mjs +91 -0
- package/src/detect/signals/injection.mjs +55 -0
- package/src/detect/signals/lines.mjs +42 -0
- package/src/detect/signals/masking.mjs +99 -0
- package/src/detect/signals/memory.mjs +357 -0
- package/src/detect/signals/packages.mjs +45 -0
- package/src/detect/signals/propagation.mjs +86 -0
- package/src/detect/signals/prose-context.mjs +82 -0
- package/src/detect/signals/scan.mjs +91 -0
- package/src/detect/signals/secrets.mjs +85 -0
- package/src/detect/signals/sensitive.mjs +9 -0
- package/src/detect/signals/severity.mjs +10 -0
- package/src/detect/signals/shell.mjs +96 -0
- package/src/detect/signals/staged-fetch.mjs +66 -0
- package/src/detect/signals/text-match.mjs +35 -0
- package/src/gate/batch.mjs +157 -0
- package/src/gate/environment.mjs +122 -0
- package/src/gate/repo-policy.mjs +65 -0
- package/src/gate/result.mjs +53 -0
- package/src/gate/sarif.mjs +33 -0
- package/src/gate/sast.mjs +64 -0
- package/src/gate/suppressions.mjs +0 -0
- package/src/guard/classify.mjs +50 -0
- package/src/guard/emit.mjs +51 -0
- package/src/guard/ignore.mjs +24 -0
- package/src/guard/ledger.mjs +112 -0
- package/src/guard/model-load.mjs +50 -0
- package/src/guard/normalize.mjs +77 -0
- package/src/guard/options.mjs +10 -0
- package/src/guard/prompt-guard.mjs +184 -0
- package/src/guard/report.mjs +35 -0
- package/src/guard/result-guard.mjs +140 -0
- package/src/guard/tool-guard.mjs +166 -0
- package/src/inventory/agent-artifacts.mjs +5 -0
- package/src/inventory/agent-posture.mjs +249 -0
- package/src/inventory/artifacts/classify.mjs +27 -0
- package/src/inventory/artifacts/discover.mjs +187 -0
- package/src/inventory/artifacts/file-read.mjs +42 -0
- package/src/inventory/artifacts/hooks.mjs +14 -0
- package/src/inventory/artifacts/limits.mjs +37 -0
- package/src/inventory/artifacts/marketplaces.mjs +45 -0
- package/src/inventory/artifacts/roots.mjs +20 -0
- package/src/inventory/artifacts/walk.mjs +36 -0
- package/src/inventory/discovery/ai-dependencies.mjs +161 -0
- package/src/inventory/discovery/ai-tools.mjs +23 -0
- package/src/inventory/discovery/all.mjs +40 -0
- package/src/inventory/discovery/coding-agents.mjs +77 -0
- package/src/inventory/discovery/fs-read.mjs +36 -0
- package/src/inventory/discovery/local-runtimes.mjs +53 -0
- package/src/inventory/discovery/mcp-clients.mjs +67 -0
- package/src/inventory/discovery/mcp-servers.mjs +78 -0
- package/src/inventory/discovery/model-keys.mjs +97 -0
- package/src/inventory/discovery/platform.mjs +16 -0
- package/src/inventory/discovery/rules-files.mjs +25 -0
- package/src/inventory/discovery/vector-stores.mjs +176 -0
- package/src/inventory/discovery/workspace.mjs +124 -0
- package/src/inventory/discovery.mjs +10 -0
- package/src/mcp/child-process.mjs +50 -0
- package/src/mcp/config-wrapping.mjs +75 -0
- package/src/mcp/connect-gate.mjs +45 -0
- package/src/mcp/hosts.mjs +16 -0
- package/src/mcp/jsonrpc.mjs +48 -0
- package/src/mcp/lookup.mjs +50 -0
- package/src/mcp/screening.mjs +103 -0
- package/src/mcp/server-tools.mjs +97 -0
- package/src/mcp/server.mjs +102 -0
- package/src/mcp/shim.mjs +205 -0
- package/src/models/lookup.mjs +79 -0
- package/src/models/references.mjs +103 -0
- package/src/rules/context.mjs +98 -0
- package/src/rules/generate.mjs +103 -0
- package/src/rules/sections.mjs +145 -0
- package/src/scaffold/agent-project.mjs +185 -0
- package/src/scaffold/artifact-templates.mjs +35 -0
- package/code-sast.mjs +0 -1063
- package/discovery.mjs +0 -977
- package/guard-ledger.mjs +0 -239
- package/guard-signals.mjs +0 -1268
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { agentHookInstalled } from '../agents/hook-files.mjs';
|
|
3
|
+
import { AGENT_KEYS, AGENT_LABELS } from '../agents/installers.mjs';
|
|
4
|
+
import { keyScope } from '../core/api-key.mjs';
|
|
5
|
+
import { breakerCooldownMs, breakerOpen, guardTimeoutMs } from '../core/circuit-breaker.mjs';
|
|
6
|
+
import { CONFIG_FILE, loadConfig, resolveSettings } from '../core/config.mjs';
|
|
7
|
+
import { bold, cyan, dim, gray, green, red, yellow } from '../core/terminal.mjs';
|
|
8
|
+
import { VERSION } from '../core/version.mjs';
|
|
9
|
+
import { envFlag } from '../guard/options.mjs';
|
|
10
|
+
|
|
11
|
+
export function cmdStatus() {
|
|
12
|
+
const cfg = loadConfig();
|
|
13
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
14
|
+
const enrolled = !!apiKey;
|
|
15
|
+
console.log(bold(cyan('\n Shomra agent')) + dim(` v${VERSION}`));
|
|
16
|
+
|
|
17
|
+
if (enrolled) {
|
|
18
|
+
console.log(` ${dim('Mode ')} ${green('● Enrolled')} ${dim(`(${keyScope(apiKey)} key)`)} - org policy, platform AI & dashboard telemetry active`);
|
|
19
|
+
} else {
|
|
20
|
+
console.log(` ${dim('Mode ')} ${cyan('● Local')} ${dim('- on-machine analysis only; nothing leaves this machine')}`);
|
|
21
|
+
console.log(` ${dim(' ')} ${dim('Run')} ${bold('shomra init --key shm_…')} ${dim('to add org policy, AI fixes, deep scans & the dashboard.')}`);
|
|
22
|
+
}
|
|
23
|
+
console.log(` ${dim('Backend ')} ${url || dim('none (local mode - set with shomra init --url)')}`);
|
|
24
|
+
console.log(` ${dim('API key ')} ${apiKey ? green(apiKey.slice(0, 14) + '…') : dim('none (local mode)')}`);
|
|
25
|
+
console.log(` ${dim('Machine ')} ${os.hostname()} ${dim('(' + (cfg.machineId || 'unenrolled') + ')')}`);
|
|
26
|
+
console.log(` ${dim('Config ')} ${CONFIG_FILE}`);
|
|
27
|
+
|
|
28
|
+
console.log(bold('\n Available now') + dim(enrolled ? '' : ' (local, no key)'));
|
|
29
|
+
console.log(` ${green('✓')} ${dim('check · gate · doctor · protect · secrets · models · new · mcp add · why (offline)')}`);
|
|
30
|
+
console.log(` ${enrolled ? green('✓') : gray('○')} ${(enrolled ? dim : gray)('fix (AI) · deep scans (scan-zip/model-scan/memory-scan) · org policy · dashboard telemetry')}`);
|
|
31
|
+
|
|
32
|
+
const localOff = process.env.SHOMRA_GUARD_LOCAL === '0' || String(process.env.SHOMRA_GUARD_LOCAL).toLowerCase() === 'false';
|
|
33
|
+
const strict = envFlag('SHOMRA_GUARD_STRICT');
|
|
34
|
+
console.log(bold('\n Runtime firewall'));
|
|
35
|
+
const installedAgents = AGENT_KEYS.map((a) => ({ agent: a, files: agentHookInstalled(a) })).filter((x) => x.files.length);
|
|
36
|
+
if (installedAgents.length) {
|
|
37
|
+
let first = true;
|
|
38
|
+
for (const { agent, files } of installedAgents) {
|
|
39
|
+
console.log(` ${dim(first ? 'Hooks ' : ' ')} ${green('installed')} ${bold(AGENT_LABELS[agent])} ${dim('→ ' + files.join(', '))}`);
|
|
40
|
+
first = false;
|
|
41
|
+
}
|
|
42
|
+
const missing = AGENT_KEYS.filter((a) => !installedAgents.some((i) => i.agent === a));
|
|
43
|
+
if (missing.length) console.log(` ${dim(' ')} ${dim('not installed: ' + missing.map((m) => AGENT_LABELS[m]).join(', '))}`);
|
|
44
|
+
} else {
|
|
45
|
+
console.log(` ${dim('Hooks ')} ${yellow('not installed for any agent')}${dim(' (run: shomra install-hook --agent all or shomra protect)')}`);
|
|
46
|
+
}
|
|
47
|
+
console.log(` ${dim('Tier 0 ')} ${localOff ? yellow('off') + dim(' (server-only)') : green('on') + dim(' - dangerous calls blocked on-machine, zero network')}`);
|
|
48
|
+
console.log(` ${dim('Mode ')} ${strict ? 'fail-closed (strict)' : 'fail-open'}${dim(` · server timeout ${guardTimeoutMs()}ms · breaker ${breakerCooldownMs()}ms`)}`);
|
|
49
|
+
console.log(` ${dim('Breaker ')} ${breakerOpen() ? red('OPEN') + dim(' - backend recently unreachable; server tier is being skipped') : green('closed')}\n`);
|
|
50
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { api } from '../core/api-client.mjs';
|
|
4
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
5
|
+
import { EXIT_USAGE } from '../core/exit-codes.mjs';
|
|
6
|
+
import { SEV_COLOR, bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
|
|
7
|
+
import { localGate } from '../detect/guard-signals.mjs';
|
|
8
|
+
import { GATE_KINDS } from '../gate/environment.mjs';
|
|
9
|
+
|
|
10
|
+
export async function cmdWhy(flags, positional) {
|
|
11
|
+
const file = positional[0];
|
|
12
|
+
if (!file) {
|
|
13
|
+
console.error(red('✗') + ' Usage: ' + bold('shomra why <file> [--kind mcp|skill|command|subagent|hook|rules] [--json]'));
|
|
14
|
+
process.exit(EXIT_USAGE);
|
|
15
|
+
}
|
|
16
|
+
let target = path.resolve(String(file));
|
|
17
|
+
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
18
|
+
const skillMd = path.join(target, 'SKILL.md');
|
|
19
|
+
if (!fs.existsSync(skillMd)) {
|
|
20
|
+
console.error(red('✗') + ` ${file} is a directory with no SKILL.md - point at a file instead.`);
|
|
21
|
+
process.exit(EXIT_USAGE);
|
|
22
|
+
}
|
|
23
|
+
target = skillMd;
|
|
24
|
+
}
|
|
25
|
+
if (!fs.existsSync(target)) {
|
|
26
|
+
console.error(red('✗') + ` File not found: ${file}`);
|
|
27
|
+
process.exit(EXIT_USAGE);
|
|
28
|
+
}
|
|
29
|
+
const content = fs.readFileSync(target, 'utf8');
|
|
30
|
+
const rel = path.relative(process.cwd(), target).split(path.sep).join('/');
|
|
31
|
+
const kind = flags.kind && GATE_KINDS.includes(String(flags.kind)) ? String(flags.kind) : undefined;
|
|
32
|
+
|
|
33
|
+
const cfg = loadConfig();
|
|
34
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
35
|
+
|
|
36
|
+
if (apiKey) {
|
|
37
|
+
try {
|
|
38
|
+
if (!flags.json) process.stdout.write(dim(` Explaining ${rel}… `));
|
|
39
|
+
const res = await api(url, apiKey, '/gate/explain', { ...(kind ? { kind } : {}), path: rel, name: rel.split('/').pop(), content });
|
|
40
|
+
if (!flags.json) console.log('');
|
|
41
|
+
if (flags.json) console.log(JSON.stringify({ path: rel, ...res }, null, 2));
|
|
42
|
+
else printWhy(res);
|
|
43
|
+
return;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (!flags.json) console.log(yellow('backend unavailable') + dim(` - on-machine explanation (${e.message})`));
|
|
46
|
+
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
whyLocal(content, kind, rel, flags);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function whyLocal(content, kind, rel, flags) {
|
|
53
|
+
const local = localGate(content, { kind, path: rel });
|
|
54
|
+
if (flags.json) {
|
|
55
|
+
console.log(JSON.stringify({ path: rel, source: 'local', ...local }, null, 2));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
console.log(bold(cyan('\n Shomra why')) + dim(` - ${rel} · on-machine`));
|
|
59
|
+
if (!local.findings.length) {
|
|
60
|
+
console.log(green('\n ✓ No findings - nothing to explain.\n'));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const f of local.findings) {
|
|
64
|
+
const at = f.line ? dim(` (line ${f.line})`) : '';
|
|
65
|
+
console.log(`\n ${SEV_COLOR[f.severity]('●')} ${SEV_COLOR[f.severity](f.severity)} ${bold(f.title)}${at}`);
|
|
66
|
+
if (f.remediationText) console.log(` ${dim('fix: ' + f.remediationText)}`);
|
|
67
|
+
}
|
|
68
|
+
console.log(dim('\n Enroll (') + bold('shomra init') + dim(') for an AI-distilled why + false-positive read.\n'));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function printWhy(res) {
|
|
72
|
+
console.log(bold(cyan('\n Shomra why')) + dim(` - ${res.path}${res.aiEnabled ? '' : ' · rule rationale (AI off)'}`));
|
|
73
|
+
if (res.summary) console.log(' ' + res.summary);
|
|
74
|
+
if (!res.findings || !res.findings.length) {
|
|
75
|
+
console.log(green('\n ✓ Nothing to explain.\n'));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const f of res.findings) {
|
|
79
|
+
const at = f.line ? dim(` (line ${f.line})`) : '';
|
|
80
|
+
const fp = f.likelyFalsePositive ? yellow(' · likely false positive') : '';
|
|
81
|
+
console.log(`\n ${SEV_COLOR[f.severity]('●')} ${SEV_COLOR[f.severity](f.severity)} ${bold(f.title)}${at}${fp}`);
|
|
82
|
+
if (f.why) console.log(` ${f.why}`);
|
|
83
|
+
if (f.exploit) console.log(` ${dim('exploit: ' + f.exploit)}`);
|
|
84
|
+
if (f.assessment) console.log(` ${dim(f.assessment)}`);
|
|
85
|
+
if (f.remediationText) console.log(` ${dim('fix: ' + f.remediationText)}`);
|
|
86
|
+
}
|
|
87
|
+
console.log('');
|
|
88
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { getMachineId, loadConfig } from './config.mjs';
|
|
3
|
+
import { clampInt } from './numbers.mjs';
|
|
4
|
+
import { VERSION } from './version.mjs';
|
|
5
|
+
|
|
6
|
+
export function gateMachine() {
|
|
7
|
+
let machineId;
|
|
8
|
+
try {
|
|
9
|
+
machineId = loadConfig().machineId;
|
|
10
|
+
} catch {
|
|
11
|
+
|
|
12
|
+
}
|
|
13
|
+
return { ...(machineId ? { machineId } : {}), hostname: os.hostname(), username: os.userInfo().username };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function machineInfo(cfg) {
|
|
17
|
+
return {
|
|
18
|
+
machineId: getMachineId(cfg),
|
|
19
|
+
hostname: os.hostname(),
|
|
20
|
+
platform: process.platform,
|
|
21
|
+
osRelease: os.release(),
|
|
22
|
+
username: os.userInfo().username,
|
|
23
|
+
agentVersion: VERSION,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function api(url, key, route, body, opts = {}) {
|
|
28
|
+
|
|
29
|
+
if (!url) throw new Error('no backend configured - set SHOMRA_URL or run `shomra init --url <your backend>`');
|
|
30
|
+
|
|
31
|
+
const timeoutMs = opts.timeoutMs ?? clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 30000, 1000, 600000);
|
|
32
|
+
const ctrl = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
34
|
+
let res;
|
|
35
|
+
try {
|
|
36
|
+
const method = opts.method ?? 'POST';
|
|
37
|
+
res = await fetch(`${url}${route}`, {
|
|
38
|
+
method,
|
|
39
|
+
|
|
40
|
+
headers: { 'Content-Type': 'application/json', 'X-Shomra-Key': key, Connection: 'close' },
|
|
41
|
+
...(method === 'GET' ? {} : { body: JSON.stringify(body) }),
|
|
42
|
+
signal: ctrl.signal,
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e?.name === 'AbortError') throw new Error(`request timed out after ${timeoutMs}ms (raise SHOMRA_API_TIMEOUT_MS or check the backend)`);
|
|
46
|
+
throw e;
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
const text = await res.text();
|
|
51
|
+
let json;
|
|
52
|
+
try {
|
|
53
|
+
json = JSON.parse(text);
|
|
54
|
+
} catch {
|
|
55
|
+
json = { raw: text };
|
|
56
|
+
}
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const msg = json?.message || json?.raw || res.statusText;
|
|
59
|
+
const err = new Error(`${res.status} ${Array.isArray(msg) ? msg.join(', ') : msg}`);
|
|
60
|
+
|
|
61
|
+
err.status = res.status;
|
|
62
|
+
err.rejected = res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
return json;
|
|
66
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CONFIG_DIR } from './config.mjs';
|
|
4
|
+
import { clampInt } from './numbers.mjs';
|
|
5
|
+
|
|
6
|
+
const BREAKER_FILE = path.join(CONFIG_DIR, 'guard-breaker.json');
|
|
7
|
+
|
|
8
|
+
export function guardTimeoutMs() {
|
|
9
|
+
return clampInt(process.env.SHOMRA_GUARD_TIMEOUT_MS, 2000, 200, 30000);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function breakerCooldownMs() {
|
|
13
|
+
return clampInt(process.env.SHOMRA_GUARD_BREAKER_MS, 30000, 0, 600000);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function breakerOpen() {
|
|
17
|
+
const cooldown = breakerCooldownMs();
|
|
18
|
+
if (cooldown === 0) return false;
|
|
19
|
+
try {
|
|
20
|
+
const { at } = JSON.parse(fs.readFileSync(BREAKER_FILE, 'utf8'));
|
|
21
|
+
return typeof at === 'number' && Date.now() - at < cooldown;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function breakerTrip() {
|
|
28
|
+
try {
|
|
29
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
30
|
+
fs.writeFileSync(BREAKER_FILE, JSON.stringify({ at: Date.now() }));
|
|
31
|
+
} catch {
|
|
32
|
+
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function breakerReset() {
|
|
37
|
+
try {
|
|
38
|
+
fs.rmSync(BREAKER_FILE, { force: true });
|
|
39
|
+
} catch {
|
|
40
|
+
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const CONFIG_DIR = path.join(os.homedir(), '.shomra');
|
|
7
|
+
|
|
8
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
|
+
|
|
10
|
+
export function loadConfig() {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function saveConfig(cfg) {
|
|
19
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
20
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getMachineId(cfg) {
|
|
24
|
+
if (cfg.machineId) return cfg.machineId;
|
|
25
|
+
cfg.machineId = crypto.randomUUID();
|
|
26
|
+
saveConfig(cfg);
|
|
27
|
+
return cfg.machineId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveSettings(cfg) {
|
|
31
|
+
|
|
32
|
+
const raw = process.env.SHOMRA_URL || cfg.url || '';
|
|
33
|
+
return {
|
|
34
|
+
apiKey: process.env.SHOMRA_API_KEY || cfg.apiKey,
|
|
35
|
+
url: raw ? raw.replace(/\/$/, '').replace('://localhost', '://127.0.0.1') : null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { bold, dim, red } from './terminal.mjs';
|
|
2
|
+
|
|
3
|
+
export const EXIT_USAGE = 3;
|
|
4
|
+
|
|
5
|
+
export function exitNotConfigured() {
|
|
6
|
+
console.error('\n' + red('✗') + ' Not configured. Run ' + bold('shomra init --key shm_live_… --url <your backend>') + ' first.');
|
|
7
|
+
console.error(' ' + dim('Get a key in the Shomra app → Settings → API Keys.'));
|
|
8
|
+
process.exit(EXIT_USAGE);
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { EXIT_USAGE } from './exit-codes.mjs';
|
|
3
|
+
import { red } from './terminal.mjs';
|
|
4
|
+
|
|
5
|
+
export function readJsonFile(file) {
|
|
6
|
+
if (!fs.existsSync(file)) return {};
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
9
|
+
} catch {
|
|
10
|
+
console.error(red('✗') + ` ${file} is not valid JSON - fix or move it first.`);
|
|
11
|
+
process.exit(EXIT_USAGE);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const CORE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
|
|
6
|
+
export const PACKAGE_ROOT = path.resolve(CORE_DIR, '..', '..');
|
|
7
|
+
|
|
8
|
+
export const PACKAGE_MANIFEST_PATH = path.join(PACKAGE_ROOT, 'package.json');
|
|
9
|
+
|
|
10
|
+
export const CLI_ENTRY_PATH = path.join(PACKAGE_ROOT, 'shomra.mjs');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
|
|
3
|
+
const ansi = (code) => (value) => (useColor ? `\x1b[${code}m${value}\x1b[0m` : String(value));
|
|
4
|
+
|
|
5
|
+
export const bold = ansi('1');
|
|
6
|
+
export const dim = ansi('2');
|
|
7
|
+
export const red = ansi('31');
|
|
8
|
+
export const green = ansi('32');
|
|
9
|
+
export const yellow = ansi('33');
|
|
10
|
+
export const magenta = ansi('35');
|
|
11
|
+
export const cyan = ansi('36');
|
|
12
|
+
export const gray = ansi('90');
|
|
13
|
+
|
|
14
|
+
export const SEV_COLOR = { CRITICAL: red, HIGH: red, MEDIUM: yellow, LOW: cyan, INFO: gray };
|
|
15
|
+
|
|
16
|
+
export const VERDICT_COLOR = { FAIL: red, REVIEW: yellow, PASS: green };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { PACKAGE_MANIFEST_PATH } from './package-root.mjs';
|
|
3
|
+
|
|
4
|
+
const FALLBACK_VERSION = '0.0.0';
|
|
5
|
+
|
|
6
|
+
function readPackageVersion() {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(fs.readFileSync(PACKAGE_MANIFEST_PATH, 'utf8')).version ?? FALLBACK_VERSION;
|
|
9
|
+
} catch {
|
|
10
|
+
return FALLBACK_VERSION;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const VERSION = readPackageVersion();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export const WIRE_LIMITS = {
|
|
5
|
+
asset: { name: 255, identifier: 1024, content: 200_000 },
|
|
6
|
+
artifact: { name: 200, path: 1024, content: 200_000 },
|
|
7
|
+
artifactFile: { path: 1024, content: 200_000 },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const FINGERPRINT = 12;
|
|
11
|
+
|
|
12
|
+
export function clampField(value, max) {
|
|
13
|
+
if (typeof value !== 'string' || value.length <= max) return value;
|
|
14
|
+
const digest = createHash('sha256').update(value).digest('hex').slice(0, 10);
|
|
15
|
+
return `${value.slice(0, Math.max(1, max - FINGERPRINT))}…#${digest}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function clampAsset(asset) {
|
|
19
|
+
const L = WIRE_LIMITS.asset;
|
|
20
|
+
return {
|
|
21
|
+
...asset,
|
|
22
|
+
name: clampField(asset.name, L.name),
|
|
23
|
+
...(asset.identifier != null ? { identifier: clampField(asset.identifier, L.identifier) } : {}),
|
|
24
|
+
...(typeof asset.content === 'string' && asset.content.length > L.content
|
|
25
|
+
? { content: asset.content.slice(0, L.content) }
|
|
26
|
+
: {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function clampArtifact(artifact) {
|
|
31
|
+
const L = WIRE_LIMITS.artifact;
|
|
32
|
+
const F = WIRE_LIMITS.artifactFile;
|
|
33
|
+
return {
|
|
34
|
+
...artifact,
|
|
35
|
+
name: clampField(artifact.name, L.name),
|
|
36
|
+
path: clampField(artifact.path, L.path),
|
|
37
|
+
...(typeof artifact.content === 'string' && artifact.content.length > L.content
|
|
38
|
+
? { content: artifact.content.slice(0, L.content) }
|
|
39
|
+
: {}),
|
|
40
|
+
...(Array.isArray(artifact.files)
|
|
41
|
+
? {
|
|
42
|
+
files: artifact.files.map((f) => ({
|
|
43
|
+
...f,
|
|
44
|
+
path: clampField(f.path, F.path),
|
|
45
|
+
|
|
46
|
+
...(typeof f.content === 'string' && f.content.length > F.content
|
|
47
|
+
? { content: f.content.slice(0, F.content) }
|
|
48
|
+
: {}),
|
|
49
|
+
})),
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { MAX_ARTIFACT_BYTES, SKIP_DIRS } from '../artifacts/matchers.mjs';
|
|
4
|
+
import { INVISIBLE_CHARS_RE, downrankCodeContext, localScan } from '../detect/guard-signals.mjs';
|
|
5
|
+
|
|
6
|
+
export const CORPUS_TEXT_RE = /\.(md|markdown|txt|rst|adoc|html?|json|jsonl|ya?ml|csv|tsv|tex)$/i;
|
|
7
|
+
export const CORPUS_OPAQUE_RE = /\.(pdf|docx?|pptx?|xlsx?|epub|rtf|odt|pages|key|numbers)$/i;
|
|
8
|
+
export const CORPUS_DEFAULT_CHUNK = 1200;
|
|
9
|
+
|
|
10
|
+
const CORPUS_MAX_FILES = 5000;
|
|
11
|
+
const MAX_FINDINGS_PER_DOC = 6;
|
|
12
|
+
const OPAQUE_REASON = 'binary format - no text extractor';
|
|
13
|
+
const SCAN_CATEGORIES = ['injection', 'secret', 'pii'];
|
|
14
|
+
|
|
15
|
+
export function chunkIndexForLine(text, line, chunkSize) {
|
|
16
|
+
if (!line || line < 1) return null;
|
|
17
|
+
const lines = text.split(/\r?\n/);
|
|
18
|
+
let offset = 0;
|
|
19
|
+
for (let index = 0; index < Math.min(line - 1, lines.length); index += 1) offset += lines[index].length + 1;
|
|
20
|
+
return Math.floor(offset / chunkSize);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function walkCorpus(root) {
|
|
24
|
+
const files = [];
|
|
25
|
+
const opaque = [];
|
|
26
|
+
const stack = [root];
|
|
27
|
+
|
|
28
|
+
while (stack.length && files.length + opaque.length < CORPUS_MAX_FILES) {
|
|
29
|
+
const dir = stack.pop();
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const full = path.join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
if (!SKIP_DIRS.has(entry.name)) stack.push(full);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const rel = path.relative(root, full).split(path.sep).join('/');
|
|
43
|
+
if (CORPUS_TEXT_RE.test(entry.name)) files.push({ full, rel });
|
|
44
|
+
else if (CORPUS_OPAQUE_RE.test(entry.name)) opaque.push({ full, rel, reason: OPAQUE_REASON });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { files, opaque };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function collectCorpusFiles(absolutePath, isDirectory) {
|
|
51
|
+
if (isDirectory) return walkCorpus(absolutePath);
|
|
52
|
+
return {
|
|
53
|
+
files: CORPUS_TEXT_RE.test(absolutePath) ? [{ full: absolutePath, rel: path.basename(absolutePath) }] : [],
|
|
54
|
+
opaque: CORPUS_OPAQUE_RE.test(absolutePath)
|
|
55
|
+
? [{ full: absolutePath, rel: path.basename(absolutePath), reason: OPAQUE_REASON }]
|
|
56
|
+
: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readDocument(file) {
|
|
61
|
+
try {
|
|
62
|
+
const { size } = fs.statSync(file.full);
|
|
63
|
+
if (size > MAX_ARTIFACT_BYTES) return { reason: `too large (${Math.round(size / 1e6)}MB)` };
|
|
64
|
+
const text = fs.readFileSync(file.full, 'utf8');
|
|
65
|
+
if (text.includes('\0')) return { reason: 'not UTF-8 text' };
|
|
66
|
+
return { text };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { reason: error.message };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function documentVerdict(scanFindings, rankedFindings, hasInvisibleChars) {
|
|
73
|
+
const liveInjection = scanFindings.some((f) => f.category === 'injection' && !f.codeContext);
|
|
74
|
+
const liveCritical = scanFindings.some((f) => f.severity === 'CRITICAL' && !f.codeContext);
|
|
75
|
+
if (liveInjection || liveCritical || hasInvisibleChars) return 'BLOCK';
|
|
76
|
+
return rankedFindings.length ? 'FLAG' : 'ALLOW';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findingRows(findings, text, chunkSize) {
|
|
80
|
+
return findings.slice(0, MAX_FINDINGS_PER_DOC).map((finding) => ({
|
|
81
|
+
severity: finding.severity,
|
|
82
|
+
category: finding.category,
|
|
83
|
+
label: finding.label,
|
|
84
|
+
line: finding.line ?? null,
|
|
85
|
+
chunk: chunkIndexForLine(text, finding.line, chunkSize),
|
|
86
|
+
codeContext: !!finding.codeContext,
|
|
87
|
+
concealed: !!finding.concealed,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function screenCorpus(files, opaque, chunkSize) {
|
|
92
|
+
const results = [];
|
|
93
|
+
const unreadable = [...opaque];
|
|
94
|
+
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
const read = readDocument(file);
|
|
97
|
+
if (!read.text) {
|
|
98
|
+
unreadable.push({ ...file, reason: read.reason });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const scan = localScan(read.text, { categories: SCAN_CATEGORIES });
|
|
103
|
+
const ranked = downrankCodeContext(scan.findings || []);
|
|
104
|
+
const hasInvisibleChars = INVISIBLE_CHARS_RE.test(read.text);
|
|
105
|
+
const verdict = documentVerdict(scan.findings, ranked, hasInvisibleChars);
|
|
106
|
+
|
|
107
|
+
if (verdict === 'ALLOW') {
|
|
108
|
+
results.push({ path: file.rel, verdict, findings: [] });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rows = findingRows(ranked, read.text, chunkSize);
|
|
113
|
+
if (hasInvisibleChars) {
|
|
114
|
+
rows.unshift({
|
|
115
|
+
severity: 'CRITICAL',
|
|
116
|
+
category: 'injection',
|
|
117
|
+
label: 'Invisible / bidirectional characters',
|
|
118
|
+
line: null,
|
|
119
|
+
chunk: null,
|
|
120
|
+
codeContext: false,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
results.push({ path: file.rel, verdict, findings: rows });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { results, unreadable };
|
|
127
|
+
}
|
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AI-USAGE inventory extractor (CLI port of the backend's checks/ai-usage.ts,
|
|
3
|
-
* kept in sync so both surfaces detect the same thing). Finds where a repo USES
|
|
4
|
-
* an LLM/AI provider in its own source — SDK imports + provider-specific call
|
|
5
|
-
* sites — so `shomra` can inventory "this code talks to OpenAI / a local model"
|
|
6
|
-
* as plain shadow-AI usage, independent of whether that usage is vulnerable.
|
|
7
|
-
*
|
|
8
|
-
* Dependency-free, line-oriented, low-false-positive: a provider is only claimed
|
|
9
|
-
* when a line either IMPORTS its SDK module or matches a provider-specific CALL
|
|
10
|
-
* signature — never from a bare mention of the word "openai" in prose.
|
|
11
|
-
*/
|
|
12
1
|
|
|
13
|
-
/** Human label per category — shared by the report + backend. */
|
|
14
2
|
export const AI_USAGE_CATEGORY_LABEL = {
|
|
15
3
|
'llm-api': 'hosted LLM API',
|
|
16
4
|
'llm-framework': 'LLM framework',
|
|
@@ -18,7 +6,6 @@ export const AI_USAGE_CATEGORY_LABEL = {
|
|
|
18
6
|
'inference-gateway': 'inference gateway',
|
|
19
7
|
};
|
|
20
8
|
|
|
21
|
-
// The provider catalog — every entry is an LLM/AI *usage* surface.
|
|
22
9
|
const PROVIDERS = [
|
|
23
10
|
{ id: 'openai', label: 'OpenAI', category: 'llm-api', npm: ['openai'], npmPrefix: ['@azure/openai'], py: ['openai'],
|
|
24
11
|
call: [/\b(?:Async)?(?:Azure)?OpenAI\s*\(/, /\bchat\.completions\.create\s*\(/, /\bresponses\.create\s*\(/, /\bembeddings\.create\s*\(/, /\bChatCompletion\.create\s*\(/, /\bopenai\.(?:ChatCompletion|Completion|Embedding)\b/] },
|
|
@@ -60,17 +47,6 @@ const PROVIDERS = [
|
|
|
60
47
|
{ id: 'llama-cpp', label: 'llama.cpp', category: 'local-runtime', npm: ['node-llama-cpp'], py: ['llama_cpp'], call: [/\bLlama\s*\(\s*model_path\s*=/] },
|
|
61
48
|
];
|
|
62
49
|
|
|
63
|
-
/**
|
|
64
|
-
* Every AI package name this catalog knows, flattened for name comparison at
|
|
65
|
-
* ACQUISITION time (`shomra add package`). A typosquat is only detectable
|
|
66
|
-
* against a list of the real names, and this catalog is already that list —
|
|
67
|
-
* maintaining a second copy is how the two drift and the check quietly stops
|
|
68
|
-
* matching the packages people actually install.
|
|
69
|
-
*
|
|
70
|
-
* `ecosystem` matters: `openai` exists on both npm and PyPI, but `crewai` is
|
|
71
|
-
* PyPI-only, so `npm i crewai` is a different and more suspicious event than
|
|
72
|
-
* `pip install crewai`.
|
|
73
|
-
*/
|
|
74
50
|
export const KNOWN_AI_PACKAGES = (() => {
|
|
75
51
|
const out = [];
|
|
76
52
|
const seen = new Set();
|
|
@@ -117,7 +93,6 @@ function importMatches(spec, p) {
|
|
|
117
93
|
return false;
|
|
118
94
|
}
|
|
119
95
|
|
|
120
|
-
/** Extract AI-usage sightings from one source file's text. */
|
|
121
96
|
export function scanAiUsage(text, file = '') {
|
|
122
97
|
if (!text || !isAiUsageScannable(file)) return [];
|
|
123
98
|
const out = [];
|
|
@@ -155,10 +130,8 @@ export function scanAiUsage(text, file = '') {
|
|
|
155
130
|
return out;
|
|
156
131
|
}
|
|
157
132
|
|
|
158
|
-
/** Cap on individual sites carried per provider row. */
|
|
159
133
|
const MAX_SITES_PER_PROVIDER = 60;
|
|
160
134
|
|
|
161
|
-
/** One inventory row per provider, aggregated across all files. */
|
|
162
135
|
export function rollupAiUsage(usages) {
|
|
163
136
|
const byProvider = new Map();
|
|
164
137
|
for (const u of usages) {
|