fullcourtdefense-cli 1.28.0 → 1.30.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/actionPolicyEngine.d.ts +22 -0
- package/dist/actionPolicyEngine.js +446 -22
- package/dist/blockExplanation.d.ts +95 -0
- package/dist/blockExplanation.js +240 -0
- package/dist/commands/cmdGuard.d.ts +9 -0
- package/dist/commands/cmdGuard.js +27 -3
- package/dist/commands/deterministicGuard.d.ts +35 -0
- package/dist/commands/deterministicGuard.js +49 -35
- package/dist/commands/hook.js +116 -21
- package/dist/commands/mcpGateway.js +53 -14
- package/dist/commands/posixShellGuard.d.ts +12 -0
- package/dist/commands/posixShellGuard.js +8 -0
- package/dist/commands/shellGuard.d.ts +12 -0
- package/dist/commands/shellGuard.js +102 -13
- package/dist/notify.d.ts +2 -0
- package/dist/notify.js +5 -0
- package/dist/version.json +1 -1
- package/package.json +6 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE developer-facing explanation for a FullCourtDefense stop.
|
|
3
|
+
*
|
|
4
|
+
* Every enforcement surface (IDE hooks, MCP gateway, terminal guards) MUST go
|
|
5
|
+
* through here when it blocks, masks, or pauses an action. A developer who is
|
|
6
|
+
* stopped has to learn, in one glance and without opening anything:
|
|
7
|
+
*
|
|
8
|
+
* 1. WHAT was stopped — the exact command / path / tool call
|
|
9
|
+
* 2. WHICH RULE, BY NAME — the policy or Local Safety rule, and where it came from
|
|
10
|
+
* 3. WHY it matched — the deterministic fact that fired (operation, secret kind, host…)
|
|
11
|
+
* 4. HOW TO REMOVE THE BLOCK — the exact console page, and what to click
|
|
12
|
+
*
|
|
13
|
+
* Fixed templates only: nothing here is generated text, and raw argument
|
|
14
|
+
* values never leak beyond the short "what" line. Pure — no I/O, so it is unit
|
|
15
|
+
* tested byte-for-byte by `scripts/test-block-explanation.js`.
|
|
16
|
+
*/
|
|
17
|
+
export type ExplanationOutcome = 'block' | 'ask' | 'approval' | 'mask';
|
|
18
|
+
export interface PolicyExplanationSource {
|
|
19
|
+
kind: 'policy';
|
|
20
|
+
policyName?: string;
|
|
21
|
+
policyId?: string;
|
|
22
|
+
/** Engine `matchedRule`, e.g. `DELETE → block (toolArgs.containsSecret equals true)`. */
|
|
23
|
+
matchedRule?: string;
|
|
24
|
+
/** Policy scope: `organization` (company default) or a machine/developer-scoped exception. */
|
|
25
|
+
scope?: string;
|
|
26
|
+
/** Engine facts that explain the match. Only well-known keys are rendered. */
|
|
27
|
+
facts?: Record<string, string | undefined>;
|
|
28
|
+
operation?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Verdict reason from the engine or the policy service. Rendered only when it
|
|
31
|
+
* says more than the rule line already does (the engine's own
|
|
32
|
+
* `Action policy "<name>": … blocked` boilerplate is dropped; a server-side
|
|
33
|
+
* or admin-written reason such as "Destructive business operation blocked" is kept).
|
|
34
|
+
*/
|
|
35
|
+
reason?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface LocalSafetyExplanationSource {
|
|
38
|
+
kind: 'local_safety';
|
|
39
|
+
itemId: string;
|
|
40
|
+
/** Console label when known (e.g. "GitHub CLI token print"); falls back to the id. */
|
|
41
|
+
label?: string;
|
|
42
|
+
category?: string;
|
|
43
|
+
source: 'builtin' | 'custom';
|
|
44
|
+
explanation?: string;
|
|
45
|
+
/** Machine-stated reason from the guard (`reason` on the finding). */
|
|
46
|
+
reason: string;
|
|
47
|
+
}
|
|
48
|
+
export interface GuardExplanationSource {
|
|
49
|
+
kind: 'taint' | 'self_protection' | 'fail_closed' | 'suspended' | 'offline';
|
|
50
|
+
ruleId?: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
}
|
|
53
|
+
export type ExplanationSource = PolicyExplanationSource | LocalSafetyExplanationSource | GuardExplanationSource;
|
|
54
|
+
export interface BlockExplanationInput {
|
|
55
|
+
outcome: ExplanationOutcome;
|
|
56
|
+
/** Hook event / surface: shell, file, mcp, prompt, terminal, gateway. */
|
|
57
|
+
event: string;
|
|
58
|
+
toolName: string;
|
|
59
|
+
toolArgs?: Record<string, unknown>;
|
|
60
|
+
source: ExplanationSource;
|
|
61
|
+
/** Console base URL (no trailing slash). */
|
|
62
|
+
consoleUrl: string;
|
|
63
|
+
/** Fleet machine id — used to deep-link the machine page. */
|
|
64
|
+
machineId?: string;
|
|
65
|
+
/** Developer display name, when the block is machine-scoped. */
|
|
66
|
+
developerName?: string;
|
|
67
|
+
/** Extra sentence appended to the "why" (e.g. no human present on CI). */
|
|
68
|
+
note?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface BlockExplanation {
|
|
71
|
+
/** One line, first thing the developer reads. */
|
|
72
|
+
headline: string;
|
|
73
|
+
/** The stopped action, shortened, never more than ~160 chars. */
|
|
74
|
+
what: string;
|
|
75
|
+
/** Rule name + origin, exactly as it appears in the console. */
|
|
76
|
+
rule: string;
|
|
77
|
+
/** Deterministic reason the rule matched. */
|
|
78
|
+
why: string;
|
|
79
|
+
/** Numbered, concrete steps to lift the block. */
|
|
80
|
+
unblock: string[];
|
|
81
|
+
/** Deep link to the console entry that controls this rule. */
|
|
82
|
+
consoleLink: string;
|
|
83
|
+
/** Full multi-line text shown to the developer (IDE toast / terminal stderr). */
|
|
84
|
+
userMessage: string;
|
|
85
|
+
/** Single paragraph for the agent: stop, do not retry, relay the message. */
|
|
86
|
+
agentMessage: string;
|
|
87
|
+
}
|
|
88
|
+
/** The stopped action in the developer's own terms — command, path, or tool call. */
|
|
89
|
+
export declare function summarizeAction(event: string, toolName: string, args?: Record<string, unknown>): string;
|
|
90
|
+
export declare function buildBlockExplanation(input: BlockExplanationInput): BlockExplanation;
|
|
91
|
+
/**
|
|
92
|
+
* Compact single-line form for surfaces with no room for a block (terminal
|
|
93
|
+
* `[FullCourtDefense]` line, Claude `permissionDecisionReason`).
|
|
94
|
+
*/
|
|
95
|
+
export declare function oneLineExplanation(explanation: BlockExplanation): string;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The ONE developer-facing explanation for a FullCourtDefense stop.
|
|
4
|
+
*
|
|
5
|
+
* Every enforcement surface (IDE hooks, MCP gateway, terminal guards) MUST go
|
|
6
|
+
* through here when it blocks, masks, or pauses an action. A developer who is
|
|
7
|
+
* stopped has to learn, in one glance and without opening anything:
|
|
8
|
+
*
|
|
9
|
+
* 1. WHAT was stopped — the exact command / path / tool call
|
|
10
|
+
* 2. WHICH RULE, BY NAME — the policy or Local Safety rule, and where it came from
|
|
11
|
+
* 3. WHY it matched — the deterministic fact that fired (operation, secret kind, host…)
|
|
12
|
+
* 4. HOW TO REMOVE THE BLOCK — the exact console page, and what to click
|
|
13
|
+
*
|
|
14
|
+
* Fixed templates only: nothing here is generated text, and raw argument
|
|
15
|
+
* values never leak beyond the short "what" line. Pure — no I/O, so it is unit
|
|
16
|
+
* tested byte-for-byte by `scripts/test-block-explanation.js`.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.summarizeAction = summarizeAction;
|
|
20
|
+
exports.buildBlockExplanation = buildBlockExplanation;
|
|
21
|
+
exports.oneLineExplanation = oneLineExplanation;
|
|
22
|
+
const MAX_WHAT = 160;
|
|
23
|
+
function str(v) {
|
|
24
|
+
return typeof v === 'string' && v.trim() ? v : undefined;
|
|
25
|
+
}
|
|
26
|
+
function shorten(text, max = MAX_WHAT) {
|
|
27
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
28
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
29
|
+
}
|
|
30
|
+
/** The stopped action in the developer's own terms — command, path, or tool call. */
|
|
31
|
+
function summarizeAction(event, toolName, args = {}) {
|
|
32
|
+
const command = str(args.command) || str(args.cmd) || str(args.commandLine) || str(args.script);
|
|
33
|
+
if (command)
|
|
34
|
+
return `${shorten(command)} (${event === 'terminal' ? 'terminal command' : 'shell command'})`;
|
|
35
|
+
const path = str(args.path) || str(args.file_path) || str(args.filePath) || str(args.target_file) || str(args.notebook_path);
|
|
36
|
+
if (path) {
|
|
37
|
+
const verb = /read|open|view|cat|list|search|grep/i.test(toolName) ? 'read' : 'edit';
|
|
38
|
+
return `${shorten(path)} (file ${verb})`;
|
|
39
|
+
}
|
|
40
|
+
const url = str(args.url) || str(args.uri) || str(args.endpoint);
|
|
41
|
+
if (url)
|
|
42
|
+
return `${toolName} → ${shorten(url, 120)} (network call)`;
|
|
43
|
+
if (event === 'prompt')
|
|
44
|
+
return 'the prompt you submitted';
|
|
45
|
+
const server = str(args.mcp_server_name) || str(args.server_name) || str(args.serverName);
|
|
46
|
+
return `${server ? `${server} / ` : ''}${toolName} (${event === 'mcp' ? 'MCP tool call' : `${event} action`})`;
|
|
47
|
+
}
|
|
48
|
+
/** Headline per outcome — `${noun}` is the action kind ("shell command", "MCP tool call"…). */
|
|
49
|
+
function headlineFor(outcome, noun) {
|
|
50
|
+
switch (outcome) {
|
|
51
|
+
case 'ask': return `FullCourtDefense paused this ${noun} — your confirmation is needed.`;
|
|
52
|
+
case 'approval': return `FullCourtDefense paused this ${noun} — Approval required from your security team.`;
|
|
53
|
+
case 'mask': return `FullCourtDefense masked secrets in this ${noun}.`;
|
|
54
|
+
default: return `FullCourtDefense blocked this ${noun}.`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The step list title — a pause is lifted by an answer, a block by a rule change. */
|
|
58
|
+
function stepsTitle(outcome) {
|
|
59
|
+
return outcome === 'block' ? 'To remove this block:' : outcome === 'mask' ? 'To change this rule:' : 'To remove this block (or to stop being asked):';
|
|
60
|
+
}
|
|
61
|
+
function eventNoun(event) {
|
|
62
|
+
switch (event) {
|
|
63
|
+
case 'shell': return 'shell command';
|
|
64
|
+
case 'terminal': return 'terminal command';
|
|
65
|
+
case 'file': return 'file operation';
|
|
66
|
+
case 'read': return 'file read';
|
|
67
|
+
case 'mcp': return 'MCP tool call';
|
|
68
|
+
case 'prompt': return 'prompt';
|
|
69
|
+
case 'gateway': return 'tool call';
|
|
70
|
+
default: return 'action';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Company default vs local exception — the same words the console uses. */
|
|
74
|
+
function scopeWords(scope, developerName) {
|
|
75
|
+
if (!scope || scope === 'organization' || scope === 'org')
|
|
76
|
+
return '🌐 company default — applies to every machine';
|
|
77
|
+
if (scope === 'machine' || scope === 'developer' || scope === 'local')
|
|
78
|
+
return `📍 local rule — only ${developerName || 'this machine'}`;
|
|
79
|
+
return `${scope} policy`;
|
|
80
|
+
}
|
|
81
|
+
const FACT_WORDS = [
|
|
82
|
+
['operation', v => v && !/^(shell|execute|unknown)$/i.test(v) ? `the action was classified as ${v.toUpperCase()}` : undefined],
|
|
83
|
+
['toolArgs.containsSecret', (v, f) => v === 'true'
|
|
84
|
+
? `a secret-shaped value was found in the arguments${f['toolArgs.secretKind'] && f['toolArgs.secretKind'] !== 'none' ? ` (${f['toolArgs.secretKind'].replace(/_/g, ' ')})` : ''}`
|
|
85
|
+
: undefined],
|
|
86
|
+
['toolArgs.containsPii', v => v === 'true' ? 'personal data (PII) was found in the arguments' : undefined],
|
|
87
|
+
['destination.type', (v, f) => v === 'external' ? `the destination ${f['destination.domain'] ? `"${f['destination.domain']}" ` : ''}is outside your organisation` : undefined],
|
|
88
|
+
['destination.domain', (v, f) => f['destination.type'] === 'external' ? undefined : `destination host: ${v}`],
|
|
89
|
+
['url.risk', v => v && v !== 'none' && v !== 'low' ? `the URL was rated ${v} risk` : undefined],
|
|
90
|
+
['path.sensitive', v => v === 'true' ? 'the path is a sensitive location' : undefined],
|
|
91
|
+
];
|
|
92
|
+
/** Which facts the rule's constraints actually referenced — only those are shown. */
|
|
93
|
+
function relevantFacts(matchedRule, facts, operation) {
|
|
94
|
+
const out = [];
|
|
95
|
+
const constraintText = (matchedRule || '').toLowerCase();
|
|
96
|
+
for (const [key, words] of FACT_WORDS) {
|
|
97
|
+
const value = key === 'operation' ? (facts.operation || operation) : facts[key];
|
|
98
|
+
if (!value)
|
|
99
|
+
continue;
|
|
100
|
+
// Operation is always the reason a per-operation rule fired; other facts
|
|
101
|
+
// are shown only when the constraint text mentions them.
|
|
102
|
+
if (key !== 'operation' && !constraintText.includes(key.toLowerCase()))
|
|
103
|
+
continue;
|
|
104
|
+
const line = words(value, facts);
|
|
105
|
+
if (line)
|
|
106
|
+
out.push(line);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** A verdict reason worth showing: not empty, not the engine's own restatement of the rule line. */
|
|
111
|
+
function informativeReason(reason, policyName) {
|
|
112
|
+
const text = (reason || '').replace(/\s+/g, ' ').trim();
|
|
113
|
+
if (!text)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (/^Action policy "/i.test(text))
|
|
116
|
+
return undefined; // engine boilerplate: `Action policy "<name>": DELETE blocked`
|
|
117
|
+
if (text.toLowerCase() === policyName.toLowerCase())
|
|
118
|
+
return undefined;
|
|
119
|
+
return shorten(text.replace(/[.\s]+$/, ''), 200);
|
|
120
|
+
}
|
|
121
|
+
function policiesLink(consoleUrl, policyId) {
|
|
122
|
+
return `${consoleUrl}/agent-security/users?view=policies&rules=mcp${policyId ? `&policy=${encodeURIComponent(policyId)}` : ''}`;
|
|
123
|
+
}
|
|
124
|
+
function shellRulesLink(consoleUrl, itemId) {
|
|
125
|
+
return `${consoleUrl}/agent-security/users?view=policies&rules=shell${itemId ? `&rule=${encodeURIComponent(itemId)}` : ''}`;
|
|
126
|
+
}
|
|
127
|
+
function machineLink(consoleUrl, machineId) {
|
|
128
|
+
return machineId ? `${consoleUrl}/agent-security/machines/${encodeURIComponent(machineId)}` : undefined;
|
|
129
|
+
}
|
|
130
|
+
function buildBlockExplanation(input) {
|
|
131
|
+
const noun = eventNoun(input.event);
|
|
132
|
+
const what = summarizeAction(input.event, input.toolName, input.toolArgs || {});
|
|
133
|
+
const consoleUrl = input.consoleUrl.replace(/\/$/, '');
|
|
134
|
+
const machine = machineLink(consoleUrl, input.machineId);
|
|
135
|
+
let rule;
|
|
136
|
+
let why;
|
|
137
|
+
let unblock;
|
|
138
|
+
let consoleLink;
|
|
139
|
+
const src = input.source;
|
|
140
|
+
if (src.kind === 'policy') {
|
|
141
|
+
const name = src.policyName || 'an Action Policy';
|
|
142
|
+
consoleLink = policiesLink(consoleUrl, src.policyId);
|
|
143
|
+
rule = `Action Policy "${name}" (${scopeWords(src.scope, input.developerName)})${src.matchedRule ? ` — rule: ${src.matchedRule}` : ''}`;
|
|
144
|
+
const facts = relevantFacts(src.matchedRule, src.facts || {}, src.operation);
|
|
145
|
+
const extraReason = informativeReason(src.reason, name);
|
|
146
|
+
if (extraReason)
|
|
147
|
+
facts.push(extraReason);
|
|
148
|
+
why = facts.length ? facts.join('; ') : `the ${noun} matched this rule`;
|
|
149
|
+
unblock = [
|
|
150
|
+
`Open the rule in the console: ${consoleLink}`,
|
|
151
|
+
src.scope === 'organization' || !src.scope
|
|
152
|
+
? `An admin can switch "${name}" to Ask the developer / Warn only, add an exception for ${input.developerName || 'this machine'} (Override), or Disable it.`
|
|
153
|
+
: `An admin can change or remove the local rule "${name}" for ${input.developerName || 'this machine'}.`,
|
|
154
|
+
...(machine ? [`See this decision, with the rule name, on your machine timeline: ${machine}`] : []),
|
|
155
|
+
'Wrong block? Tell your admin the rule name above — every decision is recorded, nothing was sent anywhere.',
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
else if (src.kind === 'local_safety') {
|
|
159
|
+
const label = src.label || src.itemId;
|
|
160
|
+
consoleLink = shellRulesLink(consoleUrl, src.itemId);
|
|
161
|
+
// Same words as the console: a built-in catalog rule vs a rule an admin wrote.
|
|
162
|
+
const detail = [label !== src.itemId ? `id ${src.itemId}` : '', src.category || ''].filter(Boolean).join(', ');
|
|
163
|
+
rule = src.source === 'custom'
|
|
164
|
+
? `org custom rule "${label}" in Local Safety${detail ? ` (${detail})` : ''}`
|
|
165
|
+
: `built-in Local Safety rule "${label}"${detail ? ` (${detail})` : ''}`;
|
|
166
|
+
why = src.explanation ? `${src.reason} — ${src.explanation}` : src.reason;
|
|
167
|
+
unblock = [
|
|
168
|
+
`Open the rule in the console: ${consoleLink}`,
|
|
169
|
+
`An admin can set "${label}" to Ask the developer, Warn only, or Off — for everyone or only for ${input.developerName || 'this machine'}.`,
|
|
170
|
+
...(machine ? [`See this decision, with the rule name, on your machine timeline: ${machine}`] : []),
|
|
171
|
+
'Wrong block? Tell your admin the rule name above — every decision is recorded, nothing was sent anywhere.',
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
consoleLink = machine || `${consoleUrl}/agent-security/users?view=machines`;
|
|
176
|
+
switch (src.kind) {
|
|
177
|
+
case 'taint':
|
|
178
|
+
rule = `FullCourtDefense taint guard${src.ruleId ? ` (${src.ruleId})` : ''}`;
|
|
179
|
+
why = src.reason;
|
|
180
|
+
unblock = [
|
|
181
|
+
'The agent tried to act on content it received from an untrusted source (tool output, web page, file).',
|
|
182
|
+
'If you want this action, run it yourself from your own terminal — the guard only stops the agent, never you.',
|
|
183
|
+
`Machine timeline: ${consoleLink}`,
|
|
184
|
+
];
|
|
185
|
+
break;
|
|
186
|
+
case 'self_protection':
|
|
187
|
+
rule = `FullCourtDefense self-protection${src.ruleId ? ` (${src.ruleId})` : ''}`;
|
|
188
|
+
why = src.reason;
|
|
189
|
+
unblock = [
|
|
190
|
+
'Agents may not approve their own actions or edit FullCourtDefense configuration.',
|
|
191
|
+
'Do it yourself from your own terminal (fullcourtdefense approve / fullcourtdefense doctor).',
|
|
192
|
+
`Machine timeline: ${consoleLink}`,
|
|
193
|
+
];
|
|
194
|
+
break;
|
|
195
|
+
case 'fail_closed':
|
|
196
|
+
rule = 'Fail-closed mode (machine setting)';
|
|
197
|
+
why = src.reason;
|
|
198
|
+
unblock = [
|
|
199
|
+
'Restore the connection to the policy service, or run: fullcourtdefense doctor',
|
|
200
|
+
`An admin can switch this machine to fail-open under Machines: ${consoleLink}`,
|
|
201
|
+
];
|
|
202
|
+
break;
|
|
203
|
+
case 'suspended':
|
|
204
|
+
rule = 'Machine suspended by your security team';
|
|
205
|
+
why = src.reason;
|
|
206
|
+
unblock = [`Ask your admin to resume this machine: ${consoleLink}`];
|
|
207
|
+
break;
|
|
208
|
+
default:
|
|
209
|
+
rule = 'Cached org policy (policy service unreachable)';
|
|
210
|
+
why = src.reason;
|
|
211
|
+
unblock = [
|
|
212
|
+
'Restore the connection to the policy service, or run: fullcourtdefense doctor',
|
|
213
|
+
`Machine page: ${consoleLink}`,
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (input.note)
|
|
218
|
+
why = `${why} ${input.note}`.trim();
|
|
219
|
+
const headline = headlineFor(input.outcome, noun);
|
|
220
|
+
const userMessage = [
|
|
221
|
+
headline,
|
|
222
|
+
`What: ${what}`,
|
|
223
|
+
`Rule: ${rule}`,
|
|
224
|
+
`Why: ${why}`,
|
|
225
|
+
stepsTitle(input.outcome),
|
|
226
|
+
...unblock.map((step, i) => ` ${i + 1}. ${step}`),
|
|
227
|
+
].join('\n');
|
|
228
|
+
const agentVerb = input.outcome === 'block' || input.outcome === 'mask'
|
|
229
|
+
? 'This is a policy decision, not an error — do not retry, rephrase, or work around it.'
|
|
230
|
+
: 'Wait for the developer\'s answer — do not retry or work around it.';
|
|
231
|
+
const agentMessage = `${headline} Rule: ${rule}. Why: ${why}. ${agentVerb} Tell the developer exactly this: the block came from ${rule}; to remove it: ${unblock[0]}`;
|
|
232
|
+
return { headline, what, rule, why, unblock, consoleLink, userMessage, agentMessage };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Compact single-line form for surfaces with no room for a block (terminal
|
|
236
|
+
* `[FullCourtDefense]` line, Claude `permissionDecisionReason`).
|
|
237
|
+
*/
|
|
238
|
+
function oneLineExplanation(explanation) {
|
|
239
|
+
return `${explanation.headline} ${explanation.rule}. Why: ${explanation.why}. Remove it: ${explanation.consoleLink}`;
|
|
240
|
+
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchors of the SHARED command rules (deterministicGuard ↔ shellGuard) that
|
|
3
|
+
* are cheap enough to wrap in cmd: `gh`, `vault`, `security`, … — derived, not
|
|
4
|
+
* hand-listed, so a new shared rule reaches cmd's index automatically unless
|
|
5
|
+
* its command is on the high-frequency diet above. cmd stays PARTIAL by
|
|
6
|
+
* construction (first token only, no `git`/`kubectl`/`aws` wrappers); the
|
|
7
|
+
* console labels it that way.
|
|
8
|
+
*/
|
|
9
|
+
export declare const INTERCEPTED_COMMANDS: string[];
|
|
1
10
|
export declare function buildGuardJs(nodePath: string, cliEntry: string): string;
|
|
2
11
|
export interface CmdGuardStatus {
|
|
3
12
|
supported: boolean;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.INTERCEPTED_COMMANDS = void 0;
|
|
36
37
|
exports.buildGuardJs = buildGuardJs;
|
|
37
38
|
exports.isCmdGuardInstalled = isCmdGuardInstalled;
|
|
38
39
|
exports.getCmdGuardStatus = getCmdGuardStatus;
|
|
@@ -45,6 +46,7 @@ const fs = __importStar(require("fs"));
|
|
|
45
46
|
const os = __importStar(require("os"));
|
|
46
47
|
const path = __importStar(require("path"));
|
|
47
48
|
const shellGuard_1 = require("./shellGuard");
|
|
49
|
+
const notify_1 = require("../notify");
|
|
48
50
|
/**
|
|
49
51
|
* Interactive cmd.exe guard — blocks dangerous commands TYPED in Command Prompt.
|
|
50
52
|
*
|
|
@@ -75,7 +77,7 @@ const AUTORUN_MARKER = 'fullcourtdefense-cmd-autorun.bat';
|
|
|
75
77
|
* so pipes/`&&` chains where the dangerous command is not first are NOT caught by
|
|
76
78
|
* cmd (they ARE under PowerShell/zsh/bash and `shell-guard-check`).
|
|
77
79
|
*/
|
|
78
|
-
const
|
|
80
|
+
const FIXED_INTERCEPTED_COMMANDS = [
|
|
79
81
|
// Destructive filesystem / disk / permission tampering
|
|
80
82
|
'del', 'erase', 'rd', 'rmdir', 'format', 'cipher', 'diskpart', 'fsutil',
|
|
81
83
|
'takeown', 'icacls', 'attrib', 'compact',
|
|
@@ -97,8 +99,25 @@ const INTERCEPTED_COMMANDS = [
|
|
|
97
99
|
* Coverage is NOT lost where it matters: agent-driven commands go through the
|
|
98
100
|
* IDE hooks (full ruleset, any position in the line), and humans typing in
|
|
99
101
|
* PowerShell are covered in-process by the PSReadLine guard. Interactive
|
|
100
|
-
* cmd.exe keeps only the rare, high-signal destructive/tamper commands above
|
|
102
|
+
* cmd.exe keeps only the rare, high-signal destructive/tamper commands above
|
|
103
|
+
* plus the low-frequency anchors of the shared command rules (below).
|
|
101
104
|
*/
|
|
105
|
+
const CMD_HIGH_FREQUENCY_COMMANDS = new Set([
|
|
106
|
+
'docker', 'git', 'kubectl', 'aws', 'gcloud', 'az', 'terraform', 'pulumi', 'wsl',
|
|
107
|
+
'curl', 'wget', 'taskkill', 'robocopy', 'xcopy', 'psql', 'mysql', 'mongo', 'mongosh', 'sqlcmd',
|
|
108
|
+
]);
|
|
109
|
+
/**
|
|
110
|
+
* Anchors of the SHARED command rules (deterministicGuard ↔ shellGuard) that
|
|
111
|
+
* are cheap enough to wrap in cmd: `gh`, `vault`, `security`, … — derived, not
|
|
112
|
+
* hand-listed, so a new shared rule reaches cmd's index automatically unless
|
|
113
|
+
* its command is on the high-frequency diet above. cmd stays PARTIAL by
|
|
114
|
+
* construction (first token only, no `git`/`kubectl`/`aws` wrappers); the
|
|
115
|
+
* console labels it that way.
|
|
116
|
+
*/
|
|
117
|
+
exports.INTERCEPTED_COMMANDS = Array.from(new Set([
|
|
118
|
+
...FIXED_INTERCEPTED_COMMANDS,
|
|
119
|
+
...(0, shellGuard_1.builtinRuleCommandNames)().filter(name => !CMD_HIGH_FREQUENCY_COMMANDS.has(name)),
|
|
120
|
+
]));
|
|
102
121
|
function regString(key, value) {
|
|
103
122
|
try {
|
|
104
123
|
const out = (0, child_process_1.execFileSync)('reg', ['query', key, '/v', value], {
|
|
@@ -129,6 +148,9 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
129
148
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
130
149
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
131
150
|
`const ASK_TIMEOUT_S=${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS};const ASK_REMEMBER_MS=${shellGuard_1.SHELL_ASK_REMEMBER_HOURS}*3600*1000;`,
|
|
151
|
+
`const RULES_URL=${JSON.stringify((0, notify_1.shellRulesConsoleUrl)())};`,
|
|
152
|
+
`// Which rule (by id + origin) and where an admin changes it — printed on every block/ask so the developer is never left guessing.`,
|
|
153
|
+
`function unblockHint(rule){return 'Rule: "'+rule.reason+'" ('+(rule.source==='custom'?'org custom rule':'built-in Local Safety rule')+', id '+rule.id+'). To remove this block an admin can set it to Ask / Warn only / Off here: '+RULES_URL+'&rule='+encodeURIComponent(rule.id);}`,
|
|
132
154
|
`// "s = remember for this cmd window": keyed by the parent cmd.exe pid (doskey runs us directly under it); dies with the window, capped in time.`,
|
|
133
155
|
`const ASK_MEMORY_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense'))}+'/cmd-ask-'+process.ppid+'.json';`,
|
|
134
156
|
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
@@ -159,12 +181,14 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
159
181
|
`if(hit.action==='ask'){`,
|
|
160
182
|
` if(askRemembered(hit.id)){console.error('[FullCourtDefense] allowed — you confirmed rule '+hit.id+' earlier in this cmd window.');spoolEvent(hit,line,'allow','remembered');delegate(args);}`,
|
|
161
183
|
` console.error('[FullCourtDefense] ASK ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
184
|
+
` console.error(unblockHint(hit));`,
|
|
162
185
|
` const a=askDeveloper(hit);`,
|
|
163
186
|
` if(a==='y'||a==='s'){if(a==='s')rememberAsk(hit.id);console.error('[FullCourtDefense] yes'+(a==='s'?' — remembered for this cmd window.':'')+' Reported to your security dashboard as allowed by you.');spoolEvent(hit,line,'allow','confirmed');delegate(args);}`,
|
|
164
187
|
` const outcome=a==='t'?'timeout':'declined';console.error('[FullCourtDefense] '+(outcome==='timeout'?'no answer':'no')+' — this command was not executed ('+outcome+'). Reported to your security dashboard.');spoolEvent(hit,line,'block',outcome);process.exit(1);`,
|
|
165
188
|
`}`,
|
|
166
189
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
167
190
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
191
|
+
`console.error(unblockHint(hit));`,
|
|
168
192
|
`spoolEvent(hit,line,'block');process.exit(1);`,
|
|
169
193
|
``,
|
|
170
194
|
].join('\n');
|
|
@@ -180,7 +204,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
180
204
|
function buildMacroFile(nodePath) {
|
|
181
205
|
const guardJs = batQuote(GUARD_JS_PATH);
|
|
182
206
|
const node = batQuote(nodePath);
|
|
183
|
-
return `${INTERCEPTED_COMMANDS.map(cmd => `${cmd}=${node} ${guardJs} ${cmd} $*`).join('\r\n')}\r\n`;
|
|
207
|
+
return `${exports.INTERCEPTED_COMMANDS.map(cmd => `${cmd}=${node} ${guardJs} ${cmd} $*`).join('\r\n')}\r\n`;
|
|
184
208
|
}
|
|
185
209
|
function buildAutorunBat(nodePath) {
|
|
186
210
|
const guardJs = batQuote(GUARD_JS_PATH);
|
|
@@ -69,6 +69,41 @@ export interface LocalSafetyScanOptions {
|
|
|
69
69
|
*/
|
|
70
70
|
trustedScriptPaths?: string[];
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Built-ins that ship OFF until an org switches them on in the console. Shared
|
|
74
|
+
* with the terminal guards so a machine with no snapshot yet (fresh install,
|
|
75
|
+
* wiped cache) enforces the SAME default set in the IDE and in a shell.
|
|
76
|
+
*/
|
|
77
|
+
export declare const DEFAULT_DISABLED_BUILT_INS: ReadonlySet<string>;
|
|
78
|
+
/**
|
|
79
|
+
* One command rule shared by EVERY surface that can see a typed command: the
|
|
80
|
+
* IDE/agent hooks (this module), the PowerShell / bash / zsh profile guards and
|
|
81
|
+
* the cmd.exe doskey guard (shellGuard.ts loads this table into the terminal
|
|
82
|
+
* rule file verbatim). `pattern` is a regex SOURCE that must compile — and
|
|
83
|
+
* mean the same thing — under both JS and .NET with IgnoreCase: `\b`, `\s`,
|
|
84
|
+
* `(?:…)`, lookaheads and `[^\n;|&]` only. No lookbehind, no named groups,
|
|
85
|
+
* no `\d`-class differences. `commands` are the leading binaries a rule can
|
|
86
|
+
* fire on — the only thing a first-token hook (doskey) can key on.
|
|
87
|
+
*/
|
|
88
|
+
export interface SharedCommandRule {
|
|
89
|
+
itemId: string;
|
|
90
|
+
/** Catalog category id (local-safety-catalog.ts). */
|
|
91
|
+
categoryId: 'credential_commands';
|
|
92
|
+
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
93
|
+
pattern: string;
|
|
94
|
+
reason: string;
|
|
95
|
+
/** Leading command names this rule anchors on (for name-keyed hooks such as cmd.exe doskey). */
|
|
96
|
+
commands: string[];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Commands that PRINT stored credentials to stdout — the file-path rules are
|
|
100
|
+
* useless against them because the secret never comes from a watched file
|
|
101
|
+
* ("gh auth token" emits the same token that lives in hosts.yml). Obvious,
|
|
102
|
+
* rarely-legitimate-for-agents forms are on by default; cloud token printers
|
|
103
|
+
* that legitimate deploy tooling uses constantly are strict (off by default,
|
|
104
|
+
* see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
|
|
105
|
+
*/
|
|
106
|
+
export declare const CREDENTIAL_COMMAND_RULES: readonly SharedCommandRule[];
|
|
72
107
|
export declare function stripInertDataSegments(text: string): string;
|
|
73
108
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
74
109
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
|
|
36
37
|
exports.toLocalSafetyRuleAction = toLocalSafetyRuleAction;
|
|
37
38
|
exports.stripInertDataSegments = stripInertDataSegments;
|
|
38
39
|
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
@@ -189,7 +190,12 @@ const SQL_KEYS = new Set([
|
|
|
189
190
|
'toolArgs.query',
|
|
190
191
|
'toolArgs.sql',
|
|
191
192
|
]);
|
|
192
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Built-ins that ship OFF until an org switches them on in the console. Shared
|
|
195
|
+
* with the terminal guards so a machine with no snapshot yet (fresh install,
|
|
196
|
+
* wiped cache) enforces the SAME default set in the IDE and in a shell.
|
|
197
|
+
*/
|
|
198
|
+
exports.DEFAULT_DISABLED_BUILT_INS = new Set([
|
|
193
199
|
'git_clean_force',
|
|
194
200
|
'generic_kubectl_delete',
|
|
195
201
|
'truncate_table',
|
|
@@ -413,7 +419,7 @@ function collectStrings(value, keyPath = 'args', out = [], depth = 0) {
|
|
|
413
419
|
return out;
|
|
414
420
|
}
|
|
415
421
|
function disabled(options) {
|
|
416
|
-
return new Set(options?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS));
|
|
422
|
+
return new Set(options?.disabledBuiltInItemIds || Array.from(exports.DEFAULT_DISABLED_BUILT_INS));
|
|
417
423
|
}
|
|
418
424
|
function isEnabled(itemId, options) {
|
|
419
425
|
return !disabled(options).has(itemId);
|
|
@@ -587,39 +593,47 @@ function containsSensitiveCredentialPath(value) {
|
|
|
587
593
|
* useless against them because the secret never comes from a watched file
|
|
588
594
|
* ("gh auth token" emits the same token that lives in hosts.yml). Obvious,
|
|
589
595
|
* rarely-legitimate-for-agents forms are on by default; cloud token printers
|
|
590
|
-
* that legitimate deploy tooling uses constantly are strict (off by default
|
|
596
|
+
* that legitimate deploy tooling uses constantly are strict (off by default,
|
|
597
|
+
* see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
|
|
591
598
|
*/
|
|
599
|
+
exports.CREDENTIAL_COMMAND_RULES = [
|
|
600
|
+
{ itemId: 'gh_auth_token_cmd', categoryId: 'credential_commands', severity: 'critical', commands: ['gh'],
|
|
601
|
+
reason: 'GitHub CLI token print (gh auth token)',
|
|
602
|
+
pattern: String.raw `\bgh\s+auth\s+token\b|\bgh\s+auth\s+status\b(?=[^\n;|&]*(?:--show-token|\s-t\b))` },
|
|
603
|
+
{ itemId: 'git_credential_fill', categoryId: 'credential_commands', severity: 'critical', commands: ['git'],
|
|
604
|
+
reason: 'git credential helper dump',
|
|
605
|
+
pattern: String.raw `\bgit\s+credential(?:-manager|-store|-cache)?\s+(?:fill|get)\b` },
|
|
606
|
+
{ itemId: 'macos_keychain_dump', categoryId: 'credential_commands', severity: 'critical', commands: ['security'],
|
|
607
|
+
reason: 'macOS keychain password dump',
|
|
608
|
+
pattern: String.raw `\bsecurity\s+(?:find-generic-password|find-internet-password)\b[^\n;|&]*\s-w\b|\bsecurity\s+dump-keychain\b` },
|
|
609
|
+
{ itemId: 'kubectl_config_raw', categoryId: 'credential_commands', severity: 'high', commands: ['kubectl'],
|
|
610
|
+
reason: 'raw kubeconfig dump (kubectl config view --raw)',
|
|
611
|
+
pattern: String.raw `\bkubectl\s+config\s+view\b[^\n;|&]*--raw\b` },
|
|
612
|
+
{ itemId: 'aws_export_credentials', categoryId: 'credential_commands', severity: 'critical', commands: ['aws'],
|
|
613
|
+
reason: 'AWS secret access key export',
|
|
614
|
+
pattern: String.raw `\baws\s+configure\s+export-credentials\b|\baws\s+configure\s+get\b[^\n;|&]*secret` },
|
|
615
|
+
{ itemId: 'gcloud_access_token_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['gcloud'],
|
|
616
|
+
reason: 'gcloud access-token print',
|
|
617
|
+
pattern: String.raw `\bgcloud\s+auth\b[^\n;|&]*\bprint-access-token\b` },
|
|
618
|
+
{ itemId: 'gcloud_secret_access_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['gcloud'],
|
|
619
|
+
reason: 'GCP Secret Manager read',
|
|
620
|
+
pattern: String.raw `\bgcloud\s+secrets\s+versions\s+access\b` },
|
|
621
|
+
{ itemId: 'az_access_token_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['az'],
|
|
622
|
+
reason: 'Azure access-token print',
|
|
623
|
+
pattern: String.raw `\baz\s+account\s+get-access-token\b` },
|
|
624
|
+
{ itemId: 'aws_secret_fetch_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['aws'],
|
|
625
|
+
reason: 'AWS secret fetch',
|
|
626
|
+
pattern: String.raw `\baws\s+secretsmanager\s+get-secret-value\b|\baws\s+ssm\s+get-parameter\b[^\n;|&]*--with-decryption\b` },
|
|
627
|
+
{ itemId: 'vault_read_cmd', categoryId: 'credential_commands', severity: 'high', commands: ['vault'],
|
|
628
|
+
reason: 'Vault secret read',
|
|
629
|
+
pattern: String.raw `\bvault\s+(?:kv\s+get|read)\b` },
|
|
630
|
+
];
|
|
631
|
+
const CREDENTIAL_COMMAND_REGEXES = exports.CREDENTIAL_COMMAND_RULES.map(rule => ({ rule, re: new RegExp(rule.pattern, 'i') }));
|
|
592
632
|
function credentialCommandReason(value) {
|
|
593
|
-
return matchCommand(value,
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (/\bgit\s+credential(?:-manager|-store|-cache)?\s+(?:fill|get)\b/.test(lower)) {
|
|
598
|
-
return { itemId: 'git_credential_fill', reason: 'git credential helper dump' };
|
|
599
|
-
}
|
|
600
|
-
if (/\bsecurity\s+(?:find-generic-password|find-internet-password)\b[^\n;|&]*\s-w\b/.test(lower) || /\bsecurity\s+dump-keychain\b/.test(lower)) {
|
|
601
|
-
return { itemId: 'macos_keychain_dump', reason: 'macOS keychain password dump' };
|
|
602
|
-
}
|
|
603
|
-
if (/\bkubectl\s+config\s+view\b[^\n;|&]*--raw\b/.test(lower)) {
|
|
604
|
-
return { itemId: 'kubectl_config_raw', reason: 'raw kubeconfig dump (kubectl config view --raw)' };
|
|
605
|
-
}
|
|
606
|
-
if (/\baws\s+configure\s+export-credentials\b/.test(lower) || /\baws\s+configure\s+get\b[^\n;|&]*secret/.test(lower)) {
|
|
607
|
-
return { itemId: 'aws_export_credentials', reason: 'AWS secret access key export' };
|
|
608
|
-
}
|
|
609
|
-
if (/\bgcloud\s+auth\b[^\n;|&]*\bprint-access-token\b/.test(lower)) {
|
|
610
|
-
return { itemId: 'gcloud_access_token_cmd', reason: 'gcloud access-token print' };
|
|
611
|
-
}
|
|
612
|
-
if (/\bgcloud\s+secrets\s+versions\s+access\b/.test(lower)) {
|
|
613
|
-
return { itemId: 'gcloud_secret_access_cmd', reason: 'GCP Secret Manager read' };
|
|
614
|
-
}
|
|
615
|
-
if (/\baz\s+account\s+get-access-token\b/.test(lower)) {
|
|
616
|
-
return { itemId: 'az_access_token_cmd', reason: 'Azure access-token print' };
|
|
617
|
-
}
|
|
618
|
-
if (/\baws\s+secretsmanager\s+get-secret-value\b/.test(lower) || /\baws\s+ssm\s+get-parameter\b[^\n;|&]*--with-decryption\b/.test(lower)) {
|
|
619
|
-
return { itemId: 'aws_secret_fetch_cmd', reason: 'AWS secret fetch' };
|
|
620
|
-
}
|
|
621
|
-
if (/\bvault\s+(?:kv\s+get|read)\b/.test(lower)) {
|
|
622
|
-
return { itemId: 'vault_read_cmd', reason: 'Vault secret read' };
|
|
633
|
+
return matchCommand(value, text => {
|
|
634
|
+
for (const { rule, re } of CREDENTIAL_COMMAND_REGEXES) {
|
|
635
|
+
if (re.test(text))
|
|
636
|
+
return { itemId: rule.itemId, reason: rule.reason };
|
|
623
637
|
}
|
|
624
638
|
return undefined;
|
|
625
639
|
});
|
|
@@ -1190,7 +1204,7 @@ function scanReferencedScript(command, cwd, options) {
|
|
|
1190
1204
|
while (finding
|
|
1191
1205
|
&& (finding.category === 'sensitive_file' || finding.category === 'metadata_ssrf')
|
|
1192
1206
|
&& !scriptActsOnFinding(content, finding.category)) {
|
|
1193
|
-
const baseDisabled = scanOptions?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS);
|
|
1207
|
+
const baseDisabled = scanOptions?.disabledBuiltInItemIds || Array.from(exports.DEFAULT_DISABLED_BUILT_INS);
|
|
1194
1208
|
scanOptions = { ...(scanOptions || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
|
|
1195
1209
|
finding = scanTextValue('script_file', content, scanOptions);
|
|
1196
1210
|
}
|
|
@@ -1212,7 +1226,7 @@ function withRuleSuppressed(options, finding) {
|
|
|
1212
1226
|
customBlocks: (options?.customBlocks || []).filter(block => block.id !== finding.itemId),
|
|
1213
1227
|
};
|
|
1214
1228
|
}
|
|
1215
|
-
const baseDisabled = options?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS);
|
|
1229
|
+
const baseDisabled = options?.disabledBuiltInItemIds || Array.from(exports.DEFAULT_DISABLED_BUILT_INS);
|
|
1216
1230
|
return { ...(options || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
|
|
1217
1231
|
}
|
|
1218
1232
|
function findingAction(finding) {
|