#!/usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import datetime
import json
import sys

import lib.args
import lib.base
import lib.time
import lib.url
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026041201'

DESCRIPTION = """Checks the security of a private Nextcloud server using the Nextcloud security scanner.
Reports the assigned security rating and alerts on known vulnerabilities in the
installed version."""

DEFAULT_INSECURE = False
DEFAULT_NO_PROXY = False
DEFAULT_TIMEOUT = 7
DEFAULT_TRIGGER = 14


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(description=DESCRIPTION)

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--insecure',
        help=lib.args.help('--insecure'),
        dest='INSECURE',
        action='store_true',
        default=DEFAULT_INSECURE,
    )

    parser.add_argument(
        '--no-proxy',
        help=lib.args.help('--no-proxy'),
        dest='NO_PROXY',
        action='store_true',
        default=DEFAULT_NO_PROXY,
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '--trigger',
        help='Trigger a re-scan if the result on scan.nextcloud.com is older than this many days. '
        'Default: %(default)s (days)',
        dest='TRIGGER',
        type=int,
        default=DEFAULT_TRIGGER,
    )

    parser.add_argument(
        '-u',
        '--url',
        help='Nextcloud server URL. Example: `cloud.example.com`.',
        dest='URL',
        required=True,
    )

    args, _ = parser.parse_known_args()
    return args


def get_uuid_from_scan_nextcloud_com(nc_url, args):
    url = 'https://scan.nextcloud.com/api/queue'
    data = {'url': nc_url}
    header = {
        'Content-type': 'application/x-www-form-urlencoded',
        'X-CSRF': 'true',
    }
    success, result = lib.url.fetch(
        url,
        header=header,
        data=data,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        timeout=args.TIMEOUT,
    )
    if not success:
        return (success, result)
    if result == '':
        return (
            False,
            f'The scan for {nc_url} failed. Either no Nextcloud or ownCloud can be found there or you tried to scan too many servers.',
        )
    try:
        return (True, json.loads(result)['uuid'])
    except Exception:
        return (False, 'No JSON object could be decoded')


def trigger_rescan_nextcloud_com(nc_url, args):
    url = 'https://scan.nextcloud.com/api/requeue'
    data = {'url': nc_url}
    header = {
        'Content-type': 'application/x-www-form-urlencoded',
        'X-CSRF': 'true',
    }
    success, result = lib.url.fetch(
        url,
        header=header,
        data=data,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        timeout=args.TIMEOUT,
    )
    if not success:
        return (success, result)
    if not result:
        return (
            True,
            'Result was outdated. Re-scan triggered. Check again in ~5 minutes to get the newest scan result.',
        )
    try:
        return (True, json.loads(result))
    except Exception:
        return (False, 'No JSON object could be decoded')


def get_scan_result_from_scan_nextcloud_com(nc_uuid, args):
    url = 'https://scan.nextcloud.com/api/result/' + nc_uuid
    success, result = lib.url.fetch(
        url,
        insecure=args.INSECURE,
        no_proxy=args.NO_PROXY,
        timeout=args.TIMEOUT,
    )
    if not success:
        return (success, result)
    try:
        return (True, json.loads(result))
    except Exception:
        return (False, 'No JSON object could be decoded')


def get_rating_string(rating):
    grade = ['F', 'E', 'D', 'C', 'A', 'A+']
    return grade[rating]


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    # fetch data
    uuid = lib.base.coe(get_uuid_from_scan_nextcloud_com(args.URL, args))
    result = lib.base.coe(get_scan_result_from_scan_nextcloud_com(uuid, args))


    # init some vars
    msg = ''
    state = STATE_OK
    # we just warn if Nextcloud security scanner warns, too
    if result['rating'] == 0 or result['rating'] == 1:
        # sorry for being CRIT, but such a state is not acceptable
        state = STATE_CRIT
    elif result['rating'] == 2 or result['rating'] == 3:
        state = STATE_WARN

    # warn if last check is more than TRIGGER days ago, and trigger a rescan.
    # `today - scan_date` yields a positive .days for scans in the past; a scan
    # dated in the future (clock skew) should not trigger a rescan, so drop the
    # abs() that the old code relied on.
    scan_date = datetime.datetime.strptime(result['scannedAt']['date'][:10], '%Y-%m-%d')
    today = lib.time.now(as_type='datetime')
    age_days = (today - scan_date).days
    if age_days > args.TRIGGER:
        lib.base.coe(trigger_rescan_nextcloud_com(args.URL, args))


    # build the message
    msg += (
        f'"{get_rating_string(result["rating"])}"'
        f' rating for {result["domain"]},'
        f' checked at {result["scannedAt"]["date"][:10]},'
        f' on '
    )

    # Version
    msg += f'{result["product"]} v{result["version"]} '
    if not result['latestVersionInBranch']:
        msg += '(NOT on latest patch level). '
    else:
        msg = msg.strip() + '. '

    # Hardenings
    if not result['hardenings']['appPasswordsScannedForHaveIBeenPwned']:
        msg += 'Password check against HaveIBeenPwned database missing. '
    if not result['hardenings']['bruteforceProtection']:
        msg += 'Bruteforce protection setting missing. '
    if not result['hardenings']['CSPv3']:
        msg += 'CSPv3 HTTP feature missing. '
    if not result['hardenings']['passwordConfirmation']:
        msg += 'Password confirmation setting missing. '
    if not result['hardenings']['appPasswordsCanBeRestricted']:
        msg += 'App passwords cannot be restricted. '
    if not result['hardenings']['__HostPrefix']:
        msg += '__HostPrefix missing. '
    if not result['hardenings']['sameSiteCookies']:
        msg += 'Same-Site-Cookie Enforcing missing. '

    # Vulnerabilities
    if result['vulnerabilities']:
        msg += 'Known vulnerablities: '
        for vul in result['vulnerabilities']:
            msg += vul['title'] + '; '
        msg = msg[:-2] + '. '

    # Setup issues
    if not result['setup']['headers']['X-Frame-Options']:
        msg += 'Header X-Frame-Options missing. '
    if not result['setup']['headers']['X-XSS-Protection']:
        msg += 'Header X-XSS-Protection missing. '
    if not result['setup']['headers']['X-Download-Options']:
        msg += 'Header X-Download-Options missing. '
    if not result['setup']['headers']['X-Content-Type-Options']:
        msg += 'Header X-Content-Type-Options missing. '
    if not result['setup']['headers']['X-Permitted-Cross-Domain-Policies']:
        msg += 'Header X-Permitted-Cross-Domain-Policies missing. '
    if not result['setup']['https']['enforced']:
        msg += 'HTTPS not enforced. '
    if not result['setup']['https']['used']:
        msg += 'HTTPS not used. '

    # over and out
    lib.base.oao(msg, state)


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
