For lang tid siden fik jeg fat i domænet wishlist.dk til min ønskeseddelservice ved at klage over, at domænet så ud til at være registreret alene med henblik på at sælge det videre. Det må man nemlig ikke gøre med .dk-domæner.
Noget tid efter blev jeg kontaktet af en, der hedder Jakob, der havde læst mit indlæg og havde brugt samme metode til at få fat i et domænenavn, han havde drømt om.
Et nyt dataprojekt
Den oplevelse gav mig den idé, at jeg kunne tjene det godes sag, hvis jeg på en eller anden måde kunne lave en liste over domæner registreret med henblik på videresalg og offentliggøre listen. Jeg tænkte, jeg kunne:
Hente en liste over danske domænenavne
Lave en robot, der henter noget data om hvert domænenavn og måske tager et skærmbillede af siden
Kategorisere et par tusind domæner efter hvad de i mine øjne bliver brugt til
Bruge noget smart maskinlærings-AI-hokus-pokus til at kategorisere de resterende domænenavne
WHOIS-databasen § 18. Administrator skal oprette og vedligeholde en database indeholdende oplysninger om registranternes navn, adresse og telefonnummer. Stk. 2. Administrator skal sikre, at oplysningerne nævnt i stk. 1 er retvisende, opdaterede og offentligt tilgængelige.
Jeg skrev til DK Hostmaster og fik nej. Jeg skrev til Dansk Internet Forum og fik intet svar. Jeg spurgte Klagenævnet for Domænenavne om jeg kunne klage over DK Hostmaster og fik nej. Så prøvede jeg at få aktindsigt i oplysningerne:
Det gik heller ikke så godt.
De oplysninger, jeg troede var offentlige, bliver åbenbart holdt tæt ind til kroppen. For at beskytte mig (og dig) mod spam!(?)
Efter afslaget klagede jeg til Erhvervsstyrelsen (nej!), forsøgte med aktindsigt hos Det Kongelige Bibliotek, som jeg fandt ud af var i besiddelse af listen (nej!), og skrev også til et par legitime domæneregistrationssælgere, som får listen af DK Hostmaster, om de var indstillet på at dele (nej!).
Jeg havde været ihærdig, men spildt så meget af min egen og andres tid, at jeg besluttede mig for at ændre kurs.
På datahøst
Det gik op for mig, at jeg ikke behøvede at kende alle .dk-domæner til mit hobbyprojekt. Nogle hundrede tusinde eller en million ville sikkert være rigeligt (der er registreret ca. 1,4 millioner i skrivende stund).
Jeg fandt en lang liste over danske ord og lavede en lille robot til at slå ordene op som domæner hos DK Hostmaster.
Jeg fandt en lignende liste med de mest brugte engelske ord og slog ordene op.
Jeg søgte på ord fra ordlisterne hos en kendt søgemaskine og fik søgeresultater tilbage med danske domænenavne.
Jeg fik API-adgang til CVR-registeret (som sjovt nok er rigtigt offentligt) og hentede domænenavne for alle danske virksomheder.
Disse metoder gav mig de første ca. 350.000 .dk-domæner og gav mig lejlighed til at skrive en masse små Python-scripts til at automatisere det meste.
Guldminen
Så fandt jeg guldminen. Apple, Google, Facebook, Cloudflare og andre hæderkronede virksomheder har et lidt andet syn på sikkerhed end DK Hostmaster, der jo gerne vil hemmeligholde danske domænenavne for at forhindre spam.
For at bekæmpe snyd med certifikater til sikker kommunikation på nettet (hængelåsen i browseren, du ved), logger de udstedelsen af certikater og har indtil videre logget lige under 8,5 milliard certifikater. I stedet for hemmeligholdelse: transparens.
Aha! Så når en ejer af et .dk-domæne får udstedt et certifikat til sin fine hjemmeside, bliver udstedelsen logget.
Jeg satte et smart program, der hedder Axeman, til at begynde at downloade logs, filtrerede for .dk-domænenavne og begyndte at tilføje dem til min database.
Det går langsomt, men det giver resultater.
På https://wallnot.dk/dotdk/ har jeg nu samlet 639.485 .dk-domænenavne til fri download og videredistribution. Og der er mange, mange flere på vej.
Tag den!
…Men hvad med projektet?
Jeg har faktisk fået kategoriseret nogle domænenavne efter hvordan jeg vurderer, de bliver brugt. Og taget en masse skærmbilleder, som jeg håber jeg kan bruge noget maskinlæring på. Men listen over .dk-domæner, jeg mener er registreret med henblik på videresalg, har lange udsigter.
Til gengæld håber jeg at listen over .dk-domæner i sig selv kan bruges af andre til et eller andet. Vi får se.
I Nordnets porteføljerapport og kontooversigter, kan man se nogle flotte grafer over udviklingen i ens portefølje og/eller konti.
Et eksempel på porteføljeudvikling hos Nordnet. I dette tilfælde en nedadgående graf.
Jeg blev spurgt, om jeg ikke ville hjælpe med, hvordan man kan hive den slags ud af Nordnet til eget brug. Det er lidt nemmere til et hurtigt overblik, end hvis man skal hive alle sine transaktioner og kurser ud i Excel og tilrettelægge data der.
# This program provides two examples of logging into a Nordnet account
# and extracting account performance as json data. One is based on standard
# intervals. The other is based on a user-defined interval.
# Storing and processing of returned data is left to you.
import requests
from nordnet_configuration import accounts
from nordnet_login import nordnet_login
session = requests.Session()
session = nordnet_login(session)
accounts_list = [value for value in accounts.values()]
### Nordnet standard intervals (one month, three months, six months, ytd, 1 year, 3 years and 5 years)
accounts_string = ','.join(accounts_list)
url = 'https://www.nordnet.dk/api/2/accounts/' + accounts_string + '/returns/performance'
period_options = ['m1','m3','m6','ty','y1','y3','y5']
standard_graph_data = {}
for period in period_options:
params = {
'period': period,
'start_at_zero': False
}
graph = session.get(url, params=params)
standard_graph_data[period] = graph.json()
# Store and process graph_data as needed
### User defined date intervals
start_date = '2019-01-30' # Edit as needed
end_date = '2019-05-14' # Edit as needed
user_defined_graph_data = {}
for account in accounts_list:
url = 'https://www.nordnet.dk/api/2/accounts/' + account + '/returns/performance'
params = {
'from': start_date,
'to': end_date
}
user_defined_graph = session.get(url, params=params)
user_defined_graph_data[account] = user_defined_graph.json()
# Store and process user_defined_graph_data as needed
Jeg brugte https://github.com/dk/Net-MitDK til at forstå metodikken og Fiddler til at overvåge trafikken til og fra https://mit.dk og aflure sidens API.
De to hovedkomponenter i programmet er a) et program til at gennemføre første login på mit.dk i en browser med NemID/MitID og b) et program til at forny adgangstokens til siden, forespørge API’et om ny post og sende e-mails af sted.
Program til at gennemføre første login på mit.dk i en browser med NemID/MitId
# Logs in to mit.dk og saves tokens needed for further requests.
# Method from https://github.com/dk/Net-MitDK/. Thank you.
from seleniumwire import webdriver
import requests
from bs4 import BeautifulSoup
import http.cookies
import gzip
import json
import base64
from hashlib import sha256
import string
import secrets
from mit_dk_configuration import tokens_filename
def random_string(size):
letters = string.ascii_lowercase+string.ascii_uppercase+string.digits+string.punctuation+string.whitespace
random_string = ''.join(secrets.choice(letters) for i in range(size))
encoded_string = random_string.encode(encoding="ascii")
url_safe_string = base64.urlsafe_b64encode(encoded_string).decode()
url_safe_string_no_padding = url_safe_string.replace('=','')
return url_safe_string_no_padding
def save_tokens(response):
with open(tokens_filename, "wt", encoding="utf8") as token_file:
token_file.write(response)
state = random_string(23)
nonce = random_string(93)
code_verifier = random_string(93)
code_challenge = base64.urlsafe_b64encode(sha256(code_verifier.encode('ascii')).digest()).decode().replace('=','')
login_url = 'https://gateway.mit.dk/view/client/authorization/login?client_id=view-client-id-mobile-prod-1-id&response_type=code&scope=openid&state=' + state + '&code_challenge=' + code_challenge + '&code_challenge_method=S256&response_mode=query&nonce=' + nonce + '&redirect_uri=com.netcompany.mitdk://nem-callback&deviceName=digitalpost-utilities&deviceId=pc&lang=en_US'
options = webdriver.ChromeOptions()
options.add_argument("--log-level=3")
driver = webdriver.Chrome(chrome_options=options)
login = driver.get(login_url)
print("Opening browser window. Log in to mit.dk using MitID or NemID in the browser.")
print("When you see a blank page in your browser at https://nemlog-in.mitid.dk/LoginOption.aspx, you're finished.")
input("Press ENTER once you're finished.")
session = requests.Session()
for request in driver.requests:
session.cookies.set('cookiecheck', 'Test', domain='nemlog-in.mitid.dk')
session.cookies.set('loginMethod', 'noeglekort', domain='nemlog-in.mitid.dk')
for request in driver.requests:
if '/api/mailboxes' in request.url and request.method == 'GET' and request.response.status_code == 200:
cookies = request.headers['Cookie'].split("; ")
for cookie in cookies:
if 'LoggedInBorgerDk' in cookie or 'CorrelationId' in cookie:
key_value = cookie.split('=')
session.cookies.set(key_value[0], key_value[1], domain='.post.borger.dk')
if request.response:
headers_string = str(request.response.headers)
headers_list = headers_string.split('\n')
for header in headers_list:
if 'set-cookie' in header:
cookie_string = header.replace('set-cookie: ','')
cookie = http.cookies.BaseCookie(cookie_string)
for key in cookie.keys():
# Requests is picky about dashes in cookie expiration dates. Fix.
if 'expires' in cookie[key]:
expiry = cookie[key]['expires']
if expiry:
expiry_list = list(expiry)
expiry_list[7] = '-'
expiry_list[11] = '-'
cookie[key]['expires'] = ''.join(expiry_list)
session.cookies.update(cookie)
if request.method == 'POST' and request.url == 'https://nemlog-in.mitid.dk/LoginOption.aspx' and request.response.status_code == 200:
if request.response.headers['content-encoding'] == 'gzip':
response = gzip.decompress(request.response.body).decode()
else:
response = request.response.body.decode()
soup = BeautifulSoup(response, "html.parser")
input = soup.find_all('input', {"name":"SAMLResponse"})
samlresponse = input[0]["value"]
driver.close()
request_code_part_one = session.post('https://gateway.digitalpost.dk/auth/s9/nemlogin/ssoack', data={'SAMLResponse': samlresponse}, allow_redirects=False)
request_code_part_one_redirect_location = request_code_part_one.headers['Location']
request_code_part_two = session.get(request_code_part_one_redirect_location, allow_redirects=False)
request_code_part_two_redirect_location = request_code_part_two.headers['Location']
request_code_part_three = session.get(request_code_part_two_redirect_location, allow_redirects=False)
request_code_part_three_redirect_location = request_code_part_three.headers['Location']
code_start = request_code_part_three_redirect_location.index('code=') + 5
code_end = request_code_part_three_redirect_location.index('&', code_start)
code = request_code_part_three_redirect_location[code_start:code_end]
redirect_url = 'com.netcompany.mitdk://nem-callback'
token_url = 'https://gateway.mit.dk/view/client/authorization/token?grant_type=authorization_code&redirect_uri=' + redirect_url + '&client_id=view-client-id-mobile-prod-1-id&code=' + code + '&code_verifier=' + code_verifier
request_tokens = session.post(token_url)
save_tokens(request_tokens.text)
print('Login to mit.dk went fine.')
print(f'Tokens saved to {tokens_filename}.')
Program til at forny adgangstokens til siden, forespørge API’et om ny post og sende e-mails af sted
# Sends unread messages from mit.dk to an e-mail.
import requests
import json
import smtplib # Sending e-mails
from email.mime.multipart import MIMEMultipart # Creating multipart e-mails
from email.mime.text import MIMEText # Attaching text to e-mails
from email.mime.application import MIMEApplication # Attaching files to e-mails
from email.utils import formataddr # Used for correct encoding of senders with special characters in name (e.g. Københavns Kommune)
from mit_dk_configuration import email_data, tokens_filename
base_url = 'https://gateway.mit.dk/view/client/'
session = requests.Session()
def open_tokens():
try:
with open(tokens_filename, "r", encoding="utf8") as token_file:
tokens = json.load(token_file)
return tokens
except:
return print('Unable to open and parse token file. Did you run mit_dk_first_login.py?')
def revoke_old_tokens(mitdkToken, ngdpToken, dppRefreshToken, ngdpRefreshToken):
endpoint = 'authorization/revoke?client_id=view-client-id-mobile-prod-1-id'
json_data = {
'dpp': {
'token': mitdkToken,
'token_type_hint': 'access_token'
},
'ngdp': {
'token': ngdpToken,
'token_type_hint': 'access_token'
},
}
revoke_access_tokens = session.post(base_url + endpoint, json=json_data)
if not revoke_access_tokens.status_code == 200:
print("Something went wrong when trying to revoke old access tokens. Here is the response:")
print(revoke_access_tokens.text)
json_data = {
'dpp': {
'refresh_token': dppRefreshToken,
'token_type_hint': 'refresh_token'
},
'ngdp': {
'refresh_token': ngdpRefreshToken,
'token_type_hint': 'refresh_token'
},
}
revoke_refresh_tokens = session.post(base_url + endpoint, json=json_data)
if not revoke_refresh_tokens.status_code == 200:
print("Something went wrong when trying to revoke old refresh tokens. Here is the response:")
print(revoke_refresh_tokens.text)
def refresh_and_save_tokens(dppRefreshToken, ngdpRefreshToken):
endpoint = 'authorization/refresh?client_id=view-client-id-mobile-prod-1-id'
json_data = {
'dppRefreshToken': dppRefreshToken,
'ngdpRefreshToken': ngdpRefreshToken,
}
refresh = session.post(base_url + endpoint, json=json_data)
if not refresh.status_code == 200:
print("Something went wrong trying to fetch new tokens.")
refresh_json = refresh.json()
if 'code' in refresh_json:
print("Something went wrong trying to fetch new tokens. Here's the response:")
print(refresh_json)
return False
else:
with open(tokens_filename, "wt", encoding="utf8") as token_file:
token_file.write(refresh.text)
return refresh_json
def get_fresh_tokens_and_revoke_old_tokens():
tokens = open_tokens()
try:
if 'dpp' in tokens:
dppRefreshToken = tokens['dpp']['refresh_token']
mitdkToken = tokens['dpp']['access_token']
else:
dppRefreshToken = tokens['refresh_token']
mitdkToken = tokens['access_token']
ngdpRefreshToken = tokens['ngdp']['refresh_token']
ngdpToken = tokens['ngdp']['access_token']
fresh_tokens = refresh_and_save_tokens(dppRefreshToken, ngdpRefreshToken)
if fresh_tokens:
revoke_old_tokens(mitdkToken, ngdpToken, dppRefreshToken, ngdpRefreshToken)
return fresh_tokens
except Exception as error:
print(error)
print('Unable to find tokens in token file. Try running mit_dk_first_login.py again.')
def get_simple_endpoint(endpoint):
response = session.get(base_url + endpoint)
return response.json()
def get_inbox_folders_and_build_query(mailbox_ids):
endpoint = 'folders/query'
json_data = {
'mailboxes': {}
}
for mailbox in mailbox_ids:
json_data['mailboxes'][mailbox['dataSource']] = mailbox['mailboxId']
response = session.post(base_url + endpoint, json=json_data)
try:
response_json = response.json()
except:
print('Unable to convert response to json. Here is the response:')
print(response.text)
folders = []
for folder in response_json['folders']['INBOX']:
folder_info = {
'dataSource': folder['dataSource'],
'foldersId': [folder['id']],
'mailboxId': folder['mailboxId'],
'startIndex': 0
}
folders.append(folder_info)
return folders
def get_messages(folders):
endpoint = 'messages/query'
json_data = {
'any': [],
'folders': folders,
'size': 20,
'sortFields': ['receivedDateTime:DESC']
}
response = session.post(base_url + endpoint, json=json_data)
return response.json()
def get_content(message):
content = []
endpoint = message['dataSource'] + '/mailboxes/' + message['mailboxId'] + '/messages/' + message['id']
for document in message['documents']:
doc_url = '/documents/' + document['id']
for file in document['files']:
encoding_format = file['encodingFormat']
file_name = file['filename']
file_url = '/files/' + file['id'] + '/content'
file_content = session.get(base_url + endpoint + doc_url + file_url)
content.append({
'file_name': file_name,
'encoding_format': encoding_format,
'file_content': file_content
})
return content
def mark_as_read(message):
endpoint = message['dataSource'] + '/mailboxes/' + message['mailboxId'] + '/messages/' + message['id']
session.headers['If-Match'] = str(message['version'])
json_data = {
'read': True
}
mark_as_read = session.patch(base_url + endpoint, json=json_data)
mailserver_connect = False
tokens = get_fresh_tokens_and_revoke_old_tokens()
if tokens:
session.headers['mitdkToken'] = tokens['dpp']['access_token']
session.headers['ngdpToken'] = tokens['ngdp']['access_token']
session.headers['platform'] = 'web'
mailboxes = get_simple_endpoint('mailboxes')
mailbox_ids = []
for mailboxes in mailboxes['groupedMailboxes']:
for mailbox in mailboxes['mailboxes']:
mailbox_info = {
'dataSource': mailbox['dataSource'],
'mailboxId': mailbox['id']
}
mailbox_ids.append(mailbox_info)
folders = get_inbox_folders_and_build_query(mailbox_ids)
messages = get_messages(folders)
for message in messages['results']:
if message['read'] == False:
if mailserver_connect == False:
server = smtplib.SMTP(email_data['emailserver'], email_data['emailserverport'])
server.ehlo()
server.starttls()
server.login(email_data['emailusername'], email_data['emailpassword'])
mailserver_connect = True
label = message['label']
sender = message['sender']['label']
message_content = get_content(message)
msg = MIMEMultipart('alternative')
msg['From'] = formataddr((sender, email_data['emailfrom']))
msg['To'] = email_data['emailto']
msg['Subject'] = "mit.dk: " + label
for content in message_content:
if content['encoding_format'] == 'text/plain':
body = content['file_content'].text
msg.attach(MIMEText(body, 'plain'))
part = MIMEApplication(content['file_content'].content)
part.add_header('Content-Disposition', 'attachment', filename=content['file_name'])
msg.attach(part)
elif content['encoding_format'] == 'text/html':
body = content['file_content'].text
msg.attach(MIMEText(body, 'html'))
part = MIMEApplication(content['file_content'].content)
part.add_header('Content-Disposition', 'attachment', filename=content['file_name'])
msg.attach(part)
elif content['encoding_format'] == 'application/pdf':
part = MIMEApplication(content['file_content'].content)
part.add_header('Content-Disposition', 'attachment', filename=content['file_name'])
msg.attach(part)
else:
encoding_format = content['encoding_format']
print(f'Ny filtype {encoding_format}')
part = MIMEApplication(content['file_content'].content)
part.add_header('Content-Disposition', 'attachment', filename=content['file_name'])
msg.attach(part)
print(f'Sender en mail fra mit.dk fra {sender} med emnet {label}')
server.sendmail(email_data['emailfrom'], email_data['emailto'], msg.as_string())
mark_as_read(message)
if mailserver_connect:
server.quit()
Siden jeg fandt ud af at hente elforbrugsdata fra Ørsted har jeg fået nyt elselskab og portalen eloverblik.dk, som omfatter alle elkunder i Danmark, har fået et API. Du kan finde dokumentation til API’et her: https://api.eloverblik.dk/CustomerApi/swagger/index.html.
Jeg er først lige kommet i gang, men her er et program, der beder om et token til at hente data fra API’et og derefter henter data om en måler, forbrugsdata og prisdata. Indsæt dit token fra eloverblik.dk i token-variablen øverst for at bruge programmet:
# https://api.eloverblik.dk/CustomerApi/swagger/index.html
import requests
token = ''
# Get data access token for subsequent requests
get_data_access_token_url = 'https://api.eloverblik.dk/CustomerApi/api/token'
headers = {
'accept': 'application/json',
'Authorization': 'Bearer ' + token,
}
response = requests.get(get_data_access_token_url, headers=headers)
data_access_token = response.json()['result']
# Get id of first meter - edit if you have more than one meter
metering_points_url = 'https://api.eloverblik.dk/CustomerApi/api/meteringpoints/meteringpoints'
headers = {
'accept': 'application/json',
'Authorization': 'Bearer ' + data_access_token,
}
meters = requests.get(metering_points_url, headers=headers)
first_meter = meters.json()['result'][0]['meteringPointId']
#Try to get data
meter_data = 'https://api.eloverblik.dk/CustomerApi/api/meterdata/gettimeseries/'
timeseries_data = {
'dateFrom': '2021-01-01',
'dateTo': '2021-01-31',
'aggregation': 'Actual'
}
meter_data_url = meter_data + timeseries_data['dateFrom'] + '/' + timeseries_data['dateTo'] + '/' + timeseries_data['aggregation']
meter_json = {
"meteringPoints": {
"meteringPoint": [
first_meter
]
}
}
meter_data_request = requests.post(meter_data_url, headers=headers, json=meter_json)
#Charges
charges_data = 'https://api.eloverblik.dk/CustomerApi/api/meteringpoints/meteringpoint/getcharges'
charges_data_request = requests.post(charges_data, headers=headers, json=meter_json)
breakpoint()
En restaurant, jeg gerne vil prøve, er fuldstændig booket op og har endnu ikke åbnet op for reservationer i april. Hvordan kan jeg komme først til fadet?
Jeg besøgte reservationssystemet og iagttog hvordan det interne API spurgte om ledige borde.
Dette billede viser forespørgslen:
Forespørgslen om ledige borde til 2 personer
Dette billede viser svaret fra API’et:
Svar fra API’et: Ingen ledige borde (hvilket billedet også viser)
For april så svaret sådan her ud:
Hverken ledige eller optagede borde i april.
API’et svarer altså med en tom ‘data’-nøgle, når der ikke er åbnet for reservationer endnu.
Jeg skrev et lille program, som jeg har sat til at køre hvert 5. minut, for at tjekke om jeg kan komme til at reservere. Programmet tjekker, om der er kommet noget indhold i ‘data’-nøglen i svaret fra API’et. Hvis der er, sender det mig en besked om, at jeg godt kan komme i gang med at reservere bord.
Mit lille program, der gerne skulle give mig en fordel i kampen.
Næste skridt kunne være at udvide programmet, sådan det også reserverer bordet for mig. Men i første omgang prøver jeg at gøre den del af arbejdet selv.
Peters idé er sjov, synes jeg, så jeg er så småt begyndt at bygge et eller andet, der monitorerer hvordan antallet af underskrifter på borgerforslag udvikler sig over tid.
Så nu tygger min webserver sig igennem nedenstående script hvert 10. minut og gemmer det aktuelle antal underskrifter på hvert borgerforslag. Når der er gået nogle uger, vil jeg se om jeg kan lave nogle interessante visualiseringer af data.
import requests
from datetime import datetime
import locale
import psycopg2
from psycopg2 import Error
### PREPARATION ###
# Locale is set to Danish to be able to parse dates from Borgerforslag
locale.setlocale(locale.LC_TIME, ('da_DK', 'UTF-8'))
# API url and request parameters
url = 'https://www.borgerforslag.dk/api/proposals/search'
suggestions_per_request = 300
params_json = {
"filter": "active",
"sortOrder": "NewestFirst",
"searchQuery":"",
"pageNumber":0,
"pageSize": suggestions_per_request
}
# Connect to database
try:
connection = psycopg2.connect(user = "",
password = "",
host = "",
port = "",
database = "")
cursor = connection.cursor()
except (Exception, psycopg2.Error) as error:
print ("Error while connecting to PostgreSQL", error)
now = datetime.utcnow()
# Insert into database function
def insert_suggestion_and_votes(connection, suggestion):
with connection:
with connection.cursor() as cur:
try:
# See if suggestion already exists
sql = '''SELECT * FROM borgerforslag_suggestion WHERE unique_id = %s'''
cur.execute(sql, (suggestion['externalId'],))
suggestion_records = cur.fetchone()
# If not, add suggestion
if not suggestion_records:
suggestion_data = (suggestion['externalId'],suggestion['title'],suggestion['date'],suggestion['url'],suggestion['status'])
sql = '''INSERT INTO borgerforslag_suggestion(unique_id,title,suggested_date,url,status) VALUES(%s,%s,%s,%s,%s) RETURNING id'''
cur.execute(sql, suggestion_data)
id = cur.fetchone()[0]
# If yes, get id
else:
id = suggestion_records[0]
# Add votes
sql = '''INSERT INTO borgerforslag_vote(suggestion_id,timestamp,votes)
VALUES(%s,%s,%s)'''
cur.execute(sql, (id,now,suggestion['votes']))
except Error as e:
print(e, suggestion)
# Loop preparation
requested_results = 0
number_of_results = requested_results + 1
number_of_loops = 0
# Loop to get suggestions and add them to database
while requested_results < number_of_results and number_of_loops < 10:
response = requests.post(url, json=params_json)
json_response = response.json()
number_of_results = json_response['resultCount']
requested_results += suggestions_per_request
number_of_loops += 1
params_json['pageNumber'] += 1
for suggestion in json_response['data']:
suggestion['date'] = datetime.strptime(suggestion['date'], '%d. %B %Y') # convert date to datetime
insert_suggestion_and_votes(connection, suggestion)
Nordnet har opdateret deres loginprocedure, så her er et dugfrist program til at hente kurser hos Nordnet – eller Morningstar, hvis Nordnet skulle fejle:
# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
""" This program extracts historical stock prices from Nordnet (and Morningstar as a fallback) """
import requests
from datetime import datetime
from datetime import date
# Nordnet user account credentials
user = ''
password = ''
# DATE AND STOCK DATA. SHOULD BE EDITED FOR YOUR NEEDS #
# Start date (start of historical price period)
startdate = '2013-01-01'
# List of shares to look up prices for.
# Format is: Name, Morningstar id, Nordnet stock identifier
# See e.g. https://www.nordnet.dk/markedet/aktiekurser/16256554-novo-nordisk-b
# (identifier is 16256554)
# All shares must have a name (whatever you like). To get prices they must
# either have a Nordnet identifier or a Morningstar id
sharelist = [
["Maj Invest Danske Obligationer","F0GBR064UX",16099874],
["Novo Nordisk B A/S","0P0000A5BQ",16256554],
]
# A variable to store historical prices before saving to csv
finalresult = ""
finalresult += '"date";"price";"instrument"' + '\n'
# LOGIN TO NORDNET #
session = requests.Session()
# Setting cookies prior to login by visiting login page
url = 'https://www.nordnet.dk/logind'
request = session.get(url)
# Update headers for login
session.headers['client-id'] = 'NEXT'
session.headers['sub-client-id'] = 'NEXT'
# Actual login
url = 'https://www.nordnet.dk/api/2/authentication/basic/login'
request = session.post(url, data = {'username': user, 'password': password})
# LOOPS TO REQUEST HISTORICAL PRICES AT NORDNET AND MORNINGSTAR #
# Nordnet loop to get historical prices
nordnet_fail = []
for share in sharelist:
# Nordnet stock identifier and market number must both exist
if share[2]:
url = "https://www.nordnet.dk/api/2/instruments/historical/prices/" + str(share[2])
payload = {"from": startdate, "fields": "last"}
data = session.get(url, params=payload)
jsondecode = data.json()
# Sometimes the final date is returned twice. A list is created to check for duplicates.
datelist = []
if jsondecode[0]['prices']:
try:
for value in jsondecode[0]['prices']:
if 'last' in value:
price = str(value['last'])
elif 'close_nav' in value:
price = str(value['close_nav'])
price = price.replace(".",",")
date = datetime.fromtimestamp(value['time'] / 1000)
date = datetime.strftime(date, '%Y-%m-%d')
# Only adds a date if it has not been added before
if date not in datelist:
datelist.append(date)
finalresult += '"' + date + '"' + ";" + '"' + price + '"' + ";" + '"' + share[0] + '"' + "\n"
except Exception as error:
print(error)
breakpoint()
# No price data returned! Try another method!
else:
nordnet_fail.append(share)
if nordnet_fail:
print(nordnet_fail)
# Morningstar loop to get historical prices
for share in nordnet_fail:
# Only runs for one specific fund in this instance
payload = {"id": share[1], "currencyId": "DKK", "idtype": "Morningstar", "frequency": "daily", "startDate": startdate, "outputType": "COMPACTJSON"}
data = requests.get("http://tools.morningstar.dk/api/rest.svc/timeseries_price/nen6ere626", params=payload)
jsondecode = data.json()
for lists in jsondecode:
price = str(lists[1])
price = price.replace(".",",")
date = datetime.fromtimestamp(lists[0] / 1000)
date = datetime.strftime(date, '%Y-%m-%d')
finalresult += '"' + date + '"' + ";" + '"' + price + '"' + ";" + '"' + share[0] + '"' + "\n"
# WRITE CSV OUTPUT TO FILE #
with open("kurser.csv", "w", newline='', encoding='utf8') as fout:
fout.write(finalresult)
Saxo var både flinke og professionelle i dialogen, og nu har de lanceret en mere sikker app. Derfor vil og kan jeg nu fortælle om, hvordan jeg undersøgte Saxos app, og hvad jeg opdagede.
Om streaming og at sikre sig mod kopiering af data
Men først en sidebemærkning: Det er meget svært at give midlertidigt adgang til data (bøger, film, musik), som Saxo, Netflix, Spotify osv., gør, og være helt sikker på, at adgangen altid og i alle tilfælde kun er midlertidig.
Hvis noget kan ses på en skærm eller lyttes til på højttalere, skal der meget til at forhindre ihærdige brugere, (der måske endda er villige til at bryde ophavsretsloven – gisp!), i at få gemt en kopi af materialet.
Spørgsmålet er mere, hvor svært og tidskrævende man gør det.
Problemet hos Saxo var altså ikke, efter min mening, at en abonnement kunne vælge at misbruge deres streamingabonnement til at tage kopier af ebøger. Problemet var, at selv en ikke-abonnement kunne gøre det.
Om at undersøge hvordan apps virker og opdage sikkerhedshuller
Hvis du tænker på selv at give dig i kast med at undersøge apps, API’er og eventuelle sikkerhedshuller, er det vigtigt, du har hjertet på det rette sted.
Du ikke udnytter en utilsigtet adgang til data. Straffeloven siger, du ikke uberettiget må skaffe dig adgang til andres data. Ophavsretsloven siger, du ikke må dele ophavsretsbeskyttede værker med andre. Du kan også (måske uden selv at have opdaget det) have accepteret nogle vilkår for brug af en app, som ejeren af appen måske kan bruge til at anlægge en sag mod dig, som de måske/måske ikke kan vinde.
Du hurtigst muligt giver virksomheden besked om sikkerhedshullerne, så den kan rette dem.
Du ikke deler din viden om sikkerhedshuller med andre, fx dine venner eller medierne. Det ville medføre, at nogle kunne udnytte hullerne og begå ulovligheder.
Det første man skal bruge for at kunne undersøge mobilapps til Android er en emulator, så apps kan køre på ens PC og man har mulighed for at overvåge trafikken.
Jeg bruger en emulator, der hedder Nox. Den er mest lavet til at kunne spille spil og er fyldt med reklamer og sikkert også overvågning. Men: Nox gør det let at hente, installere og eksportere apps, og at begynde at overvåge trafikken til og fra dem.
For at overvåge trafikken på moderne Android-versioner er det nødvendigt først at pille lidt ved den app, hvis trafik man vil overvåge.
(Nogle apps med højere sikkerhedsniveau, bruger noget der hedder “SSL pinning” til at forhindre trafikovervågning. Det gør det væsentligt sværere at overvåge trafikken, end hvad jeg beskriver her.)
Rette i appen for at tillade traffikovervågning
I Nox installerede jeg Saxos app og eksporterede appen som apk-fil.
Det er nemt at eksportere en apk-fil fra en app ud af emulatoren Nox
Sådan ser Saxos app ud, når Apktool har haft fat i den.
For at kunne overvåge trafikken skal appen tillade, at jeg bruger andre SSL-certifikater end dem, der kommer med Android.
I mappen res/xml oprettede jeg filen network_security_config.xml (nogle apps har den allerede) og satte det her indhold ind. Det fortæller, at jeg både stoler på systemcertifikater og brugerinstallerede certifikater:
Hvis alt er gået godt, kan det se sådan her ud, når Charles trafikovervåger. Her er fx oprettelsen af en bruger og de første par handlinger i Saxos app:
Hul igennem til traffikovervågning med Charles
Undersøgelsesfasen
Når først trafikken til og fra en app kan overvåges, er det bare at begynde at bruge app’en for at finde ud af, hvordan dens API virker.
Det jeg fandt ud af, var et problem ved Saxos app, var:
Når man søger med Saxos app, returneres en række id-numre på bøger
Søgning i appen kræver ikke en Premium-konto, alle kan oprette en konto og søge for at se udvalget af bøger
Download-adressen på en bog til offline-læsning kunne regnes ud alene ud fra en bogs id-nummer
Downloadede bøger var krypterede, men:
Appens nøgle til at dekryptere downloadede bøger, så de kunne læses offline, var meget nem at finde
Problemet betød, at brugere uden abonnement kunne få adgang til bøger uden abonnement.
Brugere med abonnement kunne få adgang til bøger uden at deres download blev registreret gennem Saxos API og dermed, formoder jeg, uden at Saxo kunne honorere bogens forlag og i sidste ende bogens forfatter.
Muligvis – det har jeg ikke testet – gjorde hullet også, at eventuelle check, som Saxo har af, hvor mange bøger en bruger kan hente, blev sat ud af spillet.
I de næste afsnit prøver jeg at tage dig med gennem undersøgelsesfasen.
Download af bøger til offline-læsning
Når jeg downloade en bog hos Saxo, kunne jeg se, at der blev spurgt efter en fil herfra:
Når jeg forsøgte at tilgå adressen i en browser, kunne jeg downloade filen. Men, som adressen afslører, er filerne krypteret (“encrypted-base-files”).
Nøglesammenfald mellem søgeresultater og download-adresse
Så opdagede jeg, at en bogs id gik igen i download-adressen. Her har jeg klikket mig ind på den novellesamling af Jens Blendstrup, som jeg downloadede til offlinebrug. Læg mærke til id:
Mapperne “a3“, “51“, “9d” og “F0” i stien kommer fra de første otte tegn i id’et: a3519df0.
Altså: Kender du id på en bog, kender du også downloadadressen på den krypterede bog!
Søgeresultater tilgængelige for alle
Jeg fandt også ud af, at søgning i appen, som er åben for både premium-medlemmer og ikke-betalende brugere på appen, udstiller bøgers id. Her er et eksempel, hvor jeg leder efter Puk Damsgårds Arabica:
Næsten alle kan downloade alt!
Når søgningen udstiller bøgers id, og download-adressen til bøger kan findes alene ud fra en bogs id, kunne en ondsindet bruger med god tid, eller med evnen til at programmere en robot, have:
Hentet alle ISBN-numre på alle bøger i Saxos streaming-katalog
Søgt på alle ISBN-numrene ved hjælp af Saxos app-api og fundet bøgernes id
Downloadet alle bøgerne i kataloget
Det kan være, at Saxo havde implementeret noget, der fx blokerede en bruger, der foretog rigtig mange søgninger, eller downloadede rigtigt meget fra downloadserveren. Hvis de havde det, havde det gjort øvelsen med at kopiere hele kataloget lidt sværere. Men kun en lille smule.
Sidste brik: Dekryptering af downloadede bøger
Efter at have fundet muligheden for at downloade krypterede bøger hos Saxo uden at være abonnent, ledte jeg efter en krypteringsnøgle.
Når Saxos app kan læse de downloadede, krypterede bøger, må krypteringsnøglen jo befinde sig i – eller blive leveret til – Saxos app på en eller anden måde.
Jeg fandt nøglen i mine dekompilerede filer fra appen på min harddisk efter at have lavet forskellige søgninger i filerne, som jeg dårligt nok kan huske og heller ikke vil afsløre. Nøglen viste sig at ligge meget dårligt skjult, og med en lille bid Python-kode og modulet Cryptography skrev jeg et lille program, der kunne dekryptere bøgerne:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
filename = "" # Filnavn på krypteret bog
key = "" # Fra Saxos app
iv = "" # Fra Saxos app
encoded_key = key.encode('utf-8')
encoded_iv = iv.encode('utf-8')
backend = default_backend()
cipher = Cipher(algorithms.AES(encoded_key), modes.CBC(encoded_iv), backend=backend)
with open(filename, "rb") as encrypted_book:
encrypted = encrypted_book.read()
decryptor = cipher.decryptor()
decrypted = decryptor.update(encrypted) + decryptor.finalize()
with open(filename + '.epub', "wb") as fout:
fout.write(decrypted)
Saxos rettelser
Efter at Saxo har opdateret deres app, har jeg genbesøgt appen. Jeg kan se, at:
Krypteringsnøglen til Saxos ebøger er blevet skjult en hel del bedre end tidligere.
Det er ikke længere nok at have internetadressen på en bog for at downloade den. For at brugeren kan downloade en bog, genererer appen nu en unik nøgle per bog, som downloadserveren vil have for at sende en bog tilbage.
Den gamle version af appen tillod en bruger at springe det trin over, hvor Saxo registererer, at en bog er blevet hentet. I den nye er det kun muligt at downloade en bog, hvis man inden da har spurgt API’et om downloadadressen:
Downloadadresserne på bøger har fået nogle ekstra parametre på, bl.a. en unik “signatur” (“sig”), som API’et returnerer for hver bog, brugeren downloader. Jeg har censureret nogle få bytes her:
De nye downloadadresser er mere komplicerede end tidligere. Jeg ved ikke, hvad alle de nye parametre gør, men mon ikke datoparametrene har noget at gøre med en udløbsdato, hvorefter det ikke længere er muligt at bruge linket?
Derudover har Saxo, har de fortalt mig, lavet andre sikkerhedsopstramninger bag kulisserne.
Skønt!
Hvad betyder ændringerne i Saxos app?
Ændringerne betyder, at ikke-betalende brugere af Saxos app, så vidt jeg kan se, nu er effektivt afskåret fra at kunne tilgå bøger.
Betalende brugere har ikke længere mulighed for at downloade bøger, uden at Saxo kan registrere det og sørge for passende honorering af forfatter og forlag.
Tidsforløb og Saxos kommentarer
Jeg gjorde opmærksom på sikkerhedsproblemet den 22. marts 2021. Jeg kontaktede først Saxos kundeservice den 12. marts og opdagede, så vidt jeg husker, problemet den 11. marts. Saxos opdaterede app blev rullet ud den 17. maj 2021.
Jeg har slettet de bøger, jeg selv har downloadet og dekrypteret i forbindelse med undersøgelsen af Saxos app.
Saxo har haft mulighed for at foreslå rettelser og kommentere dette blogindlæg.
Saxo foreslog at gøre ekstra opmærksom på lovgivningen omkring at få uberettiget adgang til og dele data. Det synes jeg var en god idé, så det har jeg gjort i afsnittet om at undersøge hvordan apps virker og at opdage sikkerhedshuller.
De foreslog også at gøre opmærksom på, at det at dekompilere apps, ændre dem og bygge dem igen, efter deres mening er en juridisk gråzone. Her har din hensigt med at undersøge en app betydning: Hvis du blot er ude på at undersøge, hvordan ting virker, kender jeg ikke noget lovgivning, der siger, at du gør noget forkert (hvis du gør, vil jeg gerne høre om det). Hvis du er ude på at få uberettiget adgang til data eller ophavsretsbeskyttet materiale er det en anden sag – så er du ude på at bryde loven.