diff --git a/src/skillspector/artifacts.py b/src/skillspector/artifacts.py index 0d44476e7..231b44163 100644 --- a/src/skillspector/artifacts.py +++ b/src/skillspector/artifacts.py @@ -88,6 +88,8 @@ class SecurityTextView: text: str source_offsets: array[int] | None = None reconstructions: tuple[SecurityTextReconstruction, ...] = () + right_boundary_is_fixed: bool = False + right_boundary_recovery_start: int | None = None def source_offset(self, derived_offset: int) -> int: """Map a derived character offset to the corresponding source offset.""" @@ -214,6 +216,7 @@ class _ObfuscatedIgnoreState: ".markdown", ".txt", ".py", + ".pyw", ".sh", ".json", ".yaml", @@ -374,6 +377,20 @@ def classify_artifact(path: str, data: bytes, *, referenced: bool = False) -> Ar } +def promote_artifact_to_decoded_text(artifact: ArtifactRecord) -> None: + """Apply a successful format-aware text decode without erasing prior limits.""" + generic_binary_scope = artifact["content_kind"] is ContentKind.BINARY and ( + artifact["disposition"] is ArtifactDisposition.OUT_OF_SCOPE + or artifact["disposition"] is ArtifactDisposition.PARTIAL + and "reason" not in artifact + ) + artifact["content_kind"] = ContentKind.TEXT + artifact["decodable"] = True + artifact["misleading_extension"] = _suffix(artifact["path"]) in _BINARY_EXTENSIONS + if generic_binary_scope: + artifact["disposition"] = ArtifactDisposition.ANALYZED + + def decode_text(data: bytes) -> str: """Return the loss-tolerant local text projection for static analyzers.""" return data.decode("utf-8", errors="replace") @@ -1626,28 +1643,37 @@ def _contextual_default_ignorable_spans(text: str) -> Iterator[tuple[int, int]]: yield span_start, end +def _normalization_ignored_spans_in_gap( + text: str, + gap_start: int, + gap_end: int, +) -> Iterator[tuple[int, int]]: + """Yield the normalized-view removals inside one contextual gap.""" + for match in _DEFAULT_IGNORABLE_RUN_PATTERN.finditer(text, gap_start, gap_end): + start, end = match.span() + ignored_start = ( + start + if _is_unconditionally_ignored(text[start]) + or _is_contextual_default_ignorable_offset(text, start) + else start + 1 + ) + ignored_end = ( + end + if _is_unconditionally_ignored(text[end - 1]) + or _is_contextual_default_ignorable_offset(text, end - 1) + else end - 1 + ) + if ignored_start < ignored_end: + yield ignored_start, ignored_end + + def _normalization_ignored_spans(text: str) -> Iterator[tuple[int, int]]: """Yield whole default-ignorable runs removable by the normalized view.""" for gap_start, gap_end in _token_bridging_gap_spans( text, require_word_boundaries=False, ): - for match in _DEFAULT_IGNORABLE_RUN_PATTERN.finditer(text, gap_start, gap_end): - start, end = match.span() - ignored_start = ( - start - if _is_unconditionally_ignored(text[start]) - or _is_contextual_default_ignorable_offset(text, start) - else start + 1 - ) - ignored_end = ( - end - if _is_unconditionally_ignored(text[end - 1]) - or _is_contextual_default_ignorable_offset(text, end - 1) - else end - 1 - ) - if ignored_start < ignored_end: - yield ignored_start, ignored_end + yield from _normalization_ignored_spans_in_gap(text, gap_start, gap_end) def _contextual_default_ignorable_offsets(text: str) -> Iterator[int]: @@ -1727,6 +1753,55 @@ def normalized_security_view(text: str) -> SecurityTextView: return SecurityTextView("normalized", output.getvalue(), offsets) +def normalized_security_prefix(text: str, max_chars: int) -> str: + """Return an exact bounded prefix of the normalized security projection.""" + if max_chars <= 0: + return "" + + output = StringIO() + output_chars = 0 + + def append(character: str) -> bool: + nonlocal output_chars + normalized = unicodedata.normalize("NFKC", character).translate(ASCII_CONFUSABLE_SKELETON) + remaining = max_chars - output_chars + output.write(normalized[:remaining]) + output_chars += min(len(normalized), remaining) + return output_chars >= max_chars + + source_offset = 0 + while source_offset < len(text) and output_chars < max_chars: + if not _is_token_gap_character(text[source_offset]): + if append(text[source_offset]): + break + source_offset += 1 + continue + + gap_start = source_offset + while source_offset < len(text) and _is_token_gap_character(text[source_offset]): + source_offset += 1 + gap_end = source_offset + before_is_word = gap_start > 0 and _is_word_character(text[gap_start - 1]) + after_is_word = gap_end < len(text) and _is_word_character(text[gap_end]) + ignored_spans = iter( + _normalization_ignored_spans_in_gap(text, gap_start, gap_end) + if before_is_word or after_is_word + else () + ) + next_ignored = next(ignored_spans, None) + gap_offset = gap_start + while gap_offset < gap_end and output_chars < max_chars: + if next_ignored is not None and gap_offset == next_ignored[0]: + gap_offset = next_ignored[1] + next_ignored = next(ignored_spans, None) + continue + if not _is_unconditionally_ignored(text[gap_offset]) and append(text[gap_offset]): + break + gap_offset += 1 + + return output.getvalue() + + def obfuscated_instruction_view(text: str) -> SecurityTextView: """Normalize text while removing only context-bound instruction fillers.""" output = StringIO() @@ -1997,6 +2072,35 @@ def _requires_normalized_security_view(text: str) -> bool: return not text.translate(_REMOVE_ALLOWED_FORMAT_CHARACTERS).isprintable() +def _has_derived_security_view(text: str) -> bool: + """Return whether security projection produces a distinct text view.""" + if text.isascii(): + return ( + _IGNORED_ASCII_CONTROL.search(text) is not None + or _has_letter_spacing_run(text) + or next(_obfuscated_instruction_matches(text), None) is not None + ) + if any( + unicodedata.normalize("NFKC", character).translate(ASCII_CONFUSABLE_SKELETON) != character + for character in text + ): + return True + if any(_is_unconditionally_ignored(character) for character in text): + return True + if next(_normalization_ignored_spans(text), None) is not None: + return True + if "\ufffd" in text or next(_compact_gap_offsets(text), None) is not None: + return True + return ( + _has_letter_spacing_run(text) + or next( + _obfuscated_instruction_matches(text), + None, + ) + is not None + ) + + def security_text_views(text: str) -> tuple[SecurityTextView, ...]: """Return distinct raw, normalized, and compact views deterministically.""" raw = SecurityTextView("raw", text) diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index 4cb6ef33c..3a4d1b520 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -176,6 +176,7 @@ class _CachedTransitiveResult: artifact_inventory: list[dict[str, object]] artifact_references: list[dict[str, object]] has_executable_scripts: bool + execution_successful: bool refs: list[str] @@ -188,23 +189,30 @@ class _TransitiveTraversalState: scanned_bytes: int = 0 scanned_artifacts: int = 0 truncation_reasons: list[str] = field(default_factory=list) + resource_limit_reached: bool = False budget_exhausted: bool = False paused_at: float | None = None def note_truncation(self, reason: str) -> None: + self.resource_limit_reached = True + self._note_incomplete(reason) + + def exhaust_traversal(self, reason: str) -> None: + """Record a limit that prevents additional target execution.""" + self.budget_exhausted = True + self.note_truncation(reason) + + def _note_incomplete(self, reason: str) -> None: if len(self.truncation_reasons) >= 256: sentinel = "additional transitive limitations omitted" if self.truncation_reasons[-1] != sentinel: self.truncation_reasons[-1] = sentinel - self.budget_exhausted = True return if reason not in self.truncation_reasons: self.truncation_reasons.append(reason) - if "budget" in reason or "time budget" in reason: - self.budget_exhausted = True def note_child_scan_failure(self, target: str) -> None: - self.note_truncation(f"transitive child scan failed for {target}") + self._note_incomplete(f"transitive child scan failed for {target}") def _ensure_started(self) -> None: if self.started_at is None: @@ -215,16 +223,16 @@ def can_scan_more(self) -> bool: if self.budget_exhausted: return False if self.scanned_targets >= self.budget.max_targets: - self.note_truncation(f"target budget {self.budget.max_targets} reached") + self.exhaust_traversal(f"target budget {self.budget.max_targets} reached") return False if self.remaining_bytes() <= 0: - self.note_truncation(f"byte budget {self.budget.max_bytes} reached") + self.exhaust_traversal(f"byte budget {self.budget.max_bytes} reached") return False if self.remaining_artifacts() <= 0: - self.note_truncation(f"artifact budget {self.budget.max_artifacts} reached") + self.exhaust_traversal(f"artifact budget {self.budget.max_artifacts} reached") return False if self.remaining_seconds() <= 0: - self.note_truncation(f"time budget {self.budget.max_seconds:.0f}s reached") + self.exhaust_traversal(f"time budget {self.budget.max_seconds:.0f}s reached") return False return True @@ -232,9 +240,9 @@ def record_scan(self) -> None: self._ensure_started() self.scanned_targets += 1 if self.remaining_bytes() <= 0: - self.note_truncation(f"byte budget {self.budget.max_bytes} reached") + self.exhaust_traversal(f"byte budget {self.budget.max_bytes} reached") if self.remaining_seconds() <= 0: - self.note_truncation(f"time budget {self.budget.max_seconds:.0f}s reached") + self.exhaust_traversal(f"time budget {self.budget.max_seconds:.0f}s reached") def record_bytes(self, bytes_scanned: int) -> None: self._ensure_started() @@ -256,7 +264,7 @@ def record_artifacts(self, artifacts: int) -> None: self._ensure_started() self.scanned_artifacts += max(0, artifacts) if self.scanned_artifacts > self.budget.max_artifacts: - self.note_truncation(f"artifact budget {self.budget.max_artifacts} reached") + self.exhaust_traversal(f"artifact budget {self.budget.max_artifacts} reached") def pause_deadline(self) -> None: if self.started_at is not None and self.paused_at is None: @@ -629,6 +637,9 @@ def scan( raise typer.Exit(code=2) from exc yara_dir = str(yara_rules_dir.resolve()) if yara_rules_dir else None pre_scan_ledger_events: list[dict[str, object]] = [] + discovery_console = ( + err_console if output is None and format is not FormatChoice.terminal else console + ) if recursive and resolved_path.is_dir(): detection = detect_skills(resolved_path) if not detection.complete: @@ -662,7 +673,7 @@ def scan( ) return if detection.complete and not detection.has_root_skill and len(detection.skills) == 0: - (err_console if format in _MACHINE_READABLE_FORMATS else console).print( + discovery_console.print( "[yellow]Warning:[/yellow] --recursive specified but no sub-skills " "detected. Scanning as single skill." ) @@ -675,7 +686,7 @@ def scan( "with a bounded scan and reporting partial coverage." ) if detection.is_multi_skill: - (err_console if format in _MACHINE_READABLE_FORMATS else console).print( + discovery_console.print( f"[yellow]Warning:[/yellow] Found {len(detection.skills)} skills in " f"this directory. Use --recursive to scan each independently." ) @@ -1189,6 +1200,7 @@ def _scope_finding(finding: Finding) -> Finding: source_digest=source_digest, finding_id_map=finding_id_map, ) + required_failure_events = _distinct_failed_ledger_events(scoped_ledger) retained_finding_ids = {item.finding_id for item in scoped_findings} for event in scoped_ledger: for id_field in ("input_finding_ids", "emitted_finding_ids"): @@ -1220,6 +1232,14 @@ def _scope_finding(finding: Finding) -> Finding: limit=traversal.budget.max_ledger_events, traversal=traversal, ) + if required_failure_events: + scoped_ledger = _ensure_required_failure_events( + scoped_ledger, + required_failure_events, + limit=traversal.budget.max_ledger_events, + traversal=traversal, + failures_already_observed=True, + ) retained_work_ids = { str(event.get("work_id", "")) for event in scoped_ledger if event.get("work_id") } @@ -1280,6 +1300,7 @@ def _scope_finding(finding: Finding) -> Finding: ), has_executable_scripts=bool(child_result.get("has_executable_scripts", False)) or any(bool(entry.get("executable", False)) for entry in child_metadata), + execution_successful=child_result.get("execution_successful") is not False, refs=extraction.references, ) @@ -1677,6 +1698,124 @@ def _merge_bounded_ledger( ] +def _is_failed_ledger_event(event: dict[str, object]) -> bool: + outcome = event.get("outcome") + return getattr(outcome, "value", outcome) == LedgerOutcome.FAILED.value + + +def _distinct_failed_ledger_events( + events: list[dict[str, object]], +) -> list[dict[str, object]]: + """Return failed work items once each, preserving their observed order.""" + failures: list[dict[str, object]] = [] + work_ids: set[str] = set() + for event in events: + work_id = str(event.get("work_id", "")) + if not _is_failed_ledger_event(event) or work_id in work_ids: + continue + failures.append(event) + work_ids.add(work_id) + return failures + + +def _transitive_child_failure_event(source_identity: str) -> dict[str, object]: + """Return one deterministic, payload-free fatal fact for an opaque child failure.""" + return dict( + ledger_event( + outcome=LedgerOutcome.FAILED, + record_type=LedgerRecordType.SYSTEM, + phase="transitive_child_scan", + path=f"{source_identity}/SKILL.md", + reason=LedgerReason.TRANSITIVE_CHILD_SCAN_FAILED, + ) + ) + + +def _ensure_required_failure_events( + events: list[dict[str, object]], + failures: list[dict[str, object]], + *, + limit: int, + traversal: _TransitiveTraversalState, + failures_already_observed: bool = False, +) -> list[dict[str, object]]: + """Retain distinct fatal facts before non-fatal rows at the shared bound. + + Pre-observed failures came from the input that produced an existing sentinel; + synthesized failures are new observations and remain the required fatal facts. + """ + effective_limit = max(1, limit) + incoming_failures = _distinct_failed_ledger_events(failures) + required_failures = _distinct_failed_ledger_events([*events, *failures]) + if not required_failures: + return events[:effective_limit] + required_work_ids = {str(event.get("work_id", "")) for event in required_failures} + event_work_ids = {str(event.get("work_id", "")) for event in events} + missing_failures = [ + event for event in required_failures if str(event.get("work_id", "")) not in event_work_ids + ] + + prior_sentinel = next( + (event for event in reversed(events) if event.get("phase") == "ledger_output"), + None, + ) + would_overflow = len(events) + len(missing_failures) > effective_limit + if prior_sentinel is None and not would_overflow: + combined = [*events, *missing_failures] + if len(combined) < effective_limit: + return combined + non_failures = [ + event for event in combined if str(event.get("work_id", "")) not in required_work_ids + ] + return [*required_failures, *non_failures][:effective_limit] + + traversal.note_truncation(f"inspection ledger budget {effective_limit} reached") + if effective_limit == 1: + return (incoming_failures or required_failures)[:1] + observed_value = prior_sentinel.get("observed_records") if prior_sentinel else None + observed_records = observed_value if isinstance(observed_value, int) else len(events) + newly_observed = ( + 0 if prior_sentinel is not None and failures_already_observed else len(missing_failures) + ) + sentinel_path = ( + str(prior_sentinel.get("path", "SKILL.md")) + if prior_sentinel is not None and failures_already_observed + else str((incoming_failures or required_failures)[0].get("path", "SKILL.md")) + ) + sentinel = dict( + ledger_event( + outcome=LedgerOutcome.PARTIAL, + record_type=LedgerRecordType.SYSTEM, + phase="ledger_output", + path=sentinel_path, + reason=LedgerReason.OUTPUT_LIMIT, + observed_records=max(observed_records, len(events)) + newly_observed, + limit_records=effective_limit, + ) + ) + failure_slots = effective_limit - 1 + if failures_already_observed: + retained_failures = required_failures[:failure_slots] + else: + incoming_work_ids = {str(event.get("work_id", "")) for event in incoming_failures} + retained_incoming = incoming_failures[:failure_slots] + retained_existing = [ + event + for event in required_failures + if str(event.get("work_id", "")) not in incoming_work_ids + ][: failure_slots - len(retained_incoming)] + retained_failures = [*retained_existing, *retained_incoming] + retained_work_ids = {str(event.get("work_id", "")) for event in retained_failures} + retained_non_failures = [ + event + for event in events + if event.get("phase") != "ledger_output" + and str(event.get("work_id", "")) not in retained_work_ids + and not _is_failed_ledger_event(event) + ][: failure_slots - len(retained_failures)] + return [*retained_failures, *retained_non_failures, sentinel] + + def _scan_transitive( initial_result: dict[str, object], format: FormatChoice, @@ -1729,12 +1868,22 @@ def _scan_transitive( merged_effective_finding_ids = _effective_finding_ids(initial_result)[ : traversal.budget.max_findings ] + root_inspection_ledger = _coerce_dict_list(initial_result.get("inspection_ledger")) + required_root_failure_events = _distinct_failed_ledger_events(root_inspection_ledger) merged_inspection_ledger = _merge_bounded_ledger( [], - _coerce_dict_list(initial_result.get("inspection_ledger")), + root_inspection_ledger, limit=traversal.budget.max_ledger_events, traversal=traversal, ) + if required_root_failure_events: + merged_inspection_ledger = _ensure_required_failure_events( + merged_inspection_ledger, + required_root_failure_events, + limit=traversal.budget.max_ledger_events, + traversal=traversal, + failures_already_observed=True, + ) retained_work_ids = { str(event.get("work_id", "")) for event in merged_inspection_ledger if event.get("work_id") } @@ -1837,6 +1986,7 @@ def _scan_transitive( cache_key = (target, source_local_only) cached = traversal.cache.get(cache_key) if cached is None: + traversal.record_scan() child_result = _run_graph_scan_for_source( input_path=target, format=format, @@ -1852,15 +2002,17 @@ def _scan_transitive( ) cached = _cache_transitive_result(target, child_result, traversal) traversal.cache[cache_key] = cached - traversal.record_scan() - if child_result.get("execution_successful") is False: - traversal.note_child_scan_failure(target) - child_completeness = child_result.get("analysis_completeness") - if ( - isinstance(child_completeness, dict) - and child_completeness.get("is_complete") is False + if not cached.execution_successful: + traversal.note_child_scan_failure(target) + if not any( + _is_failed_ledger_event(event) for event in cached.inspection_ledger ): - traversal.note_truncation(f"transitive child scan incomplete for {target}") + cached.inspection_ledger = _ensure_required_failure_events( + cached.inspection_ledger, + [_transitive_child_failure_event(cached.source_identity)], + limit=traversal.budget.max_ledger_events, + traversal=traversal, + ) transitive_sources.add(target) merged_inspection_ledger = _merge_bounded_ledger( merged_inspection_ledger, @@ -1868,6 +2020,15 @@ def _scan_transitive( limit=traversal.budget.max_ledger_events, traversal=traversal, ) + child_failure_events = _distinct_failed_ledger_events(cached.inspection_ledger) + if child_failure_events: + merged_inspection_ledger = _ensure_required_failure_events( + merged_inspection_ledger, + child_failure_events, + limit=traversal.budget.max_ledger_events, + traversal=traversal, + failures_already_observed=True, + ) global_work_ids = { str(event.get("work_id", "")) for event in merged_inspection_ledger @@ -2022,7 +2183,17 @@ def _scan_transitive( except Exception: transitive_sources.add(target) traversal.note_child_scan_failure(target) - if format in _MACHINE_READABLE_FORMATS: + merged_inspection_ledger = _ensure_required_failure_events( + merged_inspection_ledger, + [ + _transitive_child_failure_event( + _source_identity(target, "transitive-child-scan-failed") + ) + ], + limit=traversal.budget.max_ledger_events, + traversal=traversal, + ) + if format in _MACHINE_READABLE_FORMATS or format is FormatChoice.markdown: logger.warning("Transitive scan failed for %s", target) else: console.print(f"[yellow]Warning:[/yellow] Transitive scan failed for {target}") @@ -2045,7 +2216,8 @@ def _scan_transitive( traversal=traversal, ) - if traversal.truncation_reasons: + if traversal.resource_limit_reached: + required_failure_events = _distinct_failed_ledger_events(merged_inspection_ledger) traversal_event = ledger_event( outcome=LedgerOutcome.PARTIAL, record_type=LedgerRecordType.SYSTEM, @@ -2059,6 +2231,23 @@ def _scan_transitive( limit=traversal.budget.max_ledger_events, traversal=traversal, ) + if required_failure_events: + merged_inspection_ledger = _ensure_required_failure_events( + merged_inspection_ledger, + required_failure_events, + limit=traversal.budget.max_ledger_events, + traversal=traversal, + failures_already_observed=True, + ) + + retained_work_ids = { + str(event.get("work_id", "")) for event in merged_inspection_ledger if event.get("work_id") + } + merged_analyzer_status_events = _bounded_root_status_events( + merged_analyzer_status_events, + retained_work_ids=retained_work_ids, + limit=traversal.budget.max_status_events, + ) merged_result: dict[str, object] = { **initial_result, @@ -2095,6 +2284,7 @@ def _scan_transitive( result=merged_result, discovered_modules=ANALYZER_MODULES, ) + pre_runtime_failure_events = _distinct_failed_ledger_events(merged_inspection_ledger) if runtime_event is not None and not has_semantic_runtime_event( merged_inspection_ledger, runtime_event ): @@ -2104,6 +2294,14 @@ def _scan_transitive( limit=traversal.budget.max_ledger_events, traversal=traversal, ) + if pre_runtime_failure_events: + merged_inspection_ledger = _ensure_required_failure_events( + merged_inspection_ledger, + pre_runtime_failure_events, + limit=traversal.budget.max_ledger_events, + traversal=traversal, + failures_already_observed=True, + ) merged_result["inspection_ledger"] = merged_inspection_ledger if merged_inspection_ledger or merged_analyzer_status_events: completeness, effective_ids = finalize_ledger(merged_result) @@ -2154,9 +2352,8 @@ def _scan_skill( yara_dir = str(yara_rules_dir.resolve()) if yara_rules_dir else None active_visited: set[str] = set() if verbose: - (err_console if format in _MACHINE_READABLE_FORMATS else console).print( - "[dim]Running scan...[/dim]" - ) + progress_console = console if format is FormatChoice.terminal else err_console + progress_console.print("[dim]Running scan...[/dim]") logger.debug( "Scan started: input_path=%s, format=%s, use_llm=%s, transitive=%s", input_path, @@ -2270,10 +2467,121 @@ def _multi_skill_analysis_completeness( } +_RECURSIVE_SERIALIZED_OUTPUT_LIMIT_PREFIX = "recursive serialized report character budget " +_RECURSIVE_CHILD_SCAN_FAILED_MESSAGE = "A recursive child scan failed before complete inspection." + + +def _multi_skill_sarif_notifications( + completeness: dict[str, object], +) -> list[dict[str, object]]: + """Project exact aggregate outcomes without inventing an output-limit cause.""" + notifications: list[dict[str, object]] = [] + status = str(completeness.get("status", "partial")) + raw_limitations = completeness.get("limitations") + limitations = ( + [str(item) for item in raw_limitations if isinstance(item, str)] + if isinstance(raw_limitations, list) + else [] + ) + if status == "failed": + notifications.append( + { + "message": {"text": "One or more recursive skill scans failed."}, + "level": "error", + "properties": {"kind": "inspection_failure"}, + } + ) + for limitation in limitations: + properties: dict[str, object] = {"kind": "inspection_limitation"} + if limitation.startswith(_RECURSIVE_SERIALIZED_OUTPUT_LIMIT_PREFIX): + properties["reasonCode"] = LedgerReason.OUTPUT_LIMIT.value + notifications.append( + { + "message": {"text": limitation}, + "level": "warning", + "properties": properties, + } + ) + if status == "partial" and not limitations: + notifications.append( + { + "message": { + "text": "One or more recursive skill scans were incomplete; " + "see child run notifications." + }, + "level": "warning", + "properties": {"kind": "inspection_limitation"}, + } + ) + return notifications + + +def _multi_skill_text_completeness(completeness: dict[str, object]) -> str: + """Render the aggregate state without downgrading a failed child to partial.""" + status = str(completeness.get("status", "partial")) + raw_limitations = completeness.get("limitations") + limitations = ( + [str(item) for item in raw_limitations if isinstance(item, str)] + if isinstance(raw_limitations, list) + else [] + ) + if status == "failed": + limitations.insert(0, "One or more recursive skill scans failed.") + elif status == "partial" and not limitations: + limitations.append("One or more recursive skill scans were incomplete.") + details = "\n".join(f"- {item}" for item in limitations) + return f"--- Recursive Inspection Completeness ---\n\nStatus: {status}\n\n{details}" + + +def _multi_skill_risk_assessment( + max_score: int, + *, + execution_failed: bool, + analysis_incomplete: bool, +) -> dict[str, object]: + """Return bounded aggregate risk evidence independent of child retention.""" + if max_score >= 81: + severity = "CRITICAL" + elif max_score >= 51: + severity = "HIGH" + elif max_score >= 21: + severity = "MEDIUM" + else: + severity = "LOW" + recommendation = ( + "DO_NOT_INSTALL" + if execution_failed or max_score > RISK_THRESHOLD + else "CAUTION" + if analysis_incomplete + else "SAFE" + ) + return { + "max_risk_score": max_score, + "severity": severity, + "recommendation": recommendation, + } + + +def _multi_skill_text_summary( + completeness: dict[str, object], + risk_assessment: dict[str, object], +) -> str: + """Render aggregate risk and completeness even when child bodies are omitted.""" + recommendation = str(risk_assessment.get("recommendation", "CAUTION")).replace("_", " ") + risk = ( + "--- Recursive Risk Assessment ---\n\n" + f"Maximum score: {risk_assessment.get('max_risk_score', 0)}/100\n\n" + f"Severity: {risk_assessment.get('severity', 'LOW')}\n\n" + f"Recommendation: {recommendation}" + ) + return f"{risk}\n\n{_multi_skill_text_completeness(completeness)}" + + def _multi_skill_sarif_report( processed_skills: list[SkillDirectory], results: list[dict[str, object]], completeness: dict[str, object], + risk_assessment: dict[str, object] | None = None, ) -> dict[str, object]: """Merge bounded child SARIF runs and append one aggregate invocation run.""" runs: list[dict[str, object]] = [] @@ -2300,23 +2608,20 @@ def _multi_skill_sarif_report( run["properties"] = run_properties runs.append(run) + invocation_properties: dict[str, object] = {"analysisCompleteness": completeness} + if risk_assessment is not None: + invocation_properties["riskAssessment"] = { + "maxRiskScore": risk_assessment.get("max_risk_score", 0), + "severity": risk_assessment.get("severity", "LOW"), + "recommendation": risk_assessment.get("recommendation", "CAUTION"), + } aggregate_invocation: dict[str, object] = { "executionSuccessful": bool(completeness.get("execution_successful", False)), - "properties": {"analysisCompleteness": completeness}, + "properties": invocation_properties, } - if not bool(completeness.get("is_complete", False)): - aggregate_invocation["toolExecutionNotifications"] = [ - { - "message": { - "text": "Recursive analysis was incomplete after an aggregate safety limit." - }, - "level": "warning", - "properties": { - "kind": "inspection_limitation", - "reasonCode": "output_limit", - }, - } - ] + notifications = _multi_skill_sarif_notifications(completeness) + if notifications: + aggregate_invocation["toolExecutionNotifications"] = notifications runs.append( { "tool": {"driver": {"name": "skillspector", "version": __version__}}, @@ -2380,10 +2685,10 @@ def _scan_multi_skill( if yara_dir is None and isinstance(legacy_kwargs.get("yara_rules_dir"), Path): yara_dir = str(legacy_kwargs["yara_rules_dir"]) skills = detection.skills - status_console = ( - err_console if format in _MACHINE_READABLE_FORMATS and output is None else console + progress_console = ( + err_console if output is None and format is not FormatChoice.terminal else console ) - status_console.print( + progress_console.print( f"[bold]Multi-skill directory detected:[/bold] {len(skills)} skills found\n" ) @@ -2435,9 +2740,10 @@ def _scan_multi_skill( analysis_incomplete = True aggregate_limitations.extend(shared_transitive_traversal.truncation_reasons) break - status_console.print( + progress_console.print( f" [{i}/{len(skills)}] Scanning [bold]{skill.name}[/bold] ({skill.relative_path}/)" ) + result: dict[str, object] | None = None try: result = _scan_skill( input_path=str(skill.path), @@ -2455,76 +2761,98 @@ def _scan_multi_skill( transitive_traversal=shared_transitive_traversal, source_local_only=skill.local_only, ) + child_failed = result.get("execution_successful") is False + completeness_value = result.get("analysis_completeness") + child_partial = ( + not child_failed + and isinstance(completeness_value, dict) + and not bool(completeness_value.get("is_complete", True)) + ) + score = result.get("risk_score") or 0 + try: + score = int(score) + except (TypeError, ValueError): + score = 0 + child_transitive_count = result.get("transitive_finding_count") + child_transitive_increment = ( + child_transitive_count if isinstance(child_transitive_count, int) else 0 + ) + child_transitive_sources = _coerce_str_path_list(result.get("transitive_sources")) + severity = result.get("risk_severity") or "LOW" + progress_console.print(f" Score: {score}/100 ({severity})\n") + result_body = _result_body(result) result_characters = len(result_body) result_records = _multi_skill_public_record_count(result) - has_findings = has_findings or bool(effective_findings(result)) - if ( + child_has_findings = bool(effective_findings(result)) + exceeds_record_limit = ( retained_public_records + result_records > _MULTI_SKILL_MAX_PUBLIC_RECORDS - or retained_report_characters + result_characters - > _MULTI_SKILL_MAX_REPORT_CHARACTERS - ): + ) + exceeds_character_limit = ( + retained_report_characters + result_characters > _MULTI_SKILL_MAX_REPORT_CHARACTERS + ) + if exceeds_record_limit or exceeds_character_limit: + cleanup_result(result) + + if child_failed: + execution_failed = True + failed_skill_count += 1 + elif child_partial: + analysis_incomplete = True + partial_skill_count += 1 + else: + complete_skill_count += 1 + max_score = max(max_score, score) + transitive_finding_count += child_transitive_increment + transitive_sources.update(child_transitive_sources) + has_findings = has_findings or child_has_findings + + if exceeds_record_limit or exceeds_character_limit: analysis_incomplete = True - if retained_public_records + result_records > _MULTI_SKILL_MAX_PUBLIC_RECORDS: + if exceeds_record_limit: aggregate_limitations.append( "recursive public finding record budget " f"{_MULTI_SKILL_MAX_PUBLIC_RECORDS} reached" ) - if ( - retained_report_characters + result_characters - > _MULTI_SKILL_MAX_REPORT_CHARACTERS - ): + if exceeds_character_limit: aggregate_limitations.append( "recursive report character budget " f"{_MULTI_SKILL_MAX_REPORT_CHARACTERS} reached" ) - cleanup_result(result) break results.append(result) processed_skills.append(skill) retained_public_records += result_records retained_report_characters += result_characters - child_failed = result.get("execution_successful") is False - if child_failed: - execution_failed = True - failed_skill_count += 1 - completeness_value = result.get("analysis_completeness") - if ( - not child_failed - and isinstance(completeness_value, dict) - and not bool(completeness_value.get("is_complete", True)) - ): - analysis_incomplete = True - partial_skill_count += 1 - elif not child_failed: - complete_skill_count += 1 - score = result.get("risk_score") or 0 - try: - score = int(score) - except (TypeError, ValueError): - score = 0 - if score > max_score: - max_score = score - child_transitive_count = result.get("transitive_finding_count") - if isinstance(child_transitive_count, int): - transitive_finding_count += child_transitive_count - for source in _coerce_str_path_list(result.get("transitive_sources")): - transitive_sources.add(source) - severity = result.get("risk_severity") or "LOW" - status_console.print(f" Score: {score}/100 ({severity})\n") - except Exception as e: - error_message = str(e)[:1_024] + except Exception: + if result is not None and not any(item is result for item in results): + try: + cleanup_result(result) + except Exception: + logger.warning("Recursive child result cleanup failed") + error_message = _RECURSIVE_CHILD_SCAN_FAILED_MESSAGE err_console.print(f" [red]Error:[/red] {error_message}\n") execution_failed = True failed_skill_count += 1 results.append({"skill_name": skill.name, "error": error_message}) processed_skills.append(skill) - omitted_skill_count = len(skills) - len(processed_skills) - if omitted_skill_count: + scanned_skill_count = complete_skill_count + partial_skill_count + failed_skill_count + unscanned_skill_count = max( + 0, + len(skills) - scanned_skill_count, + ) + output_omitted_skill_count = max(0, scanned_skill_count - len(processed_skills)) + if output_omitted_skill_count: + analysis_incomplete = True + aggregate_limitations.append( + f"{output_omitted_skill_count} scanned recursive skill report(s) omitted " + "after an aggregate output limit" + ) + if unscanned_skill_count: analysis_incomplete = True aggregate_limitations.append( - f"{omitted_skill_count} recursive skill(s) omitted after an aggregate limit" + f"{unscanned_skill_count} recursive skill(s) unscanned after an aggregate limit" ) aggregate_limitations = list(dict.fromkeys(aggregate_limitations))[:256] aggregate_completeness = _multi_skill_analysis_completeness( @@ -2532,20 +2860,25 @@ def _scan_multi_skill( complete_skills=complete_skill_count, partial_skills=partial_skill_count, failed_skills=failed_skill_count, - omitted_skills=omitted_skill_count, + omitted_skills=unscanned_skill_count, limitations=aggregate_limitations, ) analysis_incomplete = not bool(aggregate_completeness["is_complete"]) + aggregate_risk_assessment = _multi_skill_risk_assessment( + max_score, + execution_failed=execution_failed, + analysis_incomplete=analysis_incomplete, + ) - status_console.print("\n[bold]═══ Multi-Skill Summary ═══[/bold]\n") - status_console.print( + progress_console.print("\n[bold]═══ Multi-Skill Summary ═══[/bold]\n") + progress_console.print( f" {'Skill':<30} {'Score':<8} {'Severity':<12} {'Findings':<10} {'Execution':<10}" ) - status_console.print(f" {'─' * 30} {'─' * 8} {'─' * 12} {'─' * 10} {'─' * 10}") + progress_console.print(f" {'─' * 30} {'─' * 8} {'─' * 12} {'─' * 10} {'─' * 10}") for skill, result in zip(processed_skills, results, strict=True): if "error" in result: - status_console.print( + progress_console.print( f" {skill.name:<30} {'ERROR':<8} {'—':<12} {'—':<10} {'error':<10}" ) continue @@ -2553,14 +2886,20 @@ def _scan_multi_skill( severity = result.get("risk_severity", "LOW") finding_count = len(effective_findings(result)) execution = "failed" if result.get("execution_successful") is False else "successful" - status_console.print( + progress_console.print( f" {skill.name:<30} {score:<8} {severity:<12} {finding_count:<10} {execution:<10}" ) - if omitted_skill_count: - status_console.print( - f" {'':<30} {'—':<8} {'—':<12} {omitted_skill_count:<10} {'partial':<10}" + if output_omitted_skill_count: + progress_console.print( + f" {'':<30} {'—':<8} {'—':<12} " + f"{output_omitted_skill_count:<10} {'partial':<10}" + ) + if unscanned_skill_count: + progress_console.print( + f" {'':<30} {'—':<8} {'—':<12} {unscanned_skill_count:<10} {'partial':<10}" ) - status_console.print( + if output_omitted_skill_count or unscanned_skill_count: + progress_console.print( "[yellow]Recursive scan incomplete:[/yellow] one or more skills were omitted " "after an aggregate safety limit." ) @@ -2570,17 +2909,13 @@ def _scan_multi_skill( "multi_skill": True, "skill_count": len(skills), "max_risk_score": max_score, + "risk_severity": aggregate_risk_assessment["severity"], "execution_successful": not execution_failed, - "risk_recommendation": ( - "DO_NOT_INSTALL" - if execution_failed or max_score > RISK_THRESHOLD - else "CAUTION" - if analysis_incomplete - else "SAFE" - ), + "risk_recommendation": aggregate_risk_assessment["recommendation"], "analysis_completeness": aggregate_completeness, - "skills_scanned": len(processed_skills), - "skills_omitted": omitted_skill_count, + "skills_scanned": scanned_skill_count, + "skills_omitted": unscanned_skill_count, + "skills_output_omitted": output_omitted_skill_count, "public_finding_records": retained_public_records, "report_characters": retained_report_characters, "transitive_finding_count": transitive_finding_count, @@ -2614,11 +2949,19 @@ def _scan_multi_skill( combined_skills.append(entry) entry["transitive_finding_count"] = result.get("transitive_finding_count", 0) entry["transitive_sources"] = result.get("transitive_sources", []) - if omitted_skill_count: + if output_omitted_skill_count: combined_skills.append( { "omitted": True, - "omitted_count": omitted_skill_count, + "omitted_count": output_omitted_skill_count, + "reason": "aggregate_output_limit", + } + ) + if unscanned_skill_count: + combined_skills.append( + { + "omitted": True, + "omitted_count": unscanned_skill_count, "reason": "aggregate_scan_limit", } ) @@ -2632,39 +2975,41 @@ def _scan_multi_skill( "multi_skill": True, "skill_count": len(skills), "max_risk_score": max_score, + "risk_severity": aggregate_risk_assessment["severity"], "execution_successful": not execution_failed, - "risk_recommendation": ( - "DO_NOT_INSTALL" - if execution_failed or max_score > RISK_THRESHOLD - else "CAUTION" - ), + "risk_recommendation": _multi_skill_risk_assessment( + max_score, + execution_failed=execution_failed, + analysis_incomplete=True, + )["recommendation"], "analysis_completeness": aggregate_completeness, - "skills_scanned": len(processed_skills), - "skills_omitted": omitted_skill_count, - "skills_output_omitted": len(processed_skills), + "skills_scanned": scanned_skill_count, + "skills_omitted": unscanned_skill_count, + "skills_output_omitted": scanned_skill_count, "public_finding_records": 0, "transitive_finding_count": transitive_finding_count, "transitive_sources": [], "skills": [ { "omitted": True, - "omitted_count": len(processed_skills), + "omitted_count": scanned_skill_count, "reason": "aggregate_output_limit", } ], } rendered = json.dumps(combined, indent=2) _ensure_recursive_output_bound(rendered) - if output: + if output is not None: Path(output).write_text(rendered, encoding="utf-8") - console.print(f"[green]Combined report saved to:[/green] {output}") + progress_console.print(f"[green]Combined report saved to:[/green] {output}") else: - print(rendered) + sys.stdout.write(rendered) elif format == FormatChoice.sarif: merged_sarif = _multi_skill_sarif_report( processed_skills, results, aggregate_completeness, + aggregate_risk_assessment, ) rendered = json.dumps(merged_sarif, indent=2) if len(rendered) > _MULTI_SKILL_MAX_REPORT_CHARACTERS: @@ -2672,23 +3017,32 @@ def _scan_multi_skill( aggregate_completeness, aggregate_limitations = _mark_recursive_output_limited( aggregate_completeness, ) - merged_sarif = _multi_skill_sarif_report([], [], aggregate_completeness) + aggregate_risk_assessment = _multi_skill_risk_assessment( + max_score, + execution_failed=execution_failed, + analysis_incomplete=True, + ) + merged_sarif = _multi_skill_sarif_report( + [], [], aggregate_completeness, aggregate_risk_assessment + ) rendered = json.dumps(merged_sarif, indent=2) _ensure_recursive_output_bound(rendered) - if output: + if output is not None: Path(output).write_text(rendered, encoding="utf-8") - console.print(f"[green]Combined report saved to:[/green] {output}") + progress_console.print(f"[green]Combined report saved to:[/green] {output}") else: - print(rendered) - elif output: + sys.stdout.write(rendered) + else: sections: list[str] = [] for skill, result in zip(processed_skills, results, strict=True): if "error" not in result: sections.append(f"--- {skill.relative_path} ---\n\n{_result_body(result)}") if analysis_incomplete: sections.append( - "--- Recursive Inspection Completeness ---\n\n" - "Status: partial\n\n" + "\n".join(f"- {item}" for item in aggregate_limitations) + _multi_skill_text_summary( + aggregate_completeness, + aggregate_risk_assessment, + ) ) rendered = "\n\n".join(sections) if len(rendered) > _MULTI_SKILL_MAX_REPORT_CHARACTERS: @@ -2696,13 +3050,23 @@ def _scan_multi_skill( aggregate_completeness, aggregate_limitations = _mark_recursive_output_limited( aggregate_completeness, ) - rendered = ( - "--- Recursive Inspection Completeness ---\n\n" - "Status: partial\n\n" + "\n".join(f"- {item}" for item in aggregate_limitations) + aggregate_risk_assessment = _multi_skill_risk_assessment( + max_score, + execution_failed=execution_failed, + analysis_incomplete=True, + ) + rendered = _multi_skill_text_summary( + aggregate_completeness, + aggregate_risk_assessment, ) _ensure_recursive_output_bound(rendered) - Path(output).write_text(rendered, encoding="utf-8") - console.print(f"[green]Combined report saved to:[/green] {output}") + if output is not None: + Path(output).write_text(rendered, encoding="utf-8") + progress_console.print(f"[green]Combined report saved to:[/green] {output}") + elif format is FormatChoice.terminal: + console.print(rendered) + else: + sys.stdout.write(rendered) for result in results: cleanup_result(result) diff --git a/src/skillspector/input_handler.py b/src/skillspector/input_handler.py index c42eaf1ce..8c2630ae8 100644 --- a/src/skillspector/input_handler.py +++ b/src/skillspector/input_handler.py @@ -81,6 +81,7 @@ _DIRECT_FILE_URL_SUFFIXES = ( ".md", ".py", + ".pyw", ".sh", ) diff --git a/src/skillspector/inspection_ledger.py b/src/skillspector/inspection_ledger.py index 836f2d6cc..8dfd5e924 100644 --- a/src/skillspector/inspection_ledger.py +++ b/src/skillspector/inspection_ledger.py @@ -94,7 +94,10 @@ class LedgerReason(StrEnum): RUNTIME_LIMIT = "runtime_limit" EXCLUDED_EXECUTABLE_CONTENT = "excluded_executable_content" OUTPUT_LIMIT = "output_limit" + TRANSITIVE_CHILD_SCAN_FAILED = "transitive_child_scan_failed" STATIC_PARSE_LIMIT = "static_parse_limit" + PYTHON_SOURCE_AMBIGUOUS = "python_source_ambiguous" + PYTHON_SOURCE_DECODE_ERROR = "python_source_decode_error" OBFUSCATED_INSTRUCTION_TEXT = "obfuscated_instruction_text" @@ -192,9 +195,18 @@ class LedgerReason(StrEnum): "Executable content was inventoried but excluded from content analysis." ), LedgerReason.OUTPUT_LIMIT: "Inspection reached its configured output limit.", + LedgerReason.TRANSITIVE_CHILD_SCAN_FAILED: ( + "A transitive child scan failed before complete inspection." + ), LedgerReason.STATIC_PARSE_LIMIT: ( "A security-relevant expression exceeded a bounded static parser's span limit." ), + LedgerReason.PYTHON_SOURCE_AMBIGUOUS: ( + "Python execution intent depends on runtime or platform-specific shebang semantics." + ), + LedgerReason.PYTHON_SOURCE_DECODE_ERROR: ( + "Python source bytes could not be decoded under their declared encoding." + ), LedgerReason.OBFUSCATED_INSTRUCTION_TEXT: ( "Obfuscated instruction text could not be fully evaluated by the deterministic layer." ), diff --git a/src/skillspector/nested_artifacts.py b/src/skillspector/nested_artifacts.py index fc02bc3f6..97312860a 100644 --- a/src/skillspector/nested_artifacts.py +++ b/src/skillspector/nested_artifacts.py @@ -39,6 +39,7 @@ LedgerRecordType, ledger_event, ) +from skillspector.python_ast import PythonSourceClassification, classify_python_source ARCHIVE_MAX_DEPTH = 3 ARCHIVE_MAX_MEMBERS = 1_000 @@ -76,6 +77,7 @@ ".phtml", ".ps1", ".py", + ".pyw", ".pyc", ".pyo", ".rb", @@ -110,6 +112,11 @@ class NestedInspectionResult: components: list[str] = field(default_factory=list) file_cache: dict[str, str] = field(default_factory=dict) raw_file_cache: dict[str, bytes] = field(default_factory=dict) + # Classify with the archive member's execution path, while retaining the + # virtual path as the stable cache/report key used by downstream analyzers. + python_source_classifications: dict[str, PythonSourceClassification] = field( + default_factory=dict + ) artifact_inventory: list[ArtifactRecord] = field(default_factory=list) metadata: list[dict[str, object]] = field(default_factory=list) outer_metadata: dict[str, dict[str, object]] = field(default_factory=dict) @@ -428,14 +435,22 @@ def _record_outer_metadata( } -def _virtual_type(path: str, data: bytes, nested_type: str | None) -> str: +def _virtual_type( + path: str, + data: bytes, + nested_type: str | None, + source_classification: PythonSourceClassification, +) -> str: if nested_type is not None: return nested_type + if source_classification is PythonSourceClassification.PYTHON: + return "python" suffix = Path(path).suffix.lower() return { ".md": "markdown", ".markdown": "markdown", ".py": "python", + ".pyw": "python", ".sh": "shell", ".bash": "shell", ".zsh": "shell", @@ -987,10 +1002,17 @@ def _inspect_zip_bytes( executable = _member_executable(info, safe_name, member_data) member_hidden = _is_hidden_path(safe_name) concealed = executable and bool(concealment_reasons) - virtual_type = _virtual_type(safe_name, member_data, nested_type) + source_classification = classify_python_source(safe_name, member_data) + virtual_type = _virtual_type( + safe_name, + member_data, + nested_type, + source_classification, + ) result.components.append(virtual_path) result.file_cache[virtual_path] = member_data.decode("utf-8", errors="replace") result.raw_file_cache[virtual_path] = member_data + result.python_source_classifications[virtual_path] = source_classification artifact = classify_artifact(virtual_path, member_data) result.artifact_inventory.append(artifact) result.metadata.append( diff --git a/src/skillspector/nodes/analyzers/behavioral_ast.py b/src/skillspector/nodes/analyzers/behavioral_ast.py index e9da09b5d..6ce1ea28c 100644 --- a/src/skillspector/nodes/analyzers/behavioral_ast.py +++ b/src/skillspector/nodes/analyzers/behavioral_ast.py @@ -32,7 +32,12 @@ ) from skillspector.logging_config import get_logger from skillspector.models import AnalyzerFinding, Finding, Location, Severity -from skillspector.python_ast import ParsedPythonFile, get_python_ast +from skillspector.python_ast import ( + ParsedPythonFile, + PythonSourceClassification, + get_python_ast, + resolve_python_source_classification, +) from skillspector.state import ( AnalyzerNodeResponse, SkillspectorState, @@ -590,6 +595,14 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: """Parse Python files via AST and detect dangerous execution patterns.""" components: list[str] = state.get("components") or [] file_cache: dict[str, str] = state.get("local_file_cache") or state.get("file_cache") or {} + raw_file_cache = state.get("raw_file_cache") + source_classifications = ( + state.get("python_source_classifications") + if "python_source_classifications" in state + else None + ) + source_classification_limitations = state.get("python_source_classification_limitations") or {} + source_decode_failures = state.get("python_source_decode_failures") or {} python_ast_cache_key = state.get("python_ast_cache_key") all_findings: list[Finding] = [] ledger_events: list[InspectionLedgerEvent] = [] @@ -597,7 +610,27 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: terminal_limit: _BehavioralResourceLimitError | None = None for path in components: - if not path.endswith(".py"): + content = file_cache.get(path) + source_classification: PythonSourceClassification | None = None + if source_classifications is not None and path in source_classifications: + source_classification = resolve_python_source_classification( + path, + content, + source_classifications=source_classifications, + raw_file_cache=raw_file_cache, + ) + if source_classification is PythonSourceClassification.NON_PYTHON: + continue + if path in source_classification_limitations: + ledger_events.append( + ledger_event( + outcome=LedgerOutcome.PARTIAL, + phase="behavioral", + analyzer_id=ANALYZER_ID, + path=path, + reason=LedgerReason.RUNTIME_LIMIT, + ) + ) continue if terminal_limit is None and budget.analyzer_exhausted(): terminal_limit = _BehavioralResourceLimitError( @@ -607,11 +640,41 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: "limit_findings": MAX_FINDINGS_PER_ANALYZER, }, ) + if terminal_limit is None: + try: + budget.check_runtime() + except _BehavioralResourceLimitError as exc: + terminal_limit = exc if terminal_limit is not None: event = _partial_limit_event(path, terminal_limit) ledger_events.append(event) continue - content = file_cache.get(path) + if path in source_decode_failures: + ledger_events.append( + ledger_event( + outcome=LedgerOutcome.PARTIAL, + phase="behavioral", + analyzer_id=ANALYZER_ID, + path=path, + reason=LedgerReason.PYTHON_SOURCE_DECODE_ERROR, + ) + ) + continue + if source_classification is None: + source_classification = resolve_python_source_classification( + path, + content, + source_classifications=source_classifications, + raw_file_cache=raw_file_cache, + ) + try: + budget.check_runtime() + except _BehavioralResourceLimitError as exc: + terminal_limit = exc + ledger_events.append(_partial_limit_event(path, exc)) + continue + if source_classification is PythonSourceClassification.NON_PYTHON: + continue if content is None: event = ledger_event( outcome=LedgerOutcome.FAILED, @@ -667,10 +730,19 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: ) else: event = ledger_event( - outcome=LedgerOutcome.COMPLETED, + outcome=( + LedgerOutcome.PARTIAL + if source_classification is PythonSourceClassification.AMBIGUOUS + else LedgerOutcome.COMPLETED + ), phase="behavioral", analyzer_id=ANALYZER_ID, path=path, + reason=( + LedgerReason.PYTHON_SOURCE_AMBIGUOUS + if source_classification is PythonSourceClassification.AMBIGUOUS + else None + ), emitted_finding_ids=[finding.finding_id for finding in path_findings], ) ledger_events.append(event) diff --git a/src/skillspector/nodes/analyzers/behavioral_taint_tracking.py b/src/skillspector/nodes/analyzers/behavioral_taint_tracking.py index 349768875..50efd82e5 100644 --- a/src/skillspector/nodes/analyzers/behavioral_taint_tracking.py +++ b/src/skillspector/nodes/analyzers/behavioral_taint_tracking.py @@ -38,7 +38,12 @@ ) from skillspector.logging_config import get_logger from skillspector.models import AnalyzerFinding, Finding, Location, Severity -from skillspector.python_ast import ParsedPythonFile, get_python_ast +from skillspector.python_ast import ( + ParsedPythonFile, + PythonSourceClassification, + get_python_ast, + resolve_python_source_classification, +) from skillspector.state import ( AnalyzerNodeResponse, SkillspectorState, @@ -641,6 +646,14 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: """Parse Python files and detect source\u2192sink data flows.""" components: list[str] = state.get("components") or [] file_cache: dict[str, str] = state.get("local_file_cache") or state.get("file_cache") or {} + raw_file_cache = state.get("raw_file_cache") + source_classifications = ( + state.get("python_source_classifications") + if "python_source_classifications" in state + else None + ) + source_classification_limitations = state.get("python_source_classification_limitations") or {} + source_decode_failures = state.get("python_source_decode_failures") or {} python_ast_cache_key = state.get("python_ast_cache_key") all_findings: list[Finding] = [] ledger_events: list[InspectionLedgerEvent] = [] @@ -648,7 +661,27 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: terminal_limit: _BehavioralResourceLimitError | None = None for path in components: - if not path.endswith(".py"): + content = file_cache.get(path) + source_classification: PythonSourceClassification | None = None + if source_classifications is not None and path in source_classifications: + source_classification = resolve_python_source_classification( + path, + content, + source_classifications=source_classifications, + raw_file_cache=raw_file_cache, + ) + if source_classification is PythonSourceClassification.NON_PYTHON: + continue + if path in source_classification_limitations: + ledger_events.append( + ledger_event( + outcome=LedgerOutcome.PARTIAL, + phase="behavioral", + analyzer_id=ANALYZER_ID, + path=path, + reason=LedgerReason.RUNTIME_LIMIT, + ) + ) continue if terminal_limit is None and budget.analyzer_exhausted(): terminal_limit = _BehavioralResourceLimitError( @@ -658,11 +691,41 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: "limit_findings": MAX_FINDINGS_PER_ANALYZER, }, ) + if terminal_limit is None: + try: + budget.check_runtime() + except _BehavioralResourceLimitError as exc: + terminal_limit = exc if terminal_limit is not None: event = _partial_limit_event(path, terminal_limit) ledger_events.append(event) continue - content = file_cache.get(path) + if path in source_decode_failures: + ledger_events.append( + ledger_event( + outcome=LedgerOutcome.PARTIAL, + phase="behavioral", + analyzer_id=ANALYZER_ID, + path=path, + reason=LedgerReason.PYTHON_SOURCE_DECODE_ERROR, + ) + ) + continue + if source_classification is None: + source_classification = resolve_python_source_classification( + path, + content, + source_classifications=source_classifications, + raw_file_cache=raw_file_cache, + ) + try: + budget.check_runtime() + except _BehavioralResourceLimitError as exc: + terminal_limit = exc + ledger_events.append(_partial_limit_event(path, exc)) + continue + if source_classification is PythonSourceClassification.NON_PYTHON: + continue if content is None: event = ledger_event( outcome=LedgerOutcome.FAILED, @@ -718,10 +781,19 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: ) else: event = ledger_event( - outcome=LedgerOutcome.COMPLETED, + outcome=( + LedgerOutcome.PARTIAL + if source_classification is PythonSourceClassification.AMBIGUOUS + else LedgerOutcome.COMPLETED + ), phase="behavioral", analyzer_id=ANALYZER_ID, path=path, + reason=( + LedgerReason.PYTHON_SOURCE_AMBIGUOUS + if source_classification is PythonSourceClassification.AMBIGUOUS + else None + ), emitted_finding_ids=[finding.finding_id for finding in path_findings], ) ledger_events.append(event) diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index 0ca9f753c..98549a107 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -24,29 +24,52 @@ from __future__ import annotations +import heapq import re import sys +import time +from bisect import bisect_left, bisect_right from collections.abc import Callable, Iterator from dataclasses import dataclass - +from hashlib import sha256 + +from skillspector.artifacts import ( + SecurityTextView, + _has_derived_security_view, + normalized_security_prefix, + normalized_security_view, + security_text_views, +) from skillspector.logging_config import get_logger -from skillspector.models import AnalyzerFinding, Location, Severity +from skillspector.models import AnalyzerFinding, Finding, Location, Severity +from skillspector.python_ast import ParsedPythonFile from skillspector.security_reconstruction import validated_json_string_spans from skillspector.state import AnalyzerNodeResponse, SkillspectorState from . import static_runner from .common import ( LINE_BREAK_CHARS, + LOGICAL_LINE_BREAK, MARKDOWN_FENCE_CLOSE, MARKDOWN_FENCE_OPEN, get_context, - get_line_number, ) from .pattern_defaults import PatternCategory logger = get_logger(__name__) ANALYZER_ID = "static_patterns_tool_misuse" +POSTPROCESS_USES_PYTHON_AST = True +POSTPROCESS_USES_RUNTIME_BUDGET = True +LEXICAL_DIRECT_SHELL_EVIDENCE = "_tm1_lexical_direct_shell" +LEXICAL_RAW_OWNER_EVIDENCE = "_tm1_lexical_raw_owner" +LEXICAL_NORMALIZED_MATCH_EVIDENCE = "_tm1_lexical_normalized_match" +LEXICAL_BOUND_IDENTITY_EVIDENCE = "_tm1_lexical_bound_identity" +LEXICAL_BOUND_IDENTITY_SOURCE_EVIDENCE = "_tm1_lexical_bound_identity_source" +LEXICAL_BOUND_CLASSIFICATION_EVIDENCE = "_tm1_lexical_bound_classification" +LEXICAL_REACH_TERMINATED_EVIDENCE = "_tm1_lexical_reach_terminated" +LEXICAL_BOUND_REACHABLE_EVIDENCE = "_tm1_lexical_bound_reachable" +_MAX_DIRECT_SHELL_CONTEXT_CHARS = 1024 _SHELL_COMMAND_WORD_START_RE = re.compile(r"[rRdDeE$'\"`\\]") _SHELL_COMMAND_WORD_CHARS = 4096 @@ -111,10 +134,14 @@ ) # TM1: Tool Parameter Abuse — dangerous parameter values -TM1_CODE_PATTERNS = [ +DIRECT_SHELL_TRUE_PATTERNS: tuple[tuple[str, float], ...] = ( # shell=True is a classic command injection vector - (r"subprocess\.\w+\s*\([^)]*shell\s*=\s*True", 0.8), - (r"Popen\s*\([^)]*shell\s*=\s*True", 0.8), + (r"subprocess\.\w+\s*\([^)]*shell\s*=\s*True\b", 0.8), + (r"Popen\s*\([^)]*shell\s*=\s*True\b", 0.8), +) +_DIRECT_SHELL_TRUE_VALUE = re.compile(r"shell\s*=\s*True\b", re.IGNORECASE) +TM1_CODE_PATTERNS = [ + *DIRECT_SHELL_TRUE_PATTERNS, # Bound command names on both sides so prefixes such as rmm/ (RAPIDS # Memory Manager headers) are not interpreted as destructive commands. # Keep the scan within one bounded shell command. The former ``[^|]*`` @@ -2109,41 +2136,252 @@ def _has_unsupported_brace_expansion(tokens: tuple[_ShellToken, ...]) -> bool: ) +def _projected_subprocess_qualifier( + content: str, + popen_start: int, + maximum_lookbehind: int, +) -> tuple[int, int, str, bool] | None: + """Return a security-view ``subprocess.`` suffix and its raw source span.""" + qualifier = "subprocess." + if popen_start <= 0: + return None + maximum = min(popen_start, max(0, maximum_lookbehind)) + lookbehind = min(maximum, 64) + while lookbehind: + prefix_start = popen_start - lookbehind + # Include the first method character so contextual default-ignorable + # handling sees the same right boundary as the full security view. + needs_more_source = False + source = content[prefix_start : popen_start + 1] + for view in security_text_views(source): + derived_popen_start = ( + popen_start - prefix_start + if view.source_offsets is None + else bisect_left(view.source_offsets, popen_start - prefix_start) + ) + prefix = view.text[:derived_popen_start] + if len(prefix) < len(qualifier): + needs_more_source = True + continue + suffix_start = len(prefix) - len(qualifier) + suffix = prefix[suffix_start:] + if suffix.casefold() == qualifier: + return ( + prefix_start + view.source_offset(suffix_start), + prefix_start + view.source_offset(derived_popen_start - 1) + 1, + suffix, + view.name != "raw", + ) + if not needs_more_source: + return None + if lookbehind == maximum: + return None + lookbehind = min(maximum, lookbehind * 2) + return None + + +def _subprocess_qualifier_start(content: str, popen_start: int) -> int | None: + """Find a bounded security-view ``subprocess.`` suffix before bare Popen.""" + qualifier = _projected_subprocess_qualifier( + content, + popen_start, + static_runner._WINDOW_OVERLAP_CHARS + len("subprocess."), + ) + return qualifier[0] if qualifier is not None else None + + +def _outer_shell_true_anchor(matched_text: str) -> int | None: + """Locate the first outer-call shell literal, ignoring quoted lookalikes.""" + stack: list[str] = [] + quote: str | None = None + triple = False + in_comment = False + cursor = 0 + pairs = {")": "(", "]": "[", "}": "{"} + while cursor < len(matched_text): + character = matched_text[cursor] + if in_comment: + if character in LINE_BREAK_CHARS: + in_comment = False + cursor += 1 + continue + if quote is not None: + if character == "\\": + cursor = min(len(matched_text), cursor + 2) + continue + marker = quote * (3 if triple else 1) + if matched_text.startswith(marker, cursor): + cursor += len(marker) + quote = None + triple = False + else: + cursor += 1 + continue + if character in "'\"": + quote = character + triple = matched_text.startswith(character * 3, cursor) + cursor += 3 if triple else 1 + continue + if character == "#": + in_comment = True + cursor += 1 + continue + if character in "([{": + stack.append(character) + cursor += 1 + continue + if character in pairs: + if stack and stack[-1] == pairs[character]: + stack.pop() + cursor += 1 + continue + if ( + stack == ["("] + and ( + cursor == 0 + or not (matched_text[cursor - 1].isalnum() or matched_text[cursor - 1] == "_") + ) + and _DIRECT_SHELL_TRUE_VALUE.match(matched_text, cursor) is not None + ): + return cursor + cursor += 1 + return None + + def _tm1_candidates( content: str, -) -> Iterator[tuple[int, int, str, float]]: - for pattern, confidence in TM1_PATTERNS: + *, + direct_shell_only: bool = False, +) -> Iterator[tuple[int, int, str, float, bool, int | None, int | None, str | None]]: + def direct_matches( + pattern_index: int, + pattern: str, + confidence: float, + ) -> Iterator[tuple[int, int, re.Match[str], float]]: + for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE): + yield match.start(), pattern_index, match, confidence + + def direct_candidates() -> Iterator[ + tuple[int, int, str, float, bool, int | None, int | None, str | None] + ]: + direct_iterators = ( + direct_matches(pattern_index, pattern, confidence) + for pattern_index, (pattern, confidence) in enumerate(DIRECT_SHELL_TRUE_PATTERNS) + ) + qualified_popen_starts: set[int] = set() + for _, pattern_index, match, confidence in heapq.merge( + *direct_iterators, + key=lambda candidate: (candidate[0], candidate[1]), + ): + alternate_start: int | None = None + alternate_matched_text: str | None = None + if pattern_index == 0: + method = re.match(r"subprocess\.(?P\w+)", match.group(0), re.IGNORECASE) + if method is not None and method.group("method").casefold() == "popen": + alternate_start = match.start() + method.start("method") + alternate_matched_text = content[alternate_start : match.end()] + qualified_popen_starts.add(alternate_start) + elif match.start() in qualified_popen_starts: + # Emit one candidate for a qualified ``subprocess.Popen`` call, + # but retain the bare-method coordinate as an ownership fallback + # when the qualifier belongs to an adjacent scan window. + qualified_popen_starts.remove(match.start()) + continue + elif ( + qualifier_start := _subprocess_qualifier_start(content, match.start()) + ) is not None: + alternate_start = qualifier_start + alternate_matched_text = content[qualifier_start : match.end()] + relative_anchor = _outer_shell_true_anchor(match.group(0)) + if relative_anchor is None: + for shell_value in _DIRECT_SHELL_TRUE_VALUE.finditer(match.group(0)): + relative_anchor = shell_value.start() + anchor = match.start() + relative_anchor if relative_anchor is not None else None + yield ( + match.start(), + match.end(), + match.group(0), + confidence, + True, + anchor, + alternate_start, + alternate_matched_text, + ) + + if direct_shell_only: + yield from direct_candidates() + return + + def ordinary_matches( + pattern_index: int, + pattern: str, + confidence: float, + ) -> Iterator[tuple[int, int, re.Match[str], float]]: matches = ( static_runner.iter_paragraph_matches if (pattern, confidence) in TM1_PROSE_PATTERNS else re.finditer ) for match in matches(pattern, content, re.IGNORECASE | re.MULTILINE): - yield match.start(), match.end(), match.group(0), confidence - - seen_commands: set[tuple[int, int]] = set() - covered_until = 0 - for command_start, body_start in _destructive_command_words(content): - if command_start < covered_until: - continue - command_key = (command_start, body_start) - if command_key in seen_commands: - continue - seen_commands.add(command_key) - # Documentation is excluded regardless of the shell parse result. Apply - # that existing semantic gate first so large manuals do not pay for a - # character-by-character shell parse for every explanatory ``rm`` noun. - if _is_root_glob_documentation(content, command_start, body_start): - continue - tokens, command_end, _ = _bounded_shell_tokens( - content, - command_start, - body_start, + yield match.start(), pattern_index, match, confidence + + def ordinary_candidates() -> Iterator[ + tuple[int, int, str, float, bool, int | None, int | None, str | None] + ]: + ordinary_iterators = ( + ordinary_matches(pattern_index, pattern, confidence) + for pattern_index, (pattern, confidence) in enumerate( + TM1_PATTERNS[len(DIRECT_SHELL_TRUE_PATTERNS) :] + ) ) - covered_until = max(covered_until, command_end) - command = content[command_start:command_end] - if _has_destructive_root_glob(tokens) or _has_destructive_root_path(tokens): - yield command_start, command_end, command, 0.9 + for _, _, match, confidence in heapq.merge( + *ordinary_iterators, + key=lambda candidate: (candidate[0], candidate[1]), + ): + yield ( + match.start(), + match.end(), + match.group(0), + confidence, + False, + None, + None, + None, + ) + + def destructive_candidates() -> Iterator[ + tuple[int, int, str, float, bool, int | None, int | None, str | None] + ]: + seen_commands: set[tuple[int, int]] = set() + covered_until = 0 + for command_start, body_start in _destructive_command_words(content): + if command_start < covered_until: + continue + command_key = (command_start, body_start) + if command_key in seen_commands: + continue + seen_commands.add(command_key) + # Documentation is excluded regardless of the shell parse result. Apply + # that existing semantic gate first so large manuals do not pay for a + # character-by-character shell parse for every explanatory ``rm`` noun. + if _is_root_glob_documentation(content, command_start, body_start): + continue + tokens, command_end, _ = _bounded_shell_tokens( + content, + command_start, + body_start, + ) + covered_until = max(covered_until, command_end) + command = content[command_start:command_end] + if _has_destructive_root_glob(tokens) or _has_destructive_root_path(tokens): + yield command_start, command_end, command, 0.9, False, None, None, None + + yield from heapq.merge( + direct_candidates(), + ordinary_candidates(), + destructive_candidates(), + key=lambda candidate: candidate[0], + ) def _markdown_block_separator(line: str, check_runtime: Callable[[], None]) -> bool: @@ -2564,46 +2802,194 @@ def _line_containing(content: str, start: int, end: int) -> str: return content[line_start:line_end] -def analyze(content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: +def _bounded_context(content: str, match_start: int) -> str: + """Return a fixed-size context centered on one direct shell call.""" + left = max(0, match_start - _MAX_DIRECT_SHELL_CONTEXT_CHARS // 2) + right = min(len(content), left + _MAX_DIRECT_SHELL_CONTEXT_CHARS) + left = max(0, right - _MAX_DIRECT_SHELL_CONTEXT_CHARS) + return content[left:right].rstrip(LINE_BREAK_CHARS) + + +def _raw_classification_bounds(content: str, source_start: int) -> tuple[int, int]: + """Return the raw scanner-window bounds that own *source_start*.""" + if len(content) <= static_runner.SECURITY_VIEW_WINDOW_CHARS: + return 0, len(content) + owned_start = ( + source_start // static_runner._RAW_WINDOW_OWNED_CHARS + ) * static_runner._RAW_WINDOW_OWNED_CHARS + owned_end = min(len(content), owned_start + static_runner._RAW_WINDOW_OWNED_CHARS) + raw_start = max(0, owned_start - static_runner._WINDOW_OVERLAP_CHARS) + raw_end = min(len(content), owned_end + static_runner._WINDOW_OVERLAP_CHARS) + return raw_start, raw_end + + +def _classify_tm1( + context: str, + matched_text: str, + matched_line: str, + confidence: float, + file_type: str, + *, + safe_context: bool | None = None, + safe_matched_line: bool | None = None, +) -> tuple[Severity, float]: + """Apply the existing TM1 contextual classification to one candidate.""" + if safe_context is None: + safe_context = _is_safe_container_command(context) or _is_safe_dockerfile_idiom( + context, matched_text + ) + if safe_matched_line is None: + safe_matched_line = _is_safe_cache_cleanup(matched_line) + if safe_context or safe_matched_line: + return Severity.LOW, min(confidence, 0.15) + adjusted = ( + min(1.0, confidence + 0.1) if file_type in ("python", "shell", "javascript") else confidence + ) + return Severity.HIGH, adjusted + + +def analyze( + content: str, + file_path: str, + file_type: str, + *, + _direct_shell_only: bool = False, +) -> list[AnalyzerFinding]: """Analyze content for tool misuse patterns (TM1–TM3).""" findings: list[AnalyzerFinding] = [] def loc(ln: int) -> Location: return Location(file=file_path, start_line=ln) + line_starts = (0, *(match.end() for match in LOGICAL_LINE_BREAK.finditer(content))) + + def line_number(start: int) -> int: + return bisect_right(line_starts, start) + + context_by_line: dict[int, str] = {} + def ctx(start: int) -> str: - return get_context(content, start) + line = line_number(start) + context = context_by_line.get(line) + if context is None: + context = get_context(content, start) + context_by_line[line] = context + return context tag = [PatternCategory.TOOL_MISUSE.value] tm1_findings_by_key: dict[tuple[int, str], AnalyzerFinding] = {} - - for match_start, match_end, matched_text, confidence in _tm1_candidates(content): - line_num = get_line_number(content, match_start) - context_text = ctx(match_start) + direct_safety_by_line: dict[int, tuple[bool, bool]] = {} + + for ( + match_start, + match_end, + matched_text, + confidence, + is_direct_shell_true, + direct_shell_anchor, + alternate_start, + alternate_matched_text, + ) in _tm1_candidates(content, direct_shell_only=_direct_shell_only): + line_num = line_number(match_start) + classification_context = ctx(match_start) + context_text = ( + _bounded_context(content, match_start) + if is_direct_shell_true + else classification_context + ) matched = matched_text[:200] matched_line = _line_containing(content, match_start, match_end) - if ( - _is_safe_container_command(context_text) - or _is_safe_dockerfile_idiom(context_text, matched) - or _is_safe_cache_cleanup(matched_line) - ): - adj = min(confidence, 0.15) - sev = Severity.LOW + if is_direct_shell_true: + safety = direct_safety_by_line.get(line_num) + if safety is None: + safety = ( + _is_safe_container_command(classification_context) + or _is_safe_dockerfile_idiom(classification_context, matched), + _is_safe_cache_cleanup(matched_line), + ) + direct_safety_by_line[line_num] = safety + sev, adj = _classify_tm1( + classification_context, + matched, + matched_line, + confidence, + file_type, + safe_context=safety[0], + safe_matched_line=safety[1], + ) else: - adj = ( - min(1.0, confidence + 0.1) - if file_type in ("python", "shell", "javascript") - else confidence + sev, adj = _classify_tm1( + classification_context, + matched, + matched_line, + confidence, + file_type, ) - sev = Severity.HIGH - candidate_key = (line_num, " ".join(matched.strip().split())) + candidate_identity = matched_text if is_direct_shell_true else matched + complete_identity_match = matched_text + candidate_key = ( + line_num, + sha256(" ".join(candidate_identity.strip().split()).encode()).hexdigest(), + ) existing = tm1_findings_by_key.get(candidate_key) if existing is not None: if adj > existing.confidence: existing.confidence = adj existing.severity = sev continue + evidence: dict[str, object] = {static_runner._VIEW_START_EVIDENCE: match_start} + if is_direct_shell_true: + canonical_start = ( + alternate_start + if alternate_start is not None and alternate_start < match_start + else match_start + ) + shell_match = ( + _DIRECT_SHELL_TRUE_VALUE.match(content, direct_shell_anchor) + if direct_shell_anchor is not None + else None + ) + canonical_match = ( + content[canonical_start : shell_match.end()] + if shell_match is not None + else ( + alternate_matched_text + if alternate_start is not None + and alternate_start < match_start + and alternate_matched_text is not None + else matched_text + ) + ) + complete_identity_match = normalized_security_view(canonical_match).text + reach_end = content.find(")", match_start) + reach_terminated = reach_end != -1 + if not reach_terminated: + reach_end = len(content) + evidence.update( + { + static_runner._PRESERVE_SOURCE_START_EVIDENCE: True, + static_runner._VIEW_REACH_END_EVIDENCE: reach_end, + static_runner._VIEW_REPLACEMENT_START_LIMIT_EVIDENCE: max( + 0, + len(content) - len("True"), + ), + LEXICAL_REACH_TERMINATED_EVIDENCE: reach_terminated, + LEXICAL_DIRECT_SHELL_EVIDENCE: True, + LEXICAL_NORMALIZED_MATCH_EVIDENCE: normalized_security_prefix( + canonical_match, + 200, + ), + } + ) + if direct_shell_anchor is not None: + evidence[static_runner._VIEW_ANCHOR_EVIDENCE] = direct_shell_anchor + if alternate_start is not None: + evidence[static_runner._VIEW_ALTERNATE_START_EVIDENCE] = alternate_start + if alternate_matched_text is not None: + evidence[static_runner._ALTERNATE_MATCHED_TEXT_EVIDENCE] = alternate_matched_text[ + :200 + ] finding = AnalyzerFinding( rule_id="TM1", message="Tool Parameter Abuse", @@ -2613,11 +2999,15 @@ def ctx(start: int) -> str: tags=tag, context=context_text, matched_text=matched, - complete_match=matched_text, - evidence={static_runner._VIEW_START_EVIDENCE: match_start}, + complete_match=complete_identity_match, + evidence=evidence, ) tm1_findings_by_key[candidate_key] = finding findings.append(finding) + + if _direct_shell_only: + return findings + for pattern, confidence in TM2_PATTERNS: matches = ( static_runner.iter_paragraph_matches @@ -2625,7 +3015,7 @@ def ctx(start: int) -> str: else re.finditer ) for match in matches(pattern, content, re.IGNORECASE | re.MULTILINE): - line_num = get_line_number(content, match.start()) + line_num = line_number(match.start()) context_text = ctx(match.start()) matched = match.group(0)[:200] @@ -2655,7 +3045,7 @@ def ctx(start: int) -> str: else re.finditer ) for match in matches(pattern, content, re.IGNORECASE | re.MULTILINE): - line_num = get_line_number(content, match.start()) + line_num = line_number(match.start()) findings.append( AnalyzerFinding( rule_id="TM3", @@ -2672,7 +3062,7 @@ def ctx(start: int) -> str: # TM4: privileged K8s workload. Example filtering is delegated to the runner. for pattern, confidence in TM4_PATTERNS: for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE): - line_num = get_line_number(content, match.start()) + line_num = line_number(match.start()) findings.append( AnalyzerFinding( rule_id="TM4", @@ -2689,8 +3079,1457 @@ def ctx(start: int) -> str: return findings +def coalesce_path_findings(content: str, findings: list[Finding]) -> list[Finding]: + """Select one owner per direct call without discarding private coordinates.""" + from . import static_python_shell_truthiness + + full_match_identities = { + finding.finding_id: finding.match_fingerprint + for finding in findings + if finding.match_fingerprint is not None + } + canonical_bound_calls: dict[tuple[str, int, str], set[int]] = {} + for finding in findings: + canonical = finding.evidence.get( + static_python_shell_truthiness.BOUND_CANONICAL_FINGERPRINT_EVIDENCE + ) + call_start = finding.evidence.get(static_python_shell_truthiness.BOUND_CALL_START_EVIDENCE) + if isinstance(canonical, str) and type(call_start) is int: + canonical_bound_calls.setdefault( + (finding.file, finding.start_line, canonical), + set(), + ).add(call_start) + colliding_bound_identities = { + key for key, call_starts in canonical_bound_calls.items() if len(call_starts) > 1 + } + + lexical_records: list[tuple[Finding, int]] = [] + parents: dict[int, int] = {} + + def find(coordinate: int) -> int: + parent = parents.setdefault(coordinate, coordinate) + while parent != coordinate: + grandparent = parents[parent] + parents[coordinate] = grandparent + coordinate = parent + parent = grandparent + return coordinate + + def union(first: int, second: int) -> None: + first_root = find(first) + second_root = find(second) + if first_root == second_root: + return + canonical = min(first_root, second_root) + parents[max(first_root, second_root)] = canonical + + for finding in findings: + if finding.evidence.get(LEXICAL_DIRECT_SHELL_EVIDENCE) is not True: + continue + start = finding.evidence.get(static_runner._ABSOLUTE_START_EVIDENCE) + if type(start) is int: + find(start) + alternate_start = finding.evidence.get(static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE) + if type(alternate_start) is int: + union(start, alternate_start) + lexical_records.append((finding, start)) + + lexical_groups: dict[int, list[Finding]] = {} + for finding, start in lexical_records: + lexical_groups.setdefault(find(start), []).append(finding) + + severity_rank = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3} + + def lexical_owner_rank( + finding: Finding, + call_start: int, + ) -> tuple[bool, bool, bool, int, float]: + """Rank public classification donors independently of scan order.""" + return ( + finding.evidence.get(LEXICAL_RAW_OWNER_EVIDENCE) is True, + "normalized-view" not in finding.tags, + finding.evidence.get(static_runner._ABSOLUTE_START_EVIDENCE) == call_start, + severity_rank.get(finding.severity, -1), + finding.confidence, + ) + + def select_lexical_owner(group: list[Finding], call_start: int) -> Finding: + return max( + enumerate(group), + key=lambda item: (*lexical_owner_rank(item[1], call_start), -item[0]), + )[1] + + def fingerprint_source_rank( + finding: Finding, + call_start: int, + ) -> tuple[bool, bool, int, bool, int]: + """Rank immutable identity donors independently of scan order.""" + anchor = finding.evidence.get(static_runner._ABSOLUTE_ANCHOR_EVIDENCE) + reach_end = finding.evidence.get(static_runner._ABSOLUTE_REACH_END_EVIDENCE) + return ( + finding.evidence.get(static_runner._ABSOLUTE_START_EVIDENCE) == call_start, + "normalized-view" in finding.tags, + anchor if type(anchor) is int else -1, + finding.evidence.get(LEXICAL_REACH_TERMINATED_EVIDENCE) is True, + reach_end if type(reach_end) is int else -1, + ) + + def fingerprint_record_rank( + record: dict[str, object], + call_start: int, + ) -> tuple[bool, bool, int, bool, int]: + """Re-rank a retained identity donor at the current group coordinate.""" + anchor = record.get("anchor") + reach_end = record.get("reach_end") + return ( + record.get("start") == call_start, + record.get("normalized") is True, + anchor if type(anchor) is int else -1, + record.get("reach_terminated") is True, + reach_end if type(reach_end) is int else -1, + ) + + def classification_record( + finding: Finding, + ) -> dict[str, object]: + """Snapshot the bounded public fields needed across cap finalizers.""" + return { + "severity": finding.severity, + "confidence": finding.confidence, + "base_tags": tuple(finding.tags), + "tags": tuple(finding.tags), + "raw_owner": finding.evidence.get(LEXICAL_RAW_OWNER_EVIDENCE) is True, + "raw_view": "normalized-view" not in finding.tags, + "start": finding.evidence.get(static_runner._ABSOLUTE_START_EVIDENCE), + } + + def classification_record_rank( + record: dict[str, object], + call_start: int, + ) -> tuple[bool, bool, bool, int, float]: + """Re-rank a retained public donor at the current group coordinate.""" + severity = record.get("severity") + confidence = record.get("confidence") + return ( + record.get("raw_owner") is True, + record.get("raw_view") is True, + record.get("start") == call_start, + severity_rank.get(severity, -1) if isinstance(severity, str) else -1, + ( + float(confidence) + if isinstance(confidence, (int, float)) and not isinstance(confidence, bool) + else -1.0 + ), + ) + + def select_fingerprint_source(group: list[Finding], call_start: int) -> Finding: + """Prefer the canonical-coordinate normalized match, independent of scan order.""" + return max( + enumerate(group), + key=lambda item: (*fingerprint_source_rank(item[1], call_start), -item[0]), + )[1] + + def lexical_fingerprint(finding: Finding) -> str | None: + """Hash immutable lexical text instead of a prior coalescing override.""" + canonical_match = finding.evidence.get(LEXICAL_NORMALIZED_MATCH_EVIDENCE) + if not isinstance(canonical_match, str) and finding.matched_text is None: + return finding.fingerprint() + if not isinstance(canonical_match, str): + canonical_match = normalized_security_prefix(finding.matched_text or "", 200) + normalized = " ".join(canonical_match.strip().split()) + return sha256(f"{finding.rule_id}\x1f{normalized}".encode()).hexdigest() + + def actual_shell_is_reachable( + candidate: Finding, + shell_value_start: int, + direct_match_end: int, + ) -> bool | None: + """Compare an AST shell value with this candidate's exact regex reach.""" + reach_end = candidate.evidence.get(static_runner._ABSOLUTE_REACH_END_EVIDENCE) + reach_terminated = candidate.evidence.get(LEXICAL_REACH_TERMINATED_EVIDENCE) + if type(reach_end) is not int or type(reach_terminated) is not bool: + return None + replacement_start_limit = candidate.evidence.get( + static_runner._ABSOLUTE_REPLACEMENT_START_LIMIT_EVIDENCE + ) + if type(replacement_start_limit) is int and shell_value_start > replacement_start_limit: + replacement_recovery_start = candidate.evidence.get( + static_runner._ABSOLUTE_REPLACEMENT_RECOVERY_START_EVIDENCE + ) + if type(replacement_recovery_start) is not int or not any( + type(candidate_start) is int and candidate_start >= replacement_recovery_start + for candidate_start in ( + candidate.evidence.get(static_runner._ABSOLUTE_START_EVIDENCE), + candidate.evidence.get(static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE), + ) + ): + return False + if reach_terminated: + return reach_end > shell_value_start + return reach_end >= direct_match_end + + lexical_owners: dict[int, Finding] = {} + lexical_member_starts: dict[str, int] = {} + for call_start, group in lexical_groups.items(): + owner = select_lexical_owner(group, call_start) + canonical = select_fingerprint_source(group, call_start) + if "normalized-view" not in owner.tags: + owner.evidence[LEXICAL_RAW_OWNER_EVIDENCE] = True + owner_start = owner.evidence.get(static_runner._ABSOLUTE_START_EVIDENCE) + if owner is not canonical: + # Coalescing can run repeatedly while a finding cap is being + # finalized. Persist the canonical identity coordinates on the + # retained public owner even when both candidates start together, + # otherwise a later pass re-hashes the earlier lexical lookalike. + canonical_match = canonical.evidence.get(LEXICAL_NORMALIZED_MATCH_EVIDENCE) + if isinstance(canonical_match, str): + owner.evidence[LEXICAL_NORMALIZED_MATCH_EVIDENCE] = canonical_match + for evidence_key in ( + static_runner._ABSOLUTE_ANCHOR_EVIDENCE, + static_runner._ABSOLUTE_REACH_END_EVIDENCE, + static_runner._ABSOLUTE_REPLACEMENT_START_LIMIT_EVIDENCE, + static_runner._ABSOLUTE_REPLACEMENT_RECOVERY_START_EVIDENCE, + LEXICAL_REACH_TERMINATED_EVIDENCE, + ): + if evidence_key in canonical.evidence: + owner.evidence[evidence_key] = canonical.evidence[evidence_key] + else: + owner.evidence.pop(evidence_key, None) + if ( + owner is not canonical + and type(owner_start) is int + and owner_start != call_start + and canonical.matched_text is not None + ): + # A raw bare-Popen owner can be the only classification context + # that existed before a cross-window normalized qualifier was + # reconstructed. Preserve that public owner while borrowing only + # the canonical call identity from the derived candidate. + owner.matched_text = canonical.matched_text + owner.finding = canonical.finding + owner.evidence[static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE] = call_start + if "normalized-view" in canonical.tags and "normalized-view" not in owner.tags: + owner.tags.append("normalized-view") + lexical_canonical_fingerprint = lexical_fingerprint(canonical) + complete_identity = full_match_identities.get(canonical.finding_id) + owner.match_fingerprint = ( + complete_identity + if isinstance(complete_identity, str) + else lexical_canonical_fingerprint + ) + lexical_owners[call_start] = owner + lexical_member_starts.update((candidate.finding_id, call_start) for candidate in group) + + ast_call_starts: set[int] = set() + ast_owners: dict[str, Finding] = {} + for finding in findings: + if finding.evidence.get(static_python_shell_truthiness.BOUND_SHELL_EVIDENCE) is not True: + continue + canonical_fingerprint = finding.evidence.get( + static_python_shell_truthiness.BOUND_CANONICAL_FINGERPRINT_EVIDENCE + ) + lexical_identity = finding.evidence.get(LEXICAL_BOUND_IDENTITY_EVIDENCE) + previously_reachable = finding.evidence.get(LEXICAL_BOUND_REACHABLE_EVIDENCE) is True + complete_identity = full_match_identities.get(finding.finding_id) + canonical_collision = ( + isinstance(canonical_fingerprint, str) + and ( + finding.file, + finding.start_line, + canonical_fingerprint, + ) + in colliding_bound_identities + ) + fingerprint = ( + lexical_identity + if isinstance(lexical_identity, str) + else complete_identity + if canonical_collision and isinstance(complete_identity, str) + else canonical_fingerprint + ) + if canonical_collision and isinstance(fingerprint, str): + finding.evidence[LEXICAL_BOUND_IDENTITY_EVIDENCE] = fingerprint + if isinstance(fingerprint, str): + finding.match_fingerprint = fingerprint + ast_call_start = finding.evidence.get( + static_python_shell_truthiness.BOUND_CALL_START_EVIDENCE + ) + ast_call_end = finding.evidence.get(static_python_shell_truthiness.BOUND_CALL_END_EVIDENCE) + direct_match_end = finding.evidence.get( + static_python_shell_truthiness.BOUND_DIRECT_MATCH_END_EVIDENCE + ) + popen_start = finding.evidence.get( + static_python_shell_truthiness.BOUND_POPEN_START_EVIDENCE + ) + shell_anchor = finding.evidence.get( + static_python_shell_truthiness.BOUND_SHELL_ANCHOR_EVIDENCE + ) + ast_owner: Finding | None = None + if type(ast_call_start) is int: + ast_call_starts.add(ast_call_start) + lexical_coordinates = [ast_call_start] + if type(popen_start) is int: + # Parenthesized, spaced, and explicitly continued receivers + # can only be recognized lexically from the method token. That + # token still belongs to this parsed call even when a quoted + # trailing ``shell=True`` produces a different shell anchor. + ast_call_starts.add(popen_start) + lexical_coordinates.append(popen_start) + coordinate_groups = [ + (coordinate, lexical_groups[coordinate]) + for coordinate in lexical_coordinates + if coordinate in lexical_groups + ] + exact_group = [ + candidate + for _, group in coordinate_groups + for candidate in group + if type(shell_anchor) is int + and candidate.evidence.get(static_runner._ABSOLUTE_ANCHOR_EVIDENCE) == shell_anchor + ] + if exact_group: + ast_owner = select_lexical_owner(exact_group, ast_call_start) + ast_owners[finding.finding_id] = ast_owner + finding.evidence.pop(LEXICAL_BOUND_IDENTITY_EVIDENCE, None) + finding.evidence.pop(LEXICAL_BOUND_IDENTITY_SOURCE_EVIDENCE, None) + finding.evidence.pop(LEXICAL_BOUND_CLASSIFICATION_EVIDENCE, None) + elif type(shell_anchor) is int and type(direct_match_end) is int: + # The legacy regex stops at the first nested ``)``. If an + # earlier parenthesized argument contains ``shell=True``, a + # direct literal is therefore fingerprinted from that retained + # same-call prefix in every file type. Keep a bound-name call + # on that established identity without borrowing its public + # text, tags, or classification. Exact coordinates exclude a + # separate nested call that happens to appear in the arguments. + same_call_candidates = [ + candidate for _, group in coordinate_groups for candidate in group + ] + shell_value_start = direct_match_end - len("True") + reachability = [ + actual_shell_is_reachable( + candidate, + shell_value_start, + direct_match_end, + ) + for candidate in same_call_candidates + ] + actual_shell_is_unreachable = bool(same_call_candidates) and all( + type( + candidate_anchor := candidate.evidence.get( + static_runner._ABSOLUTE_ANCHOR_EVIDENCE + ) + ) + is int + and ast_call_start <= candidate_anchor < shell_anchor + and (type(ast_call_end) is not int or candidate_anchor < ast_call_end) + and reachable is False + for candidate, reachable in zip( + same_call_candidates, + reachability, + strict=True, + ) + ) + if previously_reachable or any(reachable is True for reachable in reachability): + finding.evidence[LEXICAL_BOUND_REACHABLE_EVIDENCE] = True + finding.evidence.pop(LEXICAL_BOUND_IDENTITY_EVIDENCE, None) + finding.evidence.pop(LEXICAL_BOUND_IDENTITY_SOURCE_EVIDENCE, None) + finding.evidence.pop(LEXICAL_BOUND_CLASSIFICATION_EVIDENCE, None) + if isinstance(canonical_fingerprint, str): + finding.match_fingerprint = canonical_fingerprint + elif actual_shell_is_unreachable: + identity_coordinate = min(coordinate for coordinate, _ in coordinate_groups) + identity_source = select_fingerprint_source( + same_call_candidates, + identity_coordinate, + ) + public_owner = select_lexical_owner( + same_call_candidates, + identity_coordinate, + ) + legacy_fingerprint = lexical_fingerprint(identity_source) + if isinstance(legacy_fingerprint, str): + identity_record: dict[str, object] = { + "start": identity_source.evidence.get( + static_runner._ABSOLUTE_START_EVIDENCE + ), + "normalized": "normalized-view" in identity_source.tags, + "anchor": identity_source.evidence.get( + static_runner._ABSOLUTE_ANCHOR_EVIDENCE + ), + "reach_terminated": identity_source.evidence.get( + LEXICAL_REACH_TERMINATED_EVIDENCE + ), + "reach_end": identity_source.evidence.get( + static_runner._ABSOLUTE_REACH_END_EVIDENCE + ), + } + prior_identity_record = finding.evidence.get( + LEXICAL_BOUND_IDENTITY_SOURCE_EVIDENCE + ) + if not isinstance(prior_identity_record, dict) or ( + fingerprint_record_rank(identity_record, identity_coordinate) + > fingerprint_record_rank( + prior_identity_record, + identity_coordinate, + ) + ): + finding.evidence[LEXICAL_BOUND_IDENTITY_EVIDENCE] = legacy_fingerprint + finding.evidence[LEXICAL_BOUND_IDENTITY_SOURCE_EVIDENCE] = ( + identity_record + ) + persisted_identity = finding.evidence.get(LEXICAL_BOUND_IDENTITY_EVIDENCE) + if isinstance(persisted_identity, str): + finding.match_fingerprint = persisted_identity + + prior_classification = finding.evidence.get( + LEXICAL_BOUND_CLASSIFICATION_EVIDENCE + ) + current_classification = classification_record(public_owner) + if not isinstance(prior_classification, dict) or ( + classification_record_rank( + current_classification, + identity_coordinate, + ) + > classification_record_rank( + prior_classification, + identity_coordinate, + ) + ): + prior_classification = current_classification + + base_tags = prior_classification.get("base_tags") + projected_tags = ( + list(base_tags) + if isinstance(base_tags, (list, tuple)) + and all(isinstance(tag, str) for tag in base_tags) + else [] + ) + persisted_identity_record = finding.evidence.get( + LEXICAL_BOUND_IDENTITY_SOURCE_EVIDENCE + ) + if ( + isinstance(persisted_identity_record, dict) + and persisted_identity_record.get("normalized") is True + and persisted_identity_record.get("start") == identity_coordinate + and prior_classification.get("start") != identity_coordinate + and "normalized-view" not in projected_tags + ): + # This is the same projection performed above when + # a raw bare-Popen owner and its normalized qualified + # identity coexist in one pass. Re-derive it when + # those bounded donors arrive in separate cap passes. + projected_tags.append("normalized-view") + prior_classification["tags"] = tuple(projected_tags) + finding.evidence[LEXICAL_BOUND_CLASSIFICATION_EVIDENCE] = ( + prior_classification + ) + if ast_owner is not None and isinstance(canonical_fingerprint, str): + ast_owner.match_fingerprint = canonical_fingerprint + + reconciled: list[Finding] = [] + emitted_call_starts: set[int] = set() + for finding in findings: + is_ast = finding.evidence.get(static_python_shell_truthiness.BOUND_SHELL_EVIDENCE) is True + ast_call_start = finding.evidence.get( + static_python_shell_truthiness.BOUND_CALL_START_EVIDENCE + ) + ast_owner = ast_owners.get(finding.finding_id) + if is_ast: + if type(ast_call_start) is int and ast_call_start in emitted_call_starts: + continue + if ast_owner is not None: + reconciled.append(ast_owner) + else: + reconciled.append(finding) + if type(ast_call_start) is int: + emitted_call_starts.add(ast_call_start) + continue + lexical_start = lexical_member_starts.get(finding.finding_id) + if lexical_start is not None: + if lexical_start not in ast_call_starts and lexical_start not in emitted_call_starts: + reconciled.append(lexical_owners[lexical_start]) + emitted_call_starts.add(lexical_start) + continue + reconciled.append(finding) + + source_line_starts = (0, *(match.end() for match in LOGICAL_LINE_BREAK.finditer(content))) + tm1_slots: list[int] = [] + tm1_by_source: list[tuple[int, int, Finding]] = [] + for index, finding in enumerate(reconciled): + if finding.rule_id != "TM1": + continue + bound_start = finding.evidence.get(static_python_shell_truthiness.BOUND_CALL_START_EVIDENCE) + lexical_coordinate = finding.evidence.get(static_runner._ABSOLUTE_START_EVIDENCE) + source_start = ( + bound_start + if type(bound_start) is int + else lexical_coordinate + if type(lexical_coordinate) is int + else None + ) + if source_start is None: + line_index = min(max(finding.start_line - 1, 0), len(source_line_starts) - 1) + source_start = source_line_starts[line_index] + if type(finding.start_column) is int and finding.start_column > 0: + source_start += finding.start_column + tm1_slots.append(index) + tm1_by_source.append((source_start, index, finding)) + for slot, (_, _, finding) in zip( + tm1_slots, + sorted(tm1_by_source, key=lambda item: (item[0], item[1])), + strict=True, + ): + reconciled[slot] = finding + public: list[Finding] = [] + seen_direct_calls: set[tuple[str, int, str | None]] = set() + for finding in reconciled: + is_direct_shell = ( + finding.evidence.get(LEXICAL_DIRECT_SHELL_EVIDENCE) is True + or finding.evidence.get(static_python_shell_truthiness.BOUND_SHELL_EVIDENCE) is True + ) + if finding.rule_id != "TM1" or not is_direct_shell: + public.append(finding) + continue + key = (finding.file, finding.start_line, finding.fingerprint()) + if key in seen_direct_calls: + continue + seen_direct_calls.add(key) + public.append(finding) + return public + + +def _owning_raw_window( + content: str, + source_start: int, +) -> tuple[int, int, int, int]: + """Return the raw and owned bounds used by the ordinary window scanner.""" + if len(content) <= static_runner.SECURITY_VIEW_WINDOW_CHARS: + return 0, len(content), 0, len(content) + owned_start = ( + source_start // static_runner._RAW_WINDOW_OWNED_CHARS + ) * static_runner._RAW_WINDOW_OWNED_CHARS + owned_end = min(len(content), owned_start + static_runner._RAW_WINDOW_OWNED_CHARS) + raw_start = max(0, owned_start - static_runner._WINDOW_OVERLAP_CHARS) + raw_end = min(len(content), owned_end + static_runner._WINDOW_OVERLAP_CHARS) + return raw_start, raw_end, owned_start, owned_end + + +def _window_popen_qualifiers( + content: str, + popen_starts: set[int], + check_runtime: Callable[[], None], + source_context: static_runner._WindowSourceContext, +) -> dict[int, tuple[int, int, str, str, bool, bool]]: + """Replay each owning bounded window once for retained Popen anchors.""" + targets_by_window: dict[tuple[int, int, int, int], set[int]] = {} + for popen_start in popen_starts: + bounds = _owning_raw_window(content, popen_start) + targets_by_window.setdefault(bounds, set()).add(popen_start) + + qualifiers: dict[int, tuple[int, int, str, str, bool, bool]] = {} + qualified_pattern = DIRECT_SHELL_TRUE_PATTERNS[0][0] + for (raw_start, raw_end, owned_start, owned_end), targets in targets_by_window.items(): + check_runtime() + raw_window = content[raw_start:raw_end] + context_prefix = static_runner._markdown_context_prefix( + content, + raw_start, + raw_end, + source_context.fence_states, + source_context.fence_transitions, + ) + for raw_view in security_text_views(context_prefix + raw_window): + full_view = static_runner._window_view_with_markdown_context( + raw_view, + len(context_prefix), + ) + check_runtime() + for view in static_runner._bounded_view_slices(full_view): + check_runtime() + for match in re.finditer( + qualified_pattern, view.text, re.IGNORECASE | re.MULTILINE + ): + method = re.match( + r"subprocess\.(?P\w+)", + match.group(0), + re.IGNORECASE, + ) + if method is None or method.group("method").casefold() != "popen": + continue + derived_popen_start = match.start() + method.start("method") + popen_start = raw_start + view.source_offset(derived_popen_start) + if popen_start not in targets: + continue + qualifier_start = raw_start + view.source_offset(match.start()) + qualifier_end = raw_start + view.source_offset(derived_popen_start - 1) + 1 + qualifier_text = view.text[match.start() : derived_popen_start] + raw_match_end = raw_start + view.source_offset(match.end() - 1) + 1 + requires_derived_match = ( + view.name != "raw" + and match.group(0) != content[qualifier_start:raw_match_end] + ) + candidate = ( + qualifier_start, + qualifier_end, + qualifier_text, + match.group(0), + requires_derived_match, + owned_start <= qualifier_start < owned_end, + ) + previous = qualifiers.get(popen_start) + if previous is None or (candidate[4], candidate[5]) > ( + previous[4], + previous[5], + ): + qualifiers[popen_start] = candidate + return qualifiers + + +def _cross_window_subprocess_qualifiers( + content: str, + popen_anchors: dict[int, int], + check_runtime: Callable[[], None], +) -> dict[int, tuple[int, int, str, str, bool]]: + """Recover retained qualifiers while replaying each shared projection once.""" + qualifiers: dict[int, tuple[int, int, str, str, bool]] = {} + qualified_pattern = DIRECT_SHELL_TRUE_PATTERNS[0][0] + targets_by_run: dict[tuple[int, int], dict[int, int]] = {} + runs_by_anchor = static_runner._continuity_runs_for_anchors( + content, + set(popen_anchors.values()), + check_runtime, + ) + for popen_start, anchor in popen_anchors.items(): + run = runs_by_anchor.get(anchor) + if run is not None: + targets_by_run.setdefault(run, {})[popen_start] = anchor + + for _, projection_target_anchors in sorted(targets_by_run.items()): + projection_targets = set(projection_target_anchors) + projection = static_runner._anchored_continuity_view( + content, + min(projection_target_anchors.values()), + check_runtime, + ) + if projection is None or not projection.text or projection.source_offsets is None: + continue + + for full_view in security_text_views(projection.text): + for view in static_runner._bounded_view_slices(full_view): + check_runtime() + for match in re.finditer( + qualified_pattern, + view.text, + re.IGNORECASE | re.MULTILINE, + ): + method = re.match( + r"subprocess\.(?P\w+)", + match.group(0), + re.IGNORECASE, + ) + if method is None or method.group("method").casefold() != "popen": + continue + derived_popen_start = match.start() + method.start("method") + projected_popen_start = view.source_offset(derived_popen_start) + raw_popen_start = projection.source_offset(projected_popen_start) + if raw_popen_start not in projection_targets or raw_popen_start in qualifiers: + continue + projected_start = view.source_offset(match.start()) + projected_end = view.source_offset(derived_popen_start - 1) + qualifiers[raw_popen_start] = ( + projection.source_offset(projected_start), + projection.source_offset(projected_end) + 1, + view.text[match.start() : derived_popen_start], + match.group(0), + True, + ) + return qualifiers + + +def reconcile_retained_findings( + content: str, + findings: list[Finding], + check_runtime: Callable[[], None], + source_context: static_runner._WindowSourceContext, +) -> None: + """Finalize retained bare-Popen identity without discovering new findings.""" + retained: list[tuple[Finding, int, int]] = [] + for finding in findings: + if ( + finding.evidence.get(LEXICAL_DIRECT_SHELL_EVIDENCE) is not True + or re.match(r"Popen\b", finding.matched_text or "", re.IGNORECASE) is None + ): + continue + coordinates = [ + coordinate + for key in ( + static_runner._ABSOLUTE_START_EVIDENCE, + static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE, + ) + if type(coordinate := finding.evidence.get(key)) is int + ] + if coordinates: + popen_start = max(coordinates) + shell_anchor = finding.evidence.get(static_runner._ABSOLUTE_ANCHOR_EVIDENCE) + retained.append( + ( + finding, + popen_start, + shell_anchor if type(shell_anchor) is int else popen_start, + ) + ) + + window_qualifiers = _window_popen_qualifiers( + content, + {popen_start for _, popen_start, _ in retained}, + check_runtime, + source_context, + ) + cross_window_qualifiers = _cross_window_subprocess_qualifiers( + content, + {popen_start: continuity_anchor for _, popen_start, continuity_anchor in retained}, + check_runtime, + ) + for finding, popen_start, _ in retained: + check_runtime() + window_qualifier = window_qualifiers.get(popen_start) + cross_window_qualifier = cross_window_qualifiers.get(popen_start) + if cross_window_qualifier is not None: + qualifier = cross_window_qualifier + update_public_match = True + elif window_qualifier is not None: + ( + qualifier_start, + qualifier_end, + qualifier_text, + canonical_match, + normalized_view, + qualifier_owned, + ) = window_qualifier + update_public_match = normalized_view or qualifier_owned + qualifier = ( + qualifier_start, + qualifier_end, + qualifier_text, + canonical_match, + normalized_view, + ) + else: + qualifier = None + update_public_match = False + if qualifier is None: + continue + qualifier_start, _, _, canonical_match, normalized_view = qualifier + canonical_projection = normalized_security_view(canonical_match).text + canonical_preview = canonical_projection[:200] + finding.evidence[LEXICAL_NORMALIZED_MATCH_EVIDENCE] = canonical_preview + if update_public_match: + finding.matched_text = canonical_preview + finding.finding = canonical_preview + finding.evidence[static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE] = qualifier_start + finding.match_fingerprint = sha256( + f"{finding.rule_id}\x1f{' '.join(canonical_projection.strip().split())}".encode() + ).hexdigest() + if normalized_view and "normalized-view" not in finding.tags: + finding.tags.append("normalized-view") + + +def _lexical_call_start(finding: Finding) -> int | None: + """Return a lexical owner's canonical call coordinate.""" + coordinates = ( + coordinate + for key in ( + static_runner._ABSOLUTE_START_EVIDENCE, + static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE, + ) + if type(coordinate := finding.evidence.get(key)) is int + ) + return min(coordinates, default=None) + + +def _internal_evidence_keys() -> tuple[str, ...]: + """Return private reconciliation keys that must not escape public findings.""" + from . import static_python_shell_truthiness + + return ( + LEXICAL_DIRECT_SHELL_EVIDENCE, + LEXICAL_RAW_OWNER_EVIDENCE, + LEXICAL_NORMALIZED_MATCH_EVIDENCE, + LEXICAL_BOUND_IDENTITY_EVIDENCE, + LEXICAL_BOUND_IDENTITY_SOURCE_EVIDENCE, + LEXICAL_BOUND_CLASSIFICATION_EVIDENCE, + LEXICAL_REACH_TERMINATED_EVIDENCE, + LEXICAL_BOUND_REACHABLE_EVIDENCE, + static_python_shell_truthiness.BOUND_SHELL_EVIDENCE, + static_python_shell_truthiness.BOUND_CALL_START_EVIDENCE, + static_python_shell_truthiness.BOUND_CALL_END_EVIDENCE, + static_python_shell_truthiness.BOUND_SHELL_ANCHOR_EVIDENCE, + static_python_shell_truthiness.BOUND_CANONICAL_FINGERPRINT_EVIDENCE, + static_python_shell_truthiness.BOUND_NORMALIZED_VIEW_EVIDENCE, + static_python_shell_truthiness.BOUND_CLASSIFICATION_MATCH_EVIDENCE, + static_python_shell_truthiness.BOUND_DIRECT_MATCH_END_EVIDENCE, + static_python_shell_truthiness.BOUND_POPEN_START_EVIDENCE, + static_python_shell_truthiness.BOUND_DIRECT_OWNER_START_EVIDENCE, + static_python_shell_truthiness.BOUND_SHELL_VALUE_START_EVIDENCE, + static_python_shell_truthiness.BOUND_SHELL_VALUE_END_EVIDENCE, + static_python_shell_truthiness.DIRECT_LITERAL_METADATA_EVIDENCE, + static_runner._VIEW_START_EVIDENCE, + static_runner._VIEW_ANCHOR_EVIDENCE, + static_runner._VIEW_ALTERNATE_START_EVIDENCE, + static_runner._VIEW_REACH_END_EVIDENCE, + static_runner._VIEW_REPLACEMENT_START_LIMIT_EVIDENCE, + static_runner._SOURCE_START_EVIDENCE, + static_runner._SOURCE_ANCHOR_EVIDENCE, + static_runner._SOURCE_ALTERNATE_START_EVIDENCE, + static_runner._SOURCE_REACH_END_EVIDENCE, + static_runner._SOURCE_REPLACEMENT_START_LIMIT_EVIDENCE, + static_runner._SOURCE_REPLACEMENT_RECOVERY_START_EVIDENCE, + static_runner._PRESERVE_SOURCE_START_EVIDENCE, + static_runner._ABSOLUTE_START_EVIDENCE, + static_runner._ABSOLUTE_ANCHOR_EVIDENCE, + static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE, + static_runner._ABSOLUTE_REACH_END_EVIDENCE, + static_runner._ABSOLUTE_REPLACEMENT_START_LIMIT_EVIDENCE, + static_runner._ABSOLUTE_REPLACEMENT_RECOVERY_START_EVIDENCE, + static_runner._ALTERNATE_MATCHED_TEXT_EVIDENCE, + ) + + +def cleanup_path_findings(findings: list[Finding]) -> list[Finding]: + """Strip private reconciliation state when the analysis deadline has expired.""" + internal_keys = _internal_evidence_keys() + for finding in findings: + for key in internal_keys: + finding.evidence.pop(key, None) + return findings + + +class _DirectShellReplayLexical: + """Lexical-only facade used for direct-equivalent bounded windows.""" + + USES_PYTHON_SOURCE_TYPE = True + + @staticmethod + def analyze(content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + return analyze( + content, + file_path, + file_type, + _direct_shell_only=True, + ) + + +def _bound_direct_replays( + content: str, + findings: list[Finding], + python_ast: ParsedPythonFile | None, + *, + started_at: float | None = None, + timeout_seconds: float | None = None, +) -> dict[int, Finding]: + """Replay only retained owners in one cap-independent virtual artifact.""" + from . import static_python_shell_truthiness + + if python_ast is None or python_ast.tree is None: + return {} + retained_call_starts: set[int] = set() + file_path = "" + for finding in findings: + if ( + finding.evidence.get(static_python_shell_truthiness.BOUND_SHELL_EVIDENCE) is not True + or finding.evidence.get(static_python_shell_truthiness.DIRECT_LITERAL_METADATA_EVIDENCE) + is True + ): + continue + call_start = finding.evidence.get(static_python_shell_truthiness.BOUND_CALL_START_EVIDENCE) + if type(call_start) is int: + retained_call_starts.add(call_start) + file_path = finding.file + if not retained_call_starts: + return {} + + budget_started_at = time.monotonic() if started_at is None else started_at + runtime_limit = static_runner.MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT + if timeout_seconds is not None: + runtime_limit = min(runtime_limit, max(0.0, timeout_seconds)) + replay_budget = static_runner._FindingBudget( + max_findings=static_runner.MAX_FILE_CHARS, + started_at=budget_started_at, + deadline=budget_started_at + runtime_limit, + clock=time.monotonic, + ) + try: + specs = static_python_shell_truthiness.bound_shell_metadata( + python_ast, + file_path, + check_runtime=replay_budget.check_runtime, + ) + except static_runner._StaticResourceLimitError: + return {} + if not specs: + return {} + + cursor = 0 + shift = 0 + virtual_parts: list[str] = [] + virtual_coordinates_by_call: dict[int, tuple[int, int | None, int]] = {} + try: + for spec in specs: + if spec.value_start < cursor: + # The supported straight-line grammar cannot overlap shell-name + # spans. Stay conservative if malformed coordinates ever do. + return {} + replay_budget.check_runtime() + virtual_parts.append(content[cursor : spec.value_start]) + virtual_call_start = spec.call_start + shift + virtual_coordinates_by_call[spec.call_start] = ( + virtual_call_start, + spec.popen_start + shift if spec.popen_start is not None else None, + spec.shell_anchor + shift, + ) + virtual_parts.append("True") + cursor = spec.value_end + shift += len("True") - (spec.value_end - spec.value_start) + virtual_parts.append(content[cursor:]) + virtual_content = "".join(virtual_parts) + replay_budget.check_runtime() + except static_runner._StaticResourceLimitError: + return {} + replacement_starts = [spec.value_start for spec in specs] + replacement_ends: list[int] = [] + cumulative_deltas: list[int] = [0] + for spec in specs: + replacement_ends.append(spec.value_end) + cumulative_deltas.append( + cumulative_deltas[-1] + len("True") - (spec.value_end - spec.value_start) + ) + + def source_to_virtual_boundary(source_offset: int) -> int: + replacement_count = bisect_right(replacement_ends, source_offset) + return source_offset + cumulative_deltas[replacement_count] + + def boundary_is_preserved(source_offset: int, virtual_offset: int) -> bool: + """Return whether a raw-window edge has one unambiguous virtual edge.""" + replacement_index = bisect_right(replacement_starts, source_offset) - 1 + if ( + replacement_index >= 0 + and replacement_starts[replacement_index] + < source_offset + < replacement_ends[replacement_index] + ): + return False + return source_to_virtual_boundary(source_offset) == virtual_offset + + metadata_by_call = {spec.call_start: spec for spec in specs} + fixed_coordinates = { + coordinate + for finding in findings + if finding.evidence.get(LEXICAL_DIRECT_SHELL_EVIDENCE) is True + and type(finding.evidence.get(static_runner._ABSOLUTE_REPLACEMENT_START_LIMIT_EVIDENCE)) + is int + for key in ( + static_runner._ABSOLUTE_START_EVIDENCE, + static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE, + ) + if type(coordinate := finding.evidence.get(key)) is int + } + retained_shell_anchors = { + spec.shell_anchor for spec in specs if spec.call_start in retained_call_starts + } + try: + continuity_runs = static_runner._continuity_runs_for_anchors( + content, + retained_shell_anchors, + replay_budget.check_runtime, + ) + except static_runner._StaticResourceLimitError: + return {} + continuity_calls = { + spec.call_start + for spec in specs + if spec.call_start in retained_call_starts and spec.shell_anchor in continuity_runs + } + replay_targets: dict[int, int] = {} + target_calls: set[int] = set() + for call_start in retained_call_starts: + target_spec = metadata_by_call.get(call_start) + target_coordinates = virtual_coordinates_by_call.get(call_start) + if target_spec is None or target_coordinates is None: + continue + virtual_call_start, virtual_popen_start, _ = target_coordinates + related_fixed_boundary = bool( + {call_start, target_spec.popen_start}.difference({None}).intersection(fixed_coordinates) + ) + source_coordinates = (call_start, target_spec.popen_start) + virtual_coordinates = (virtual_call_start, virtual_popen_start) + changes_outer_window = any( + not all( + boundary_is_preserved(source_boundary, virtual_boundary) + for source_boundary, virtual_boundary in zip( + _owning_raw_window(content, source_coordinate), + _owning_raw_window(virtual_content, virtual_coordinate), + strict=True, + ) + ) + for source_coordinate, virtual_coordinate in zip( + source_coordinates, + virtual_coordinates, + strict=True, + ) + if source_coordinate is not None and virtual_coordinate is not None + ) or (len(content) <= static_runner.SECURITY_VIEW_WINDOW_CHARS) != ( + len(virtual_content) <= static_runner.SECURITY_VIEW_WINDOW_CHARS + ) + call_end = ( + target_spec.call_end if target_spec.call_end is not None else target_spec.value_end + ) + try: + replay_budget.check_runtime() + call_text = content[target_spec.call_start : call_end] + call_has_derived_view = _has_derived_security_view(call_text) + call_has_direct_lookalike = _DIRECT_SHELL_TRUE_VALUE.search(call_text) is not None + replay_budget.check_runtime() + except static_runner._StaticResourceLimitError: + return {} + if not ( + target_spec.normalized_view + or call_has_derived_view + or call_has_direct_lookalike + or related_fixed_boundary + or changes_outer_window + or call_start in continuity_calls + ): + continue + target_calls.add(call_start) + replay_targets[virtual_call_start] = call_start + if virtual_popen_start is not None: + replay_targets[virtual_popen_start] = call_start + if not target_calls: + return {} + + source_line_starts = ( + 0, + *(match.end() for match in LOGICAL_LINE_BREAK.finditer(virtual_content)), + ) + window_targets: dict[tuple[int, int, int, int], set[int]] = {} + for coordinate in replay_targets: + bounds = _owning_raw_window(virtual_content, coordinate) + window_targets.setdefault(bounds, set()).add(coordinate) + + replay_candidates: list[Finding] = [] + seen_candidates: set[static_runner._ViewFindingKey] = set() + whole_artifact_window = len(virtual_content) <= static_runner.SECURITY_VIEW_WINDOW_CHARS + try: + for (raw_start, raw_end, owned_start, owned_end), _ in window_targets.items(): + replay_budget.check_runtime() + raw_window = virtual_content[raw_start:raw_end] + owned_source_start = owned_start - raw_start + owned_source_end = owned_end - raw_start + outer_right_boundary_is_fixed = raw_end < len(virtual_content) or ( + whole_artifact_window + and len(virtual_content) == static_runner.SECURITY_VIEW_WINDOW_CHARS + ) + right_boundary_recovery_start = ( + owned_start + static_runner._RAW_WINDOW_OWNED_CHARS - raw_start + if raw_end < len(virtual_content) + else static_runner._RAW_WINDOW_OWNED_CHARS + if whole_artifact_window + and len(virtual_content) > static_runner.SECURITY_VIEW_WINDOW_CHARS - len("True") + else None + ) + for full_view in security_text_views(raw_window): + full_view = static_runner._with_fixed_right_boundary( + full_view, + outer_right_boundary_is_fixed, + right_boundary_recovery_start, + ) + for view in static_runner._bounded_view_slices(full_view): + replay_budget.check_runtime() + view_findings, _ = static_runner._scan_view_windows( + file_path, + view, + [_DirectShellReplayLexical], + replay_budget, + None, + python_source=True, + source_text=raw_window, + ) + owned_findings: list[Finding] = [] + for replay in view_findings: + source_start = replay.evidence.get(static_runner._SOURCE_START_EVIDENCE) + if isinstance(source_start, int) and not ( + owned_source_start <= source_start < owned_source_end + ): + alternate_start = replay.evidence.get( + static_runner._SOURCE_ALTERNATE_START_EVIDENCE + ) + if not ( + isinstance(alternate_start, int) + and owned_source_start <= alternate_start < owned_source_end + ): + continue + replay.evidence[static_runner._SOURCE_START_EVIDENCE] = alternate_start + replay.evidence[static_runner._SOURCE_ALTERNATE_START_EVIDENCE] = ( + source_start + ) + owned_findings.append(replay) + static_runner._restore_source_lines( + owned_findings, + raw_window=raw_window, + window_line=1, + view=view, + window_start=raw_start, + source_line_starts=source_line_starts, + ) + for replay in owned_findings: + candidate_coordinates = { + coordinate + for key in ( + static_runner._ABSOLUTE_START_EVIDENCE, + static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE, + ) + if type(coordinate := replay.evidence.get(key)) is int + } + if not candidate_coordinates.intersection(replay_targets): + continue + key = static_runner._view_finding_key(replay) + if key not in seen_candidates: + seen_candidates.add(key) + replay_candidates.append(replay) + + for call_start in continuity_calls.intersection(target_calls): + replay_budget.check_runtime() + _, _, virtual_shell_anchor = virtual_coordinates_by_call[call_start] + projection = static_runner._anchored_continuity_view( + virtual_content, + virtual_shell_anchor, + replay_budget.check_runtime, + ) + if projection is None or not projection.text or projection.source_offsets is None: + continue + projection_is_fixed = projection.source_offsets[-1] + 1 < len(virtual_content) + for full_view in security_text_views(projection.text): + full_view = SecurityTextView( + name=f"continuity-{full_view.name}", + text=full_view.text, + source_offsets=full_view.source_offsets, + right_boundary_is_fixed=projection_is_fixed, + ) + for view in static_runner._bounded_view_slices(full_view): + replay_budget.check_runtime() + view_findings, _ = static_runner._scan_view_windows( + file_path, + view, + [_DirectShellReplayLexical], + replay_budget, + None, + python_source=True, + source_text=projection.text, + ) + static_runner._restore_source_lines( + view_findings, + raw_window=projection.text, + window_line=1, + view=view, + start_source_offsets=projection.source_offsets, + ) + for replay in view_findings: + candidate_coordinates = { + coordinate + for key in ( + static_runner._ABSOLUTE_START_EVIDENCE, + static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE, + ) + if type(coordinate := replay.evidence.get(key)) is int + } + if not candidate_coordinates.intersection(replay_targets): + continue + key = static_runner._view_finding_key(replay) + if key not in seen_candidates: + seen_candidates.add(key) + replay_candidates.append(replay) + except static_runner._StaticResourceLimitError: + return {} + + replay_findings = static_runner._deduplicate_view_findings( + coalesce_path_findings(virtual_content, replay_candidates) + ) + replay_by_call: dict[int, Finding] = {} + for replay in replay_findings: + replay_coordinates = { + coordinate + for key in ( + static_runner._ABSOLUTE_START_EVIDENCE, + static_runner._ABSOLUTE_ALTERNATE_START_EVIDENCE, + ) + if type(coordinate := replay.evidence.get(key)) is int + } + for coordinate in sorted(replay_coordinates): + call_start = replay_targets.get(coordinate) + if call_start is not None: + spec = metadata_by_call[call_start] + _, _, virtual_shell_anchor = virtual_coordinates_by_call[call_start] + lexical_anchor = replay.evidence.get(static_runner._ABSOLUTE_ANCHOR_EVIDENCE) + if lexical_anchor == virtual_shell_anchor: + replay.match_fingerprint = spec.canonical_fingerprint + if spec.normalized_view and "normalized-view" not in replay.tags: + replay.tags.append("normalized-view") + replay_by_call.setdefault(call_start, replay) + break + return replay_by_call + + +def postprocess_path_findings( + content: str, + findings: list[Finding], + *, + python_ast: ParsedPythonFile | None = None, + started_at: float | None = None, + timeout_seconds: float | None = None, +) -> list[Finding]: + """Finalize ownership, classification, and private TM1 coordinates.""" + from . import static_python_shell_truthiness + + direct_replays = _bound_direct_replays( + content, + findings, + python_ast, + started_at=started_at, + timeout_seconds=timeout_seconds, + ) + reconciled = coalesce_path_findings(content, findings) + if python_ast is not None: + direct_starts = { + start + for finding in reconciled + if finding.evidence.get(LEXICAL_DIRECT_SHELL_EVIDENCE) is True + and type(start := _lexical_call_start(finding)) is int + } + direct_metadata = static_python_shell_truthiness.direct_literal_metadata( + python_ast, + next((finding.file for finding in reconciled), ""), + direct_starts, + ) + canonical_direct_calls: dict[tuple[str, int, str], set[int]] = {} + for finding in reconciled: + direct_call_start = _lexical_call_start(finding) + metadata = ( + direct_metadata.get(direct_call_start) if type(direct_call_start) is int else None + ) + if metadata is not None: + canonical_direct_calls.setdefault( + (finding.file, finding.start_line, metadata.match_fingerprint), + set(), + ).add(metadata.call_start) + colliding_direct_identities = { + key for key, call_starts in canonical_direct_calls.items() if len(call_starts) > 1 + } + for finding in reconciled: + direct_call_start = _lexical_call_start(finding) + metadata = ( + direct_metadata.get(direct_call_start) if type(direct_call_start) is int else None + ) + lexical_anchor = finding.evidence.get(static_runner._ABSOLUTE_ANCHOR_EVIDENCE) + if metadata is None or lexical_anchor != metadata.shell_anchor: + continue + if ( + finding.file, + finding.start_line, + metadata.match_fingerprint, + ) not in colliding_direct_identities: + finding.match_fingerprint = metadata.match_fingerprint + finding.evidence.update( + { + static_python_shell_truthiness.BOUND_SHELL_EVIDENCE: True, + static_python_shell_truthiness.BOUND_CALL_START_EVIDENCE: metadata.call_start, + static_python_shell_truthiness.DIRECT_LITERAL_METADATA_EVIDENCE: True, + } + ) + if metadata.call_end is not None: + finding.evidence[static_python_shell_truthiness.BOUND_CALL_END_EVIDENCE] = ( + metadata.call_end + ) + if metadata.normalized_view and "normalized-view" not in finding.tags: + finding.tags.append("normalized-view") + if metadata.normalized_view: + finding.evidence[static_python_shell_truthiness.BOUND_NORMALIZED_VIEW_EVIDENCE] = ( + True + ) + safety_by_scope: dict[tuple[int, int, bool], tuple[bool, bool]] = {} + raw_lines_by_start: dict[int, tuple[str, ...]] = {} + source_line_starts = (0, *(match.end() for match in LOGICAL_LINE_BREAK.finditer(content))) + + for finding in reconciled: + is_bound = finding.evidence.get(static_python_shell_truthiness.BOUND_SHELL_EVIDENCE) is True + if ( + finding.evidence.get(LEXICAL_DIRECT_SHELL_EVIDENCE) is True + and "normalized-view" in finding.tags + and isinstance( + canonical_match := finding.evidence.get(LEXICAL_NORMALIZED_MATCH_EVIDENCE), + str, + ) + ): + finding.matched_text = canonical_match + finding.finding = canonical_match + is_direct_literal = ( + finding.evidence.get(static_python_shell_truthiness.DIRECT_LITERAL_METADATA_EVIDENCE) + is True + ) + if is_bound and not is_direct_literal: + if ( + finding.evidence.get(static_python_shell_truthiness.BOUND_NORMALIZED_VIEW_EVIDENCE) + is True + and "normalized-view" not in finding.tags + ): + finding.tags.append("normalized-view") + matched = (finding.matched_text or "")[:200] + classification_match = finding.evidence.get( + static_python_shell_truthiness.BOUND_CLASSIFICATION_MATCH_EVIDENCE, + matched, + ) + if not isinstance(classification_match, str): + classification_match = matched + call_start = finding.evidence.get( + static_python_shell_truthiness.BOUND_CALL_START_EVIDENCE + ) + call_end = finding.evidence.get(static_python_shell_truthiness.BOUND_CALL_END_EVIDENCE) + classification_context = finding.context or "" + matched_line = matched + safe_context: bool | None = None + safe_matched_line: bool | None = None + if type(call_start) is int: + preserves_lexical_location = ( + finding.evidence.get( + static_python_shell_truthiness.DIRECT_LITERAL_METADATA_EVIDENCE + ) + is True + ) + if not preserves_lexical_location: + finding.start_line = bisect_right(source_line_starts, call_start) + if type(call_end) is int and call_end > call_start: + finding.end_line = bisect_right(source_line_starts, call_end - 1) + retained_start = finding.evidence.get(static_runner._ABSOLUTE_START_EVIDENCE) + direct_owner_start = finding.evidence.get( + static_python_shell_truthiness.BOUND_DIRECT_OWNER_START_EVIDENCE + ) + classification_start = ( + retained_start + if preserves_lexical_location and type(retained_start) is int + else direct_owner_start + if type(direct_owner_start) is int + else call_start + ) + direct_match_end = finding.evidence.get( + static_python_shell_truthiness.BOUND_DIRECT_MATCH_END_EVIDENCE + ) + popen_start = finding.evidence.get( + static_python_shell_truthiness.BOUND_POPEN_START_EVIDENCE + ) + _, qualifier_window_end = _raw_classification_bounds(content, call_start) + if ( + not preserves_lexical_location + and classification_start == call_start + and type(direct_match_end) is int + and direct_match_end > qualifier_window_end + and type(popen_start) is int + ): + classification_start = popen_start + raw_start, raw_end = _raw_classification_bounds(content, classification_start) + classification_line = bisect_right( + source_line_starts, + classification_start, + ) + normalized_view = ( + finding.evidence.get( + static_python_shell_truthiness.BOUND_NORMALIZED_VIEW_EVIDENCE + ) + is True + ) + scope_key = (raw_start, classification_line, normalized_view) + safety = safety_by_scope.get(scope_key) + if safety is None: + raw_lines = raw_lines_by_start.get(raw_start) + if raw_lines is None: + raw_lines = tuple(content[raw_start:raw_end].splitlines()) + raw_lines_by_start[raw_start] = raw_lines + raw_start_line = bisect_right(source_line_starts, raw_start) + local_line = classification_line - raw_start_line + if 0 <= local_line < len(raw_lines): + context_start = max(0, local_line - 3) + context_end = min(len(raw_lines), local_line + 4) + classification_context = "\n".join(raw_lines[context_start:context_end]) + matched_line = raw_lines[local_line] + if normalized_view: + classification_context = normalized_security_view( + classification_context + ).text + matched_line = normalized_security_view(matched_line).text + classification_match = normalized_security_view(classification_match).text + safety = ( + _is_safe_container_command(classification_context) + or _is_safe_dockerfile_idiom( + classification_context, + classification_match, + ), + _is_safe_cache_cleanup(matched_line), + ) + safety_by_scope[scope_key] = safety + safe_context, safe_matched_line = safety + severity, finding.confidence = _classify_tm1( + classification_context, + classification_match, + matched_line, + ( + 0.8 + if finding.evidence.get( + static_python_shell_truthiness.DIRECT_LITERAL_METADATA_EVIDENCE + ) + is True + else finding.confidence + ), + "python", + safe_context=safe_context, + safe_matched_line=safe_matched_line, + ) + finding.severity = severity.value + borrowed_classification = finding.evidence.get(LEXICAL_BOUND_CLASSIFICATION_EVIDENCE) + if isinstance(borrowed_classification, dict): + borrowed_severity = borrowed_classification.get("severity") + borrowed_confidence = borrowed_classification.get("confidence") + borrowed_tags = borrowed_classification.get("tags") + if isinstance(borrowed_severity, str): + finding.severity = borrowed_severity + if isinstance(borrowed_confidence, (int, float)) and not isinstance( + borrowed_confidence, bool + ): + finding.confidence = float(borrowed_confidence) + if isinstance(borrowed_tags, (list, tuple)) and all( + isinstance(tag, str) for tag in borrowed_tags + ): + finding.tags = list(borrowed_tags) + replay_call_start = finding.evidence.get( + static_python_shell_truthiness.BOUND_CALL_START_EVIDENCE + ) + replay = ( + direct_replays.get(replay_call_start) + if type(replay_call_start) is int and not is_direct_literal + else None + ) + if replay is not None: + finding.match_fingerprint = replay.fingerprint() + finding.severity = replay.severity + finding.confidence = replay.confidence + finding.tags = list(replay.tags) + return cleanup_path_findings(reconciled) + + def node(state: SkillspectorState) -> AnalyzerNodeResponse: """Run tool_misuse patterns and return findings.""" - response = static_runner.run_static_patterns_with_ledger(state, [sys.modules[__name__]]) + from . import static_python_shell_truthiness + + response = static_runner.run_static_patterns_with_ledger( + state, + [sys.modules[__name__], static_python_shell_truthiness], + ) logger.info("%s: %d findings", ANALYZER_ID, len(response["findings"])) return response diff --git a/src/skillspector/nodes/analyzers/static_python_shell_truthiness.py b/src/skillspector/nodes/analyzers/static_python_shell_truthiness.py new file mode 100644 index 000000000..758a73013 --- /dev/null +++ b/src/skillspector/nodes/analyzers/static_python_shell_truthiness.py @@ -0,0 +1,1180 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Find direct subprocess calls using a definitely truthy local name. + +This companion recognizes the straight-line ordinary-Python form reported in +issue #475. Arguments evaluated through ``shell=`` must be passive, and +unsupported expressions or compound statements discard facts rather than +guessing about Python execution. +""" + +from __future__ import annotations + +import ast +import re +from collections.abc import Callable +from dataclasses import dataclass + +from skillspector.artifacts import ( + normalized_security_prefix, + normalized_security_view, + security_text_views, +) +from skillspector.models import AnalyzerFinding, Location, Severity, compute_match_fingerprint +from skillspector.python_ast import ParsedPythonFile, parse_python_source + +from .common import LINE_BREAK_CHARS, get_complete_source_segment, get_context_from_lines +from .pattern_defaults import PatternCategory + +ANALYZER_ID = "static_patterns_tool_misuse" +USES_PYTHON_AST = True +BOUND_SHELL_EVIDENCE = "_tm1_bound_shell_value" +BOUND_CALL_START_EVIDENCE = "_tm1_bound_call_start" +BOUND_CALL_END_EVIDENCE = "_tm1_bound_call_end" +BOUND_SHELL_ANCHOR_EVIDENCE = "_tm1_bound_shell_anchor" +BOUND_CANONICAL_FINGERPRINT_EVIDENCE = "_tm1_bound_canonical_fingerprint" +BOUND_NORMALIZED_VIEW_EVIDENCE = "_tm1_bound_normalized_view" +BOUND_CLASSIFICATION_MATCH_EVIDENCE = "_tm1_bound_classification_match" +BOUND_DIRECT_MATCH_END_EVIDENCE = "_tm1_bound_direct_match_end" +BOUND_POPEN_START_EVIDENCE = "_tm1_bound_popen_start" +BOUND_DIRECT_OWNER_START_EVIDENCE = "_tm1_bound_direct_owner_start" +BOUND_SHELL_VALUE_START_EVIDENCE = "_tm1_bound_shell_value_start" +BOUND_SHELL_VALUE_END_EVIDENCE = "_tm1_bound_shell_value_end" +DIRECT_LITERAL_METADATA_EVIDENCE = "_tm1_direct_literal_metadata" +_DIRECT_CALL_NAMES = frozenset({"subprocess", "Popen"}) +_DIRECT_CALLEE = re.compile(r"(?:subprocess\.\w+|Popen)", re.IGNORECASE) +_SHELL_KEYWORD_PREFIX = re.compile(r"shell\s*=\s*", re.IGNORECASE) +_MAX_CONTEXT_CHARS = 1024 +_MAX_DIRECT_NAME_CHARS = len("subprocess") + 1 + + +@dataclass(frozen=True, slots=True) +class DirectLiteralMetadata: + """AST metadata attached only to an already-confirmed lexical finding.""" + + call_start: int + call_end: int | None + shell_anchor: int + match_fingerprint: str + normalized_view: bool + + +@dataclass(frozen=True, slots=True) +class BoundShellMetadata: + """Cap-independent source coordinates for one supported bound shell call.""" + + call_start: int + call_end: int | None + shell_anchor: int + value_start: int + value_end: int + popen_start: int | None + canonical_fingerprint: str + normalized_view: bool + + +def _truth_value( + expression: ast.expr | None, + facts: dict[str, bool], +) -> bool | None: + """Return truth for a small, immutable, side-effect-free expression subset.""" + if expression is None: + return None + + resolved: dict[ast.expr, bool] = {} + pending: list[tuple[ast.expr, bool]] = [(expression, False)] + while pending: + current, expanded = pending.pop() + if isinstance(current, ast.Constant): + resolved[current] = bool(current.value) + elif isinstance(current, ast.Name): + if current.id not in facts: + return None + resolved[current] = facts[current.id] + elif isinstance(current, ast.Tuple): + if not current.elts: + resolved[current] = False + elif any(isinstance(item, ast.Starred) for item in current.elts): + return None + elif all(_is_passive_argument(item) for item in current.elts): + resolved[current] = True + else: + return None + elif isinstance(current, ast.UnaryOp): + if not isinstance(current.op, ast.Not) and not ( + isinstance(current.op, (ast.UAdd, ast.USub)) + and isinstance(current.operand, ast.Constant) + and type(current.operand.value) in (bool, int, float, complex) + ): + return None + if expanded: + operand = resolved[current.operand] + resolved[current] = not operand if isinstance(current.op, ast.Not) else operand + else: + pending.append((current, True)) + pending.append((current.operand, False)) + else: + return None + return resolved[expression] + + +def _update_trusted_names_from_import( + statement: ast.Import | ast.ImportFrom, + trusted_names: set[str], +) -> None: + """Update only direct receiver names that the import actually binds.""" + if isinstance(statement, ast.Import): + for imported in statement.names: + bound = imported.asname or imported.name.partition(".")[0] + if imported.name == "subprocess" and bound == "subprocess": + trusted_names.add(bound) + elif bound in trusted_names: + trusted_names.discard(bound) + return + + if any(imported.name == "*" for imported in statement.names): + trusted_names.clear() + return + for imported in statement.names: + bound = imported.asname or imported.name + if ( + statement.level == 0 + and statement.module == "subprocess" + and imported.name == "Popen" + and bound == "Popen" + ): + trusted_names.add(bound) + elif bound in trusted_names: + trusted_names.discard(bound) + + +class _DirectBindingCollector: + """Collect direct receiver bindings without entering nested scopes.""" + + def __init__(self, tracked_names: set[str] | frozenset[str]) -> None: + self.tracked_names = tracked_names + self.bound: set[str] = set() + self.mutated: set[str] = set() + self.nonlocal_names: set[str] = set() + + @staticmethod + def _function_header_nodes( + node: ast.FunctionDef | ast.AsyncFunctionDef, + ) -> list[ast.AST]: + nodes: list[ast.AST] = [*node.decorator_list, *node.args.defaults] + nodes.extend(item for item in node.args.kw_defaults if item is not None) + arguments = (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs) + nodes.extend( + argument.annotation for argument in arguments if argument.annotation is not None + ) + if node.args.vararg is not None and node.args.vararg.annotation is not None: + nodes.append(node.args.vararg.annotation) + if node.args.kwarg is not None and node.args.kwarg.annotation is not None: + nodes.append(node.args.kwarg.annotation) + if node.returns is not None: + nodes.append(node.returns) + nodes.extend(getattr(node, "type_params", ())) + return nodes + + def visit(self, node: ast.AST) -> None: + pending = [node] + while pending: + current = pending.pop() + if isinstance(current, ast.Name): + if ( + isinstance(current.ctx, (ast.Store, ast.Del)) + and current.id in self.tracked_names + ): + self.bound.add(current.id) + continue + if isinstance(current, (ast.Attribute, ast.Subscript)): + if isinstance(current.ctx, (ast.Store, ast.Del)): + root: ast.expr = current.value + while isinstance(root, (ast.Attribute, ast.Subscript)): + root = root.value + if isinstance(root, ast.Name) and root.id in self.tracked_names: + self.mutated.add(root.id) + pending.extend(ast.iter_child_nodes(current)) + continue + if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)): + if current.name in self.tracked_names: + self.bound.add(current.name) + pending.extend(self._function_header_nodes(current)) + continue + if isinstance(current, ast.ClassDef): + if current.name in self.tracked_names: + self.bound.add(current.name) + pending.extend(current.decorator_list) + pending.extend(current.bases) + pending.extend(keyword.value for keyword in current.keywords) + continue + if isinstance(current, ast.Lambda): + pending.extend(current.args.defaults) + pending.extend(item for item in current.args.kw_defaults if item is not None) + continue + if isinstance(current, (ast.ListComp, ast.SetComp, ast.GeneratorExp)): + pending.append(current.elt) + for generator in current.generators: + pending.append(generator.iter) + pending.extend(generator.ifs) + continue + if isinstance(current, ast.DictComp): + pending.extend((current.key, current.value)) + for generator in current.generators: + pending.append(generator.iter) + pending.extend(generator.ifs) + continue + if isinstance(current, ast.Import): + for imported in current.names: + bound = imported.asname or imported.name.partition(".")[0] + if bound in self.tracked_names: + self.bound.add(bound) + continue + if isinstance(current, ast.ImportFrom): + if any(imported.name == "*" for imported in current.names): + self.bound.update(self.tracked_names) + continue + for imported in current.names: + bound = imported.asname or imported.name + if bound in self.tracked_names: + self.bound.add(bound) + continue + if isinstance(current, ast.ExceptHandler): + if isinstance(current.name, str) and current.name in self.tracked_names: + self.bound.add(current.name) + pending.extend(ast.iter_child_nodes(current)) + continue + if isinstance(current, (ast.Global, ast.Nonlocal)): + self.nonlocal_names.update(current.names) + continue + if isinstance(current, ast.MatchAs): + if isinstance(current.name, str) and current.name in self.tracked_names: + self.bound.add(current.name) + if current.pattern is not None: + pending.append(current.pattern) + continue + if isinstance(current, ast.MatchStar): + if isinstance(current.name, str) and current.name in self.tracked_names: + self.bound.add(current.name) + continue + if isinstance(current, ast.MatchMapping): + if isinstance(current.rest, str) and current.rest in self.tracked_names: + self.bound.add(current.rest) + pending.extend(current.patterns) + continue + pending.extend(ast.iter_child_nodes(current)) + + +def _direct_bound_names(node: ast.AST) -> set[str]: + """Return names bound by *node* without entering deferred nested scopes.""" + candidates: set[str] = set() + for current in ast.walk(node): + if isinstance(current, ast.Name) and isinstance(current.ctx, (ast.Store, ast.Del)): + candidates.add(current.id) + elif isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + candidates.add(current.name) + elif isinstance(current, ast.Import): + candidates.update( + imported.asname or imported.name.partition(".")[0] for imported in current.names + ) + elif isinstance(current, ast.ImportFrom): + candidates.update( + imported.asname or imported.name + for imported in current.names + if imported.name != "*" + ) + elif isinstance(current, ast.ExceptHandler) and isinstance(current.name, str): + candidates.add(current.name) + elif isinstance(current, (ast.MatchAs, ast.MatchStar)) and isinstance( + current.name, + str, + ): + candidates.add(current.name) + elif isinstance(current, ast.MatchMapping) and isinstance(current.rest, str): + candidates.add(current.rest) + collector = _DirectBindingCollector(candidates) + collector.visit(node) + return collector.bound + + +def _function_parameter_names(statement: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]: + """Return names that may already hold unsafe values on body entry.""" + arguments = statement.args + names = { + argument.arg + for argument in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs) + } + if arguments.vararg is not None: + names.add(arguments.vararg.arg) + if arguments.kwarg is not None: + names.add(arguments.kwarg.arg) + declarations = _DirectBindingCollector(set()) + for child in statement.body: + declarations.visit(child) + names.update(declarations.nonlocal_names) + return names + + +def _function_bound_direct_names( + statement: ast.FunctionDef | ast.AsyncFunctionDef, + tracked_names: set[str], +) -> set[str]: + """Return compile-time local receiver names for one function scope.""" + arguments = statement.args + named = (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs) + names = {argument.arg for argument in named} + if arguments.vararg is not None: + names.add(arguments.vararg.arg) + if arguments.kwarg is not None: + names.add(arguments.kwarg.arg) + collector = _DirectBindingCollector(tracked_names) + for child in statement.body: + collector.visit(child) + return names.intersection(tracked_names).union( + collector.bound.difference(collector.nonlocal_names) + ) + + +def _changed_direct_names(nodes: list[ast.AST], tracked_names: set[str]) -> set[str]: + """Return receiver names explicitly rebound or mutated by current-scope nodes.""" + collector = _DirectBindingCollector(tracked_names) + for node in nodes: + collector.visit(node) + return collector.bound.union(collector.mutated) + + +def _class_body_changed_direct_names( + statement: ast.ClassDef, + tracked_names: set[str], +) -> set[str]: + """Return explicit class-execution effects on outer receiver objects.""" + + def nested_classes(node: ast.AST) -> list[ast.ClassDef]: + classes: list[ast.ClassDef] = [] + pending = [node] + while pending: + current = pending.pop() + if isinstance(current, ast.ClassDef): + classes.append(current) + continue + if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + pending.extend(ast.iter_child_nodes(current)) + return classes + + affected: set[str] = set() + pending_classes = [statement] + while pending_classes: + current_class = pending_classes.pop() + declaration_collector = _DirectBindingCollector(tracked_names) + for child in current_class.body: + declaration_collector.visit(child) + global_names = declaration_collector.nonlocal_names.intersection(tracked_names) + + local_direct: dict[str, bool] = {} + affected.update(global_names.intersection(declaration_collector.bound)) + for child in current_class.body: + collector = _DirectBindingCollector(tracked_names) + collector.visit(child) + affected.update( + name + for name in collector.mutated + if name in global_names or local_direct.get(name, True) + ) + affected.update(collector.bound.intersection(global_names)) + pending_classes.extend(nested_classes(child)) + + local_bound = collector.bound.difference(global_names) + if isinstance(child, ast.Import): + for imported in child.names: + bound = imported.asname or imported.name.partition(".")[0] + if bound in local_bound: + local_direct[bound] = ( + imported.name == "subprocess" and bound == "subprocess" + ) + elif isinstance(child, ast.ImportFrom): + for imported in child.names: + bound = imported.asname or imported.name + if bound in local_bound: + local_direct[bound] = ( + child.level == 0 + and child.module == "subprocess" + and imported.name == "Popen" + and bound == "Popen" + ) + elif isinstance(child, ast.Assign): + prior_local_direct = dict(local_direct) + for name in local_bound: + local_direct[name] = False + for target in child.targets: + if isinstance(target, ast.Name) and target.id in local_bound: + local_direct[target.id] = ( + isinstance(child.value, ast.Name) + and child.value.id == target.id + and prior_local_direct.get( + child.value.id, + child.value.id in tracked_names, + ) + ) + elif isinstance(child, ast.AnnAssign) and child.value is not None: + prior_local_direct = dict(local_direct) + for name in local_bound: + local_direct[name] = False + if isinstance(child.target, ast.Name) and child.target.id in local_bound: + local_direct[child.target.id] = ( + isinstance(child.value, ast.Name) + and child.value.id == child.target.id + and prior_local_direct.get( + child.value.id, + child.value.id in tracked_names, + ) + ) + elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if child.name in tracked_names and child.name not in global_names: + local_direct[child.name] = False + elif isinstance(child, ast.Delete): + for name in collector.bound: + local_direct.pop(name, None) + elif local_bound: + for name in local_bound: + local_direct.pop(name, None) + return affected + + +def _normalized_direct_name(name: str) -> str | None: + """Return the bounded canonical spelling of a direct subprocess receiver.""" + normalized = normalized_security_prefix(name, _MAX_DIRECT_NAME_CHARS).casefold() + return normalized if normalized in {"subprocess", "popen"} else None + + +def _is_direct_subprocess_call(call: ast.Call, trusted_names: set[str]) -> bool: + function = call.func + if isinstance(function, ast.Name): + return _normalized_direct_name(function.id) == "popen" and function.id in trusted_names + if not isinstance(function, ast.Attribute) or not isinstance(function.value, ast.Name): + return False + receiver = function.value.id + return _normalized_direct_name(receiver) == "subprocess" and receiver in trusted_names + + +def _is_passive_argument(expression: ast.expr) -> bool: + """Return whether evaluation cannot invoke user-controlled Python code.""" + normal, hash_required, truth_required, numeric_required, integral_required = range(5) + pending: list[tuple[ast.expr, int]] = [(expression, normal)] + while pending: + current, requirement = pending.pop() + if isinstance(current, ast.Constant): + if requirement == numeric_required and type(current.value) not in ( + bool, + int, + float, + complex, + ): + return False + if requirement == integral_required and type(current.value) not in (bool, int): + return False + continue + if isinstance(current, ast.Name): + if requirement != normal: + return False + continue + if isinstance(current, ast.List): + if requirement in (hash_required, numeric_required, integral_required) or any( + isinstance(item, ast.Starred) for item in current.elts + ): + return False + pending.extend((item, normal) for item in current.elts) + continue + if isinstance(current, ast.Tuple): + if requirement in (numeric_required, integral_required): + return False + if any(isinstance(item, ast.Starred) for item in current.elts): + return False + nested_requirement = hash_required if requirement == hash_required else normal + pending.extend((item, nested_requirement) for item in current.elts) + continue + if isinstance(current, ast.Dict): + if requirement in (hash_required, numeric_required, integral_required) or any( + key is None for key in current.keys + ): + return False + pending.extend((key, hash_required) for key in current.keys if key is not None) + pending.extend((value, normal) for value in current.values) + continue + if isinstance(current, ast.Set): + if requirement in (hash_required, numeric_required, integral_required): + return False + pending.extend((item, hash_required) for item in current.elts) + continue + if isinstance(current, ast.UnaryOp): + if isinstance(current.op, ast.Not): + pending.append((current.operand, truth_required)) + elif isinstance(current.op, (ast.UAdd, ast.USub)): + operand_requirement = ( + integral_required if requirement == integral_required else numeric_required + ) + pending.append((current.operand, operand_requirement)) + elif isinstance(current.op, ast.Invert): + pending.append((current.operand, integral_required)) + else: + return False + continue + if isinstance(current, ast.JoinedStr) and all( + isinstance(item, ast.Constant) for item in current.values + ): + if requirement in (numeric_required, integral_required): + return False + continue + return False + return True + + +def _call_arguments_are_passive(call: ast.Call) -> bool: + return all(_is_passive_argument(argument) for argument in call.args) and all( + keyword.arg is not None and _is_passive_argument(keyword.value) for keyword in call.keywords + ) + + +def _shell_argument_is_captured_before_effects(call: ast.Call) -> bool: + """Return whether evaluation reaches ``shell=`` without user-code effects. + + Python evaluates every positional argument, including starred expansions, + before keyword arguments. Keyword values are then evaluated in their stored + order. Effects after ``shell=`` cannot change the already captured value. + """ + if any(not _is_passive_argument(argument) for argument in call.args): + return False + for keyword in call.keywords: + if keyword.arg == "shell": + return _is_passive_argument(keyword.value) + if keyword.arg is None or not _is_passive_argument(keyword.value): + return False + return False + + +def _is_finalizer_safe_value(expression: ast.expr, safe_names: set[str]) -> bool: + """Return whether releasing the resulting value cannot run user code.""" + if not _is_passive_argument(expression): + return False + return all( + not isinstance(node, ast.Name) or node.id in safe_names for node in ast.walk(expression) + ) + + +def _call_arguments_are_protocol_safe(call: ast.Call, safe_names: set[str]) -> bool: + """Return whether subprocess argument consumption cannot dispatch user code.""" + return all(_is_finalizer_safe_value(argument, safe_names) for argument in call.args) and all( + keyword.arg is not None and _is_finalizer_safe_value(keyword.value, safe_names) + for keyword in call.keywords + ) + + +def _annotation_is_passive(annotation: ast.expr) -> bool: + """Accept only annotation spellings whose evaluation cannot rebind a name.""" + return all( + isinstance(node, (ast.Name, ast.Constant, ast.Load)) for node in ast.walk(annotation) + ) + + +def _function_header_is_passive( + statement: ast.FunctionDef | ast.AsyncFunctionDef, +) -> bool: + """Reject definition-time expressions that could mutate tracked bindings.""" + if statement.decorator_list or getattr(statement, "type_params", []): + return False + defaults = (*statement.args.defaults, *(item for item in statement.args.kw_defaults if item)) + if any(not _is_passive_argument(default) for default in defaults): + return False + arguments = ( + *statement.args.posonlyargs, + *statement.args.args, + *statement.args.kwonlyargs, + ) + annotations = [argument.annotation for argument in arguments if argument.annotation is not None] + if statement.args.vararg is not None and statement.args.vararg.annotation is not None: + annotations.append(statement.args.vararg.annotation) + if statement.args.kwarg is not None and statement.args.kwarg.annotation is not None: + annotations.append(statement.args.kwarg.annotation) + if statement.returns is not None: + annotations.append(statement.returns) + return all(_annotation_is_passive(annotation) for annotation in annotations) + + +def _is_immediate_function(statement: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """Return whether a direct call begins executing this function body.""" + if isinstance(statement, ast.AsyncFunctionDef): + return False + pending: list[ast.AST] = list(statement.body) + while pending: + current = pending.pop() + if isinstance(current, (ast.Yield, ast.YieldFrom)): + return False + if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + continue + pending.extend(ast.iter_child_nodes(current)) + return True + + +def _passive_direct_call(statement: ast.stmt) -> ast.Call | None: + """Return a directly evaluated simple-name call with passive arguments.""" + value: ast.expr | None = None + if isinstance(statement, (ast.Expr, ast.Assign)): + value = statement.value + elif isinstance(statement, ast.AnnAssign): + value = statement.value + if ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and _call_arguments_are_passive(value) + ): + return value + return None + + +def _advance_trusted_names(statement: ast.stmt, trusted_names: set[str]) -> None: + """Apply one statement's explicit receiver-binding effects.""" + if isinstance(statement, (ast.Import, ast.ImportFrom)): + _update_trusted_names_from_import(statement, trusted_names) + return + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + if not _function_header_is_passive(statement): + trusted_names.clear() + trusted_names.discard(statement.name) + return + if isinstance(statement, ast.Assign): + changed = _changed_direct_names( + [statement.value, *statement.targets], + trusted_names, + ) + preserved = { + target.id + for target in statement.targets + if isinstance(target, ast.Name) + and isinstance(statement.value, ast.Name) + and statement.value.id == target.id + and target.id in trusted_names + } + trusted_names.difference_update(changed.difference(preserved)) + return + if isinstance(statement, ast.ClassDef): + trusted_names.difference_update(_changed_direct_names([statement], trusted_names)) + trusted_names.difference_update(_class_body_changed_direct_names(statement, trusted_names)) + return + trusted_names.difference_update(_changed_direct_names([statement], trusted_names)) + + +class _Analyzer: + def __init__( + self, + file_path: str, + parsed: ParsedPythonFile, + *, + emit_findings: bool = True, + check_runtime: Callable[[], None] | None = None, + ) -> None: + self.file_path = file_path + self.parsed = parsed + self.python_ast = parsed + self.content = parsed.content + self.lines = parsed.lines + self._emit_findings = emit_findings + self._check_runtime = check_runtime + self.findings: list[AnalyzerFinding] = [] + self._finding_keys: set[tuple[int, str]] = set() + self.bound_shell_metadata: list[BoundShellMetadata] = [] + + def _source_position(self, node: ast.AST, *, end: bool = False) -> int | None: + """Map one AST UTF-8 byte coordinate through the shared parsed source.""" + line_name = "end_lineno" if end else "lineno" + column_name = "end_col_offset" if end else "col_offset" + line = getattr(node, line_name, None) + byte_column = getattr(node, column_name, None) + if not isinstance(line, int) or not isinstance(byte_column, int): + return None + character_column = self.parsed.character_column(line, byte_column) + line_index = line - 1 + if character_column is None or not 0 <= line_index < len(self.parsed.line_character_starts): + return None + return self.parsed.line_character_starts[line_index] + character_column + + def _source_start(self, node: ast.AST) -> int | None: + return self._source_position(node) + + def _source_end(self, node: ast.AST) -> int | None: + return self._source_position(node, end=True) + + def _source_segment(self, node: ast.AST) -> str: + source = self.parsed.source_segment(node) + if source is not None: + return source + line = getattr(node, "lineno", 1) + end_line = getattr(node, "end_lineno", None) + return get_complete_source_segment(self.lines, line, end_line) + + def _canonical_fingerprint( + self, + call: ast.Call, + shell_keyword: ast.keyword, + shell: ast.expr, + ) -> tuple[str, bool, int] | None: + """Mirror the direct lexical match through ``shell=True``.""" + call_start = self._source_start(call) + shell_start = self._source_start(shell) + if call_start is None or shell_start is None or shell_start < call_start: + return None + raw_callee = self._source_segment(call.func) + canonical_start = call_start + if ( + isinstance(call.func, ast.Attribute) + and _normalized_direct_name(call.func.attr) == "popen" + and not any( + view.text.casefold() == "subprocess.popen" + for view in security_text_views(raw_callee) + ) + ): + function_end = self._source_end(call.func) + raw_method = re.search(r"(?P\w+)\s*$", raw_callee) + if function_end is not None and raw_method is not None: + canonical_start = function_end - len(raw_method.group("method")) + raw_canonical = self.content[canonical_start:shell_start] + "True" + canonical = normalized_security_view(raw_canonical).text + normalized_callee = normalized_security_view(raw_callee).text + keyword_start = self._source_start(shell_keyword) + raw_keyword = self.content[keyword_start:shell_start] if keyword_start is not None else "" + normalized_keyword = normalized_security_view(raw_keyword).text + normalization_exposed_direct_spelling = ( + _DIRECT_CALLEE.fullmatch(raw_callee) is None + and _DIRECT_CALLEE.fullmatch(normalized_callee) is not None + or _SHELL_KEYWORD_PREFIX.fullmatch(raw_keyword) is None + and _SHELL_KEYWORD_PREFIX.fullmatch(normalized_keyword) is not None + ) + return ( + compute_match_fingerprint("TM1", canonical), + normalization_exposed_direct_spelling, + canonical_start, + ) + + def _bounded_context(self, call: ast.Call) -> str: + call_start = self._source_start(call) + if call_start is None: + line = getattr(call, "lineno", 1) + return get_context_from_lines(self.lines, line)[:_MAX_CONTEXT_CHARS] + left = max(0, call_start - _MAX_CONTEXT_CHARS // 2) + right = min(len(self.content), left + _MAX_CONTEXT_CHARS) + left = max(0, right - _MAX_CONTEXT_CHARS) + return self.content[left:right].rstrip(LINE_BREAK_CHARS) + + def _append_finding( + self, + call: ast.Call, + shell_keyword: ast.keyword, + shell: ast.expr, + ) -> None: + line = getattr(call, "lineno", 1) + end_line = getattr(call, "end_lineno", None) + start_column = self.parsed.character_column(line, getattr(call, "col_offset", 0)) + end_column = ( + self.parsed.character_column(end_line, getattr(call, "end_col_offset", 0)) + if isinstance(end_line, int) + else None + ) + source_start = self._source_start(call) + source_end = self._source_end(call) + shell_anchor = self._source_start(shell_keyword) + shell_start = self._source_start(shell) + shell_end = self._source_end(shell) + popen_start: int | None = None + if ( + isinstance(call.func, ast.Attribute) + and _normalized_direct_name(call.func.attr) == "popen" + ): + function_end = self._source_end(call.func) + raw_function = self._source_segment(call.func) + raw_method = re.search(r"(?P\w+)\s*$", raw_function) + if function_end is not None and raw_method is not None: + popen_start = function_end - len(raw_method.group("method")) + canonical = self._canonical_fingerprint(call, shell_keyword, shell) + if ( + source_start is not None + and shell_anchor is not None + and shell_start is not None + and shell_end is not None + and canonical is not None + ): + self.bound_shell_metadata.append( + BoundShellMetadata( + call_start=source_start, + call_end=source_end, + shell_anchor=shell_anchor, + value_start=shell_start, + value_end=shell_end, + popen_start=popen_start, + canonical_fingerprint=canonical[0], + normalized_view=canonical[1], + ) + ) + if not self._emit_findings: + return + + source = self._source_segment(call) + evidence: dict[str, object] = {BOUND_SHELL_EVIDENCE: True} + if source_start is not None: + evidence[BOUND_CALL_START_EVIDENCE] = source_start + if source_end is not None: + evidence[BOUND_CALL_END_EVIDENCE] = source_end + if shell_anchor is not None: + evidence[BOUND_SHELL_ANCHOR_EVIDENCE] = shell_anchor + if shell_start is not None: + evidence[BOUND_SHELL_VALUE_START_EVIDENCE] = shell_start + if shell_end is not None: + evidence[BOUND_SHELL_VALUE_END_EVIDENCE] = shell_end + if source_start is not None and shell_start is not None: + evidence[BOUND_CLASSIFICATION_MATCH_EVIDENCE] = ( + self.content[source_start:shell_start] + "True" + )[:200] + evidence[BOUND_DIRECT_MATCH_END_EVIDENCE] = shell_start + len("True") + if popen_start is not None: + evidence[BOUND_POPEN_START_EVIDENCE] = popen_start + finding_key = (line, compute_match_fingerprint("TM1", source)) + if finding_key in self._finding_keys: + return + self._finding_keys.add(finding_key) + if canonical is not None: + fingerprint, is_normalized, direct_owner_start = canonical + evidence[BOUND_CANONICAL_FINGERPRINT_EVIDENCE] = fingerprint + evidence[BOUND_DIRECT_OWNER_START_EVIDENCE] = direct_owner_start + if is_normalized: + evidence[BOUND_NORMALIZED_VIEW_EVIDENCE] = True + self.findings.append( + AnalyzerFinding( + rule_id="TM1", + message="Tool Parameter Abuse", + severity=Severity.HIGH, + location=Location( + file=self.file_path, + start_line=line, + end_line=end_line, + start_column=start_column, + end_column=end_column, + ), + confidence=0.8, + tags=[PatternCategory.TOOL_MISUSE.value], + context=self._bounded_context(call), + matched_text=source[:200], + complete_match=source, + evidence=evidence, + ) + ) + + def _inspect_call(self, call: ast.Call, facts: dict[str, bool]) -> None: + shell_keyword = next((item for item in call.keywords if item.arg == "shell"), None) + if shell_keyword is None: + return + shell = shell_keyword.value + if not isinstance(shell, ast.Name) or facts.get(shell.id) is not True: + return + self._append_finding(call, shell_keyword, shell) + + def _scan_assignment( + self, + targets: list[ast.expr], + value: ast.expr, + facts: dict[str, bool], + trusted_names: set[str], + bound_names: set[str], + finalizer_safe_names: set[str], + ) -> None: + simple_targets = all(isinstance(target, ast.Name) for target in targets) + releases_unsafe_value = simple_targets and any( + target.id in bound_names + and target.id not in finalizer_safe_names + and not (isinstance(value, ast.Name) and value.id == target.id) + for target in targets + if isinstance(target, ast.Name) + ) + result_is_finalizer_safe = _is_finalizer_safe_value(value, finalizer_safe_names) + call_has_protocol_effects = False + if isinstance(value, ast.Call) and _is_direct_subprocess_call(value, trusted_names): + resolved = None + safe_value = _call_arguments_are_passive(value) + if _shell_argument_is_captured_before_effects(value): + self._inspect_call(value, facts) + if safe_value: + call_has_protocol_effects = not _call_arguments_are_protocol_safe( + value, + finalizer_safe_names, + ) + else: + resolved = _truth_value(value, facts) + safe_value = resolved is not None or _is_passive_argument(value) + + if not safe_value or not simple_targets: + facts.clear() + finalizer_safe_names.clear() + for target in targets: + if isinstance(target, ast.Name): + bound_names.add(target.id) + trusted_names.difference_update(_changed_direct_names([value, *targets], trusted_names)) + return + if releases_unsafe_value: + facts.clear() + finalizer_safe_names.clear() + trusted_names.clear() + if call_has_protocol_effects: + facts.clear() + finalizer_safe_names.clear() + trusted_names.clear() + for target in targets: + assert isinstance(target, ast.Name) + bound_names.add(target.id) + if releases_unsafe_value or call_has_protocol_effects or resolved is None: + facts.pop(target.id, None) + else: + facts[target.id] = resolved + if releases_unsafe_value or call_has_protocol_effects or not result_is_finalizer_safe: + finalizer_safe_names.discard(target.id) + else: + finalizer_safe_names.add(target.id) + preserves_binding = ( + isinstance(value, ast.Name) and value.id == target.id and value.id in trusted_names + ) + if not preserves_binding: + trusted_names.discard(target.id) + + def _scan_block( + self, + statements: list[ast.stmt], + *, + trusted_names: set[str] | None = None, + initial_bound_names: set[str] | None = None, + ) -> None: + trusted_names = set(_DIRECT_CALL_NAMES if trusted_names is None else trusted_names) + facts: dict[str, bool] = {} + bound_names = set(initial_bound_names or ()) + finalizer_safe_names: set[str] = set() + + last_invalidation_by_name: dict[str, int] = {} + receiver_trust = set(trusted_names) + for candidate_index, candidate in enumerate(statements): + before = set(receiver_trust) + _advance_trusted_names(candidate, receiver_trust) + for name in before.difference(receiver_trust): + last_invalidation_by_name[name] = candidate_index + + trusted_at_call_by_definition: dict[int, set[str]] = {} + receiver_trust = set(trusted_names) + active_functions: dict[str, int] = {} + for candidate_index, candidate in enumerate(statements): + call = _passive_direct_call(candidate) + if call is not None: + assert isinstance(call.func, ast.Name) + owner = active_functions.get(call.func.id) + if owner is not None: + trusted_at_call_by_definition.setdefault(owner, set()).update(receiver_trust) + + changed_names = _direct_bound_names(candidate) + for name in changed_names: + active_functions.pop(name, None) + if ( + isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)) + and _function_header_is_passive(candidate) + and _is_immediate_function(candidate) + ): + active_functions[candidate.name] = candidate_index + _advance_trusted_names(candidate, receiver_trust) + + for index, statement in enumerate(statements): + if self._check_runtime is not None: + self._check_runtime() + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + passive_header = _function_header_is_passive(statement) + trusted_at_call = trusted_at_call_by_definition.get(index, set()) + nested_trusted_names = set(trusted_names).union(trusted_at_call) + nested_trusted_names = { + name + for name in nested_trusted_names + if last_invalidation_by_name.get(name, -1) <= index or name in trusted_at_call + } + nested_trusted_names.difference_update( + _function_bound_direct_names(statement, nested_trusted_names) + ) + nested_trusted_names.discard(statement.name) + if not passive_header: + nested_trusted_names.clear() + self._scan_block( + statement.body, + trusted_names=nested_trusted_names, + initial_bound_names=_function_parameter_names(statement), + ) + releases_unsafe_value = ( + statement.name in bound_names and statement.name not in finalizer_safe_names + ) + if passive_header and not releases_unsafe_value: + facts.pop(statement.name, None) + else: + facts.clear() + finalizer_safe_names.clear() + trusted_names.clear() + bound_names.add(statement.name) + finalizer_safe_names.discard(statement.name) + trusted_names.discard(statement.name) + elif isinstance(statement, (ast.Import, ast.ImportFrom)): + facts.clear() + finalizer_safe_names.clear() + bound_names.update(_direct_bound_names(statement)) + _update_trusted_names_from_import(statement, trusted_names) + elif isinstance(statement, ast.Assign): + self._scan_assignment( + list(statement.targets), + statement.value, + facts, + trusted_names, + bound_names, + finalizer_safe_names, + ) + elif isinstance(statement, ast.AnnAssign): + value = statement.value + if ( + isinstance(value, ast.Call) + and _is_direct_subprocess_call(value, trusted_names) + and _shell_argument_is_captured_before_effects(value) + ): + self._inspect_call(value, facts) + facts.clear() + finalizer_safe_names.clear() + if value is not None: + bound_names.update(_direct_bound_names(statement)) + trusted_names.difference_update(_changed_direct_names([statement], trusted_names)) + elif isinstance(statement, (ast.AugAssign, ast.Delete)): + facts.clear() + finalizer_safe_names.clear() + bound_names.update(_direct_bound_names(statement)) + trusted_names.difference_update(_changed_direct_names([statement], trusted_names)) + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + direct_call = _is_direct_subprocess_call(call, trusted_names) + if direct_call and _shell_argument_is_captured_before_effects(call): + self._inspect_call(call, facts) + if direct_call and _call_arguments_are_passive(call): + if not _call_arguments_are_protocol_safe(call, finalizer_safe_names): + facts.clear() + finalizer_safe_names.clear() + trusted_names.clear() + else: + facts.clear() + finalizer_safe_names.clear() + trusted_names.difference_update(_changed_direct_names([call], trusted_names)) + elif isinstance(statement, ast.Pass) or ( + isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant) + ): + continue + elif isinstance(statement, ast.ClassDef): + facts.clear() + finalizer_safe_names.clear() + bound_names.update(_direct_bound_names(statement)) + trusted_names.difference_update(_changed_direct_names([statement], trusted_names)) + trusted_names.difference_update( + _class_body_changed_direct_names(statement, trusted_names) + ) + else: + facts.clear() + finalizer_safe_names.clear() + bound_names.update(_direct_bound_names(statement)) + trusted_names.difference_update(_changed_direct_names([statement], trusted_names)) + + def run(self, tree: ast.Module) -> list[AnalyzerFinding]: + self._scan_block(tree.body) + return sorted(self.findings, key=lambda finding: finding.location.start_line) + + +def bound_shell_metadata( + parsed: ParsedPythonFile, + file_path: str, + *, + check_runtime: Callable[[], None] | None = None, +) -> tuple[BoundShellMetadata, ...]: + """Return every supported bound call independently of the finding cap.""" + if parsed.tree is None: + return () + analyzer = _Analyzer( + file_path, + parsed, + emit_findings=False, + check_runtime=check_runtime, + ) + analyzer.run(parsed.tree) + return tuple(sorted(analyzer.bound_shell_metadata, key=lambda item: item.call_start)) + + +def direct_literal_metadata( + parsed: ParsedPythonFile, + file_path: str, + call_starts: set[int], +) -> dict[int, DirectLiteralMetadata]: + """Enrich only retained lexical owners without creating budgeted findings.""" + if parsed.tree is None or not call_starts: + return {} + locator = _Analyzer(file_path, parsed) + metadata: dict[int, DirectLiteralMetadata] = {} + for node in ast.walk(parsed.tree): + if not isinstance(node, ast.Call): + continue + call_start = locator._source_start(node) + if call_start is None: + continue + lookup_coordinates = {call_start} + function = node.func + if ( + isinstance(function, ast.Attribute) + and isinstance(function.value, ast.Name) + and _normalized_direct_name(function.value.id) == "subprocess" + and _normalized_direct_name(function.attr) == "popen" + ): + function_end = locator._source_end(function) + raw_function = locator._source_segment(function) + raw_method = re.search(r"(?P\w+)\s*$", raw_function) + if function_end is not None and raw_method is not None: + lookup_coordinates.add(function_end - len(raw_method.group("method"))) + retained_coordinates = lookup_coordinates.intersection(call_starts) + if not retained_coordinates: + continue + shell_keyword = next((item for item in node.keywords if item.arg == "shell"), None) + if shell_keyword is None: + continue + shell = shell_keyword.value + if not ( + isinstance(shell, ast.Constant) and type(shell.value) is bool and shell.value is True + ): + continue + canonical = locator._canonical_fingerprint(node, shell_keyword, shell) + shell_anchor = locator._source_start(shell_keyword) + if canonical is None or shell_anchor is None: + continue + fingerprint, normalized_view, _ = canonical + direct_metadata = DirectLiteralMetadata( + call_start=call_start, + call_end=locator._source_end(node), + shell_anchor=shell_anchor, + match_fingerprint=fingerprint, + normalized_view=normalized_view, + ) + for coordinate in retained_coordinates: + metadata[coordinate] = direct_metadata + return metadata + + +def analyze( + content: str, + file_path: str, + file_type: str, + *, + python_ast: ParsedPythonFile | None = None, +) -> list[AnalyzerFinding]: + """Find straight-line truthy names passed to direct subprocess calls.""" + if file_type != "python": + return [] + parsed = python_ast or parse_python_source(content, file_path) + if parsed.tree is None: + return [] + return _Analyzer(file_path, parsed).run(parsed.tree) diff --git a/src/skillspector/nodes/analyzers/static_runner.py b/src/skillspector/nodes/analyzers/static_runner.py index e064cc1bd..0b163576b 100644 --- a/src/skillspector/nodes/analyzers/static_runner.py +++ b/src/skillspector/nodes/analyzers/static_runner.py @@ -17,6 +17,7 @@ from __future__ import annotations +import inspect import json import math import os @@ -57,7 +58,10 @@ from skillspector.python_ast import ( MAX_PYTHON_AST_SOURCE_CHARS, ParsedPythonFile, + PythonSourceClassification, get_python_ast, + may_be_python_source, + resolve_python_source_classification, ) from skillspector.security_reconstruction import ( MAX_DECLARED_MARKER_RIGHT_CONTEXT_CHARS, @@ -89,6 +93,7 @@ ".md": "markdown", ".markdown": "markdown", ".py": "python", + ".pyw": "python", ".sh": "shell", ".bash": "shell", ".zsh": "shell", @@ -109,8 +114,25 @@ _WINDOW_OVERLAP_CHARS = 8192 _RAW_WINDOW_OWNED_CHARS = SECURITY_VIEW_WINDOW_CHARS - 2 * _WINDOW_OVERLAP_CHARS _VIEW_START_EVIDENCE = "_security_view_start" +_VIEW_ANCHOR_EVIDENCE = "_security_view_anchor" +_VIEW_ALTERNATE_START_EVIDENCE = "_security_view_alternate_start" +_VIEW_REACH_END_EVIDENCE = "_security_view_reach_end" +_VIEW_REPLACEMENT_START_LIMIT_EVIDENCE = "_security_view_replacement_start_limit" _SOURCE_START_EVIDENCE = "_security_source_start" _SOURCE_END_EVIDENCE = "_security_source_end" +_SOURCE_ANCHOR_EVIDENCE = "_security_source_anchor" +_SOURCE_ALTERNATE_START_EVIDENCE = "_security_source_alternate_start" +_SOURCE_REACH_END_EVIDENCE = "_security_source_reach_end" +_SOURCE_REPLACEMENT_START_LIMIT_EVIDENCE = "_security_source_replacement_start_limit" +_SOURCE_REPLACEMENT_RECOVERY_START_EVIDENCE = "_security_source_replacement_recovery_start" +_PRESERVE_SOURCE_START_EVIDENCE = "_security_preserve_source_start" +_ABSOLUTE_START_EVIDENCE = "_security_absolute_start" +_ABSOLUTE_ANCHOR_EVIDENCE = "_security_absolute_anchor" +_ABSOLUTE_ALTERNATE_START_EVIDENCE = "_security_absolute_alternate_start" +_ABSOLUTE_REACH_END_EVIDENCE = "_security_absolute_reach_end" +_ABSOLUTE_REPLACEMENT_START_LIMIT_EVIDENCE = "_security_absolute_replacement_start_limit" +_ABSOLUTE_REPLACEMENT_RECOVERY_START_EVIDENCE = "_security_absolute_replacement_recovery_start" +_ALTERNATE_MATCHED_TEXT_EVIDENCE = "_security_alternate_matched_text" _VIEW_ORIGIN_TAGS = frozenset({"normalized-view", "declared-marker-view"}) _CONTEXTUAL_TRIAGE_TAG = "contextual-triage" _ActiveSecurityView = tuple[SecurityTextView, str] @@ -144,6 +166,7 @@ # ordinary module-input ceiling. _CONTINUITY_SEPARATOR_CHARS = _WINDOW_OVERLAP_CHARS _CONTINUITY_CONTEXT_CHARS = 2048 +_CONTINUITY_RIGHT_CONTEXT_CHARS = _WINDOW_OVERLAP_CHARS _CONTINUITY_MAX_CHAIN_RUNS = 24 MAX_FINDINGS_PER_ARTIFACT = 10_000 MAX_FINDINGS_PER_ANALYZER = 10_000 @@ -248,6 +271,10 @@ def deduplicate_analyzer_findings( _LICENSE_OTHER_SUFFIXES = frozenset({".lesser"}) _ASCII_CONTINUITY_SEPARATOR_RUN = re.compile(r"[\s\x00-\x08\x0b\x0c\x0e-\x1f\x7f]+") +_RETAINED_CONTINUITY_NON_ASCII_WHITESPACE = re.compile(r"(?=[^\x00-\x7f])[^\S\x00-\x1f\x7f-\x9f]") +_RETAINED_CONTINUITY_ASCII_WHITESPACE = re.compile(r"[ \t]") +_RETAINED_CONTINUITY_REPLACEMENT = re.compile("\ufffd") +_RETAINED_CONTINUITY_LINE_BREAK = re.compile(r"\r\n|[\r\n\u2028\u2029]") _ASCII_NON_NEWLINE_WHITESPACE = re.compile(r"[ \t\r\f\v]") _PARAGRAPH_BOUNDARY = re.compile( rf"(?>{LOGICAL_LINE_BREAK.pattern})[ \t]*(?>{LOGICAL_LINE_BREAK.pattern})" @@ -395,7 +422,42 @@ def _window_view_with_markdown_context( offsets = array("I", (max(0, offset - prefix_length) for offset in range(len(view.text)))) else: offsets = array("I", (max(0, offset - prefix_length) for offset in view.source_offsets)) - return SecurityTextView(view.name, view.text, offsets) + return SecurityTextView( + view.name, + view.text, + offsets, + right_boundary_is_fixed=view.right_boundary_is_fixed, + right_boundary_recovery_start=( + max(0, view.right_boundary_recovery_start - prefix_length) + if view.right_boundary_recovery_start is not None + else None + ), + ) + + +def _with_fixed_right_boundary( + view: SecurityTextView, + is_fixed: bool, + recovery_start: int | None = None, +) -> SecurityTextView: + """Attach a scanner right edge and any overlapping recovery coordinate.""" + recovery_candidates = [ + candidate + for candidate in (view.right_boundary_recovery_start, recovery_start) + if candidate is not None + ] + merged_recovery = min(recovery_candidates, default=None) + if ( + not is_fixed or view.right_boundary_is_fixed + ) and merged_recovery == view.right_boundary_recovery_start: + return view + return SecurityTextView( + view.name, + view.text, + view.source_offsets, + right_boundary_is_fixed=(view.right_boundary_is_fixed or is_fixed), + right_boundary_recovery_start=merged_recovery, + ) def _markdown_context_prefix( @@ -477,7 +539,7 @@ def _normalize_license_line(line: str) -> str: def _infer_file_type(path: str) -> str: - """Infer file type from path (extension).""" + """Infer the declared file type from the path extension.""" idx = path.rfind(".") suffix = path[idx:].lower() if idx >= 0 else "" return FILE_TYPES.get(suffix, "other") @@ -630,6 +692,60 @@ def _uses_python_ast(module: object) -> bool: return getattr(module, "USES_PYTHON_AST", False) is True +def _uses_python_source_type(module: object) -> bool: + """Return whether a module needs the artifact's Python execution type.""" + return ( + _uses_python_ast(module) + or _explicit_module_hook(module, "POSTPROCESS_USES_PYTHON_AST") is True + or getattr(module, "USES_PYTHON_SOURCE_TYPE", False) is True + ) + + +def _effective_module_file_type(path: str, module: object, *, python_source: bool) -> str: + """Resolve the file type consistently for every hook owned by one module.""" + if python_source and _uses_python_source_type(module): + return "python" + return _infer_file_type(path) + + +def _requires_python_ast(pattern_modules: list) -> bool: + """Return whether an analyzer or its postprocessor consumes the shared AST.""" + return any(_uses_python_ast(module) for module in pattern_modules) or bool( + pattern_modules + and _explicit_module_hook(pattern_modules[0], "POSTPROCESS_USES_PYTHON_AST") is True + ) + + +def _requires_python_source_type(pattern_modules: list) -> bool: + """Return whether any analyzer behavior depends on Python execution identity.""" + return any(_uses_python_source_type(module) for module in pattern_modules) + + +def _python_ast_for_path( + path: str, + content: str, + pattern_modules: list, + python_ast_cache_key: str | None, + *, + python_source: bool | None = None, +) -> ParsedPythonFile | None: + """Return the shared parse needed by analyzer or postprocessor hooks.""" + if len(content) > MAX_FILE_CHARS or not _requires_python_ast(pattern_modules): + return None + if python_source is None: + python_source = may_be_python_source(path, content) + if not python_source: + return None + return get_python_ast(python_ast_cache_key, content, path) + + +def _explicit_module_hook(module: object, name: str) -> object | None: + """Return a hook only when the module or its class actually declares it.""" + if inspect.getattr_static(module, name, None) is None: + return None + return getattr(module, name, None) + + def _uses_runtime_check(module: object) -> bool: """Return whether a pattern module accepts the runner-owned deadline hook.""" return getattr(module, "USES_RUNTIME_CHECK", False) is True @@ -711,10 +827,11 @@ def observe_emission(self) -> None: @dataclass(frozen=True) class _ContinuityView: - """One bounded cross-window projection with exact raw line locations.""" + """One bounded cross-window projection with exact raw coordinates.""" view: SecurityTextView source_lines: tuple[int, ...] + source_offsets: array[int] @dataclass(frozen=True) @@ -816,24 +933,32 @@ def _scan_path( pattern_modules: list, finding_budget: _FindingBudget, python_ast_cache_key: str | None = None, + python_ast: ParsedPythonFile | None = None, + python_source: bool | None = None, ) -> tuple[list[Finding], _StaticResourceLimitError | None]: """Run pattern modules with construction, emission, and runtime guards.""" findings: list[Finding] = [] file_type = _infer_file_type(path) + if python_source is None: + python_source = may_be_python_source(path, content) content_lines = content.splitlines() normalized_license_lines = ( tuple(_normalize_license_line(line) for line in content_lines) if _is_license_basename(path, file_type) else None ) - python_ast: ParsedPythonFile | None = None - if file_type == "python" and any(_uses_python_ast(module) for module in pattern_modules): + if python_source and any(_uses_python_ast(module) for module in pattern_modules): finding_budget.check_runtime() - python_ast = get_python_ast(python_ast_cache_key, content, path) + python_ast = python_ast or get_python_ast(python_ast_cache_key, content, path) finding_budget.check_runtime() line_starts = logical_line_starts(content) for module in pattern_modules: + module_file_type = _effective_module_file_type( + path, + module, + python_source=python_source, + ) module_finding_start = len(findings) occurrence_columns = _OccurrenceColumnResolver(content, line_starts) finding_budget.begin_module() @@ -842,9 +967,9 @@ def _scan_path( analyze_kwargs: dict[str, object] = { "content": content, "file_path": path, - "file_type": file_type, + "file_type": module_file_type, } - if file_type == "python" and _uses_python_ast(module): + if module_file_type == "python" and _uses_python_ast(module): analyze_kwargs["python_ast"] = python_ast if _uses_runtime_check(module): analyze_kwargs["check_runtime"] = finding_budget.check_runtime @@ -856,7 +981,7 @@ def _scan_path( converted = _convert_analyzer_finding( af, path=path, - file_type=file_type, + file_type=module_file_type, content=content, content_lines=content_lines, normalized_license_lines=normalized_license_lines, @@ -876,7 +1001,7 @@ def _scan_path( converted = _convert_analyzer_finding( af, path=path, - file_type=file_type, + file_type=module_file_type, content=content, content_lines=content_lines, normalized_license_lines=normalized_license_lines, @@ -952,6 +1077,7 @@ def _extend_unique_findings( candidates: list[Finding], *, max_findings: int, + coalesce: Callable[[list[Finding]], list[Finding]] | None = None, ) -> _StaticResourceLimitError | None: """Append distinct final findings and enforce the user-visible output cap.""" for finding in candidates: @@ -960,7 +1086,7 @@ def _extend_unique_findings( continue seen.add(key) result.append(finding) - if len(result) > max_findings: + if coalesce is None and len(result) > max_findings: return _StaticResourceLimitError( LedgerReason.OUTPUT_LIMIT, { @@ -968,6 +1094,16 @@ def _extend_unique_findings( "limit_findings": max_findings, }, ) + if len(result) > max_findings and coalesce is not None: + result[:] = coalesce(result) + if len(result) > max_findings: + return _StaticResourceLimitError( + LedgerReason.OUTPUT_LIMIT, + { + "observed_findings": len(result), + "limit_findings": max_findings, + }, + ) return None @@ -1017,6 +1153,7 @@ def _scan_view_windows( finding_budget: _FindingBudget, python_ast_cache_key: str | None, *, + python_source: bool, source_text: str, ) -> tuple[list[Finding], _StaticResourceLimitError | None]: """Scan one already-bounded view.""" @@ -1028,9 +1165,18 @@ def _scan_view_windows( pattern_modules, finding_budget, python_ast_cache_key, + python_source=python_source, ) finally: _ACTIVE_SECURITY_VIEW.reset(view_token) + + def source_boundary(derived_offset: int) -> int: + if view.source_offsets is None: + return derived_offset + if derived_offset < len(view.source_offsets): + return view.source_offsets[derived_offset] + return view.source_offsets[-1] + 1 if view.source_offsets else 0 + for finding in findings: finding.evidence.pop(_SOURCE_START_EVIDENCE, None) local_start = finding.evidence.pop(_VIEW_START_EVIDENCE, None) @@ -1038,6 +1184,56 @@ def _scan_view_windows( local_start = _line_start_offset(view.text, finding.start_line) + finding.start_column if isinstance(local_start, int) and 0 <= local_start < len(view.text): finding.evidence[_SOURCE_START_EVIDENCE] = view.source_offset(local_start) + local_anchor = finding.evidence.pop(_VIEW_ANCHOR_EVIDENCE, None) + if isinstance(local_anchor, int) and 0 <= local_anchor < len(view.text): + finding.evidence[_SOURCE_ANCHOR_EVIDENCE] = view.source_offset(local_anchor) + local_alternate = finding.evidence.pop(_VIEW_ALTERNATE_START_EVIDENCE, None) + if isinstance(local_alternate, int) and 0 <= local_alternate < len(view.text): + finding.evidence[_SOURCE_ALTERNATE_START_EVIDENCE] = view.source_offset(local_alternate) + local_reach_end = finding.evidence.pop(_VIEW_REACH_END_EVIDENCE, None) + if isinstance(local_reach_end, int) and 0 <= local_reach_end <= len(view.text): + finding.evidence[_SOURCE_REACH_END_EVIDENCE] = source_boundary(local_reach_end) + local_replacement_start_limit = finding.evidence.pop( + _VIEW_REPLACEMENT_START_LIMIT_EVIDENCE, + None, + ) + replacement_width = ( + len(view.text) - local_replacement_start_limit + if isinstance(local_replacement_start_limit, int) + else 0 + ) + prospective_slice_boundary = ( + not view.right_boundary_is_fixed + and 0 < replacement_width <= len(view.text) + and len(view.text) < SECURITY_VIEW_WINDOW_CHARS + and len(view.text) + max(0, replacement_width - 1) > SECURITY_VIEW_WINDOW_CHARS + ) + if ( + (view.right_boundary_is_fixed or prospective_slice_boundary) + and isinstance(local_replacement_start_limit, int) + and 0 <= local_replacement_start_limit < len(view.text) + ): + if prospective_slice_boundary: + local_replacement_start_limit += SECURITY_VIEW_WINDOW_CHARS - len(view.text) + finding.evidence[_SOURCE_REPLACEMENT_START_LIMIT_EVIDENCE] = view.source_offset( + local_replacement_start_limit + ) + recovery_candidates = [ + candidate + for candidate in ( + view.right_boundary_recovery_start, + ( + view.source_offset(SECURITY_VIEW_WINDOW_CHARS - _WINDOW_OVERLAP_CHARS) + if prospective_slice_boundary + else None + ), + ) + if candidate is not None + ] + if recovery_candidates: + finding.evidence[_SOURCE_REPLACEMENT_RECOVERY_START_EVIDENCE] = min( + recovery_candidates + ) if finding.end_line is not None and finding.end_column is not None: local_end = _line_start_offset(view.text, finding.end_line) + finding.end_column if 0 < local_end <= len(view.text): @@ -1056,16 +1252,44 @@ def _scan_view_windows( def _bounded_view_slices(view: SecurityTextView) -> Iterator[SecurityTextView]: """Split an expanded derived view before any pattern module sees it.""" if len(view.text) <= SECURITY_VIEW_WINDOW_CHARS: - yield view + exact_ceiling_recovery = ( + view.source_offset(SECURITY_VIEW_WINDOW_CHARS - _WINDOW_OVERLAP_CHARS) + if len(view.text) == SECURITY_VIEW_WINDOW_CHARS + else None + ) + yield _with_fixed_right_boundary( + view, + len(view.text) == SECURITY_VIEW_WINDOW_CHARS, + exact_ceiling_recovery, + ) return step = SECURITY_VIEW_WINDOW_CHARS - _WINDOW_OVERLAP_CHARS for start in range(0, len(view.text), step): end = min(len(view.text), start + SECURITY_VIEW_WINDOW_CHARS) - offsets = None if view.source_offsets is None else view.source_offsets[start:end] + has_following_slice = end < len(view.text) + fills_ceiling = end - start == SECURITY_VIEW_WINDOW_CHARS + slice_recovery = ( + view.source_offset(start + step) if has_following_slice or fills_ceiling else None + ) + inherited_recovery = ( + view.right_boundary_recovery_start if view.right_boundary_is_fixed else None + ) + recovery_candidates = [ + candidate for candidate in (slice_recovery, inherited_recovery) if candidate is not None + ] + offsets = ( + array("I", range(start, end)) + if view.source_offsets is None + else view.source_offsets[start:end] + ) yield SecurityTextView( name=view.name, text=view.text[start:end], source_offsets=offsets, + right_boundary_is_fixed=( + view.right_boundary_is_fixed or has_following_slice or fills_ceiling + ), + right_boundary_recovery_start=min(recovery_candidates, default=None), ) if end == len(view.text): break @@ -1082,16 +1306,78 @@ def _is_continuity_separator(character: str) -> bool: ) +def _continuity_runs_for_anchors( + content: str, + anchors: set[int], + check_runtime: Callable[[], None], +) -> dict[int, tuple[int, int]]: + """Find each anchor's nearest relevant long run in merged local ranges.""" + ordered_anchors = sorted(anchor for anchor in anchors if 0 <= anchor < len(content)) + if not ordered_anchors: + return {} + + search_ranges: list[tuple[int, int]] = [] + for anchor in ordered_anchors: + left = max(0, anchor - _CONTINUITY_RIGHT_CONTEXT_CHARS) + if search_ranges and left <= search_ranges[-1][1]: + search_ranges[-1] = (search_ranges[-1][0], anchor) + else: + search_ranges.append((left, anchor)) + + examined = 0 + + def is_separator(index: int) -> bool: + nonlocal examined + examined += 1 + if examined % _WINDOW_OVERLAP_CHARS == 0: + check_runtime() + return _is_continuity_separator(content[index]) + + runs: set[tuple[int, int]] = set() + for left, right in search_ranges: + # A merged search range may begin inside the only relevant long run. + # Extend through that run once so its full length remains observable. + while left > 0 and is_separator(left - 1): + left -= 1 + cursor = left + while cursor < right: + if not is_separator(cursor): + cursor += 1 + continue + run_start = cursor + cursor += 1 + while cursor < right and is_separator(cursor): + cursor += 1 + if cursor - run_start > _WINDOW_OVERLAP_CHARS: + runs.add((run_start, cursor)) + + check_runtime() + ordered_runs = sorted(runs, key=lambda run: run[1]) + run_ends = [run[1] for run in ordered_runs] + relevant: dict[int, tuple[int, int]] = {} + for anchor in ordered_anchors: + run_index = bisect_right(run_ends, anchor) - 1 + if run_index < 0: + continue + run = ordered_runs[run_index] + if anchor - run[1] < _CONTINUITY_RIGHT_CONTEXT_CHARS: + relevant[anchor] = run + return relevant + + def _continuity_separator_runs( content: str, finding_budget: _FindingBudget, + *, + search_end: int | None = None, ) -> Iterator[tuple[int, int]]: """Yield long separator runs without allocating a whole-file projection.""" + limit = len(content) if search_end is None else min(len(content), max(0, search_end)) if content.isascii(): # Keep ordinary source files on the regex engine's bounded C-level # fast path. Unicode category inspection below is reserved for input # that can actually contain normalized-away format characters. - for match in _ASCII_CONTINUITY_SEPARATOR_RUN.finditer(content): + for match in _ASCII_CONTINUITY_SEPARATOR_RUN.finditer(content, 0, limit): finding_budget.check_runtime() if match.end() - match.start() > _WINDOW_OVERLAP_CHARS: yield match.start(), match.end() @@ -1102,7 +1388,7 @@ def _continuity_separator_runs( # character accepted by ``_is_continuity_separator``. Keep that common # multilingual-text case on C-level predicates instead of walking every # code point in Python. - if ( + if limit == len(content) and ( content.isprintable() and _ASCII_CONTINUITY_SEPARATOR_RUN.search(content) is None and "\ufffd" not in content @@ -1112,7 +1398,8 @@ def _continuity_separator_runs( return run_start: int | None = None - for index, character in enumerate(content): + for index in range(limit): + character = content[index] if index % _WINDOW_OVERLAP_CHARS == 0: finding_budget.check_runtime() if _is_continuity_separator(character): @@ -1122,27 +1409,137 @@ def _continuity_separator_runs( if run_start is not None and index - run_start > _WINDOW_OVERLAP_CHARS: yield run_start, index run_start = None - if run_start is not None and len(content) - run_start > _WINDOW_OVERLAP_CHARS: - yield run_start, len(content) + if run_start is not None and limit - run_start > _WINDOW_OVERLAP_CHARS: + yield run_start, limit def _append_projected_piece( text_parts: list[str], source_lines: list[int], + source_offsets: array[int], piece: str, + source_start: int, source_line: int, ) -> int: - """Append one contiguous raw piece and extend its exact line projection.""" + """Append one contiguous raw piece and extend its exact projections.""" text_parts.append(piece) + source_offsets.extend(range(source_start, source_start + len(piece))) for _ in LOGICAL_LINE_BREAK.finditer(piece): source_line += 1 source_lines.append(source_line) return source_line +def _retained_continuity_separator( + content: str, + start: int, + end: int, +) -> tuple[str, int] | None: + """Return one representative retained by the same security-view classes.""" + for pattern in ( + _RETAINED_CONTINUITY_ASCII_WHITESPACE, + _RETAINED_CONTINUITY_NON_ASCII_WHITESPACE, + _RETAINED_CONTINUITY_REPLACEMENT, + ): + if match := pattern.search(content, start, end): + return match.group(0), match.start() + return None + + +def _anchored_continuity_view( + content: str, + anchor: int, + check_runtime: Callable[[], None], +) -> SecurityTextView | None: + """Project the long-separator chain immediately preceding *anchor*. + + This mirrors the ordinary continuity chain bound while searching backward + from one already-retained finding. It therefore avoids rediscovering every + unrelated separator in the artifact during output-limit finalization. + """ + anchor = min(max(0, anchor), len(content)) + search_end = anchor + reverse_runs: list[tuple[int, int]] = [] + examined = 0 + while search_end > 0 and len(reverse_runs) < _CONTINUITY_MAX_CHAIN_RUNS: + search_span = ( + _CONTINUITY_RIGHT_CONTEXT_CHARS + if not reverse_runs + # The forward producer compares the exclusive prior-run end with + # the next-run start. Include the prior run's final character when + # that gap is exactly the configured chain limit. + else _CONTINUITY_CONTEXT_CHARS + 1 + ) + search_start = max(0, search_end - search_span) + cursor = search_end + prior_run: tuple[int, int] | None = None + while cursor > search_start: + cursor -= 1 + examined += 1 + if examined % _WINDOW_OVERLAP_CHARS == 0: + check_runtime() + if not _is_continuity_separator(content[cursor]): + continue + run_end = cursor + 1 + while cursor > 0 and _is_continuity_separator(content[cursor - 1]): + cursor -= 1 + examined += 1 + if examined % _WINDOW_OVERLAP_CHARS == 0: + check_runtime() + if run_end - cursor > _WINDOW_OVERLAP_CHARS: + prior_run = (cursor, run_end) + break + if prior_run is None: + break + reverse_runs.append(prior_run) + search_end = prior_run[0] + check_runtime() + if not reverse_runs: + return None + + separator_runs = list(reversed(reverse_runs)) + left = max(0, separator_runs[0][0] - _CONTINUITY_CONTEXT_CHARS) + right = min(len(content), separator_runs[-1][1] + _CONTINUITY_RIGHT_CONTEXT_CHARS) + source_offsets = array("I") + text_parts: list[str] = [] + + def append(piece: str, source_start: int) -> None: + text_parts.append(piece) + source_offsets.extend(range(source_start, source_start + len(piece))) + + cursor = left + for run_start, run_end in separator_runs: + append(content[cursor:run_start], cursor) + run_length = run_end - run_start + if run_length <= _CONTINUITY_SEPARATOR_CHARS: + append(content[run_start:run_end], run_start) + else: + head_length = _CONTINUITY_SEPARATOR_CHARS // 2 + tail_length = _CONTINUITY_SEPARATOR_CHARS - head_length + head_end = run_start + head_length + tail_start = run_end - tail_length + append(content[run_start:head_end], run_start) + if newline := _RETAINED_CONTINUITY_LINE_BREAK.search(content, head_end, tail_start): + append(newline.group(0), newline.start()) + elif retained := _retained_continuity_separator(content, head_end, tail_start): + character, source_offset = retained + text_parts.append(character) + source_offsets.append(source_offset) + append(content[tail_start:run_end], tail_start) + cursor = run_end + append(content[cursor:right], cursor) + + projected = "".join(text_parts) + assert len(projected) <= SECURITY_VIEW_WINDOW_CHARS + assert len(source_offsets) == len(projected) + return SecurityTextView("anchored-continuity", projected, source_offsets) + + def _continuity_views( content: str, finding_budget: _FindingBudget, + *, + separator_search_end: int | None = None, ) -> Iterator[_ContinuityView]: """Build bounded neighborhoods that preserve lexical state across raw windows. @@ -1154,7 +1551,13 @@ def _continuity_views( map is constructed per view, so neither a whole-file normalized copy nor a whole-file offset table exists. """ - separator_runs = list(_continuity_separator_runs(content, finding_budget)) + separator_runs = list( + _continuity_separator_runs( + content, + finding_budget, + search_end=separator_search_end, + ) + ) previous_left = 0 previous_left_line = 1 for run_index, (run_start, _) in enumerate(separator_runs): @@ -1169,12 +1572,13 @@ def _continuity_views( last_run_index += 1 selected_runs = separator_runs[run_index : last_run_index + 1] left = max(0, run_start - _CONTINUITY_CONTEXT_CHARS) - right = min(len(content), selected_runs[-1][1] + _CONTINUITY_CONTEXT_CHARS) + right = min(len(content), selected_runs[-1][1] + _CONTINUITY_RIGHT_CONTEXT_CHARS) previous_left_line += sum( 1 for _ in LOGICAL_LINE_BREAK.finditer(content, previous_left, left) ) previous_left = left source_lines = [previous_left_line] + source_offsets = array("I") text_parts: list[str] = [] current_line = previous_left_line cursor = left @@ -1182,7 +1586,9 @@ def _continuity_views( current_line = _append_projected_piece( text_parts, source_lines, + source_offsets, content[cursor:selected_start], + cursor, current_line, ) run_length = selected_end - selected_start @@ -1190,7 +1596,9 @@ def _continuity_views( current_line = _append_projected_piece( text_parts, source_lines, + source_offsets, content[selected_start:selected_end], + selected_start, current_line, ) else: @@ -1201,33 +1609,57 @@ def _continuity_views( current_line = _append_projected_piece( text_parts, source_lines, + source_offsets, content[selected_start:head_end], + selected_start, current_line, ) skipped_newlines = sum( 1 for _ in LOGICAL_LINE_BREAK.finditer(content, head_end, tail_start) ) - if skipped_newlines: + retained_line_break = _RETAINED_CONTINUITY_LINE_BREAK.search( + content, head_end, tail_start + ) + if retained_line_break is not None: # Retain a line boundary so DOT-without-DOTALL and anchors # do not acquire semantics absent from the original source. - text_parts.append("\n") + line_break = retained_line_break.group(0) + text_parts.append(line_break) + source_offsets.extend( + range(retained_line_break.start(), retained_line_break.end()) + ) + if skipped_newlines: current_line += skipped_newlines - source_lines.append(current_line) - elif _ASCII_NON_NEWLINE_WHITESPACE.search(content, head_end, tail_start): - # Never let truncation erase a real word boundary and turn - # separated tokens into a normalized security match. - text_parts.append(" ") + if retained_line_break is not None: + source_lines.append(current_line) + retained_separator = ( + None + if retained_line_break is not None + else _retained_continuity_separator(content, head_end, tail_start) + ) + if retained_separator is not None: + # Preserve a representative which the normalized view + # retains. Keeping the original character also lets the + # compact view make the same contextual decision as it + # would over the complete separator run. + character, source_offset = retained_separator + text_parts.append(character) + source_offsets.append(source_offset) current_line = _append_projected_piece( text_parts, source_lines, + source_offsets, content[tail_start:selected_end], + tail_start, current_line, ) cursor = selected_end _append_projected_piece( text_parts, source_lines, + source_offsets, content[cursor:right], + cursor, current_line, ) @@ -1235,9 +1667,15 @@ def _continuity_views( # Context, the retained separators, and the bounded text between # chained runs remain below the ordinary module-input ceiling. assert len(projected) <= SECURITY_VIEW_WINDOW_CHARS + assert len(source_offsets) == len(projected) yield _ContinuityView( - view=SecurityTextView("continuity", projected), + view=SecurityTextView( + "continuity", + projected, + right_boundary_is_fixed=right < len(content), + ), source_lines=tuple(source_lines), + source_offsets=source_offsets, ) @@ -1295,6 +1733,7 @@ def _restore_source_lines( view: SecurityTextView, window_start: int = 0, source_line_starts: tuple[int, ...] | None = None, + start_source_offsets: array[int] | None = None, ) -> None: """Map normalized/window-relative locations to raw whole-file coordinates.""" @@ -1345,6 +1784,62 @@ def source_end_offset(offset: int) -> int: ) finding.end_line, raw_end_column = source_position(raw_end) finding.end_column = raw_end_column if has_exact_end else None + source_anchor = finding.evidence.pop(_SOURCE_ANCHOR_EVIDENCE, None) + source_alternate_start = finding.evidence.pop(_SOURCE_ALTERNATE_START_EVIDENCE, None) + source_reach_end = finding.evidence.pop(_SOURCE_REACH_END_EVIDENCE, None) + source_replacement_start_limit = finding.evidence.pop( + _SOURCE_REPLACEMENT_START_LIMIT_EVIDENCE, + None, + ) + source_replacement_recovery_start = finding.evidence.pop( + _SOURCE_REPLACEMENT_RECOVERY_START_EVIDENCE, + None, + ) + preserve_start = finding.evidence.pop(_PRESERVE_SOURCE_START_EVIDENCE, None) is True + + def absolute_offset(source_offset: object) -> int | None: + if not isinstance(source_offset, int) or source_offset < 0: + return None + if start_source_offsets is None: + return window_start + source_offset + if source_offset < len(start_source_offsets): + return start_source_offsets[source_offset] + return None + + def absolute_boundary(source_offset: object) -> int | None: + if not isinstance(source_offset, int) or source_offset < 0: + return None + if start_source_offsets is None: + return window_start + source_offset + if source_offset < len(start_source_offsets): + return start_source_offsets[source_offset] + if source_offset == len(start_source_offsets) and start_source_offsets: + return start_source_offsets[-1] + 1 + return None + + if preserve_start: + absolute_start = absolute_offset(source_start) + if absolute_start is not None: + finding.evidence[_ABSOLUTE_START_EVIDENCE] = absolute_start + absolute_anchor = absolute_offset(source_anchor) + if absolute_anchor is not None: + finding.evidence[_ABSOLUTE_ANCHOR_EVIDENCE] = absolute_anchor + absolute_alternate_start = absolute_offset(source_alternate_start) + if absolute_alternate_start is not None: + finding.evidence[_ABSOLUTE_ALTERNATE_START_EVIDENCE] = absolute_alternate_start + absolute_reach_end = absolute_boundary(source_reach_end) + if absolute_reach_end is not None: + finding.evidence[_ABSOLUTE_REACH_END_EVIDENCE] = absolute_reach_end + absolute_replacement_start_limit = absolute_offset(source_replacement_start_limit) + if absolute_replacement_start_limit is not None: + finding.evidence[_ABSOLUTE_REPLACEMENT_START_LIMIT_EVIDENCE] = ( + absolute_replacement_start_limit + ) + absolute_replacement_recovery_start = absolute_offset(source_replacement_recovery_start) + if absolute_replacement_recovery_start is not None: + finding.evidence[_ABSOLUTE_REPLACEMENT_RECOVERY_START_EVIDENCE] = ( + absolute_replacement_recovery_start + ) def _scan_declared_marker_views( @@ -1356,6 +1851,8 @@ def _scan_declared_marker_views( owned_starts: tuple[int, ...], raw_starts: tuple[int, ...], source_context: _WindowSourceContext, + python_source: bool, + defer_projected_output_limit: bool, ) -> tuple[list[Finding], bool, _StaticResourceLimitError | None]: """Reconstruct marker payloads with directive-relative context windows.""" findings: list[Finding] = [] @@ -1374,6 +1871,35 @@ def check_runtime() -> None: projection_limited = False seen_views: set[tuple[str, int, int]] = set() seen_finding_counts: dict[tuple[object, ...], int] = {} + deferred_output_limit: _StaticResourceLimitError | None = None + + def defer_output_limit( + resource_limit: _StaticResourceLimitError, + *, + across_security_views: bool, + ) -> bool: + nonlocal deferred_output_limit + if not ( + defer_projected_output_limit + and across_security_views + and resource_limit.reason is LedgerReason.OUTPUT_LIMIT + ): + return False + if deferred_output_limit is None: + deferred_output_limit = resource_limit + else: + observed = max( + int(deferred_output_limit.metrics.get("observed_findings", 0)), + int(resource_limit.metrics.get("observed_findings", 0)), + ) + deferred_output_limit = _StaticResourceLimitError( + LedgerReason.OUTPUT_LIMIT, + { + "observed_findings": observed, + "limit_findings": finding_budget.max_findings, + }, + ) + return True for owned_start, raw_start in zip(owned_starts, raw_starts, strict=True): check_runtime() @@ -1395,8 +1921,10 @@ def check_runtime() -> None: _window_view_with_markdown_context(full_view, len(context_prefix)) for full_view in security_text_views(context_prefix + raw_window) ) + across_security_views = len(full_views) > 1 check_runtime() for full_view in full_views: + full_view_limited = False reconstruction = build_declared_marker_views( full_view, check_runtime=check_runtime, @@ -1406,12 +1934,18 @@ def check_runtime() -> None: ) projection_limited = projection_limited or reconstruction.limited for marker_view in reconstruction.views: - if not marker_view.source_offsets: + marker_offsets = marker_view.source_offsets + if not marker_offsets: continue + marker_view = _with_fixed_right_boundary( + marker_view, + reconstruction.limited + or (raw_end < len(content) and marker_offsets[-1] >= len(raw_window) - 1), + ) marker_key = ( marker_view.text, - raw_start + marker_view.source_offsets[0], - raw_start + marker_view.source_offsets[-1], + raw_start + marker_offsets[0], + raw_start + marker_offsets[-1], ) if marker_key in seen_views: continue @@ -1432,6 +1966,7 @@ def check_runtime() -> None: pattern_modules, view_budget, None, + python_source=python_source, source_text=raw_window, ) _restore_source_lines( @@ -1455,19 +1990,35 @@ def check_runtime() -> None: seen_finding_counts[key] = projection_count findings.append(finding) if len(findings) > finding_budget.max_findings: - return ( - findings, - projection_limited, - _StaticResourceLimitError( - LedgerReason.OUTPUT_LIMIT, - { - "observed_findings": len(findings), - "limit_findings": finding_budget.max_findings, - }, - ), + output_limit = _StaticResourceLimitError( + LedgerReason.OUTPUT_LIMIT, + { + "observed_findings": len(findings), + "limit_findings": finding_budget.max_findings, + }, ) + if not defer_output_limit( + output_limit, + across_security_views=across_security_views, + ): + return findings, projection_limited, output_limit + full_view_limited = True + break + if full_view_limited: + break if resource_limit is not None: - return findings, projection_limited, resource_limit + if not defer_output_limit( + resource_limit, + across_security_views=across_security_views, + ): + return findings, projection_limited, resource_limit + full_view_limited = True + break + if full_view_limited: + break + + if deferred_output_limit is not None: + return findings, projection_limited, deferred_output_limit if owned_end == len(content): break @@ -1483,13 +2034,31 @@ def _scan_all_views_detailed( *, max_findings: int = MAX_FINDINGS_PER_ARTIFACT, timeout_seconds: float | None = None, + started_at: float | None = None, + python_ast: ParsedPythonFile | None = None, + python_source: bool | None = None, ) -> tuple[list[Finding], LedgerReason | None, dict[str, int | float]]: """Scan bounded raw windows and return any limit with observed/limit metrics.""" + started_at = time.monotonic() if started_at is None else started_at ast_modules = [module for module in pattern_modules if _uses_python_ast(module)] lexical_modules = [module for module in pattern_modules if not _uses_python_ast(module)] + if python_source is None: + python_source = ( + may_be_python_source(path, content) + if _requires_python_source_type(pattern_modules) + else False + ) + python_ast_eligible = python_source and len(content) <= MAX_FILE_CHARS + if python_ast_eligible and _requires_python_ast(pattern_modules) and python_ast is None: + python_ast = get_python_ast(python_ast_cache_key, content, path) + python_syntax_error = bool( + python_ast_eligible + and _requires_python_ast(pattern_modules) + and python_ast is not None + and python_ast.tree is None + ) findings: list[Finding] = [] seen_findings: set[_ViewFindingKey] = set() - started_at = time.monotonic() runtime_limit = MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT if timeout_seconds is not None: runtime_limit = min(runtime_limit, max(0.0, timeout_seconds)) @@ -1501,6 +2070,19 @@ def _scan_all_views_detailed( clock=time.monotonic, ) marker_projection_limited = False + coalesce_hook = ( + _explicit_module_hook(pattern_modules[0], "coalesce_path_findings") + if pattern_modules + else None + ) + coalesce: Callable[[list[Finding]], list[Finding]] | None = ( + (lambda candidates: coalesce_hook(content, candidates)) if callable(coalesce_hook) else None + ) + retained_reconciliation_hook = ( + _explicit_module_hook(pattern_modules[0], "reconcile_retained_findings") + if pattern_modules + else None + ) modules_for_windows = lexical_modules or ([] if ast_modules else pattern_modules) bounded_parse_limited = False marker_owned_starts: tuple[int, ...] = () @@ -1509,6 +2091,72 @@ def _scan_all_views_detailed( raw_starts: tuple[int, ...] = () source_context: _WindowSourceContext | None = None whole_artifact_window = False + deferred_output_limit: _StaticResourceLimitError | None = None + defers_mixed_output_limit = bool( + coalesce is not None + and ast_modules + and modules_for_windows + and python_ast_eligible + and python_ast is not None + and python_ast.tree is not None + ) + + def defer_output_limit( + resource_limit: _StaticResourceLimitError, + *, + across_security_views: bool = False, + ) -> bool: + """Delay caps until mixed producers or alternate views can reconcile.""" + nonlocal deferred_output_limit + defers_projected_output_limit = coalesce is not None and across_security_views + if not ( + (defers_mixed_output_limit or defers_projected_output_limit) + and resource_limit.reason is LedgerReason.OUTPUT_LIMIT + ): + return False + if deferred_output_limit is None: + deferred_output_limit = resource_limit + else: + observed = max( + int(deferred_output_limit.metrics.get("observed_findings", 0)), + int(resource_limit.metrics.get("observed_findings", 0)), + ) + deferred_output_limit = _StaticResourceLimitError( + LedgerReason.OUTPUT_LIMIT, + { + "observed_findings": observed, + "limit_findings": max_findings, + }, + ) + return True + + def reconciled_findings() -> list[Finding]: + """Coalesce mixed owners before selecting any early-return prefix.""" + candidates = coalesce(findings) if coalesce is not None else findings + return _deduplicate_view_findings(candidates) + + def reconciled_prefix() -> list[Finding]: + return reconciled_findings()[:max_findings] + + def limited_result( + resource_limit: _StaticResourceLimitError, + ) -> tuple[list[Finding], LedgerReason, dict[str, int | float]]: + """Finalize retained identity without discovering work beyond a hard cap.""" + if ( + resource_limit.reason is LedgerReason.OUTPUT_LIMIT + and callable(retained_reconciliation_hook) + and source_context is not None + ): + try: + retained_reconciliation_hook( + content, + reconciled_prefix(), + finding_budget.check_runtime, + source_context, + ) + except _StaticResourceLimitError as exc: + return reconciled_prefix(), exc.reason, exc.metrics + return reconciled_prefix(), resource_limit.reason, resource_limit.metrics if modules_for_windows: marker_owned_starts = tuple(range(0, max(1, len(content)), DECLARED_MARKER_OWNED_CHARS)) @@ -1549,6 +2197,8 @@ def _scan_all_views_detailed( owned_starts=marker_owned_starts, raw_starts=marker_raw_starts, source_context=source_context, + python_source=python_source, + defer_projected_output_limit=coalesce is not None, ) ) except _StaticResourceLimitError as exc: @@ -1557,26 +2207,22 @@ def _scan_all_views_detailed( seen_findings, exc.partial_findings, max_findings=max_findings, + coalesce=coalesce, ) - return ( - findings[:max_findings], - exc.reason, - exc.metrics, - ) + return limited_result(exc) unique_limit = _extend_unique_findings( findings, seen_findings, marker_findings, max_findings=max_findings, + coalesce=coalesce, ) if unique_limit is not None: - return findings[:max_findings], unique_limit.reason, unique_limit.metrics + if not defer_output_limit(unique_limit): + return limited_result(unique_limit) if resource_limit is not None: - return ( - _deduplicate_view_findings(findings)[:max_findings], - resource_limit.reason, - resource_limit.metrics, - ) + if not defer_output_limit(resource_limit): + return limited_result(resource_limit) if ast_modules and len(content) <= MAX_FILE_CHARS: try: @@ -1586,23 +2232,30 @@ def _scan_all_views_detailed( ast_modules, finding_budget, python_ast_cache_key, + python_ast, + python_source=python_source, ) except _StaticResourceLimitError as exc: - return _deduplicate_view_findings(findings), exc.reason, exc.metrics + return reconciled_prefix(), exc.reason, exc.metrics unique_limit = _extend_unique_findings( findings, seen_findings, ast_findings, max_findings=max_findings, + coalesce=coalesce, ) if unique_limit is not None: - return findings[:max_findings], unique_limit.reason, unique_limit.metrics + if not defer_output_limit(unique_limit): + return limited_result(unique_limit) if resource_limit is not None: - return ( - _deduplicate_view_findings(findings)[:max_findings], - resource_limit.reason, - resource_limit.metrics, - ) + if defer_output_limit(resource_limit): + # AST and lexical producers can own the same logical finding, + # and AST traversal alone cannot decide the earliest public + # prefix. Retain its bounded prefix and let the lexical producer + # enter reconciliation before enforcing the shared cap. + pass + else: + return limited_result(resource_limit) if modules_for_windows: assert source_context is not None @@ -1610,7 +2263,7 @@ def _scan_all_views_detailed( now = time.monotonic() if now >= deadline: return ( - _deduplicate_view_findings(findings), + reconciled_prefix(), LedgerReason.RUNTIME_LIMIT, { "observed_seconds": max(0.0, now - started_at), @@ -1638,8 +2291,28 @@ def _scan_all_views_detailed( source_context.fence_states, source_context.fence_transitions, ) - for full_view in security_text_views(context_prefix + raw_window): + outer_right_boundary_is_fixed = raw_end < len(content) or ( + whole_artifact_window and len(content) == SECURITY_VIEW_WINDOW_CHARS + ) + if raw_end < len(content): + next_owned_start = owned_start + _RAW_WINDOW_OWNED_CHARS + right_boundary_recovery_start = next_owned_start - raw_start + elif whole_artifact_window and len(content) > SECURITY_VIEW_WINDOW_CHARS - len("True"): + # Replacing a short terminal value can move an exact-ceiling + # artifact into the multi-window regime. Its next raw window + # owns calls from the standard owned boundary onward. + right_boundary_recovery_start = _RAW_WINDOW_OWNED_CHARS + else: + right_boundary_recovery_start = None + full_views = security_text_views(context_prefix + raw_window) + across_security_views = len(full_views) > 1 + for full_view in full_views: full_view = _window_view_with_markdown_context(full_view, len(context_prefix)) + full_view = _with_fixed_right_boundary( + full_view, + outer_right_boundary_is_fixed, + right_boundary_recovery_start, + ) try: for module in modules_for_windows: exhaustion_hook = getattr( @@ -1649,11 +2322,16 @@ def _scan_all_views_detailed( ) if callable(exhaustion_hook): finding_budget.check_runtime() + module_file_type = _effective_module_file_type( + path, + module, + python_source=python_source, + ) bounded_parse_limited = bounded_parse_limited or bool( exhaustion_hook( full_view.text, finding_budget.check_runtime, - file_type=_infer_file_type(path), + file_type=module_file_type, # A fragment cannot prove surrounding HTML, # container, or inline delimiter ownership. complete_context=whole_artifact_window, @@ -1661,7 +2339,7 @@ def _scan_all_views_detailed( ) except _StaticResourceLimitError as exc: return ( - _deduplicate_view_findings(findings)[:max_findings], + reconciled_prefix(), exc.reason, exc.metrics, ) @@ -1680,11 +2358,12 @@ def _scan_all_views_detailed( modules_for_windows, view_budget, None, + python_source=python_source, source_text=raw_window, ) except _StaticResourceLimitError as exc: return ( - _deduplicate_view_findings(findings)[:max_findings], + reconciled_prefix(), exc.reason, exc.metrics, ) @@ -1706,7 +2385,23 @@ def _scan_all_views_detailed( and source_end > owned_source_start + _WINDOW_OVERLAP_CHARS ) if not starts_in_owned_range and not first_discoverable_in_this_window: - continue + alternate_start = finding.evidence.get( + _SOURCE_ALTERNATE_START_EVIDENCE + ) + if not ( + isinstance(alternate_start, int) + and owned_source_start <= alternate_start < owned_source_end + ): + continue + finding.evidence[_SOURCE_START_EVIDENCE] = alternate_start + finding.evidence[_SOURCE_ALTERNATE_START_EVIDENCE] = source_start + alternate_matched_text = finding.evidence.pop( + _ALTERNATE_MATCHED_TEXT_EVIDENCE, + None, + ) + if isinstance(alternate_matched_text, str): + finding.matched_text = alternate_matched_text + finding.finding = alternate_matched_text owned_findings.append(finding) view_findings = owned_findings _restore_source_lines( @@ -1722,15 +2417,26 @@ def _scan_all_views_detailed( seen_findings, view_findings, max_findings=max_findings, + coalesce=coalesce, ) if unique_limit is not None: - return findings[:max_findings], unique_limit.reason, unique_limit.metrics + if not defer_output_limit( + unique_limit, + across_security_views=across_security_views, + ): + return limited_result(unique_limit) if resource_limit is not None: - return ( - _deduplicate_view_findings(findings)[:max_findings], - resource_limit.reason, - resource_limit.metrics, - ) + if not defer_output_limit( + resource_limit, + across_security_views=across_security_views, + ): + return limited_result(resource_limit) + if deferred_output_limit is not None and not defers_mixed_output_limit: + # Alternate projections of this bounded raw window can expose + # an earlier source occurrence than its raw view. Once every + # projection has reconciled, enforce the cap before advancing + # to another source window so retained evidence stays bounded. + return limited_result(deferred_output_limit) if owned_end == len(content): break @@ -1742,12 +2448,18 @@ def _scan_all_views_detailed( # all resource accounting remains on the same artifact budget. continuity_seen = {_continuity_finding_key(finding) for finding in findings} try: - for continuity in _continuity_views(content, finding_budget): - for full_view in security_text_views(continuity.view.text): + for continuity in _continuity_views( + content, + finding_budget, + ): + full_views = security_text_views(continuity.view.text) + across_security_views = len(full_views) > 1 + for full_view in full_views: named_view = SecurityTextView( name=f"continuity-{full_view.name}", text=full_view.text, source_offsets=full_view.source_offsets, + right_boundary_is_fixed=continuity.view.right_boundary_is_fixed, ) for view in _bounded_view_slices(named_view): finding_budget.check_runtime() @@ -1763,6 +2475,7 @@ def _scan_all_views_detailed( modules_for_windows, view_budget, None, + python_source=python_source, source_text=continuity.view.text, ) _restore_source_lines( @@ -1770,6 +2483,7 @@ def _scan_all_views_detailed( raw_window=continuity.view.text, window_line=1, view=view, + start_source_offsets=continuity.source_offsets, ) _restore_continuity_lines( view_findings, @@ -1785,27 +2499,30 @@ def _scan_all_views_detailed( seen_findings, [finding], max_findings=max_findings, + coalesce=coalesce, ) if unique_limit is not None: - return ( - findings[:max_findings], - unique_limit.reason, - unique_limit.metrics, - ) + if not defer_output_limit( + unique_limit, + across_security_views=across_security_views, + ): + return limited_result(unique_limit) if resource_limit is not None: - return ( - _deduplicate_view_findings(findings)[:max_findings], - resource_limit.reason, - resource_limit.metrics, - ) + if not defer_output_limit( + resource_limit, + across_security_views=across_security_views, + ): + return limited_result(resource_limit) + if deferred_output_limit is not None and not defers_mixed_output_limit: + return limited_result(deferred_output_limit) except _StaticResourceLimitError as exc: return ( - _deduplicate_view_findings(findings)[:max_findings], + reconciled_prefix(), exc.reason, exc.metrics, ) - deduplicated = _deduplicate_view_findings(findings) + deduplicated = reconciled_findings() if len(deduplicated) > max_findings: return ( deduplicated[:max_findings], @@ -1815,10 +2532,18 @@ def _scan_all_views_detailed( "limit_findings": max_findings, }, ) + if deferred_output_limit is not None: + return ( + deduplicated, + deferred_output_limit.reason, + deferred_output_limit.metrics, + ) return ( deduplicated, ( - LedgerReason.STATIC_PARSE_LIMIT + LedgerReason.SYNTAX_ERROR + if python_syntax_error + else LedgerReason.STATIC_PARSE_LIMIT if bounded_parse_limited else LedgerReason.OBFUSCATED_INSTRUCTION_TEXT if marker_projection_limited @@ -1836,6 +2561,9 @@ def _scan_all_views( *, max_findings: int = MAX_FINDINGS_PER_ARTIFACT, timeout_seconds: float | None = None, + started_at: float | None = None, + python_ast: ParsedPythonFile | None = None, + python_source: bool | None = None, ) -> list[Finding]: findings, _, _ = _scan_all_views_detailed( path, @@ -1844,10 +2572,75 @@ def _scan_all_views( python_ast_cache_key, max_findings=max_findings, timeout_seconds=timeout_seconds, + started_at=started_at, + python_ast=python_ast, + python_source=python_source, ) return findings +def _postprocess_path_findings( + content: str, + pattern_modules: list, + findings: list[Finding], + *, + python_ast: ParsedPythonFile | None = None, + started_at: float | None = None, + timeout_seconds: float | None = None, +) -> list[Finding]: + """Let one analyzer family reconcile findings after every view has run.""" + hook = ( + _explicit_module_hook(pattern_modules[0], "postprocess_path_findings") + if pattern_modules + else None + ) + if not callable(hook): + return findings + uses_python_ast = bool( + pattern_modules + and _explicit_module_hook(pattern_modules[0], "POSTPROCESS_USES_PYTHON_AST") is True + ) + uses_runtime_budget = bool( + pattern_modules + and _explicit_module_hook(pattern_modules[0], "POSTPROCESS_USES_RUNTIME_BUDGET") is True + ) + if uses_python_ast or uses_runtime_budget: + kwargs: dict[str, object] = {} + if uses_python_ast: + kwargs["python_ast"] = python_ast + if uses_runtime_budget: + kwargs.update( + { + "started_at": started_at, + "timeout_seconds": timeout_seconds, + } + ) + return cast(list[Finding], hook(content, findings, **kwargs)) + return cast(list[Finding], hook(content, findings)) + + +def _cleanup_expired_path_findings( + pattern_modules: list, + findings: list[Finding], +) -> list[Finding]: + """Run only a module's bounded private-evidence cleanup after a deadline.""" + hook = ( + _explicit_module_hook(pattern_modules[0], "cleanup_path_findings") + if pattern_modules + else None + ) + if callable(hook): + return cast(list[Finding], hook(findings)) + has_postprocessor = bool( + pattern_modules + and callable(_explicit_module_hook(pattern_modules[0], "postprocess_path_findings")) + ) + # A module requiring postprocessing owns the contract that turns its private + # intermediate findings into public objects. Without an explicit bounded + # cleanup hook, dropping that partial prefix is safer than leaking it. + return [] if has_postprocessor else findings + + def run_static_patterns( state: Mapping[str, object], pattern_modules: list, @@ -1863,6 +2656,17 @@ def run_static_patterns( file_cache = cast( dict[str, str], state.get("local_file_cache") or state.get("file_cache") or {} ) + raw_file_cache = cast(Mapping[str, bytes] | None, state.get("raw_file_cache")) + source_classifications = cast( + Mapping[str, PythonSourceClassification | str] | None, + state.get("python_source_classifications") + if "python_source_classifications" in state + else None, + ) + source_classification_limitations = cast( + Mapping[str, str], state.get("python_source_classification_limitations") or {} + ) + needs_python_source = _requires_python_source_type(pattern_modules) python_ast_cache_key = cast(str | None, state.get("python_ast_cache_key")) container_paths = { str(metadata.get("path", "")) @@ -1889,24 +2693,72 @@ def run_static_patterns( if content is None: logger.debug("Skipping %s: no content in file_cache", path) continue + if needs_python_source and path in source_classification_limitations: + continue if path in binary_paths or (not binary_paths and _is_binary_file(path, content)): continue remaining = MAX_FINDINGS_PER_ANALYZER - len(findings) if remaining <= 0: break + path_started_at = time.monotonic() shared_remaining = transitive_remaining_seconds(cast(SkillspectorState, state)) if shared_remaining is not None and shared_remaining <= 0: break - findings.extend( - _scan_all_views( + python_source = False + if needs_python_source: + source_classification = resolve_python_source_classification( path, content, - pattern_modules, - python_ast_cache_key, - max_findings=min(MAX_FINDINGS_PER_ARTIFACT, remaining), - timeout_seconds=shared_remaining, + source_classifications=source_classifications, + raw_file_cache=raw_file_cache, ) + python_source = source_classification is not PythonSourceClassification.NON_PYTHON + current_remaining = transitive_remaining_seconds(cast(SkillspectorState, state)) + if current_remaining is not None and current_remaining <= 0: + break + python_ast = _python_ast_for_path( + path, + content, + pattern_modules, + python_ast_cache_key, + python_source=python_source, + ) + path_limit = min(MAX_FINDINGS_PER_ARTIFACT, remaining) + path_findings, resource_limit, _ = _scan_all_views_detailed( + path, + content, + pattern_modules, + python_ast_cache_key, + max_findings=path_limit, + timeout_seconds=shared_remaining, + started_at=path_started_at, + python_ast=python_ast, + python_source=python_source, ) + runtime_limit = MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT + if shared_remaining is not None: + runtime_limit = min(runtime_limit, max(0.0, shared_remaining)) + expired = ( + resource_limit is LedgerReason.RUNTIME_LIMIT + or time.monotonic() - path_started_at >= runtime_limit + ) + if expired: + path_findings = _cleanup_expired_path_findings(pattern_modules, path_findings) + else: + path_findings = _postprocess_path_findings( + content, + pattern_modules, + path_findings, + python_ast=python_ast, + started_at=path_started_at, + timeout_seconds=runtime_limit, + ) + if time.monotonic() - path_started_at >= runtime_limit: + path_findings = _cleanup_expired_path_findings( + pattern_modules, + path_findings, + ) + findings.extend(path_findings[:path_limit]) return findings @@ -1921,6 +2773,20 @@ def run_static_patterns_with_ledger( file_cache = cast( dict[str, str], state.get("local_file_cache") or state.get("file_cache") or {} ) + raw_file_cache = cast(Mapping[str, bytes] | None, state.get("raw_file_cache")) + source_classifications = cast( + Mapping[str, PythonSourceClassification | str] | None, + state.get("python_source_classifications") + if "python_source_classifications" in state + else None, + ) + source_classification_limitations = cast( + Mapping[str, str], state.get("python_source_classification_limitations") or {} + ) + source_decode_failures = cast( + Mapping[str, str], state.get("python_source_decode_failures") or {} + ) + needs_python_source = _requires_python_source_type(pattern_modules) python_ast_cache_key = cast(str | None, state.get("python_ast_cache_key")) container_paths = { str(metadata.get("path", "")) @@ -1947,7 +2813,23 @@ def run_static_patterns_with_ledger( ) else: artifact = inventory.get(path, {}) - if path not in container_paths and artifact.get("content_kind") == ContentKind.OPAQUE: + if path not in container_paths and path in source_classification_limitations: + event = ledger_event( + outcome=LedgerOutcome.PARTIAL, + phase="static", + analyzer_id=analyzer_id, + path=path, + reason=LedgerReason.RUNTIME_LIMIT, + ) + elif path not in container_paths and path in source_decode_failures: + event = ledger_event( + outcome=LedgerOutcome.PARTIAL, + phase="static", + analyzer_id=analyzer_id, + path=path, + reason=LedgerReason.PYTHON_SOURCE_DECODE_ERROR, + ) + elif path not in container_paths and artifact.get("content_kind") == ContentKind.OPAQUE: event = ledger_event( outcome=( LedgerOutcome.FAILED @@ -1993,10 +2875,12 @@ def run_static_patterns_with_ledger( ) else: remaining = MAX_FINDINGS_PER_ANALYZER - len(findings) + path_started_at = time.monotonic() shared_remaining = transitive_remaining_seconds(cast(SkillspectorState, state)) path_findings: list[Finding] resource_limit: LedgerReason | None resource_metrics: dict[str, int | float] + source_classification: PythonSourceClassification | None = None if shared_remaining is not None and shared_remaining <= 0: path_findings = [] resource_limit = LedgerReason.RUNTIME_LIMIT @@ -2006,14 +2890,124 @@ def run_static_patterns_with_ledger( } else: try: + python_source = False + if needs_python_source: + source_classification = resolve_python_source_classification( + path, + content, + source_classifications=source_classifications, + raw_file_cache=raw_file_cache, + ) + python_source = ( + source_classification is not PythonSourceClassification.NON_PYTHON + ) + current_remaining = transitive_remaining_seconds( + cast(SkillspectorState, state) + ) + if current_remaining is not None and current_remaining <= 0: + raise _StaticResourceLimitError( + LedgerReason.RUNTIME_LIMIT, + { + "observed_seconds": max( + 0.0, time.monotonic() - path_started_at + ), + "limit_seconds": max(0.0, shared_remaining or 0.0), + }, + ) + python_ast = _python_ast_for_path( + path, + content, + pattern_modules, + python_ast_cache_key, + python_source=python_source, + ) + path_limit = min(MAX_FINDINGS_PER_ARTIFACT, remaining) path_findings, resource_limit, resource_metrics = _scan_all_views_detailed( path, content, pattern_modules, python_ast_cache_key, - max_findings=min(MAX_FINDINGS_PER_ARTIFACT, remaining), + max_findings=path_limit, timeout_seconds=shared_remaining, + started_at=path_started_at, + python_ast=python_ast, + python_source=python_source, + ) + has_postprocessor = bool( + pattern_modules + and callable( + _explicit_module_hook( + pattern_modules[0], + "postprocess_path_findings", + ) + ) + ) + runtime_limit = MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT + if shared_remaining is not None: + runtime_limit = min(runtime_limit, max(0.0, shared_remaining)) + observed_seconds = ( + float(resource_metrics.get("observed_seconds", 0.0)) + if resource_limit is LedgerReason.RUNTIME_LIMIT + else max(0.0, time.monotonic() - path_started_at) ) + expired = ( + resource_limit is LedgerReason.RUNTIME_LIMIT + or observed_seconds >= runtime_limit + ) + if expired: + resource_limit = LedgerReason.RUNTIME_LIMIT + resource_metrics = { + "observed_seconds": observed_seconds, + "limit_seconds": runtime_limit, + } + path_findings = _cleanup_expired_path_findings( + pattern_modules, + path_findings, + ) + elif has_postprocessor: + path_findings = _postprocess_path_findings( + content, + pattern_modules, + path_findings, + python_ast=python_ast, + started_at=path_started_at, + timeout_seconds=runtime_limit, + ) + observed_seconds = max(0.0, time.monotonic() - path_started_at) + if observed_seconds >= runtime_limit: + resource_limit = LedgerReason.RUNTIME_LIMIT + resource_metrics = { + "observed_seconds": observed_seconds, + "limit_seconds": runtime_limit, + } + path_findings = _cleanup_expired_path_findings( + pattern_modules, + path_findings, + ) + if len(path_findings) > path_limit: + postprocessed_count = len(path_findings) + path_findings = path_findings[:path_limit] + if resource_limit is not LedgerReason.RUNTIME_LIMIT: + if remaining < MAX_FINDINGS_PER_ARTIFACT: + observed_findings = len(findings) + postprocessed_count + limit_findings = MAX_FINDINGS_PER_ANALYZER + else: + observed_findings = postprocessed_count + limit_findings = MAX_FINDINGS_PER_ARTIFACT + if resource_limit is LedgerReason.OUTPUT_LIMIT: + observed_findings = max( + observed_findings, + int(resource_metrics.get("observed_findings", 0)), + ) + resource_limit = LedgerReason.OUTPUT_LIMIT + resource_metrics = { + "observed_findings": observed_findings, + "limit_findings": limit_findings, + } + except _StaticResourceLimitError as exc: + path_findings = [] + resource_limit = exc.reason + resource_metrics = exc.metrics except Exception as exc: logger.warning("%s: scan error on %s: %s", analyzer_id, path, exc) event = ledger_event( @@ -2034,12 +3028,22 @@ def run_static_patterns_with_ledger( path_findings = path_findings[:remaining] resource_limit = LedgerReason.OUTPUT_LIMIT findings.extend(path_findings) - partial = resource_limit is not None or ( - _infer_file_type(path) == "python" + oversized_python = ( + source_classification is not None + and source_classification is not PythonSourceClassification.NON_PYTHON and len(content) > MAX_FILE_CHARS - and any(_uses_python_ast(module) for module in pattern_modules) + and _requires_python_ast(pattern_modules) + ) + ambiguous_python = ( + source_classification is PythonSourceClassification.AMBIGUOUS + and needs_python_source + ) + partial = resource_limit is not None or oversized_python or ambiguous_python + partial_reason = ( + resource_limit + or (LedgerReason.SIZE_LIMIT if oversized_python else None) + or LedgerReason.PYTHON_SOURCE_AMBIGUOUS ) - partial_reason = resource_limit or LedgerReason.SIZE_LIMIT event = ledger_event( outcome=LedgerOutcome.PARTIAL if partial else LedgerOutcome.COMPLETED, phase="static", diff --git a/src/skillspector/nodes/build_context.py b/src/skillspector/nodes/build_context.py index d45239f1e..9f96fce86 100644 --- a/src/skillspector/nodes/build_context.py +++ b/src/skillspector/nodes/build_context.py @@ -40,6 +40,7 @@ ContentKind, classify_artifact, decode_text, + promote_artifact_to_decoded_text, ) from skillspector.constants import ( MAX_ANALYZABLE_FILE_BYTES, @@ -68,7 +69,13 @@ is_executable_content, is_zip_content, ) -from skillspector.python_ast import prewarm_python_ast_cache +from skillspector.python_ast import ( + PythonSourceClassification, + classify_python_source, + decode_python_source, + is_python_source, + prewarm_python_ast_cache, +) from skillspector.references import ( MAX_ACCEPTED_REFERENCES, MAX_RAW_REFERENCE_CANDIDATES, @@ -134,6 +141,7 @@ def _is_allowed_inactive_git_hook_template(path: str, probe: bytes) -> bool: ".md": "markdown", ".markdown": "markdown", ".py": "python", + ".pyw": "python", ".sh": "shell", ".bash": "shell", ".zsh": "shell", @@ -785,8 +793,19 @@ def _record_runtime_limit( ) -def _infer_file_type(path: str) -> str: - """Infer file type from path (extension).""" +def _infer_file_type( + path: str, + content: str | bytes | None = None, + *, + source_classification: PythonSourceClassification | None = None, +) -> str: + """Infer file type from path and bounded execution metadata.""" + if ( + source_classification is PythonSourceClassification.PYTHON + if source_classification is not None + else is_python_source(path, content) + ): + return "python" idx = path.rfind(".") suffix = path[idx:].lower() if idx >= 0 else "" return _FILE_TYPES.get(suffix, "other") @@ -883,6 +902,7 @@ def _build_component_metadata( raw_file_cache: Mapping[str, bytes], recognized_oms_signatures: frozenset[str] = frozenset(), *, + source_classifications: Mapping[str, PythonSourceClassification] | None = None, clock: Callable[[], float] = monotonic, started_at: float | None = None, deadline: float | None = None, @@ -908,8 +928,21 @@ def _expired(path: str) -> bool: if _expired(path): break full = skill_dir / path - file_type = "oms_signature" if path in recognized_oms_signatures else _infer_file_type(path) content = file_cache.get(path) + raw_content = raw_file_cache.get(path) if raw_file_cache is not None else None + source_content = raw_content if raw_content is not None else content + source_classification = ( + source_classifications.get(path) if source_classifications is not None else None + ) + file_type = ( + "oms_signature" + if path in recognized_oms_signatures + else _infer_file_type( + path, + source_content, + source_classification=source_classification, + ) + ) lines = ( len(content.splitlines()) if content is not None @@ -925,7 +958,13 @@ def _expired(path: str) -> bool: logger.debug("Could not stat file: %s", path) size_bytes = 0 mode = 0 - data = raw_file_cache.get(path, b"") + data = ( + raw_content + if raw_content is not None + else content.encode("utf-8", errors="replace") + if content is not None + else b"" + ) executable = is_executable_content(path, data, mode) if executable: has_executable = True @@ -3093,9 +3132,179 @@ def mark_excluded_nested_metadata( ) for path in [*recognized_containers, *recognized_oms_signatures]: llm_file_cache.pop(path, None) + + postprocessing_events: list[InspectionLedgerEvent] = [] + runtime_limit = max(0.0, processing_deadline - processing_started) + + def _mark_runtime_partial(affected_paths: list[str], first_limited_path: str) -> None: + limited = False + for affected_path in affected_paths: + if affected_path == first_limited_path: + limited = True + if not limited: + continue + affected_artifact = inventory_by_path.get(affected_path) + if ( + affected_artifact is None + or affected_artifact.get("disposition") == ArtifactDisposition.FAILED + ): + continue + if affected_artifact.get("disposition") != ArtifactDisposition.PARTIAL: + affected_artifact["reason"] = LedgerReason.RUNTIME_LIMIT.value + affected_artifact["disposition"] = ArtifactDisposition.PARTIAL + + classification_events: list[InspectionLedgerEvent] = [] + source_classifications = dict(nested.python_source_classifications) + completed_source_classifications: set[str] = set() + source_decode_failures: dict[str, str] = {} + nested_metadata_by_path = { + str(metadata.get("path", "")): metadata for metadata in nested.metadata + } + classification_runtime_limitation: tuple[str, float] | None = None + for path in components: + now = monotonic() + if now >= processing_deadline: + classification_runtime_limitation = ( + path, + max(0.0, now - processing_started), + ) + break + content = local_file_cache.get(path) + raw_content = raw_file_cache.get(path) + source_classification = source_classifications.get(path) + if source_classification is None: + source_classification = classify_python_source( + path, + raw_content if raw_content is not None else content, + ) + source_classifications[path] = source_classification + if ( + source_classification is not PythonSourceClassification.NON_PYTHON + and raw_content is not None + ): + try: + decoded_python = decode_python_source(raw_content) + except Exception as exc: + source_decode_failures[path] = LedgerReason.PYTHON_SOURCE_DECODE_ERROR.value + classified_artifact = inventory_by_path.get(path) + if ( + classified_artifact is not None + and classified_artifact.get("disposition") != ArtifactDisposition.FAILED + ): + if ( + classified_artifact.get("disposition") != ArtifactDisposition.PARTIAL + or "reason" not in classified_artifact + ): + classified_artifact["reason"] = ( + LedgerReason.PYTHON_SOURCE_DECODE_ERROR.value + ) + classified_artifact["disposition"] = ArtifactDisposition.PARTIAL + local_file_cache.pop(path, None) + llm_file_cache.pop(path, None) + classification_events.append( + ledger_event( + outcome=LedgerOutcome.PARTIAL, + record_type=LedgerRecordType.SYSTEM, + phase="python_source_decoding", + path=path, + reason=LedgerReason.PYTHON_SOURCE_DECODE_ERROR, + error_class=type(exc).__name__, + ) + ) + else: + local_file_cache[path] = decoded_python + nested_metadata = nested_metadata_by_path.get(path) + if nested_metadata is not None: + nested_metadata["lines"] = len(decoded_python.splitlines()) + classified_artifact = inventory_by_path.get(path) + if classified_artifact is not None: + promote_artifact_to_decoded_text(classified_artifact) + disposition = classified_artifact.get("disposition") + artifact_reason = classified_artifact.get("reason") + bounded_provider_view = ( + disposition == ArtifactDisposition.PARTIAL + and artifact_reason + in { + LedgerReason.SIZE_LIMIT.value, + LedgerReason.TOTAL_BYTES_LIMIT.value, + } + ) + if not source_local_only and ( + path in llm_file_cache + or ( + path not in nested.file_cache + and path not in recognized_containers + and not _is_hidden_path(path) + and ( + disposition == ArtifactDisposition.ANALYZED or bounded_provider_view + ) + ) + ): + provider_content = decoded_python + if bounded_provider_view: + provider_content = _llm_view_of_truncated_file( + decoded_python, + total_size=max( + len(raw_content), + int(classified_artifact.get("size_bytes", 0)), + ), + read_bytes=len(raw_content), + ) + llm_file_cache[path] = _redact_for_external_model( + path, + provider_content, + ) + completed_source_classifications.add(path) + now = monotonic() + if now >= processing_deadline: + classification_runtime_limitation = ( + path, + max(0.0, now - processing_started), + ) + break + if source_classification is PythonSourceClassification.AMBIGUOUS: + classified_artifact = inventory_by_path.get(path) + if classified_artifact is not None and classified_artifact.get("disposition") not in { + ArtifactDisposition.FAILED, + ArtifactDisposition.OUT_OF_SCOPE, + }: + if classified_artifact.get("disposition") != ArtifactDisposition.PARTIAL: + classified_artifact["reason"] = LedgerReason.PYTHON_SOURCE_AMBIGUOUS.value + classified_artifact["disposition"] = ArtifactDisposition.PARTIAL + classification_events.append( + ledger_event( + outcome=LedgerOutcome.PARTIAL, + record_type=LedgerRecordType.SYSTEM, + phase="python_source_classification", + path=path, + reason=LedgerReason.PYTHON_SOURCE_AMBIGUOUS, + ) + ) + source_classification_limitations = { + path: LedgerReason.RUNTIME_LIMIT.value + for path in components + if path not in completed_source_classifications + } + for path in source_classification_limitations: + local_file_cache.pop(path, None) + llm_file_cache.pop(path, None) + if classification_runtime_limitation is not None: + path, elapsed = classification_runtime_limitation + _mark_runtime_partial(components, path) + classification_events.append( + ledger_event( + outcome=LedgerOutcome.PARTIAL, + record_type=LedgerRecordType.SYSTEM, + phase="python_source_classification", + path=path, + reason=LedgerReason.RUNTIME_LIMIT, + observed_seconds=elapsed, + limit_seconds=runtime_limit, + ) + ) + llm_components = sorted(llm_file_cache) file_cache = dict(llm_file_cache) - manifest_events: list[InspectionLedgerEvent] = [] manifest = _parse_manifest( skill_dir, @@ -3174,49 +3383,39 @@ def mark_excluded_nested_metadata( ) ) - disposition_by_path = {item["path"]: item["disposition"] for item in artifact_inventory} - for reference in references: - target = reference["target_path"] - if target and target in disposition_by_path: - reference["disposition"] = disposition_by_path[target] - - postprocessing_events: list[InspectionLedgerEvent] = [] - runtime_limit = max(0.0, processing_deadline - processing_started) - - def _mark_runtime_partial(affected_paths: list[str], first_limited_path: str) -> None: - limited = False - for affected_path in affected_paths: - if affected_path == first_limited_path: - limited = True - if not limited: - continue - affected_artifact = inventory_by_path.get(affected_path) - if ( - affected_artifact is None - or affected_artifact.get("disposition") == ArtifactDisposition.FAILED - ): - continue - if affected_artifact.get("disposition") != ArtifactDisposition.PARTIAL: - affected_artifact["reason"] = LedgerReason.RUNTIME_LIMIT.value - affected_artifact["disposition"] = ArtifactDisposition.PARTIAL - + python_components = [ + component + for component in components + if component in local_file_cache + and source_classifications.get(component, PythonSourceClassification.AMBIGUOUS) + is not PythonSourceClassification.NON_PYTHON + ] + python_component_set = set(python_components) ast_runtime_limitations: list[tuple[str, float]] = [] - python_ast_cache_key = prewarm_python_ast_cache( - components, - local_file_cache, - clock=monotonic, - started_at=processing_started, - deadline=processing_deadline, - runtime_limitations=ast_runtime_limitations, - ) + if classification_runtime_limitation is not None: + python_ast_cache_key = None + ast_runtime_limitations.append(classification_runtime_limitation) + else: + python_ast_cache_key = prewarm_python_ast_cache( + python_components, + local_file_cache, + raw_file_cache=raw_file_cache, + source_classifications=source_classifications, + clock=monotonic, + started_at=processing_started, + deadline=processing_deadline, + runtime_limitations=ast_runtime_limitations, + ) if ast_runtime_limitations: path, elapsed = ast_runtime_limitations[0] - python_components = [ + limited_index = components.index(path) + affected_python_components = [ component - for component in components - if component.lower().endswith(".py") and component in local_file_cache + for component in components[limited_index:] + if component in python_component_set ] - _mark_runtime_partial(python_components, path) + if affected_python_components: + _mark_runtime_partial(affected_python_components, affected_python_components[0]) postprocessing_events.append( ledger_event( outcome=LedgerOutcome.PARTIAL, @@ -3238,6 +3437,7 @@ def _mark_runtime_partial(affected_paths: list[str], first_limited_path: str) -> local_file_cache, raw_file_cache, recognized_oms_signatures, + source_classifications=source_classifications, clock=monotonic, started_at=processing_started, deadline=processing_deadline, @@ -3281,12 +3481,26 @@ def _mark_runtime_partial(affected_paths: list[str], first_limited_path: str) -> or any(bool(metadata.get("executable")) for metadata in excluded_component_metadata) ) + # Post-cache classification, AST prewarm, and metadata work can still + # downgrade inventory rows. Project those final dispositions only after + # every mutation so reference coverage cannot remain falsely complete. + disposition_by_path = {item["path"]: item["disposition"] for item in artifact_inventory} + for reference in references: + target = reference["target_path"] + if target and target in disposition_by_path: + reference["disposition"] = disposition_by_path[target] + result: dict[str, object] = { "components": components, "llm_components": llm_components, "file_cache": file_cache, "local_file_cache": local_file_cache, "raw_file_cache": raw_file_cache, + "python_source_classifications": { + path: classification.value for path, classification in source_classifications.items() + }, + "python_source_classification_limitations": source_classification_limitations, + "python_source_decode_failures": source_decode_failures, "llm_file_cache": llm_file_cache, "source_local_only": source_local_only, "artifact_inventory": artifact_inventory, @@ -3303,6 +3517,7 @@ def _mark_runtime_partial(affected_paths: list[str], first_limited_path: str) -> *cache_events, *nested.ledger_events, *excluded_nested_events, + *classification_events, *manifest_events, *structured_events, *postprocessing_events, diff --git a/src/skillspector/python_ast.py b/src/skillspector/python_ast.py index 2bf1ce5ca..aa3e3f3b3 100644 --- a/src/skillspector/python_ast.py +++ b/src/skillspector/python_ast.py @@ -24,11 +24,16 @@ from __future__ import annotations import ast +import codecs +import posixpath +import re import time +import unicodedata from bisect import bisect_right from collections import OrderedDict from collections.abc import Callable, Iterable, Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace +from enum import StrEnum from threading import RLock from uuid import uuid4 @@ -39,6 +44,1702 @@ # source retained as parsed trees for any one scan; files beyond this budget # use the existing on-demand behavior rather than retaining unbounded memory. MAX_PYTHON_AST_CACHE_SOURCE_CHARS = 8_000_000 +PYTHON_SOURCE_EXTENSIONS = frozenset({".py", ".pyw"}) +MAX_PYTHON_SHEBANG_CHARS = 512 +_MAX_PYTHON_CONSUMED_SOURCE_SPELLING_CHARS = 4_096 +# Linux used a 128-byte ``BINPRM_BUF_SIZE`` through 5.0 and has used 256 +# bytes since 5.1. Supported Python runtimes still run on both kernel lines, +# so classifications must agree with both truncation boundaries. +_LINUX_SHEBANG_BUFFER_SIZES = (128, 256) +_TRUSTED_ENV_PATHS = frozenset({"/bin/env", "/usr/bin/env"}) +_PYTHON_INTERPRETER_BASENAME = re.compile( + r"(?:python(?:[0-9]+(?:\.[0-9]+)*(?:d?m?u?|t?d?)" + r"(?:-(?:intel64|32|dbg))?)?" + r"|pypy(?:[0-9]+(?:\.[0-9]+)*)?)\Z" +) +_ENV_GNU_SPLIT_SHORT_PREFIX = re.compile(r"-[iv]*S") +_ENV_FREEBSD_SPLIT_SHORT_PREFIX = re.compile(r"-[iv-]*S") +_ENV_VARIABLE = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}") +_ENV_CHARACTER_ESCAPES = { + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", + "v": "\v", +} +_ENV_GNU_SHORT_FLAGS = frozenset({"i", "v"}) +_ENV_FREEBSD_SHORT_FLAGS = frozenset({"-", "i", "v"}) +_ENV_DARWIN_SHORT_FLAGS = frozenset({"-", "i", "v"}) +_ENV_GNU_SHORT_OPERANDS = frozenset({"C", "a", "u"}) +_ENV_FREEBSD_SHORT_OPERANDS = frozenset({"C", "L", "P", "U", "u"}) +_ENV_DARWIN_SHORT_OPERANDS = frozenset({"C", "P", "u"}) +_ENV_SPLIT_LONG_FLAGS = frozenset( + { + "--debug", + "--ignore-environment", + "--list-signal-handling", + } +) +_ENV_SPLIT_LONG_OPERANDS = frozenset({"--argv0", "--chdir", "--env0-from", "--unset"}) +_ENV_SPLIT_LONG_OPTIONAL_OPERANDS = frozenset( + { + "--block-signal", + "--default-signal", + "--ignore-signal", + } +) +_ENV_SPLIT_LONG_OPTIONS = ( + _ENV_SPLIT_LONG_FLAGS + | _ENV_SPLIT_LONG_OPERANDS + | _ENV_SPLIT_LONG_OPTIONAL_OPERANDS + | {"--split-string"} +) +_MAX_ENV_PARSE_STATES = MAX_PYTHON_SHEBANG_CHARS * 4 + + +class PythonSourceClassification(StrEnum): + """Confidence in Python source identity derived from bounded metadata.""" + + PYTHON = "python" + NON_PYTHON = "non_python" + AMBIGUOUS = "ambiguous" + + +class _EnvPlatform(StrEnum): + """Supported ``env`` grammar whose branches must remain coherent.""" + + GNU = "gnu" + FREEBSD = "freebsd" + DARWIN = "darwin" + + +class _PythonInspectEnvironment(StrEnum): + """Static knowledge about PYTHONINSPECT supplied by the env launcher. + + Ambient inherited variables are deliberately UNMENTIONED: without an + explicit launcher action the scanner does not assume a TTY-driven REPL. + Environment-file and login-class sources are UNKNOWN until a later exact + assignment determines the final value. + """ + + UNMENTIONED = "unmentioned" + ABSENT_OR_EMPTY = "absent_or_empty" + NONEMPTY = "nonempty" + UNKNOWN = "unknown" + + +@dataclass(frozen=True, slots=True) +class _EnvArgument: + """One split argument with projected text and runtime-expansion offsets.""" + + text: str + dynamic_offsets: tuple[int, ...] = () + retained_when_empty: bool = True + + +@dataclass(frozen=True, slots=True) +class _EnvExecution: + """One selected env utility and its arguments before the script path.""" + + utility: str + arguments: tuple[_EnvArgument, ...] = () + python_inspect: _PythonInspectEnvironment = _PythonInspectEnvironment.UNMENTIONED + + +def _has_python_source_extension(path: str) -> bool: + """Return whether the basename has an authoritative Python extension.""" + basename = path.replace("\\", "/").rsplit("/", 1)[-1] + dot = basename.rfind(".") + suffix = basename[dot:].casefold() if dot >= 0 else "" + return suffix in PYTHON_SOURCE_EXTENSIONS + + +def _bounded_shebang_line(content: str | bytes | None) -> str | None: + """Return one bounded shebang line, excluding its line ending.""" + if content is None: + return None + sample = content[: MAX_PYTHON_SHEBANG_CHARS + 1] + if isinstance(sample, bytes): + terminators = [offset for marker in (b"\n", b"\0") if (offset := sample.find(marker)) >= 0] + else: + terminators = [offset for marker in ("\n", "\0") if (offset := sample.find(marker)) >= 0] + terminator = min(terminators, default=-1) + if terminator < 0: + if len(sample) > MAX_PYTHON_SHEBANG_CHARS: + return None + line = sample + else: + if terminator > MAX_PYTHON_SHEBANG_CHARS: + return None + line = sample[:terminator] + if isinstance(line, bytes): + decoded = line.decode("utf-8", errors="surrogateescape") + else: + decoded = line + return decoded + + +def _has_overlong_shebang(content: str | bytes | None) -> bool: + """Return whether a shebang line extends beyond the inspection bound.""" + if content is None: + return False + if isinstance(content, bytes): + if not content.startswith(b"#!"): + return False + bytes_sample = content[: MAX_PYTHON_SHEBANG_CHARS + 1] + return len(bytes_sample) > MAX_PYTHON_SHEBANG_CHARS and bytes_sample.find(b"\n") < 0 + if not content.startswith("#!"): + return False + text_sample = content[: MAX_PYTHON_SHEBANG_CHARS + 1] + return len(text_sample) > MAX_PYTHON_SHEBANG_CHARS and text_sample.find("\n") < 0 + + +def _linux_truncated_shebang( + content: str | bytes | None, + buffer_bytes: int, +) -> tuple[bool, bytes | None]: + """Return one Linux ``BINPRM_BUF_SIZE`` view when its line is truncated.""" + if content is None: + return False, None + if isinstance(content, bytes): + if not content.startswith(b"#!"): + return False, None + encoded = content[: buffer_bytes + 1] + else: + if not content.startswith("#!"): + return False, None + sample = content[: buffer_bytes + 1] + try: + encoded = sample.encode("utf-8", errors="surrogateescape") + except UnicodeEncodeError: + # An in-memory string that cannot be faithfully mapped back to + # source bytes has no single kernel interpretation. + return True, None + terminators = [offset for marker in (b"\n", b"\0") if (offset := encoded.find(marker)) >= 0] + line = encoded[: min(terminators)] if terminators else encoded + if len(line) < buffer_bytes: + return False, None + # Linux reserves the last byte of BINPRM_BUF_SIZE as the exclusive end + # marker. Bytes at ``buffer_bytes - 1`` and later are therefore absent + # from the interpreter line parsed by the kernel. + return True, encoded[: buffer_bytes - 1] + b"\n" + + +def _is_python_interpreter(command: str) -> bool: + """Return whether an absolute or PATH-resolved command names Python.""" + basename = command.rsplit("/", 1)[-1] + return _PYTHON_INTERPRETER_BASENAME.fullmatch(basename) is not None + + +def _is_python_interpreter_filesystem_alias(command: str) -> bool: + """Return whether a case/Unicode-insensitive volume may resolve Python.""" + basename = command.rsplit("/", 1)[-1] + normalized = unicodedata.normalize("NFD", basename).casefold() + return _PYTHON_INTERPRETER_BASENAME.fullmatch(normalized) is not None + + +def _is_uv_launcher(command: str, *, allow_filesystem_aliases: bool = False) -> bool: + """Return whether a command names Astral's Python-capable ``uv`` launcher.""" + basename = command.rsplit("/", 1)[-1] + if basename == "uv": + return True + return allow_filesystem_aliases and unicodedata.normalize("NFD", basename).casefold() == "uv" + + +def _is_trusted_env_filesystem_alias(command: str) -> bool: + """Return whether filesystem/path aliases may resolve trusted ``env``.""" + if command.startswith("/"): + # ``normpath`` intentionally preserves exactly two leading slashes; + # collapse them here because that spelling is implementation-defined + # and must remain a possible trusted-env branch. + command = "/" + posixpath.normpath("/" + command.lstrip("/")).lstrip("/") + normalized = unicodedata.normalize("NFD", command).casefold() + return any( + normalized == unicodedata.normalize("NFD", trusted).casefold() + for trusted in _TRUSTED_ENV_PATHS + ) + + +def _split_env_arguments(value: str, platform: _EnvPlatform) -> list[_EnvArgument] | None: + """Apply the bounded ``env -S`` grammar and preserve runtime uncertainty.""" + arguments: list[_EnvArgument] = [] + current: list[str] = [] + argument_started = False + dynamic_offsets: list[int] = [] + single_quoted = False + double_quoted = False + index = 0 + + def finish_argument() -> None: + nonlocal argument_started, current, dynamic_offsets + if argument_started or dynamic_offsets: + arguments.append( + _EnvArgument( + "".join(current), + tuple(dynamic_offsets), + retained_when_empty=argument_started, + ) + ) + current = [] + argument_started = False + dynamic_offsets = [] + + while index < len(value): + character = value[index] + if character == "'" and not double_quoted: + single_quoted = not single_quoted + argument_started = True + index += 1 + continue + if character == '"' and not single_quoted: + double_quoted = not double_quoted + argument_started = True + index += 1 + continue + if character in " \t\n\r\v\f" and not single_quoted and not double_quoted: + finish_argument() + index += 1 + continue + if character == "#" and not argument_started and not dynamic_offsets: + break + if character == "\\": + if single_quoted and (index + 1 >= len(value) or value[index + 1] not in {"\\", "'"}): + current.append(character) + argument_started = True + index += 1 + continue + if index + 1 >= len(value): + return None + escaped = value[index + 1] + if escaped in {'"', "#", "$", "'", "\\"}: + current.append(escaped) + argument_started = True + index += 2 + continue + if escaped in " \t\n\r\v\f" and platform is not _EnvPlatform.GNU: + # FreeBSD env accepts escaped literal whitespace. GNU env + # rejects this spelling, but the successful BSD branch still + # establishes execution intent and must not be skipped. + current.append(escaped) + argument_started = True + index += 2 + continue + if escaped == "_": + if double_quoted: + current.append(" ") + argument_started = True + else: + finish_argument() + index += 2 + continue + if escaped == "c": + if double_quoted: + return None + break + replacement = _ENV_CHARACTER_ESCAPES.get(escaped) + if replacement is None: + return None + current.append(replacement) + argument_started = True + index += 2 + continue + if character == "$" and not single_quoted: + variable = _ENV_VARIABLE.match(value, index) + if variable is None: + return None + # Expansion values and even the presence of an unquoted standalone + # argument depend on the runtime environment. The projected text + # remains useful for syntax validation, but callers must retain the + # uncertainty rather than treating the empty branch as definitive. + dynamic_offsets.append(len(current)) + index = variable.end() + continue + current.append(character) + argument_started = True + index += 1 + + if single_quoted or double_quoted: + return None + finish_argument() + return arguments + + +def _dynamic_env_argument_role(argument: _EnvArgument, platform: _EnvPlatform) -> str: + """Return the only stable role available before a dynamic expansion.""" + first_dynamic = argument.dynamic_offsets[0] + static_prefix = argument.text[:first_dynamic] + if not static_prefix: + return "ambiguous" + if first_dynamic > 0 and not argument.text.startswith("-") and "=" in argument.text: + return "assignment" + if argument.text.startswith("-") and ( + platform is not _EnvPlatform.GNU or not argument.text.startswith("--") + ): + short_flags = { + _EnvPlatform.GNU: _ENV_GNU_SHORT_FLAGS, + _EnvPlatform.FREEBSD: _ENV_FREEBSD_SHORT_FLAGS, + _EnvPlatform.DARWIN: _ENV_DARWIN_SHORT_FLAGS, + }[platform] + short_operands = { + _EnvPlatform.GNU: _ENV_GNU_SHORT_OPERANDS, + _EnvPlatform.FREEBSD: _ENV_FREEBSD_SHORT_OPERANDS, + _EnvPlatform.DARWIN: _ENV_DARWIN_SHORT_OPERANDS, + }[platform] + cluster = static_prefix[1:] + for position, option in enumerate(cluster): + if option in short_flags: + continue + if option == "S": + return "ambiguous" + if option in short_operands: + has_static_operand = bool(cluster[position + 1 :] or argument.text[first_dynamic:]) + return "option" if has_static_operand else "branching_option" + return "invalid" + if platform is _EnvPlatform.GNU and argument.text.startswith("--") and "=" in static_prefix: + resolved = _resolve_env_long_option(argument.text) + if resolved is None: + return "invalid" + option, attached_operand = resolved + if option == "--split-string": + return "ambiguous" + if attached_operand is not None and option not in _ENV_SPLIT_LONG_FLAGS: + return "option" + return "invalid" + return "ambiguous" + + +def _fixed_dynamic_utility(argument: _EnvArgument, *, options_ended: bool = False) -> str | None: + """Return a utility basename fixed after all dynamic path segments.""" + first_dynamic = argument.dynamic_offsets[0] + static_prefix = argument.text[:first_dynamic] + static_suffix = argument.text[argument.dynamic_offsets[-1] :] + if ( + not static_prefix + or (static_prefix.startswith("-") and not options_ended) + or "/" not in static_suffix + ): + return None + basename = argument.text.rsplit("/", 1)[-1] + return basename or None + + +def _resolve_env_long_option(argument: str) -> tuple[str, str | None] | None: + """Resolve a GNU long option, including an unambiguous abbreviation.""" + option_name, separator, attached_operand = argument.partition("=") + if option_name in _ENV_SPLIT_LONG_OPTIONS: + resolved = option_name + else: + matches = [option for option in _ENV_SPLIT_LONG_OPTIONS if option.startswith(option_name)] + if len(matches) != 1: + return None + resolved = matches[0] + return resolved, attached_operand if separator else None + + +def _required_operand_continuations( + arguments: tuple[_EnvArgument, ...], operand_index: int +) -> set[int]: + """Return positions after consuming every viable required-operand branch.""" + continuations: set[int] = set() + index = operand_index + while index < len(arguments): + argument = arguments[index] + continuations.add(index + 1) + if not argument.dynamic_offsets or argument.retained_when_empty: + break + # A standalone unquoted ${VAR} is absent when VAR is unset. In that + # branch the option consumes the next retained argument instead. + index += 1 + return continuations + + +def _unset_operand_continuations( + arguments: tuple[_EnvArgument, ...], operand_index: int +) -> tuple[set[int], set[int], bool]: + """Return valid/invalid unset branches plus a missing-operand branch.""" + continuations: set[int] = set() + invalid_continuations: set[int] = set() + missing = operand_index >= len(arguments) + index = operand_index + while index < len(arguments): + argument = arguments[index] + can_succeed = "=" not in argument.text and bool(argument.text or argument.dynamic_offsets) + if can_succeed: + continuations.add(index + 1) + else: + invalid_continuations.add(index + 1) + if argument.dynamic_offsets: + # A substituted value can contain '='. A retained dynamic-only + # argument can also be empty; both make unsetenv reject before the + # utility is selected. + invalid_continuations.add(index + 1) + if not argument.dynamic_offsets or argument.retained_when_empty: + break + index += 1 + if index >= len(arguments): + missing = True + return continuations, invalid_continuations, missing + + +@dataclass(frozen=True, slots=True) +class _EnvParseState: + """One coherent platform parse, including GNU's deferred env actions.""" + + pending: tuple[_EnvArgument, ...] + index: int = 0 + options_ended: bool = False + split_count: int = 0 + clear_environment: bool = False + env0_from_file: bool = False + invalid_unset: bool = False + python_inspect: _PythonInspectEnvironment = _PythonInspectEnvironment.UNMENTIONED + python_inspect_unset: bool = False + login_environment: bool = False + + +def _nested_env_split_state( + state: _EnvParseState, + payload: _EnvArgument, + tail_index: int, + platform: _EnvPlatform, +) -> _EnvParseState | None: + """Build a restarted parser state for one static nested split.""" + if payload.dynamic_offsets: + return None + split_arguments = _split_env_arguments(payload.text, platform) + if split_arguments is None: + return None + return _EnvParseState( + pending=tuple(split_arguments) + state.pending[tail_index:], + split_count=state.split_count + 1, + clear_environment=state.clear_environment, + env0_from_file=state.env0_from_file, + invalid_unset=state.invalid_unset, + python_inspect=state.python_inspect, + python_inspect_unset=state.python_inspect_unset, + login_environment=state.login_environment, + ) + + +def _python_inspect_after_env_operand( + platform: _EnvPlatform, + option: str, + operand: str, + *, + current: _PythonInspectEnvironment, + unset_requested: bool, + login_environment: bool, +) -> tuple[_PythonInspectEnvironment, bool, bool]: + """Apply one exact env option that can determine launcher environment state.""" + if option == "u" and operand == "PYTHONINSPECT": + unset_requested = True + current = ( + _PythonInspectEnvironment.UNKNOWN + if platform is _EnvPlatform.FREEBSD and login_environment + else _PythonInspectEnvironment.ABSENT_OR_EMPTY + ) + elif platform is _EnvPlatform.FREEBSD and option in {"L", "U"}: + # FreeBSD constructs the login environment after clear/unset actions, + # irrespective of the options' textual order. + login_environment = True + current = _PythonInspectEnvironment.UNKNOWN + return current, unset_requested, login_environment + + +def _short_env_parse_states( + state: _EnvParseState, + cluster: str, + platform: _EnvPlatform, +) -> tuple[list[_EnvParseState], bool, bool]: + """Parse one short-option branch, retaining ambiguity and rejection.""" + pending = state.pending + index = state.index + clear_environment = state.clear_environment + python_inspect = state.python_inspect + python_inspect_unset = state.python_inspect_unset + login_environment = state.login_environment + short_flags = { + _EnvPlatform.GNU: _ENV_GNU_SHORT_FLAGS, + _EnvPlatform.FREEBSD: _ENV_FREEBSD_SHORT_FLAGS, + _EnvPlatform.DARWIN: _ENV_DARWIN_SHORT_FLAGS, + }[platform] + short_operands = { + _EnvPlatform.GNU: _ENV_GNU_SHORT_OPERANDS, + _EnvPlatform.FREEBSD: _ENV_FREEBSD_SHORT_OPERANDS, + _EnvPlatform.DARWIN: _ENV_DARWIN_SHORT_OPERANDS, + }[platform] + for position, option in enumerate(cluster): + if option in short_flags: + if option == "i": + clear_environment = True + python_inspect = ( + _PythonInspectEnvironment.UNKNOWN + if platform is _EnvPlatform.FREEBSD and login_environment + else _PythonInspectEnvironment.ABSENT_OR_EMPTY + ) + continue + if option == "0": + # Both implementations reject combining null-delimited output + # with a utility, so this branch cannot execute source payload. + return [], False, True + if option == "S": + if position + 1 < len(cluster): + payload = _EnvArgument(cluster[position + 1 :]) + tail_index = index + 1 + elif index + 1 < len(pending): + payload = pending[index + 1] + tail_index = index + 2 + else: + return [], False, True + nested_state = _nested_env_split_state( + _EnvParseState( + pending=pending, + index=index, + options_ended=state.options_ended, + split_count=state.split_count, + clear_environment=clear_environment, + env0_from_file=state.env0_from_file, + invalid_unset=state.invalid_unset, + python_inspect=python_inspect, + python_inspect_unset=python_inspect_unset, + login_environment=login_environment, + ), + payload, + tail_index, + platform, + ) + if nested_state is None: + return [], bool(payload.dynamic_offsets), not payload.dynamic_offsets + return [nested_state], False, False + if option not in short_operands: + return [], False, True + if position + 1 < len(cluster): + operand = cluster[position + 1 :] + if option == "u" and (not operand or "=" in operand): + # BSD getopt accepts GNU-looking ``--unset=NAME`` as the + # short spelling ``-u nset=NAME``. env then rejects that + # statically invalid variable name instead of executing the + # following utility. + if platform is not _EnvPlatform.GNU: + return [], False, True + return ( + [ + _EnvParseState( + pending=pending, + index=index + 1, + split_count=state.split_count, + clear_environment=clear_environment, + env0_from_file=state.env0_from_file, + invalid_unset=True, + python_inspect=python_inspect, + python_inspect_unset=python_inspect_unset, + login_environment=login_environment, + ) + ], + False, + False, + ) + ( + next_python_inspect, + next_python_inspect_unset, + next_login_environment, + ) = _python_inspect_after_env_operand( + platform, + option, + operand, + current=python_inspect, + unset_requested=python_inspect_unset, + login_environment=login_environment, + ) + return ( + [ + _EnvParseState( + pending=pending, + index=index + 1, + split_count=state.split_count, + clear_environment=clear_environment, + env0_from_file=state.env0_from_file, + invalid_unset=state.invalid_unset, + python_inspect=next_python_inspect, + python_inspect_unset=next_python_inspect_unset, + login_environment=next_login_environment, + ) + ], + False, + False, + ) + if option == "u": + continuations, invalid_continuations, missing = _unset_operand_continuations( + pending, index + 1 + ) + else: + continuations = _required_operand_continuations(pending, index + 1) + invalid_continuations = set() + missing = not continuations + states: list[_EnvParseState] = [] + for continuation in continuations: + operand = pending[continuation - 1].text + ( + next_python_inspect, + next_python_inspect_unset, + next_login_environment, + ) = _python_inspect_after_env_operand( + platform, + option, + operand, + current=python_inspect, + unset_requested=python_inspect_unset, + login_environment=login_environment, + ) + states.append( + _EnvParseState( + pending=pending, + index=continuation, + split_count=state.split_count, + clear_environment=clear_environment, + env0_from_file=state.env0_from_file, + invalid_unset=state.invalid_unset, + python_inspect=next_python_inspect, + python_inspect_unset=next_python_inspect_unset, + login_environment=next_login_environment, + ) + ) + if platform is _EnvPlatform.GNU: + states.extend( + _EnvParseState( + pending=pending, + index=continuation, + split_count=state.split_count, + clear_environment=clear_environment, + env0_from_file=state.env0_from_file, + invalid_unset=True, + python_inspect=python_inspect, + python_inspect_unset=python_inspect_unset, + login_environment=login_environment, + ) + for continuation in invalid_continuations + ) + return ( + states, + False, + missing or (bool(invalid_continuations) and platform is not _EnvPlatform.GNU), + ) + return ( + [ + _EnvParseState( + pending=pending, + index=index + 1, + split_count=state.split_count, + clear_environment=clear_environment, + env0_from_file=state.env0_from_file, + invalid_unset=state.invalid_unset, + python_inspect=python_inspect, + python_inspect_unset=python_inspect_unset, + login_environment=login_environment, + ) + ], + False, + False, + ) + + +def _env_split_executions( + arguments: list[_EnvArgument], + platform: _EnvPlatform, + *, + clear_environment: bool = False, +) -> tuple[set[_EnvExecution], bool, bool]: + """Return viable executions, runtime ambiguity, and reachable nonexecution.""" + stack = [ + _EnvParseState( + pending=tuple(arguments), + clear_environment=clear_environment, + python_inspect=( + _PythonInspectEnvironment.ABSENT_OR_EMPTY + if clear_environment + else _PythonInspectEnvironment.UNMENTIONED + ), + ) + ] + seen: set[_EnvParseState] = set() + executions: set[_EnvExecution] = set() + ambiguous = False + nonexecuting = False + + while stack: + state = stack.pop() + if state in seen: + continue + seen.add(state) + if len(seen) > _MAX_ENV_PARSE_STATES: + return executions, True, nonexecuting + + pending = state.pending + index = state.index + options_ended = state.options_ended + split_count = state.split_count + if index >= len(pending): + # With no utility selected from the inspected arguments, env sees + # the kernel-appended artifact as its utility. That branch does + # not establish Python execution intent and must survive beside a + # different runtime branch that did select Python. + nonexecuting = True + continue + if split_count > MAX_PYTHON_SHEBANG_CHARS: + ambiguous = True + continue + + argument_record = pending[index] + argument = argument_record.text + if argument_record.dynamic_offsets: + if options_ended and "=" in argument: + # Once option parsing has ended, a statically present '=' + # makes this an assignment even when its name begins with '-'. + stack.append(replace(state, index=index + 1, options_ended=True)) + continue + dynamic_role = _dynamic_env_argument_role(argument_record, platform) + if dynamic_role == "assignment": + stack.append(replace(state, index=index + 1, options_ended=True)) + continue + if not options_ended and dynamic_role == "option": + stack.append(replace(state, index=index + 1, options_ended=False)) + continue + if not options_ended and dynamic_role == "branching_option": + # A nonempty expansion is an attached operand. If every + # expansion is empty, the bare option consumes the next + # runtime-present argument instead. + stack.append(replace(state, index=index + 1, options_ended=False)) + stack.extend( + replace(state, index=continuation, options_ended=False) + for continuation in _required_operand_continuations(pending, index + 1) + ) + continue + if not options_ended and dynamic_role == "invalid": + nonexecuting = True + continue + fixed_utility = _fixed_dynamic_utility(argument_record, options_ended=options_ended) + if fixed_utility is not None: + if state.invalid_unset and not ( + platform is _EnvPlatform.GNU + and state.clear_environment + and not state.env0_from_file + ): + nonexecuting = True + else: + executions.add( + _EnvExecution( + fixed_utility, + pending[index + 1 :], + python_inspect=state.python_inspect, + ) + ) + # A runtime '=' can instead make this token an assignment. + # Continue that neutral/following-utility branch explicitly. + stack.append(replace(state, index=index + 1, options_ended=True)) + continue + ambiguous = True + continue + + if not options_ended: + if argument == "--": + next_index = index + 1 + clear_after_terminator = state.clear_environment + if ( + platform is _EnvPlatform.GNU + and next_index < len(pending) + and pending[next_index].text == "-" + and not pending[next_index].dynamic_offsets + ): + # GNU accepts its legacy lone ``-`` ignore-environment + # spelling immediately after getopt's ``--`` terminator. + # This check belongs here rather than in the general + # options-ended path: a second ``-`` or one following an + # assignment is the selected utility instead. + next_index += 1 + clear_after_terminator = True + stack.append( + replace( + state, + index=next_index, + options_ended=True, + clear_environment=clear_after_terminator, + python_inspect=( + _PythonInspectEnvironment.ABSENT_OR_EMPTY + if clear_after_terminator + else state.python_inspect + ), + ) + ) + continue + if argument == "-": + # GNU stops parsing after this legacy -i spelling; FreeBSD's + # getopt treats it as an ordinary flag and keeps scanning. + stack.append( + replace( + state, + index=index + 1, + options_ended=platform is _EnvPlatform.GNU, + clear_environment=True, + python_inspect=( + _PythonInspectEnvironment.UNKNOWN + if platform is _EnvPlatform.FREEBSD and state.login_environment + else _PythonInspectEnvironment.ABSENT_OR_EMPTY + ), + ) + ) + continue + if platform is _EnvPlatform.GNU and argument.startswith("--"): + resolved = _resolve_env_long_option(argument) + if resolved is None: + nonexecuting = True + continue + option, attached_operand = resolved + if option in _ENV_SPLIT_LONG_FLAGS: + if attached_operand is None: + stack.append( + replace( + state, + index=index + 1, + options_ended=False, + clear_environment=( + state.clear_environment or option == "--ignore-environment" + ), + python_inspect=( + _PythonInspectEnvironment.ABSENT_OR_EMPTY + if option == "--ignore-environment" + else state.python_inspect + ), + ) + ) + else: + nonexecuting = True + elif option == "--split-string": + if attached_operand is not None: + payload = _EnvArgument(attached_operand) + tail_index = index + 1 + elif index + 1 < len(pending): + payload = pending[index + 1] + tail_index = index + 2 + else: + payload = None + if payload is None: + nonexecuting = True + else: + nested_state = _nested_env_split_state(state, payload, tail_index, platform) + if nested_state is None: + if payload.dynamic_offsets: + ambiguous = True + else: + nonexecuting = True + else: + stack.append(nested_state) + elif option in _ENV_SPLIT_LONG_OPERANDS: + env0_from_file = state.env0_from_file or option == "--env0-from" + if attached_operand is not None: + if option != "--unset" or ( + attached_operand and "=" not in attached_operand + ): + python_inspect = state.python_inspect + python_inspect_unset = state.python_inspect_unset + login_environment = state.login_environment + if option == "--env0-from": + python_inspect = ( + _PythonInspectEnvironment.ABSENT_OR_EMPTY + if python_inspect_unset + else _PythonInspectEnvironment.UNKNOWN + ) + elif option == "--unset": + ( + python_inspect, + python_inspect_unset, + login_environment, + ) = _python_inspect_after_env_operand( + platform, + "u", + attached_operand, + current=python_inspect, + unset_requested=python_inspect_unset, + login_environment=login_environment, + ) + stack.append( + replace( + state, + index=index + 1, + options_ended=False, + env0_from_file=env0_from_file, + python_inspect=python_inspect, + python_inspect_unset=python_inspect_unset, + login_environment=login_environment, + ) + ) + elif platform is _EnvPlatform.GNU: + stack.append( + replace( + state, + index=index + 1, + options_ended=False, + env0_from_file=env0_from_file, + invalid_unset=True, + ) + ) + else: + nonexecuting = True + else: + if option == "--unset": + ( + continuations, + invalid_continuations, + missing, + ) = _unset_operand_continuations(pending, index + 1) + else: + continuations = _required_operand_continuations(pending, index + 1) + invalid_continuations = set() + missing = not continuations + nonexecuting = ( + nonexecuting + or missing + or (bool(invalid_continuations) and platform is not _EnvPlatform.GNU) + ) + for continuation in continuations: + operand = pending[continuation - 1].text + python_inspect = state.python_inspect + python_inspect_unset = state.python_inspect_unset + login_environment = state.login_environment + if option == "--env0-from": + python_inspect = ( + _PythonInspectEnvironment.ABSENT_OR_EMPTY + if python_inspect_unset + else _PythonInspectEnvironment.UNKNOWN + ) + elif option == "--unset": + ( + python_inspect, + python_inspect_unset, + login_environment, + ) = _python_inspect_after_env_operand( + platform, + "u", + operand, + current=python_inspect, + unset_requested=python_inspect_unset, + login_environment=login_environment, + ) + stack.append( + replace( + state, + index=continuation, + options_ended=False, + env0_from_file=env0_from_file, + python_inspect=python_inspect, + python_inspect_unset=python_inspect_unset, + login_environment=login_environment, + ) + ) + if platform is _EnvPlatform.GNU: + stack.extend( + replace( + state, + index=continuation, + options_ended=False, + env0_from_file=env0_from_file, + invalid_unset=True, + ) + for continuation in invalid_continuations + ) + else: + # GNU optional long-option operands are accepted only with '='. + stack.append(replace(state, index=index + 1, options_ended=False)) + + continue + if argument.startswith("-"): + short_states, short_ambiguity, short_nonexecution = _short_env_parse_states( + state, argument[1:], platform + ) + stack.extend(short_states) + ambiguous = ambiguous or short_ambiguity + nonexecuting = nonexecuting or short_nonexecution + continue + if "=" in argument: + # GNU's putenv-backed implementation accepts an empty variable + # name, while the BSD setenv-backed implementations reject it. + # Retain both outcomes so GNU-only execution remains incomplete. + name, _, value = argument.partition("=") + if name or platform is _EnvPlatform.GNU: + stack.append( + replace( + state, + index=index + 1, + options_ended=True, + python_inspect=( + _PythonInspectEnvironment.NONEMPTY + if name == "PYTHONINSPECT" and value + else _PythonInspectEnvironment.ABSENT_OR_EMPTY + if name == "PYTHONINSPECT" + else state.python_inspect + ), + python_inspect_unset=( + False if name == "PYTHONINSPECT" else state.python_inspect_unset + ), + login_environment=( + False if name == "PYTHONINSPECT" else state.login_environment + ), + ) + ) + else: + nonexecuting = True + continue + if argument: + if state.invalid_unset and not ( + platform is _EnvPlatform.GNU + and state.clear_environment + and not state.env0_from_file + ): + nonexecuting = True + else: + executions.add( + _EnvExecution( + argument, + pending[index + 1 :], + python_inspect=state.python_inspect, + ) + ) + else: + nonexecuting = True + + return executions, ambiguous, nonexecuting + + +_PYTHON_NO_OPERAND_SHORT_OPTIONS = frozenset("bBdEiIOPqRsStuvx") +_PYTHON_TERMINAL_SHORT_OPTIONS = frozenset("?hV") +_PYTHON_VERSION_DEPENDENT_SHORT_OPTIONS = frozenset("IPq") +_PYTHON_HASH_BASED_PYCS_VALUES = frozenset({"always", "default", "never"}) +_PYTHON_ENCODING_COOKIE = re.compile(rb"^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)") +_PYTHON_BLANK_OR_COMMENT_LINE = re.compile(rb"^[ \t\f]*(?:[#\r\n]|$)") +_PYTHON_PHYSICAL_LINE_END = re.compile(rb"\r\n?|\n") + + +def _python_script_argument_executes_source( + argument: _EnvArgument, source_path: str +) -> bool | None: + """Resolve a selected Python script argument against the analyzed artifact.""" + if argument.dynamic_offsets: + return None + if not argument.text: + return False + # The caller's working directory is not part of bundle analysis. Even an + # identically spelled relative argument can therefore select a different + # script when the inspected artifact itself was invoked by absolute path. + if not posixpath.isabs(argument.text) or not posixpath.isabs(source_path): + return None + # Shebang argv uses POSIX path semantics: a backslash is a literal filename + # character, and collapsing ``..`` can cross a symlink into a different + # script. Dot/repeated-separator normalization is safe only when neither + # spelling contains a parent traversal. + if ".." in argument.text.split("/") or ".." in source_path.split("/"): + return None + normalized_argument = posixpath.normpath(argument.text) + normalized_source = posixpath.normpath(source_path) + if normalized_argument == normalized_source: + return True + if ( + unicodedata.normalize("NFD", normalized_argument).casefold() + == unicodedata.normalize("NFD", normalized_source).casefold() + ): + # Default macOS volumes resolve case and canonical Unicode variants to + # the same artifact, while case-sensitive Unix volumes may not. + return None + argument_basename = normalized_argument.rsplit("/", 1)[-1] + source_basename = normalized_source.rsplit("/", 1)[-1] + if argument_basename and unicodedata.normalize("NFD", argument_basename).casefold() == ( + unicodedata.normalize("NFD", source_basename).casefold() + ): + # The caller's working directory, an absolute bundle root, or an alias + # can make this the same file, but that identity is not statically known. + return None + # Any selected Python script receives the kernel-appended artifact in argv + # and can load/execute it (the stdlib ``trace.py`` entrypoint is one concrete + # example). Without inspecting that external script, non-self paths are + # therefore unresolved rather than proof that this source is not executed. + return None + + +def _implicit_python_source_path_can_be_an_option(source_path: str) -> bool: + """Return whether one valid relative invocation starts the path with ``-``.""" + return any(component.startswith("-") for component in source_path.split("/") if component) + + +def _python_command_source_spelling_may_execute(source_path: str) -> bool: + """Return whether a bare ``-c`` source spelling is not provably inert.""" + basename = source_path.rsplit("/", 1)[-1] + candidates = tuple(dict.fromkeys((source_path, basename))) + for candidate in candidates: + if len(candidate) > _MAX_PYTHON_CONSUMED_SOURCE_SPELLING_CHARS: + return True + try: + parsed = ast.parse(candidate, filename="", mode="exec") + except (SyntaxError, ValueError): + continue + except (MemoryError, RecursionError): + return True + if not all( + isinstance(statement, ast.Pass) + or ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, (ast.Constant, ast.Name)) + ) + for statement in parsed.body + ): + return True + return False + + +def _python_arguments_execute_appended_script( + arguments: tuple[_EnvArgument, ...], + source_path: str, + *, + environment_inspect: _PythonInspectEnvironment = _PythonInspectEnvironment.UNMENTIONED, +) -> bool | None: + """Classify whether Python consumes all arguments before the script path.""" + index = 0 + version_dependent = False + forced_interactive = False + ignore_environment = False + + def merge_version_branches(result: bool | None) -> bool | None: + if version_dependent and result is True: + return None + return result + + while index < len(arguments): + argument = arguments[index] + if argument.dynamic_offsets: + return None + value = argument.text + if value == "--": + tail = arguments[index + 1 :] + if not tail: + return merge_version_branches(True) + first = tail[0] + if first.dynamic_offsets: + # A retained expansion selects a runtime path; an unquoted + # empty expansion can instead expose a later/appended path. + return None + return merge_version_branches( + _python_script_argument_executes_source(first, source_path) + ) + if value == "-": + # Stdin is the selected program, but the kernel-appended artifact + # remains in ``sys.argv``. Caller-supplied stdin can therefore + # load and execute that path just like a supplied ``-c`` command + # or external Python script. + return None + if value in { + "--help", + "--help-all", + "--help-env", + "--help-xoptions", + "--version", + }: + return False + if value == "--check-hash-based-pycs": + if index + 1 >= len(arguments): + # The kernel-appended invocation spelling becomes this option's + # operand. A source whose basename is one of the accepted values + # can be invoked from its containing directory by that basename; + # Python then falls through to stdin, which can recover the path + # from ``_imp.check_hash_based_pycs`` and execute it. Preserve + # case-insensitive filesystem aliases as the same viable branch. + source_basename = source_path.rsplit("/", 1)[-1] + return ( + None if source_basename.casefold() in _PYTHON_HASH_BASED_PYCS_VALUES else False + ) + if arguments[index + 1].dynamic_offsets: + return None + if arguments[index + 1].text not in _PYTHON_HASH_BASED_PYCS_VALUES: + return False + version_dependent = True + index += 2 + continue + if value.startswith("--check-hash-based-pycs="): + return False + if not value.startswith("-"): + return merge_version_branches( + _python_script_argument_executes_source(argument, source_path) + ) + if value.startswith("--"): + return None + + cluster = value[1:] + position = 0 + while position < len(cluster): + option = cluster[position] + if option == "i": + forced_interactive = True + if option in {"E", "I"}: + ignore_environment = True + if option in _PYTHON_VERSION_DEPENDENT_SHORT_OPTIONS: + version_dependent = True + position += 1 + continue + if option in _PYTHON_NO_OPERAND_SHORT_OPTIONS: + position += 1 + continue + if option in _PYTHON_TERMINAL_SHORT_OPTIONS: + return False + if option in {"c", "m"}: + # A bare option consumes the kernel-appended source path as + # command/module text and does not load the file. An attached + # or explicitly supplied command/module leaves that path in + # argv, where arbitrary code may read and execute it. + if ( + forced_interactive + or ( + environment_inspect + in { + _PythonInspectEnvironment.NONEMPTY, + _PythonInspectEnvironment.UNKNOWN, + } + and not ignore_environment + ) + or position + 1 < len(cluster) + or index + 1 < len(arguments) + ): + return None + if option == "m" or _python_command_source_spelling_may_execute(source_path): + return None + return False + if option in {"W", "X"}: + if position + 1 < len(cluster): + position = len(cluster) + continue + if index + 1 >= len(arguments): + # The appended artifact becomes the option operand and + # Python falls through to stdin. Stdin code can recover + # that path from ``sys.warnoptions`` or ``sys._xoptions`` + # and execute it, so applicability remains unresolved. + return None + if arguments[index + 1].dynamic_offsets: + return None + index += 1 + position = len(cluster) + continue + return None + index += 1 + if _implicit_python_source_path_can_be_an_option(source_path): + return None + return merge_version_branches(True) + + +def _uv_arguments_execute_appended_script( + arguments: tuple[_EnvArgument, ...], + source_path: str, + *, + allow_filesystem_aliases: bool = False, +) -> bool | None: + """Classify bounded ``uv run`` forms that can consume the source path. + + ``uv run -s``, ``uv run --script``, and ``uv run --gui-script`` mark the + following positional command as Python. In a shebang the kernel appends + the inspected artifact after these arguments, so an exact trailing + selector executes that artifact as Python unless one valid relative + invocation spelling can be parsed as another option. Other ``uv`` argv + shapes stay unresolved because global options can precede ``run``, the + appended path can itself become a command, or an explicit command/script + can receive it and load it from argv. Exact terminal version queries and + bare launchers whose appended source cannot select ``run`` or an option are + certified as non-executing. + """ + if any(argument.dynamic_offsets for argument in arguments): + return None + if not arguments: + source_basename = source_path.rsplit("/", 1)[-1] + source_selects_run = source_basename == "run" or ( + allow_filesystem_aliases + and unicodedata.normalize("NFD", source_basename).casefold() == "run" + ) + if source_selects_run or _implicit_python_source_path_can_be_an_option(source_path): + # The kernel-appended source can become the ``run`` subcommand, or + # a global option that leaves caller-supplied argv to select it. + # Later arguments can then choose this artifact with ``--script``. + return None + return False + + selectors = {"-s", "--script", "--gui-script"} + if len(arguments) == 2 and arguments[0].text == "run" and arguments[1].text in selectors: + return None if _implicit_python_source_path_can_be_an_option(source_path) else True + if len(arguments) == 1 and arguments[0].text in {"-V", "--version"}: + return False + return None + + +def _record_source_execution( + execution: _EnvExecution, + execution_kinds: set[bool], + source_path: str, + *, + allow_filesystem_aliases: bool = False, +) -> bool: + """Record a viable source interpreter and return unresolved Python argv.""" + is_python_interpreter = _is_python_interpreter(execution.utility) or ( + allow_filesystem_aliases and _is_python_interpreter_filesystem_alias(execution.utility) + ) + if is_python_interpreter: + executes_script = _python_arguments_execute_appended_script( + execution.arguments, + source_path, + environment_inspect=execution.python_inspect, + ) + elif _is_uv_launcher( + execution.utility, + allow_filesystem_aliases=allow_filesystem_aliases, + ): + executes_script = _uv_arguments_execute_appended_script( + execution.arguments, + source_path, + allow_filesystem_aliases=allow_filesystem_aliases, + ) + else: + execution_kinds.add(False) + return False + if executes_script is True: + execution_kinds.add(True) + elif executes_script is False: + execution_kinds.add(False) + return executes_script is None + + +def _source_classification_from_executions( + execution_kinds: set[bool], *, ambiguous: bool +) -> PythonSourceClassification: + """Merge successful interpreter identities and unresolved argv branches.""" + if ambiguous or len(execution_kinds) > 1: + return PythonSourceClassification.AMBIGUOUS + if execution_kinds == {True}: + return PythonSourceClassification.PYTHON + return PythonSourceClassification.NON_PYTHON + + +def _darwin_shebang_tokens(command_line: str) -> list[str]: + """Tokenize the XNU shebang branch, where ``#`` ends the line.""" + xnu_line = command_line.partition("#")[0].strip(" \t") + return re.split(r"[ \t]+", xnu_line) if xnu_line else [] + + +def _env_split_payload(command_line: str, platform: _EnvPlatform) -> tuple[str, bool] | None: + """Extract an outer split string and its already-applied clear-env flag.""" + short_pattern = ( + _ENV_GNU_SPLIT_SHORT_PREFIX + if platform is _EnvPlatform.GNU + else _ENV_FREEBSD_SPLIT_SHORT_PREFIX + ) + short_prefix = short_pattern.match(command_line) + if short_prefix is not None: + return command_line[short_prefix.end() :], "i" in short_prefix.group() + if platform is _EnvPlatform.GNU: + resolved = _resolve_env_long_option(command_line) + if resolved is not None: + option, attached_operand = resolved + if option == "--split-string" and attached_operand is not None: + return attached_operand, False + return None + + +def _classify_python_source_platforms( + path: str, content: str | bytes | None = None +) -> PythonSourceClassification: + """Classify Python execution intent from a path and bounded metadata. + + Normal Python source extensions are authoritative. Other paths qualify + only through a short shebang naming a Python interpreter directly + or through a conventional system ``env`` launcher. Runtime-dependent + ``env -S`` expansion is retained as ambiguous rather than guessed. + """ + if _has_python_source_extension(path): + return PythonSourceClassification.PYTHON + + line = _bounded_shebang_line(content) + if line is None: + return ( + PythonSourceClassification.AMBIGUOUS + if _has_overlong_shebang(content) + else PythonSourceClassification.NON_PYTHON + ) + if not line.startswith("#!"): + return PythonSourceClassification.NON_PYTHON + command_line = line[2:].lstrip(" \t") + if not command_line.startswith("/"): + return PythonSourceClassification.NON_PYTHON + tokens = re.split(r"[ \t]+", command_line.strip(" \t")) + if not tokens or not tokens[0]: + return PythonSourceClassification.NON_PYTHON + darwin_tokens = _darwin_shebang_tokens(command_line) + if tokens[0] not in _TRUSTED_ENV_PATHS: + execution_kinds: set[bool] = set() + opaque_argument = command_line[len(tokens[0]) :].strip(" \t") + opaque_execution = _EnvExecution( + tokens[0], + (_EnvArgument(opaque_argument),) if opaque_argument else (), + ) + execution_ambiguity = _record_source_execution(opaque_execution, execution_kinds, path) + if ( + darwin_tokens + and darwin_tokens[0].startswith("/") + and _is_trusted_env_filesystem_alias(darwin_tokens[0]) + ): + darwin_executions, darwin_ambiguity, darwin_nonexecution = _env_split_executions( + [_EnvArgument(argument) for argument in darwin_tokens[1:]], + _EnvPlatform.DARWIN, + ) + if darwin_nonexecution: + execution_kinds.add(False) + for darwin_execution in darwin_executions: + execution_ambiguity = ( + _record_source_execution( + darwin_execution, + execution_kinds, + path, + allow_filesystem_aliases=True, + ) + or execution_ambiguity + ) + execution_ambiguity = execution_ambiguity or darwin_ambiguity + elif darwin_tokens and darwin_tokens[0].startswith("/"): + darwin_execution = _EnvExecution( + darwin_tokens[0], + tuple(_EnvArgument(argument) for argument in darwin_tokens[1:]), + ) + execution_ambiguity = ( + _record_source_execution( + darwin_execution, + execution_kinds, + path, + allow_filesystem_aliases=True, + ) + or execution_ambiguity + ) + return _source_classification_from_executions( + execution_kinds, ambiguous=execution_ambiguity + ) + + env_command_line = command_line[len(tokens[0]) :].lstrip(" \t") + execution_kinds = set() + runtime_ambiguity = False + for platform in (_EnvPlatform.GNU, _EnvPlatform.FREEBSD): + split = _env_split_payload(env_command_line, platform) + initial_clear_environment = False + if split is not None: + split_payload, initial_clear_environment = split + env_arguments = _split_env_arguments(split_payload, platform) + if env_arguments is None: + execution_kinds.add(False) + continue + if any(argument.dynamic_offsets for argument in env_arguments): + # A valid env -S substitution can alter token boundaries, + # option operands, assignment roles, or the selected utility. + # Analyze the source, but never certify every runtime branch + # as Python without knowing the environment. + return PythonSourceClassification.AMBIGUOUS + elif env_command_line: + # Linux and FreeBSD kernels pass the entire optional shebang + # argument opaquely. Feed that one argv item through this + # platform's option/assignment parser even when another platform + # recognized an outer split form: a GNU-only spelling, for + # example, is an invalid non-executing branch on FreeBSD. + env_arguments = [_EnvArgument(env_command_line)] + else: + continue + platform_executions, platform_ambiguity, platform_nonexecution = _env_split_executions( + env_arguments, + platform, + clear_environment=initial_clear_environment, + ) + if platform_nonexecution: + execution_kinds.add(False) + for execution in platform_executions: + runtime_ambiguity = ( + _record_source_execution(execution, execution_kinds, path) or runtime_ambiguity + ) + runtime_ambiguity = runtime_ambiguity or platform_ambiguity + + # XNU tokenizes all interpreter-line arguments, with '#' ending the line, + # instead of passing one opaque optional argument. Preserve each selected + # utility's argv so a preceding Python option (for example ``-I``) is not + # confused with a preceding script name that would prevent this file from + # being executed. + if darwin_tokens and darwin_tokens[0] in _TRUSTED_ENV_PATHS: + darwin_executions, darwin_ambiguity, darwin_nonexecution = _env_split_executions( + [_EnvArgument(argument) for argument in darwin_tokens[1:]], + _EnvPlatform.DARWIN, + ) + if darwin_nonexecution: + execution_kinds.add(False) + for execution in darwin_executions: + runtime_ambiguity = ( + _record_source_execution( + execution, + execution_kinds, + path, + allow_filesystem_aliases=True, + ) + or runtime_ambiguity + ) + runtime_ambiguity = runtime_ambiguity or darwin_ambiguity + + return _source_classification_from_executions(execution_kinds, ambiguous=runtime_ambiguity) + + +def classify_python_source( + path: str, content: str | bytes | None = None +) -> PythonSourceClassification: + """Classify Python source across supported interpreter-line semantics.""" + full_line = _classify_python_source_platforms(path, content) + if _has_python_source_extension(path): + return full_line + platform_views = {full_line} + for buffer_bytes in _LINUX_SHEBANG_BUFFER_SIZES: + was_truncated, linux_content = _linux_truncated_shebang(content, buffer_bytes) + if not was_truncated: + continue + if linux_content is None: + return PythonSourceClassification.AMBIGUOUS + platform_views.add(_classify_python_source_platforms(path, linux_content)) + return full_line if len(platform_views) == 1 else PythonSourceClassification.AMBIGUOUS + + +def is_python_source(path: str, content: str | bytes | None = None) -> bool: + """Return whether bounded metadata definitively identifies Python source.""" + return classify_python_source(path, content) is PythonSourceClassification.PYTHON + + +def may_be_python_source(path: str, content: str | bytes | None = None) -> bool: + """Return whether Python analysis is required, including ambiguous execution metadata.""" + return classify_python_source(path, content) is not PythonSourceClassification.NON_PYTHON + + +def resolve_python_source_classification( + path: str, + content: str | bytes | None = None, + *, + source_classifications: Mapping[str, PythonSourceClassification | str] | None = None, + raw_file_cache: Mapping[str, bytes] | None = None, +) -> PythonSourceClassification: + """Resolve one source identity, preferring cached byte-derived classification. + + Build-context classification is the canonical decision shared by analyzer + branches. Falling back keeps direct analyzer/unit invocations compatible, + while raw bytes preserve kernel shebang limits that a lossy text projection + can shift. + """ + if source_classifications is not None: + cached = source_classifications.get(path) + if cached is not None: + try: + return PythonSourceClassification(cached) + except (TypeError, ValueError): + return PythonSourceClassification.AMBIGUOUS + raw_content = raw_file_cache.get(path) if raw_file_cache is not None else None + return classify_python_source(path, raw_content if raw_content is not None else content) + + +def _normalize_python_source_encoding(encoding: bytes) -> str: + """Apply CPython's bounded UTF-8 and Latin-1 cookie normalization.""" + original = encoding.decode("ascii") + normalized = original[:12].lower().replace("_", "-") + if normalized == "utf-8" or normalized.startswith("utf-8-"): + return "utf-8" + if normalized in {"latin-1", "iso-8859-1", "iso-latin-1"} or normalized.startswith( + ("latin-1-", "iso-8859-1-", "iso-latin-1-") + ): + return "iso-8859-1" + return original + + +def _normalize_python_source_newlines(content: bytes) -> bytes: + """Apply CPython's raw universal-newline pass before source decoding.""" + if b"\r" not in content: + return content + return content.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + + +def _first_two_python_source_lines(content: bytes) -> tuple[bytes, bytes]: + """Return at most two universal-newline physical lines.""" + first_match = _PYTHON_PHYSICAL_LINE_END.search(content) + if first_match is None: + return content, b"" + first_end = first_match.end() + second_match = _PYTHON_PHYSICAL_LINE_END.search(content, first_end) + if second_match is None: + return content[:first_end], content[first_end:] + return content[:first_end], content[first_end : second_match.end()] + + +def _python_source_cookie(line: bytes, *, bom_found: bool) -> str | None: + """Resolve one bytes-level PEP 263 cookie using CPython 3.14 semantics.""" + match = _PYTHON_ENCODING_COOKIE.match(line) + if match is None: + return None + encoding = _normalize_python_source_encoding(match.group(1)) + try: + codecs.lookup(encoding) + except LookupError as exc: + raise SyntaxError(f"unknown encoding: {encoding}") from exc + if bom_found: + if encoding != "utf-8": + raise SyntaxError("encoding problem: utf-8") + return "utf-8-sig" + return encoding + + +def _validate_python_source_prefix(content: bytes, encoding: str) -> None: + """Reject a prefix that CPython cannot decode under the selected encoding.""" + try: + content.decode(encoding) + except UnicodeDecodeError as exc: + raise SyntaxError("invalid or missing encoding declaration") from exc + + +def _detect_python_source_encoding(content: bytes) -> str: + """Detect PEP 263 encoding independently of the scanner host's Python.""" + first, second = _first_two_python_source_lines(content) + bom_found = first.startswith(codecs.BOM_UTF8) + if bom_found: + first = first[len(codecs.BOM_UTF8) :] + default = "utf-8-sig" if bom_found else "utf-8" + if not first: + return default + + encoding = _python_source_cookie(first, bom_found=bom_found) + if encoding is not None: + _validate_python_source_prefix(first, encoding) + return encoding + if _PYTHON_BLANK_OR_COMMENT_LINE.match(first) is None: + _validate_python_source_prefix(first, default) + return default + if not second: + _validate_python_source_prefix(first, default) + return default + + encoding = _python_source_cookie(second, bom_found=bom_found) + if encoding is not None: + _validate_python_source_prefix(first + second, encoding) + return encoding + _validate_python_source_prefix(first + second, default) + return default + + +def decode_python_source(content: bytes) -> str: + """Decode raw source with stable CPython 3.14 PEP 263 rules.""" + if b"\0" in content: + raise SyntaxError("Python source contains a null byte") + normalized = _normalize_python_source_newlines(content) + encoding = _detect_python_source_encoding(normalized) + decoded = normalized.decode(encoding) + if "\0" in decoded: + raise SyntaxError("Python source decoder produced a null character") + # ``str`` AST consumers and report byte accounting require scalar Unicode; + # CPython likewise rejects source decoders that produce lone surrogates. + decoded.encode("utf-8") + return decoded @dataclass(frozen=True, slots=True) @@ -233,6 +1934,8 @@ def build_python_ast_cache( components: Iterable[str], file_cache: Mapping[str, str], *, + raw_file_cache: Mapping[str, bytes] | None = None, + source_classifications: Mapping[str, PythonSourceClassification | str] | None = None, max_source_chars: int = MAX_PYTHON_AST_SOURCE_CHARS, max_cache_source_chars: int = MAX_PYTHON_AST_CACHE_SOURCE_CHARS, clock: Callable[[], float] = time.monotonic, @@ -256,17 +1959,25 @@ def _expired(path: str) -> bool: return True for path in components: - if not path.lower().endswith(".py"): - continue + if _expired(path): + break content = file_cache.get(path) + if content is None: + continue + source_classification = resolve_python_source_classification( + path, + content, + source_classifications=source_classifications, + raw_file_cache=raw_file_cache, + ) + if _expired(path): + break if ( - content is None + source_classification is PythonSourceClassification.NON_PYTHON or len(content) > max_source_chars or source_characters + len(content) > max_cache_source_chars ): continue - if _expired(path): - break cache[path] = parse_python_source(content, path) source_characters += len(content) if _expired(path): @@ -278,6 +1989,8 @@ def prewarm_python_ast_cache( components: Iterable[str], file_cache: Mapping[str, str], *, + raw_file_cache: Mapping[str, bytes] | None = None, + source_classifications: Mapping[str, PythonSourceClassification | str] | None = None, max_source_chars: int = MAX_PYTHON_AST_SOURCE_CHARS, max_cache_source_chars: int = MAX_PYTHON_AST_CACHE_SOURCE_CHARS, clock: Callable[[], float] = time.monotonic, @@ -289,6 +2002,8 @@ def prewarm_python_ast_cache( cache = build_python_ast_cache( components, file_cache, + raw_file_cache=raw_file_cache, + source_classifications=source_classifications, max_source_chars=max_source_chars, max_cache_source_chars=max_cache_source_chars, clock=clock, diff --git a/src/skillspector/state.py b/src/skillspector/state.py index 1a887e76f..1fc5d1a46 100644 --- a/src/skillspector/state.py +++ b/src/skillspector/state.py @@ -250,6 +250,12 @@ class SkillspectorState(TypedDict, total=False): local_file_cache: dict[str, str] # Raw bytes remain the canonical source for YARA and content classification. raw_file_cache: dict[str, bytes] + # Byte-derived Python execution identity shared by every analyzer branch. + python_source_classifications: dict[str, str] + # Paths whose Python applicability could not be resolved before the shared deadline. + python_source_classification_limitations: dict[str, str] + # Paths withheld from text/AST consumers after strict PEP 263 decode failure. + python_source_decode_failures: dict[str, str] # External-model consumers use the redacted projection for sensitive local files. llm_file_cache: dict[str, str] artifact_inventory: list[ArtifactRecord] diff --git a/src/skillspector/transitive.py b/src/skillspector/transitive.py index 9686bca11..b3408755a 100644 --- a/src/skillspector/transitive.py +++ b/src/skillspector/transitive.py @@ -36,6 +36,7 @@ { ".md", ".py", + ".pyw", ".sh", ".bash", ".zsh", diff --git a/tests/nodes/analyzers/test_shared_python_ast.py b/tests/nodes/analyzers/test_shared_python_ast.py index fa864ed60..9227c723f 100644 --- a/tests/nodes/analyzers/test_shared_python_ast.py +++ b/tests/nodes/analyzers/test_shared_python_ast.py @@ -5,21 +5,33 @@ from __future__ import annotations +import io import json +import zipfile +import pytest from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +import skillspector.nodes.build_context as build_context_module import skillspector.python_ast as python_ast +from skillspector.artifacts import ArtifactDisposition, ContentKind, decode_text from skillspector.graph import graph +from skillspector.inspection_ledger import LedgerOutcome, LedgerReason from skillspector.nodes.analyzers import ( behavioral_ast, behavioral_taint_tracking, static_patterns_data_exfiltration, static_patterns_output_handling, + static_patterns_tool_misuse, ) from skillspector.nodes.build_context import build_context from skillspector.nodes.deduplicate import deduplicate -from skillspector.python_ast import ParsedPythonFile, get_python_ast +from skillspector.python_ast import ( + ParsedPythonFile, + PythonSourceClassification, + classify_python_source, + get_python_ast, +) def test_long_output_flow_uses_complete_ast_source_identity() -> None: @@ -55,14 +67,25 @@ def code(tail: str) -> str: assert "UNIQUE_FIRST_TAIL" not in json.dumps(first.to_dict(), sort_keys=True) -def test_preparsed_python_is_reused_by_all_ast_analyzers(tmp_path, monkeypatch) -> None: +@pytest.mark.parametrize( + ("filename", "prefix"), + [ + pytest.param("script.py", "", id="py"), + pytest.param("script.pyw", "", id="pyw"), + pytest.param("script", "#!/usr/bin/env python3\n", id="env-shebang"), + ], +) +def test_preparsed_python_is_reused_by_all_ast_analyzers( + tmp_path, monkeypatch, filename: str, prefix: str +) -> None: """One scan parses each eligible Python file once before analyzer fan-out.""" - (tmp_path / "script.py").write_text( + (tmp_path / filename).write_text( + prefix + "import subprocess\n" + "use_shell = True\n" + "subprocess.run(output, shell=use_shell)\n" "import os\n" - "import subprocess\n" "payload = input()\n" "environment = os.environ.copy()\n" - "subprocess.run(output)\n" "exec(payload)\n", encoding="utf-8", ) @@ -77,12 +100,16 @@ def count_parse(*args, **kwargs): monkeypatch.setattr(python_ast.ast, "parse", count_parse) state = build_context({"skill_path": str(tmp_path)}) + metadata = next(item for item in state["component_metadata"] if item["path"] == filename) + assert metadata["type"] == "python" + assert metadata["executable"] is True + python_ast_cache_key = state["python_ast_cache_key"] assert isinstance(python_ast_cache_key, str) parsed = get_python_ast( python_ast_cache_key, - state["file_cache"]["script.py"], - "script.py", + state["file_cache"][filename], + filename, ) assert isinstance(parsed, ParsedPythonFile) assert parsed.is_parseable @@ -90,16 +117,433 @@ def count_parse(*args, **kwargs): data_findings = static_patterns_data_exfiltration.node(state)["findings"] output_findings = static_patterns_output_handling.node(state)["findings"] + tool_misuse_findings = static_patterns_tool_misuse.node(state)["findings"] ast_findings = behavioral_ast.node(state)["findings"] taint_findings = behavioral_taint_tracking.node(state)["findings"] assert any(finding.rule_id == "E2" for finding in data_findings) assert any(finding.rule_id == "OH1" for finding in output_findings) + assert any(finding.rule_id == "TM1" for finding in tool_misuse_findings) assert any(finding.rule_id == "AST1" for finding in ast_findings) assert any(finding.rule_id == "TT5" for finding in taint_findings) assert parse_calls == 1 +def test_analyzers_reuse_build_context_python_classification(tmp_path, monkeypatch) -> None: + """Analyzer fan-out does not repeat bounded shebang parsing per family.""" + filename = "runner" + (tmp_path / filename).write_text( + "#!/usr/bin/env python3\n" + "import subprocess\n" + "enabled = True\n" + "subprocess.run(command, shell=enabled)\n" + "payload = input()\n" + "exec(payload)\n", + encoding="utf-8", + ) + state = build_context({"skill_path": str(tmp_path)}) + + def forbidden_reclassification(*_args, **_kwargs): + raise AssertionError("downstream analyzers must reuse build-context classification") + + monkeypatch.setattr(python_ast, "classify_python_source", forbidden_reclassification) + + assert any( + finding.rule_id == "TM1" for finding in static_patterns_tool_misuse.node(state)["findings"] + ) + assert any(finding.rule_id == "AST1" for finding in behavioral_ast.node(state)["findings"]) + assert any( + finding.rule_id == "TT5" for finding in behavioral_taint_tracking.node(state)["findings"] + ) + + +def test_ambiguous_python_source_is_analyzed_once_and_marks_coverage_partial( + tmp_path, monkeypatch +) -> None: + """Runtime-dependent Python intent is scanned without claiming definitive coverage.""" + filename = "runner" + (tmp_path / filename).write_text( + "#!/usr/bin/env -S ${SKILLSPECTOR_INTERPRETER}\n" + "import subprocess\n" + "use_shell = True\n" + "subprocess.run(output, shell=use_shell)\n" + "import os\n" + "payload = input()\n" + "environment = os.environ.copy()\n" + "exec(payload)\n", + encoding="utf-8", + ) + original_parse = python_ast.ast.parse + parse_calls = 0 + + def count_parse(*args, **kwargs): + nonlocal parse_calls + parse_calls += 1 + return original_parse(*args, **kwargs) + + monkeypatch.setattr(python_ast.ast, "parse", count_parse) + state = build_context({"skill_path": str(tmp_path)}) + + metadata = next(item for item in state["component_metadata"] if item["path"] == filename) + artifact = next(item for item in state["artifact_inventory"] if item["path"] == filename) + classification_event = next( + item + for item in state["inspection_ledger"] + if item.get("reason_code") is LedgerReason.PYTHON_SOURCE_AMBIGUOUS + ) + assert metadata["type"] == "other" + assert metadata["executable"] is True + assert artifact["disposition"] is ArtifactDisposition.PARTIAL + assert artifact["reason"] == LedgerReason.PYTHON_SOURCE_AMBIGUOUS.value + assert classification_event["outcome"] is LedgerOutcome.PARTIAL + + tool_response = static_patterns_tool_misuse.node(state) + ast_response = behavioral_ast.node(state) + taint_response = behavioral_taint_tracking.node(state) + assert any(finding.rule_id == "TM1" for finding in tool_response["findings"]) + assert any(finding.rule_id == "AST1" for finding in ast_response["findings"]) + assert any(finding.rule_id == "TT5" for finding in taint_response["findings"]) + for response in (tool_response, ast_response, taint_response): + event = next(item for item in response["inspection_ledger"] if item["path"] == filename) + assert event["outcome"] is LedgerOutcome.PARTIAL + assert event["reason_code"] is LedgerReason.PYTHON_SOURCE_AMBIGUOUS + assert parse_calls == 1 + + +def test_raw_shebang_bytes_control_shared_python_classification(tmp_path) -> None: + """Exact PEP 263 decoding retains raw ambiguity while enabling analysis.""" + filename = "runner" + raw_line = b"#!/usr/bin/env -S X=" + b"\xff" + b"a" * 96 + b" python3 --" + raw = ( + raw_line + + b"\n# coding: latin-1\n" + + b"import subprocess\n" + + b"enabled = True\n" + + b"subprocess.run(command, shell=enabled)\n" + ) + (tmp_path / filename).write_bytes(raw) + + assert len(raw_line) == 128 + assert raw_line.find(b"python3") == 118 + assert classify_python_source(filename, raw) is PythonSourceClassification.AMBIGUOUS + assert classify_python_source(filename, decode_text(raw)) is PythonSourceClassification.PYTHON + + state = build_context({"skill_path": str(tmp_path)}) + artifact = next(item for item in state["artifact_inventory"] if item["path"] == filename) + assert state["raw_file_cache"][filename] == raw + assert state["python_source_classifications"][filename] == "ambiguous" + assert artifact["disposition"] is ArtifactDisposition.PARTIAL + assert artifact["reason"] == LedgerReason.PYTHON_SOURCE_AMBIGUOUS.value + assert "ÿ" in state["local_file_cache"][filename] + assert "\ufffd" not in state["local_file_cache"][filename] + + tool_response = static_patterns_tool_misuse.node(state) + assert any(finding.rule_id == "TM1" for finding in tool_response["findings"]) + event = next(item for item in tool_response["inspection_ledger"] if item["path"] == filename) + assert event["outcome"] is LedgerOutcome.PARTIAL + assert event["reason_code"] is LedgerReason.PYTHON_SOURCE_AMBIGUOUS + + +def test_pep263_python_source_is_strictly_decoded_and_analyzed(tmp_path) -> None: + filename = "runner" + raw = ( + b"#!/usr/bin/env python3\n" + b"# coding: latin-1\n" + b"# " + b"\xff" * 1_000 + b"\nimport subprocess\n" + b"enabled = True\n" + b"subprocess.run(command, shell=enabled)\n" + ) + (tmp_path / filename).write_bytes(raw) + + state = build_context({"skill_path": str(tmp_path)}) + artifact = next(item for item in state["artifact_inventory"] if item["path"] == filename) + + assert state["raw_file_cache"][filename] == raw + assert "ÿ" * 1_000 in state["local_file_cache"][filename] + assert "\ufffd" not in state["local_file_cache"][filename] + assert state["python_source_classifications"][filename] == "python" + assert artifact["content_kind"] is ContentKind.TEXT + assert artifact["disposition"] is ArtifactDisposition.ANALYZED + assert artifact["decodable"] is True + assert any( + finding.rule_id == "TM1" for finding in static_patterns_tool_misuse.node(state)["findings"] + ) + + +def test_truncated_pep263_python_preserves_exact_provider_text_and_audit_gap( + tmp_path, monkeypatch +) -> None: + """A bounded provider view keeps the exact Python decode and truncation marker.""" + filename = "script.py" + raw = ("# coding: latin-1\n# café\nvalue = 'bounded'\n" + "x" * 256 + "\n").encode("latin-1") + (tmp_path / filename).write_bytes(raw) + monkeypatch.setattr(build_context_module, "MAX_ANALYZABLE_FILE_BYTES", 64) + + state = build_context({"skill_path": str(tmp_path)}) + provider_text = state["llm_file_cache"][filename] + + assert "café" in provider_text + assert "audit" in provider_text + assert "gap" in provider_text + + +def test_truncated_binary_like_pep263_python_enters_exact_provider_cache( + tmp_path, monkeypatch +) -> None: + """A strict Python decode can promote a binary heuristic without losing LLM coverage.""" + filename = "script.py" + raw = b"# coding: latin-1\nvalue='" + b"\xff" * 200 + b"'\n" + b"x" * 200 + (tmp_path / filename).write_bytes(raw) + monkeypatch.setattr(build_context_module, "MAX_ANALYZABLE_FILE_BYTES", 128) + + state = build_context({"skill_path": str(tmp_path)}) + artifact = next(item for item in state["artifact_inventory"] if item["path"] == filename) + + assert artifact["content_kind"] is ContentKind.TEXT + assert artifact["disposition"] is ArtifactDisposition.PARTIAL + assert artifact["reason"] == LedgerReason.SIZE_LIMIT.value + provider_text = state["llm_file_cache"][filename] + assert "ÿ" in provider_text + assert "audit" in provider_text + assert "gap" in provider_text + + +def test_promoted_binary_like_python_respects_provider_boundaries(tmp_path, monkeypatch) -> None: + """Exact decoding must not publish local-only, hidden, or nested source.""" + raw = b"# coding: latin-1\nvalue='" + b"\xff" * 200 + b"'\n" + b"x" * 200 + monkeypatch.setattr(build_context_module, "MAX_ANALYZABLE_FILE_BYTES", 128) + + local_only = tmp_path / "local-only" + local_only.mkdir() + (local_only / "script.py").write_bytes(raw) + local_state = build_context({"skill_path": str(local_only), "source_local_only": True}) + assert local_state["llm_file_cache"] == {} + + hidden = tmp_path / "hidden" + hidden.mkdir() + (hidden / ".script.py").write_bytes(raw) + hidden_state = build_context({"skill_path": str(hidden)}) + assert ".script.py" not in hidden_state["llm_file_cache"] + + nested = tmp_path / "nested" + nested.mkdir() + archive_bytes = io.BytesIO() + with zipfile.ZipFile(archive_bytes, "w") as archive: + archive.writestr("script.py", raw) + (nested / "bundle.zip").write_bytes(archive_bytes.getvalue()) + nested_state = build_context({"skill_path": str(nested)}) + assert "bundle.zip!/script.py" not in nested_state["llm_file_cache"] + + +@pytest.mark.parametrize( + ("filename", "raw", "decoded_marker"), + [ + pytest.param( + "runner", + b"#!/usr/bin/env python3\n" + b"# coding: latin-1\n" + b"import subprocess\n" + b"activ\xe9 = True\n" + b"subprocess.run(command, shell=activ\xe9)\n", + "activé", + id="heuristic-text-latin1", + ), + pytest.param( + "runner.py", + b"\xef\xbb\xbfimport subprocess\n" + b"enabled = True\n" + b"subprocess.run(command, shell=enabled)\n", + "import subprocess", + id="utf8-bom", + ), + ], +) +def test_python_exact_decode_replaces_every_lossy_text_projection( + tmp_path, filename: str, raw: bytes, decoded_marker: str +) -> None: + (tmp_path / filename).write_bytes(raw) + + state = build_context({"skill_path": str(tmp_path)}) + content = state["local_file_cache"][filename] + artifact = next(item for item in state["artifact_inventory"] if item["path"] == filename) + + assert decoded_marker in content + assert "\ufffd" not in content + assert not content.startswith("\ufeff") + assert state["file_cache"][filename] == content + assert artifact["content_kind"] is ContentKind.TEXT + assert artifact["decodable"] is True + assert any( + finding.rule_id == "TM1" for finding in static_patterns_tool_misuse.node(state)["findings"] + ) + + +@pytest.mark.parametrize( + "raw", + [ + pytest.param( + b"#!/usr/bin/env python3\n" + b"# coding: definitely-unknown\n" + b"import subprocess\n" + b"enabled = True\n" + b"subprocess.run(command, shell=enabled)\n", + id="unknown-cookie", + ), + pytest.param( + b"#!/usr/bin/env python3\n# coding: utf-8\n# \xff\nvalue = 1\n", + id="invalid-declared-utf8", + ), + pytest.param( + b"#!/usr/bin/env python3\nvalue = 'before\x00after'\n", + id="nul-byte", + ), + pytest.param( + b"#!/usr/bin/env python3\n# coding: raw_unicode_escape\nvalue = '\\ud800'\n", + id="lone-surrogate-escape", + ), + pytest.param( + b"#!/usr/bin/env python3\n# coding: utf-7\n# +2AA-\nvalue = 1\n", + id="lone-surrogate-utf7", + ), + pytest.param( + b"#!/usr/bin/env python3\n# coding: unicode_escape\nvalue = '\\x00'\n", + id="decoder-produced-nul", + ), + ], +) +def test_python_decode_failure_is_never_out_of_scope_or_complete(tmp_path, raw: bytes) -> None: + filename = "runner" + (tmp_path / filename).write_bytes(raw) + + state = build_context({"skill_path": str(tmp_path)}) + artifact = next(item for item in state["artifact_inventory"] if item["path"] == filename) + event = next( + item + for item in state["inspection_ledger"] + if item.get("path") == filename and item.get("reason_code") == "python_source_decode_error" + ) + + assert state["raw_file_cache"][filename] == raw + assert state["python_source_classifications"][filename] == "python" + assert artifact["disposition"] is ArtifactDisposition.PARTIAL + assert artifact["reason"] == "python_source_decode_error" + assert event["outcome"] is LedgerOutcome.PARTIAL + assert filename not in state["local_file_cache"] + assert filename not in state["file_cache"] + + +def test_mixed_newline_python_decode_failure_is_partial(tmp_path) -> None: + filename = "mixed.py" + raw = b"\r\n\t#coding:utf_16be\rx=1\n" + (tmp_path / filename).write_bytes(raw) + + state = build_context({"skill_path": str(tmp_path)}) + artifact = next(item for item in state["artifact_inventory"] if item["path"] == filename) + + assert state["raw_file_cache"][filename] == raw + assert artifact["disposition"] is ArtifactDisposition.PARTIAL + assert artifact["reason"] == LedgerReason.PYTHON_SOURCE_DECODE_ERROR.value + assert filename not in state["local_file_cache"] + assert filename not in state["file_cache"] + assert any( + item.get("path") == filename + and item.get("outcome") is LedgerOutcome.PARTIAL + and item.get("reason_code") is LedgerReason.PYTHON_SOURCE_DECODE_ERROR + for item in state["inspection_ledger"] + ) + + +def test_crlf_python_source_is_normalized_before_exact_decode(tmp_path) -> None: + filename = "encoded.py" + raw = b"\t#coding=utf_16be\r\nx=1\r\n" + expected = b"\t#coding=utf_16be\nx=1\n".decode("utf_16be") + (tmp_path / filename).write_bytes(raw) + + state = build_context({"skill_path": str(tmp_path)}) + artifact = next(item for item in state["artifact_inventory"] if item["path"] == filename) + parsed = get_python_ast( + state["python_ast_cache_key"], + state["file_cache"][filename], + filename, + ) + + assert state["raw_file_cache"][filename] == raw + assert state["local_file_cache"][filename] == expected + assert state["file_cache"][filename] == expected + assert artifact["disposition"] is ArtifactDisposition.ANALYZED + assert artifact["content_kind"] is ContentKind.TEXT + assert parsed is not None and parsed.is_parseable + assert not any( + item.get("path") == filename + and item.get("reason_code") is LedgerReason.PYTHON_SOURCE_DECODE_ERROR + for item in state["inspection_ledger"] + ) + + +def test_classification_deadline_withholds_unclassified_python_from_analyzers( + tmp_path, monkeypatch +) -> None: + """A deadline suffix cannot fall back to the lossy generic text cache.""" + + class FakeClock: + now = 0.0 + + def __call__(self) -> float: + return self.now + + fake_clock = FakeClock() + (tmp_path / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + (tmp_path / "middle").write_text( + "#!/usr/bin/env python3\npass\n", + encoding="utf-8", + ) + invalid_path = "zbad.py" + (tmp_path / invalid_path).write_bytes( + b"# coding: definitely-unknown\n" + b"import subprocess\n" + b"enabled = True\n" + b"subprocess.run(command, shell=enabled)\n" + ) + original_classify = build_context_module.classify_python_source + + def expiring_classification(path: str, content: str | bytes | None) -> object: + result = original_classify(path, content) + if path == "middle": + fake_clock.now = 1.0 + return result + + monkeypatch.setattr(build_context_module, "MAX_BUNDLE_CACHE_SECONDS", 1.0) + monkeypatch.setattr(build_context_module, "monotonic", fake_clock) + monkeypatch.setattr( + build_context_module, + "classify_python_source", + expiring_classification, + ) + + state = build_context({"skill_path": str(tmp_path)}) + responses = ( + static_patterns_tool_misuse.node(state), + behavioral_ast.node(state), + behavioral_taint_tracking.node(state), + ) + + assert invalid_path not in state["python_source_classifications"] + assert state["python_source_classification_limitations"][invalid_path] == "runtime_limit" + assert invalid_path in state["components"] + assert invalid_path in state["raw_file_cache"] + assert invalid_path not in state["local_file_cache"] + assert invalid_path not in state["file_cache"] + assert invalid_path not in state["llm_file_cache"] + assert invalid_path not in state["llm_components"] + assert not any( + finding.file == invalid_path for response in responses for finding in response["findings"] + ) + for response in responses: + event = next(item for item in response["inspection_ledger"] if item["path"] == invalid_path) + assert event["outcome"] is LedgerOutcome.PARTIAL + assert event["reason_code"] is LedgerReason.RUNTIME_LIMIT + + def test_uppercase_python_path_reuses_preparsed_ast_for_static_analyzers( tmp_path, monkeypatch ) -> None: diff --git a/tests/nodes/analyzers/test_static_runner_filtering.py b/tests/nodes/analyzers/test_static_runner_filtering.py index ba86e80a1..23dfee632 100644 --- a/tests/nodes/analyzers/test_static_runner_filtering.py +++ b/tests/nodes/analyzers/test_static_runner_filtering.py @@ -47,6 +47,14 @@ def _findings(content: str, path: str, module: object) -> set[str]: return {finding.rule_id for finding in static_runner.run_static_patterns(state, [module])} +def test_python_execution_intent_does_not_override_declared_markdown_type() -> None: + content = ( + "#!/usr/bin/env -S ${SKILLSPECTOR_INTERPRETER}\n\n" + ) + + assert "P2" in _findings(content, "payload.md", pi_module) + + def _view_finding(**overrides: object) -> Finding: values: dict[str, object] = { "rule_id": "T1", @@ -1166,6 +1174,419 @@ def test_non_documentation_paths_not_matched(self, path: str) -> None: class TestInspectionLedgerResponse: + def test_postprocessor_time_is_included_in_runtime_ledger( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + now = [0.0] + monkeypatch.setattr(static_runner.time, "monotonic", lambda: now[0]) + monkeypatch.setattr(static_runner, "MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT", 30.0) + + class SlowPostprocessingModule: + ANALYZER_ID = "slow_postprocessed_static" + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + del content, file_type + return [ + AnalyzerFinding( + rule_id="T1", + message="candidate", + severity=Severity.HIGH, + location=Location(file=file_path, start_line=1), + ) + ] + + @staticmethod + def postprocess_path_findings(content: str, findings: list) -> list: + del content + now[0] = 31.0 + return findings + + response = static_runner.run_static_patterns_with_ledger( + {"components": ["input.md"], "file_cache": {"input.md": "input"}}, + [SlowPostprocessingModule], + ) + event = response["inspection_ledger"][0] + + assert event["outcome"] == "partial" + assert event["reason_code"] == "runtime_limit" + assert event["observed_seconds"] == 31.0 + assert event["limit_seconds"] == 30.0 + + def test_postprocessor_deadline_runs_private_evidence_cleanup( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + now = [0.0] + cleanup_calls = 0 + monkeypatch.setattr(static_runner.time, "monotonic", lambda: now[0]) + monkeypatch.setattr(static_runner, "MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT", 30.0) + + class SlowPostprocessingModule: + ANALYZER_ID = "slow_postprocessed_static" + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + del content, file_type + return [ + AnalyzerFinding( + rule_id="T1", + message="candidate", + severity=Severity.HIGH, + location=Location(file=file_path, start_line=1), + evidence={"_private_intermediate": "scan"}, + ) + ] + + @staticmethod + def postprocess_path_findings(content: str, findings: list) -> list: + del content + findings[0].evidence["_private_intermediate"] = "postprocess" + now[0] = 31.0 + return findings + + @staticmethod + def cleanup_path_findings(findings: list) -> list: + nonlocal cleanup_calls + cleanup_calls += 1 + for finding in findings: + finding.evidence.pop("_private_intermediate", None) + return findings + + response = static_runner.run_static_patterns_with_ledger( + {"components": ["input.md"], "file_cache": {"input.md": "input"}}, + [SlowPostprocessingModule], + ) + event = response["inspection_ledger"][0] + + assert cleanup_calls == 1 + assert response["findings"][0].evidence == {} + assert event["outcome"] == "partial" + assert event["reason_code"] == "runtime_limit" + + def test_postprocessor_runtime_limit_supersedes_prior_output_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + now = [0.0] + monkeypatch.setattr(static_runner.time, "monotonic", lambda: now[0]) + monkeypatch.setattr(static_runner, "MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT", 30.0) + monkeypatch.setattr(static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + + class SlowLimitedModule: + ANALYZER_ID = "slow_limited_static" + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + del content, file_type + return [ + AnalyzerFinding( + rule_id="T1", + message="candidate", + severity=Severity.HIGH, + location=Location(file=file_path, start_line=line), + ) + for line in (1, 2) + ] + + @staticmethod + def postprocess_path_findings(content: str, findings: list) -> list: + del content + now[0] = 31.0 + return findings + + response = static_runner.run_static_patterns_with_ledger( + {"components": ["input.md"], "file_cache": {"input.md": "input"}}, + [SlowLimitedModule], + ) + event = response["inspection_ledger"][0] + + assert event["outcome"] == "partial" + assert event["reason_code"] == "runtime_limit" + assert event["observed_seconds"] == 31.0 + assert event["limit_seconds"] == 30.0 + + def test_postprocessor_is_skipped_after_scan_runtime_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + now = [0.0] + postprocess_called = False + monkeypatch.setattr(static_runner.time, "monotonic", lambda: now[0]) + monkeypatch.setattr(static_runner, "MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT", 30.0) + + class ExpiredBeforePostprocessingModule: + ANALYZER_ID = "expired_before_postprocessing_static" + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + del content, file_type + finding = AnalyzerFinding( + rule_id="T1", + message="candidate", + severity=Severity.HIGH, + location=Location(file=file_path, start_line=1), + ) + now[0] = 31.0 + return [finding] + + @staticmethod + def postprocess_path_findings(content: str, findings: list) -> list: + nonlocal postprocess_called + del content + postprocess_called = True + raise AssertionError("postprocessor must not run after the shared deadline") + + response = static_runner.run_static_patterns_with_ledger( + {"components": ["input.md"], "file_cache": {"input.md": "input"}}, + [ExpiredBeforePostprocessingModule], + ) + event = response["inspection_ledger"][0] + + assert not postprocess_called + assert event["outcome"] == "partial" + assert event["reason_code"] == "runtime_limit" + assert event["observed_seconds"] == 31.0 + assert event["limit_seconds"] == 30.0 + + def test_postprocessor_runs_before_findings_and_ledger_ids_are_committed(self) -> None: + class PostprocessingModule: + ANALYZER_ID = "postprocessed_static" + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + del content, file_type + return [ + AnalyzerFinding( + rule_id="T1", + message="candidate", + severity=Severity.HIGH, + location=Location(file=file_path, start_line=line), + matched_text=f"match-{line}", + ) + for line in (1, 2) + ] + + @staticmethod + def postprocess_path_findings(content: str, findings: list) -> list: + assert content == "input\nsecond" + return findings[1:] + + state = { + "components": ["input.md"], + "file_cache": {"input.md": "input\nsecond"}, + } + response = static_runner.run_static_patterns_with_ledger( + state, + [PostprocessingModule], + ) + findings = response["findings"] + + assert len(findings) == 1 + assert findings[0].start_line == 2 + assert response["inspection_ledger"][0]["emitted_finding_ids"] == [findings[0].finding_id] + assert static_runner.run_static_patterns(state, [PostprocessingModule])[0].start_line == 2 + + def test_ast_aware_postprocessor_requests_shared_parse_without_ast_analyzer(self) -> None: + class AstPostprocessingModule: + ANALYZER_ID = "ast_postprocessed_static" + POSTPROCESS_USES_PYTHON_AST = True + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + del content, file_type + return [ + AnalyzerFinding( + rule_id="T1", + message="candidate", + severity=Severity.HIGH, + location=Location(file=file_path, start_line=1), + ) + ] + + @staticmethod + def postprocess_path_findings(content: str, findings: list, *, python_ast) -> list: + del content + assert python_ast is not None + assert python_ast.tree is not None + return findings + + state = {"components": ["input.py"], "file_cache": {"input.py": "value = 1\n"}} + + response = static_runner.run_static_patterns_with_ledger( + state, + [AstPostprocessingModule], + ) + + assert len(response["findings"]) == 1 + assert response["inspection_ledger"][0]["outcome"] == "completed" + + def test_ast_aware_postprocessor_marks_oversized_python_partial( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + path = "input.py" + content = "value = 1\n" + monkeypatch.setattr(static_runner, "MAX_FILE_CHARS", 4) + + class AstPostprocessingModule: + ANALYZER_ID = "ast_postprocessed_static" + POSTPROCESS_USES_PYTHON_AST = True + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list: + del content, file_path, file_type + return [] + + @staticmethod + def postprocess_path_findings(content: str, findings: list, *, python_ast) -> list: + del content + assert python_ast is None + return findings + + response = static_runner.run_static_patterns_with_ledger( + {"components": [path], "file_cache": {path: content}}, + [AstPostprocessingModule], + ) + event = response["inspection_ledger"][0] + + assert event["outcome"] == "partial" + assert event["reason_code"] == "size_limit" + assert event["observed_characters"] == len(content) + assert event["limit_characters"] == 4 + + def test_ast_aware_postprocessor_marks_invalid_python_partial(self) -> None: + class AstPostprocessingModule: + ANALYZER_ID = "ast_postprocessed_static" + POSTPROCESS_USES_PYTHON_AST = True + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list: + del content, file_path, file_type + return [] + + @staticmethod + def postprocess_path_findings(content: str, findings: list, *, python_ast) -> list: + del content + assert python_ast is not None + assert python_ast.tree is None + return findings + + response = static_runner.run_static_patterns_with_ledger( + {"components": ["input.py"], "file_cache": {"input.py": "if:\n"}}, + [AstPostprocessingModule], + ) + event = response["inspection_ledger"][0] + + assert event["outcome"] == "partial" + assert event["reason_code"] == "syntax_error" + + def test_nonledger_runner_counts_shared_python_parse_against_deadline( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + now = [0.0] + analyzed = False + + class AstModule: + USES_PYTHON_AST = True + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str, python_ast) -> list: + nonlocal analyzed + del content, file_path, file_type, python_ast + analyzed = True + return [] + + def delayed_parse(*_args, **_kwargs): + now[0] = 31.0 + return type("Parsed", (), {"tree": object()})() + + monkeypatch.setattr(static_runner.time, "monotonic", lambda: now[0]) + monkeypatch.setattr(static_runner, "get_python_ast", delayed_parse) + monkeypatch.setattr(static_runner, "MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT", 30.0) + + findings = static_runner.run_static_patterns( + {"components": ["input.py"], "file_cache": {"input.py": "value = 1\n"}}, + [AstModule], + ) + + assert findings == [] + assert analyzed is False + + def test_nonledger_runner_discards_unfinished_postprocessing_after_deadline( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + now = [0.0] + postprocessed = False + + class PostprocessingModule: + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + del content, file_type + return [ + AnalyzerFinding( + rule_id="T1", + message="candidate", + severity=Severity.HIGH, + location=Location(file=file_path, start_line=1), + ) + ] + + @staticmethod + def postprocess_path_findings(content: str, findings: list) -> list: + nonlocal postprocessed + del content + postprocessed = True + now[0] = 31.0 + return findings + + monkeypatch.setattr(static_runner.time, "monotonic", lambda: now[0]) + monkeypatch.setattr(static_runner, "MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT", 30.0) + + findings = static_runner.run_static_patterns( + {"components": ["input.md"], "file_cache": {"input.md": "input"}}, + [PostprocessingModule], + ) + + assert postprocessed is True + assert findings == [] + + def test_postprocessor_cannot_expand_past_per_artifact_output_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + + class ExpandingPostprocessorModule: + ANALYZER_ID = "expanding_postprocessed_static" + + @staticmethod + def analyze(*, content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: + del content, file_type + return [ + AnalyzerFinding( + rule_id="T1", + message="candidate", + severity=Severity.HIGH, + location=Location(file=file_path, start_line=1), + ) + ] + + @staticmethod + def postprocess_path_findings(content: str, findings: list) -> list: + del content + return findings * 4 + + state = {"components": ["input.md"], "file_cache": {"input.md": "input"}} + response = static_runner.run_static_patterns_with_ledger( + state, + [ExpandingPostprocessorModule], + ) + event = response["inspection_ledger"][0] + + assert len(response["findings"]) == 1 + assert event["outcome"] == "partial" + assert event["reason_code"] == "output_limit" + assert event["observed_findings"] == 4 + assert event["limit_findings"] == 1 + assert len(static_runner.run_static_patterns(state, [ExpandingPostprocessorModule])) == 1 + def test_static_runner_records_and_recovers_from_pattern_failure(self) -> None: class FailingPatternModule: ANALYZER_ID = "failing_static" diff --git a/tests/nodes/analyzers/test_tm1_window_identity.py b/tests/nodes/analyzers/test_tm1_window_identity.py new file mode 100644 index 000000000..06e5e5e25 --- /dev/null +++ b/tests/nodes/analyzers/test_tm1_window_identity.py @@ -0,0 +1,403 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""TM1 ownership and projection contracts split from PR #497.""" + +from __future__ import annotations + +import pytest + +import skillspector.artifacts as artifacts_module +from skillspector.inspection_ledger import LedgerOutcome, LedgerReason +from skillspector.nodes.analyzers import static_patterns_tool_misuse as tm_module +from skillspector.nodes.deduplicate import deduplicate + + +def _run(content: str, path: str = "run.py") -> dict: + return tm_module.node({"components": [path], "file_cache": {path: content}}) + + +def _tm1(content: str, path: str = "run.py") -> list: + return [finding for finding in _run(content, path)["findings"] if finding.rule_id == "TM1"] + + +@pytest.mark.parametrize( + "source", + [ + "plain subprocess.run(command, shell=True)", + "ig\u00adn\u03bfre and system", + "word\u200b\u200c boundary", + "\N{BLACK SUN WITH RAYS}\N{VARIATION SELECTOR-16} emoji", + "left\u0085\u0600right", + "prefix " + "ﷺ" * 100 + " suffix", + "a" + "\u200b" * 300 + "b", + ], +) +@pytest.mark.parametrize("max_chars", [0, 1, 7, 31, 200]) +def test_normalized_security_prefix_matches_full_projection( + source: str, + max_chars: int, +) -> None: + assert hasattr(artifacts_module, "normalized_security_prefix") + assert ( + artifacts_module.normalized_security_prefix(source, max_chars) + == artifacts_module.normalized_security_view(source).text[:max_chars] + ) + + +@pytest.mark.parametrize( + "source", + [ + "plain source", + "😀" * 20, + "Cafe\u0301", + "☀️", + "\u034f", + "\u0085", + "\u0600", + "\u200b", + "subprocess", + "shell\x00=True", + "i g n o r e previous instructions.", + "i g n o r e previous instructions.\ufffd", + "i-g-n-o-r-e previous instructions", + ], +) +def test_derived_security_view_predicate_matches_materialized_views(source: str) -> None: + assert hasattr(artifacts_module, "_has_derived_security_view") + assert artifacts_module._has_derived_security_view(source) is ( + len(artifacts_module.security_text_views(source)) > 1 + ) + + +def test_bound_call_uses_runner_logical_line_coordinates() -> None: + direct = _tm1("enabled = True\n\fsubprocess.run(command, shell=True)\n") + bound = _tm1("enabled = True\n\fsubprocess.run(command, shell=enabled)\n") + + assert len(direct) == len(bound) == 1 + assert (direct[0].start_line, bound[0].start_line) == (3, 3) + assert bound[0].end_line == 3 + + +def test_embedded_direct_literal_text_does_not_duplicate_bound_call() -> None: + findings = _tm1("enabled = True\nsubprocess.run('shell=True', shell=enabled)\n") + + assert len(findings) == 1 + assert "shell=enabled" in (findings[0].matched_text or "") + + +def test_normalized_expansion_slice_maps_back_to_ast_call_start() -> None: + confusable_name = "tru\N{CYRILLIC SMALL LETTER IE}_value" + prefix = "#" + "ﷺ" * 20_000 + "\n" + result = _run( + prefix + + f"{confusable_name} = True\n" + + f"subprocess.run(command, shell={confusable_name})\n", + "expanded.py", + ) + findings = [finding for finding in result["findings"] if finding.rule_id == "TM1"] + + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + assert len(findings) == 1 + assert findings[0].start_line == 3 + + +def test_normalized_continuity_view_owns_its_bound_call_once() -> None: + confusable_name = "tru\N{CYRILLIC SMALL LETTER IE}_value" + payload = " " * 256_000 + result = _run( + f"{confusable_name} = True\nsubprocess.run({payload!r}, shell={confusable_name})\n", + "wide.py", + ) + findings = [finding for finding in result["findings"] if finding.rule_id == "TM1"] + + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + assert len(findings) == 1 + + +def test_distinct_bound_calls_on_one_line_are_not_deduplicated() -> None: + findings = _tm1( + "enabled = True\n" + "pi_π = 1; subprocess.run('one', shell=enabled); " + "subprocess.run('two', shell=enabled)\n" + ) + + assert len(findings) == 2 + assert {finding.matched_text for finding in findings} == { + "subprocess.run('one', shell=enabled)", + "subprocess.run('two', shell=enabled)", + } + + +def test_long_direct_calls_with_shared_preview_keep_distinct_identity() -> None: + payload = "x" * 240 + first_call = f'subprocess.run("{payload}A", shell=True)' + second_call = f'subprocess.run("{payload}B", shell=True)' + findings = _tm1(f"first = {first_call}; second = {second_call}\n") + + assert len(findings) == 2 + assert findings[0].fingerprint() != findings[1].fingerprint() + + +def test_long_shared_preview_identity_is_cap_stable_and_matches_bound_call(monkeypatch) -> None: + payload = "x" * 240 + direct_source = ( + f'subprocess.run("{payload}A", shell=True); subprocess.run("{payload}B", shell=True)\n' + ) + bound_source = ( + "enabled = True\n" + f'subprocess.run("{payload}A", shell=enabled); ' + f'subprocess.run("{payload}B", shell=enabled)\n' + ) + + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + direct_capped = _tm1(direct_source) + bound_capped = _tm1(bound_source) + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 2) + direct_complete = _tm1(direct_source) + bound_complete = _tm1(bound_source) + + assert len(direct_capped) == len(bound_capped) == 1 + assert len(direct_complete) == len(bound_complete) == 2 + assert direct_capped[0].fingerprint() == direct_complete[0].fingerprint() + assert bound_capped[0].fingerprint() == bound_complete[0].fingerprint() + assert [finding.fingerprint() for finding in direct_complete] == [ + finding.fingerprint() for finding in bound_complete + ] + + +def test_long_shared_preview_mixed_direct_and_bound_calls_remain_distinct() -> None: + payload = "x" * 240 + first = f'subprocess.run("{payload}A", shell=True)' + second = f'subprocess.run("{payload}B", shell=enabled)' + + result = _run(f"enabled = True\nfirst = {first}; second = {second}\n") + findings = [finding for finding in result["findings"] if finding.rule_id == "TM1"] + + assert len(findings) == 2 + assert findings[0].fingerprint() != findings[1].fingerprint() + assert len(deduplicate(findings)) == 2 + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + + +def test_normalized_replay_preserves_long_bound_collision_identity() -> None: + payload = "x" + "x" * 240 + first = f'subprocess.run("{payload}A", shell=enabled)' + second = f'subprocess.run("{payload}B", shell=enabled)' + + result = _run(f"enabled = True\nfirst = {first}; second = {second}\n") + findings = [finding for finding in result["findings"] if finding.rule_id == "TM1"] + + assert len(findings) == 2 + assert findings[0].fingerprint() != findings[1].fingerprint() + assert len(deduplicate(findings)) == 2 + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + + +def test_output_cap_keeps_first_mixed_owner_in_source_order(monkeypatch) -> None: + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + result = _run( + "subprocess.run('first', shell=True)\n" + "enabled = True\n" + "subprocess.run('second', shell=enabled)\n" + ) + findings = [finding for finding in result["findings"] if finding.rule_id == "TM1"] + + assert [finding.start_line for finding in findings] == [1] + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL + + +def test_output_cap_orders_bound_and_ordinary_tm1_by_source(monkeypatch) -> None: + content = ( + "# --skip-validation\n" + "enabled = True\n" + 'subprocess.run("bound", shell=enabled)\n' + 'subprocess.run("direct", shell=True)\n' + ) + + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 10) + complete = _tm1(content) + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + capped_result = _run(content) + capped = [finding for finding in capped_result["findings"] if finding.rule_id == "TM1"] + + assert [finding.start_line for finding in complete] == [1, 3, 4] + assert [finding.start_line for finding in capped] == [1] + assert capped_result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL + assert capped_result["inspection_ledger"][0]["reason_code"] is LedgerReason.OUTPUT_LIMIT + + +def test_lexical_output_cap_keeps_ordinary_tm1_before_later_direct_call(monkeypatch) -> None: + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + result = _run('# --skip-validation\nsubprocess.run("direct", shell=True)\n') + findings = [finding for finding in result["findings"] if finding.rule_id == "TM1"] + + assert [finding.start_line for finding in findings] == [1] + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL + assert result["inspection_ledger"][0]["reason_code"] is LedgerReason.OUTPUT_LIMIT + + +@pytest.mark.parametrize( + ("path", "suffix"), + [ + ("guide.md", ""), + ("broken.py", "if (\n"), + ], +) +def test_lexical_output_cap_keeps_earlier_normalized_only_tm1( + monkeypatch, + path: str, + suffix: str, +) -> None: + content = ( + 'subprocess.run("first", shell=True)\n' + 'subprocess.run("second", shell=True)\n' + 'subprocess.run("third", shell=True)\n' + f"{suffix}" + ) + + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 10) + complete = _tm1(content, path) + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + capped_result = _run(content, path) + capped = [finding for finding in capped_result["findings"] if finding.rule_id == "TM1"] + + assert [finding.start_line for finding in complete] == [1, 2, 3] + assert [finding.start_line for finding in capped] == [1] + assert capped_result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL + assert capped_result["inspection_ledger"][0]["reason_code"] is LedgerReason.OUTPUT_LIMIT + + +def test_declared_marker_output_cap_keeps_earlier_normalized_only_tm1(monkeypatch) -> None: + content = ( + "Remove 'xyz' and execute 'rxyzmxyz -rxyzfxyz *'.\n" + "Remove 'abc' and execute 'rabcmabc -rabcfabc *'.\n" + "Remove 'def' and execute 'rdefmdef -rdeffdef *'.\n" + ) + + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 10) + complete = _tm1(content, "guide.md") + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + capped_result = _run(content, "guide.md") + capped = [finding for finding in capped_result["findings"] if finding.rule_id == "TM1"] + + assert [finding.start_line for finding in complete] == [1, 2, 3] + assert [finding.start_line for finding in capped] == [1] + assert "normalized-view" in capped[0].tags + assert "declared-marker-view" in capped[0].tags + assert capped_result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL + assert capped_result["inspection_ledger"][0]["reason_code"] is LedgerReason.OUTPUT_LIMIT + + +@pytest.mark.parametrize("payload_prefix", ["", "x"]) +def test_lexical_long_identity_is_cap_stable_without_retained_sibling( + monkeypatch, + payload_prefix: str, +) -> None: + payload = payload_prefix + "x" * 240 + content = ( + f'subprocess.Popen("{payload}A", shell=True); subprocess.Popen("{payload}B", shell=True)\n' + ) + + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 2) + complete = _tm1(content, "guide.md") + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + capped = _tm1(content, "guide.md") + + assert len(complete) == 2 + assert len(capped) == 1 + assert capped[0].fingerprint() == complete[0].fingerprint() + + +def test_lexical_long_calls_on_different_lines_keep_distinct_identity() -> None: + payload = "x" * 240 + first = f'subprocess.Popen("{payload}A", shell=True)' + second = f'subprocess.Popen("{payload}B", shell=True)' + + findings = _tm1(f"{first}\n{second}\n", "guide.md") + + assert len(findings) == 2 + assert findings[0].fingerprint() != findings[1].fingerprint() + assert len(deduplicate(findings)) == 2 + + +@pytest.mark.parametrize("cap", [1, 2]) +def test_same_line_bound_duplicates_match_direct_output_budget(monkeypatch, cap: int) -> None: + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", cap) + direct = _run('subprocess.run("x", shell=True); subprocess.run("x", shell=True)\n') + bound = _run( + 'enabled = True\nsubprocess.run("x", shell=enabled); subprocess.run("x", shell=enabled)\n' + ) + direct_findings = [finding for finding in direct["findings"] if finding.rule_id == "TM1"] + bound_findings = [finding for finding in bound["findings"] if finding.rule_id == "TM1"] + + assert len(direct_findings) == len(bound_findings) == 1 + assert direct_findings[0].fingerprint() == bound_findings[0].fingerprint() + assert direct["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + assert bound["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + + +@pytest.mark.parametrize("bound_name", ["a", "true_value"]) +def test_bound_fingerprint_matches_direct_literal(bound_name: str) -> None: + direct = _tm1("subprocess.run(command, shell=True, capture_output=True)\n") + bound = _tm1(f"{bound_name} = True\nsubprocess.run(command, shell={bound_name}, text=True)\n") + + assert len(direct) == len(bound) == 1 + assert bound[0].fingerprint() == direct[0].fingerprint() + + +def test_cross_window_qualified_and_bare_popen_share_one_budget_owner(monkeypatch) -> None: + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + content = "subprocess." + "\u200b" * 256_000 + "Popen(command, shell=True)\n" + result = _run(content, "guide.md") + findings = [finding for finding in result["findings"] if finding.rule_id == "TM1"] + + assert len(findings) == 1 + assert findings[0].matched_text == "subprocess.Popen(command, shell=True" + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + + +@pytest.mark.parametrize("ignored", ["\u200b", "\ufffd"]) +def test_output_limit_finalizes_retained_cross_window_popen(monkeypatch, ignored: str) -> None: + expected = _tm1("subprocess.Popen(command, shell=True)\n", "guide.md")[0].fingerprint() + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + content = ( + "subprocess." + + ignored * 256_000 + + "Popen(command, shell=True)\n" + + "subprocess.run(command_0, shell=True)\n" + ) + + result = _run(content, "guide.md") + findings = [finding for finding in result["findings"] if finding.rule_id == "TM1"] + + assert len(findings) == 1 + assert findings[0].matched_text == "subprocess.Popen(command, shell=True" + assert findings[0].fingerprint() == expected + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL + assert result["inspection_ledger"][0]["reason_code"] is LedgerReason.OUTPUT_LIMIT + + +def test_output_cap_keeps_long_cross_window_popen_source_prefix(monkeypatch) -> None: + payload = "x" * 240 + content = ( + f'subprocess.Popen("{payload}A", shell=True); ' + + "subprocess." + + "\u200b" * 256_000 + + f'Popen("{payload}B", shell=True); ' + + 'subprocess.run("third", shell=True)\n' + ) + + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 3) + complete = _tm1(content, "guide.md") + monkeypatch.setattr(tm_module.static_runner, "MAX_FINDINGS_PER_ARTIFACT", 2) + capped_result = _run(content, "guide.md") + capped = [finding for finding in capped_result["findings"] if finding.rule_id == "TM1"] + + assert [finding.start_column for finding in complete] == [0, 256_286, 256_550] + assert [finding.start_column for finding in capped] == [0, 256_286] + assert [finding.fingerprint() for finding in capped] == [ + finding.fingerprint() for finding in complete[:2] + ] + assert capped_result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL + assert capped_result["inspection_ledger"][0]["reason_code"] is LedgerReason.OUTPUT_LIMIT diff --git a/tests/nodes/analyzers/test_tool_misuse_python_ast.py b/tests/nodes/analyzers/test_tool_misuse_python_ast.py new file mode 100644 index 000000000..0bb189043 --- /dev/null +++ b/tests/nodes/analyzers/test_tool_misuse_python_ast.py @@ -0,0 +1,418 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused coverage for issue #475's ordinary-Python binding form.""" + +from __future__ import annotations + +import pytest + +from skillspector.nodes.analyzers import static_patterns_tool_misuse as tm_module +from skillspector.nodes.deduplicate import deduplicate + + +def _run(content: str, path: str = "run.py") -> dict: + return tm_module.node({"components": [path], "file_cache": {path: content}}) + + +def _tm1(content: str, path: str = "run.py") -> list: + return [finding for finding in _run(content, path)["findings"] if finding.rule_id == "TM1"] + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("True", id="issue-body-boolean"), + pytest.param("'True'", id="reporter-attachment-string"), + ], +) +def test_issue_475_multiline_binding_matches_direct_tm1(value: str) -> None: + findings = _tm1( + "import subprocess\n" + "command = f'python a.py'\n" + f"enabled = {value}\n" + "result = subprocess.run(\n" + " command,\n" + " shell=enabled,\n" + " capture_output=True,\n" + " text=True,\n" + ")\n" + ) + + assert len(findings) == 1 + assert findings[0].start_line == 4 + assert findings[0].severity == "HIGH" + assert findings[0].confidence == pytest.approx(0.9) + assert "shell=enabled" in findings[0].matched_text + assert not findings[0].evidence + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("1", id="integer"), + pytest.param("-1", id="negative-integer"), + pytest.param("(0,)", id="nonempty-tuple"), + pytest.param("not False", id="negation"), + ], +) +def test_simple_immutable_truthy_values_are_tracked(value: str) -> None: + assert ( + len(_tm1(f"import subprocess\nenabled = {value}\nsubprocess.run(cmd, shell=enabled)\n")) + == 1 + ) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("False", id="boolean"), + pytest.param("0", id="integer"), + pytest.param("''", id="string"), + pytest.param("()", id="tuple"), + pytest.param("None", id="none"), + ], +) +def test_definitely_false_values_are_not_tracked(value: str) -> None: + assert not _tm1(f"import subprocess\nenabled = {value}\nsubprocess.run(cmd, shell=enabled)\n") + + +def test_simple_alias_chain_and_bare_popen_are_tracked() -> None: + findings = _tm1( + "first = 'enabled'\nsecond = first\nthird = second\nPopen(command, shell=third)\n" + ) + + assert len(findings) == 1 + assert findings[0].start_line == 4 + + +@pytest.mark.parametrize( + "rebind", + [ + pytest.param("enabled = False", id="false-assignment"), + pytest.param("enabled = dynamic", id="unknown-assignment"), + pytest.param("import pathlib as enabled", id="import"), + pytest.param("from settings import enabled", id="from-import"), + ], +) +def test_rebinding_invalidates_truthy_fact(rebind: str) -> None: + assert not _tm1( + f"import subprocess\nenabled = True\n{rebind}\nsubprocess.run(command, shell=enabled)\n" + ) + + +def test_import_side_effect_boundary_clears_truth_facts() -> None: + assert not _tm1( + "import subprocess\nenabled = True\nimport attacker\n" + "subprocess.run(command, shell=enabled)\n" + ) + + +@pytest.mark.parametrize( + "shadow", + [ + pytest.param("subprocess = Proxy()", id="assignment"), + pytest.param("import other as subprocess", id="import-alias"), + pytest.param("for subprocess in values:\n pass", id="compound-binder"), + pytest.param("subprocess.run = Proxy()", id="attribute-mutation"), + pytest.param("subprocess, other = pair", id="unpacking"), + ], +) +def test_explicit_subprocess_shadow_rejects_bound_call(shadow: str) -> None: + assert not _tm1(f"{shadow}\nenabled = True\nsubprocess.run(cmd, shell=enabled)\n") + + +def test_explicit_import_reestablishes_direct_receivers() -> None: + assert ( + len( + _tm1( + "subprocess = Proxy()\n" + "import subprocess\n" + "Popen = Proxy()\n" + "from subprocess import Popen\n" + "enabled = True\n" + "subprocess.run('/usr/bin/true', shell=enabled)\n" + "Popen('/usr/bin/true', shell=enabled)\n" + ) + ) + == 2 + ) + + +def test_relative_import_does_not_establish_bare_popen() -> None: + assert not _tm1( + "Popen = proxy\nfrom .subprocess import Popen\nenabled = True\n" + "Popen(command, shell=enabled)\n" + ) + + +def test_function_local_binding_and_outer_fact_are_independent() -> None: + findings = _tm1( + "outer = True\n" + "def execute(command):\n" + " enabled = 'True'\n" + " subprocess.run(command, shell=enabled)\n" + "subprocess.run(command, shell=outer)\n" + ) + + assert [finding.start_line for finding in findings] == [4, 5] + + +def test_function_compile_time_receiver_shadow_rejects_earlier_lookup() -> None: + assert not _tm1( + "def execute(command):\n" + " enabled = True\n" + " subprocess.run(command, shell=enabled)\n" + " subprocess = Proxy()\n" + ) + + +def test_later_global_receiver_mutation_does_not_suppress_earlier_function_call() -> None: + findings = _tm1( + "def execute(command):\n" + " enabled = True\n" + " subprocess.run(command, shell=enabled)\n" + "execute(command)\n" + "subprocess = Proxy()\n" + ) + + assert [finding.start_line for finding in findings] == [3] + + +def test_later_global_receiver_mutation_suppresses_unobserved_function_body() -> None: + assert not _tm1( + "def execute(command):\n" + " enabled = True\n" + " subprocess.run(command, shell=enabled)\n" + "subprocess = Proxy()\n" + ) + + +def test_passive_function_definition_preserves_outer_fact() -> None: + assert ( + len( + _tm1( + "enabled = True\n" + "def helper(value=1):\n" + " pass\n" + "subprocess.run(command, shell=enabled)\n" + ) + ) + == 1 + ) + + +@pytest.mark.parametrize( + "compound", + [ + pytest.param("if condition:\n pass", id="if"), + pytest.param("for item in values:\n pass", id="for"), + pytest.param("with provider():\n pass", id="with"), + pytest.param("try:\n pass\nexcept Exception:\n pass", id="try"), + pytest.param("class Local:\n pass", id="class"), + ], +) +def test_compound_statement_conservatively_clears_truth_facts(compound: str) -> None: + assert not _tm1(f"enabled = True\n{compound}\nsubprocess.run(command, shell=enabled)\n") + + +def test_calls_inside_compound_statements_are_out_of_scope() -> None: + assert not _tm1( + "if condition:\n enabled = True\n subprocess.run(command, shell=enabled)\n" + ) + + +@pytest.mark.parametrize( + "argument", + [ + pytest.param("disable()", id="call"), + pytest.param("mutator.command", id="attribute"), + pytest.param("holder[0]", id="subscript"), + pytest.param("left + right", id="operator"), + pytest.param("f'{value}'", id="formatted-string"), + pytest.param("[item for item in items]", id="comprehension"), + pytest.param("*commands", id="starred-expansion"), + ], +) +def test_side_effect_capable_call_arguments_are_rejected(argument: str) -> None: + assert not _tm1(f"enabled = True\nsubprocess.run({argument}, shell=enabled)\n") + + +@pytest.mark.parametrize( + "statement", + [ + pytest.param( + "subprocess.run(command, shell=enabled, env=build_env())", + id="expression", + ), + pytest.param( + "result = subprocess.run(command, shell=enabled, env=build_env())", + id="assignment", + ), + pytest.param( + "result: object = subprocess.run(command, shell=enabled, env=build_env())", + id="annotated-assignment", + ), + ], +) +def test_later_keyword_effect_preserves_captured_shell_value(statement: str) -> None: + findings = _tm1(f"enabled = True\n{statement}\n") + literal_findings = _tm1(statement.replace("shell=enabled", "shell=True")) + + assert len(findings) == len(literal_findings) == 1 + assert findings[0].start_line == 2 + assert findings[0].severity == literal_findings[0].severity + assert findings[0].confidence == literal_findings[0].confidence + + +@pytest.mark.parametrize( + "call", + [ + pytest.param( + "subprocess.run(command, env=build_env(), shell=enabled)", + id="earlier-keyword", + ), + pytest.param( + "subprocess.run(build_command(), shell=enabled)", + id="earlier-positional", + ), + pytest.param( + "subprocess.run(shell=enabled, *build_args())", + id="starred-positional-written-later", + ), + pytest.param( + "subprocess.run(command, **build_options(), shell=enabled)", + id="earlier-keyword-expansion", + ), + ], +) +def test_earlier_argument_effect_keeps_shell_value_uncertain(call: str) -> None: + assert not _tm1(f"enabled = True\n{call}\n") + + +def test_later_keyword_expansion_preserves_captured_shell_value() -> None: + findings = _tm1("enabled = True\nsubprocess.run(command, shell=enabled, **build_options())\n") + + assert len(findings) == 1 + assert findings[0].start_line == 2 + + +def test_later_argument_effect_invalidates_fact_after_captured_call() -> None: + findings = _tm1( + "enabled = True\n" + "subprocess.run(command, shell=enabled, env=build_env())\n" + "subprocess.run(command, shell=enabled)\n" + ) + + assert [finding.start_line for finding in findings] == [2] + + +def test_unsupported_assignment_clears_existing_facts() -> None: + assert not _tm1("enabled = True\nresult = factory()\nsubprocess.run(cmd, shell=enabled)\n") + + +def test_simple_name_store_with_unsafe_prior_binding_invalidates_truth_facts() -> None: + findings = _tm1( + "import subprocess\n" + "class Trigger:\n" + " def __del__(self):\n" + " global enabled\n" + " enabled = False\n" + "trigger = Trigger()\n" + "enabled = True\n" + "trigger = 0\n" + "subprocess.run('/usr/bin/true', shell=enabled)\n" + ) + + assert not findings + + +def test_external_name_store_treats_prior_binding_as_finalizer_capable() -> None: + findings = _tm1( + "import subprocess\n" + "class Trigger:\n" + " def __del__(self):\n" + " global enabled\n" + " enabled = False\n" + "trigger = Trigger()\n" + "enabled = False\n" + "def execute():\n" + " global enabled, trigger\n" + " enabled = True\n" + " trigger = 0\n" + " subprocess.run('/usr/bin/true', shell=enabled)\n" + "execute()\n" + ) + + assert not findings + + +def test_protocol_consuming_direct_call_invalidates_later_truth_fact() -> None: + findings = _tm1( + "import subprocess\n" + "class MutatingArgs:\n" + " def __iter__(self):\n" + " global enabled\n" + " enabled = False\n" + " return iter(('/usr/bin/true',))\n" + "mutator = MutatingArgs()\n" + "enabled = True\n" + "subprocess.run(mutator, shell=enabled)\n" + "subprocess.run('/usr/bin/true', shell=enabled)\n" + ) + + assert [finding.start_line for finding in findings] == [9] + + +def test_annotated_assignment_is_outside_side_effect_free_contract() -> None: + assert not _tm1("enabled: bool = True\nsubprocess.run(command, shell=enabled)\n") + + +def test_assignment_rhs_direct_call_is_inspected_before_invalidation() -> None: + findings = _tm1("enabled = True\nresult = subprocess.run(command, shell=enabled)\n") + + assert len(findings) == 1 + assert findings[0].start_line == 2 + + +def test_true_prefixed_identifier_has_one_lexical_owner() -> None: + findings = _tm1("true_value = True\nsubprocess.run(command, shell=true_value)\n") + + assert len(findings) == 1 + + +def test_long_same_line_calls_keep_exact_coordinates_and_distinct_identity() -> None: + payload = "x" * 240 + first_call = f'subprocess.run("{payload}A", shell=enabled)' + second_call = f'subprocess.run("{payload}B", shell=enabled)' + call_line = f"first = {first_call}; second = {second_call}" + + findings = _tm1(f"import subprocess\nenabled = True\n{call_line}\n") + + assert len(findings) == 2 + assert [(finding.start_column, finding.end_column) for finding in findings] == [ + ( + call_line.index(first_call), + call_line.index(first_call) + len(first_call), + ), + ( + call_line.index(second_call), + call_line.index(second_call) + len(second_call), + ), + ] + assert findings[0].fingerprint() != findings[1].fingerprint() + assert len(deduplicate(findings)) == 2 + + +@pytest.mark.parametrize("path", ["run", "run.sh"]) +def test_non_py_surfaces_do_not_enable_ast_companion(path: str) -> None: + assert not _tm1("enabled = True\nsubprocess.run(command, shell=enabled)\n", path) + + +def test_python_window_surface_enables_ast_companion() -> None: + findings = _tm1( + "import subprocess\nenabled = True\nsubprocess.run(command, shell=enabled)\n", + "run.pyw", + ) + + assert len(findings) == 1 diff --git a/tests/nodes/test_nested_artifacts.py b/tests/nodes/test_nested_artifacts.py index fd292b11b..a37f82a66 100644 --- a/tests/nodes/test_nested_artifacts.py +++ b/tests/nodes/test_nested_artifacts.py @@ -31,8 +31,10 @@ from skillspector.nodes.analyzers.static_patterns_supply_chain import ( _analyze_concealed_executables, ) +from skillspector.nodes.analyzers.static_patterns_tool_misuse import node as analyze_tool_misuse from skillspector.nodes.build_context import build_context from skillspector.nodes.report import _compute_risk_score +from skillspector.python_ast import ParsedPythonFile, get_python_ast def _zip_bytes( @@ -359,6 +361,138 @@ def note_truncation(self, reason: str) -> None: assert traversal.reasons == [] +@pytest.mark.parametrize( + ("member_name", "content"), + [ + pytest.param("run.pyw", b"value = 1\n", id="pyw"), + pytest.param( + "runner", + b"#!/usr/bin/env python3\nvalue = 2\n", + id="env-shebang", + ), + ], +) +def test_nested_python_execution_surfaces_are_typed_and_prewarmed( + tmp_path: Path, member_name: str, content: bytes +) -> None: + (tmp_path / "SKILL.md").write_text("# Nested Python\n", encoding="utf-8") + _write_archive(tmp_path / "bundle.zip", {member_name: content}) + + context = build_context({"skill_path": str(tmp_path)}) + virtual_path = f"bundle.zip!/{member_name}" + metadata = next(item for item in context["component_metadata"] if item["path"] == virtual_path) + + assert metadata["type"] == "python" + assert metadata["executable"] is True + assert context["python_source_classifications"][virtual_path] == "python" + cache_key = context["python_ast_cache_key"] + assert isinstance(cache_key, str) + parsed = get_python_ast(cache_key, context["local_file_cache"][virtual_path], virtual_path) + assert isinstance(parsed, ParsedPythonFile) + assert parsed.is_parseable + + +def test_nested_relative_selected_script_remains_partial(tmp_path: Path) -> None: + """Archive provenance cannot establish the invocation working directory.""" + (tmp_path / "SKILL.md").write_text("# Nested Python\n", encoding="utf-8") + _write_archive( + tmp_path / "bundle.zip", + {"runner": b"#!/usr/bin/python3 runner\nvalue = 1\n"}, + ) + + context = build_context({"skill_path": str(tmp_path)}) + virtual_path = "bundle.zip!/runner" + artifact = next(item for item in context["artifact_inventory"] if item["path"] == virtual_path) + + assert context["python_source_classifications"][virtual_path] == "ambiguous" + assert artifact["disposition"] == ArtifactDisposition.PARTIAL + assert artifact["reason"] == LedgerReason.PYTHON_SOURCE_AMBIGUOUS + assert any( + event.get("path") == virtual_path + and event.get("reason_code") == LedgerReason.PYTHON_SOURCE_AMBIGUOUS + for event in context["inspection_ledger"] + ) + + +def test_nested_pep263_python_is_decoded_and_analyzed_locally(tmp_path: Path) -> None: + (tmp_path / "SKILL.md").write_text("# Nested encoded Python\n", encoding="utf-8") + raw = ( + b"#!/usr/bin/env python3\n" + b"# coding: latin-1\n" + b"# " + b"\xff" * 1_000 + b"\nimport subprocess\n" + b"enabled = True\n" + b"subprocess.run(command, shell=enabled)\n" + ) + _write_archive(tmp_path / "bundle.zip", {"runner": raw}) + + context = build_context({"skill_path": str(tmp_path)}) + virtual_path = "bundle.zip!/runner" + artifact = next(item for item in context["artifact_inventory"] if item["path"] == virtual_path) + + assert context["raw_file_cache"][virtual_path] == raw + assert "ÿ" * 1_000 in context["local_file_cache"][virtual_path] + assert virtual_path not in context["file_cache"] + assert artifact["content_kind"] is ContentKind.TEXT + assert artifact["disposition"] is ArtifactDisposition.ANALYZED + assert any(finding.rule_id == "TM1" for finding in analyze_tool_misuse(context)["findings"]) + + +def test_nested_python_metadata_uses_exact_decoded_line_count(tmp_path: Path) -> None: + (tmp_path / "SKILL.md").write_text("# Nested encoded Python\n", encoding="utf-8") + raw = ( + b"# coding: unicode_escape\nimport subprocess\n# hidden\\nsubprocess.run('x', shell=True)\n" + ) + _write_archive(tmp_path / "bundle.zip", {"run.py": raw}) + + context = build_context({"skill_path": str(tmp_path)}) + virtual_path = "bundle.zip!/run.py" + metadata = next(item for item in context["component_metadata"] if item["path"] == virtual_path) + tm1 = next( + finding + for finding in analyze_tool_misuse(context)["findings"] + if finding.rule_id == "TM1" and finding.file == virtual_path + ) + + assert len(context["local_file_cache"][virtual_path].splitlines()) == 4 + assert metadata["lines"] == 4 + assert tm1.start_line == 4 + + +def test_nested_python_member_filesystem_alias_remains_partial(tmp_path: Path) -> None: + (tmp_path / "SKILL.md").write_text("# Nested Python\n", encoding="utf-8") + _write_archive( + tmp_path / "bundle.zip", + {"Runner": b"#!/usr/bin/python3 runner\nvalue = 1\n"}, + ) + + context = build_context({"skill_path": str(tmp_path)}) + virtual_path = "bundle.zip!/Runner" + artifact = next(item for item in context["artifact_inventory"] if item["path"] == virtual_path) + + assert context["python_source_classifications"][virtual_path] == "ambiguous" + assert artifact["disposition"] == ArtifactDisposition.PARTIAL + assert artifact["reason"] == LedgerReason.PYTHON_SOURCE_AMBIGUOUS + assert any( + event.get("path") == virtual_path + and event.get("reason_code") == LedgerReason.PYTHON_SOURCE_AMBIGUOUS + for event in context["inspection_ledger"] + ) + + +def test_nested_deceptive_python_shebang_remains_non_python(tmp_path: Path) -> None: + (tmp_path / "SKILL.md").write_text("# Nested non-Python\n", encoding="utf-8") + _write_archive( + tmp_path / "bundle.zip", + {"runner": b"#!/usr/bin/env node python3\nvalue = 1\n"}, + ) + + context = build_context({"skill_path": str(tmp_path)}) + virtual_path = "bundle.zip!/runner" + metadata = next(item for item in context["component_metadata"] if item["path"] == virtual_path) + + assert metadata["type"] != "python" + + def test_hidden_disguised_document_inventories_nested_executable_locally(tmp_path: Path) -> None: archive_path = tmp_path / ".instructions.docx.txt" _write_archive(archive_path, _document_members(**{"word/sync1.sh": b"#!/bin/sh\necho ok\n"})) diff --git a/tests/nodes/test_python_execution_surface_end_to_end.py b/tests/nodes/test_python_execution_surface_end_to_end.py new file mode 100644 index 000000000..038900b92 --- /dev/null +++ b/tests/nodes/test_python_execution_surface_end_to_end.py @@ -0,0 +1,339 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""End-to-end coverage for byte-derived Python execution surfaces.""" + +from __future__ import annotations + +import io +import zipfile +from pathlib import Path + +import pytest + +from skillspector.graph import graph + + +def _scan(root: Path) -> dict: + return graph.invoke( + { + "input_path": str(root), + "output_format": "json", + "use_llm": False, + } + ) + + +def _write_bundle(root: Path, files: dict[str, str | bytes]) -> None: + for relative_path, content in files.items(): + target = root / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + target.write_bytes(content) + else: + target.write_text(content, encoding="utf-8") + + +def _tm1_paths(result: dict) -> set[str]: + return {finding.file for finding in result["filtered_findings"] if finding.rule_id == "TM1"} + + +@pytest.mark.parametrize( + ("filename", "prefix"), + [ + pytest.param("run.pyw", "", id="python-window"), + pytest.param("run", "#!/usr/bin/env python3\n", id="extensionless-shebang"), + ], +) +def test_python_execution_surfaces_reach_static_and_behavioral_analyzers( + tmp_path: Path, + filename: str, + prefix: str, +) -> None: + _write_bundle( + tmp_path, + { + "SKILL.md": "# Python helper", + filename: ( + prefix + + "import subprocess\n" + + "enabled = True\n" + + "subprocess.run(command, shell=enabled)\n" + ), + }, + ) + + result = _scan(tmp_path) + metadata = next(row for row in result["component_metadata"] if row["path"] == filename) + + assert filename in _tm1_paths(result) + assert metadata["type"] == "python" + assert result["analysis_completeness"]["is_complete"] is True + + +def test_python_shebang_overrides_markdown_suffix_for_parse_limits(tmp_path: Path) -> None: + filename = "script.md" + _write_bundle( + tmp_path, + { + "SKILL.md": "# Python helper with misleading suffix", + filename: ('#!/usr/bin/env python3\npayload = "`$(resolve_tool).example` -rf /"\n'), + }, + ) + (tmp_path / filename).chmod(0o755) + + result = _scan(tmp_path) + metadata = next(row for row in result["component_metadata"] if row["path"] == filename) + exceptions = result["analysis_completeness"]["ledger_exceptions"] + + assert metadata["type"] == "python" + assert metadata["executable"] is True + assert result["analysis_completeness"]["is_complete"] is False + assert any( + row["path"] == filename and row["reason_code"] == "static_parse_limit" for row in exceptions + ) + + +@pytest.mark.parametrize("selector", ["-s", "--script", "--gui-script"]) +def test_uv_script_launcher_reaches_static_analyzers(tmp_path: Path, selector: str) -> None: + filename = "runner" + _write_bundle( + tmp_path, + { + "SKILL.md": "# uv Python helper", + filename: ( + f"#!/usr/bin/env -S uv run {selector}\n" + "import subprocess\n" + "enabled = True\n" + "subprocess.run(command, shell=enabled)\n" + ), + }, + ) + (tmp_path / filename).chmod(0o755) + + result = _scan(tmp_path) + metadata = next(row for row in result["component_metadata"] if row["path"] == filename) + + assert filename in _tm1_paths(result) + assert metadata["type"] == "python" + assert metadata["executable"] is True + assert result["analysis_completeness"]["is_complete"] is True + + +def test_uv_run_without_script_selector_is_analyzed_fail_closed(tmp_path: Path) -> None: + filename = "runner" + _write_bundle( + tmp_path, + { + "SKILL.md": "# Ambiguous uv helper", + filename: ( + "#!/usr/bin/env -S uv run\n" + "import subprocess\n" + "enabled = True\n" + "subprocess.run(command, shell=enabled)\n" + ), + }, + ) + (tmp_path / filename).chmod(0o755) + + result = _scan(tmp_path) + exceptions = result["analysis_completeness"]["ledger_exceptions"] + + assert filename in _tm1_paths(result) + assert result["analysis_completeness"]["is_complete"] is False + assert any( + row["path"] == filename and row["reason_code"] == "python_source_ambiguous" + for row in exceptions + ) + + +def test_bare_uv_run_launcher_is_analyzed_fail_closed(tmp_path: Path) -> None: + filename = "run" + _write_bundle( + tmp_path, + { + "SKILL.md": "# Bare uv helper", + filename: ( + "#!/usr/bin/env -S uv\n" + "import subprocess\n" + "enabled = True\n" + "subprocess.run(command, shell=enabled)\n" + ), + }, + ) + (tmp_path / filename).chmod(0o755) + + result = _scan(tmp_path) + metadata = next(row for row in result["component_metadata"] if row["path"] == filename) + exceptions = result["analysis_completeness"]["ledger_exceptions"] + + assert filename in _tm1_paths(result) + assert metadata["type"] == "other" + assert metadata["executable"] is True + assert result["analysis_completeness"]["is_complete"] is False + assert any( + row["path"] == filename and row["reason_code"] == "python_source_ambiguous" + for row in exceptions + ) + + +def test_uv_filesystem_alias_is_analyzed_fail_closed(tmp_path: Path) -> None: + filename = "runner" + _write_bundle( + tmp_path, + { + "SKILL.md": "# Platform-dependent uv helper", + filename: ( + "#!/usr/bin/env -S UV run --script\n" + "import subprocess\n" + "enabled = True\n" + "subprocess.run(command, shell=enabled)\n" + ), + }, + ) + (tmp_path / filename).chmod(0o755) + + result = _scan(tmp_path) + exceptions = result["analysis_completeness"]["ledger_exceptions"] + + assert filename in _tm1_paths(result) + assert result["analysis_completeness"]["is_complete"] is False + assert any( + row["path"] == filename and row["reason_code"] == "python_source_ambiguous" + for row in exceptions + ) + + +def test_uv_global_option_script_launcher_is_analyzed_fail_closed(tmp_path: Path) -> None: + filename = "runner" + _write_bundle( + tmp_path, + { + "SKILL.md": "# uv global-option helper", + filename: ( + "#!/usr/bin/env -S uv --offline run --script\n" + "import subprocess\n" + "enabled = True\n" + "subprocess.run(command, shell=enabled)\n" + ), + }, + ) + (tmp_path / filename).chmod(0o755) + + result = _scan(tmp_path) + exceptions = result["analysis_completeness"]["ledger_exceptions"] + + assert filename in _tm1_paths(result) + assert result["analysis_completeness"]["is_complete"] is False + assert any( + row["path"] == filename and row["reason_code"] == "python_source_ambiguous" + for row in exceptions + ) + + +def test_uv_option_like_source_path_is_analyzed_fail_closed(tmp_path: Path) -> None: + filename = "-runner" + _write_bundle( + tmp_path, + { + "SKILL.md": "# Option-like uv helper", + filename: ( + "#!/usr/bin/env -S uv run --script\n" + "import subprocess\n" + "enabled = True\n" + "subprocess.run(command, shell=enabled)\n" + ), + }, + ) + (tmp_path / filename).chmod(0o755) + + result = _scan(tmp_path) + exceptions = result["analysis_completeness"]["ledger_exceptions"] + + assert filename in _tm1_paths(result) + assert result["analysis_completeness"]["is_complete"] is False + assert any( + row["path"] == filename and row["reason_code"] == "python_source_ambiguous" + for row in exceptions + ) + + +def test_ambiguous_python_surface_is_analyzed_fail_closed(tmp_path: Path) -> None: + filename = "runner" + _write_bundle( + tmp_path, + { + "SKILL.md": "# Platform-dependent helper", + filename: ( + "#!/usr/bin/env -i python3\n" + "import subprocess\n" + "enabled = True\n" + "subprocess.run(command, shell=enabled)\n" + ), + }, + ) + + result = _scan(tmp_path) + exceptions = result["analysis_completeness"]["ledger_exceptions"] + + assert filename in _tm1_paths(result) + assert result["analysis_completeness"]["is_complete"] is False + assert any( + row["path"] == filename and row["reason_code"] == "python_source_ambiguous" + for row in exceptions + ) + + +def test_pep263_python_source_is_decoded_before_analysis(tmp_path: Path) -> None: + filename = "run.py" + source = ( + "# coding: latin-1\n# café\nimport subprocess\nsubprocess.run(command, shell=True)\n" + ).encode("latin-1") + _write_bundle(tmp_path, {"SKILL.md": "# Encoded helper", filename: source}) + + result = _scan(tmp_path) + + assert filename in _tm1_paths(result) + assert "café" in result["local_file_cache"][filename] + assert result["analysis_completeness"]["is_complete"] is True + + +def test_nested_extensionless_python_surface_is_analyzed(tmp_path: Path) -> None: + archive_bytes = io.BytesIO() + with zipfile.ZipFile(archive_bytes, "w") as archive: + archive.writestr( + "runner", + "#!/usr/bin/env python3\n" + "import subprocess\n" + "enabled = True\n" + "subprocess.run(command, shell=enabled)\n", + ) + _write_bundle( + tmp_path, + {"SKILL.md": "# Nested helper", "bundle.zip": archive_bytes.getvalue()}, + ) + + result = _scan(tmp_path) + virtual_path = "bundle.zip!/runner" + + assert virtual_path in _tm1_paths(result) + assert result["analysis_completeness"]["is_complete"] is True + + +def test_python_declared_encoding_failure_is_partial(tmp_path: Path) -> None: + filename = "broken.py" + _write_bundle( + tmp_path, + { + "SKILL.md": "# Broken helper", + filename: b"# coding: ascii\nname = '\xff'\n", + }, + ) + + result = _scan(tmp_path) + artifact = next(row for row in result["artifact_inventory"] if row["path"] == filename) + + assert filename not in result["local_file_cache"] + assert artifact["disposition"] == "partial" + assert artifact["reason"] == "python_source_decode_error" + assert result["analysis_completeness"]["is_complete"] is False diff --git a/tests/nodes/test_security_end_to_end.py b/tests/nodes/test_security_end_to_end.py index 76d47965d..d6bbdd81f 100644 --- a/tests/nodes/test_security_end_to_end.py +++ b/tests/nodes/test_security_end_to_end.py @@ -297,6 +297,81 @@ async def _assert_incomplete_across_public_surfaces( assert sc9["evidence"]["excluded_inspection_incomplete"] is True +@pytest.mark.parametrize( + "bound_value", + [ + pytest.param("True", id="boolean"), + pytest.param("'True'", id="reporter-truthy-string"), + ], +) +def test_tm1_bound_true_matches_literal_in_graph( + tmp_path: Path, + bound_value: str, +) -> None: + direct = tmp_path / "direct-shell" + bound = tmp_path / "bound-shell" + _write_bundle( + direct, + { + "SKILL.md": "# Shell helper", + "run.py": "import subprocess\nsubprocess.run(command, shell=True)\n", + }, + ) + _write_bundle( + bound, + { + "SKILL.md": "# Shell helper", + "run.py": ( + "import subprocess\n" + f"use_shell = {bound_value}\n" + "subprocess.run(command, shell=use_shell)\n" + ), + }, + ) + + direct_result = _scan(direct) + bound_result = _scan(bound) + direct_tm1 = _assert_rule(direct_result, "TM1", "run.py") + bound_tm1 = _assert_rule(bound_result, "TM1", "run.py") + + assert len(direct_tm1) == len(bound_tm1) == 1 + assert (bound_tm1[0].severity, bound_tm1[0].confidence) == ( + direct_tm1[0].severity, + direct_tm1[0].confidence, + ) + assert ( + bound_result["risk_score"], + bound_result["risk_severity"], + bound_result["risk_recommendation"], + ) == ( + direct_result["risk_score"], + direct_result["risk_severity"], + direct_result["risk_recommendation"], + ) + + +@pytest.mark.asyncio +async def test_tm1_bound_true_across_public_surfaces(tmp_path: Path) -> None: + bound = tmp_path / "bound-shell-public" + _write_bundle( + bound, + { + "SKILL.md": "# Shell helper", + "run.py": ( + "import subprocess\nuse_shell = True\nsubprocess.run(command, shell=use_shell)\n" + ), + }, + ) + + result = _scan(bound) + _assert_rule(result, "TM1", "run.py") + await _assert_rules_across_public_surfaces( + bound, + expected_locations={"TM1": {"run.py"}}, + python_result=result, + ) + + @pytest.mark.parametrize( ("finding", "normal_files", "bypass_files", "rule_id", "normal_path", "bypass_path"), [ diff --git a/tests/nodes/test_security_remediation.py b/tests/nodes/test_security_remediation.py index dd60f9e3d..141d0c68f 100644 --- a/tests/nodes/test_security_remediation.py +++ b/tests/nodes/test_security_remediation.py @@ -572,6 +572,8 @@ def forbidden_prework(*args: object, **kwargs: object) -> object: monkeypatch.setattr(build_context_module, "_read_file_cache", expiring_read_cache) monkeypatch.setattr(build_context_module, "decode_text", guarded_decode) monkeypatch.setattr(build_context_module, "_is_valid_oms_signature_bytes", forbidden_prework) + monkeypatch.setattr(build_context_module, "classify_python_source", forbidden_prework) + monkeypatch.setattr(python_ast_module, "may_be_python_source", forbidden_prework) monkeypatch.setattr(python_ast_module, "parse_python_source", forbidden_prework) monkeypatch.setattr(build_context_module, "_infer_file_type", forbidden_prework) @@ -581,6 +583,7 @@ def forbidden_prework(*args: object, **kwargs: object) -> object: assert { "signature_recognition", "reference_resolution", + "python_source_classification", "manifest", "python_ast_prewarm", "component_metadata", @@ -597,6 +600,78 @@ def forbidden_prework(*args: object, **kwargs: object) -> object: ) +def test_python_classification_overrun_has_one_runtime_work_identity( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + class FakeClock: + now = 0.0 + + def __call__(self) -> float: + return self.now + + fake_clock = FakeClock() + (tmp_path / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + (tmp_path / "runner").write_text( + "#!/usr/bin/env -S ${SKILLSPECTOR_INTERPRETER}\npass\n", + encoding="utf-8", + ) + original_classify = build_context_module.classify_python_source + + def expiring_classification(path: str, content: str) -> object: + result = original_classify(path, content) + if path == "runner": + fake_clock.now = 1.0 + return result + + monkeypatch.setattr(build_context_module, "MAX_BUNDLE_CACHE_SECONDS", 1.0) + monkeypatch.setattr(build_context_module, "monotonic", fake_clock) + monkeypatch.setattr( + build_context_module, + "classify_python_source", + expiring_classification, + ) + + result = build_context({"skill_path": str(tmp_path)}) + events = [ + event + for event in result["inspection_ledger"] + if event["phase"] == "python_source_classification" and event["path"] == "runner" + ] + + assert len(events) == 1 + assert events[0]["reason_code"] == LedgerReason.RUNTIME_LIMIT + work_ids = [event["work_id"] for event in result["inspection_ledger"]] + assert len(work_ids) == len(set(work_ids)) + + +def test_python_prewarm_runtime_downgrade_reaches_artifact_reference( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "SKILL.md").write_text("[runner](runner)\n", encoding="utf-8") + (tmp_path / "runner").write_text( + "#!/usr/bin/env python3\npass\n", + encoding="utf-8", + ) + + def limited_prewarm(*_args: object, **kwargs: object) -> None: + limitations = kwargs["runtime_limitations"] + assert isinstance(limitations, list) + limitations.append(("runner", 5.1)) + return None + + monkeypatch.setattr(build_context_module, "prewarm_python_ast_cache", limited_prewarm) + + result = build_context({"skill_path": str(tmp_path)}) + artifact = next(item for item in result["artifact_inventory"] if item["path"] == "runner") + reference = next( + item for item in result["artifact_references"] if item["target_path"] == "runner" + ) + + assert artifact["disposition"] == ArtifactDisposition.PARTIAL + assert artifact["reason"] == LedgerReason.RUNTIME_LIMIT.value + assert reference["disposition"] == ArtifactDisposition.PARTIAL + + def test_reference_limit_cannot_produce_complete_clean_graph_verdict(tmp_path: Path) -> None: (tmp_path / ".hidden.md").write_text("ordinary local notes", encoding="utf-8") candidates = "\n".join( diff --git a/tests/nodes/test_transitive_analyzer_deadlines.py b/tests/nodes/test_transitive_analyzer_deadlines.py index 54a651ab6..a5b1bcbfa 100644 --- a/tests/nodes/test_transitive_analyzer_deadlines.py +++ b/tests/nodes/test_transitive_analyzer_deadlines.py @@ -18,6 +18,8 @@ from skillspector.mcp_server import run_scan from skillspector.models import AnalyzerFinding, Finding, Location, Severity from skillspector.nodes.analyzers import ( + behavioral_ast, + behavioral_taint_tracking, mcp_tool_poisoning, semantic_developer_intent, semantic_quality_policy, @@ -26,6 +28,7 @@ ) from skillspector.nodes.build_context import build_context from skillspector.nodes.meta_analyzer import meta_analyzer +from skillspector.python_ast import PythonSourceClassification from skillspector.state import WorkflowResourceBudget @@ -177,6 +180,24 @@ def test_default_build_budget_is_shared_with_downstream_analyzers(tmp_path) -> N assert result["analyzer_status_events"][0]["status"] == "degraded" +@pytest.mark.parametrize("analyzer", [behavioral_ast, behavioral_taint_tracking]) +def test_behavioral_deadline_excludes_cached_non_python_work(analyzer: object) -> None: + state = { + "components": ["notes.md", "script.py"], + "local_file_cache": {"notes.md": "# Notes\n", "script.py": "pass\n"}, + "python_source_classifications": { + "notes.md": "non_python", + "script.py": "python", + }, + "workflow_resource_budget": _expired_workflow_budget(), + } + + result = analyzer.node(state) # type: ignore[attr-defined] + + assert [event["path"] for event in result["inspection_ledger"]] == ["script.py"] + assert result["inspection_ledger"][0]["reason_code"] is LedgerReason.RUNTIME_LIMIT + + def test_static_per_artifact_runtime_is_minimum_of_local_and_shared( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -199,6 +220,39 @@ def test_static_per_artifact_runtime_is_minimum_of_local_and_shared( analyze.assert_not_called() +def test_static_classification_time_is_not_subtracted_twice( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = SimpleNamespace( + ANALYZER_ID="deadline_static", + USES_PYTHON_SOURCE_TYPE=True, + analyze=MagicMock(return_value=[]), + ) + state = { + "components": ["runner"], + "file_cache": {"runner": "#!/usr/bin/env python3\npass\n"}, + } + scan = MagicMock(return_value=([], None, {})) + monkeypatch.setattr( + static_runner, + "transitive_remaining_seconds", + MagicMock(side_effect=[1.0, 0.6]), + ) + monkeypatch.setattr( + static_runner, + "resolve_python_source_classification", + MagicMock(return_value=PythonSourceClassification.PYTHON), + ) + monkeypatch.setattr(static_runner, "_scan_all_views_detailed", scan) + monkeypatch.setattr(static_runner.time, "monotonic", MagicMock(side_effect=[0.0, 0.7])) + + result = static_runner.run_static_patterns_with_ledger(state, [module]) + + assert scan.call_args.kwargs["started_at"] == 0.0 + assert scan.call_args.kwargs["timeout_seconds"] == 1.0 + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + + @pytest.mark.parametrize( "node", [ diff --git a/tests/test_python_ast.py b/tests/test_python_ast.py index 92adab09f..1b66d9c08 100644 --- a/tests/test_python_ast.py +++ b/tests/test_python_ast.py @@ -5,16 +5,1865 @@ from __future__ import annotations +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + import skillspector.python_ast as python_ast from skillspector.python_ast import ( + PythonSourceClassification, build_python_ast_cache, + classify_python_source, clear_python_ast_cache, + decode_python_source, get_python_ast, + is_python_source, + may_be_python_source, parse_python_source, prewarm_python_ast_cache, ) +@pytest.mark.parametrize( + ("path", "content"), + [ + pytest.param("run.py", "pass\n", id="py"), + pytest.param("run.PY", "pass\n", id="uppercase-py"), + pytest.param("run.pyw", "pass\n", id="pyw"), + pytest.param("run.PYW", "pass\n", id="uppercase-pyw"), + pytest.param("runner", "#!/usr/bin/python3\npass\n", id="direct-python"), + pytest.param( + "runner", + "#!/usr/bin/python3.14t\npass\n", + id="direct-free-threaded-python", + ), + pytest.param("runner", "#! /usr/local/bin/python3.12 -B\npass\n", id="direct-option"), + pytest.param( + "runner", + "#!/usr/bin/python3 -t\npass\n", + id="direct-legacy-tab-compatibility-option", + ), + pytest.param("runner", "#!/usr/bin/pypy3\npass\n", id="direct-pypy"), + pytest.param("runner", "#!/usr/bin/env python3\npass\n", id="env-python"), + pytest.param("runner", "#!/bin/env python3\npass\n", id="bin-env-python"), + pytest.param( + "runner", + b"#!/usr/bin/env python3\0 node\npass\n", + id="nul-terminated-env-python-bytes", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S python3\0 node\npass\n", + id="nul-terminated-env-split-python", + ), + pytest.param( + "runner", + "#!/usr/bin/env python3.13t\npass\n", + id="env-free-threaded-python", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S python3 -B\npass\n", + id="env-split-string", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S python3 -tt\npass\n", + id="env-legacy-tab-compatibility-option", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S python3 -tEB\npass\n", + id="env-clustered-legacy-tab-compatibility-option", + ), + pytest.param( + "runner", + "#!/usr/bin/env -Spython3 -B\npass\n", + id="env-attached-split-string", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S 'python3' -B\npass\n", + id="env-single-quoted-interpreter", + ), + pytest.param( + "runner", + '#!/usr/bin/env -S "python3" -B\npass\n', + id="env-double-quoted-interpreter", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S -i python3\npass\n", + id="env-split-ignore-environment-option", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S PYTHONSAFEPATH=1 python3\npass\n", + id="env-split-assignment", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S -- python3\npass\n", + id="env-split-option-terminator", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S -- PYTHONSAFEPATH=1 python3\npass\n", + id="env-split-assignment-after-option-terminator", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S -u PYTHONPATH python3\npass\n", + id="env-split-separate-option-operand", + ), + pytest.param( + "runner", + "#!/usr/bin/env -vS python3\npass\n", + id="env-verbose-split-string", + ), + pytest.param( + "runner", + "#!/usr/bin/env -iS python3\npass\n", + id="env-ignore-split-string", + ), + pytest.param( + "runner", + "#!/usr/bin/env -ivS python3\npass\n", + id="env-clustered-outer-split-string", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S FOO=café python3\npass\n", + id="env-split-unicode-assignment", + ), + pytest.param( + "runner", + b"#!/usr/bin/env -S FOO=caf\xc3\xa9 python3\npass\n", + id="env-split-unicode-assignment-bytes", + ), + pytest.param( + "runner", + b"#!/usr/bin/env -S FOO=bar\fpython3\npass\n", + id="env-split-form-feed-separator", + ), + pytest.param( + "runner", + r"#!/usr/bin/env -S python3\_-B" "\npass\n", + id="env-escaped-argument-separator", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S -Spython3\npass\n", + id="nested-env-split-string", + ), + pytest.param( + "runner", + "#!/usr/bin/python3" + + " " * (python_ast.MAX_PYTHON_SHEBANG_CHARS - len("#!/usr/bin/python3")) + + "\npass\n", + id="maximum-length-shebang", + ), + pytest.param( + "bundle.zip!/runner", + b"#!/usr/bin/env -S python3\r\npass\n", + id="nested-env-split-bytes-crlf", + ), + pytest.param( + "typing.pyi", + "#!/usr/bin/python3\npass\n", + id="pyi-with-execution-intent", + ), + ], +) +def test_is_python_source_accepts_supported_execution_surfaces( + path: str, content: str | bytes +) -> None: + assert is_python_source(path, content) + + +@pytest.mark.parametrize( + "selector", + [ + pytest.param("-s", id="short-script"), + pytest.param("--script", id="long-script"), + pytest.param("--gui-script", id="gui-script"), + ], +) +def test_uv_run_script_shebang_executes_appended_python_source(selector: str) -> None: + content = f"#!/usr/bin/env -S uv run {selector}\npass\n" + + assert classify_python_source("runner", content) is PythonSourceClassification.PYTHON + + +@pytest.mark.parametrize("selector", ["-s", "--script", "--gui-script"]) +def test_uv_script_source_with_option_like_invocation_is_ambiguous(selector: str) -> None: + content = f"#!/usr/bin/env -S uv run {selector}\npass\n" + + assert classify_python_source("-runner", content) is PythonSourceClassification.AMBIGUOUS + + +@pytest.mark.parametrize( + "arguments", + [ + pytest.param("run", id="implicit-command"), + pytest.param("run echo", id="explicit-command"), + pytest.param("run echo --script", id="selector-after-command"), + pytest.param("run --frozen --script", id="run-option-before-selector"), + pytest.param("--offline run --script", id="global-flag-before-run"), + pytest.param("--quiet run --script", id="global-quiet-before-run"), + pytest.param("--no-cache run --script", id="global-cache-before-run"), + pytest.param("--color never run --script", id="global-operand-before-run"), + pytest.param("--directory /tmp run --script", id="global-directory-before-run"), + pytest.param("run --script /tmp/other.py", id="explicit-script-operand"), + pytest.param("run --script=/tmp/other.py", id="attached-long-script"), + pytest.param("run --gui-script=/tmp/other.py", id="attached-gui-script"), + pytest.param("run -s/tmp/other.py", id="attached-short-script"), + ], +) +def test_uv_run_uncertain_source_selection_is_ambiguous(arguments: str) -> None: + content = f"#!/usr/bin/env -S uv {arguments}\npass\n" + + assert classify_python_source("runner", content) is PythonSourceClassification.AMBIGUOUS + + +@pytest.mark.parametrize( + ("path", "launcher"), + [ + pytest.param("run", "/usr/bin/env -S uv", id="env-command-name"), + pytest.param("run", "/usr/bin/env uv", id="env-opaque-command-name"), + pytest.param("nested/run", "/usr/bin/env -S uv", id="env-command-basename"), + pytest.param("nested/RUN", "/usr/bin/env -S uv", id="darwin-command-alias"), + pytest.param("--offline", "/usr/bin/env -S uv", id="env-global-option"), + pytest.param("run", "/usr/local/bin/uv", id="direct-command-name"), + pytest.param("-q", "/usr/local/bin/uv", id="direct-global-option"), + ], +) +def test_bare_uv_launcher_with_runtime_selectable_source_is_ambiguous( + path: str, launcher: str +) -> None: + content = f"#!{launcher}\npass\n" + + assert classify_python_source(path, content) is PythonSourceClassification.AMBIGUOUS + + +def test_bare_uv_launcher_with_inert_source_name_is_non_python() -> None: + content = "#!/usr/bin/env -S uv\npass\n" + + assert classify_python_source("runner", content) is PythonSourceClassification.NON_PYTHON + + +@pytest.mark.parametrize("utility", ["node", "bash", "uv --version"]) +def test_non_python_env_utilities_remain_non_python(utility: str) -> None: + content = f"#!/usr/bin/env -S {utility}\npass\n" + + assert classify_python_source("runner", content) is PythonSourceClassification.NON_PYTHON + + +@pytest.mark.skipif( + os.name != "posix" or shutil.which("uv") is None, + reason="POSIX shebang execution with uv is unavailable", +) +@pytest.mark.parametrize("selector", ["-s", "--script", "--gui-script"]) +def test_real_uv_script_launcher_executes_extensionless_python( + tmp_path: Path, selector: str +) -> None: + source = tmp_path / "runner" + marker = "UV_SCRIPT_EXECUTED" + source.write_text( + f'#!/usr/bin/env -S uv run {selector}\nprint("{marker}")\n', + encoding="utf-8", + ) + source.chmod(0o755) + + executed = subprocess.run( + [str(source)], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert executed.returncode == 0, executed.stderr + assert marker in executed.stdout + + +@pytest.mark.skipif( + os.name != "posix" or shutil.which("uv") is None, + reason="POSIX shebang execution with uv is unavailable", +) +@pytest.mark.parametrize("launcher_kind", ["env", "direct"]) +@pytest.mark.parametrize( + ("source_name", "caller_arguments"), + [ + pytest.param("run", ("--script", "run"), id="command-name"), + pytest.param( + "--offline", + ("run", "--script", "./--offline"), + id="global-option", + ), + ], +) +def test_real_bare_uv_launcher_can_select_source( + tmp_path: Path, + launcher_kind: str, + source_name: str, + caller_arguments: tuple[str, ...], +) -> None: + uv_path = shutil.which("uv") + assert uv_path is not None + if launcher_kind == "direct" and any(character.isspace() for character in uv_path): + pytest.skip("direct uv shebang path contains whitespace") + launcher = "/usr/bin/env -S uv" if launcher_kind == "env" else uv_path + source = tmp_path / source_name + marker = "UV_BARE_EXECUTED" + content = f'#!{launcher}\nprint("{marker}")\n' + source.write_text(content, encoding="utf-8") + source.chmod(0o755) + execve_probe = ( + "import os, sys; os.chdir(sys.argv[1]); os.execve(sys.argv[2], sys.argv[2:], os.environ)" + ) + + executed = subprocess.run( + [ + sys.executable, + "-c", + execve_probe, + str(tmp_path), + source.name, + *caller_arguments, + ], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert executed.returncode == 0, executed.stderr + assert marker in executed.stdout + assert classify_python_source(source.name, content) is PythonSourceClassification.AMBIGUOUS + + +def test_real_macos_uv_filesystem_alias_is_classified_ambiguous(tmp_path: Path) -> None: + if sys.platform != "darwin": + pytest.skip("case-insensitive uv alias proof is macOS-specific") + alias_probe = subprocess.run( + ["/usr/bin/env", "UV", "--version"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if alias_probe.returncode != 0: + pytest.skip("this macOS filesystem does not resolve UV to uv") + + source = tmp_path / "runner" + marker = "UV_ALIAS_EXECUTED" + content = f'#!/usr/bin/env -S UV run --script\nprint("{marker}")\n' + source.write_text(content, encoding="utf-8") + source.chmod(0o755) + + executed = subprocess.run( + [str(source)], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert executed.returncode == 0, executed.stderr + assert marker in executed.stdout + assert classify_python_source("runner", content) is PythonSourceClassification.AMBIGUOUS + + +@pytest.mark.skipif( + os.name != "posix" or shutil.which("uv") is None, + reason="POSIX shebang execution with uv is unavailable", +) +@pytest.mark.parametrize( + "arguments", + [ + "--offline run --script", + "--quiet run --script", + "--no-cache run --script", + "--color never run --script", + "--directory /tmp run --script", + ], +) +def test_real_uv_global_options_can_launch_python(tmp_path: Path, arguments: str) -> None: + source = tmp_path / "runner" + marker = "UV_GLOBAL_OPTION_EXECUTED" + content = f'#!/usr/bin/env -S uv {arguments}\nprint("{marker}")\n' + source.write_text(content, encoding="utf-8") + source.chmod(0o755) + + executed = subprocess.run( + [str(source)], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert executed.returncode == 0, executed.stderr + assert marker in executed.stdout + assert classify_python_source("runner", content) is PythonSourceClassification.AMBIGUOUS + + +@pytest.mark.skipif( + os.name != "posix" or shutil.which("uv") is None, + reason="POSIX shebang execution with uv is unavailable", +) +@pytest.mark.parametrize("selector", ["-s", "--script", "--gui-script"]) +def test_real_uv_option_like_source_spelling_has_multiple_branches( + tmp_path: Path, selector: str +) -> None: + source = tmp_path / "-runner" + marker = "UV_OPTION_LIKE_SOURCE_EXECUTED" + source.write_text( + f'#!/usr/bin/env -S uv run {selector}\nprint("{marker}")\n', + encoding="utf-8", + ) + source.chmod(0o755) + execve_probe = ( + "import os, sys; os.chdir(sys.argv[1]); os.execve(sys.argv[2], [sys.argv[2]], os.environ)" + ) + + option_like = subprocess.run( + [sys.executable, "-c", execve_probe, str(tmp_path), source.name], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + relative = subprocess.run( + [sys.executable, "-c", execve_probe, str(tmp_path), f"./{source.name}"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + absolute = subprocess.run( + [sys.executable, "-c", execve_probe, str(tmp_path), str(source)], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert option_like.returncode != 0 + assert marker not in option_like.stdout + assert relative.returncode == 0, relative.stderr + assert marker in relative.stdout + assert absolute.returncode == 0, absolute.stderr + assert marker in absolute.stdout + + +@pytest.mark.parametrize( + "content", + [ + pytest.param( + "#!/usr/local/bin/python3.14-intel64\npass\n", + id="python-org-versioned-intel64", + ), + pytest.param( + "#!/usr/bin/env python3-intel64\npass\n", + id="python-org-env-intel64", + ), + pytest.param( + "#!/usr/bin/env -S python3.14t-intel64 -B\npass\n", + id="python-org-free-threaded-intel64", + ), + pytest.param( + "#!/usr/local/bin/python3.12-32\npass\n", + id="python-org-legacy-32-bit", + ), + pytest.param( + "#!/usr/bin/python3.11d\npass\n", + id="cpython-debug-abi-flag", + ), + pytest.param( + "#!/usr/bin/env python3.11-dbg\npass\n", + id="debian-debug-interpreter", + ), + pytest.param( + "#!/usr/bin/env python3.7m\npass\n", + id="legacy-pymalloc-abi-flag", + ), + pytest.param( + "#!/usr/bin/env -S python3.2dmu -B\npass\n", + id="legacy-debug-pymalloc-unicode-abi-flags", + ), + ], +) +def test_python_org_macos_interpreter_aliases_are_python(content: str) -> None: + assert classify_python_source("runner", content) is PythonSourceClassification.PYTHON + + +@pytest.mark.parametrize( + "interpreter", + [ + pytest.param("python3.14-config", id="config-tool"), + pytest.param("python3.14-intel640", id="invalid-intel-suffix"), + pytest.param("python3.14-arm64", id="unsupported-arm-suffix"), + pytest.param("python3.11-debug", id="invalid-debug-suffix"), + pytest.param("python3.7md", id="misordered-legacy-abi-flags"), + pytest.param("python3.2dmm", id="repeated-legacy-abi-flag"), + ], +) +def test_python_like_macos_tools_are_not_interpreters(interpreter: str) -> None: + assert ( + classify_python_source("runner", f"#!/usr/local/bin/{interpreter}\npass\n") + is PythonSourceClassification.NON_PYTHON + ) + + +@pytest.mark.parametrize( + ("path", "content"), + [ + pytest.param("typing.pyi", "value: int\n", id="pyi-without-shebang"), + pytest.param("runner", None, id="missing-content"), + pytest.param("runner", "python3 is installed\n", id="prose"), + pytest.param("runner", "#!/usr/bin/env node\n", id="env-node"), + pytest.param("runner", "#!/usr/bin/env node python3\n", id="deceptive-env"), + pytest.param( + "runner", + "#!/usr/bin/env -L default python3\n", + id="darwin-rejects-freebsd-login-class-option", + ), + pytest.param( + "runner", + "#!/usr/bin/env -U name python3\n", + id="darwin-rejects-freebsd-unset-alt-option", + ), + pytest.param("runner", "#!/usr/bin/env python3 -h\n", id="darwin-python-help"), + pytest.param("runner", "#!/usr/bin/env python3 -?\n", id="darwin-python-help-alias"), + pytest.param("runner", "#!/usr/bin/env python3 -VV\n", id="darwin-python-version"), + pytest.param( + "runner", + "#!/usr/bin/env python3 --check-hash-based-pycs=default\n", + id="darwin-python-invalid-hash-option-equals-form", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S -u FOO=BAR python3\n", + id="env-split-invalid-short-unset-name", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S --unset '' python3\n", + id="env-split-empty-long-unset-name", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S --unset= python3\n", + id="env-split-empty-attached-long-unset-name", + ), + pytest.param("runner", "#!/usr/bin/env -S node python3\n", id="deceptive-env-s"), + pytest.param("runner", "#!/usr/bin/env -Snode python3\n", id="deceptive-attached-s"), + pytest.param( + "runner", + "#!/usr/bin/env python3 node\n", + id="non-split-extra-argument", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S --ignore python3\n", + id="ambiguous-long-option-abbreviation", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S --zunknown python3\n", + id="unknown-long-and-freebsd-cluster-option", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S -a alternate -P/usr/bin python3\n", + id="mixed-gnu-freebsd-short-options", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S --debug -P/usr/bin python3\n", + id="mixed-gnu-long-freebsd-short-options", + ), + pytest.param( + "runner", + '#!/usr/bin/env -S -S "-a alternate -P/usr/bin python3"\n', + id="nested-mixed-gnu-freebsd-options", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S FOO=bar\\ baz -a alternate python3\n", + id="mixed-freebsd-lexer-gnu-option", + ), + pytest.param( + "runner", + "#!/usr/bin/env --S-a alternate python3\n", + id="mixed-freebsd-outer-gnu-inner-option", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S PYTHONSAFEPATH=1 -i python3\n", + id="env-option-after-assignment", + ), + pytest.param( + "runner", + r"#!/usr/bin/env -S node ${SKILLSPECTOR_ARGUMENT}" "\n", + id="non-python-with-dynamic-argument", + ), + pytest.param( + "runner", + r"#!/usr/bin/env -S /opt/${SKILLSPECTOR_ROOT}/node" "\n", + id="fixed-node-basename-after-dynamic-path", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S - node python3\n", + id="lone-dash-still-selects-node", + ), + pytest.param( + "runner", + r"#!/usr/bin/env -S pyth\on3" "\n", + id="invalid-env-escape", + ), + pytest.param( + "runner", + r"#!/usr/bin/env -S python3\ -I" "\n", + id="freebsd-escaped-space-inside-utility", + ), + pytest.param( + "runner", + r'#!/usr/bin/env -S "python3\c"' "\n", + id="env-string-terminator-in-double-quotes", + ), + pytest.param( + "runner", + r'#!/usr/bin/env -S "python3\_-I"' "\n", + id="env-quoted-escaped-separator", + ), + pytest.param( + "runner", + "#!/usr/bin/env -xS python3\n", + id="unknown-outer-env-option", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S -P\n", + id="missing-env-option-operand", + ), + pytest.param("runner", "#!/bin/sh python3\n", id="shell-with-python-argument"), + pytest.param( + "runner", + b"#!/usr/bin/node\0/usr/bin/python3\npass\n", + id="nul-terminated-node-before-python-bytes", + ), + pytest.param( + "runner", + "#!/usr/bin/node\0/usr/bin/python3\npass\n", + id="nul-terminated-node-before-python-text", + ), + pytest.param( + "bundle.zip!/runner", + b"#!/usr/bin/env python3\r\npass\n", + id="plain-env-bytes-crlf", + ), + pytest.param("runner", "#!/tmp/env python3\n", id="untrusted-env-path"), + pytest.param("runner", " #!/usr/bin/env python3\n", id="leading-space"), + pytest.param("runner", "\ufeff#!/usr/bin/env python3\n", id="leading-bom"), + pytest.param("runner", "#!/usr/bin/env 'python3'\n", id="quoted-interpreter"), + pytest.param( + "runner", + "#!/usr/bin/env -S 'python3 -I'\n", + id="quoted-interpreter-with-option", + ), + pytest.param( + "runner", + "#!/usr/bin/env -S 'python3\n", + id="unterminated-env-quote", + ), + pytest.param("runner", "#!/usr/bin/env pyth\u03bfn3\n", id="unicode-confusable"), + pytest.param("runner", "#!/usr/bin/env -S python\u0663\n", id="unicode-version-digit"), + ], +) +def test_is_python_source_rejects_non_python_execution_surfaces( + path: str, content: str | bytes | None +) -> None: + assert not is_python_source(path, content) + + +@pytest.mark.parametrize( + "content", + [ + pytest.param( + r"#!/usr/bin/env -S -i${SKILLSPECTOR_EMPTY} python3" "\npass\n", + id="dynamic-option-suffix", + ), + pytest.param( + r"#!/usr/bin/env -S PATH=/usr/bin:${PATH} python3" "\npass\n", + id="dynamic-assignment-value", + ), + pytest.param( + r"#!/usr/bin/env -S X${SKILLSPECTOR_VALUE}=1 python3" "\npass\n", + id="dynamic-assignment-name-suffix", + ), + pytest.param( + r"#!/usr/bin/env -S -C${PWD}/work /usr/bin/python3" "\npass\n", + id="dynamic-chdir-operand-suffix", + ), + pytest.param( + r"#!/usr/bin/env -S -- -X${SKILLSPECTOR_VALUE}=1 python3" "\npass\n", + id="dynamic-dash-assignment-after-terminator", + ), + pytest.param( + r"#!/usr/bin/env -S -u${SKILLSPECTOR_MAYBE} python3" "\npass\n", + id="dynamic-attached-operand-can-consume-python-utility", + ), + pytest.param( + r"#!/usr/bin/env -S -u ${SKILLSPECTOR_MAYBE} python3" "\npass\n", + id="dynamic-separate-operand-can-consume-python-utility", + ), + pytest.param( + r"#!/usr/bin/env -S /opt/${SKILLSPECTOR_ROOT}/python3" "\npass\n", + id="dynamic-python-path-can-become-assignment", + ), + pytest.param( + r"#!/usr/bin/env -S /opt/${SKILLSPECTOR_ROOT}/pypy3" "\npass\n", + id="dynamic-pypy-path-can-become-assignment", + ), + pytest.param( + r"#!/usr/bin/env -S -C${PWD} /usr/bin/python3" "\npass\n", + id="dynamic-attached-chdir-can-consume-python-utility", + ), + pytest.param( + r"#!/usr/bin/env -S -- -x${SKILLSPECTOR_ROOT}/python3" "\npass\n", + id="dynamic-dash-path-can-become-assignment", + ), + pytest.param( + r"#!/usr/bin/env -S -u${SKILLSPECTOR_NAME} /usr/bin/true python3" + "\npass\n", + id="dynamic-attached-operand-python-after-shift", + ), + pytest.param( + r"#!/usr/bin/env -S -u ${SKILLSPECTOR_NAME} /usr/bin/true python3" + "\npass\n", + id="dynamic-separate-operand-python-after-shift", + ), + pytest.param( + r"#!/usr/bin/env -S --unset ${SKILLSPECTOR_NAME} /usr/bin/true python3" + "\npass\n", + id="dynamic-long-separate-operand-shift", + ), + pytest.param( + r"#!/usr/bin/env -S -P${PATH} /usr/bin/true python3" "\npass\n", + id="dynamic-bsd-attached-operand-shift", + ), + pytest.param( + "#!/usr/bin/python3# xnu comment\npass\n", + id="platform-dependent-direct-comment", + ), + pytest.param( + "#!/usr/bin/Python3\npass\n", + id="platform-dependent-interpreter-case-alias", + ), + pytest.param( + "#!/usr/bin/env Python3\npass\n", + id="platform-dependent-env-utility-case-alias", + ), + pytest.param( + "#!/usr/bin/Env python3\npass\n", + id="platform-dependent-env-path-case-alias", + ), + pytest.param( + "#!/usr//bin/env python3\npass\n", + id="platform-dependent-env-repeated-slash-alias", + ), + pytest.param( + "#!/usr/bin/./env python3\npass\n", + id="platform-dependent-env-dot-segment-alias", + ), + pytest.param( + "#!/usr/bin/../bin/env python3\npass\n", + id="platform-dependent-env-parent-segment-alias", + ), + pytest.param( + "#!//usr/bin/env python3\npass\n", + id="platform-dependent-env-double-leading-slash-alias", + ), + pytest.param( + "#!/usr/bin/python3 -W ignore /tmp/other.py\npass\n", + id="platform-dependent-python-warning-argument", + ), + pytest.param( + "#!/usr/bin/python3 -\npass\n", + id="python-stdin-can-load-appended-source", + ), + pytest.param( + "#!/usr/bin/env -S python3 -\npass\n", + id="env-python-stdin-can-load-appended-source", + ), + pytest.param( + "#!/usr/bin/python3 -X\npass\n", + id="python-xoption-operand-exposes-appended-source", + ), + pytest.param( + "#!/usr/bin/env -S python3 -X\npass\n", + id="env-python-xoption-operand-exposes-appended-source", + ), + pytest.param( + "#!/usr/bin/python3 -W\npass\n", + id="python-warning-operand-exposes-appended-source", + ), + pytest.param( + "#!/usr/bin/env -S python3 -W\npass\n", + id="env-python-warning-operand-exposes-appended-source", + ), + pytest.param( + "#!/usr/bin/env -i python3\npass\n", + id="platform-dependent-env-ignore-environment", + ), + pytest.param( + "#!/usr/bin/env -u SKILLSPECTOR_NAME python3\npass\n", + id="platform-dependent-env-unset", + ), + pytest.param( + "#!/usr/bin/env -C /tmp python3\npass\n", + id="platform-dependent-env-chdir", + ), + pytest.param( + "#!/usr/bin/env python3 -I\npass\n", + id="platform-dependent-python-option", + ), + pytest.param( + "#!/usr/bin/env python3 -vv\npass\n", + id="platform-dependent-python-repeated-option", + ), + pytest.param( + "#!/usr/bin/env python3 -IB\npass\n", + id="platform-dependent-python-clustered-options", + ), + pytest.param( + "#!/usr/bin/env python3 -bbb\npass\n", + id="platform-dependent-python-repeated-bytes-option", + ), + pytest.param( + "#!/usr/bin/env python3 -OOO\npass\n", + id="platform-dependent-python-repeated-optimize-option", + ), + pytest.param( + "#!/usr/bin/env -i python3 # xnu comment\npass\n", + id="platform-dependent-env-comment", + ), + pytest.param( + "#!/bin/sh#/python3\npass\n", + id="platform-dependent-direct-interpreter-identity", + ), + pytest.param( + "#!/usr/bin/env -S - -i python3\npass\n", + id="platform-dependent-lone-dash", + ), + pytest.param( + "#!/usr/bin/env -S --unset /usr/bin/true python3\npass\n", + id="platform-dependent-double-dash-option", + ), + pytest.param( + "#!/usr/bin/env -S-P/usr/bin:/bin python3\npass\n", + id="freebsd-darwin-attached-path-option", + ), + pytest.param( + "#!/usr/bin/env -S -i-v python3\npass\n", + id="freebsd-clustered-compatibility-option", + ), + pytest.param( + "#!/usr/bin/env -S-iv -P/usr/bin:/bin python3\npass\n", + id="freebsd-darwin-clustered-path-option", + ), + pytest.param( + "#!/usr/bin/env -S -a alternate python3\npass\n", + id="gnu-argv0-option", + ), + pytest.param( + "#!/usr/bin/env -S --argv0=alternate python3\npass\n", + id="gnu-long-argv0-option", + ), + pytest.param( + "#!/usr/bin/env -S --argv0= python3\npass\n", + id="gnu-empty-long-argv0-option", + ), + pytest.param( + "#!/usr/bin/env -S -L root python3\npass\n", + id="freebsd-login-class-separate-operand", + ), + pytest.param( + "#!/usr/bin/env -S -Lroot python3\npass\n", + id="freebsd-login-class-attached-operand", + ), + pytest.param( + "#!/usr/bin/env -S -ivLroot python3\npass\n", + id="freebsd-clustered-login-class-operand", + ), + pytest.param( + "#!/usr/bin/env -S =x python3\npass\n", + id="gnu-empty-name-assignment", + ), + pytest.param( + "#!/usr/bin/env -S FOO=bar =x python3\npass\n", + id="gnu-empty-name-assignment-after-assignment", + ), + pytest.param( + "#!/usr/bin/env -S -i =x python3\npass\n", + id="gnu-empty-name-assignment-after-option", + ), + pytest.param( + "#!/usr/bin/env -S -- - python3\npass\n", + id="gnu-post-terminator-legacy-dash", + ), + pytest.param( + "#!/usr/bin/env -S -- - FOO=bar python3\npass\n", + id="gnu-post-terminator-legacy-dash-before-assignment", + ), + pytest.param( + "#!/usr/bin/env -S --env0-from=environment python3\npass\n", + id="gnu-environment-file-option", + ), + pytest.param( + r"#!/usr/bin/env -S -P/usr/bin:${PATH} python3" "\npass\n", + id="freebsd-dynamic-path-operand", + ), + pytest.param( + r"#!/usr/bin/env -S -P${PATH} python3" "\npass\n", + id="freebsd-dynamic-attached-path-arity", + ), + pytest.param( + r'#!/usr/bin/env -S -P "${PATH}" /usr/bin/python3' "\npass\n", + id="freebsd-quoted-dynamic-path-operand", + ), + pytest.param( + r"#!/usr/bin/env -S -P ${PATH} /usr/bin/python3" "\npass\n", + id="freebsd-dynamic-separate-path-arity", + ), + pytest.param( + r"#!/usr/bin/env -S -u ${SKILLSPECTOR_NAME}#suffix python3" "\npass\n", + id="platform-dependent-dynamic-prefix-before-comment-marker", + ), + pytest.param( + "#!/usr/bin/env -i-vSpython3\npass\n", + id="freebsd-clustered-outer-split-string", + ), + pytest.param( + "#!/usr/bin/env --split=python3\npass\n", + id="gnu-outer-abbreviated-split-string", + ), + pytest.param( + "#!/usr/bin/env -S --chd=/tmp python3\npass\n", + id="gnu-abbreviated-long-chdir", + ), + pytest.param( + "#!/usr/bin/env -S --ignore-e python3\npass\n", + id="gnu-abbreviated-long-ignore-environment", + ), + pytest.param( + "#!/usr/bin/env -S --i python3\npass\n", + id="freebsd-double-dash-cluster", + ), + pytest.param( + "#!/usr/bin/env -S --unknown python3\npass\n", + id="freebsd-double-dash-unset-operand", + ), + pytest.param( + "#!/usr/bin/env -S --uns=PYTHONPATH python3\npass\n", + id="gnu-abbreviated-long-unset", + ), + pytest.param( + "#!/usr/bin/env -S FOO=bar\\ baz python3\npass\n", + id="freebsd-escaped-space", + ), + pytest.param( + r'#!/usr/bin/env -S -S "FOO=a\\\nb /usr/bin/python3"' "\npass\n", + id="freebsd-nested-escaped-newline", + ), + pytest.param( + "#!/usr/bin/env --split-string=python3\npass\n", + id="gnu-only-outer-long-split", + ), + pytest.param( + "#!/usr/bin/env --spl=python3\npass\n", + id="gnu-only-outer-abbreviated-split", + ), + pytest.param( + "#!/usr/bin/env -S --split-string=python3\npass\n", + id="gnu-only-nested-long-split", + ), + pytest.param( + "#!/usr/bin/env -S --spl=python3\npass\n", + id="gnu-only-nested-abbreviated-split", + ), + pytest.param( + "#!/usr/bin/env -S --ignore-environment python3\npass\n", + id="gnu-only-nested-ignore-environment", + ), + pytest.param( + "#!/usr/bin/env -S --unset=FOO python3\npass\n", + id="gnu-only-nested-unset", + ), + pytest.param( + "#!/usr/bin/env -S --unset FOO=BAR python3\npass\n", + id="gnu-invalid-unset-name-bsd-assignment", + ), + pytest.param( + "#!/usr/bin/env -S -u '' python3\npass\n", + id="darwin-xnu-retains-quotes-after-split-payload", + ), + pytest.param( + r"#!/usr/bin/env -S -- ${SKILLSPECTOR_NAME}=X python3" "\npass\n", + id="dynamic-assignment-name-after-option-terminator", + ), + pytest.param( + r"#!/usr/bin/env -S python3 ${SKILLSPECTOR_ARGUMENT}" "\npass\n", + id="dynamic-python-pre-script-argument", + ), + pytest.param( + r"#!/usr/bin/env -S python3\c node" "\npass\n", + id="platform-dependent-env-string-terminator", + ), + pytest.param( + r"#!/usr/bin/env -S python3 -- ${SKILLSPECTOR_ARGUMENT}" "\npass\n", + id="dynamic-python-argument-after-option-terminator", + ), + pytest.param( + r"#!/usr/bin/env -S python3 -- ${SKILLSPECTOR_ARGUMENT} /tmp/other.py" + "\npass\n", + id="dynamic-python-argument-before-fixed-other-script", + ), + pytest.param( + r"#!/usr/bin/env -S py${SKILLSPECTOR_EMPTY}thon3" "\npass\n", + id="dynamic-interpreter-fragment", + ), + pytest.param( + r"#!/usr/bin/env -S ${SKILLSPECTOR_INTERPRETER}" "\npass\n", + id="dynamic-utility", + ), + pytest.param( + r"#!/usr/bin/env -S X${SKILLSPECTOR_ASSIGNMENT} python3" "\npass\n", + id="dynamic-token-role", + ), + pytest.param( + r"#!/usr/bin/env -S ${SKILLSPECTOR_INTERPRETER}#suffix python3" "\npass\n", + id="dynamic-utility-before-comment-marker", + ), + pytest.param( + r"#!/usr/bin/env -S ${SKILLSPECTOR_OPTION} python3" "\npass\n", + id="dynamic-leading-token", + ), + pytest.param( + r"#!/usr/bin/env -S --split-string=${SKILLSPECTOR_SPLIT}" "\npass\n", + id="dynamic-nested-split-string", + ), + pytest.param( + r"#!/usr/bin/env -S --spl=${SKILLSPECTOR_SPLIT}" "\npass\n", + id="dynamic-abbreviated-nested-split-string", + ), + pytest.param( + r"#!/usr/bin/env -S -S ${SKILLSPECTOR_SPLIT}" "\npass\n", + id="dynamic-nested-short-split-string", + ), + pytest.param( + "#!/usr/bin/env python3 node\npass\n", + id="external-python-script-via-plain-env", + ), + pytest.param( + "#!/usr/bin/python3 /tmp/other.py\npass\n", + id="external-python-script-direct", + ), + pytest.param( + "#!/usr/bin/env -S python3 /tmp/other.py\npass\n", + id="external-python-script-via-env-split", + ), + pytest.param( + r"#!/usr/bin/env -S python3 argument\ with-space" "\npass\n", + id="external-python-script-with-escaped-space", + ), + pytest.param( + r"#!/usr/bin/env -S -u${SKILLSPECTOR_NAME} python3 /usr/bin/true" + "\npass\n", + id="external-script-after-dynamic-attached-operand", + ), + pytest.param( + r"#!/usr/bin/env -S -u ${SKILLSPECTOR_NAME} python3 /usr/bin/true" + "\npass\n", + id="external-script-after-dynamic-separate-operand", + ), + pytest.param( + r"#!/usr/bin/env -S -u${SKILLSPECTOR_NAME} python3 ''" "\npass\n", + id="platform-dependent-empty-script-argument", + ), + pytest.param( + r"#!/usr/bin/env -S /opt/${SKILLSPECTOR_ROOT}/python3 /usr/bin/true" + "\npass\n", + id="external-script-after-dynamic-python-path", + ), + pytest.param( + r"#!/usr/bin/env -S -- -x${SKILLSPECTOR_ROOT}/python3 /usr/bin/true" + "\npass\n", + id="external-script-after-dynamic-dash-path", + ), + pytest.param( + "#!/usr/bin/python3" + + " " * (python_ast.MAX_PYTHON_SHEBANG_CHARS - len("#!/usr/bin/python3") + 1) + + "\npass\n", + id="over-maximum-length-shebang", + ), + ], +) +def test_uncertain_shebang_is_explicitly_ambiguous(content: str) -> None: + assert classify_python_source("runner", content) is PythonSourceClassification.AMBIGUOUS + assert not is_python_source("runner", content) + assert may_be_python_source("runner", content) + + +@pytest.mark.parametrize( + "content", + [ + pytest.param("#!/usr/bin/env -S -L python3\n", id="separate-missing-utility"), + pytest.param("#!/usr/bin/env -S -iL python3\n", id="clustered-after-ignore"), + pytest.param("#!/usr/bin/env -S -vL python3\n", id="clustered-after-verbose"), + pytest.param("#!/usr/bin/env -iLSpython3\n", id="outer-operand-not-split"), + ], +) +def test_freebsd_login_class_option_requires_an_operand(content: str) -> None: + assert classify_python_source("runner", content) is PythonSourceClassification.NON_PYTHON + + +@pytest.mark.parametrize( + "content", + [ + pytest.param("#!/usr/bin/env -S =BAD python3\n", id="empty-name"), + pytest.param("#!/usr/bin/env -S -- =BAD python3\n", id="after-option-terminator"), + pytest.param("#!/usr/bin/env -S == python3\n", id="repeated-equals"), + pytest.param("#!/usr/bin/env -S = python3\n", id="bare-equals"), + pytest.param( + "#!/usr/bin/env -S FOO=bar =x python3\n", + id="after-valid-assignment", + ), + pytest.param("#!/usr/bin/env -S -i =x python3\n", id="after-ignore-option"), + ], +) +def test_empty_env_assignment_name_is_platform_ambiguous(content: str) -> None: + assert classify_python_source("runner", content) is PythonSourceClassification.AMBIGUOUS + + +@pytest.mark.parametrize( + "content", + [ + pytest.param("#!/usr/bin/env -S -- - python3\n", id="direct-utility"), + pytest.param( + "#!/usr/bin/env -S -- - FOO=bar python3\n", + id="before-assignment", + ), + ], +) +def test_gnu_post_terminator_legacy_dash_is_platform_ambiguous(content: str) -> None: + assert classify_python_source("runner", content) is PythonSourceClassification.AMBIGUOUS + + +@pytest.mark.parametrize( + "content", + [ + pytest.param("#!/usr/bin/env -S -- - - python3\n", id="repeated-dash"), + pytest.param("#!/usr/bin/env -S -- FOO=bar - python3\n", id="after-assignment"), + ], +) +def test_gnu_legacy_dash_is_only_recognized_immediately_after_getopt(content: str) -> None: + assert classify_python_source("runner", content) is PythonSourceClassification.NON_PYTHON + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param("-i -u A=B python3", id="short-separate-clear-before"), + pytest.param("-u A=B -i python3", id="short-separate-clear-after"), + pytest.param("-i -uA=B python3", id="short-attached-clear-before"), + pytest.param("-uA=B -i python3", id="short-attached-clear-after"), + pytest.param( + "--ignore-environment --unset A=B python3", + id="long-separate-clear-before", + ), + pytest.param( + "--unset A=B --ignore-environment python3", + id="long-separate-clear-after", + ), + pytest.param( + "--ignore-environment --unset=A=B python3", + id="long-attached-clear-before", + ), + pytest.param( + "--unset=A=B --ignore-environment python3", + id="long-attached-clear-after", + ), + pytest.param("-i -u '' python3", id="empty-short-operand"), + pytest.param("-i --unset= python3", id="empty-long-attached-operand"), + pytest.param("-i -u = python3", id="equals-short-operand"), + pytest.param("-u A=B - python3", id="legacy-clear-after"), + pytest.param("-iuA=B python3", id="same-cluster-clear-before"), + pytest.param("-iS-uA=B python3", id="nested-split-clear-before"), + ], +) +def test_gnu_clear_environment_skips_invalid_queued_unsets(payload: str) -> None: + content = f"#!/usr/bin/env -S {payload}\npass\n" + assert classify_python_source("runner", content) is PythonSourceClassification.AMBIGUOUS + + +@pytest.mark.parametrize( + "content", + [ + pytest.param( + "#!/usr/bin/env -iS-uA=B python3\npass\n", + id="outer-short-attached-unset", + ), + pytest.param( + "#!/usr/bin/env -viS-u A=B python3\npass\n", + id="outer-cluster-separate-unset", + ), + ], +) +def test_gnu_outer_split_clear_environment_is_retained(content: str) -> None: + assert classify_python_source("runner", content) is PythonSourceClassification.AMBIGUOUS + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param("-u A=B python3", id="short-separate"), + pytest.param("-uA=B python3", id="short-attached"), + pytest.param( + "--split-string='--unset A=B python3'", + id="gnu-outer-long-separate", + ), + pytest.param("--unset=A=B python3", id="long-attached"), + pytest.param("--unset= python3", id="long-attached-empty"), + pytest.param("- -u A=B python3", id="legacy-clear-before-ends-options"), + pytest.param( + "-i --env0-from=environment -u A=B python3", + id="env0-from-after-clear", + ), + pytest.param( + "-u A=B -i --env0-from environment python3", + id="env0-from-after-unset", + ), + ], +) +def test_invalid_gnu_unset_without_effective_clear_cannot_execute(payload: str) -> None: + content = f"#!/usr/bin/env -S {payload}\npass\n" + assert classify_python_source("runner", content) is PythonSourceClassification.NON_PYTHON + + +@pytest.mark.parametrize( + "split_payload", + [ + pytest.param( + r"-u${SKILLSPECTOR_MAYBE} python3", + id="attached-unset-operand", + ), + pytest.param( + r"-u ${SKILLSPECTOR_MAYBE} python3", + id="separate-unset-operand", + ), + ], +) +def test_real_env_split_substitution_has_executing_and_nonexecuting_branches( + tmp_path: Path, + split_payload: str, +) -> None: + source = tmp_path / "runner" + marker = "ENV_BRANCH_EXECUTED" + source.write_text(f'print("{marker}")\n', encoding="utf-8") + assert not os.access(source, os.X_OK) + + runtime_environment = dict(os.environ) + runtime_environment["SKILLSPECTOR_MAYBE"] = "SKILLSPECTOR_UNUSED" + executed = subprocess.run( + ["/usr/bin/env", "-S", split_payload, str(source)], + env=runtime_environment, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + assert executed.returncode == 0 + assert marker in executed.stdout + + runtime_environment.pop("SKILLSPECTOR_MAYBE") + not_executed = subprocess.run( + ["/usr/bin/env", "-S", split_payload, str(source)], + env=runtime_environment, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + assert not_executed.returncode != 0 + assert marker not in not_executed.stdout + + +def test_plain_env_opaque_path_with_spaces_is_platform_ambiguous() -> None: + content = "#!/usr/bin/env /tmp/a b/python3\npass\n" + + assert classify_python_source("runner", content) is PythonSourceClassification.AMBIGUOUS + assert may_be_python_source("runner", content) + + +def test_bare_python_command_option_consumes_inert_appended_path() -> None: + assert ( + classify_python_source("runner", "#!/usr/bin/python3 -c\npass\n") + is PythonSourceClassification.NON_PYTHON + ) + + +@pytest.mark.parametrize( + "path", + [ + pytest.param("eval(input())", id="active-basename"), + pytest.param("tools/eval(input())", id="active-relative-basename"), + ], +) +def test_bare_python_command_can_execute_active_source_spelling(path: str) -> None: + assert ( + classify_python_source(path, "#!/usr/bin/env -S python3 -c\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize("path", ["runner", "pass", "invalid("]) +def test_bare_python_command_inert_or_invalid_source_spelling_is_non_python(path: str) -> None: + assert ( + classify_python_source(path, "#!/usr/bin/env -S python3 -c\npass\n") + is PythonSourceClassification.NON_PYTHON + ) + + +@pytest.mark.parametrize("path", ["runner", "pkg.evil", "tools/a-b", "eval(input())"]) +def test_bare_python_module_can_select_external_module_for_source(path: str) -> None: + assert ( + classify_python_source(path, "#!/usr/bin/env -S python3 -m\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + ("launcher", "option"), + [ + pytest.param("/usr/bin/python3", "-ic", id="direct-command-cluster"), + pytest.param("/usr/bin/python3", "-im", id="direct-module-cluster"), + pytest.param("/usr/bin/python3", "-Iic", id="direct-isolated-command-cluster"), + pytest.param("/usr/bin/python3", "-Iim", id="direct-isolated-module-cluster"), + pytest.param("/usr/bin/env -S python3", "-i -c", id="env-command-separate"), + pytest.param("/usr/bin/env -S python3", "-ic", id="env-command-cluster"), + pytest.param("/usr/bin/env -S python3", "-I -i -c", id="env-isolated-command-separate"), + pytest.param("/usr/bin/env -S python3", "-Iic", id="env-isolated-command-cluster"), + pytest.param("/usr/bin/env -S python3", "-i -m", id="env-module-separate"), + pytest.param("/usr/bin/env -S python3", "-im", id="env-module-cluster"), + pytest.param("/usr/bin/env -S python3", "-I -i -m", id="env-isolated-module-separate"), + pytest.param("/usr/bin/env -S python3", "-Iim", id="env-isolated-module-cluster"), + ], +) +def test_forced_interactive_bare_command_option_can_recover_appended_path( + launcher: str, option: str +) -> None: + assert ( + classify_python_source("runner", f"#!{launcher} {option}\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + "option", + ["-i -V", "-iV", "-i -h", "-ih", "-i --version", "-i --help"], +) +def test_forced_interactive_terminal_option_remains_non_python(option: str) -> None: + assert ( + classify_python_source( + "runner", + f"#!/usr/bin/env -S python3 {option}\npass\n", + ) + is PythonSourceClassification.NON_PYTHON + ) + + +def test_forced_interactive_missing_hash_option_operand_remains_non_python() -> None: + assert ( + classify_python_source( + "runner", + "#!/usr/bin/env -S python3 -i --check-hash-based-pycs\npass\n", + ) + is PythonSourceClassification.NON_PYTHON + ) + + +@pytest.mark.parametrize( + "arguments", + [ + "PYTHONINSPECT=1 python3 -c", + "PYTHONINSPECT=0 python3 -m", + "-i PYTHONINSPECT=yes python3 -c", + "PYTHONINSPECT= PYTHONINSPECT=enabled python3 -m", + ], +) +def test_static_pythoninspect_can_recover_bare_command_path(arguments: str) -> None: + assert ( + classify_python_source( + "runner", + f"#!/usr/bin/env -S {arguments}\npass\n", + ) + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + "arguments", + [ + "PYTHONINSPECT= python3 -c", + "PYTHONINSPECT=enabled PYTHONINSPECT= python3 -c", + "PYTHONINSPECT=enabled python3 -E -c", + "PYTHONINSPECT=enabled python3 -I -c", + ], +) +def test_pythoninspect_disabled_or_ignored_keeps_bare_command_non_python( + arguments: str, +) -> None: + assert ( + classify_python_source( + "runner", + f"#!/usr/bin/env -S {arguments}\npass\n", + ) + is PythonSourceClassification.NON_PYTHON + ) + + +@pytest.mark.parametrize( + "arguments", + [ + "--env0-from=/tmp/environment python3 -c", + "-L login python3 -c", + "-U user python3 -m", + "-u PYTHONINSPECT -L login python3 -c", + "-L login -u PYTHONINSPECT python3 -m", + "-L login PYTHONINSPECT=enabled python3 -c", + ], +) +def test_environment_sources_make_pythoninspect_runtime_dependent(arguments: str) -> None: + assert ( + classify_python_source( + "runner", + f"#!/usr/bin/env -S {arguments}\npass\n", + ) + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + "arguments", + [ + "--env0-from=/tmp/environment -u PYTHONINSPECT python3 -c", + "-u PYTHONINSPECT --env0-from=/tmp/environment python3 -c", + "--env0-from=/tmp/environment --unset=PYTHONINSPECT python3 -c", + "--unset PYTHONINSPECT --env0-from=/tmp/environment python3 -c", + "--env0-from=/tmp/environment PYTHONINSPECT= python3 -c", + "-L login PYTHONINSPECT= python3 -c", + "--env0-from=/tmp/environment python3 -E -c", + "-L login python3 -I -c", + ], +) +def test_final_pythoninspect_removal_disables_environment_repl(arguments: str) -> None: + assert ( + classify_python_source( + "runner", + f"#!/usr/bin/env -S {arguments}\npass\n", + ) + is PythonSourceClassification.NON_PYTHON + ) + + +@pytest.mark.parametrize( + ("path", "launcher"), + [ + pytest.param("default", "/usr/bin/python3", id="direct-default"), + pytest.param("tools/always", "/usr/bin/python3", id="direct-subdirectory-always"), + pytest.param( + "bundle.zip!/never", + "/usr/bin/env -S python3", + id="env-nested-like-never", + ), + pytest.param( + "tools/Default", + "/usr/bin/env -S python3", + id="env-case-insensitive-default-alias", + ), + ], +) +def test_bare_python_hash_option_can_consume_runtime_source_alias(path: str, launcher: str) -> None: + assert ( + classify_python_source( + path, + f"#!{launcher} --check-hash-based-pycs\npass\n", + ) + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize("path", ["runner", "tools/default.txt", "/tmp/runner"]) +@pytest.mark.parametrize("launcher", ["/usr/bin/python3", "/usr/bin/env -S python3"]) +def test_bare_python_hash_option_rejects_non_value_source_path(path: str, launcher: str) -> None: + assert ( + classify_python_source( + path, + f"#!{launcher} --check-hash-based-pycs\npass\n", + ) + is PythonSourceClassification.NON_PYTHON + ) + + +def test_explicit_python_hash_option_operand_preserves_appended_source() -> None: + assert ( + classify_python_source( + "runner", + "#!/usr/bin/env -S python3 --check-hash-based-pycs default\npass\n", + ) + is PythonSourceClassification.AMBIGUOUS + ) + assert ( + classify_python_source( + "runner", + "#!/usr/bin/python3 --check-hash-based-pycs default\npass\n", + ) + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + "interpreter", + [ + pytest.param("python3.6", id="known-old-version"), + pytest.param("python3", id="unversioned-minor"), + pytest.param("python3.14", id="known-new-version-conservative"), + ], +) +@pytest.mark.parametrize( + "option", + [ + pytest.param("--check-hash-based-pycs default", id="hash-based-pycs"), + pytest.param("-I", id="isolated-mode"), + pytest.param("-P", id="safe-path"), + pytest.param("-q", id="quiet-mode"), + ], +) +def test_version_dependent_python_option_is_ambiguous(interpreter: str, option: str) -> None: + assert ( + classify_python_source( + "runner", + f"#!/usr/bin/env -S {interpreter} {option}\npass\n", + ) + is PythonSourceClassification.AMBIGUOUS + ) + + +def test_version_dependent_python_option_followed_by_help_is_non_python() -> None: + assert ( + classify_python_source( + "runner", + "#!/usr/bin/env -S python3 -Ph\npass\n", + ) + is PythonSourceClassification.NON_PYTHON + ) + + +@pytest.mark.parametrize( + ("path", "launcher"), + [ + pytest.param("-h", "/usr/bin/python3", id="root-dash-basename-direct"), + pytest.param( + "tools/-V", + "/usr/bin/env -S python3", + id="subdirectory-dash-basename-env-split", + ), + pytest.param( + "tools/-options/runner", + "/usr/bin/python3 -B", + id="dash-intermediate-component-direct", + ), + pytest.param( + "bundle.zip!/-nested/runner", + "/usr/bin/env -S python3", + id="nested-dash-component-env-split", + ), + ], +) +def test_implicit_python_source_path_can_be_reparsed_as_an_option(path: str, launcher: str) -> None: + assert ( + classify_python_source(path, f"#!{launcher}\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + + +def test_python_option_terminator_protects_dash_prefixed_appended_source() -> None: + assert ( + classify_python_source( + "tools/-h", + "#!/usr/bin/env -S python3 --\npass\n", + ) + is PythonSourceClassification.PYTHON + ) + + +@pytest.mark.parametrize("option", ["-cpass", "-mrunpy", "-m trace --trace"]) +def test_supplied_python_command_can_execute_appended_path(option: str) -> None: + assert ( + classify_python_source("runner", f"#!/usr/bin/python3 {option}\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + ("path", "argument"), + [ + pytest.param("/tmp/runner", "/tmp/runner", id="same-absolute-path"), + ], +) +def test_python_script_argument_can_select_analyzed_source(path: str, argument: str) -> None: + assert ( + classify_python_source(path, f"#!/usr/bin/python3 {argument}\npass\n") + is PythonSourceClassification.PYTHON + ) + + +@pytest.mark.parametrize("argument", ["runner", "./runner"]) +def test_python_relative_script_argument_depends_on_invocation_cwd(argument: str) -> None: + assert ( + classify_python_source("runner", f"#!/usr/bin/python3 {argument}\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + + +def test_python_script_alias_to_analyzed_source_is_ambiguous() -> None: + assert ( + classify_python_source("runner", "#!/usr/bin/python3 /tmp/runner\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + ("path", "argument"), + [ + pytest.param("Runner", "runner", id="case-insensitive-volume"), + pytest.param("rúnner", "ru\u0301nner", id="unicode-normalizing-volume"), + ], +) +def test_python_script_filesystem_alias_is_ambiguous(path: str, argument: str) -> None: + assert ( + classify_python_source(path, f"#!/usr/bin/python3 {argument}\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + ("path", "argument"), + [ + pytest.param("dir/runner", r"dir\runner", id="backslash-in-argument"), + pytest.param(r"dir\runner", "dir/runner", id="backslash-in-source-path"), + pytest.param("runner", "link/../runner", id="parent-through-symlink"), + ], +) +def test_python_script_lexical_path_alias_is_not_certified_as_self( + path: str, argument: str +) -> None: + assert ( + classify_python_source(path, f"#!/usr/bin/python3 {argument}\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("X=/tmp/python3", id="assignment"), + pytest.param("/tmp/X=/python3", id="assignment-with-path-like-name"), + pytest.param("-P/tmp/python3", id="freebsd-option-operand"), + ], +) +def test_plain_env_opaque_non_utility_is_not_python(command: str) -> None: + assert ( + classify_python_source("runner", f"#!/usr/bin/env {command}\npass\n") + is PythonSourceClassification.NON_PYTHON + ) + + +def test_linux_shebang_buffer_boundaries_are_explicitly_ambiguous() -> None: + prefix = "#!/usr/bin/env -S python3" + line_at_127_bytes = prefix + " " * (125 - len(prefix)) + "-h" + line_at_128_bytes = prefix + " " * (126 - len(prefix)) + "-h" + line_at_255_bytes = prefix + " " * (253 - len(prefix)) + "-h" + line_at_256_bytes = prefix + " " * (254 - len(prefix)) + "-h" + + assert len(line_at_127_bytes.encode()) == 127 + assert len(line_at_128_bytes.encode()) == 128 + assert len(line_at_255_bytes.encode()) == 255 + assert len(line_at_256_bytes.encode()) == 256 + assert ( + classify_python_source("runner", line_at_127_bytes + "\npass\n") + is PythonSourceClassification.NON_PYTHON + ) + for line in (line_at_128_bytes, line_at_255_bytes, line_at_256_bytes): + assert ( + classify_python_source("runner", line + "\npass\n") + is PythonSourceClassification.AMBIGUOUS + ) + assert ( + classify_python_source("runner", (line + "\npass\n").encode()) + is PythonSourceClassification.AMBIGUOUS + ) + + +def test_legacy_linux_shebang_buffer_is_included_in_classification() -> None: + prefix = "#!/usr/bin/env -S python3" + line = prefix + " " * (130 - len(prefix)) + "-h" + + assert len(line.encode()) == 132 + assert ( + classify_python_source("runner", line + "\npass\n") is PythonSourceClassification.AMBIGUOUS + ) + + +def test_linux_shebang_views_with_same_identity_remain_definite() -> None: + line = "#!/usr/bin/env -S python3" + " " * 300 + + assert classify_python_source("runner", line + "\npass\n") is PythonSourceClassification.PYTHON + + +def test_linux_shebang_buffer_boundary_is_measured_in_bytes() -> None: + line = "#!/usr/bin/env -S FOO=" + "é" * 105 + " python3" + " " * 30 + "node" + + assert len(line) < 256 + assert len(line.encode()) >= 256 + assert ( + classify_python_source("runner", line + "\npass\n") is PythonSourceClassification.AMBIGUOUS + ) + + +@pytest.mark.parametrize( + ("raw", "marker"), + [ + pytest.param( + b"#! latin-1 comment: \xff\n# coding: latin-1\nvalue = '\xff'\n", + "ÿ", + id="non-utf8-first-comment-line-second-line-latin1-cookie", + ), + pytest.param( + b"# comment: \xff\n# coding: iso_latin_1\nvalue = '\xff'\n", + "ÿ", + id="canonical-latin1-alias", + ), + pytest.param( + b"# comment: \xff\r# coding: latin-1\rvalue = '\xff'\r", + "ÿ", + id="second-physical-line-cookie-with-cr-newlines", + ), + pytest.param( + b"# comment: \xff\r\n# coding: latin-1\r\nvalue = '\xff'\r\n", + "ÿ", + id="crlf-counts-as-one-physical-newline", + ), + pytest.param( + b"# coding: UTF_8\nvalue = '\xc3\xa9'\n", + "é", + id="canonical-utf8-alias", + ), + pytest.param( + b"\xef\xbb\xbf# comment\n# coding: utf-8\nvalue = 'ok'\n", + "value = 'ok'", + id="utf8-bom-compatible-cookie", + ), + ], +) +def test_decode_python_source_matches_python314_pep263_detection(raw: bytes, marker: str) -> None: + decoded = decode_python_source(raw) + + assert marker in decoded + assert not decoded.startswith("\ufeff") + + +@pytest.mark.parametrize( + ("raw", "error_type"), + [ + pytest.param( + b"value = '\xff'\n# coding: latin-1\n", + SyntaxError, + id="cookie-after-non-comment-source-line", + ), + pytest.param( + b"\xef\xbb\xbf# comment\n# coding: latin-1\n", + SyntaxError, + id="utf8-bom-conflicts-with-latin1-cookie", + ), + pytest.param( + b"# comment\n# coding: definitely-unknown\n", + SyntaxError, + id="unknown-codec", + ), + pytest.param( + b"# coding: utf-8\n# second\nvalue = '\xff'\n", + UnicodeDecodeError, + id="full-buffer-invalid-decode", + ), + pytest.param( + b"# first\n# second\n# coding: latin-1\nvalue = '\xff'\n", + UnicodeDecodeError, + id="third-line-cookie-is-ignored", + ), + pytest.param( + b"# first\r# second\r# coding: latin-1\rvalue = '\xff'\r", + UnicodeDecodeError, + id="third-physical-line-cookie-with-cr-newlines", + ), + pytest.param( + b"# first\r# second\n# coding: latin-1\nvalue = '\xff'\n", + UnicodeDecodeError, + id="third-physical-line-cookie-with-mixed-newlines", + ), + ], +) +def test_decode_python_source_rejects_python314_pep263_errors( + raw: bytes, error_type: type[Exception] +) -> None: + with pytest.raises(error_type): + decode_python_source(raw) + + +def test_decode_python_source_normalizes_mixed_newlines_before_detection() -> None: + raw = b"\r\n\t#coding:utf_16be\rx=1\n" + + with pytest.raises(SyntaxError): + decode_python_source(raw) + + +def test_decode_python_source_normalizes_crlf_before_full_decode() -> None: + raw = b"\t#coding=utf_16be\r\nx=1\r\n" + normalized = b"\t#coding=utf_16be\nx=1\n" + + decoded = decode_python_source(raw) + + assert decoded == normalized.decode("utf_16be") + assert parse_python_source(decoded, "encoded.py").is_parseable + + +def test_long_shebang_with_same_linux_and_full_identity_is_not_ambiguous() -> None: + line = "#!/usr/bin/node" + " " * 300 + + assert ( + classify_python_source("runner", line + "\npass\n") is PythonSourceClassification.NON_PYTHON + ) + + def test_parse_python_source_exposes_import_aliases() -> None: parsed = parse_python_source( "import os as operating_system\nfrom subprocess import run\n", "script.py" @@ -82,6 +1931,28 @@ def test_build_python_ast_cache_caches_failures_and_skips_oversized_files() -> N assert not cache["broken.py"].is_parseable +def test_build_python_ast_cache_includes_all_python_execution_surfaces() -> None: + cache = build_python_ast_cache( + [ + "window.pyw", + "runner", + "bundle.zip!/nested-runner", + "typing.pyi", + "node-runner", + ], + { + "window.pyw": "value = 1\n", + "runner": "#!/usr/bin/env python3\nvalue = 2\n", + "bundle.zip!/nested-runner": "#!/usr/bin/python3\nvalue = 3\n", + "typing.pyi": "value: int\n", + "node-runner": "#!/usr/bin/env node python3\nvalue = 4\n", + }, + ) + + assert set(cache) == {"window.pyw", "runner", "bundle.zip!/nested-runner"} + assert all(parsed.is_parseable for parsed in cache.values()) + + def test_build_python_ast_cache_respects_aggregate_source_budget() -> None: cache = build_python_ast_cache( ["first.py", "second.py"], @@ -95,6 +1966,28 @@ def test_build_python_ast_cache_respects_aggregate_source_budget() -> None: assert set(cache) == {"first.py"} +def test_build_python_ast_cache_checks_deadline_before_classification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def forbidden_classification(*_args: object, **_kwargs: object) -> bool: + raise AssertionError("classification must not start after the deadline") + + monkeypatch.setattr(python_ast, "may_be_python_source", forbidden_classification) + limitations: list[tuple[str, float]] = [] + + cache = build_python_ast_cache( + ["runner"], + {"runner": "#!/usr/bin/env python3\npass\n"}, + clock=lambda: 1.0, + started_at=0.0, + deadline=0.5, + runtime_limitations=limitations, + ) + + assert cache == {} + assert limitations == [("runner", 1.0)] + + def test_get_python_ast_reparses_when_cached_source_changes() -> None: cache_key = prewarm_python_ast_cache(["script.py"], {"script.py": "import os as old_name\n"}) assert cache_key is not None diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index a868ff7e8..eb6ac2d7a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -474,7 +474,7 @@ def test_recursive_scan_exception_marks_combined_execution_as_failed(tmp_path: P "skillspector.cli.graph.invoke", side_effect=[ {"report_body": '{"skill": {"name": "one"}}', "risk_score": 0}, - RuntimeError("child scan crashed"), + RuntimeError("TOKEN=child-scan-secret"), ], ): with pytest.raises(typer.Exit) as exit_info: @@ -490,7 +490,11 @@ def test_recursive_scan_exception_marks_combined_execution_as_failed(tmp_path: P assert exit_info.value.exit_code == 2 payload = json.loads(output.read_text()) assert payload["execution_successful"] is False - assert payload["skills"][1] == {"name": "two", "error": "child scan crashed"} + assert payload["skills"][1] == { + "name": "two", + "error": "A recursive child scan failed before complete inspection.", + } + assert "TOKEN=child-scan-secret" not in output.read_text() def test_recursive_scan_string_risk_score_counts_toward_exit_code(tmp_path: Path) -> None: @@ -632,7 +636,11 @@ def test_recursive_dot_child_static_finding_never_reaches_a_provider( transports: list[MagicMock] = [] def structured_output(schema: type) -> MagicMock: - response = schema(findings=[]) + response = ( + schema(is_mismatch=False) + if "is_mismatch" in schema.model_fields + else schema(findings=[]) + ) transport = MagicMock( invoke=MagicMock(return_value=response), ainvoke=AsyncMock(return_value=response), @@ -1392,8 +1400,9 @@ def test_scan_multi_skill_json_stdout_survives_child_failure( captured = capsys.readouterr() payload = json.loads(captured.out) assert payload["execution_successful"] is False - assert payload["skills"][1] == {"name": "broken", "error": "boom"} - assert "Error: boom" in captured.err + expected_error = "A recursive child scan failed before complete inspection." + assert payload["skills"][1] == {"name": "broken", "error": expected_error} + assert f"Error: {expected_error}" in captured.err @pytest.mark.parametrize("output_format", ["json", "sarif"]) @@ -1599,6 +1608,229 @@ def fake_invoke(*_args, **_kwargs) -> dict[str, object]: } +def test_recursive_oversized_failed_child_preserves_fatal_aggregate( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A child failure is classified before its oversized body is omitted.""" + skill = SkillDirectory(tmp_path / "failed", "failed", "failed") + output = tmp_path / "combined.json" + monkeypatch.setattr(cli, "_MULTI_SKILL_MAX_REPORT_CHARACTERS", 1_000) + child = { + **_bounded_recursive_result("failed", finding_count=0), + "report_body": "x" * 1_001, + "execution_successful": False, + "analysis_completeness": { + "is_complete": False, + "status": "failed", + "execution_successful": False, + }, + } + monkeypatch.setattr(cli, "_scan_skill", lambda *args, **kwargs: child) + + with pytest.raises(typer.Exit) as exit_info: + cli._scan_multi_skill( + MultiSkillDetectionResult(is_multi_skill=True, skills=[skill]), + FormatChoice.json, + output, + no_llm=True, + ) + + assert exit_info.value.exit_code == 2 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["execution_successful"] is False + assert payload["risk_recommendation"] == "DO_NOT_INSTALL" + assert payload["analysis_completeness"]["status"] == "failed" + assert payload["analysis_completeness"]["execution_successful"] is False + assert payload["skills_scanned"] == 1 + assert payload["skills_omitted"] == 0 + assert payload["skills_output_omitted"] == 1 + assert "x" * 1_001 not in output.read_text(encoding="utf-8") + + +def test_recursive_postscan_failure_counts_child_once_and_cleans_result( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A failure after scanning replaces, rather than duplicates, child accounting.""" + skill = SkillDirectory(tmp_path / "one", "one", "one") + output = tmp_path / "combined.json" + child = _bounded_recursive_result("one", finding_count=0) + child["report_body"] = "" + child["sarif_report"] = {"not_json_serializable": object()} + cleaned: list[dict[str, object]] = [] + monkeypatch.setattr(cli, "_scan_skill", lambda *args, **kwargs: child) + monkeypatch.setattr(cli, "cleanup_result", lambda result: cleaned.append(result)) + + with pytest.raises(typer.Exit) as exit_info: + cli._scan_multi_skill( + MultiSkillDetectionResult(is_multi_skill=True, skills=[skill]), + FormatChoice.json, + output, + no_llm=True, + ) + + assert exit_info.value.exit_code == 2 + payload = json.loads(output.read_text(encoding="utf-8")) + completeness = payload["analysis_completeness"] + assert payload["skill_count"] == 1 + assert payload["skills_scanned"] == 1 + assert payload["skills_output_omitted"] == 0 + assert completeness["fully_inspected_files"] == 0 + assert completeness["entirely_uninspected_files"] == 1 + assert completeness["total_files"] == 1 + assert sum(result is child for result in cleaned) == 1 + + +def test_recursive_over_record_budget_child_preserves_risk_and_exit( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Risk is aggregated before an over-record-budget child body is omitted.""" + skill = SkillDirectory(tmp_path / "critical", "critical", "critical") + output = tmp_path / "combined.json" + monkeypatch.setattr(cli, "_MULTI_SKILL_MAX_PUBLIC_RECORDS", 1) + child = { + **_bounded_recursive_result("critical", finding_count=2), + "risk_score": 100, + "risk_severity": "CRITICAL", + "risk_recommendation": "DO_NOT_INSTALL", + } + monkeypatch.setattr(cli, "_scan_skill", lambda *args, **kwargs: child) + + with pytest.raises(typer.Exit) as exit_info: + cli._scan_multi_skill( + MultiSkillDetectionResult(is_multi_skill=True, skills=[skill]), + FormatChoice.json, + output, + no_llm=True, + ) + + assert exit_info.value.exit_code == 1 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["max_risk_score"] == 100 + assert payload["risk_recommendation"] == "DO_NOT_INSTALL" + assert payload["analysis_completeness"]["is_complete"] is False + assert payload["skills_scanned"] == 1 + assert payload["skills_omitted"] == 0 + assert payload["skills_output_omitted"] == 1 + assert payload["skills"][-1] == { + "omitted": True, + "omitted_count": 1, + "reason": "aggregate_output_limit", + } + + +@pytest.mark.parametrize( + "output_format", + list(FormatChoice), +) +@pytest.mark.parametrize("cap_kind", ["child-retention", "serialized-output"]) +def test_recursive_non_json_caps_preserve_aggregate_risk( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + output_format: FormatChoice, + cap_kind: str, +) -> None: + """Omitted child details never erase known high aggregate risk.""" + relative_path = "critical" + child = { + **_bounded_recursive_result("critical", finding_count=2), + "risk_score": 100, + "risk_severity": "CRITICAL", + "risk_recommendation": "DO_NOT_INSTALL", + } + if cap_kind == "child-retention": + monkeypatch.setattr(cli, "_MULTI_SKILL_MAX_PUBLIC_RECORDS", 1) + elif output_format in {FormatChoice.terminal, FormatChoice.markdown}: + monkeypatch.setattr(cli, "_MULTI_SKILL_MAX_REPORT_CHARACTERS", 1_000) + relative_path = "p" * 400 + child["report_body"] = "x" * 700 + elif output_format is FormatChoice.json: + monkeypatch.setattr(cli, "_MULTI_SKILL_MAX_REPORT_CHARACTERS", 900) + child["report_body"] = json.dumps({"padding": "x" * 700}) + else: + monkeypatch.setattr(cli, "_MULTI_SKILL_MAX_REPORT_CHARACTERS", 1_800) + sarif = cast(dict[str, object], child["sarif_report"]) + runs = cast(list[dict[str, object]], sarif["runs"]) + runs[0]["properties"] = {"padding": "x" * 2_000} + + skill = SkillDirectory(tmp_path / "critical", "critical", relative_path) + output = tmp_path / f"combined.{output_format.value}" + monkeypatch.setattr(cli, "_scan_skill", lambda *args, **kwargs: child) + + with pytest.raises(typer.Exit) as exit_info: + cli._scan_multi_skill( + MultiSkillDetectionResult(is_multi_skill=True, skills=[skill]), + output_format, + output, + no_llm=True, + ) + + assert exit_info.value.exit_code == 1 + body = output.read_text(encoding="utf-8") + if output_format is FormatChoice.json: + payload = json.loads(body) + assert payload["max_risk_score"] == 100 + assert payload["risk_severity"] == "CRITICAL" + assert payload["risk_recommendation"] == "DO_NOT_INSTALL" + elif output_format is FormatChoice.sarif: + payload = json.loads(body) + validate_sarif_report(payload) + aggregate = payload["runs"][-1]["invocations"][0]["properties"] + risk = aggregate["riskAssessment"] + assert risk == { + "maxRiskScore": 100, + "severity": "CRITICAL", + "recommendation": "DO_NOT_INSTALL", + } + else: + assert "Maximum score: 100/100" in body + assert "Severity: CRITICAL" in body + # Human-readable report formats follow the single-skill convention. + assert "Recommendation: DO NOT INSTALL" in body + + +@pytest.mark.parametrize( + "limit_kind", + ["public_records", "child_report_characters"], +) +def test_recursive_sarif_retention_caps_keep_exact_reason_without_output_limit( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + limit_kind: str, +) -> None: + """Pre-serialization retention caps are not mislabeled as output limits.""" + skill = SkillDirectory(tmp_path / "one", "one", "one") + output = tmp_path / "combined.sarif" + child = _bounded_recursive_result( + "one", + finding_count=2 if limit_kind == "public_records" else 0, + ) + if limit_kind == "public_records": + monkeypatch.setattr(cli, "_MULTI_SKILL_MAX_PUBLIC_RECORDS", 1) + expected = "recursive public finding record budget 1 reached" + else: + monkeypatch.setattr(cli, "_MULTI_SKILL_MAX_REPORT_CHARACTERS", 2_000) + child["report_body"] = "x" * 2_001 + expected = "recursive report character budget 2000 reached" + monkeypatch.setattr(cli, "_scan_skill", lambda *args, **kwargs: child) + + cli._scan_multi_skill( + MultiSkillDetectionResult(is_multi_skill=True, skills=[skill]), + FormatChoice.sarif, + output, + no_llm=True, + ) + + payload = json.loads(output.read_text(encoding="utf-8")) + validate_sarif_report(payload) + notifications = payload["runs"][-1]["invocations"][0]["toolExecutionNotifications"] + exact = next(item for item in notifications if item["message"]["text"] == expected) + assert exact["properties"] == {"kind": "inspection_limitation"} + assert not any( + item.get("properties", {}).get("reasonCode") == "output_limit" for item in notifications + ) + + def test_recursive_markdown_report_character_limit_is_explicit( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -1701,6 +1933,330 @@ def test_recursive_sarif_is_valid_and_carries_aggregate_completeness( assert completeness["is_complete"] is True +@pytest.mark.parametrize( + ("status", "reason_code", "level", "execution_successful"), + [ + ("partial", "static_parse_limit", "warning", True), + ("failed", "analyzer_runtime_error", "error", False), + ], +) +def test_recursive_sarif_preserves_intrinsic_child_state_without_output_limit( + tmp_path: Path, + status: str, + reason_code: str, + level: str, + execution_successful: bool, +) -> None: + """Intrinsic child outcomes remain exact and are not mislabeled as output caps.""" + skill = SkillDirectory(tmp_path / "one", "one", "one") + child = _bounded_recursive_result("one", finding_count=0) + sarif = cast(dict[str, object], child["sarif_report"]) + child_run = cast(list[dict[str, object]], sarif["runs"])[0] + child_run["invocations"] = [ + { + "executionSuccessful": execution_successful, + "toolExecutionNotifications": [ + { + "message": {"text": f"Exact child {status} reason."}, + "level": level, + "properties": { + "kind": "inspection_failure" + if status == "failed" + else "inspection_limitation", + "reasonCode": reason_code, + }, + } + ], + } + ] + completeness = cli._multi_skill_analysis_completeness( + total_skills=1, + complete_skills=0, + partial_skills=int(status == "partial"), + failed_skills=int(status == "failed"), + omitted_skills=0, + limitations=[], + ) + + payload = cli._multi_skill_sarif_report([skill], [child], completeness) + + validate_sarif_report(payload) + child_notifications = payload["runs"][0]["invocations"][0]["toolExecutionNotifications"] + assert child_notifications[0]["properties"]["reasonCode"] == reason_code + aggregate = payload["runs"][-1]["invocations"][0] + assert aggregate["executionSuccessful"] is execution_successful + aggregate_notifications = aggregate["toolExecutionNotifications"] + assert not any( + item.get("properties", {}).get("reasonCode") == "output_limit" + for item in aggregate_notifications + ) + assert "aggregate safety limit" not in json.dumps(aggregate_notifications) + + +def test_recursive_sarif_labels_actual_serialized_output_cap() -> None: + """Only a real recursive output bound uses the output-limit reason code.""" + reason = "recursive serialized report character budget 1800 reached" + completeness = cli._multi_skill_analysis_completeness( + total_skills=1, + complete_skills=0, + partial_skills=1, + failed_skills=0, + omitted_skills=0, + limitations=[reason], + ) + + payload = cli._multi_skill_sarif_report([], [], completeness) + + validate_sarif_report(payload) + aggregate = payload["runs"][-1]["invocations"][0] + notifications = aggregate["toolExecutionNotifications"] + assert notifications == [ + { + "message": {"text": reason}, + "level": "warning", + "properties": { + "kind": "inspection_limitation", + "reasonCode": "output_limit", + }, + } + ] + + +@pytest.mark.parametrize("output_format", [FormatChoice.terminal, FormatChoice.markdown]) +def test_recursive_text_report_labels_failed_aggregate( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + output_format: FormatChoice, +) -> None: + """A failed child is never rendered as merely partial in combined text output.""" + skill = SkillDirectory(tmp_path / "one", "one", "one") + child = _bounded_recursive_result("one", finding_count=0) + child.update( + { + "execution_successful": False, + "analysis_completeness": { + "is_complete": False, + "status": "failed", + "execution_successful": False, + }, + } + ) + monkeypatch.setattr(cli.graph, "invoke", lambda *_args, **_kwargs: child) + output = tmp_path / f"combined.{output_format.value}" + + with pytest.raises(typer.Exit) as exit_info: + _scan_multi_skill( + MultiSkillDetectionResult(is_multi_skill=True, skills=[skill]), + output_format, + output, + no_llm=True, + ) + + assert exit_info.value.exit_code == 2 + body = output.read_text(encoding="utf-8") + assert "Status: failed" in body + assert "Status: partial" not in body + assert "One or more recursive skill scans failed" in body + + +@pytest.mark.parametrize("output_format", list(FormatChoice)) +def test_recursive_no_output_emits_selected_format_with_intrinsic_partial_reason( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + output_format: FormatChoice, +) -> None: + """Without --output, stdout is still the selected bounded recursive report.""" + skill = SkillDirectory(tmp_path / "one", "one", "one") + child = _bounded_recursive_result("one", finding_count=0) + child_completeness = { + "is_complete": False, + "status": "partial", + "execution_successful": True, + "ledger_exceptions": [ + { + "outcome": "partial", + "reason_code": "static_parse_limit", + "message": "A security expression exceeded its bounded parser span.", + "path": "runner", + } + ], + } + child["analysis_completeness"] = child_completeness + child["risk_recommendation"] = "CAUTION" + if output_format is FormatChoice.json: + child["report_body"] = json.dumps({"analysis_completeness": child_completeness}) + elif output_format is FormatChoice.sarif: + sarif = cast(dict[str, object], child["sarif_report"]) + run = cast(list[dict[str, object]], sarif["runs"])[0] + run["invocations"] = [ + { + "executionSuccessful": True, + "toolExecutionNotifications": [ + { + "message": { + "text": "A security expression exceeded its bounded parser span." + }, + "level": "warning", + "properties": { + "kind": "inspection_limitation", + "reasonCode": "static_parse_limit", + }, + } + ], + } + ] + child["report_body"] = json.dumps(sarif) + else: + child["report_body"] = "A security expression exceeded its bounded parser span." + + monkeypatch.setattr(cli, "_scan_skill", lambda *args, **kwargs: child) + cli._scan_multi_skill( + MultiSkillDetectionResult(is_multi_skill=True, skills=[skill]), + output_format, + None, + no_llm=True, + ) + + captured = capsys.readouterr() + if output_format is FormatChoice.json: + payload = json.loads(captured.out) + assert payload["analysis_completeness"]["status"] == "partial" + assert ( + payload["skills"][0]["analysis_completeness"]["ledger_exceptions"][0]["reason_code"] + == "static_parse_limit" + ) + elif output_format is FormatChoice.sarif: + payload = json.loads(captured.out) + validate_sarif_report(payload) + aggregate = payload["runs"][-1]["invocations"][0] + assert aggregate["properties"]["analysisCompleteness"]["status"] == "partial" + assert "static_parse_limit" in captured.out + else: + assert "Recursive Inspection Completeness" in captured.out + assert "Status: partial" in captured.out + assert "A security expression exceeded its bounded parser span" in captured.out + if output_format is not FormatChoice.terminal: + assert "Multi-skill directory detected" not in captured.out + assert "Multi-skill directory detected" in captured.err + + +@pytest.mark.parametrize( + ("output_format", "report_body"), + [ + (FormatChoice.json, "{}"), + (FormatChoice.sarif, '{"version":"2.1.0","runs":[]}'), + (FormatChoice.markdown, "# Report"), + ], +) +@pytest.mark.parametrize("warning_kind", ["recursive-empty", "multi-skill"]) +def test_directory_discovery_warnings_do_not_corrupt_machine_stdout( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + output_format: FormatChoice, + report_body: str, + warning_kind: str, +) -> None: + """Directory discovery diagnostics stay off report-only machine stdout.""" + if warning_kind == "recursive-empty": + detection = MultiSkillDetectionResult( + is_multi_skill=False, + skills=[], + has_root_skill=False, + ) + recursive_args = ["--recursive"] + expected_warning = "no sub-skills detected" + else: + detection = MultiSkillDetectionResult( + is_multi_skill=True, + skills=[ + SkillDirectory(tmp_path / "one", "one", "one"), + SkillDirectory(tmp_path / "two", "two", "two"), + ], + has_root_skill=False, + ) + recursive_args = [] + expected_warning = "Found 2 skills" + + monkeypatch.setattr(cli, "detect_skills", lambda _path: detection) + monkeypatch.setattr( + cli, + "_scan_skill", + lambda *args, **kwargs: { + "report_body": report_body, + "execution_successful": True, + "risk_score": 0, + }, + ) + + result = runner.invoke( + app, + [ + "scan", + str(tmp_path), + *recursive_args, + "--format", + output_format.value, + "--no-llm", + ], + ) + + assert result.exit_code == 0 + assert result.stdout == report_body + "\n" + assert expected_warning not in result.stdout + assert expected_warning in result.stderr + + +@pytest.mark.parametrize("output_format", list(FormatChoice)) +@pytest.mark.parametrize("write_file", [False, True], ids=["stdout", "file"]) +def test_recursive_child_exception_is_sanitized_across_public_formats( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + output_format: FormatChoice, + write_file: bool, +) -> None: + """Recursive child exceptions expose a generic failure, never their payload.""" + skill = SkillDirectory(tmp_path / "one", "one", "one") + output = tmp_path / f"combined.{output_format.value}" if write_file else None + secret = "TOKEN=secret-child-payload" + + def fail_child(*_args: object, **_kwargs: object) -> dict[str, object]: + raise RuntimeError(secret) + + monkeypatch.setattr(cli, "_scan_skill", fail_child) + with pytest.raises(typer.Exit) as exit_info: + cli._scan_multi_skill( + MultiSkillDetectionResult(is_multi_skill=True, skills=[skill]), + output_format, + output, + no_llm=True, + ) + + assert exit_info.value.exit_code == 2 + captured = capsys.readouterr() + public = captured.out + captured.err + if output is not None: + public += output.read_text(encoding="utf-8") + assert secret not in public + assert "A recursive child scan failed before complete inspection." in public + if output_format is FormatChoice.json: + report = output.read_text(encoding="utf-8") if output else captured.out + payload = json.loads(report) + assert payload["execution_successful"] is False + assert payload["skills"][0]["error"] == ( + "A recursive child scan failed before complete inspection." + ) + elif output_format is FormatChoice.sarif: + report = output.read_text(encoding="utf-8") if output else captured.out + payload = json.loads(report) + validate_sarif_report(payload) + assert payload["runs"][-1]["invocations"][0]["executionSuccessful"] is False + else: + report = output.read_text(encoding="utf-8") if output else captured.out + assert "Status: failed" in report + + def test_recursive_sarif_without_output_writes_only_the_log_to_stdout( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture ) -> None: @@ -2495,11 +3051,106 @@ def test_transitive_artifact_budget_allows_exact_limit() -> None: assert traversal.truncation_reasons == ["artifact budget 2 reached"] -def test_scan_transitive_depth_one_merges_provenance(tmp_path: Path, monkeypatch) -> None: - """--transitive-depth 1 follows one approved external target and merges provenance.""" - direct_output = "See dependency: https://github.com/org/transitive.git" +@pytest.mark.parametrize( + "reason", + [ + "inspection ledger budget 1 reached", + "analyzer status budget 1 reached", + "finding budget 1 reached", + "component budget 1 reached", + "provider cache budget 1 reached", + "report output budget 1 reached", + ], +) +def test_transitive_retention_limits_do_not_exhaust_execution(reason: str) -> None: + """Public storage truncation remains partial without skipping planned work.""" + traversal = cli._TransitiveTraversalState() - def fake_run_graph_scan( + traversal.note_truncation(reason) + + assert traversal.resource_limit_reached is True + assert traversal.budget_exhausted is False + assert traversal.can_scan_more() is True + assert traversal.truncation_reasons == [reason] + + +def test_child_failure_target_text_cannot_exhaust_traversal_by_substring() -> None: + """An untrusted target containing 'budget' cannot control traversal state.""" + traversal = cli._TransitiveTraversalState() + + traversal.note_child_scan_failure("https://github.com/org/budget") + + assert traversal.budget_exhausted is False + assert traversal.can_scan_more() is True + + +def test_transitive_failed_attempt_consumes_shared_target_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed target attempt still consumes one shared execution slot.""" + traversal = cli._TransitiveTraversalState( + budget=cli._TransitiveBudget(max_targets=1), + ) + attempted_targets: list[str] = [] + + def fail_child(*args: object, **kwargs: object) -> dict[str, object]: + input_path = kwargs.get("input_path") if kwargs else args[0] + attempted_targets.append(str(input_path)) + raise RuntimeError("TOKEN=private-child-failure") + + monkeypatch.setattr(cli, "_run_graph_scan", fail_child) + targets = ["https://github.com/org/failed-one", "https://github.com/org/failed-two"] + for target in targets: + root = { + **_mock_graph_result(file_cache={"SKILL.md": target}), + "local_file_cache": {"SKILL.md": target}, + } + cli._scan_transitive( + initial_result=root, + format=cli.FormatChoice.json, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + traversal=traversal, + ) + + assert attempted_targets == [targets[0]] + assert traversal.scanned_targets == 1 + assert traversal.budget_exhausted is True + assert traversal.truncation_reasons[-1] == "target budget 1 reached" + + +@pytest.mark.parametrize( + ("budget_kwargs", "expected_reason"), + [ + ({"max_targets": 0}, "target budget 0 reached"), + ({"max_bytes": 0}, "byte budget 0 reached"), + ({"max_artifacts": 0}, "artifact budget 0 reached"), + ({"max_seconds": 0.0}, "time budget 0s reached"), + ], +) +def test_transitive_execution_limits_still_stop_planned_work( + budget_kwargs: dict[str, object], expected_reason: str +) -> None: + """Only executable target, byte, artifact, and time ceilings stop traversal.""" + traversal = cli._TransitiveTraversalState( + budget=cli._TransitiveBudget(**budget_kwargs), + ) + + assert traversal.can_scan_more() is False + assert traversal.budget_exhausted is True + assert traversal.truncation_reasons == [expected_reason] + + +def test_scan_transitive_depth_one_merges_provenance(tmp_path: Path, monkeypatch) -> None: + """--transitive-depth 1 follows one approved external target and merges provenance.""" + direct_output = "See dependency: https://github.com/org/transitive.git" + + def fake_run_graph_scan( input_path: str, format, no_llm: bool, @@ -2543,6 +3194,49 @@ def fake_run_graph_scan( assert transitive_issue["source_url"] == "https://github.com/org/transitive" +def test_scan_transitive_routes_python_window_script_with_provenance( + tmp_path: Path, monkeypatch +) -> None: + """A referenced ``.pyw`` reaches the child scan and keeps its source identity.""" + target = "https://raw.githubusercontent.com/NVIDIA/SkillSpector/main/tool.pyw" + calls: list[str] = [] + + def fake_run_graph_scan( + input_path: str, + format, + no_llm: bool, + yara_dir: str | None = None, + baseline=None, + show_suppressed: bool = False, + transitive_traversal=None, + ) -> dict[str, object]: + calls.append(input_path) + if input_path == str(tmp_path): + return _mock_graph_result( + file_cache={"SKILL.md": target}, + output_format=format.value, + ) + assert input_path == target + return _mock_graph_result( + findings=[_finding("TM1", "Tool Parameter Abuse", file="tool.pyw", depth=1)], + output_format=format.value, + ) + + monkeypatch.setattr(cli, "_run_graph_scan", fake_run_graph_scan) + result = runner.invoke( + app, + ["scan", str(tmp_path), "--format", "json", "--transitive", "--no-llm"], + ) + + assert result.exit_code == 0 + assert calls == [str(tmp_path), target] + issue = json.loads(result.output)["issues"][0] + assert issue["id"] == "TM1" + assert issue["location"]["file"] == "tool.pyw" + assert issue["transitive_depth"] == 1 + assert issue["source_url"] == target + + def test_scan_transitive_ignores_non_scannable_urls(tmp_path: Path, monkeypatch) -> None: """Non-scannable documentation or badge URLs are not followed transitively.""" calls: list[str] = [] @@ -2801,8 +3495,11 @@ def fake_run_graph_scan( assert len(recursive_calls) == 2 -def test_transitive_resolver_failure_preserves_direct_report(tmp_path: Path, monkeypatch) -> None: - """A transitive resolver failure should preserve the direct report result.""" +@pytest.mark.parametrize("strict", [False, True], ids=["default", "strict"]) +def test_transitive_resolver_failure_preserves_fatal_report( + tmp_path: Path, monkeypatch, strict: bool +) -> None: + """A transitive resolver failure writes a sanitized report and exits two.""" target = "https://github.com/org/broken.git" file_cache = {"SKILL.md": f"deps {target}"} @@ -2824,21 +3521,70 @@ def fake_run_graph_scan( raise ValueError("resolver failure") monkeypatch.setattr(cli, "_run_graph_scan", fake_run_graph_scan) - result = runner.invoke( - app, - [ - "scan", - str(tmp_path), - "--format", - "json", - "--transitive", - "--no-llm", - ], - ) - assert result.exit_code == 0 + arguments = [ + "scan", + str(tmp_path), + "--format", + "json", + "--transitive", + "--no-llm", + ] + if strict: + arguments.append("--fail-on-incomplete") + result = runner.invoke(app, arguments) + assert result.exit_code == 2 data = json.loads(result.output) assert len(data["issues"]) == 1 assert data["issues"][0]["id"] == "D1" + assert data["execution_successful"] is False + assert data["analysis_completeness"]["status"] == "failed" + failures = [ + item + for item in data["analysis_completeness"]["ledger_exceptions"] + if item["reason_code"] == "transitive_child_scan_failed" + ] + assert len(failures) == 1 + assert failures[0]["fatal"] is True + assert failures[0]["path"].startswith("external/") + assert target not in failures[0]["path"] + assert "resolver failure" not in result.output + + +def test_transitive_resolver_failure_keeps_sarif_stdout_parseable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A child warning is diagnostic stderr, never a prefix before SARIF JSON.""" + target = "https://github.com/org/broken-sarif.git" + + def fake_run_graph_scan( + input_path: str, + format, + no_llm: bool, + yara_dir: str | None = None, + baseline=None, + show_suppressed: bool = False, + transitive_traversal=None, + ) -> dict[str, object]: + if input_path == str(tmp_path): + return _mock_graph_result( + file_cache={"SKILL.md": target}, + output_format=format.value, + ) + raise RuntimeError("TOKEN=private-resolver-detail") + + monkeypatch.setattr(cli, "_run_graph_scan", fake_run_graph_scan) + result = runner.invoke( + app, + ["scan", str(tmp_path), "--format", "sarif", "--transitive", "--no-llm"], + ) + + assert result.exit_code == 2 + payload = json.loads(result.stdout) + validate_sarif_report(payload) + invocation = payload["runs"][0]["invocations"][0] + assert invocation["executionSuccessful"] is False + assert "TOKEN=private-resolver-detail" not in result.stdout + assert "Transitive scan failed" not in result.stdout def test_transitive_failure_warning_stays_off_sarif_stdout(tmp_path: Path, monkeypatch) -> None: @@ -2860,7 +3606,7 @@ def fake_run_graph_scan(input_path: str, format, no_llm: bool, **_kwargs) -> dic ["scan", str(tmp_path), "--format", "sarif", "--transitive", "--no-llm"], ) - assert result.exit_code == 0, result.output + assert result.exit_code == 2, result.output assert "Transitive scan failed" not in result.stdout payload = json.loads(result.stdout) validate_sarif_report(payload) @@ -3560,8 +4306,106 @@ def fake_run_graph_scan( assert merged["transitive_finding_count"] == 1 +@pytest.mark.parametrize("output_format", list(cli.FormatChoice)) +def test_scan_transitive_intrinsic_child_partial_is_not_traversal_truncation( + monkeypatch: pytest.MonkeyPatch, output_format: cli.FormatChoice +) -> None: + """A fully traversed partial child keeps its exact cause without a false limit.""" + target = "https://github.com/org/partial" + root_event = ledger_event( + outcome=LedgerOutcome.COMPLETED, + phase="static", + path="SKILL.md", + analyzer_id="root-analyzer", + ) + child_event = ledger_event( + outcome=LedgerOutcome.PARTIAL, + phase="static", + path="runner", + analyzer_id="child-analyzer", + reason=LedgerReason.STATIC_PARSE_LIMIT, + ) + initial_result: dict[str, object] = { + **_mock_graph_result(file_cache={"SKILL.md": target}, output_format=output_format.value), + "local_file_cache": {"SKILL.md": target}, + "component_metadata": [ + { + "path": "SKILL.md", + "type": "markdown", + "lines": 1, + "executable": False, + "size_bytes": len(target), + } + ], + "inspection_ledger": [root_event], + "analyzer_status_events": [analyzer_status_for_events("root-analyzer", [root_event])], + } + child_result: dict[str, object] = { + **_mock_graph_result(file_cache={"runner": "pass\n"}, output_format=output_format.value), + "components": ["runner"], + "local_file_cache": {"runner": "pass\n"}, + "component_metadata": [ + { + "path": "runner", + "type": "other", + "lines": 1, + "executable": True, + "size_bytes": 5, + } + ], + "inspection_ledger": [child_event], + "analyzer_status_events": [analyzer_status_for_events("child-analyzer", [child_event])], + "analysis_completeness": { + "is_complete": False, + "status": "partial", + "execution_successful": True, + }, + "execution_successful": True, + } + + monkeypatch.setattr(cli, "_run_graph_scan", lambda *args, **kwargs: child_result) + merged = cli._scan_transitive( + initial_result=initial_result, + format=output_format, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + ) + + assert merged["transitive_truncated"] is False + assert merged["transitive_truncation_reasons"] == [] + completeness = cast(dict[str, object], merged["analysis_completeness"]) + assert completeness["is_complete"] is False + assert completeness["status"] == "partial" + assert completeness["execution_successful"] is True + exceptions = cast(list[dict[str, object]], completeness["ledger_exceptions"]) + assert {item["reason_code"] for item in exceptions} == {"static_parse_limit"} + report_body = cast(str, merged["report_body"]) + assert "Inspection reached its configured output limit." not in report_body + assert "Transitive traversal truncated" not in report_body + if output_format is cli.FormatChoice.json: + payload = json.loads(report_body) + assert "transitive_truncated" not in payload["metadata"] + elif output_format is cli.FormatChoice.sarif: + payload = json.loads(report_body) + invocation = payload["runs"][0]["invocations"][0] + assert invocation["executionSuccessful"] is True + reason_codes = { + item["properties"].get("reasonCode") + for item in invocation.get("toolExecutionNotifications", []) + if item["properties"].get("reasonCode") is not None + } + assert reason_codes == {"static_parse_limit"} + else: + assert "bounded static parser's span limit" in report_body + + def test_scan_transitive_child_failure_stays_visible_and_fail_closed(monkeypatch) -> None: - """Child scan exceptions should degrade the report without leaking raw error text.""" + """Child scan exceptions fail execution without leaking raw error text.""" failed_target = "https://github.com/org/broken" initial_result = { "findings": [_finding("D1", "direct finding")], @@ -3610,13 +4454,16 @@ def fake_run_graph_scan( body = json.loads(merged["report_body"]) assert merged["temp_dir_for_cleanup"] == "root-temp" assert merged["transitive_sources"] == [failed_target] - assert merged["transitive_targets_scanned"] == 0 + assert merged["transitive_targets_scanned"] == 1 assert merged["transitive_truncated"] is True assert merged["transitive_truncation_reasons"] == [ f"transitive child scan failed for {failed_target}" ] assert merged["risk_recommendation"] == "CAUTION" assert body["analysis_completeness"]["is_complete"] is False + assert body["analysis_completeness"]["status"] == "failed" + assert body["analysis_completeness"]["execution_successful"] is False + assert merged["execution_successful"] is False assert body["metadata"]["transitive_truncated"] is True assert any( "transitive child scan failed for https://github.com/org/broken" in limitation @@ -3624,6 +4471,574 @@ def fake_run_graph_scan( ) assert "secret token should stay private" not in merged["transitive_truncation_reasons"][0] assert "secret token should stay private" not in merged["report_body"] + failures = [ + item + for item in body["analysis_completeness"]["ledger_exceptions"] + if item["reason_code"] == "transitive_child_scan_failed" + ] + assert len(failures) == 1 + assert failures[0]["outcome"] == "failed" + assert failures[0]["fatal"] is True + assert failures[0]["path"].startswith("external/") + assert failed_target not in failures[0]["path"] + assert not any( + item["reason_code"] == "output_limit" + for item in body["analysis_completeness"]["ledger_exceptions"] + ) + + +def test_scan_transitive_preserves_returned_child_failure_without_synthetic_duplicate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An exact FAILED child ledger row remains the sole fatal diagnostic.""" + target = "https://github.com/org/failed-result" + child_event = ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="runner.py", + analyzer_id="child-analyzer", + reason=LedgerReason.ANALYZER_RUNTIME_ERROR, + ) + initial_result: dict[str, object] = { + **_mock_graph_result(file_cache={"SKILL.md": target}), + "local_file_cache": {"SKILL.md": target}, + } + child_result: dict[str, object] = { + **_mock_graph_result(file_cache={"runner.py": "pass\n"}), + "components": ["runner.py"], + "local_file_cache": {"runner.py": "pass\n"}, + "inspection_ledger": [child_event], + "analyzer_status_events": [analyzer_status_for_events("child-analyzer", [child_event])], + "analysis_completeness": { + "is_complete": False, + "status": "failed", + "execution_successful": False, + }, + "execution_successful": False, + } + + monkeypatch.setattr(cli, "_run_graph_scan", lambda *args, **kwargs: child_result) + merged = cli._scan_transitive( + initial_result=initial_result, + format=cli.FormatChoice.json, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + ) + + body = json.loads(cast(str, merged["report_body"])) + assert merged["execution_successful"] is False + exceptions = body["analysis_completeness"]["ledger_exceptions"] + assert [item["reason_code"] for item in exceptions] == ["analyzer_runtime_error"] + assert exceptions[0]["fatal"] is True + assert not any(item["reason_code"] == "output_limit" for item in exceptions) + + +@pytest.mark.parametrize("ledger_cap", [1, 2]) +def test_transitive_child_failure_survives_shared_ledger_cap( + monkeypatch: pytest.MonkeyPatch, ledger_cap: int +) -> None: + """A required fatal row wins over an output sentinel at the smallest caps.""" + target = "https://github.com/org/capped-failure" + root_event = ledger_event( + outcome=LedgerOutcome.COMPLETED, + phase="static", + path="SKILL.md", + analyzer_id="root-analyzer", + ) + initial_result: dict[str, object] = { + **_mock_graph_result(file_cache={"SKILL.md": target}), + "local_file_cache": {"SKILL.md": target}, + "inspection_ledger": [root_event], + "analyzer_status_events": [analyzer_status_for_events("root-analyzer", [root_event])], + } + + def fail_child(*_args: object, **_kwargs: object) -> dict[str, object]: + raise RuntimeError("private child error") + + monkeypatch.setattr(cli, "_run_graph_scan", fail_child) + merged = cli._scan_transitive( + initial_result=initial_result, + format=cli.FormatChoice.json, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + budget=cli._TransitiveBudget(max_ledger_events=ledger_cap), + ) + + body = json.loads(cast(str, merged["report_body"])) + assert merged["execution_successful"] is False + exceptions = body["analysis_completeness"]["ledger_exceptions"] + assert any(item["reason_code"] == "transitive_child_scan_failed" for item in exceptions) + assert not any(item["reason_code"] == "output_limit" for item in exceptions) + assert not any(item["reason_code"] == "unaccounted_work" for item in exceptions) + assert "private child error" not in merged["report_body"] + + +def test_new_child_failure_remains_required_when_only_one_fatal_slot_fits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A new opaque child failure keeps the prior tight-cap replacement contract.""" + target = "https://github.com/org/root-and-child-tight-cap" + root_events = [ + ledger_event( + outcome=LedgerOutcome.COMPLETED, + phase="static", + path=path, + analyzer_id="root-analyzer", + ) + for path in ("one.py", "two.py") + ] + root_events.append( + ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="root-failed.py", + analyzer_id="root-analyzer", + reason=LedgerReason.READ_ERROR, + ) + ) + initial_result: dict[str, object] = { + **_mock_graph_result(file_cache={"SKILL.md": target}), + "components": ["one.py", "two.py", "root-failed.py", "SKILL.md"], + "local_file_cache": {"SKILL.md": target}, + "inspection_ledger": root_events, + "execution_successful": False, + } + + def fail_child(*_args: object, **_kwargs: object) -> dict[str, object]: + raise RuntimeError("private child failure") + + monkeypatch.setattr(cli, "_run_graph_scan", fail_child) + merged = cli._scan_transitive( + initial_result=initial_result, + format=cli.FormatChoice.json, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + budget=cli._TransitiveBudget(max_ledger_events=2), + ) + + body = json.loads(cast(str, merged["report_body"])) + exceptions = body["analysis_completeness"]["ledger_exceptions"] + assert {item["reason_code"] for item in exceptions} == { + "output_limit", + "transitive_child_scan_failed", + } + assert "private child failure" not in merged["report_body"] + + +@pytest.mark.parametrize("ledger_cap", [1, 2]) +def test_transitive_child_failure_runs_after_real_root_ledger_overflow( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ledger_cap: int, +) -> None: + """A root output cap cannot prevent planned transitive execution.""" + target = "https://github.com/org/real-root-capped-failure" + (tmp_path / "SKILL.md").write_text( + f"---\nname: capped-root\ndescription: Root cap regression\n---\n\n{target}\n", + encoding="utf-8", + ) + initial_result = cli._run_graph_scan( + input_path=str(tmp_path), + format=cli.FormatChoice.json, + no_llm=True, + ) + root_ledger = cast(list[dict[str, object]], initial_result["inspection_ledger"]) + root_statuses = cast(list[dict[str, object]], initial_result["analyzer_status_events"]) + assert len(root_ledger) > ledger_cap + assert len(root_statuses) > ledger_cap + + calls: list[str] = [] + secret = "TOKEN=private-real-root-child-error" + + def fail_child(*args: object, **kwargs: object) -> dict[str, object]: + input_path = kwargs.get("input_path") if kwargs else args[0] + calls.append(str(input_path)) + raise RuntimeError(secret) + + monkeypatch.setattr(cli, "_run_graph_scan", fail_child) + merged = cli._scan_transitive( + initial_result=initial_result, + format=cli.FormatChoice.json, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + budget=cli._TransitiveBudget(max_ledger_events=ledger_cap), + ) + + assert calls == [target] + assert merged["execution_successful"] is False + completeness = cast(dict[str, object], merged["analysis_completeness"]) + assert completeness["status"] == "failed" + assert completeness["execution_successful"] is False + exceptions = cast(list[dict[str, object]], completeness["ledger_exceptions"]) + reasons = {str(item["reason_code"]) for item in exceptions} + if ledger_cap == 1: + assert reasons == {"transitive_child_scan_failed"} + else: + assert reasons == {"output_limit", "transitive_child_scan_failed"} + assert "unaccounted_work" not in reasons + assert secret not in cast(str, merged["report_body"]) + + +@pytest.mark.parametrize("ledger_cap", [1, 2]) +def test_transitive_child_exact_failure_survives_pre_cache_ledger_cap( + monkeypatch: pytest.MonkeyPatch, ledger_cap: int +) -> None: + """Bounding a child ledger cannot replace an available exact fatal reason.""" + target = "https://github.com/org/exact-capped-failure" + initial_result: dict[str, object] = { + **_mock_graph_result(file_cache={"SKILL.md": target}), + "local_file_cache": {"SKILL.md": target}, + } + child_events = [ + ledger_event( + outcome=LedgerOutcome.COMPLETED, + phase="static", + path=path, + analyzer_id="child-analyzer", + ) + for path in ("one.py", "two.py") + ] + child_events.append( + ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="failed.py", + analyzer_id="child-analyzer", + reason=LedgerReason.ANALYZER_RUNTIME_ERROR, + ) + ) + child_result: dict[str, object] = { + **_mock_graph_result( + file_cache={"one.py": "pass\n", "two.py": "pass\n", "failed.py": "pass\n"} + ), + "components": ["one.py", "two.py", "failed.py"], + "local_file_cache": { + "one.py": "pass\n", + "two.py": "pass\n", + "failed.py": "pass\n", + }, + "inspection_ledger": child_events, + "analyzer_status_events": [analyzer_status_for_events("child-analyzer", child_events)], + "analysis_completeness": { + "is_complete": False, + "status": "failed", + "execution_successful": False, + }, + "execution_successful": False, + } + + monkeypatch.setattr(cli, "_run_graph_scan", lambda *args, **kwargs: child_result) + merged = cli._scan_transitive( + initial_result=initial_result, + format=cli.FormatChoice.json, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + budget=cli._TransitiveBudget(max_ledger_events=ledger_cap), + ) + + body = json.loads(cast(str, merged["report_body"])) + reasons = {item["reason_code"] for item in body["analysis_completeness"]["ledger_exceptions"]} + assert "analyzer_runtime_error" in reasons + assert "transitive_child_scan_failed" not in reasons + assert "unaccounted_work" not in reasons + + +def test_transitive_root_ledger_cap_preserves_all_distinct_failures() -> None: + """All pre-cap root failures displace completed work before the sentinel.""" + root_events = [ + ledger_event( + outcome=LedgerOutcome.COMPLETED, + phase="static", + path=path, + analyzer_id="root-analyzer", + ) + for path in ("one.py", "two.py") + ] + root_events.extend( + [ + ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="read-failed.py", + analyzer_id="root-reader", + reason=LedgerReason.READ_ERROR, + ), + ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="runtime-failed.py", + analyzer_id="root-runtime", + reason=LedgerReason.ANALYZER_RUNTIME_ERROR, + ), + ] + ) + initial_result: dict[str, object] = { + **_mock_graph_result(file_cache={}), + "components": ["one.py", "two.py", "read-failed.py", "runtime-failed.py"], + "local_file_cache": {}, + "inspection_ledger": root_events, + "execution_successful": False, + } + + merged = cli._scan_transitive( + initial_result=initial_result, + format=cli.FormatChoice.json, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + budget=cli._TransitiveBudget(max_ledger_events=3), + ) + + body = json.loads(cast(str, merged["report_body"])) + exceptions = body["analysis_completeness"]["ledger_exceptions"] + assert [(item["reason_code"], item["path"]) for item in exceptions] == [ + ("output_limit", "read-failed.py"), + ("read_error", "read-failed.py"), + ("analyzer_runtime_error", "runtime-failed.py"), + ] + assert merged["execution_successful"] is False + + +def test_transitive_child_ledger_cap_preserves_all_distinct_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Child failures are scoped, deduplicated, and retained before completed work.""" + target = "https://github.com/org/multiple-child-failures" + initial_result: dict[str, object] = { + **_mock_graph_result(file_cache={"SKILL.md": target}), + "local_file_cache": {"SKILL.md": target}, + } + completed_events = [ + ledger_event( + outcome=LedgerOutcome.COMPLETED, + phase="static", + path=path, + analyzer_id="child-analyzer", + ) + for path in ("one.py", "two.py") + ] + read_failure = ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="read-failed.py", + analyzer_id="child-reader", + reason=LedgerReason.READ_ERROR, + ) + runtime_failure = ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="runtime-failed.py", + analyzer_id="child-runtime", + reason=LedgerReason.ANALYZER_RUNTIME_ERROR, + ) + child_result: dict[str, object] = { + **_mock_graph_result( + file_cache={ + "one.py": "pass\n", + "two.py": "pass\n", + "read-failed.py": "pass\n", + "runtime-failed.py": "pass\n", + } + ), + "components": ["one.py", "two.py", "read-failed.py", "runtime-failed.py"], + "local_file_cache": { + "one.py": "pass\n", + "two.py": "pass\n", + "read-failed.py": "pass\n", + "runtime-failed.py": "pass\n", + }, + "inspection_ledger": [ + *completed_events, + read_failure, + dict(read_failure), + runtime_failure, + ], + "execution_successful": False, + } + + monkeypatch.setattr(cli, "_run_graph_scan", lambda *args, **kwargs: child_result) + merged = cli._scan_transitive( + initial_result=initial_result, + format=cli.FormatChoice.json, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + budget=cli._TransitiveBudget(max_ledger_events=3), + ) + + body = json.loads(cast(str, merged["report_body"])) + exceptions = body["analysis_completeness"]["ledger_exceptions"] + reasons = [item["reason_code"] for item in exceptions] + assert reasons.count("read_error") == 1 + assert reasons.count("analyzer_runtime_error") == 1 + assert reasons.count("output_limit") == 1 + paths_by_reason = {item["reason_code"]: item["path"] for item in exceptions} + assert paths_by_reason["read_error"].startswith("external/") + assert paths_by_reason["read_error"].endswith("/read-failed.py") + assert paths_by_reason["analyzer_runtime_error"].startswith("external/") + assert paths_by_reason["analyzer_runtime_error"].endswith("/runtime-failed.py") + assert merged["execution_successful"] is False + + +def test_transitive_ledger_cap_preserves_distinct_root_and_child_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A ledger cap retains both fatal scopes before completed work.""" + target = "https://github.com/org/root-and-child-failures" + root_events = [ + ledger_event( + outcome=LedgerOutcome.COMPLETED, + phase="static", + path=path, + analyzer_id="root-analyzer", + ) + for path in ("one.py", "two.py") + ] + root_events.append( + ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="root-failed.py", + analyzer_id="root-analyzer", + reason=LedgerReason.READ_ERROR, + ) + ) + initial_result: dict[str, object] = { + **_mock_graph_result(file_cache={"SKILL.md": target}), + "components": ["one.py", "two.py", "root-failed.py", "SKILL.md"], + "local_file_cache": {"SKILL.md": target}, + "inspection_ledger": root_events, + "analyzer_status_events": [analyzer_status_for_events("root-analyzer", root_events)], + "execution_successful": False, + } + child_event = ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="child-failed.py", + analyzer_id="child-analyzer", + reason=LedgerReason.ANALYZER_RUNTIME_ERROR, + ) + child_result: dict[str, object] = { + **_mock_graph_result(file_cache={"child-failed.py": "pass\n"}), + "components": ["child-failed.py"], + "local_file_cache": {"child-failed.py": "pass\n"}, + "inspection_ledger": [child_event], + "analyzer_status_events": [analyzer_status_for_events("child-analyzer", [child_event])], + "execution_successful": False, + } + + monkeypatch.setattr(cli, "_run_graph_scan", lambda *args, **kwargs: child_result) + merged = cli._scan_transitive( + initial_result=initial_result, + format=cli.FormatChoice.json, + no_llm=True, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + budget=cli._TransitiveBudget(max_ledger_events=3), + ) + + body = json.loads(cast(str, merged["report_body"])) + reasons = {item["reason_code"] for item in body["analysis_completeness"]["ledger_exceptions"]} + assert reasons == {"read_error", "analyzer_runtime_error", "output_limit"} + + +@pytest.mark.parametrize("ledger_cap", [1, 2]) +@pytest.mark.parametrize("no_llm", [True, False]) +def test_transitive_root_exact_failure_survives_initial_ledger_cap( + ledger_cap: int, + no_llm: bool, +) -> None: + """Initialization and late semantic accounting cannot cap away a root fatal fact.""" + root_events = [ + ledger_event( + outcome=LedgerOutcome.COMPLETED, + phase="static", + path="one.py", + analyzer_id="root-analyzer", + ), + ledger_event( + outcome=LedgerOutcome.COMPLETED, + phase="static", + path="two.py", + analyzer_id="root-analyzer", + ), + ledger_event( + outcome=LedgerOutcome.FAILED, + phase="static", + path="failed.py", + analyzer_id="root-analyzer", + reason=LedgerReason.ANALYZER_RUNTIME_ERROR, + ), + ] + initial_result: dict[str, object] = { + **_mock_graph_result(file_cache={}), + "components": ["one.py", "two.py", "failed.py"], + "local_file_cache": {}, + "inspection_ledger": root_events, + "analyzer_status_events": [analyzer_status_for_events("root-analyzer", root_events)], + "analysis_completeness": { + "is_complete": False, + "status": "failed", + "execution_successful": False, + }, + "execution_successful": False, + } + + merged = cli._scan_transitive( + initial_result=initial_result, + format=cli.FormatChoice.json, + no_llm=no_llm, + max_depth=1, + transitive_allow_prefix=(), + transitive_deny_prefix=(), + baseline=None, + show_suppressed=False, + visited=set(), + budget=cli._TransitiveBudget(max_ledger_events=ledger_cap), + ) + + body = json.loads(cast(str, merged["report_body"])) + reasons = {item["reason_code"] for item in body["analysis_completeness"]["ledger_exceptions"]} + assert merged["execution_successful"] is False + assert "analyzer_runtime_error" in reasons + assert "unaccounted_work" not in reasons def test_scan_transitive_keeps_source_aware_component_coverage(monkeypatch) -> None: @@ -4456,7 +5871,11 @@ def _mcp_module_missing(d: Path) -> FatalPath: "not supported for recursive", id="recursive-baseline", ), - pytest.param(_multi_skill_child_crashes, "child scan crashed", id="multi-skill-child"), + pytest.param( + _multi_skill_child_crashes, + "A recursive child scan failed before complete inspection.", + id="multi-skill-child", + ), pytest.param(_scan_input_missing, "skill vanished", id="scan-input-missing"), pytest.param(_scan_crashes, "scan crashed", id="scan-crashes"), pytest.param(_scan_crashes_verbose, "RuntimeError", id="scan-crashes-verbose"), diff --git a/tests/unit/test_input_handler.py b/tests/unit/test_input_handler.py index e3243a5a6..e33c6c078 100644 --- a/tests/unit/test_input_handler.py +++ b/tests/unit/test_input_handler.py @@ -462,6 +462,20 @@ def test_http_urls_are_not_accepted_as_remote_inputs() -> None: assert handler._is_file_url("http://raw.githubusercontent.com/org/repo/SKILL.md") is False +def test_resolve_routes_github_python_window_asset_as_direct_file(tmp_path: Path) -> None: + """A GitHub-hosted ``.pyw`` asset is downloaded instead of cloned as a repository.""" + handler = InputHandler() + url = "https://github.com/NVIDIA/SkillSpector/releases/download/v1/tool.pyw" + with ( + patch.object(handler, "_download_file", return_value=tmp_path) as download, + patch.object(handler, "_clone_git", side_effect=AssertionError("unexpected clone")), + ): + resolved, source_type = handler.resolve(url) + + assert (resolved, source_type) == (tmp_path, "url") + download.assert_called_once_with(url) + + @pytest.mark.parametrize("budgeted", [False, True], ids=["direct", "workflow-budget"]) @pytest.mark.parametrize( ("page_url", "raw_url"), diff --git a/tests/unit/test_transitive.py b/tests/unit/test_transitive.py index 37bfd3138..f37f9217f 100644 --- a/tests/unit/test_transitive.py +++ b/tests/unit/test_transitive.py @@ -64,6 +64,8 @@ def test_extract_excludes_badges_docs_and_issue_urls() -> None: "docs https://github.com/NVIDIA/SkillSpector/wiki, " "ci https://github.com/NVIDIA/SkillSpector/actions, " "src https://raw.githubusercontent.com/NVIDIA/SkillSpector/main/tool.py, " + "window https://raw.githubusercontent.com/NVIDIA/SkillSpector/main/tool.pyw, " + "binary https://raw.githubusercontent.com/NVIDIA/SkillSpector/main/tool.exe, " "zip https://huggingface.co/abc/archive/main.zip" ), } @@ -71,6 +73,7 @@ def test_extract_excludes_badges_docs_and_issue_urls() -> None: refs = transitive.extract_external_refs(file_cache) assert refs == [ "https://raw.githubusercontent.com/NVIDIA/SkillSpector/main/tool.py", + "https://raw.githubusercontent.com/NVIDIA/SkillSpector/main/tool.pyw", "https://huggingface.co/abc/archive/main.zip", ]