#!/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 os
import sys

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

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

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. When --path points at a local Nextcloud installation, the
plugin reads the installed version and forces a fresh scan after an update, so
the rating never lags behind."""

DEFAULT_INSECURE = False
DEFAULT_NO_PROXY = False
DEFAULT_PATH = None
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(
        '--path',
        help='Local path to the Nextcloud installation, typically the web server document root. '
        'When set, the plugin reads the installed version via `occ` and triggers an immediate '
        're-scan if it differs from the version scan.nextcloud.com last saw, so the rating no '
        'longer lags a Nextcloud update. '
        'Requires running on the Nextcloud server with sudo for the UID owning '
        'config/config.php.',
        dest='PATH',
        default=DEFAULT_PATH,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    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 load_test_fixture(test_args, path):
    # Replace the first element of args.TEST with the source-specific fixture
    # path, read it via lib.lftest.test() and return the parsed JSON. On a
    # missing file or malformed JSON, exit STATE_UNKNOWN with a helpful message
    # instead of letting json.loads raise a traceback.
    if not os.path.isfile(path):
        lib.base.cu(f'Test fixture not found: "{path}".')
    test_args[0] = path
    stdout, _, _ = lib.lftest.test(test_args)
    try:
        return json.loads(stdout)
    except (json.JSONDecodeError, ValueError) as e:
        lib.base.cu(f'Test fixture "{path}" does not contain valid JSON: {e}')


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
    installed_version = None
    if args.TEST is None:
        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))
        if args.PATH:
            # Reading the locally installed version lets us spot a scan result that
            # predates a local Nextcloud update. Needs to run on the Nextcloud server
            # with sudo for the UID owning config/config.php.
            config = lib.base.coe(lib.nextcloud.run_occ(args.PATH, 'config:list'))
            installed_version = config['system']['version']
    else:
        # do not call the API, put in test data. args.TEST[0] is the fixture base
        # path; the plugin appends `-scan` (scan.nextcloud.com result) and, only
        # for scenarios that exercise the post-update staleness path, `-installed`
        # (the `occ config:list` output that --path would normally produce).
        test_base = args.TEST[0]
        result = load_test_fixture(args.TEST, f'{test_base}-scan')
        installed_path = f'{test_base}-installed'
        if os.path.isfile(installed_path):
            config = load_test_fixture(args.TEST, installed_path)
            installed_version = config['system']['version']

    # init some vars
    msg = ''
    state = STATE_OK

    # analyze data
    # we just warn if Nextcloud security scanner warns, too
    if result['rating'] in (0, 1):
        # sorry for being CRIT, but such a state is not acceptable
        state = STATE_CRIT
    elif result['rating'] in (2, 3):
        state = STATE_WARN

    # Decide whether to trigger a re-scan on scan.nextcloud.com. Two independent
    # reasons make the cached result stale:
    #   1. the result is older than --trigger days, or
    #   2. the locally installed version differs from the version the scanner saw,
    #      which happens right after a Nextcloud update (issue #118).
    # `today - scan_date` yields a positive .days for scans in the past; a scan
    # dated in the future (clock skew) must not trigger a rescan, so we avoid abs().
    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
    version_changed = (
        installed_version is not None and installed_version != result['version']
    )
    if args.TEST is None and (age_days > args.TRIGGER or version_changed):
        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() + '. '

    # Flag a stale result: the scanner still reports an older version than the one
    # installed locally. A fresh scan was triggered above (issue #118).
    if version_changed:
        msg += (
            f'Scan result is stale; installed version is v{installed_version}. '
            'Re-scan triggered, check again in ~5 minutes. '
        )

    # 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()
