
    PL
ju>              
         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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dAd#ZdBd&ZdCd(ZdDd)ZdEd+ZdFd-Zd.Zd/ZdGd1Z dHd3Z!dId6Z"ed7dJd:Z#dKd<Z$dLd=Z%dLd>Z&dS )Ma1  
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field)Path)IterableOptionalT)frozenc                  l    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<   dS )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        B/home/kuhnn/.hermes/hermes-agent/hermes_cli/security_advisories.pyr   r   B   s|          " GGGJJJLLLHHH7777    IH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.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                  2    e Zd ZU dZded<   ded<   ded<   dS )AdvisoryHitz.One package-version match against an advisory.r   advisoryr   packageinstalled_versionN)r   r   r   r   r   r   r   r   r#   r#      s9         88LLLr   r#   pkg_namer   returnOptional[str]c                    	 ddl m}m} n# t          $ r Y dS w xY w	  ||           S # |$ r Y dS t          $ r! t
                              d| d           Y dS 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)r'   r+   r,   s      r   _installed_versionr4      s    DDDDDDDDD   ttwx      tt    	<hQUVVVtt	s     

( A&AA
advisoriesIterable[Advisory]list[AdvisoryHit]c           	         g }| D ]L}|j         D ]B\  }}t          |          }||r||v r%|                    t          |||                     CM|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).
    N)r$   r%   r&   )r   r4   appendr#   )r5   hitsr$   r'   bad_versions	installeds         r   detect_compromisedr=      s     !D 
 
&.&: 		 		"Hl*844I  9#<#<K%$&/     		 Kr   set[str]c                 T   	 ddl m}   |             }n:# t          $ r- t                              dd           t                      cY S w xY w|                    d          pi }|                    d          pg }t          |t                    st                      S d |D             S )	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_advisoriesc                    h | ]D}t          |                                          #t          |                                          ES r   )r   strip).0xs     r   	<setcomp>z get_acked_ids.<locals>.<setcomp>   s9    :::q3q66<<>>:CFFLLNN:::r   )	hermes_cli.configr@   r1   r2   r3   setget
isinstancelist)r@   cfgsecraws       r   get_acked_idsrP      s    111111kmm   >NNNuu ''*


#C
''$
%
%
+Cc4   uu::C::::s    4A
	A
advisory_idboolc                   |                                  } | sdS 	 ddlm}m} n+# t          $ r t
                              d           Y dS w xY w	  |            }|                    di           }|                    d          pg }t          |t                    sg }| |vr%|                    |            ||d<    ||           dS # t          $ r t
                              d|            Y dS 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 ackrA   rB   Tz%Failed to persist advisory ack for %s)rD   rH   r@   rT   r1   r2   warning
setdefaultrJ   rK   rL   r9   	exception)rQ   r@   rT   rM   rN   existings         r   ack_advisoryrY      s>   
 ##%%K u>>>>>>>>>   FGGGuukmmnnZ,,77-..4"(D)) 	Hh&&OOK(((&.C"#Kt   @+NNNuus"   # $A
AA7C %C10C1r:   c                D    | sg S t                      fd| D             S )z=Return only hits whose advisories the user has not dismissed.c                0    g | ]}|j         j        v|S r   r$   r   )rE   hackeds     r   
<listcomp>z"filter_unacked.<locals>.<listcomp>   s'    :::!qz}E99A999r   )rP   )r:   r^   s    @r   filter_unackedr`      s3     	OOE::::t::::r   c                     t           j                            d          rdS t          j                                        sdS dS )NNO_COLORFT)osenvironrJ   sysstdoutisattyr   r   r   _term_supports_colorrh     s=    	z~~j!! u: u4r   	list[str]c           	     &   | sg S | d         }d|j         j         d|j         j         d|j         d|j         dg}t          |           dk    rB|                    ddt          |           dz
   d	t          |           d
k    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)r:   primaryliness      r   short_banner_linesrt     s      	1gGNg.1NNg6F6LNNEwEE'*CEE6E
 4yy1}}Q Jc$ii!m J J#&t99q==%%cJ J J 	K 	K 	KLr   hitc                   | 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 ] \  }}|                    d| d|            !|S )z@Return a multi-line block describing the advisory + remediation.z=== z ===zID:        z    Severity: z    Published: zDetected:  rk   zReference: r   zRemediation:rl   z  z. )r$   r   r   r   r   r%   r&   r   r   	enumerater   r9   )ru   ars   isteps        r   full_remediation_textr{   "  s    AqwRadRR!*RRQ[RR<ck<<S%:<<ae
		
	E Q]A.. ' '4%!%%t%%&&&&Lr   advisory_banner_seen   Optional[Path]c                     	 ddl m}  t           |                       dz  }|                    dd           |t          z  S # t
          $ r Y d S w xY w)Nr   )get_hermes_homecacheT)parentsexist_ok)hermes_constantsr   r   mkdir_BANNER_CACHE_FILEr1   )r   	cache_dirs     r   _banner_cache_pathr   E  sw    444444**++g5	t444---   tts   A A 
AAdict[str, float]c                    t                      } | |                                 si S i }	 |                     d                                          D ]k}|                                }|s|                    d d          }t          |          dk    rC|\  }}	 t          |          ||<   \# t          $ r Y hw xY wn# t          $ r i cY S w xY w|S )Nutf-8encodingrl   rm   )
r   exists	read_text
splitlinesrD   splitrp   float
ValueErrorr1   )poutlinepartsrQ   tss         r   _read_banner_cacher   O  s   Ay

y	CKKK11<<>> 	 	D::<<D JJtQ''E5zzQ#OK#(99K     	    			Js6   A0B? B.-B? .
B;8B? :B;;B? ?CCseenNonec                   t                      }|d S 	 d |                                 D             }|                    d                    |          dz   d           d S # t          $ r  t
                              dd           Y d S w xY w)Nc                "    g | ]\  }}| d | S ) r   )rE   aidr   s      r   r_   z'_write_banner_cache.<locals>.<listcomp>k  s&    ;;;73C";;;r   
r   r   z%Could not write advisory banner cacheTr-   )r   items
write_textjoinr1   r2   r3   )r   r   rs   s      r   _write_banner_cacher   f  s    AyM;;djjll;;;	TYYu%%,w????? M M M<tLLLLLLMs   AA! !&B
B)repeat_hoursr   intc               L   ddl }t          |           }|sg S |                                 }t                      }||dz  z
  }g }|D ]L}|                    |j        j        d          }	|	|k     r$|                    |           |||j        j        <   M|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        )timer`   r   rJ   r$   r   r9   r   )
r:   r   r   freshnowr   cutoffdueru   lasts
             r   hits_due_for_bannerr   q  s     KKK4  E 	
))++C  EL4'(FC ) )yy#..&==JJsOOO%(E#,/"
 #E"""Jr   tuple[bool, list[str]]c                    t          |           }|sddgfS g }t          |          D ]>\  }}|r|                    d           |                    t	          |                     ?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)r`   rw   r9   extendr{   )r:   r   rs   ry   ru   s        r   render_doctor_sectionr     s     4  E ><===EE"" 1 13 	LL*3//0000;r   c                    t          |           }|sdS t          |          }t                      rd}d}|d                    |          z   |z   S d                    |          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   rt   rh   r   )r:   r   rs   redresets        r   startup_bannerr     sq     d
#
#C ts##E .TYYu%%%--99Ur   c           
     4   t          |           }|sdS t          |          dk    rA|d         }d|j        j         d|j         d|j         d|j        j         d|j        j         
S t          |           d	d
                    d |D                        dS )z=Return a one-line log message for gateway operators, or None.Nrl   r   zSecurity advisory [z
] active: rk   z	 matches z. See z" security advisories active (IDs: z, c              3  .   K   | ]}|j         j        V  d S )Nr\   )rE   r]   s     r   	<genexpr>z&gateway_log_message.<locals>.<genexpr>  s&      <<qz}<<<<<<r   z7). Run `hermes doctor` on the gateway host for details.)	r`   rp   r$   r   r%   r&   r   r   r   )r:   r   r]   s      r   gateway_log_messager     s    4  E t
5zzQ!H(ajm ( (9( ( ! 3( (>?j>N( (z~( ( 	) 5zz D DYY<<e<<<<<D D D Er   )r'   r   r(   r)   )r5   r6   r(   r7   )r(   r>   )rQ   r   r(   rR   )r:   r7   r(   r7   )r(   rR   )r:   r7   r(   ri   )ru   r#   r(   ri   )r(   r~   )r(   r   )r   r   r(   r   )r:   r7   r   r   r(   r7   )r:   r7   r(   r   )r:   r7   r(   r)   )'r   
__future__r   loggingrc   re   dataclassesr   r   pathlibr   typingr   r   	getLoggerr   r2   r   	frozensetr!   r   r#   r4   r=   rP   rY   r`   rh   rt   r{   r   _BANNER_REPEAT_HOURSr   r   r   r   r   r   r   r   r   r   <module>r      s    B # " " " " "  				 



 ( ( ( ( ( ( ( (       % % % % % % % %		8	$	$. $       : HL @))WI../


 ;   $
        P $          , &0    F; ; ; ;(   :; ; ; ;      (   > ,        .M M M M -     F   $   "E E E E E Er   