From 551f2cb155a774febbddbe0e2ccca40d67f6735d Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 24 Mar 2026 12:06:36 -0600 Subject: [PATCH 1/3] fix delete_relationship() method --- README.md | 10 +- examples/03_relationship_management.py | 42 ++++- examples/06_advanced_operations.py | 28 ++-- examples/examples.py | 6 +- .../snyk_findings_resolved_within_15_days.py | 143 ++++++++++++++++++ examples/test_list_parameter_items.py | 128 ++++++++++++++++ jupiterone/client.py | 38 ++++- jupiterone/constants.py | 14 +- setup.py | 2 +- tests/test_delete_relationship.py | 6 +- 10 files changed, 390 insertions(+), 27 deletions(-) create mode 100644 examples/snyk_findings_resolved_within_15_days.py create mode 100644 examples/test_list_parameter_items.py diff --git a/README.md b/README.md index 92e2d3c..0cb2400 100644 --- a/README.md +++ b/README.md @@ -292,12 +292,18 @@ j1.update_relationship( ##### Delete a relationship ```python -# Delete by relationship ID -j1.delete_relationship(relationship_id='') +# Delete a relationship (requires relationship ID, source entity ID, and target entity ID) +j1.delete_relationship( + relationship_id='', + from_entity_id='', + to_entity_id='' +) # Delete with timestamp j1.delete_relationship( relationship_id='', + from_entity_id='', + to_entity_id='', timestamp=int(time.time()) * 1000 ) ``` diff --git a/examples/03_relationship_management.py b/examples/03_relationship_management.py index 04e7997..896bcea 100644 --- a/examples/03_relationship_management.py +++ b/examples/03_relationship_management.py @@ -175,20 +175,26 @@ def update_relationship_examples(j1, relationship_id, from_entity_id, to_entity_ ) print(f"Updated with custom timestamp\n") -def delete_relationship_examples(j1, relationship_id): +def delete_relationship_examples(j1, relationship_id, from_entity_id, to_entity_id): """Demonstrate relationship deletion.""" print("=== Relationship Deletion Examples ===\n") # 1. Basic deletion print("1. Deleting a relationship:") - delete_result = j1.delete_relationship(relationship_id=relationship_id) + delete_result = j1.delete_relationship( + relationship_id=relationship_id, + from_entity_id=from_entity_id, + to_entity_id=to_entity_id + ) print(f"Deleted relationship: {delete_result['relationship']['_id']}\n") # 2. Deletion with timestamp print("2. Deleting with specific timestamp:") j1.delete_relationship( relationship_id=relationship_id, + from_entity_id=from_entity_id, + to_entity_id=to_entity_id, timestamp=int(time.time()) * 1000 ) print(f"Deleted with timestamp\n") @@ -234,7 +240,11 @@ def relationship_lifecycle_example(j1, from_entity_id, to_entity_id): # 4. Delete relationship print("4. Deleting relationship:") - j1.delete_relationship(relationship_id=relationship_id) + j1.delete_relationship( + relationship_id=relationship_id, + from_entity_id=from_entity_id, + to_entity_id=to_entity_id + ) print("Deleted successfully") # 5. Verify deletion @@ -281,14 +291,22 @@ def network_relationship_example(j1): 'bandwidth': '100Mbps' } ) - relationships.append(relationship['relationship']['_id']) + relationships.append({ + 'id': relationship['relationship']['_id'], + 'from': entities[i], + 'to': entities[i+1] + }) print(f"Created connection {i}: {relationship['relationship']['_id']}") print(f"Created {len(entities)} nodes with {len(relationships)} connections") # Clean up - for relationship_id in relationships: - j1.delete_relationship(relationship_id=relationship_id) + for rel in relationships: + j1.delete_relationship( + relationship_id=rel['id'], + from_entity_id=rel['from'], + to_entity_id=rel['to'] + ) for entity_id in entities: j1.delete_entity(entity_id=entity_id) @@ -356,7 +374,11 @@ def access_control_relationship_example(j1): print("Updated access level to write") # Clean up - j1.delete_relationship(relationship_id=access_relationship['relationship']['_id']) + j1.delete_relationship( + relationship_id=access_relationship['relationship']['_id'], + from_entity_id=user_entity['entity']['_id'], + to_entity_id=resource_entity['entity']['_id'] + ) j1.delete_entity(entity_id=user_entity['entity']['_id']) j1.delete_entity(entity_id=resource_entity['entity']['_id']) @@ -397,7 +419,11 @@ def main(): relationships_to_clean = [basic_rel, props_rel, complex_rel] for rel in relationships_to_clean: try: - j1.delete_relationship(relationship_id=rel['relationship']['_id']) + j1.delete_relationship( + relationship_id=rel['relationship']['_id'], + from_entity_id=from_entity_id, + to_entity_id=to_entity_id + ) print(f"Cleaned up relationship: {rel['relationship']['_id']}") except Exception: # Relationship may already be deleted or not exist diff --git a/examples/06_advanced_operations.py b/examples/06_advanced_operations.py index d347ce7..d1e5a55 100644 --- a/examples/06_advanced_operations.py +++ b/examples/06_advanced_operations.py @@ -112,7 +112,11 @@ def bulk_operations_examples(j1): for rel_data in relationships_to_create: try: relationship = j1.create_relationship(**rel_data) - created_relationships.append(relationship['relationship']['_id']) + created_relationships.append({ + 'id': relationship['relationship']['_id'], + 'from': rel_data['from_entity_id'], + 'to': rel_data['to_entity_id'] + }) print(f"Created relationship: {relationship['relationship']['_id']}") except Exception as e: print(f"Error creating relationship: {e}") @@ -137,29 +141,35 @@ def bulk_operations_examples(j1): # 4. Bulk relationship updates print("4. Bulk relationship updates:") - for rel_id in created_relationships: + for rel in created_relationships: try: j1.update_relationship( - relationship_id=rel_id, + relationship_id=rel['id'], + from_entity_id=rel['from'], + to_entity_id=rel['to'], properties={ "lastUpdated": int(time.time()) * 1000, "tag.BulkUpdated": "true" } ) - print(f"Updated relationship: {rel_id}") + print(f"Updated relationship: {rel['id']}") except Exception as e: - print(f"Error updating relationship {rel_id}: {e}") + print(f"Error updating relationship {rel['id']}: {e}") print() # 5. Bulk deletion print("5. Bulk deletion:") # Delete relationships first - for rel_id in created_relationships: + for rel in created_relationships: try: - j1.delete_relationship(relationship_id=rel_id) - print(f"Deleted relationship: {rel_id}") + j1.delete_relationship( + relationship_id=rel['id'], + from_entity_id=rel['from'], + to_entity_id=rel['to'] + ) + print(f"Deleted relationship: {rel['id']}") except Exception as e: - print(f"Error deleting relationship {rel_id}: {e}") + print(f"Error deleting relationship {rel['id']}: {e}") # Then delete entities for entity_id in created_entities: diff --git a/examples/examples.py b/examples/examples.py index 6a710f0..4b2e51c 100644 --- a/examples/examples.py +++ b/examples/examples.py @@ -85,7 +85,11 @@ print(create_relationship_r) # delete_relationship -delete_relationship_r = j1.delete_relationship(relationship_id=create_relationship_r['relationship']['_id']) +delete_relationship_r = j1.delete_relationship( + relationship_id=create_relationship_r['relationship']['_id'], + from_entity_id=create_r['entity']['_id'], + to_entity_id=create_r_2['entity']['_id'] +) print("delete_relationship()") print(delete_relationship_r) diff --git a/examples/snyk_findings_resolved_within_15_days.py b/examples/snyk_findings_resolved_within_15_days.py new file mode 100644 index 0000000..00f33c5 --- /dev/null +++ b/examples/snyk_findings_resolved_within_15_days.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Find critical Snyk findings linked to resolved issues where the issue +was resolved (updatedOn) within 15 days of being opened (createdOn). + +J1QL cannot perform property-to-property arithmetic in WHERE clauses, +so we retrieve both timestamps and filter in Python. + +Usage: + export JUPITERONE_ACCOUNT_ID="" + export JUPITERONE_API_TOKEN="" + python snyk_findings_resolved_within_15_days.py [--csv output.csv] +""" + +import os +import sys +import csv +import argparse +from datetime import datetime, timezone + +from jupiterone import JupiterOneClient + +FIFTEEN_DAYS_MS = 15 * 24 * 60 * 60 * 1000 + +J1QL_QUERY = """\ +FIND snyk_finding WITH severity = 'critical' AS finding + THAT RELATES TO snyk_issue WITH status = 'resolved' AS issue +RETURN + finding.displayName AS findingName, + finding._key AS findingKey, + finding.severity AS severity, + issue.displayName AS issueName, + issue._key AS issueKey, + issue.status AS issueStatus, + issue.createdOn AS createdOn, + issue.updatedOn AS updatedOn +LIMIT 250\ +""" + + +def ms_to_iso(epoch_ms): + """Convert epoch milliseconds to a human-readable ISO-8601 string.""" + if epoch_ms is None: + return "N/A" + try: + return datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + except (TypeError, ValueError, OSError): + return str(epoch_ms) + + +def main(): + parser = argparse.ArgumentParser( + description="Find critical Snyk findings resolved within 15 days." + ) + parser.add_argument( + "--csv", + metavar="FILE", + help="Write results to a CSV file instead of stdout.", + ) + args = parser.parse_args() + + account = os.getenv("JUPITERONE_ACCOUNT_ID") + token = os.getenv("JUPITERONE_API_TOKEN") + if not account or not token: + sys.exit( + "Error: JUPITERONE_ACCOUNT_ID and JUPITERONE_API_TOKEN " + "environment variables are required." + ) + + j1 = JupiterOneClient( + account=account, + token=token, + url=os.getenv("JUPITERONE_URL", "https://graphql.us.jupiterone.io"), + sync_url=os.getenv("JUPITERONE_SYNC_URL", "https://api.us.jupiterone.io"), + ) + + print(f"Executing J1QL query ...\n{J1QL_QUERY}\n") + result = j1.query_v1(query=J1QL_QUERY) + rows = result.get("data", []) + print(f"Total rows returned: {len(rows)}") + + filtered = [] + skipped_missing_dates = 0 + + for row in rows: + props = row.get("properties", row) + created_on = props.get("createdOn") + updated_on = props.get("updatedOn") + + if created_on is None or updated_on is None: + skipped_missing_dates += 1 + continue + + delta_ms = updated_on - created_on + if delta_ms <= FIFTEEN_DAYS_MS: + filtered.append( + { + "findingName": props.get("findingName", ""), + "findingKey": props.get("findingKey", ""), + "severity": props.get("severity", ""), + "issueName": props.get("issueName", ""), + "issueKey": props.get("issueKey", ""), + "issueStatus": props.get("issueStatus", ""), + "createdOn": created_on, + "updatedOn": updated_on, + "createdOnHuman": ms_to_iso(created_on), + "updatedOnHuman": ms_to_iso(updated_on), + "daysToResolve": round(delta_ms / (24 * 60 * 60 * 1000), 2), + } + ) + + print(f"Rows matching <=15-day window: {len(filtered)}") + if skipped_missing_dates: + print(f"Rows skipped (missing createdOn/updatedOn): {skipped_missing_dates}") + + if not filtered: + print("No matching results.") + return + + if args.csv: + fieldnames = list(filtered[0].keys()) + with open(args.csv, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(filtered) + print(f"\nResults written to {args.csv}") + else: + print(f"\n{'Finding':<40} {'Issue':<40} {'Created':<24} {'Updated':<24} {'Days':>6}") + print("-" * 138) + for r in filtered: + print( + f"{r['findingName']:<40} " + f"{r['issueName']:<40} " + f"{r['createdOnHuman']:<24} " + f"{r['updatedOnHuman']:<24} " + f"{r['daysToResolve']:>6}" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/test_list_parameter_items.py b/examples/test_list_parameter_items.py new file mode 100644 index 0000000..2649bc2 --- /dev/null +++ b/examples/test_list_parameter_items.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Test script: Create an account parameter of the list type with N items. + +Takes an integer argument for how many list items to include, allowing +easy testing of list size limits. + +Prerequisites (environment variables): +- JUPITERONE_ACCOUNT_ID +- JUPITERONE_API_TOKEN +- (optional) JUPITERONE_URL +- (optional) JUPITERONE_SYNC_URL + +Usage: + python test_list_parameter_21_items.py + python test_list_parameter_21_items.py 21 + python test_list_parameter_21_items.py 50 +""" + +import os +import sys +from jupiterone import JupiterOneClient + +PARAM_NAME = "TEST_LIST_PARAM_N_ITEMS" + + +def build_list(n: int) -> list: + return [f"item_{str(i).zfill(3)}_test_entity_type" for i in range(1, n + 1)] + + +def main() -> None: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + try: + count = int(sys.argv[1]) + except ValueError: + print(f"ERROR: '{sys.argv[1]}' is not a valid integer") + sys.exit(1) + + if count < 1: + print("ERROR: number of items must be at least 1") + sys.exit(1) + + param_value = build_list(count) + + print(f"Test: Create list-type account parameter with {count} items") + print("=" * 70) + + account_id = os.getenv("JUPITERONE_ACCOUNT_ID") + api_token = os.getenv("JUPITERONE_API_TOKEN") + + if not account_id or not api_token: + print("ERROR: JUPITERONE_ACCOUNT_ID and JUPITERONE_API_TOKEN must be set") + sys.exit(1) + + j1 = JupiterOneClient( + account=account_id, + token=api_token, + url=os.getenv("JUPITERONE_URL", "https://graphql.us.jupiterone.io"), + sync_url=os.getenv("JUPITERONE_SYNC_URL", "https://api.us.jupiterone.io"), + ) + + print(f"Parameter name : {PARAM_NAME}") + print(f"List length : {len(param_value)}") + print(f"Items : {param_value[:3]} ... {param_value[-1]}") + print() + + # Step 1: Create the parameter + print("[1/3] Creating parameter...") + try: + result = j1.create_update_parameter( + name=PARAM_NAME, + value=param_value, + secret=False, + ) + success = result and result.get("data", {}).get("setParameter", {}).get("success") is True + print(f" Response: {result}") + if success: + print(" Result: SUCCESS") + else: + print(" Result: UNEXPECTED RESPONSE (see above)") + except Exception as exc: + print(f" Result: FAILED — {exc}") + sys.exit(1) + + # Step 2: Read back and verify + print(f"\n[2/3] Reading parameter back...") + try: + details = j1.get_parameter_details(name=PARAM_NAME) + print(f" Response: {details}") + + stored_value = ( + details.get("data", {}) + .get("parameter", {}) + .get("value") + ) + if isinstance(stored_value, list): + print(f" Stored list length: {len(stored_value)}") + if len(stored_value) == count: + print(f" Verification: ALL {count} ITEMS PERSISTED") + else: + print(f" Verification: MISMATCH — expected {count}, got {len(stored_value)}") + else: + print(f" Verification: VALUE IS NOT A LIST — type={type(stored_value)}") + except Exception as exc: + print(f" Result: FAILED to read back — {exc}") + + # Step 3: Cleanup + print("\n[3/3] Cleaning up (overwriting with empty list)...") + try: + cleanup = j1.create_update_parameter( + name=PARAM_NAME, + value=[], + secret=False, + ) + print(f" Cleanup response: {cleanup}") + print(" Cleanup: DONE") + except Exception as exc: + print(f" Cleanup: FAILED — {exc}") + + print("\n" + "=" * 70) + print("Test complete.") + + +if __name__ == "__main__": + main() diff --git a/jupiterone/client.py b/jupiterone/client.py index a0dff9f..903d037 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -764,13 +764,45 @@ def update_relationship(self, **kwargs: Any) -> Dict[str, Any]: response = self._execute_query(query=UPDATE_RELATIONSHIP, variables=variables) return response["data"]["updateRelationship"] - def delete_relationship(self, relationship_id: Optional[str] = None) -> Dict[str, Any]: + def delete_relationship( + self, + relationship_id: Optional[str] = None, + from_entity_id: Optional[str] = None, + to_entity_id: Optional[str] = None, + timestamp: Optional[int] = None, + ) -> Dict[str, Any]: """Deletes a relationship between two entities. args: - relationship_id (str): The ID of the relationship + relationship_id (str): The _id of the relationship to delete + from_entity_id (str): The _id of the source entity + to_entity_id (str): The _id of the target entity + timestamp (int, optional): Timestamp for the deletion """ - variables = {"relationshipId": relationship_id} + if not relationship_id: + raise JupiterOneClientError("relationship_id is required") + if not isinstance(relationship_id, str) or not relationship_id.strip(): + raise JupiterOneClientError("relationship_id must be a non-empty string") + + if not from_entity_id: + raise JupiterOneClientError("from_entity_id is required") + self._validate_entity_id(from_entity_id, "from_entity_id") + + if not to_entity_id: + raise JupiterOneClientError("to_entity_id is required") + self._validate_entity_id(to_entity_id, "to_entity_id") + + if timestamp is not None: + if not isinstance(timestamp, int) or timestamp <= 0: + raise JupiterOneClientError("timestamp must be a positive integer") + + variables: Dict[str, Any] = { + "relationshipId": relationship_id, + "fromEntityId": from_entity_id, + "toEntityId": to_entity_id, + } + if timestamp is not None: + variables["timestamp"] = timestamp response = self._execute_query(DELETE_RELATIONSHIP, variables=variables) return response["data"]["deleteRelationship"] diff --git a/jupiterone/constants.py b/jupiterone/constants.py index 4f741d9..82bf97b 100644 --- a/jupiterone/constants.py +++ b/jupiterone/constants.py @@ -117,8 +117,18 @@ } """ DELETE_RELATIONSHIP = """ - mutation DeleteRelationship($relationshipId: String! $timestamp: Long) { - deleteRelationship (relationshipId: $relationshipId, timestamp: $timestamp) { + mutation DeleteRelationship( + $relationshipId: String! + $fromEntityId: String! + $toEntityId: String! + $timestamp: Long + ) { + deleteRelationship( + relationshipId: $relationshipId + fromEntityId: $fromEntityId + toEntityId: $toEntityId + timestamp: $timestamp + ) { relationship { _id } diff --git a/setup.py b/setup.py index ed34102..4c39383 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="jupiterone", - version="2.1.0", + version="2.2.0", description="A Python client for the JupiterOne API", license="MIT License", author="JupiterOne", diff --git a/tests/test_delete_relationship.py b/tests/test_delete_relationship.py index dba6d5d..a9fca72 100644 --- a/tests/test_delete_relationship.py +++ b/tests/test_delete_relationship.py @@ -42,7 +42,11 @@ def request_callback(request): ) j1 = JupiterOneClient(account='testAccount', token='testToken1234567890') - response = j1.delete_relationship('1') + response = j1.delete_relationship( + relationship_id='1', + from_entity_id='2222222222', + to_entity_id='3333333333' + ) assert type(response) == dict assert type(response['relationship']) == dict From f182012ea0d802c7d63430d317987a91e5bd97ed Mon Sep 17 00:00:00 2001 From: Colin Blumer Date: Wed, 25 Mar 2026 09:28:04 -0600 Subject: [PATCH 2/3] Update examples/test_list_parameter_items.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- examples/test_list_parameter_items.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/test_list_parameter_items.py b/examples/test_list_parameter_items.py index 2649bc2..95bc754 100644 --- a/examples/test_list_parameter_items.py +++ b/examples/test_list_parameter_items.py @@ -12,9 +12,9 @@ - (optional) JUPITERONE_SYNC_URL Usage: - python test_list_parameter_21_items.py - python test_list_parameter_21_items.py 21 - python test_list_parameter_21_items.py 50 + python test_list_parameter_items.py + python test_list_parameter_items.py 21 + python test_list_parameter_items.py 50 """ import os From 8a31f410c08987783624fccabe4de0af666df745 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Wed, 25 Mar 2026 09:48:29 -0600 Subject: [PATCH 3/3] copilot and review comment updates --- README.md | 8 - examples/03_relationship_management.py | 11 -- .../snyk_findings_resolved_within_15_days.py | 143 ------------------ examples/test_list_parameter_items.py | 128 ---------------- jupiterone/client.py | 8 - jupiterone/constants.py | 2 - tests/test_delete_relationship.py | 76 ++++++---- 7 files changed, 47 insertions(+), 329 deletions(-) delete mode 100644 examples/snyk_findings_resolved_within_15_days.py delete mode 100644 examples/test_list_parameter_items.py diff --git a/README.md b/README.md index 0cb2400..b4364f3 100644 --- a/README.md +++ b/README.md @@ -298,14 +298,6 @@ j1.delete_relationship( from_entity_id='', to_entity_id='' ) - -# Delete with timestamp -j1.delete_relationship( - relationship_id='', - from_entity_id='', - to_entity_id='', - timestamp=int(time.time()) * 1000 -) ``` ##### Fetch Graph Entity Properties diff --git a/examples/03_relationship_management.py b/examples/03_relationship_management.py index 896bcea..9017fa6 100644 --- a/examples/03_relationship_management.py +++ b/examples/03_relationship_management.py @@ -180,7 +180,6 @@ def delete_relationship_examples(j1, relationship_id, from_entity_id, to_entity_ print("=== Relationship Deletion Examples ===\n") - # 1. Basic deletion print("1. Deleting a relationship:") delete_result = j1.delete_relationship( relationship_id=relationship_id, @@ -188,16 +187,6 @@ def delete_relationship_examples(j1, relationship_id, from_entity_id, to_entity_ to_entity_id=to_entity_id ) print(f"Deleted relationship: {delete_result['relationship']['_id']}\n") - - # 2. Deletion with timestamp - print("2. Deleting with specific timestamp:") - j1.delete_relationship( - relationship_id=relationship_id, - from_entity_id=from_entity_id, - to_entity_id=to_entity_id, - timestamp=int(time.time()) * 1000 - ) - print(f"Deleted with timestamp\n") def relationship_lifecycle_example(j1, from_entity_id, to_entity_id): """Demonstrate complete relationship lifecycle.""" diff --git a/examples/snyk_findings_resolved_within_15_days.py b/examples/snyk_findings_resolved_within_15_days.py deleted file mode 100644 index 00f33c5..0000000 --- a/examples/snyk_findings_resolved_within_15_days.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env python3 -""" -Find critical Snyk findings linked to resolved issues where the issue -was resolved (updatedOn) within 15 days of being opened (createdOn). - -J1QL cannot perform property-to-property arithmetic in WHERE clauses, -so we retrieve both timestamps and filter in Python. - -Usage: - export JUPITERONE_ACCOUNT_ID="" - export JUPITERONE_API_TOKEN="" - python snyk_findings_resolved_within_15_days.py [--csv output.csv] -""" - -import os -import sys -import csv -import argparse -from datetime import datetime, timezone - -from jupiterone import JupiterOneClient - -FIFTEEN_DAYS_MS = 15 * 24 * 60 * 60 * 1000 - -J1QL_QUERY = """\ -FIND snyk_finding WITH severity = 'critical' AS finding - THAT RELATES TO snyk_issue WITH status = 'resolved' AS issue -RETURN - finding.displayName AS findingName, - finding._key AS findingKey, - finding.severity AS severity, - issue.displayName AS issueName, - issue._key AS issueKey, - issue.status AS issueStatus, - issue.createdOn AS createdOn, - issue.updatedOn AS updatedOn -LIMIT 250\ -""" - - -def ms_to_iso(epoch_ms): - """Convert epoch milliseconds to a human-readable ISO-8601 string.""" - if epoch_ms is None: - return "N/A" - try: - return datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc).strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - except (TypeError, ValueError, OSError): - return str(epoch_ms) - - -def main(): - parser = argparse.ArgumentParser( - description="Find critical Snyk findings resolved within 15 days." - ) - parser.add_argument( - "--csv", - metavar="FILE", - help="Write results to a CSV file instead of stdout.", - ) - args = parser.parse_args() - - account = os.getenv("JUPITERONE_ACCOUNT_ID") - token = os.getenv("JUPITERONE_API_TOKEN") - if not account or not token: - sys.exit( - "Error: JUPITERONE_ACCOUNT_ID and JUPITERONE_API_TOKEN " - "environment variables are required." - ) - - j1 = JupiterOneClient( - account=account, - token=token, - url=os.getenv("JUPITERONE_URL", "https://graphql.us.jupiterone.io"), - sync_url=os.getenv("JUPITERONE_SYNC_URL", "https://api.us.jupiterone.io"), - ) - - print(f"Executing J1QL query ...\n{J1QL_QUERY}\n") - result = j1.query_v1(query=J1QL_QUERY) - rows = result.get("data", []) - print(f"Total rows returned: {len(rows)}") - - filtered = [] - skipped_missing_dates = 0 - - for row in rows: - props = row.get("properties", row) - created_on = props.get("createdOn") - updated_on = props.get("updatedOn") - - if created_on is None or updated_on is None: - skipped_missing_dates += 1 - continue - - delta_ms = updated_on - created_on - if delta_ms <= FIFTEEN_DAYS_MS: - filtered.append( - { - "findingName": props.get("findingName", ""), - "findingKey": props.get("findingKey", ""), - "severity": props.get("severity", ""), - "issueName": props.get("issueName", ""), - "issueKey": props.get("issueKey", ""), - "issueStatus": props.get("issueStatus", ""), - "createdOn": created_on, - "updatedOn": updated_on, - "createdOnHuman": ms_to_iso(created_on), - "updatedOnHuman": ms_to_iso(updated_on), - "daysToResolve": round(delta_ms / (24 * 60 * 60 * 1000), 2), - } - ) - - print(f"Rows matching <=15-day window: {len(filtered)}") - if skipped_missing_dates: - print(f"Rows skipped (missing createdOn/updatedOn): {skipped_missing_dates}") - - if not filtered: - print("No matching results.") - return - - if args.csv: - fieldnames = list(filtered[0].keys()) - with open(args.csv, "w", newline="") as f: - writer = csv.DictWriter(f, fieldnames=fieldnames) - writer.writeheader() - writer.writerows(filtered) - print(f"\nResults written to {args.csv}") - else: - print(f"\n{'Finding':<40} {'Issue':<40} {'Created':<24} {'Updated':<24} {'Days':>6}") - print("-" * 138) - for r in filtered: - print( - f"{r['findingName']:<40} " - f"{r['issueName']:<40} " - f"{r['createdOnHuman']:<24} " - f"{r['updatedOnHuman']:<24} " - f"{r['daysToResolve']:>6}" - ) - - -if __name__ == "__main__": - main() diff --git a/examples/test_list_parameter_items.py b/examples/test_list_parameter_items.py deleted file mode 100644 index 95bc754..0000000 --- a/examples/test_list_parameter_items.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script: Create an account parameter of the list type with N items. - -Takes an integer argument for how many list items to include, allowing -easy testing of list size limits. - -Prerequisites (environment variables): -- JUPITERONE_ACCOUNT_ID -- JUPITERONE_API_TOKEN -- (optional) JUPITERONE_URL -- (optional) JUPITERONE_SYNC_URL - -Usage: - python test_list_parameter_items.py - python test_list_parameter_items.py 21 - python test_list_parameter_items.py 50 -""" - -import os -import sys -from jupiterone import JupiterOneClient - -PARAM_NAME = "TEST_LIST_PARAM_N_ITEMS" - - -def build_list(n: int) -> list: - return [f"item_{str(i).zfill(3)}_test_entity_type" for i in range(1, n + 1)] - - -def main() -> None: - if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} ") - sys.exit(1) - - try: - count = int(sys.argv[1]) - except ValueError: - print(f"ERROR: '{sys.argv[1]}' is not a valid integer") - sys.exit(1) - - if count < 1: - print("ERROR: number of items must be at least 1") - sys.exit(1) - - param_value = build_list(count) - - print(f"Test: Create list-type account parameter with {count} items") - print("=" * 70) - - account_id = os.getenv("JUPITERONE_ACCOUNT_ID") - api_token = os.getenv("JUPITERONE_API_TOKEN") - - if not account_id or not api_token: - print("ERROR: JUPITERONE_ACCOUNT_ID and JUPITERONE_API_TOKEN must be set") - sys.exit(1) - - j1 = JupiterOneClient( - account=account_id, - token=api_token, - url=os.getenv("JUPITERONE_URL", "https://graphql.us.jupiterone.io"), - sync_url=os.getenv("JUPITERONE_SYNC_URL", "https://api.us.jupiterone.io"), - ) - - print(f"Parameter name : {PARAM_NAME}") - print(f"List length : {len(param_value)}") - print(f"Items : {param_value[:3]} ... {param_value[-1]}") - print() - - # Step 1: Create the parameter - print("[1/3] Creating parameter...") - try: - result = j1.create_update_parameter( - name=PARAM_NAME, - value=param_value, - secret=False, - ) - success = result and result.get("data", {}).get("setParameter", {}).get("success") is True - print(f" Response: {result}") - if success: - print(" Result: SUCCESS") - else: - print(" Result: UNEXPECTED RESPONSE (see above)") - except Exception as exc: - print(f" Result: FAILED — {exc}") - sys.exit(1) - - # Step 2: Read back and verify - print(f"\n[2/3] Reading parameter back...") - try: - details = j1.get_parameter_details(name=PARAM_NAME) - print(f" Response: {details}") - - stored_value = ( - details.get("data", {}) - .get("parameter", {}) - .get("value") - ) - if isinstance(stored_value, list): - print(f" Stored list length: {len(stored_value)}") - if len(stored_value) == count: - print(f" Verification: ALL {count} ITEMS PERSISTED") - else: - print(f" Verification: MISMATCH — expected {count}, got {len(stored_value)}") - else: - print(f" Verification: VALUE IS NOT A LIST — type={type(stored_value)}") - except Exception as exc: - print(f" Result: FAILED to read back — {exc}") - - # Step 3: Cleanup - print("\n[3/3] Cleaning up (overwriting with empty list)...") - try: - cleanup = j1.create_update_parameter( - name=PARAM_NAME, - value=[], - secret=False, - ) - print(f" Cleanup response: {cleanup}") - print(" Cleanup: DONE") - except Exception as exc: - print(f" Cleanup: FAILED — {exc}") - - print("\n" + "=" * 70) - print("Test complete.") - - -if __name__ == "__main__": - main() diff --git a/jupiterone/client.py b/jupiterone/client.py index 903d037..f32134a 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -769,7 +769,6 @@ def delete_relationship( relationship_id: Optional[str] = None, from_entity_id: Optional[str] = None, to_entity_id: Optional[str] = None, - timestamp: Optional[int] = None, ) -> Dict[str, Any]: """Deletes a relationship between two entities. @@ -777,7 +776,6 @@ def delete_relationship( relationship_id (str): The _id of the relationship to delete from_entity_id (str): The _id of the source entity to_entity_id (str): The _id of the target entity - timestamp (int, optional): Timestamp for the deletion """ if not relationship_id: raise JupiterOneClientError("relationship_id is required") @@ -792,17 +790,11 @@ def delete_relationship( raise JupiterOneClientError("to_entity_id is required") self._validate_entity_id(to_entity_id, "to_entity_id") - if timestamp is not None: - if not isinstance(timestamp, int) or timestamp <= 0: - raise JupiterOneClientError("timestamp must be a positive integer") - variables: Dict[str, Any] = { "relationshipId": relationship_id, "fromEntityId": from_entity_id, "toEntityId": to_entity_id, } - if timestamp is not None: - variables["timestamp"] = timestamp response = self._execute_query(DELETE_RELATIONSHIP, variables=variables) return response["data"]["deleteRelationship"] diff --git a/jupiterone/constants.py b/jupiterone/constants.py index 82bf97b..ea5de8f 100644 --- a/jupiterone/constants.py +++ b/jupiterone/constants.py @@ -121,13 +121,11 @@ $relationshipId: String! $fromEntityId: String! $toEntityId: String! - $timestamp: Long ) { deleteRelationship( relationshipId: $relationshipId fromEntityId: $fromEntityId toEntityId: $toEntityId - timestamp: $timestamp ) { relationship { _id diff --git a/tests/test_delete_relationship.py b/tests/test_delete_relationship.py index a9fca72..0c4773e 100644 --- a/tests/test_delete_relationship.py +++ b/tests/test_delete_relationship.py @@ -3,41 +3,43 @@ import responses from jupiterone.client import JupiterOneClient -from jupiterone.constants import CREATE_ENTITY +from jupiterone.errors import JupiterOneClientError -@responses.activate -def test_tree_query_v1(): +MOCK_RESPONSE = { + 'data': { + 'deleteRelationship': { + 'relationship': { + '_id': '1' + }, + 'edge': { + 'id': '1', + 'toVertexId': '1', + 'fromVertexId': '2', + 'relationship': { + '_id': '1' + }, + 'properties': {} + } + } + } +} + +def _make_callback(captured_requests): + """Return a callback that records request bodies and returns a success response.""" def request_callback(request): - headers = { - 'Content-Type': 'application/json' - } + captured_requests.append(json.loads(request.body)) + return (200, {'Content-Type': 'application/json'}, json.dumps(MOCK_RESPONSE)) + return request_callback - response = { - 'data': { - 'deleteRelationship': { - 'relationship': { - '_id': '1' - }, - 'edge': { - 'id': '1', - 'toVertexId': '1', - 'fromVertexId': '2', - 'relationship': { - '_id': '1' - }, - 'properties': {} - } - } - } - } - return (200, headers, json.dumps(response)) - +@responses.activate +def test_delete_relationship_sends_required_variables(): + captured = [] responses.add_callback( responses.POST, 'https://graphql.us.jupiterone.io', - callback=request_callback, + callback=_make_callback(captured), content_type='application/json', ) @@ -48,8 +50,24 @@ def request_callback(request): to_entity_id='3333333333' ) - assert type(response) == dict - assert type(response['relationship']) == dict + assert len(captured) == 1 + variables = captured[0]['variables'] + assert variables['relationshipId'] == '1' + assert variables['fromEntityId'] == '2222222222' + assert variables['toEntityId'] == '3333333333' + assert response['relationship']['_id'] == '1' assert response['edge']['toVertexId'] == '1' assert response['edge']['fromVertexId'] == '2' + + +def test_delete_relationship_raises_without_from_entity_id(): + j1 = JupiterOneClient(account='testAccount', token='testToken1234567890') + with pytest.raises(JupiterOneClientError, match="from_entity_id is required"): + j1.delete_relationship(relationship_id='1', to_entity_id='3333333333') + + +def test_delete_relationship_raises_without_to_entity_id(): + j1 = JupiterOneClient(account='testAccount', token='testToken1234567890') + with pytest.raises(JupiterOneClientError, match="to_entity_id is required"): + j1.delete_relationship(relationship_id='1', from_entity_id='2222222222')