@shomra/agent 0.3.17 → 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} +17 -106
- 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 -2055
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { api, gateMachine } from '../core/api-client.mjs';
|
|
4
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
5
|
+
import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
|
|
6
|
+
import { localGate } from '../detect/guard-signals.mjs';
|
|
7
|
+
import { detectEnv } from '../gate/environment.mjs';
|
|
8
|
+
import { DEC_RANK } from '../gate/sast.mjs';
|
|
9
|
+
import { rulesContext } from '../rules/context.mjs';
|
|
10
|
+
import { generateRules, mergeRulesBlock, resolveRulesTargets, rulesBlock } from '../rules/generate.mjs';
|
|
11
|
+
import { RULES_BEGIN, RULES_TARGETS } from '../rules/sections.mjs';
|
|
12
|
+
|
|
13
|
+
const ORG_POLICY_TIMEOUT_MS = 5000;
|
|
14
|
+
const MAX_ORG_DIRECTIVES = 20;
|
|
15
|
+
|
|
16
|
+
const plural = (count, word) => `${word}${count === 1 ? '' : 's'}`;
|
|
17
|
+
|
|
18
|
+
async function fetchOrgDirectives(root, flags) {
|
|
19
|
+
const { apiKey, url } = resolveSettings(loadConfig());
|
|
20
|
+
if (!apiKey || !url || flags['no-policy']) return { apiKey, orgLines: [], orgError: null };
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await api(
|
|
24
|
+
url,
|
|
25
|
+
apiKey,
|
|
26
|
+
'/gate/rules',
|
|
27
|
+
{ cwd: root, env: detectEnv(), machine: gateMachine() },
|
|
28
|
+
{ timeoutMs: ORG_POLICY_TIMEOUT_MS },
|
|
29
|
+
);
|
|
30
|
+
const directives = Array.isArray(response?.directives) ? response.directives : [];
|
|
31
|
+
const orgLines = directives.filter((line) => typeof line === 'string' && line.trim()).slice(0, MAX_ORG_DIRECTIVES);
|
|
32
|
+
return { apiKey, orgLines, orgError: null };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return { apiKey, orgLines: [], orgError: error.message };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function gateRank(content, targetFile) {
|
|
39
|
+
try {
|
|
40
|
+
return DEC_RANK[localGate(content, { kind: 'rules', path: targetFile }).verdict] ?? 0;
|
|
41
|
+
} catch {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function planTarget(root, key, block) {
|
|
47
|
+
const target = RULES_TARGETS[key];
|
|
48
|
+
const absolutePath = path.join(root, target.file);
|
|
49
|
+
|
|
50
|
+
let existing = '';
|
|
51
|
+
try {
|
|
52
|
+
existing = fs.readFileSync(absolutePath, 'utf8');
|
|
53
|
+
} catch {
|
|
54
|
+
existing = '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const next = mergeRulesBlock(existing, block, target);
|
|
58
|
+
const hadBlock = existing.includes(RULES_BEGIN);
|
|
59
|
+
const worsens = next !== null && gateRank(next, target.file) > (existing ? gateRank(existing, target.file) : 0);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
key,
|
|
63
|
+
label: target.label,
|
|
64
|
+
file: target.file,
|
|
65
|
+
abs: absolutePath,
|
|
66
|
+
next,
|
|
67
|
+
worsens,
|
|
68
|
+
state: next === null ? 'current' : hadBlock ? 'stale' : 'absent',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildJsonReport({ root, sections, orgLines, orgError, gate, context, written, plan, flags, body }) {
|
|
73
|
+
return {
|
|
74
|
+
root,
|
|
75
|
+
sections,
|
|
76
|
+
orgDirectives: orgLines.length,
|
|
77
|
+
orgError,
|
|
78
|
+
gate: gate ? { verdict: gate.verdict, riskScore: gate.riskScore } : null,
|
|
79
|
+
observed: context.observed,
|
|
80
|
+
artifacts: context.artifactCount,
|
|
81
|
+
modelRefs: context.modelRefs,
|
|
82
|
+
aiUsage: context.aiUsage,
|
|
83
|
+
written,
|
|
84
|
+
targets: plan.map(({ key, label, file, state, worsens }) => ({
|
|
85
|
+
key, label, file, state, ...(worsens ? { skipped: 'would-worsen' } : {}),
|
|
86
|
+
})),
|
|
87
|
+
...(flags.write ? {} : { block: body }),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function printDriftReport(plan, drifted) {
|
|
92
|
+
if (!drifted.length) {
|
|
93
|
+
console.log(`\n ${green(`✓ Shomra rules current in ${plan.length} ${plural(plan.length, 'file')}.`)}\n`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
console.log(`\n ${red(`✗ Shomra rules out of date in ${drifted.length} ${plural(drifted.length, 'file')}:`)}`);
|
|
97
|
+
for (const target of drifted) {
|
|
98
|
+
const state = target.state === 'absent' ? red('absent') : yellow('stale ');
|
|
99
|
+
console.log(` ${state} ${bold(target.file)} ${dim(`· ${target.label}`)}`);
|
|
100
|
+
}
|
|
101
|
+
console.log(dim('\n Run ') + bold('shomra rules --write') + dim(' and commit the result.\n'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeTarget(target, flags) {
|
|
105
|
+
if (target.state === 'current') {
|
|
106
|
+
if (!flags.json) console.log(` ${yellow('•')} ${target.label} ${dim(`already current (${target.file})`)}`);
|
|
107
|
+
return { wrote: false, failed: false };
|
|
108
|
+
}
|
|
109
|
+
if (target.worsens) {
|
|
110
|
+
if (!flags.json) {
|
|
111
|
+
console.log(` ${red('✗')} ${target.file} ${dim("- skipped: writing the block would raise this file's own gate verdict. Please report it.")}`);
|
|
112
|
+
}
|
|
113
|
+
return { wrote: false, failed: true };
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
fs.mkdirSync(path.dirname(target.abs), { recursive: true });
|
|
117
|
+
fs.writeFileSync(target.abs, target.next);
|
|
118
|
+
if (!flags.json) {
|
|
119
|
+
console.log(` ${green('✓')} ${target.state === 'stale' ? 'Updated' : 'Wrote'} ${bold(target.file)} ${dim(`· ${target.label}`)}`);
|
|
120
|
+
}
|
|
121
|
+
return { wrote: true, failed: false };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (!flags.json) console.log(` ${red('✗')} ${target.file} ${dim(`- ${error.message}`)}`);
|
|
124
|
+
return { wrote: false, failed: true };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function printWriteSummary(wrote, sections, orgLines) {
|
|
129
|
+
const headline = wrote
|
|
130
|
+
? green(`✓ ${wrote} rules ${plural(wrote, 'file')} updated`)
|
|
131
|
+
: green('✓ Already current');
|
|
132
|
+
const orgNote = orgLines.length ? ` · ${orgLines.length} org ${plural(orgLines.length, 'directive')}` : '';
|
|
133
|
+
console.log(`\n ${headline}${dim(` · ${sections.length} ${plural(sections.length, 'section')}${orgNote}`)}`);
|
|
134
|
+
console.log(dim(' Commit these - the agent reads them before it writes, so the blocked pattern is never generated.'));
|
|
135
|
+
console.log(dim(' Keep them honest in CI with ') + bold('shomra rules --check') + dim('.\n'));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function printPreview({ root, context, sections, apiKey, orgError, body, gate, plan }) {
|
|
139
|
+
const artifacts = `${context.artifactCount} ${plural(context.artifactCount, 'artifact')}`;
|
|
140
|
+
console.log(bold(cyan('\n Shomra rules')) + dim(` - ${sections.length} ${plural(sections.length, 'section')} for ${artifacts} under ${root}`));
|
|
141
|
+
|
|
142
|
+
if (orgError) console.log(` ${yellow('⚠')} ${dim(`org policy not applied - ${orgError}`)}`);
|
|
143
|
+
else if (!apiKey) console.log(` ${dim('On-machine rules only - run')} ${bold('shomra init')} ${dim('to layer your org policy on top.')}`);
|
|
144
|
+
|
|
145
|
+
console.log('');
|
|
146
|
+
console.log(body.split('\n').map((line) => ` ${dim(line)}`).join('\n'));
|
|
147
|
+
|
|
148
|
+
const gateLabel = gate && gate.verdict === 'ALLOW'
|
|
149
|
+
? green('✓ gate: clean')
|
|
150
|
+
: yellow(`gate: ${gate ? gate.verdict : 'unknown'}`);
|
|
151
|
+
console.log(` ${gateLabel}${dim(" - the block passes Shomra's own rules-file check.")}`);
|
|
152
|
+
console.log('');
|
|
153
|
+
|
|
154
|
+
for (const target of plan) {
|
|
155
|
+
const mark = target.state === 'current' ? green('✓') : target.state === 'stale' ? yellow('~') : dim('+');
|
|
156
|
+
console.log(` ${mark} ${bold(target.file)} ${dim(`· ${target.label} · ${target.state}`)}`);
|
|
157
|
+
}
|
|
158
|
+
console.log(dim('\n Write them with ') + bold('shomra rules --write') + dim(' (nothing outside the markers is touched).\n'));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function cmdRules(flags, positional) {
|
|
162
|
+
const root = path.resolve(positional[0] || flags.path || '.');
|
|
163
|
+
const context = rulesContext(root);
|
|
164
|
+
const { apiKey, orgLines, orgError } = await fetchOrgDirectives(root, flags);
|
|
165
|
+
|
|
166
|
+
const { body, sections, gate, observedOmitted } = generateRules(context, { orgLines });
|
|
167
|
+
const block = rulesBlock(body);
|
|
168
|
+
const plan = resolveRulesTargets(root, flags).map((key) => planTarget(root, key, block));
|
|
169
|
+
const drifted = plan.filter((target) => target.state !== 'current');
|
|
170
|
+
|
|
171
|
+
const written = [];
|
|
172
|
+
const emitJson = () => {
|
|
173
|
+
if (!flags.json) return;
|
|
174
|
+
console.log(JSON.stringify(
|
|
175
|
+
buildJsonReport({ root, sections, orgLines, orgError, gate, context, written, plan, flags, body }),
|
|
176
|
+
null,
|
|
177
|
+
2,
|
|
178
|
+
));
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (gate && gate.verdict === 'BLOCK') {
|
|
182
|
+
emitJson();
|
|
183
|
+
if (!flags.json) {
|
|
184
|
+
console.error(`\n${red('✗')} The generated block does not pass Shomra's own rules-file gate - refusing to write. This is a bug in the CLI; please report it.`);
|
|
185
|
+
}
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (observedOmitted && !flags.json) {
|
|
191
|
+
const issues = `${observedOmitted} ${plural(observedOmitted, 'issue')}`;
|
|
192
|
+
console.log(` ${yellow('!')} ${dim(`Left out the "already present in this repo" section (${issues}) - quoting those titles tripped the rules gate. Run `)}${bold('shomra check')}${dim(' to see them.')}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (flags.check) {
|
|
196
|
+
emitJson();
|
|
197
|
+
if (!flags.json) printDriftReport(plan, drifted);
|
|
198
|
+
if (drifted.length) process.exitCode = 1;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (flags.write) {
|
|
203
|
+
let wrote = 0;
|
|
204
|
+
for (const target of plan) {
|
|
205
|
+
const result = writeTarget(target, flags);
|
|
206
|
+
if (result.wrote) {
|
|
207
|
+
wrote += 1;
|
|
208
|
+
written.push(target.file);
|
|
209
|
+
}
|
|
210
|
+
if (result.failed) process.exitCode = 1;
|
|
211
|
+
}
|
|
212
|
+
emitJson();
|
|
213
|
+
if (!flags.json) printWriteSummary(wrote, sections, orgLines);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
emitJson();
|
|
218
|
+
if (flags.json) return;
|
|
219
|
+
printPreview({ root, context, sections, apiKey, orgError, body, gate, plan });
|
|
220
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { api } from '../core/api-client.mjs';
|
|
3
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
4
|
+
import { EXIT_USAGE, exitNotConfigured } from '../core/exit-codes.mjs';
|
|
5
|
+
import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
|
|
6
|
+
|
|
7
|
+
const STEP_MARK = {
|
|
8
|
+
SUCCESS: () => green('✓'),
|
|
9
|
+
FAILED: () => red('✗'),
|
|
10
|
+
SKIPPED: () => dim('-'),
|
|
11
|
+
RUNNING: () => cyan('•'),
|
|
12
|
+
PENDING: () => dim('·'),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const NUMERIC = /^-?\d+(\.\d+)?$/;
|
|
16
|
+
|
|
17
|
+
function coerceInputValue(raw) {
|
|
18
|
+
if (raw === 'true') return true;
|
|
19
|
+
if (raw === 'false') return false;
|
|
20
|
+
return NUMERIC.test(raw) ? Number(raw) : raw;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseInputs(flags) {
|
|
24
|
+
const inputs = {};
|
|
25
|
+
for (const entry of [].concat(flags.input ?? [])) {
|
|
26
|
+
const text = String(entry);
|
|
27
|
+
const separator = text.indexOf('=');
|
|
28
|
+
if (separator < 0) {
|
|
29
|
+
console.error(` ${red('✗')} --input expects key=value (got ${JSON.stringify(entry)})\n`);
|
|
30
|
+
process.exit(EXIT_USAGE);
|
|
31
|
+
}
|
|
32
|
+
inputs[text.slice(0, separator).trim()] = coerceInputValue(text.slice(separator + 1));
|
|
33
|
+
}
|
|
34
|
+
return inputs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function requestOrExit(request, flags) {
|
|
38
|
+
try {
|
|
39
|
+
return await request();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (!flags.json) console.log(red('failed'));
|
|
42
|
+
console.error(` ${red('✗')} ${error.message}\n`);
|
|
43
|
+
return process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function printCatalog(catalog) {
|
|
48
|
+
console.log(`\n ${bold('Playbooks')} ${dim('- run one end-to-end, exit non-zero when a gate holds')}\n`);
|
|
49
|
+
for (const playbook of catalog) {
|
|
50
|
+
console.log(` ${cyan(playbook.id)} ${dim(`v${playbook.version} · ${playbook.steps.length} steps`)}`);
|
|
51
|
+
console.log(` ${playbook.name}`);
|
|
52
|
+
const required = Object.entries(playbook.inputs || {}).filter(([, spec]) => spec.required).map(([key]) => key);
|
|
53
|
+
if (required.length) console.log(` ${dim('needs')} ${required.map((key) => bold(key)).join(', ')}`);
|
|
54
|
+
console.log('');
|
|
55
|
+
}
|
|
56
|
+
console.log(dim(` Run one: ${bold('shomra run <id> --input key=value')}\n`));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function listPlaybooks({ url, apiKey, flags, hasId }) {
|
|
60
|
+
const catalog = await requestOrExit(
|
|
61
|
+
() => api(url, apiKey, '/playbooks/agent/catalog', null, { method: 'GET' }),
|
|
62
|
+
{ json: true },
|
|
63
|
+
);
|
|
64
|
+
if (flags.json) {
|
|
65
|
+
console.log(JSON.stringify(catalog, null, 2));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
printCatalog(catalog);
|
|
69
|
+
if (!hasId) process.exitCode = EXIT_USAGE;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stepNote(step) {
|
|
73
|
+
if (step.status === 'SKIPPED') return dim('skipped - its condition was false');
|
|
74
|
+
if (!step.error) return '';
|
|
75
|
+
return step.uses === 'gate' ? yellow(step.error) : red(step.error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function printOutcome(run) {
|
|
79
|
+
const failedStep = (run.steps || []).find((step) => step.status === 'FAILED');
|
|
80
|
+
const gateHeld = run.status === 'FAILED' && failedStep?.uses === 'gate';
|
|
81
|
+
console.log('');
|
|
82
|
+
|
|
83
|
+
if (gateHeld) {
|
|
84
|
+
console.log(` ${yellow('⚠')} ${bold('The gate held.')} ${dim('That is the assertion firing - exiting non-zero.')}\n`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (run.status === 'FAILED') {
|
|
88
|
+
console.log(` ${red('✗')} ${bold('A step failed.')} ${dim(run.error || '')}\n`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
console.log(` ${green('✓')} ${bold('Completed.')} ${dim('Every step that was meant to run did.')}\n`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function printRun(run) {
|
|
95
|
+
console.log(`\n ${bold(run.playbookName)} ${dim(`· ${run.playbookId}`)}\n`);
|
|
96
|
+
for (const step of run.steps || []) {
|
|
97
|
+
const mark = (STEP_MARK[step.status] || STEP_MARK.PENDING)();
|
|
98
|
+
const note = stepNote(step);
|
|
99
|
+
console.log(` ${mark} ${step.name}${note ? ` ${dim('·')} ${note}` : ''}`);
|
|
100
|
+
}
|
|
101
|
+
printOutcome(run);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function cmdRun(flags, positional) {
|
|
105
|
+
const id = positional[0];
|
|
106
|
+
const inputs = parseInputs(flags);
|
|
107
|
+
|
|
108
|
+
const { apiKey, url } = resolveSettings(loadConfig());
|
|
109
|
+
if (!apiKey) exitNotConfigured();
|
|
110
|
+
|
|
111
|
+
if (!id || flags.list) {
|
|
112
|
+
await listPlaybooks({ url, apiKey, flags, hasId: !!id });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!flags.json) process.stdout.write(dim(`\n Running ${bold(id)}… `));
|
|
117
|
+
const run = await requestOrExit(() => api(url, apiKey, `/playbooks/agent/${encodeURIComponent(id)}/run`, {
|
|
118
|
+
inputs,
|
|
119
|
+
...(flags.project ? { projectId: String(flags.project) } : {}),
|
|
120
|
+
actor: `${os.hostname()}/${os.userInfo().username}`,
|
|
121
|
+
}), flags);
|
|
122
|
+
if (!flags.json) console.log(green('done'));
|
|
123
|
+
|
|
124
|
+
if (flags.json) console.log(JSON.stringify(run, null, 2));
|
|
125
|
+
else printRun(run);
|
|
126
|
+
|
|
127
|
+
if (run.status === 'FAILED') process.exitCode = 1;
|
|
128
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
5
|
+
import { EXIT_USAGE, exitNotConfigured } from '../core/exit-codes.mjs';
|
|
6
|
+
import { SEV_COLOR, VERDICT_COLOR, bold, dim, gray, green, red, yellow } from '../core/terminal.mjs';
|
|
7
|
+
|
|
8
|
+
const MAX_POLICY_HITS_SHOWN = 3;
|
|
9
|
+
|
|
10
|
+
function resolveArchive(positional) {
|
|
11
|
+
const file = positional[0];
|
|
12
|
+
if (!file) {
|
|
13
|
+
console.error(`${red('✗')} Usage: ${bold('shomra scan-zip <workspace.zip> [--project <id>] [--json]')}`);
|
|
14
|
+
process.exit(EXIT_USAGE);
|
|
15
|
+
}
|
|
16
|
+
const target = path.resolve(String(file));
|
|
17
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
18
|
+
console.error(`${red('✗')} File not found: ${file}`);
|
|
19
|
+
process.exit(EXIT_USAGE);
|
|
20
|
+
}
|
|
21
|
+
if (!/\.zip$/i.test(target)) {
|
|
22
|
+
console.error(`${red('✗')} ${file} is not a .zip archive.`);
|
|
23
|
+
process.exit(EXIT_USAGE);
|
|
24
|
+
}
|
|
25
|
+
return target;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildUploadForm(target, flags) {
|
|
29
|
+
const form = new FormData();
|
|
30
|
+
form.append('file', new Blob([fs.readFileSync(target)], { type: 'application/zip' }), path.basename(target));
|
|
31
|
+
form.append('actor', `${os.hostname()}/${os.userInfo().username}`);
|
|
32
|
+
if (flags.project) form.append('projectId', String(flags.project));
|
|
33
|
+
return form;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readResponse(response) {
|
|
37
|
+
const text = await response.text();
|
|
38
|
+
let body;
|
|
39
|
+
try {
|
|
40
|
+
body = JSON.parse(text);
|
|
41
|
+
} catch {
|
|
42
|
+
body = { raw: text };
|
|
43
|
+
}
|
|
44
|
+
if (response.ok) return body;
|
|
45
|
+
|
|
46
|
+
const message = body?.message || body?.raw || response.statusText;
|
|
47
|
+
throw new Error(`${response.status} ${Array.isArray(message) ? message.join(', ') : message}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function uploadArchive({ url, apiKey, target, flags }) {
|
|
51
|
+
if (!flags.json) process.stdout.write(dim('\n Uploading to Workspace Scan… '));
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(`${url}/bundle/agent-scan`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'X-Shomra-Key': apiKey, Connection: 'close' },
|
|
56
|
+
body: buildUploadForm(target, flags),
|
|
57
|
+
});
|
|
58
|
+
const report = await readResponse(response);
|
|
59
|
+
if (!flags.json) console.log(green('done'));
|
|
60
|
+
return report;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!flags.json) console.log(red('failed'));
|
|
63
|
+
console.error(` ${red('✗')} ${error.message}\n`);
|
|
64
|
+
return process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function printPolicyDecision(report) {
|
|
69
|
+
if (!report.policyDecision || report.policyDecision === 'ALLOW') return;
|
|
70
|
+
const colour = report.policyDecision === 'BLOCK' ? red : yellow;
|
|
71
|
+
const policies = (report.policyHits || []).map((hit) => hit.policy).slice(0, MAX_POLICY_HITS_SHOWN).join(', ');
|
|
72
|
+
console.log(` ${colour('▎')} ${colour(`org policy: ${report.policyDecision}`)}${policies ? dim(` - ${policies}`) : ''}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function printArtifactGroups(report) {
|
|
76
|
+
for (const group of report.groups || []) {
|
|
77
|
+
if (!group.count) continue;
|
|
78
|
+
console.log(`\n ${bold(group.kind.replace(/_/g, ' ').toLowerCase())} ${dim(`(${group.count})`)}`);
|
|
79
|
+
|
|
80
|
+
for (const artifact of group.artifacts || []) {
|
|
81
|
+
const colour = VERDICT_COLOR[artifact.verdict] || gray;
|
|
82
|
+
console.log(` ${colour('●')} ${bold(artifact.name)} ${dim(artifact.path)} ${colour(artifact.verdict)} ${dim(`risk ${artifact.riskScore}`)}`);
|
|
83
|
+
for (const finding of (artifact.findings || []).filter((f) => f.severity !== 'INFO')) {
|
|
84
|
+
console.log(` ${SEV_COLOR[finding.severity](String(finding.severity).padEnd(8))} ${finding.title}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function summaryLine(verdict) {
|
|
91
|
+
if (verdict === 'FAIL') return red('✗ Do not install this workspace unreviewed.');
|
|
92
|
+
if (verdict === 'REVIEW') return yellow('⚠ Review the findings above before trusting this workspace.');
|
|
93
|
+
return green('✓ Clean.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function printReport(report) {
|
|
97
|
+
const colour = VERDICT_COLOR[report.verdict] || gray;
|
|
98
|
+
console.log(`\n ${bold(report.filename)} ${dim(`· ${report.fileCount} files · ${report.artifactCount} AI artifact(s)`)}`);
|
|
99
|
+
console.log(` ${colour('●')} ${colour(bold(report.verdict))} ${dim(`risk ${report.riskScore}/100 · ${report.findingCount} finding(s) · ${report.criticalCount} critical · ${report.highCount} high`)}`);
|
|
100
|
+
|
|
101
|
+
printPolicyDecision(report);
|
|
102
|
+
printArtifactGroups(report);
|
|
103
|
+
console.log(`\n ${summaryLine(report.verdict)}${dim(' Full report in the Shomra dashboard → Workspace Scan.\n')}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function cmdScanZip(flags, positional) {
|
|
107
|
+
const { apiKey, url } = resolveSettings(loadConfig());
|
|
108
|
+
if (!apiKey) exitNotConfigured();
|
|
109
|
+
|
|
110
|
+
const target = resolveArchive(positional);
|
|
111
|
+
const report = await uploadArchive({ url, apiKey, target, flags });
|
|
112
|
+
|
|
113
|
+
if (flags.json) console.log(JSON.stringify(report, null, 2));
|
|
114
|
+
else printReport(report);
|
|
115
|
+
|
|
116
|
+
if (report.policyDecision === 'BLOCK' || report.verdict === 'FAIL') process.exitCode = 1;
|
|
117
|
+
else if ((report.policyDecision === 'FLAG' || report.verdict === 'REVIEW') && flags.strict) process.exitCode = 2;
|
|
118
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { printArtifacts, printAssets } from '../artifacts/report.mjs';
|
|
3
|
+
import { api, machineInfo } from '../core/api-client.mjs';
|
|
4
|
+
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
5
|
+
import { exitNotConfigured } from '../core/exit-codes.mjs';
|
|
6
|
+
import { clampInt } from '../core/numbers.mjs';
|
|
7
|
+
import { SEV_COLOR, VERDICT_COLOR, bold, cyan, dim, gray, green, red, yellow } from '../core/terminal.mjs';
|
|
8
|
+
import { VERSION } from '../core/version.mjs';
|
|
9
|
+
import { discoverAgentArtifacts } from '../inventory/agent-artifacts.mjs';
|
|
10
|
+
import { discoverAll } from '../inventory/discovery.mjs';
|
|
11
|
+
|
|
12
|
+
function discover(flags) {
|
|
13
|
+
|
|
14
|
+
const roots = flags.path ? [path.resolve(String(flags.path))] : [process.cwd()];
|
|
15
|
+
return discoverAll(roots, { autoExpand: !flags.path });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function discoverArtifacts(flags) {
|
|
19
|
+
const cwd = flags.path ? path.resolve(String(flags.path)) : process.cwd();
|
|
20
|
+
return discoverAgentArtifacts(cwd);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function cmdScan(flags) {
|
|
24
|
+
const cfg = loadConfig();
|
|
25
|
+
const assets = discover(flags);
|
|
26
|
+
const { artifacts, capped, available } = discoverArtifacts(flags);
|
|
27
|
+
if (flags.json && !flags.report) {
|
|
28
|
+
console.log(JSON.stringify({ machine: machineInfo(cfg), assets, artifacts, capped, available }, null, 2));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
console.log(bold(cyan('\n Shomra')) + dim(` agent v${VERSION} - local scan`));
|
|
32
|
+
printAssets(assets);
|
|
33
|
+
printArtifacts(artifacts, capped, available);
|
|
34
|
+
|
|
35
|
+
if (flags.report) {
|
|
36
|
+
await sendReport(cfg, assets, flags, { artifacts, capped, available });
|
|
37
|
+
} else {
|
|
38
|
+
console.log(
|
|
39
|
+
dim('\n Run ') + bold('shomra report') + dim(' to analyze these on the platform and see findings.\n'),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function sendReport(cfg, assets, flags, extra = {}) {
|
|
45
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
46
|
+
if (!apiKey) {
|
|
47
|
+
exitNotConfigured();
|
|
48
|
+
}
|
|
49
|
+
const artifacts = extra.artifacts ?? [];
|
|
50
|
+
const capped = extra.capped ?? [];
|
|
51
|
+
const available = extra.available ?? [];
|
|
52
|
+
process.stdout.write(dim('\n Reporting to platform… '));
|
|
53
|
+
try {
|
|
54
|
+
const res = await api(url, apiKey, '/agent/report', {
|
|
55
|
+
machine: machineInfo(cfg),
|
|
56
|
+
assets,
|
|
57
|
+
artifacts,
|
|
58
|
+
|
|
59
|
+
capped,
|
|
60
|
+
|
|
61
|
+
available,
|
|
62
|
+
}, {
|
|
63
|
+
|
|
64
|
+
timeoutMs: clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 180000, 1000, 600000),
|
|
65
|
+
});
|
|
66
|
+
console.log(
|
|
67
|
+
green('done') +
|
|
68
|
+
dim(` (${res.assets} assets analyzed`) +
|
|
69
|
+
(res.artifacts ? dim(`, ${res.artifacts.registered} artifacts registered`) : '') +
|
|
70
|
+
dim(')'),
|
|
71
|
+
);
|
|
72
|
+
if (flags.json) {
|
|
73
|
+
console.log(JSON.stringify(res, null, 2));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
console.log('');
|
|
77
|
+
if (Array.isArray(res.results)) {
|
|
78
|
+
for (const r of res.results.filter((x) => x.findingCount > 0)) {
|
|
79
|
+
const vc = VERDICT_COLOR[r.verdict] || gray;
|
|
80
|
+
console.log(` ${vc('●')} ${bold(r.name)} ${dim('risk ' + r.riskScore)} ${vc(r.verdict)}`);
|
|
81
|
+
for (const f of r.findings || []) {
|
|
82
|
+
console.log(` ${SEV_COLOR[f.severity](f.severity.padEnd(8))} ${f.title}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const crit = res.critical ?? 0;
|
|
87
|
+
const high = res.high ?? 0;
|
|
88
|
+
console.log(
|
|
89
|
+
'\n ' +
|
|
90
|
+
(crit + high > 0
|
|
91
|
+
? `${red(crit + ' critical')} · ${yellow(high + ' high')} ${dim('- view & remediate at the Shomra dashboard')}`
|
|
92
|
+
: green('No high-severity findings. ') + dim('Nice and clean.')),
|
|
93
|
+
);
|
|
94
|
+
console.log(dim(` Endpoint: ${res.endpointId}\n`));
|
|
95
|
+
if (crit > 0) process.exitCode = 1;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.log(red('failed'));
|
|
98
|
+
console.error(` ${red('✗')} ${e.message}\n`);
|
|
99
|
+
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { MAX_ARTIFACT_BYTES, SKIP_DIRS } from '../artifacts/matchers.mjs';
|
|
5
|
+
import { clampInt } from '../core/numbers.mjs';
|
|
6
|
+
import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
|
|
7
|
+
import { SECRET_PATTERNS, localScan } from '../detect/guard-signals.mjs';
|
|
8
|
+
|
|
9
|
+
function redactSecret(s) {
|
|
10
|
+
const t = String(s).trim();
|
|
11
|
+
return t.length <= 8 ? t[0] + '••••' : `${t.slice(0, 4)}…${t.slice(-2)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isGitRepo(root) {
|
|
15
|
+
try { execSync('git rev-parse --is-inside-work-tree', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }); return true; }
|
|
16
|
+
catch { return false; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function walkFiles(root, cap = 8000) {
|
|
20
|
+
const found = [];
|
|
21
|
+
const stack = [root];
|
|
22
|
+
while (stack.length && found.length < cap) {
|
|
23
|
+
const dir = stack.pop();
|
|
24
|
+
let entries;
|
|
25
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
26
|
+
for (const ent of entries) {
|
|
27
|
+
if (ent.isDirectory()) { if (!SKIP_DIRS.has(ent.name) && ent.name !== '.git') stack.push(path.join(dir, ent.name)); continue; }
|
|
28
|
+
found.push(path.relative(root, path.join(dir, ent.name)).split(path.sep).join('/'));
|
|
29
|
+
if (found.length >= cap) break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function scanGitHistory(root, depth) {
|
|
36
|
+
let out;
|
|
37
|
+
try { out = execSync(`git log --all -p -n ${depth} --no-color --format="commit %H %an %ad"`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 128 * 1024 * 1024 }).toString(); }
|
|
38
|
+
catch { return null; }
|
|
39
|
+
const hits = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
let commit = '', file = '';
|
|
42
|
+
for (const line of out.split(/\r?\n/)) {
|
|
43
|
+
if (line.startsWith('commit ')) { commit = line.slice(7, 19); continue; }
|
|
44
|
+
if (line.startsWith('+++ b/')) { file = line.slice(6); continue; }
|
|
45
|
+
if (line[0] !== '+' || line.startsWith('+++')) continue;
|
|
46
|
+
const added = line.slice(1);
|
|
47
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
48
|
+
const m = added.match(re);
|
|
49
|
+
if (!m) continue;
|
|
50
|
+
const key = `${commit}:${file}:${name}`;
|
|
51
|
+
if (seen.has(key)) continue;
|
|
52
|
+
seen.add(key);
|
|
53
|
+
hits.push({ where: 'history', commit, file, secret: name, sample: redactSecret(m[0]) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return hits;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function cmdSecrets(flags, positional) {
|
|
60
|
+
const root = path.resolve(positional[0] || '.');
|
|
61
|
+
const hits = [];
|
|
62
|
+
const isGit = isGitRepo(root);
|
|
63
|
+
for (const rel of walkFiles(root)) {
|
|
64
|
+
let content;
|
|
65
|
+
try {
|
|
66
|
+
const full = path.join(root, rel);
|
|
67
|
+
if (fs.statSync(full).size > MAX_ARTIFACT_BYTES) continue;
|
|
68
|
+
content = fs.readFileSync(full, 'utf8');
|
|
69
|
+
} catch { continue; }
|
|
70
|
+
if (content.includes('\0')) continue;
|
|
71
|
+
for (const f of localScan(content, { categories: ['secret'] }).findings) {
|
|
72
|
+
hits.push({ where: 'working-tree', file: rel, line: f.line, secret: f.label.replace(/^Live credential:\s*/, '') });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let history = null;
|
|
76
|
+
if (flags.history) {
|
|
77
|
+
history = scanGitHistory(root, clampInt(flags.depth, 300, 1, 5000));
|
|
78
|
+
if (history) hits.push(...history);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (flags.json) { console.log(JSON.stringify({ workingTree: hits.filter((h) => h.where === 'working-tree').length, history: history ? history.length : null, hits }, null, 2)); return; }
|
|
82
|
+
|
|
83
|
+
console.log(bold(cyan('\n Shomra secrets')) + dim(` - ${path.relative(process.cwd(), root).split(path.sep).join('/') || '.'}${flags.history ? ' · working tree + git history' : ' · working tree'}`));
|
|
84
|
+
if (!isGit) console.log(dim(' (not a git repo - working tree only; --history unavailable)'));
|
|
85
|
+
else if (!flags.history) console.log(dim(' Tip: add ') + bold('--history') + dim(' to also scan past commits (a leaked key removed from HEAD is still live).'));
|
|
86
|
+
const wt = hits.filter((h) => h.where === 'working-tree');
|
|
87
|
+
const hi = hits.filter((h) => h.where === 'history');
|
|
88
|
+
if (!hits.length) { console.log(green('\n ✓ No secret-shaped values found.\n')); return; }
|
|
89
|
+
if (wt.length) {
|
|
90
|
+
console.log(red(`\n ${wt.length} in the working tree:`));
|
|
91
|
+
for (const h of wt.slice(0, 25)) console.log(` ${red('●')} ${bold(h.file)}${h.line ? dim(':' + h.line) : ''} ${dim(h.secret)}`);
|
|
92
|
+
}
|
|
93
|
+
if (hi.length) {
|
|
94
|
+
console.log(yellow(`\n ${hi.length} in git history ${dim('(rotate - still reachable):')}`));
|
|
95
|
+
for (const h of hi.slice(0, 25)) console.log(` ${yellow('●')} ${dim(h.commit)} ${bold(h.file)} ${dim(h.secret + ' ' + (h.sample || ''))}`);
|
|
96
|
+
}
|
|
97
|
+
console.log(dim(`\n Rotate every matched credential now. A committed secret is compromised even after you delete it - history keeps it.\n`));
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
}
|