
    `gj*?              
         U d Z ddlmZ ddlZddlZddlZddlmZ ddlm	Z	 ddl
mZmZ  ej                  e      Z ed       G d	 d
             Z eddddd edh      ffddd      fZded<    ed       G d d             Zd+dZef	 	 	 d,dZd-dZd.dZd/dZd0dZd1dZd2d Zd!Zd"Zd3d#Zd4d$Z d5d%Z!ed&	 	 	 	 	 d6d'Z"d7d(Z#d8d)Z$d8d*Z%y)9a1  
Security advisory checker for Hermes Agent.

Detects known-compromised Python packages installed in the active venv
(supply-chain attacks like the Mini Shai-Hulud worm of May 2026 that
poisoned ``mistralai 2.4.6`` on PyPI) and surfaces remediation guidance to
the user.

Design goals:

- **Cheap.** A single ``importlib.metadata.version()`` call per advisory
  package. Safe to run on every CLI startup.
- **Loud when it matters, silent otherwise.** If no compromised package is
  installed, the user sees nothing.
- **Acknowledgeable.** Once the user has read and acted on an advisory they
  can dismiss it via ``hermes doctor --ack <id>``; the ack is persisted to
  ``config.security.acked_advisories`` and survives restart.
- **Extensible.** Adding a new advisory is one entry in ``ADVISORIES``;
  adding a new compromised version is a one-line edit. No code changes
  needed when the next worm hits.

The check is invoked from three places:

1. ``hermes doctor`` (and ``hermes doctor --ack <id>``)
2. CLI startup banner (one short line, then full guidance via
   ``hermes doctor``)
3. Gateway startup (logged to gateway.log; first interactive message gets
   a one-line operator banner)

This module is intentionally dependency-free beyond the stdlib so it can
run in environments where the rest of Hermes failed to import.
    )annotationsN)	dataclass)Path)IterableOptionalT)frozenc                  j    e Zd ZU dZded<   ded<   ded<   ded<   ded<   d	ed
<   dZded<   dZded<   y)Advisoryu(  One security advisory entry.

    Attributes:
        id: stable identifier used for acks (e.g. ``shai-hulud-2026-05``).
            Lowercase-hyphen, never reused.
        title: one-line headline shown in banners.
        summary: 1-3 sentence description of what was compromised and how.
        url: reference URL (Socket advisory, GitHub advisory, PyPI page).
        compromised: tuple of ``(package_name, frozenset_of_versions)``
            pairs. Empty frozenset means "any version of this package is
            considered suspect" — use sparingly.
        remediation: ordered list of steps the user should take. First step
            should be the uninstall command; subsequent steps the credential
            audit / rotation guidance.
        published: ISO date string for sort order.
    stridtitlesummaryurlz&tuple[tuple[str, frozenset[str]], ...]compromisedztuple[str, ...]remediation 	publishedhighseverityN)__name__
__module____qualname____doc____annotations__r   r        Q/root/.hermes/venv/lib/python3.12/site-packages/hermes_cli/security_advisories.pyr
   r
   B   s=    " 	GJL	H77  IsHcr   r
   zshai-hulud-2026-05u<   Mini Shai-Hulud worm — mistralai 2.4.6 compromised on PyPIu`  PyPI quarantined the mistralai package on 2026-05-12 after a malicious 2.4.6 release. The worm steals credentials from environment variables and credential files (~/.npmrc, ~/.pypirc, ~/.aws/credentials, GitHub PATs, cloud SDK tokens) and exfils them to a hardcoded webhook. If you ran any Python process that imported mistralai 2.4.6 — including hermes when configured with provider=mistral for TTS or STT — assume those credentials are exposed. PyPI has since removed 2.4.6 and the project ships clean releases again (2.4.7, 2.4.8); this advisory only fires if the compromised 2.4.6 is still installed.z1https://socket.dev/blog/mini-shai-hulud-worm-pypi	mistralaiz2.4.6)zARun: pip uninstall -y mistralai  (or: uv pip uninstall mistralai)zlRotate API keys in ~/.hermes/.env (OpenRouter, Anthropic, OpenAI, Nous, GitHub, AWS, Google, Mistral, etc.).zAudit ~/.npmrc, ~/.pypirc, ~/.aws/credentials, ~/.config/gh/hosts.yml, and any other credential files for tokens that may have been read.zgCheck GitHub for unexpected new SSH keys, deploy keys, or webhook additions on repos you have admin on.zOAfter cleanup: hermes doctor --ack shai-hulud-2026-05  to dismiss this warning.z
2026-05-12critical)r   r   r   r   r   r   r   r   ztuple[Advisory, ...]
ADVISORIESc                  0    e Zd ZU dZded<   ded<   ded<   y)AdvisoryHitz.One package-version match against an advisory.r
   advisoryr   packageinstalled_versionN)r   r   r   r   r   r   r   r   r"   r"      s    8Lr   r"   c                    	 ddl m}m} 	  ||       S # t        $ r Y yw xY w# |$ r Y yt        $ r t
        j                  d| d       Y yw xY w)zReturn the installed version of ``pkg_name``, or None if not installed.

    Uses ``importlib.metadata`` so we don't depend on pip being importable
    inside the active venv (uv-created venvs may lack pip).
    r   )PackageNotFoundErrorversionNz%importlib.metadata.version(%s) raisedTexc_info)importlib.metadatar'   r(   ImportError	Exceptionloggerdebug)pkg_namer'   r(   s      r   _installed_versionr1      sb    Dx         	<hQUV	s     " 	A AAc           	         g }| D ]H  }|j                   D ]7  \  }}t        |      }||r||v s|j                  t        |||             9 J |S )zScan installed packages and return all advisory hits.

    A "hit" means an advisory's listed package is installed AND the version
    is in the compromised set (or the compromised set is empty, meaning
    *any* version is suspect).
    )r#   r$   r%   )r   r1   appendr"   )
advisorieshitsr#   r0   bad_versions	installeds         r   detect_compromisedr8      sr     !D 
&.&:&: 		"Hl*84I 9#<K%$&/ 		
 Kr   c                    	 ddl m}   |        }|j                  d      xs i }|j                  d      xs g }t        |t              s
t               S |D ch c]5  }t        |      j                         st        |      j                         7 c}S # t        $ r$ t        j	                  dd       t               cY S w xY wc c}w )u   Return the set of advisory IDs the user has dismissed.

    Returns an empty set if config can't be loaded (don't block startup
    just because config is broken — the advisory will keep firing until
    config is repaired, which is fine).
    r   )load_configz'Could not load config for advisory acksTr)   securityacked_advisories)hermes_cli.configr:   r-   r.   r/   setget
isinstancelistr   strip)r:   cfgsecrawxs        r   get_acked_idsrG      s    1m ''*

#C
''$
%
+Cc4 u$':q3q6<<>CFLLN::  >Nu ;s   B C6C*C Cc                   | j                         } | sy	 ddlm}m} 	  |       }|j                  di       }|j                  d      xs g }t        |t              sg }| |vr|j                  |        ||d<    ||       y# t        $ r t
        j                  d       Y yw xY w# t        $ r t
        j                  d|        Y yw xY w)	u|   Persist an ack for ``advisory_id``. Returns True on success.

    Idempotent — acking an already-acked ID is a no-op.
    Fr   )r:   save_configz-Could not import config module to persist ackr;   r<   Tz%Failed to persist advisory ack for %s)rB   r=   r:   rI   r-   r.   warning
setdefaultr?   r@   rA   r3   	exception)advisory_idr:   rI   rC   rD   existings         r   ack_advisoryrO      s    
 ##%K>mnnZ,77-.4"(D)Hh&OOK(&.C"#  FG  @+Ns#   B A"B% B"!B"%CCc                z    | sg S t               }| D cg c]  }|j                  j                  |vs| c}S c c}w )z=Return only hits whose advisories the user has not dismissed.)rG   r#   r   )r5   ackedhs      r   filter_unackedrS      s4    	OE:!qzz}}E9A:::s   88c                     t         j                  j                  d      ryt        j                  j                         syy)NNO_COLORFT)osenvironr?   sysstdoutisattyr   r   r   _term_supports_colorr[     s+    	zz~~j!::r   c           	     8   | sg S | d   }d|j                   j                   d|j                   j                   d|j                   d|j                   dg}t        |       dkD  r5|j                  ddt        |       dz
   d	t        |       d
kD  rdnd d       |S )zReturn 1-3 short lines suitable for a startup banner.

    Caller is responsible for color/styling. Always names the worst hit
    explicitly so the user knows what's wrong without running doctor.
    r   zSECURITY ADVISORY [z]: z  Detected: ==z,  Run 'hermes doctor' for remediation steps.   z  (z additional advisor   iesyz also active.))r#   r   r   r$   r%   leninsert)r5   primaryliness      r   short_banner_linesrf     s     	1gG
g..112#g6F6F6L6L5MN
w'r'*C*C)DE6E
 4y1}Q#c$i!m_,?#&t9q=%c:.J 	KLr   c                h   | j                   }d|j                   dd|j                   d|j                   d|j                   d| j
                   d| j                   d|j                   d	|j                  d	d
g}t        |j                  d      D ]  \  }}|j                  d| d|         |S )z@Return a multi-line block describing the advisory + remediation.z=== z ===zID:        z    Severity: z    Published: zDetected:  r]   zReference: r   zRemediation:r^   z  z. )r#   r   r   r   r   r$   r%   r   r   	enumerater   r3   )hitare   isteps        r   full_remediation_textrm   $  s    A
qwwit
addV>!**_Q[[MR
ckk]"S%:%:$;<
aeeW
			
	E Q]]A. '4r!Btf%&'Lr   advisory_banner_seen   c                     	 ddl m}  t         |              dz  }|j                  dd       |t        z  S # t
        $ r Y y w xY w)Nr   )get_hermes_homecacheT)parentsexist_ok)hermes_constantsrq   r   mkdir_BANNER_CACHE_FILEr-   )rq   	cache_dirs     r   _banner_cache_pathry   G  sJ    4*+g5	t4--- s   47 	AAc                 d   t               } | | j                         si S i }	 | j                  d      j                         D ]J  }|j	                         }|s|j                  d d      }t        |      dk7  r7|\  }}	 t        |      ||<   L 	 |S # t        $ r Y [w xY w# t        $ r i cY S w xY w)Nutf-8encodingr^   r_   )
ry   exists	read_text
splitlinesrB   splitrb   float
ValueErrorr-   )poutlinepartsrM   tss         r   _read_banner_cacher   Q  s    Ay
	CKKK1<<> 	D::<DJJtQ'E5zQ#OK#(9K 	 J	   	s6   AB! ?BB! 	BB! BB! !B/.B/c                   t               }|y 	 | j                         D cg c]  \  }}| d|  }}}|j                  dj                  |      dz   d       y c c}}w # t        $ r t
        j                  dd       Y y w xY w)N 
r{   r|   z%Could not write advisory banner cacheTr)   )ry   items
write_textjoinr-   r.   r/   )seenr   aidr   re   s        r   _write_banner_cacher   h  s    AyM.2jjl;73C5";;	TYYu%,w? < M<tLMs!   A" A(A" A" " BB)repeat_hoursc               H   ddl }t        |       }|sg S |j                         }t               }||dz  z
  }g }|D ]X  }|j                  |j                  j
                  d      }	|	|k  s/|j                  |       |||j                  j
                  <   Z |rt        |       |S )zReturn only hits whose banner is due (not acked, not recently shown).

    Side effect: stamps the banner cache for any hit that's about to be
    shown. Callers should subsequently render the result.
    r   Ni  g        )timerS   r   r?   r#   r   r3   r   )
r5   r   r   freshnowrr   cutoffdueri   lasts
             r   hits_due_for_bannerr   s  s     4 E	
))+C EL4'(FC )yy#.&=JJsO%(E#,,//"	)
 E"Jr   c                    t        |       }|sddgfS g }t        |      D ]2  \  }}|r|j                  d       |j                  t	        |             4 d|fS )zRender the security-advisory section for ``hermes doctor``.

    Returns ``(has_problems, lines)``. Caller is responsible for printing
    with whatever color scheme it uses.
    Fu#   No active security advisories.  ✓r   T)rS   rh   r3   extendrm   )r5   r   re   rk   ri   s        r   render_doctor_sectionr     sj     4 E<===EE" 13LL*3/01 ;r   c                    t        |       }|syt        |      }t               rd}d}|dj                  |      z   |z   S dj                  |      S )zReturn a printable startup banner, or None if nothing is due.

    Updates the banner cache as a side effect (so the next call within
    24h returns None for the same hit).
    Nz[1;31mz[0mr   )r   rf   r[   r   )r5   r   re   redresets        r   startup_bannerr     sV     d
#Cs#ETYYu%%--99Ur   c           
     T   t        |       }|syt        |      dk(  rf|d   }d|j                  j                   d|j                   d|j
                   d|j                  j                   d|j                  j                   
S t        |       d	d
j                  d |D               dS )z=Return a one-line log message for gateway operators, or None.Nr^   r   zSecurity advisory [z
] active: r]   z	 matches z. See z" security advisories active (IDs: z, c              3  H   K   | ]  }|j                   j                    y w)N)r#   r   ).0rR   s     r   	<genexpr>z&gateway_log_message.<locals>.<genexpr>  s     <qzz}}<s    "z7). Run `hermes doctor` on the gateway host for details.)	rS   rb   r#   r   r$   r%   r   r   r   )r5   r   rR   s      r   gateway_log_messager     s    4 E
5zQ!H%ajjmm_J99+R 3 34Iajj>N>N=O Pzz~~&( 	) 5zl YY<e<<= >CD Er   )r0   r   returnOptional[str])r4   zIterable[Advisory]r   list[AdvisoryHit])r   zset[str])rM   r   r   bool)r5   r   r   r   )r   r   )r5   r   r   	list[str])ri   r"   r   r   )r   zOptional[Path])r   dict[str, float])r   r   r   None)r5   r   r   intr   r   )r5   r   r   ztuple[bool, list[str]])r5   r   r   r   )&r   
__future__r   loggingrV   rX   dataclassesr   pathlibr   typingr   r   	getLoggerr   r.   r
   	frozensetr    r   r"   r1   r8   rG   rO   rS   r[   rf   rm   rw   _BANNER_REPEAT_HOURSry   r   r   r   r   r   r   r   r   r   <module>r      sO  B #  	 
 !  %			8	$. $  : L	8 @)WI./


 ? "$
  "T $  , &0"F;(:;(> ,  .M -
  	F$"Er   