Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion NESTED_SHARE_FOLDER_COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ To get help on a particular command, run:
| `[nsf-rndir]` | Rename a folder or change its color |
| `[nsf-list]` | List Nested Share Folders and Nested Share Records |
| `[nsf-rmdir]` | Remove one or more Nested Share Folders |
| `[nsf-move]` | Move a Nested Share Record or Nested Share Folder to a new location |
| `[nsf-share-folder]` | Grant or remove a user's access to a folder |
| `[nsf-record-add]` | Create a new Nested Share Record |
| `[nsf-record-update]` | Update an existing Nested Share Record |
| `[nsf-convert]` | Convert Classic record(s) to Nested Share Record(s) |
| `[nsf-rm]` | Remove (trash or unlink) one or more Nested Share Records |
| `[nsf-ln]` | Link a record into a Nested Share Folder |
| `[nsf-shortcut]` | Manage records that appear in more than one folder |
Expand Down Expand Up @@ -291,6 +293,38 @@ nsf-record-update -r rec123 -r rec456 -t "Shared Title"

---

### nsf-convert command:

**Command:** `nsf-convert`

**Detail:** Permanently convert up to 100 Classic records per request to Nested Share Records. Results are reported per record. Records already known to be Nested Share Records are skipped.

**Parameters:**

One or more Classic record UIDs, titles, or paths

**Switches:**

`--folder <FOLDER>` Existing Nested Share Folder UID, name, or path. Omit to target `Vault (root)`.

`-f`, `--force` Skip the confirmation prompt

**Important:** Conversion cannot be rolled back. Direct user shares are retained. Classic shared-folder membership and additional Classic folder placements are not retained.

**Examples:**

```
nsf-convert abc123recorduid
nsf-convert abc123recorduid def456recorduid --folder "Projects/Archive"
nsf-convert abc123recorduid --folder "Projects/Archive" --force
```

1. Convert one Classic record to `Vault (root)`; review the warning and confirm when prompted.
2. Convert two Classic records to the `Projects/Archive` Nested Share Folder.
3. Convert one Classic record to `Projects/Archive` without a confirmation prompt.

---

### nsf-rm command:

**Command:** `nsf-rm`
Expand Down Expand Up @@ -363,6 +397,32 @@ nsf-ln rec123abc abc123folder

---

### nsf-move command:

**Command:** `nsf-move`

**Detail:** Move a Nested Share Record or Nested Share Folder to another Nested Share Folder or to the Nested Share Folder root. Classic records and folders cannot be moved with this command.

Folder moves are limited to five Nested Share Folder levels. Records can be moved into a folder at the maximum depth.

**Parameters:**

`src` Nested Share Record or Nested Share Folder UID, title/name, or path

`dst` Destination Nested Share Folder UID, name, or path, or `root`

**Examples:**

```
nsf-move "Client Portal" "Archive"
nsf-move "Old Projects" root
```

1. Move a Nested Share Record to the `Archive` folder.
2. Move a Nested Share Folder to the Nested Share Folder root.

---

### nsf-shortcut command:

**Command:** `nsf-shortcut`
Expand Down Expand Up @@ -616,6 +676,7 @@ nsf-get rec123abc --format json --verbose
| `nsf-rndir` | Rename / recolor a folder |
| `nsf-list` | List folders and records |
| `nsf-rmdir` | Remove folder(s) |
| `nsf-move` | Move a Nested Share Record or folder |
| `nsf-share-folder` | Grant or remove folder access |


Expand All @@ -626,6 +687,8 @@ nsf-get rec123abc --format json --verbose
| ------------------ | ----------------------------------------------- |
| `nsf-record-add` | Create a record |
| `nsf-record-update` | Update a record |
| `nsf-convert` | Convert Classic record(s) to Nested Share Record(s) |
| `nsf-move` | Move a Nested Share Record or folder |
| `nsf-rm` | Remove / trash / unlink a record |
| `nsf-ln` | Link a record into a folder |
| `nsf-shortcut list` | List multi-folder records |
Expand Down Expand Up @@ -709,6 +772,28 @@ nsf-rmdir "Old Archive"
nsf-rmdir "Old Archive" --operation delete-permanent --force
```

### Move a Nested Share Record or folder

```bash
# Move a record into another folder
nsf-move "Client Portal" "Archive"

# Move a folder to the Nested Share Folder root
nsf-move "Old Projects" root
```

### Convert Classic records

```bash
# Convert one record to Vault (root)
nsf-convert abc123recorduid

# Convert multiple records into an existing Nested Share Folder
nsf-convert abc123recorduid def456recorduid --folder "Projects/Archive"
```

Review the permanent-conversion warning and record list, then confirm when prompted. Use `--force` to skip the prompt.

### Bulk-revoke permissions across a folder tree

```bash
Expand Down Expand Up @@ -740,4 +825,3 @@ Both `--expire-at` and `--expire-in` are accepted by `nsf-share-folder` and `nsf
---

---

2 changes: 1 addition & 1 deletion keepercommander/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
# Contact: commander@keepersecurity.com
#

__version__ = '18.1.6'
__version__ = '18.1.7'
3 changes: 2 additions & 1 deletion keepercommander/command_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@
'nsf-mkdir', 'nsf-record-add', 'nsf-record-update', 'nsf-rndir', 'nsf-list',
'nsf-share-folder', 'nsf-record-details', 'nsf-share-record',
'nsf-record-permission', 'nsf-transfer-record',
'nsf-ln', 'nsf-rm', 'nsf-rmdir', 'nsf-shortcut', 'nsf-get', 'nsf-move'
'nsf-ln', 'nsf-rm', 'nsf-rmdir', 'nsf-shortcut', 'nsf-get', 'nsf-move',
'nsf-convert',
},

# Legacy Commands
Expand Down
25 changes: 22 additions & 3 deletions keepercommander/commands/aram.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,19 @@ def load_syslog_templates(params):
syslog_templates[name] = syslog


def _is_owned_audit_log_record(params: KeeperParams, record_uid: str) -> bool:
"""True if the current account owns this record.

Uses ``record_owner_cache`` first, then ``meta_data_cache`` from
recordMetaData (server-asserted). Unknown ownership fails closed.
"""
owner = (params.record_owner_cache or {}).get(record_uid)
if owner and owner.owner:
return True
meta = (getattr(params, 'meta_data_cache', None) or {}).get(record_uid) or {}
return bool(meta.get('owner'))


class AuditLogBaseExport(abc.ABC):
def __init__(self):
self.store_record = False
Expand Down Expand Up @@ -494,7 +507,7 @@ def get_properties(self, record, props):
for test_url in ['https://{0}/services/collector'.format(address), 'http://{0}/services/collector'.format(address)]:
try:
print('Testing \'{0}\' ...'.format(test_url), file=sys.stderr, end='', flush=True)
rs = requests.post(test_url, json='', verify=False)
rs = requests.post(test_url, json='')
if rs.status_code == 401:
js = rs.json()
if 'code' in js:
Expand All @@ -519,7 +532,7 @@ def get_properties(self, record, props):
return
try:
auth={'Authorization': 'Splunk {0}'.format(test_token)}
rs = requests.post(url, json='', headers=auth, verify=False)
rs = requests.post(url, json='', headers=auth)
if rs.status_code == 400:
js = rs.json()
if 'code' in js:
Expand Down Expand Up @@ -553,7 +566,7 @@ def export_events(self, props, events):
auth = { 'Authorization': 'Splunk {0}'.format(props['token']) }
try:
logging.captureWarnings(True)
rs = requests.post(props['hec_url'], data='\n'.join(events), headers=auth, verify=False)
rs = requests.post(props['hec_url'], data='\n'.join(events), headers=auth)
finally:
logging.captureWarnings(False)

Expand Down Expand Up @@ -942,7 +955,11 @@ def execute(self, params, **kwargs):
for r_uid in params.record_cache:
rec = vault.KeeperRecord.load(params, r_uid)
if record_name in [rec.record_uid, rec.title]:
if not _is_owned_audit_log_record(params, r_uid):
print(f'Note: Record "{rec.title}" is not owned by you and will not be used.')
continue
record = rec
break
if record is None:
answer = user_choice('Do you want to create a Keeper record to store audit log settings?', 'yn', 'n')
if answer.lower() == 'y':
Expand All @@ -957,6 +974,8 @@ def execute(self, params, **kwargs):
if record is None:
raise CommandError('audit-log', 'Record not found')

print(f'Export Destination: "{record.title}" ({record.record_uid})')

shared_folder_uids = kwargs.get('shared_folder_uid')
node_ids = kwargs.get('node_id')
days = kwargs.get('days')
Expand Down
47 changes: 44 additions & 3 deletions keepercommander/commands/connect_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,31 @@ def _bold_cyan(s: str) -> str: return _c('1;36', s)


def _sanitize(text: Optional[str]) -> str:
"""Strip C0/C1 control characters (incl. ESC) from attacker-supplied text."""
"""Escape C0/C1 control characters (incl. ESC) from attacker-supplied text.

Renders each control character as a visible escape sequence so that
(1) the prompt accurately represents what will execute and (2) a newline
cannot be hidden inside a comment, making two statements render identically.
"""
if not isinstance(text, str):
return ''
return _CONTROL_CHAR_RE.sub('', text)

def escape_ctrl(m):
c = m.group(0)
code = ord(c)
if c == '\n':
return '\\n'
elif c == '\t':
return '\\t'
elif c == '\r':
return '\\r'
elif code < 0x20:
return f'\\x{code:02x}'
elif 0x7f <= code < 0xa0:
return f'\\x{code:02x}'
return c

return _CONTROL_CHAR_RE.sub(escape_ctrl, text)


def _is_interpreter(prog: Optional[str]) -> bool:
Expand Down Expand Up @@ -143,7 +164,15 @@ def _looks_multi_statement(arg: str) -> bool:


def split_shell_statements(script: Optional[str]) -> List[str]:
"""Split a shell-script into top-level statements"""
"""Split a shell-script into top-level statements.

Handles:
- Single/double quoted strings (quote escaping)
- Escaped characters (backslash)
- Shell statement separators (; \n && ||)
- Comments (#) — # only starts a comment when it begins a word
(at line start or preceded by whitespace)
"""
if not script:
return []

Expand All @@ -159,6 +188,13 @@ def flush() -> None:
parts.append(stmt)
buf.clear()

def is_word_boundary(pos: int) -> bool:
"""True if position is at the start of a shell word (start of line or after whitespace)."""
if pos == 0:
return True
prev_char = script[pos - 1]
return prev_char in (' ', '\t', '\n', ';', '&', '|', '(', ')')

while i < n:
c = script[i]
if escaped:
Expand All @@ -174,6 +210,11 @@ def flush() -> None:
elif c in ('"', "'"):
quote = c
buf.append(c)
elif c == '#' and quote is None and is_word_boundary(i):
# Comment: skip until newline (only if # starts a word)
while i < n and script[i] != '\n':
i += 1
continue
elif c in ('\n', ';'):
flush()
elif c in ('&', '|') and i + 1 < n and script[i + 1] == c:
Expand Down
2 changes: 2 additions & 0 deletions keepercommander/commands/discoveryrotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
from .pam.cnapp_commands import PAMCnappCommand
from .pam_import.commands import PAMProjectCommand
from keepercommander.commands.pam_cloud.pam_privileged_access import PAMPrivilegedAccessCommand
from keepercommander.commands.pam_cloud.pam_cloud_credentials import PAMCloudCommand
from .pam_launch.launch import PAMLaunchCommand
from .workflow import PAMWorkflowCommand
from .pam_service.list import PAMActionServiceListCommand
Expand Down Expand Up @@ -325,6 +326,7 @@ def __init__(self):
self.register_command('universal-sync-run', PAMUniversalSyncRunCommand(), 'Run Universal Sync', 'usr')
self.register_command('cnapp', PAMCnappCommand(), 'Manage CNAPP integrations', 'cn')
self.register_command('recording', PAMRecordingCommand(), 'Manage PAM Session Recordings', 'rec')
self.register_command('cloud', PAMCloudCommand(), 'Manage PAM cloud resource access', 'cl')


class PAMGatewayCommand(GroupCommand):
Expand Down
7 changes: 7 additions & 0 deletions keepercommander/commands/nested_share_folder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
NestedShareGetCommand,
)

# Classic-to-Nested Share conversion
from .conversion_commands import ( # noqa: F401
NestedShareConvertCommand,
)


def register_commands(commands):
"""Register Nested Share Folder commands with the command framework."""
Expand All @@ -79,6 +84,7 @@ def register_commands(commands):
commands['nsf-shortcut'] = NestedShareRecordShortcutCommand()
commands['nsf-get'] = NestedShareGetCommand()
commands['nsf-move'] = NestedSharedMoveCommander()
commands['nsf-convert'] = NestedShareConvertCommand()


def register_command_info(aliases, command_info):
Expand All @@ -99,3 +105,4 @@ def register_command_info(aliases, command_info):
command_info['nsf-shortcut'] = 'Manage Nested Share Record shortcuts'
command_info['nsf-get'] = 'Get details of a Nested Share Record or folder'
command_info['nsf-move'] = 'Move a Nested Share Record or folder to a new location'
command_info['nsf-convert'] = 'Convert Classic records to Nested Share Records'
Loading
Loading