
    j*?              
         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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)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        C/home/ubuntu/.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. 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                  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_versionr3      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   r3   appendr"   )r4   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?   r0   r1   r2   setget
isinstancelist)r?   cfgsecraws       r   get_acked_idsrO      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 ackr@   rA   Tz%Failed to persist advisory ack for %s)rC   rG   r?   rS   r0   r1   warning
setdefaultrI   rJ   rK   r8   	exception)rP   r?   rS   rL   rM   existings         r   ack_advisoryrX      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r9   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   )rD   hackeds     r   
<listcomp>z"filter_unacked.<locals>.<listcomp>   s'    :::!qz}E99A999r   )rO   )r9   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environrI   sysstdoutisattyr   r   r   _term_supports_colorrg     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)r9   primaryliness      r   short_banner_linesrs     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:  rj   zReference: r   zRemediation:rk   z  z. )r#   r   r   r   r   r$   r%   r   r   	enumerater   r8   )rt   arr   isteps        r   full_remediation_textrz   $  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_FILEr0   )r   	cache_dirs     r   _banner_cache_pathr   G  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encodingrk   rl   )
r   exists	read_text
splitlinesrC   splitro   float
ValueErrorr0   )poutlinepartsrP   tss         r   _read_banner_cacher   Q  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   )rD   aidr   s      r   r^   z'_write_banner_cache.<locals>.<listcomp>m  s&    ;;;73C";;;r   
r   r   z%Could not write advisory banner cacheTr,   )r   items
write_textjoinr0   r1   r2   )r   r   rr   s      r   _write_banner_cacher   h  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   rI   r#   r   r8   r   )
r9   r   r   freshnowr   cutoffduert   lasts
             r   hits_due_for_bannerr   s  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_   rv   r8   extendrz   )r9   r   rr   rx   rt   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   rs   rg   r   )r9   r   rr   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.Nrk   r   zSecurity advisory [z
] active: rj   z	 matches z. See z" security advisories active (IDs: z, c              3  .   K   | ]}|j         j        V  d S )Nr[   )rD   r\   s     r   	<genexpr>z&gateway_log_message.<locals>.<genexpr>  s&      <<qz}<<<<<<r   z7). Run `hermes doctor` on the gateway host for details.)	r_   ro   r#   r   r$   r%   r   r   r   )r9   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(   )r4   r5   r'   r6   )r'   r=   )rP   r   r'   rQ   )r9   r6   r'   r6   )r'   rQ   )r9   r6   r'   rh   )rt   r"   r'   rh   )r'   r}   )r'   r   )r   r   r'   r   )r9   r6   r   r   r'   r6   )r9   r6   r'   r   )r9   r6   r'   r(   )&r   
__future__r   loggingrb   rd   dataclassesr   pathlibr   typingr   r   	getLoggerr   r1   r
   	frozensetr    r   r"   r3   r<   rO   rX   r_   rg   rs   rz   r   _BANNER_REPEAT_HOURSr   r   r   r   r   r   r   r   r   r   <module>r      s    B # " " " " "  				 



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