Passa al contenuto principale

Obbligazioni del Credential Scanner

Questa pagina documenta le quattro obbligazioni di sicurezza applicabili ad ogni PR che tocca src/zenzic/core/. Una PR che risolve un bug senza soddisfare tutte e quattro verrà rifiutata dall'Architecture Lead.

Queste regole esistono perché una security review ha dimostrato che quattro scelte di design individualmente ragionevoli — ognuna corretta in isolamento — si sono composte in quattro vettori di attacco distinti. Vedi docs/internal/security/shattered_mirror_report.md per il post-mortem completo.


Obbligazione 1 — La Tassa di Sicurezza (Worker Timeout)

Qualsiasi PR che modifica l'uso di ProcessPoolExecutor in scanner.py deve preservare la chiamata future.result(timeout=_WORKER_TIMEOUT_S). Il timeout corrente è 30 secondi.

# ✅ Forma richiesta — usa sempre submit() + wait(FIRST_COMPLETED) + result(timeout=...)
futures_map = {executor.submit(_worker, item): item[0] for item in work_items}
raw: list[IntegrityReport] = []
_pending: set[concurrent.futures.Future[IntegrityReport]] = set(futures_map)
while _pending:
done, _pending = concurrent.futures.wait(
_pending,
timeout=_WORKER_TIMEOUT_S,
return_when=concurrent.futures.FIRST_COMPLETED,
)
if not done:
# ZRT-002 deadlock guard: nessun worker completato entro la finestra di timeout
for fut in _pending:
raw.append(_make_timeout_report(futures_map[fut])) # finding Z902
fut.cancel()
break
for fut in done:
raw.append(fut.result())

# ❌ Vietato — blocca indefinitamente su ReDoS o worker in deadlock
raw = list(executor.map(_worker, work_items))

Il finding Z902 (WORKER_TIMEOUT) non è un crash — emerge nell'UI standard del report. Un worker che va in timeout non termina la scansione; il coordinatore continua con i worker rimanenti.

Se la tua modifica richiede un timeout più lungo, aumenta _WORKER_TIMEOUT_S con un commento che spieghi il costo e un benchmark che dimostri il worst-case input.


Obbligazione 2 — Il Protocollo Regex-Canary

Ogni entry [[custom_rules]] che specifica un pattern è soggetta al Regex-Canary, un test di stress basato su POSIX SIGALRM che viene eseguito alla costruzione di AdaptiveRuleEngine.

# _assert_regex_canary() in rules.py — eseguito automaticamente per ogni CustomRule
_CANARY_STRINGS = (
"a" * 30 + "b", # trigger classico (a+)+
"A" * 25 + "!", # variante maiuscola
"1" * 20 + "x", # variante numerica
)
_CANARY_TIMEOUT_S = 0.1 # 100 ms

Testa il tuo pattern prima del commit:

from pathlib import Path
from zenzic.core.rules import CustomRule, _assert_regex_canary
from zenzic.core.exceptions import PluginContractError

rule = CustomRule(
id="MY-001",
pattern=r"tuo-pattern-qui",
message="Trovato.",
severity="warning",
)

try:
_assert_regex_canary(rule)
print("✅ Canary superato — pattern sicuro per produzione")
except PluginContractError as e:
print(f"❌ Canary fallito — rischio ReDoS rilevato:\n{e}")

Pattern da evitare (trigger di backtracking catastrofico):

PatternPerché pericoloso
(a+)+Quantificatori annidati — percorsi esponenziali
(a|aa)+Alternazione con sovrapposizione
(a*)*Star annidato — match vuoti infiniti
.+foo.+barMulti-wildcard greedy con suffisso

Pattern sempre sicuri:

PatternNote
TODOMatch letterale, O(n)
^(DRAFT|WIP):Alternazione ancorata, O(1) per posizione
[A-Z]{3}-\d+Classi di caratteri delimitate
\bfoo\bAncorato a word-boundary

Nota piattaforma: _assert_regex_canary() usa signal.SIGALRM, disponibile solo su sistemi POSIX (Linux, macOS). Su Windows il canary è un no-op. Il worker timeout (Obbligazione 1) è il backstop universale.


Obbligazione 3 — L'Invariante Dual-Stream

Lo stream del credential scanner e lo stream Content in ReferenceScanner.harvest() non devono mai condividere un generator. Questa è la lezione architetturale di ZRT-001.

# ✅ CORRETTO — generator indipendenti, contratti di filtering indipendenti
with file_path.open(encoding="utf-8") as fh:
for lineno, line in enumerate(fh, start=1): # Credential scanner: TUTTE le righe
list(scan_line_for_secrets(line, file_path, lineno))

for lineno, line in _iter_content_lines(file_path): # Content: filtrato
...

# ❌ VIETATO — condividere un generator scarta silenziosamente il frontmatter dal credential scanner
with file_path.open(encoding="utf-8") as fh:
shared = _skip_frontmatter(fh)
for lineno, line in shared:
list(scan_line_for_secrets(...)) # ← cieco al frontmatter
for lineno, line in shared: # ← già esaurito
...

Baseline di performance: il dual-scan (riga raw + normalizzata) gira a circa 235 000 righe/secondo (12.74 ms mediana per 3 000 righe su 20 iterazioni). Se una PR rifattorizza harvest() e il throughput CI scende sotto 100 000 righe/secondo, indagare prima del merge.


Obbligazione 4 — Mutation Score ≥ 90% per Modifiche al Core

Qualsiasi PR che modifica src/zenzic/core/ deve mantenere o migliorare il mutation score sul modulo interessato. La baseline corrente per rules.py è 86.7% (242/279 mutanti eliminati). Target per rc1: ≥ 90%.

nox -s mutation

La sessione punta a rules.py, credentials.py e reporter.py. Qualsiasi PR che tocca la funzione di conversione _map_credentials_to_finding(), il percorso della severity SECURITY_BREACH in ZenzicReporter, o il routing del codice di uscita in cli.py deve eliminare tutti e tre i mutanti obbligatori:

Nome mutanteCosa viene cambiatoTest che deve eliminarlo
The Invisibleseverity="security_breach"severity="warning" in _map_credentials_to_finding()test_map_always_emits_security_breach_severity
The Amnesiac_obfuscate_secret() restituisce raw invece della forma redattatest_obfuscate_never_leaks_raw_secret
The Silencer_map_credentials_to_finding() restituisce None invece di un Findingtest_pipeline_appends_breach_finding_to_list

Validazione pickle di ResolutionContext: qualsiasi PR che aggiunge un campo a ResolutionContext deve includere:

def test_resolution_context_is_pickleable():
import pickle
ctx = ResolutionContext(docs_root=Path("/docs"), source_file=Path("/docs/a.md"))
assert pickle.loads(pickle.dumps(ctx)) == ctx

Integrità dei report: un segreto rilevato ma non riportato correttamente è un bug CRITICO — indistinguibile da un segreto che non è mai stato rilevato.