Collecter les journaux Oracle NetSuite

Compatible avec :

Ce document explique comment ingérer des journaux Oracle NetSuite dans Google Security Operations à l'aide de Cloud Storage V2.

Oracle NetSuite est une plate-forme ERP (Enterprise Resource Planning) dans le cloud qui fournit des fonctionnalités complètes de gestion d'entreprise, y compris la gestion financière, la gestion de la CRM'e-commerce et la gestion de l'inventaire. NetSuite génère des journaux d'audit pour les connexions des utilisateurs, les modifications du système et l'activité de l'API. Ces journaux peuvent être interrogés via l'API REST SuiteQL et ingérés dans Google SecOps pour la surveillance de la sécurité et la conformité.

Avant de commencer

Assurez-vous de remplir les conditions suivantes :

  • Une instance Google SecOps
  • Un projet Google Cloud avec les API Cloud Storage, Cloud Run, Pub/Sub et Cloud Scheduler activées
  • Autorisations de créer et de gérer des buckets Cloud Storage
  • Autorisations de gérer les stratégies IAM (Identity and Access Management) sur les buckets Cloud Storage
  • Autorisations permettant de créer des services Cloud Run, des sujets Pub/Sub et des jobs Cloud Scheduler
  • Accès administrateur à votre compte Oracle NetSuite
  • Les services Web REST et les fonctionnalités OAuth 2.0 doivent être activés dans votre compte NetSuite.
  • ID de votre compte NetSuite (disponible sous Setup > Company > Company Information)

Activer les fonctionnalités NetSuite requises

  1. Connectez-vous à votre compte NetSuite en tant qu'administrateur.
  2. Accédez à Setup > Company > Enable Features (Configuration > Entreprise > Activer les fonctionnalités).
  3. Cliquez sur le sous-onglet SuiteCloud.
  4. Dans la section SuiteTalk (Web Services), cochez la case suivante :
    • Services Web REST
  5. Dans la section Gérer l'authentification, cochez la case suivante :
    • OAuth 2.0
  6. Cliquez sur Enregistrer.

Configurer les identifiants OAuth 2.0 Oracle NetSuite

Pour permettre à la fonction Cloud Run de récupérer les journaux d'audit de NetSuite, vous devez créer un enregistrement d'intégration avec l'authentification par identifiants client OAuth 2.0.

Créer un enregistrement d'intégration

  1. Connectez-vous à votre compte NetSuite en tant qu'administrateur.
  2. Accédez à Setup > Integration > Manage Integrations > New (Configuration > Intégration > Gérer les intégrations > Nouveau).
  3. Dans le champ Nom, saisissez Google SecOps Integration.
  4. Dans le champ Description, saisissez Integration with Google SecOps for audit log ingestion.
  5. Dans le champ État, sélectionnez Activé.
  6. Cliquez sur le sous-onglet Authentication (Authentification).
  7. Dans la section OAuth 2.0, configurez les éléments suivants :
    • Sélectionnez Client Credentials (Machine to Machine) Grant (Attribution d'identifiants client (machine à machine)).
    • Sélectionnez Services Web REST.
  8. Décochez toutes les cases de la section Authentification basée sur un jeton.
  9. Cliquez sur Enregistrer.

Enregistrer les identifiants OAuth 2.0

Après avoir enregistré l'enregistrement d'intégration, NetSuite affiche vos identifiants OAuth 2.0 :

  • ID client : votre identifiant client unique
  • Code secret du client : votre clé API secrète

Générer un certificat pour les identifiants client OAuth 2.0

Le flux d'identifiants client OAuth 2.0 nécessite un certificat pour la signature JWT. Générez un certificat ECDSA à l'aide d'OpenSSL :

  1. Sur une machine sur laquelle OpenSSL est installé, exécutez la commande suivante pour générer une clé privée et un certificat :

    openssl req -new -x509 -newkey ec \
        -pkeyopt ec_paramgen_curve:prime256v1 \
        -pkeyopt ec_param_enc:named_curve \
        -nodes -days 730 \
        -out public.pem -keyout private.pem
    
  2. Lorsque vous y êtes invité, saisissez les champs d'objet du certificat (pays, État, organisation, etc.). Dans le champ Nom commun, saisissez Google SecOps Integration.

  3. La commande génère deux fichiers :

    • private.pem : clé privée (à conserver en lieu sûr ; utilisée pour la signature JWT dans la fonction Cloud Run)
    • public.pem : certificat public (à importer dans NetSuite)

Créer un mappage d'identifiants client OAuth 2.0

  1. Dans NetSuite, accédez à Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup.
  2. Cliquez sur Créer.
  3. Dans la boîte de dialogue, configurez les éléments suivants :
    • Entité : sélectionnez l'employé ou l'utilisateur qui sera associé aux requêtes API.
    • Rôle : sélectionnez un rôle disposant de l'autorisation Se connecter à l'aide de jetons d'accès OAuth 2.0 (voir Autorisations requises). Sans cette autorisation, le rôle n'apparaît pas dans cette liste.
    • Application : sélectionnez Google SecOps Integration.
  4. Cliquez sur Sélectionner un fichier, puis importez le fichier de certificat public.pem.
  5. Cliquez sur Enregistrer.
  6. Après l'enregistrement, notez la valeur ID du certificat affichée dans la liste de mappage. Cette valeur est utilisée comme kid (ID de clé) dans l'en-tête JWT.

Autorisations requises

Le rôle attribué au mappage OAuth 2.0 doit disposer des autorisations suivantes :

Autorisation Niveau d'accès Objectif
Se connecter à l'aide de jetons d'accès OAuth 2.0 Complète Autoriser le rôle à utiliser la connexion par jeton OAuth 2.0
Services Web REST Complète Exécuter des requêtes API REST
Piste d'audit des connexions Complète Accéder aux données de la piste d'audit des connexions
SuiteAnalytics Workbook Afficher Accéder aux données d'analyse via SuiteQL
Notes système pour Analytics et REST Afficher Interroger les enregistrements SystemNote via SuiteQL

Créer un rôle personnalisé pour l'intégration

  1. Accédez à Setup > Users/Roles > Manage Roles > New (Configuration > Utilisateurs/Rôles > Gérer les rôles > Nouveau).
  2. Dans le champ Nom, saisissez Google SecOps Integration Role.
  3. Cliquez sur le sous-onglet Autorisations.
  4. Cliquez sur le sous-sous-onglet Configuration.
  5. Ajoutez les autorisations suivantes :
    • Se connecter à l'aide de jetons d'accès OAuth 2.0 : définissez cette option sur Complet.
    • Services Web REST : définissez cette option sur Complet.
    • Piste d'audit de connexion : définie sur Complète
  6. Cliquez sur le sous-onglet Rapports.
  7. Ajoutez l'autorisation suivante :
    • SuiteAnalytics Workbook : défini sur View (Afficher)
  8. Cliquez sur le sous-sous-onglet Listes.
  9. Ajoutez l'autorisation suivante :
    • Notes système pour Analytics et REST : définissez sur Afficher.
  10. Cliquez sur Enregistrer.

Vérifier les autorisations

Pour vérifier que le rôle dispose des autorisations requises, procédez comme suit :

  1. Connectez-vous à NetSuite.
  2. Accédez à Setup > Users/Roles > Manage Roles (Configuration > Utilisateurs/Rôles > Gérer les rôles).
  3. Recherchez Google SecOps Integration Role, puis cliquez sur Modifier.
  4. Vérifiez que les autorisations listées dans Autorisations requises sont présentes dans le sous-onglet Autorisations.

Tester l'accès à l'API

  • Testez vos identifiants avant de procéder à l'intégration :

    # Replace with your actual values
    ACCOUNT_ID="your-account-id"
    CLIENT_ID="your-client-id"
    CERTIFICATE_ID="your-certificate-id"
    PRIVATE_KEY_FILE="private.pem"
    
    # Generate JWT (requires Python 3 with PyJWT and cryptography)
    pip install PyJWT cryptography
    
    TOKEN=$(python3 -c "
    import jwt, time, json
    with open('${PRIVATE_KEY_FILE}', 'r') as f:
        key = f.read()
    now = int(time.time())
    payload = {
        'iss': '${CLIENT_ID}',
        'scope': ['restlets', 'rest_webservices'],
        'aud': 'https://${ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token',
        'iat': now,
        'exp': now + 3600
    }
    print(jwt.encode(payload, key, algorithm='ES256', headers={'kid': '${CERTIFICATE_ID}', 'typ': 'JWT'}))
    ")
    
    # Obtain access token
    ACCESS_TOKEN=$(curl -s -X POST \
        "https://${ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token" \
        -H "Content-Type: application/x-www-form-urlencoded" \
        -d "grant_type=client_credentials&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion=${TOKEN}" \
        | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
    
    # Test SuiteQL query against the login audit trail
    curl -s -X POST \
        "https://${ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=5" \
        -H "Authorization: Bearer ${ACCESS_TOKEN}" \
        -H "Content-Type: application/json" \
        -H "Prefer: transient" \
        -d '{"q": "SELECT Date, Status, User, IPAddress FROM LoginAudit ORDER BY Date DESC"}' \
        | python3 -m json.tool
    
    # Test SuiteQL query against system notes
    curl -s -X POST \
        "https://${ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=5" \
        -H "Authorization: Bearer ${ACCESS_TOKEN}" \
        -H "Content-Type: application/json" \
        -H "Prefer: transient" \
        -d '{"q": "SELECT Date, RecordID, Field, BUILTIN.DF(SystemNote.Name) AS EmployeeName FROM SystemNote ORDER BY Date DESC"}' \
        | python3 -m json.tool
    

Une réponse réussie renvoie un objet JSON avec un tableau items contenant les enregistrements d'audit de connexion.

La deuxième requête doit renvoyer les notes système récentes, y compris les modifications apportées par des utilisateurs autres que l'utilisateur d'intégration. Un tableau items vide ou ne listant que l'utilisateur d'intégration dans EmployeeName signifie que l'autorisation System Notes for Analytics and REST (Notes système pour Analytics et REST) est manquante pour le rôle.

Créer un bucket Google Cloud Storage

  1. Accédez à la consoleGoogle Cloud .
  2. Sélectionnez votre projet ou créez-en un.
  3. Dans le menu de navigation, accédez à Cloud Storage > Buckets.
  4. Cliquez sur Créer un bucket.
  5. Fournissez les informations de configuration suivantes :

    Paramètre Valeur
    Attribuez un nom au bucket Saisissez un nom unique (par exemple, netsuite-audit-logs).
    Type d'emplacement Choisissez celle qui correspond le mieux à vos besoins (régionale, birégionale ou multirégionale).
    Emplacement Sélectionnez l'emplacement (par exemple, us-central1).
    Classe de stockage Standard (recommandé pour les journaux fréquemment consultés)
    Access control (Contrôle des accès) Uniforme (recommandé)
    Outils de protection Facultatif : Activez la gestion des versions d'objets ou la règle de conservation.
  6. Cliquez sur Créer.

Créer un compte de service pour la fonction Cloud Run

La fonction Cloud Run a besoin d'un compte de service autorisé à écrire dans un bucket Cloud Storage et à être appelée par Pub/Sub.

Créer un compte de service

  1. Dans la console Google Cloud , accédez à IAM et administration > Comptes de service.
  2. Cliquez sur Créer un compte de service.
  3. Fournissez les informations de configuration suivantes :
    • Nom du compte de service : saisissez netsuite-audit-collector-sa.
    • Description du compte de service : saisissez Service account for Cloud Run function to collect Oracle NetSuite audit logs.
  4. Cliquez sur Créer et continuer.
  5. Dans la section Autoriser ce compte de service à accéder au projet, ajoutez les rôles suivants :
    1. Cliquez sur Sélectionner un rôle.
    2. Recherchez et sélectionnez Administrateur des objets Storage.
    3. Cliquez sur + Ajouter un autre rôle.
    4. Recherchez et sélectionnez Demandeur Cloud Run.
    5. Cliquez sur + Ajouter un autre rôle.
    6. Recherchez et sélectionnez Demandeur Cloud Functions.
  6. Cliquez sur Continuer.
  7. Cliquez sur OK.

Ces rôles sont requis pour :

  • Administrateur des objets de l'espace de stockage : écrire des journaux dans le bucket Cloud Storage et gérer les fichiers d'état
  • Demandeur Cloud Run : autorise Pub/Sub à appeler la fonction.
  • Demandeur Cloud Functions : autorise l'appel de fonctions.

Accorder des autorisations IAM sur le bucket Cloud Storage

  1. Accédez à Cloud Storage > Buckets.
  2. Cliquez sur le nom de votre bucket (netsuite-audit-logs).
  3. Accédez à l'onglet Autorisations.
  4. Cliquez sur Accorder l'accès.
  5. Fournissez les informations de configuration suivantes :
    • Ajouter des comptes principaux : saisissez l'adresse e-mail du compte de service (netsuite-audit-collector-sa@PROJECT_ID.iam.gserviceaccount.com).
    • Attribuer des rôles : sélectionnez Administrateur des objets Storage.
  6. Cliquez sur Enregistrer.

Créer un sujet Pub/Sub

Créez un sujet Pub/Sub dans lequel Cloud Scheduler publiera des messages et auquel la fonction Cloud Run s'abonnera.

  1. Dans la console GCP, accédez à Pub/Sub > Sujets.
  2. Cliquez sur Créer un sujet.
  3. Fournissez les informations de configuration suivantes :
    • ID du sujet : saisissez netsuite-audit-trigger.
    • Conservez les valeurs par défaut des autres paramètres.
  4. Cliquez sur Créer.

Créer une fonction Cloud Run pour collecter les journaux

La fonction Cloud Run sera déclenchée par les messages Pub/Sub de Cloud Scheduler pour récupérer les journaux d'audit de l'API REST Oracle NetSuite SuiteQL et les écrire dans Cloud Storage.

  1. Dans la console GCP, accédez à Cloud Run.
  2. Cliquez sur Créer un service.
  3. Sélectionnez Fonction (utilisez un éditeur de modification directe pour créer une fonction).
  4. Dans la section Configurer, fournissez les informations de configuration suivantes :

    Paramètre Valeur
    Nom du service netsuite-audit-collector
    Région Sélectionnez la région correspondant à votre bucket Cloud Storage (par exemple, us-central1).
    Durée d'exécution Sélectionnez Python 3.12 ou une version ultérieure.
  5. Dans la section Déclencheur (facultatif) :

    1. Cliquez sur + Ajouter un déclencheur.
    2. Sélectionnez Cloud Pub/Sub.
    3. Dans Sélectionner un sujet Cloud Pub/Sub, choisissez netsuite-audit-trigger.
    4. Cliquez sur Enregistrer.
  6. Dans la section Authentification :

    1. Sélectionnez Exiger l'authentification.
    2. Sélectionnez Identity and Access Management (IAM).
  7. Faites défiler la page vers le bas, puis développez Conteneurs, mise en réseau, sécurité.

  8. Accédez à l'onglet Sécurité :

    • Compte de service : sélectionnez netsuite-audit-collector-sa.
  9. Accédez à l'onglet Conteneurs :

    1. Cliquez sur Variables et secrets.
    2. Cliquez sur + Ajouter une variable pour chaque variable d'environnement :
    Nom de la variable Exemple de valeur Description
    GCS_BUCKET netsuite-audit-logs Nom du bucket Cloud Storage
    GCS_PREFIX netsuite-audit Préfixe des fichiers journaux
    STATE_KEY netsuite-audit-state.json Chemin d'état, en dehors du préfixe du journal
    NS_ACCOUNT_ID 123456 ID de compte NetSuite
    NS_CLIENT_ID your-client-id ID client OAuth 2.0
    NS_CERTIFICATE_ID your-certificate-id ID de certificat (kid) du mappage M2M
    NS_PRIVATE_KEY -----BEGIN EC PRIVATE KEY-----... Contenu de la clé privée ECDSA
    LOOKBACK_HOURS 24 Période d'analyse initiale
    PAGE_SIZE 1000 Enregistrements par page SuiteQL (1 000 max.)
    MAX_RECORDS 50000 Nombre maximal d'enregistrements par exécution
  10. Dans la section Variables et secrets, faites défiler la page jusqu'à Requêtes :

    • Délai avant expiration de la requête : saisissez 600 secondes (10 minutes).
  11. Accédez à l'onglet Paramètres :

    • Dans la section Ressources :
      • Mémoire : sélectionnez 512 Mio ou plus.
      • Processeur : sélectionnez 1.
  12. Dans la section Scaling de révision :

    • Nombre minimal d'instances : saisissez 0.
    • Nombre maximal d'instances : saisissez 100.
  13. Cliquez sur Créer.

  14. Attendez que le service soit créé (une à deux minutes).

  15. Une fois le service créé, l'éditeur de code intégré s'ouvre automatiquement.

Ajouter le code de la fonction

  1. Saisissez main dans le champ Point d'entrée.
  2. Dans l'éditeur de code intégré, créez deux fichiers :

    • Premier fichier : main.py:

      import functions_framework
      from google.cloud import storage
      import json
      import os
      import urllib3
      import urllib.parse
      import jwt
      import time
      import hashlib
      from datetime import datetime, timezone, timedelta
      
      http = urllib3.PoolManager(
          timeout=urllib3.Timeout(connect=10.0, read=60.0),
          retries=False,
      )
      
      storage_client = storage.Client()
      
      GCS_BUCKET = os.environ.get('GCS_BUCKET')
      GCS_PREFIX = os.environ.get('GCS_PREFIX', 'netsuite-audit')
      STATE_KEY = os.environ.get('STATE_KEY', 'netsuite-audit-state.json')
      NS_ACCOUNT_ID = os.environ.get('NS_ACCOUNT_ID')
      NS_CLIENT_ID = os.environ.get('NS_CLIENT_ID')
      NS_CERTIFICATE_ID = os.environ.get('NS_CERTIFICATE_ID')
      NS_PRIVATE_KEY = os.environ.get('NS_PRIVATE_KEY', '').replace('\\n', '\n')
      LOOKBACK_HOURS = int(os.environ.get('LOOKBACK_HOURS', '24'))
      PAGE_SIZE = int(os.environ.get('PAGE_SIZE', '1000'))
      MAX_RECORDS = int(os.environ.get('MAX_RECORDS', '50000'))
      
      # Neither SuiteQL query selects a unique row identifier, so the content hash
      # is the only identity available.
      ID_FIELDS = ()
      
      def event_keys(event):
          """Return every identity this event can be recognised by."""
          keys = {'sha256:' + hashlib.sha256(
              json.dumps(event, sort_keys=True, ensure_ascii=False).encode('utf-8')
          ).hexdigest()}
          for field in ID_FIELDS:
              value = event.get(field)
              if value:
                  keys.add(f'{field}:{value}')
          return keys
      
      @functions_framework.cloud_event
      def main(cloud_event):
          if not all([GCS_BUCKET, NS_ACCOUNT_ID, NS_CLIENT_ID, NS_CERTIFICATE_ID, NS_PRIVATE_KEY]):
              print('Error: Missing required environment variables')
              return
      
          try:
              bucket = storage_client.bucket(GCS_BUCKET)
              state = load_state(bucket)
              seen_keys = load_seen(bucket)
              now = datetime.now(timezone.utc)
      
              if isinstance(state, dict) and state.get('last_event_time'):
                  try:
                      last_val = state['last_event_time']
                      if last_val.endswith('Z'):
                          last_val = last_val[:-1] + '+00:00'
                      last_time = datetime.fromisoformat(last_val)
                      last_time = last_time - timedelta(minutes=2)
                  except Exception as e:
                      print(f"Warning: Could not parse last_event_time: {e}")
                      last_time = now - timedelta(hours=LOOKBACK_HOURS)
              else:
                  last_time = now - timedelta(hours=LOOKBACK_HOURS)
      
              print(f"Fetching logs from {last_time.isoformat()} to {now.isoformat()}")
      
              access_token = get_access_token()
      
              login_audits = fetch_login_audit(access_token, last_time, now)
              system_notes = fetch_system_notes(access_token, last_time, now)
      
              all_records = []
              for record in login_audits:
                  record['_netsuite_record_type'] = 'login_audit'
                  all_records.append(record)
              for record in system_notes:
                  record['_netsuite_record_type'] = 'system_note'
                  all_records.append(record)
      
              if not all_records:
                  print("No new records found.")
                  save_state(bucket, now.isoformat())
                  return
      
              # Drop the rows the overlap window re-read from the previous run
              fresh = [r for r in all_records if not (event_keys(r) & seen_keys)]
              all_keys = set()
              for record in all_records:
                  all_keys |= event_keys(record)
      
              if not fresh:
                  print("No new records found. Watermark left unchanged.")
                  return
      
              timestamp = now.strftime('%Y%m%d_%H%M%S')
              object_key = f"{GCS_PREFIX}/netsuite_audit_{timestamp}.ndjson"
              blob = bucket.blob(object_key)
      
              ndjson = '\n'.join(
                  [json.dumps(r, ensure_ascii=False, default=str) for r in fresh]
              ) + '\n'
              blob.upload_from_string(ndjson, content_type='application/x-ndjson')
      
              print(f"Wrote {len(fresh)} records to gs://{GCS_BUCKET}/{object_key}")
      
              # Remember every fetched row so the next overlap window skips it
              save_seen(bucket, all_keys)
      
              newest = find_newest_time(login_audits, system_notes)
              save_state(bucket, newest if newest else now.isoformat())
      
              print(f"Successfully processed {len(fresh)} records "
                      f"(login_audit: {len(login_audits)}, system_note: {len(system_notes)})")
      
          except Exception as e:
              print(f'Error processing logs: {str(e)}')
              raise
      
      def get_access_token():
          token_url = (
              f"https://{NS_ACCOUNT_ID}.suitetalk.api.netsuite.com"
              f"/services/rest/auth/oauth2/v1/token"
          )
      
          now = int(time.time())
          payload = {
              'iss': NS_CLIENT_ID,
              'scope': ['restlets', 'rest_webservices'],
              'aud': token_url,
              'iat': now,
              'exp': now + 3600,
          }
      
          client_assertion = jwt.encode(
              payload,
              NS_PRIVATE_KEY,
              algorithm='ES256',
              headers={'kid': NS_CERTIFICATE_ID, 'typ': 'JWT'},
          )
      
          body = urllib.parse.urlencode({
              'grant_type': 'client_credentials',
              'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
              'client_assertion': client_assertion,
          }).encode('utf-8')
      
          response = http.request(
              'POST', token_url,
              body=body,
              headers={'Content-Type': 'application/x-www-form-urlencoded'},
          )
      
          if response.status != 200:
              raise Exception(
                  f"Token request failed: {response.status} - "
                  f"{response.data.decode('utf-8')}"
              )
      
          data = json.loads(response.data.decode('utf-8'))
          access_token = data.get('access_token')
          if not access_token:
              raise Exception("No access_token in token response")
      
          print("Successfully obtained NetSuite OAuth 2.0 access token")
          return access_token
      
      def run_suiteql(access_token, query, page_size=1000, max_records=50000):
          base_url = (
              f"https://{NS_ACCOUNT_ID}.suitetalk.api.netsuite.com"
              f"/services/rest/query/v1/suiteql"
          )
      
          headers = {
              'Authorization': f'Bearer {access_token}',
              'Content-Type': 'application/json',
              'Prefer': 'transient',
          }
      
          all_items = []
          offset = 0
          backoff = 1.0
      
          while True:
              if len(all_items) >= max_records:
                  print(f"Reached max_records limit ({max_records})")
                  break
      
              url = f"{base_url}?limit={page_size}&offset={offset}"
              body = json.dumps({'q': query}).encode('utf-8')
      
              try:
                  response = http.request('POST', url, body=body, headers=headers)
      
                  if response.status == 429:
                      retry_after = int(response.headers.get('Retry-After', str(int(backoff))))
                      print(f"Rate limited (429). Retrying after {retry_after}s...")
                      time.sleep(retry_after)
                      backoff = min(backoff * 2, 60.0)
                      continue
      
                  backoff = 1.0
      
                  if response.status != 200:
                      print(f"SuiteQL error: {response.status} - "
                              f"{response.data.decode('utf-8')}")
                      break
      
                  data = json.loads(response.data.decode('utf-8'))
                  items = data.get('items', [])
      
                  if not items:
                      break
      
                  all_items.extend(items)
                  page_num = (offset // page_size) + 1
                  print(f"Page {page_num}: Retrieved {len(items)} records "
                          f"(total: {len(all_items)})")
      
                  has_more = data.get('hasMore', False)
                  if not has_more:
                      break
      
                  offset += len(items)
      
              except Exception as e:
                  print(f"Error executing SuiteQL query: {e}")
                  break
      
          return all_items
      
      def fetch_login_audit(access_token, start_time, end_time):
          start_str = start_time.strftime('%m/%d/%Y %H:%M:%S')
          end_str = end_time.strftime('%m/%d/%Y %H:%M:%S')
      
          query = (
              "SELECT "
              "LoginAudit.Date, "
              "LoginAudit.Status, "
              "LoginAudit.User, "
              "BUILTIN.DF(LoginAudit.User) AS Username, "
              "LoginAudit.EmailAddress, "
              "LoginAudit.Role, "
              "BUILTIN.DF(LoginAudit.Role) AS Rolename, "
              "LoginAudit.IPAddress, "
              "LoginAudit.UserAgent, "
              "LoginAudit.Detail, "
              "LoginAudit.oAuthAppName, "
              "LoginAudit.oAuthAccessTokenName, "
              "LoginAudit.requestUri "
              "FROM LoginAudit "
              f"WHERE LoginAudit.Date >= TO_DATE('{start_str}', 'MM/DD/YYYY HH24:MI:SS') "
              f"AND LoginAudit.Date <= TO_DATE('{end_str}', 'MM/DD/YYYY HH24:MI:SS') "
              "ORDER BY LoginAudit.Date ASC"
          )
      
          print("Fetching LoginAudit records...")
          return run_suiteql(access_token, query, PAGE_SIZE, MAX_RECORDS)
      
      def fetch_system_notes(access_token, start_time, end_time):
          start_str = start_time.strftime('%m/%d/%Y %H:%M:%S')
          end_str = end_time.strftime('%m/%d/%Y %H:%M:%S')
      
          query = (
              "SELECT "
              "SystemNote.Date, "
              "SystemNote.RecordTypeID, "
              "BUILTIN.DF(SystemNote.RecordTypeID) AS RecordType, "
              "SystemNote.RecordID, "
              "SystemNote.Field, "
              "SystemNote.OldValue, "
              "SystemNote.NewValue, "
              "SystemNote.Name AS EmployeeID, "
              "BUILTIN.DF(SystemNote.Name) AS EmployeeName, "
              "SystemNote.Role, "
              "BUILTIN.DF(SystemNote.Role) AS RoleName, "
              "SystemNote.Context, "
              "BUILTIN.DF(SystemNote.Context) AS ContextName "
              "FROM SystemNote "
              f"WHERE SystemNote.Date >= TO_DATE('{start_str}', 'MM/DD/YYYY HH24:MI:SS') "
              f"AND SystemNote.Date <= TO_DATE('{end_str}', 'MM/DD/YYYY HH24:MI:SS') "
              "ORDER BY SystemNote.Date ASC"
          )
      
          print("Fetching SystemNote records...")
          return run_suiteql(access_token, query, PAGE_SIZE, MAX_RECORDS)
      
      def find_newest_time(login_audits, system_notes):
          newest = None
          for r in login_audits:
              t = r.get('date')
              if t and (newest is None or t > newest):
                  newest = t
          for r in system_notes:
              t = r.get('date')
              if t and (newest is None or t > newest):
                  newest = t
          return newest
      
      def load_state(bucket):
          try:
              blob = bucket.blob(STATE_KEY)
              if blob.exists():
                  return json.loads(blob.download_as_text())
          except Exception as e:
              print(f"Warning: Could not load state: {e}")
              raise
          return {}
      
      def save_state(bucket, last_event_time_iso):
          try:
              state = {
                  'last_event_time': last_event_time_iso,
                  'last_run': datetime.now(timezone.utc).isoformat()
              }
              blob = bucket.blob(STATE_KEY)
              blob.upload_from_string(
                  json.dumps(state, indent=2),
                  content_type='application/json'
              )
              print(f"Saved state: last_event_time={last_event_time_iso}")
          except Exception as e:
              print(f"Warning: Could not save state: {e}")
              raise
      
      def load_seen(bucket):
          try:
              blob = bucket.blob(STATE_KEY + '.seen')
              if blob.exists():
                  return set(json.loads(blob.download_as_text()))
          except Exception as e:
              print(f"Warning: Could not load seen keys: {e}")
              raise
          return set()
      
      def save_seen(bucket, keys):
          try:
              blob = bucket.blob(STATE_KEY + '.seen')
              blob.upload_from_string(
                  json.dumps(sorted(keys)),
                  content_type='application/json'
              )
              print(f"Saved {len(keys)} event keys for deduplication")
          except Exception as e:
              print(f"Warning: Could not save seen keys: {e}")
              raise
      

    • Deuxième fichier : requirements.txt:

      functions-framework==3.*
      google-cloud-storage==2.*
      urllib3>=2.0.0
      PyJWT[crypto]>=2.8.0
      
  3. Cliquez sur Déployer pour enregistrer et déployer la fonction.

  4. Attendez la fin du déploiement (deux à trois minutes).

Créer une tâche Cloud Scheduler

Cloud Scheduler publiera des messages dans le sujet Pub/Sub à intervalles réguliers, ce qui déclenchera la fonction Cloud Run.

  1. Dans la console GCP, accédez à Cloud Scheduler.
  2. Cliquez sur Créer une tâche.
  3. Fournissez les informations de configuration suivantes :

    Paramètre Valeur
    Nom netsuite-audit-collector-hourly
    Région Sélectionnez la même région que la fonction Cloud Run.
    Fréquence 0 * * * * (toutes les heures, à l'heure pile)
    Fuseau horaire Sélectionnez un fuseau horaire (UTC recommandé)
    Type de cible Pub/Sub
    Sujet Sélectionner netsuite-audit-trigger
    Corps du message {} (objet JSON vide)
  4. Cliquez sur Créer.

Options de fréquence de programmation

Choisissez la fréquence en fonction du volume de journaux et des exigences de latence :

Fréquence Expression Cron Cas d'utilisation
Toutes les 15 minutes */15 * * * * Volume élevé, faible latence
Toutes les heures 0 * * * * Standard (recommandé)
Toutes les 6 heures 0 */6 * * * Traitement par lot de faible volume
Tous les jours 0 0 * * * Collecte des données historiques

Tester l'intégration

  1. Dans la console Cloud Scheduler, recherchez votre job (netsuite-audit-collector-hourly).
  2. Cliquez sur Forcer l'exécution pour déclencher le job manuellement.
  3. Patientez pendant quelques secondes.
  4. Accédez à Cloud Run > Services.
  5. Cliquez sur netsuite-audit-collector.
  6. Cliquez sur l'onglet Journaux.
  7. Vérifiez que la fonction a bien été exécutée. Par exemple :

    Fetching logs from YYYY-MM-DDTHH:MM:SS+00:00 to YYYY-MM-DDTHH:MM:SS+00:00
    Successfully obtained NetSuite OAuth 2.0 access token
    Fetching LoginAudit records...
    Page 1: Retrieved X records (total: X)
    Fetching SystemNote records...
    Page 1: Retrieved X records (total: X)
    Wrote X records to gs://netsuite-audit-logs/netsuite-audit/netsuite_audit_YYYYMMDD_HHMMSS.ndjson
    Successfully processed X records (login_audit: X, system_note: X)
    
  8. Accédez à Cloud Storage > Buckets.

  9. Cliquez sur netsuite-audit-logs.

  10. Accédez au dossier netsuite-audit/.

  11. Vérifiez qu'un nouveau fichier .ndjson a été créé avec le code temporel actuel.

Si des erreurs s'affichent dans les journaux ou si un type d'enregistrement est manquant dans la sortie :

  • HTTP 401 : vérifiez que les variables d'environnement NS_CLIENT_ID, NS_CERTIFICATE_ID et NS_PRIVATE_KEY sont correctes.
  • HTTP 403 : vérifiez que le rôle NetSuite dispose des autorisations de connexion à l'aide des jetons d'accès OAuth 2.0 (complet), des services Web REST (complet) et de la piste d'audit de connexion (complet).
  • Aucun enregistrement system_note dans le résultat : le rôle ne dispose pas de l'autorisation "System Notes for Analytics and REST (View)". La requête SuiteQL aboutit toujours et ne renvoie que les notes concernant les modifications apportées par l'utilisateur de l'intégration. Par conséquent, aucune erreur n'est consignée dans les journaux.
  • HTTP 429 : limitation du débit. La fonction effectue automatiquement une nouvelle tentative avec un intervalle exponentiel entre les tentatives.
  • Variables d'environnement manquantes : vérifiez que toutes les variables requises sont définies dans la configuration de la fonction Cloud Run.

Récupérer le compte de service Google SecOps

  1. Accédez à Paramètres > Gestion des données > Flux. Si vous utilisez une plate-forme SIEM uniquement, accédez à Paramètres > Flux.
  2. Cliquez sur Ajouter un flux d'actualités.
  3. Cliquez sur Configurer un flux unique.
  4. Dans le champ Nom du flux, saisissez un nom pour le flux (par exemple, NetSuite Audit Logs).
  5. Sélectionnez Google Cloud Storage V2 comme Type de source.
  6. Sélectionnez Oracle NetSuite comme Type de journal.
  7. Cliquez sur Get Service Account (Obtenir un compte de service).
  8. Une adresse e-mail de compte de service unique s'affiche, par exemple :

    chronicle-12345678@chronicle-gcp-prod.iam.gserviceaccount.com
    
  9. Copiez cette adresse e-mail pour l'utiliser à l'étape suivante.

Accorder des autorisations IAM au compte de service Google SecOps

Le compte de service Google SecOps a besoin de deux rôles sur votre bucket Cloud Storage : Lecteur des objets Storage pour lire les objets de journal et un rôle au niveau du bucket pour lire les métadonnées du bucket.

  1. Accédez à Cloud Storage > Buckets.
  2. Cliquez sur netsuite-audit-logs.
  3. Accédez à l'onglet Autorisations.
  4. Cliquez sur Accorder l'accès.
  5. Fournissez les informations de configuration suivantes :
    • Ajouter des comptes principaux : collez l'adresse e-mail du compte de service Google SecOps.
    • Attribuer des rôles : sélectionnez les deux rôles suivants :
      • Lecteur des objets de l'espace de stockage : lit les objets de journal.
      • Lecteur des anciens buckets Storage : lit les métadonnées du bucket. Si vous avez sélectionné l'option de suppression Supprimer les fichiers transférés et les répertoires vides, sélectionnez plutôt Rédacteur d'ancien bucket Storage, qui accorde également l'autorisation de suppression.
  6. Cliquez sur Enregistrer.

Terminer la configuration du flux Google SecOps

  1. Revenez à l'assistant de création de flux Google SecOps.
  2. Cliquez sur Suivant.
  3. Spécifiez les valeurs des paramètres d'entrée suivants :

    • URL du bucket de stockage : saisissez l'URI du bucket Cloud Storage avec le chemin d'accès du préfixe :

      gs://netsuite-audit-logs/netsuite-audit/
      
    • Option de suppression de la source : sélectionnez l'option de suppression de votre choix :

      • Ne jamais supprimer les fichiers : ne supprime jamais les fichiers de la source (recommandé pour les tests).
      • Supprimer les fichiers transférés et les répertoires vides : supprimez les fichiers et les répertoires vides de la source une fois la récupération terminée.
    • Âge maximal du fichier : incluez les fichiers modifiés au cours d'un certain nombre de jours (180 jours par défaut).

    • Espace de noms de l'élément : espace de noms de l'élément

    • Étiquettes d'ingestion : étiquette à appliquer aux événements de ce flux.

  4. Cliquez sur Suivant.

  5. Vérifiez la configuration de votre nouveau flux d'actualités sur l'écran Finaliser, puis cliquez sur Envoyer.

Limites de débit de l'API NetSuite

L'API REST NetSuite est soumise aux limites de gouvernance suivantes :

Type de limite Valeur
Limite de simultanéité 10 requêtes simultanées par intégration
Résultats de la requête SuiteQL par page 1 000 lignes maximum
Nombre total de résultats SuiteQL 100 000 lignes par requête

La fonction Cloud Run gère automatiquement la limitation du débit avec un intervalle exponentiel entre les tentatives.

Table de mappage UDM

Champ du journal Mappage UDM Logique
Login_Audit_Trail_Access_Token_Name_label additional.fields Fusionné
Login_Audit_Trail_Detail_label additional.fields Fusionné
additional_preview additional.fields Fusionné
detail_label additional.fields Fusionné
role_change_label additional.fields Fusionné
sec_challenge_label additional.fields Fusionné
oauthaccesstokenname extensions.auth.auth_details Mappage direct
has_principal extensions.auth.type Mappé : true → AUTHTYPE_UNSPECIFIED
column7 metadata.description Mappage direct
msg metadata.description Mappage direct
Role_Change_Date metadata.event_timestamp Analysé en tant que M/d/yyyy h:mm a
date metadata.event_timestamp Analysé en tant que M/d/yyyy h:mm:ss a
result.Login_Audit_Trail_Date metadata.event_timestamp Analysé en tant que LDAP
ts metadata.event_timestamp Analysé en tant que MMM d HH:mm:ss
has_principal metadata.event_type Mappé : true → USER_LOGIN, true → STATUS_UPDATE
has_principal_user metadata.event_type Mappé : true → USER_UNCATEGORIZED
column1 metadata.product_event_type Mappage direct
log_type metadata.product_event_type Mappage direct
useragent network.http.parsed_user_agent Mappage direct
Login_Audit_Trail_User_Agent network.http.user_agent Mappage direct
result.Login_Audit_Trail_User_Agent network.http.user_agent Mappage direct
useragent network.http.user_agent Mappage direct
Login_Audit_Trail_Application principal.application Mappage direct
column10 principal.application Mappage direct
oauthappname principal.application Mappage direct
result.host principal.asset.hostname Mappage direct
column3 principal.asset.ip Fusionné
ipaddress principal.asset.ip Fusionné
result.Login_Audit_Trail_IP_Address principal.asset.ip Mappage direct
src_ip principal.asset.ip Fusionné
result.host principal.hostname Mappage direct
column3 principal.ip Fusionné
ipaddress principal.ip Fusionné
result.Login_Audit_Trail_IP_Address principal.ip Mappage direct
src_ip principal.ip Fusionné
Login_Audit_Trail_Role_label principal.user.attribute.labels Fusionné
login_audits_role_label principal.user.attribute.labels Fusionné
supervisor_label principal.user.attribute.labels Fusionné
role principal.user.attribute.roles Fusionné
Email principal.user.email_addresses Mappé : ^.+@.+$ → Email
column2 principal.user.email_addresses Fusionné
emailaddress principal.user.email_addresses Mappé : ^.+@.+$ → emailaddress
result.Login_Audit_Trail_Email_Address principal.user.email_addresses Fusionné
Job_Title principal.user.title Mappage direct
Name principal.user.user_display_name Mappage direct
Login_Audit_Trail_User principal.user.userid Mappage direct
result.Login_Audit_Trail_User principal.user.userid Mappage direct
user principal.user.userid Mappage direct
Login_Audit_Trail_Security_Challenge_label security_result.about.resource.attribute.labels Fusionné
security_result_action security_result.action Fusionné
Login_Audit_Trail_Status security_result.summary Mappage direct
result.Login_Audit_Trail_Status security_result.summary Mappage direct
api_type_label target.resource.attribute.labels Fusionné
Login_Audit_Trail_Request_URI target.url Mappage direct
result.Login_Audit_Trail_Request_URI target.url Mappage direct
N/A extensions.auth.type Constante : AUTHTYPE_UNSPECIFIED
N/A metadata.event_type Constante : USER_LOGIN
N/A network.http.parsed_user_agent Constante : parseduseragent

Journal des modifications

Afficher le journal des modifications de cet analyseur

Vous avez encore besoin d'aide ? Obtenez des réponses de membres de la communauté et de professionnels Google SecOps.