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
8 changes: 7 additions & 1 deletion doc/commands/gcts.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,17 @@ sapcli gcts config [-l|--list] [--unset] PACKAGE [NAME] [VALUE]
Get credentials of the logged in user

```bash
sapcli gcts user get-credentials [-f|--format] {HUMAN|JSON}
sapcli gcts user get-credentials [-f|--format] {HUMAN|JSON} [-e|--endpoint ENDPOINT]
```

**Parameters:**
- `--format`: The format of the command's output
- `--endpoint`: Filter credentials by HTTP API endpoint. When specified,
only credentials matching the given endpoint are returned. The endpoint
is canonicalized before matching (trailing slashes are stripped and
comparison is case-insensitive). The command returns a non-zero exit code
if no credentials are found for the endpoint or if all matching
credentials have an invalid state.

## user set-credentials

Expand Down
95 changes: 95 additions & 0 deletions sap/adt/system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""ADT System Information wrappers"""

from xml.etree import ElementTree

from sap.adt.core import Connection


XMLNS_ATOM = '{http://www.w3.org/2005/Atom}'

JSON_KEY_MAPPING = {
'systemID': 'SID',
}


# pylint: disable=too-few-public-methods
class SystemInfoEntry:
"""A single entry from the system information feed"""

def __init__(self, identity, title):
self.identity = identity
self.title = title


class SystemInformation:
"""Parsed system information from ADT"""

def __init__(self, entries):
self._entries = {entry.identity: entry for entry in entries}

@property
def entries(self):
"""Returns a list of all system information entries"""
return list(self._entries.values())

def get(self, identity):
"""Returns the title for the given identity or None if not found"""
entry = self._entries.get(identity)
return entry.title if entry else None

def __iter__(self):
return iter(self._entries.values())


def _fetch_xml_entries(connection):
"""Fetch entries from the Atom feed endpoint /sap/bc/adt/system/information"""

resp = connection.execute(
'GET',
'system/information',
accept='application/atom+xml;type=feed',
)

root = ElementTree.fromstring(resp.text)

entries = []
for entry_elem in root.findall(f'{XMLNS_ATOM}entry'):
identity = entry_elem.find(f'{XMLNS_ATOM}id').text
title = entry_elem.find(f'{XMLNS_ATOM}title').text
entries.append(SystemInfoEntry(identity, title))
Comment on lines +53 to +59
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential AttributeError if XML entry lacks id or title element.

If the server returns an <entry> without an <atom:id> or <atom:title> child, find() returns None and accessing .text will raise AttributeError. Consider adding defensive checks.

🛡️ Suggested defensive handling
     for entry_elem in root.findall(f'{XMLNS_ATOM}entry'):
-        identity = entry_elem.find(f'{XMLNS_ATOM}id').text
-        title = entry_elem.find(f'{XMLNS_ATOM}title').text
-        entries.append(SystemInfoEntry(identity, title))
+        id_elem = entry_elem.find(f'{XMLNS_ATOM}id')
+        title_elem = entry_elem.find(f'{XMLNS_ATOM}title')
+        if id_elem is not None and title_elem is not None:
+            entries.append(SystemInfoEntry(id_elem.text, title_elem.text))
🧰 Tools
🪛 Ruff (0.15.7)

[error] 53-53: Using xml to parse untrusted data is known to be vulnerable to XML attacks; use defusedxml equivalents

(S314)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sap/adt/system.py` around lines 53 - 59, The loop that builds entries from
ElementTree.fromstring can raise AttributeError when
entry_elem.find(f'{XMLNS_ATOM}id') or .find(f'{XMLNS_ATOM}title') returns None;
update the loop that populates entries (the for entry_elem in root.findall(...)
block that creates SystemInfoEntry(identity, title)) to defensively check the
result of entry_elem.find(...) before accessing .text, e.g., extract element =
entry_elem.find(...), skip or supply a safe default if element is None, and
optionally log or warn about missing id/title so you don’t call .text on None.


return entries


def _fetch_json_entries(connection):
"""Fetch entries from the JSON endpoint /sap/bc/adt/core/http/systeminformation"""

resp = connection.execute(
'GET',
'core/http/systeminformation',
accept='application/vnd.sap.adt.core.http.systeminformation.v1+json'
)

data = resp.json()

return [SystemInfoEntry(JSON_KEY_MAPPING.get(key, key), value) for key, value in data.items()]


def get_information(connection: Connection) -> SystemInformation:
"""Fetch system information from ADT endpoints

Sends GET requests to /sap/bc/adt/system/information and
/sap/bc/adt/core/http/systeminformation, merges the results
into a single SystemInformation object. Entries from the XML
endpoint take precedence over entries from the JSON endpoint
if the same key exists in both.
"""

xml_entries = _fetch_xml_entries(connection)
json_entries = _fetch_json_entries(connection)

merged = {entry.identity: entry for entry in json_entries}
for entry in xml_entries:
merged[entry.identity] = entry

return SystemInformation(list(merged.values()))
5 changes: 5 additions & 0 deletions sap/cli/_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ def parse_command_line(argv):
log.setLevel(loglevel)
logging.debug('Logging level: %i', loglevel)

if not hasattr(args, 'execute'):
report_args_error_and_exit(
arg_parser,
'No command specified - please consult the help and specify a command to execute')

sap.cli.resolve_default_connection_values(args)

if not args.ashost and not args.mshost:
Expand Down
19 changes: 19 additions & 0 deletions sap/cli/abap.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sys

import sap.cli.core
import sap.adt.system
import sap.platform.abap.run


Expand Down Expand Up @@ -36,3 +37,21 @@ def run(connection, args):
)

console.printout(result)


@CommandGroup.argument('--key', type=str, default=None, help='Print only the value for the given key')
@CommandGroup.command()
def systeminfo(connection, args):
"""Prints system information"""

console = args.console_factory()

info = sap.adt.system.get_information(connection)

if args.key:
value = info.get(args.key)
if value is not None:
console.printout(value)
else:
for entry in info:
console.printout(f'{entry.identity}: {entry.title}')
30 changes: 30 additions & 0 deletions sap/cli/gcts.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,18 @@ def __init__(self):
super().__init__('user')


@UserCommandGroup.argument('-e', '--endpoint', default=None)
@UserCommandGroup.argument('-f', '--format', choices=['HUMAN', 'JSON'], default='HUMAN')
@UserCommandGroup.command('get-credentials')
def get_user_credentials(connection, args):
"""Get user credentials"""

user_credentials = sap.rest.gcts.simple.get_user_credentials(connection)
console = args.console_factory()

if args.endpoint is not None:
user_credentials = _filter_credentials_by_endpoint(user_credentials, args.endpoint)

if args.format == 'JSON':
console.printout(user_credentials)
else:
Expand All @@ -98,6 +103,31 @@ def get_user_credentials(connection, args):
sap.cli.helpers.TableWriter(user_credentials, columns).printout(console)


def _canonicalize_endpoint(endpoint):
"""Canonicalize endpoint URL for consistent matching"""

return endpoint.strip().rstrip('/').lower()


def _filter_credentials_by_endpoint(user_credentials, endpoint):
"""Filter credentials by endpoint and validate state"""

canonical_endpoint = _canonicalize_endpoint(endpoint)

matching = [cred for cred in user_credentials
if _canonicalize_endpoint(cred['endpoint']) == canonical_endpoint]

if not matching:
raise SAPCliError(f'No credentials found for endpoint: {endpoint}')

valid = [cred for cred in matching if cred.get('state', '') != 'false']

if not valid:
raise SAPCliError(f'Credentials for endpoint {endpoint} are not valid: {matching[0]["state"]}')

return valid


@UserCommandGroup.argument('-t', '--token')
@UserCommandGroup.argument('-a', '--api-url')
@UserCommandGroup.command('set-credentials')
Expand Down
128 changes: 128 additions & 0 deletions test/unit/fixtures_adt_system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""System Information ADT fixtures"""

from mock import Response

SYSTEM_INFORMATION_XML = '''<?xml version="1.0" encoding="utf-8"?>
<atom:feed xmlns:atom="http://www.w3.org/2005/Atom">
<atom:author>
<atom:name>SAP SE</atom:name>
</atom:author>
<atom:title>System Information</atom:title>
<atom:updated>2026-03-23T13:43:39Z</atom:updated>
<atom:entry>
<atom:id>ApplicationServerName</atom:id>
<atom:title>C50_ddci</atom:title>
</atom:entry>
<atom:entry>
<atom:id>DBLibrary</atom:id>
<atom:title>SQLDBC 2.27.024.1772569942</atom:title>
</atom:entry>
<atom:entry>
<atom:id>DBName</atom:id>
<atom:title>C50/02</atom:title>
</atom:entry>
<atom:entry>
<atom:id>DBRelease</atom:id>
<atom:title>2.00.089.01.1769502981</atom:title>
</atom:entry>
<atom:entry>
<atom:id>DBSchema</atom:id>
<atom:title>SAPHANADB</atom:title>
</atom:entry>
<atom:entry>
<atom:id>DBServer</atom:id>
<atom:title>saphost</atom:title>
</atom:entry>
<atom:entry>
<atom:id>DBSystem</atom:id>
<atom:title>HDB</atom:title>
</atom:entry>
<atom:entry>
<atom:id>IPAddress</atom:id>
<atom:title>172.27.4.5</atom:title>
</atom:entry>
<atom:entry>
<atom:id>KernelCompilationDate</atom:id>
<atom:title>Linux GNU SLES-15 x86_64 cc10.3.0 use-pr260304 Mar 09 2026 11:05:09</atom:title>
</atom:entry>
<atom:entry>
<atom:id>KernelKind</atom:id>
<atom:title>opt</atom:title>
</atom:entry>
<atom:entry>
<atom:id>KernelPatchLevel</atom:id>
<atom:title>0</atom:title>
</atom:entry>
<atom:entry>
<atom:id>KernelRelease</atom:id>
<atom:title>920</atom:title>
</atom:entry>
<atom:entry>
<atom:id>MachineType</atom:id>
<atom:title>x86_64</atom:title>
</atom:entry>
<atom:entry>
<atom:id>NodeName</atom:id>
<atom:title>saphost</atom:title>
</atom:entry>
<atom:entry>
<atom:id>NotAuthorizedDB</atom:id>
<atom:title>false</atom:title>
</atom:entry>
<atom:entry>
<atom:id>NotAuthorizedHost</atom:id>
<atom:title>false</atom:title>
</atom:entry>
<atom:entry>
<atom:id>NotAuthorizedKernel</atom:id>
<atom:title>false</atom:title>
</atom:entry>
<atom:entry>
<atom:id>NotAuthorizedSystem</atom:id>
<atom:title>false</atom:title>
</atom:entry>
<atom:entry>
<atom:id>NotAuthorizedUser</atom:id>
<atom:title>false</atom:title>
</atom:entry>
<atom:entry>
<atom:id>OSName</atom:id>
<atom:title>Linux</atom:title>
</atom:entry>
<atom:entry>
<atom:id>OSVersion</atom:id>
<atom:title>6.4.0-150600.23.60-default</atom:title>
</atom:entry>
<atom:entry>
<atom:id>SAPSystemID</atom:id>
<atom:title>390</atom:title>
</atom:entry>
<atom:entry>
<atom:id>SAPSystemNumber</atom:id>
<atom:title>000000000000000001</atom:title>
</atom:entry>
<atom:entry>
<atom:id>UnicodeSystem</atom:id>
<atom:title>True</atom:title>
</atom:entry>
</atom:feed>'''

RESPONSE_SYSTEM_INFORMATION = Response(
text=SYSTEM_INFORMATION_XML,
status_code=200,
headers={'Content-Type': 'application/atom+xml;type=feed'}
)

JSON_SYSTEM_INFORMATION = {
'systemID': 'C50',
'userName': 'DEVELOPER',
'userFullName': '',
'client': '100',
'language': 'EN',
}

RESPONSE_JSON_SYSTEM_INFORMATION = Response(
status_code=200,
json=JSON_SYSTEM_INFORMATION,
headers={'Content-Type': 'application/vnd.sap.adt.core.http.systeminformation.v1+json'}
)
Loading
Loading