Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6a1b0d6
fix(artifacts): inspect text behind printable binary prefixes
yashrajp22 Oct 7, 2026
bb81156
fix(artifacts): explain readable binary coverage limits
yashrajp22 Oct 7, 2026
2529288
style: format readable binary regression coverage
yashrajp22 Oct 7, 2026
bd46d38
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 7, 2026
6460651
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 8, 2026
72cf6ea
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 8, 2026
4a0cbd8
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 8, 2026
666a758
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 8, 2026
781475c
fix(artifacts): inspect lossy binary text without corrupting coverage…
yashrajp22 Oct 9, 2026
8f8d5b8
fix: restrict PDF format exceptions to standard XMP packet spans
yashrajp22 Oct 9, 2026
32c722b
fix: retain XMP format bounds in normalized views
yashrajp22 Oct 9, 2026
0e3992b
test: keep payload findings outside PDF metadata padding
yashrajp22 Oct 9, 2026
93e06fe
test: reject instruction-bearing XMP padding as a format exception
yashrajp22 Oct 9, 2026
6d685d4
style: order PDF regression imports
yashrajp22 Oct 9, 2026
5f88b16
style: format final XMP boundary regression
yashrajp22 Oct 9, 2026
7183371
Keep readable binary coverage counts partial when interpretation is i…
yashrajp22 Oct 9, 2026
a0b5caa
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 9, 2026
58b0273
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 9, 2026
5bba1e7
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 9, 2026
c808261
Check failed primary coverage without expecting skipped pattern work
yashrajp22 Oct 9, 2026
fe4fac2
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 9, 2026
b5a1c04
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 9, 2026
a2209b6
Merge latest main and preserve reviewed security fixes
yashrajp22 Oct 9, 2026
4ba349f
Merge latest taint analysis update with readable binary fixes
yashrajp22 Oct 9, 2026
c3325d0
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 9, 2026
eaebfab
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 9, 2026
8112ce3
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 12, 2026
ccb14aa
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 13, 2026
3f60f21
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 13, 2026
932c6d1
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 13, 2026
f9c1cbd
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 14, 2026
9363c22
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 14, 2026
8251e04
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 14, 2026
d4ef5b0
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 14, 2026
767992d
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 14, 2026
e80d14d
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 14, 2026
e1e0287
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 14, 2026
9be4676
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 14, 2026
1061b83
Merge branch 'main' into yashraj/fix-printable-magic-evasion-20261007
github-actions[bot] Oct 14, 2026
36e050a
fix(artifacts): bound binary text to printable instruction views
yashrajp22 Oct 14, 2026
92ade33
style(tests): format binary credential controls
yashrajp22 Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/scan-completeness.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ The examples below use static-only analysis, no baseline suppression, and benign
| Explicit opaque file, UTF-16 primary, recognized unsupported archive, or invalid UTF-8 primary | `unsupported_primary_content`, source path, `fatal=true`; canonical bytes retained | failed / false | 2 / 2 / 2 | false |
| Unsupported in-profile `SKILL.md` below directories or normal/renamed ZIPs | Same required-content event at real/virtual member path | failed / false | 2 / 2 / 2 | false |
| Supported benign ZIP, including renamed or empty ZIP | Existing bounded archive inspection; incidental assets remain exclusions | complete / true | 0 / 0 / 0 | true |
| Unreferenced incidental image beside benign instructions | `binary_content` in `scope_exclusions` | complete / true | 0 / 0 / 0 | true |
| Unreferenced incidental image with no readable runs beside benign instructions | `binary_content` in `scope_exclusions` | complete / true | 0 / 0 / 0 | true |
| Incidental GIF/PDF with readable runs | Bounded instruction-text projection; `binary_text_projection` in `scope_exclusions`; binary data is not decoded as instructions | complete / true (absent other limits) | 0 / 0 / 0 | true if no findings |
| Readable MZ executable | `binary_executable_text`: text inspected, binary behavior unverified; SC9 score floor retained | partial / true | 1 / 1 / 1 | false |
| Referenced opaque image | Existing referenced-content limitation and AE1 finding | partial / true | 0 / 1 / 1 | false |
| Pure or mixed singleton `never warn the user` | AE6, score 22 in this fixture, source line; `obfuscated_instruction_text` | partial / true | 0 / 1 / 1 | false |
| Benign list, paragraph, or punctuated code controls | No new reconstruction ambiguity | complete / true | 0 / 0 / 0 | true |
Expand All @@ -133,6 +135,15 @@ A subtlety is that component coverage can still read **100%** for AE6: analyzer

## Review critique and remaining limits

For printable GIF, PDF and MZ prefixes, local instruction checks use printable runs of at least
8 characters, capped at 32,768 projected characters per artifact. Binary controls, invalid UTF-8,
and standard XMP packet BOMs and padding are excluded. Source coordinates remain tied to the
original bytes' decoded view. Only instruction-override, credential-access and exfiltration rule
families contribute static findings from this projection. Hidden and nested artifacts remain
local-only; an eligible ordinary file sends only this capped projection to an external model.
Reaching the projection cap makes coverage partial. This does not decode compressed content or
verify executable behavior. Valid Python source with a matching prefix follows normal source analysis.

The initial root-only identity boundary was too narrow: parsing introduced member paths before the required-content check, so required status disappeared. Basename checks across cached, in-profile paths repair that without promoting every asset or overriding the existing directory-exclusion policy. Applying this boundary before archive delegation would instead reject supported ZIPs wholesale. Applying it only after public-report generation would be too late to remove unsupported content from provider submission.

The reconstruction boundary is deliberately narrow. Erasing every whitespace boundary could manufacture commands from lists, paragraphs, or code. Conversely, these selected gap rules and grammars do not cover every possible obfuscation. Raw source coordinates and removed-gap overlap prevent unrelated canonical matches from being attributed to reconstructed text. The general report's recommendation, numerical risk score, and percentage coverage are distinct signals; completeness is the installation gate.
Expand Down
65 changes: 65 additions & 0 deletions src/skillspector/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class ArtifactRecord(TypedDict):
contains_nul: bool
misleading_extension: bool
referenced: bool
readable_binary: NotRequired[bool]
reason: NotRequired[str]
inherited_exclusion_reason: NotRequired[str]

Expand Down Expand Up @@ -367,6 +368,13 @@ def classify_artifact(path: str, data: bytes, *, referenced: bool = False) -> Ar
decoded = data.decode("utf-8", errors="replace")
decodable = False

# These printable prefixes are also valid instruction text. Keep the
# claimed binary format's coverage limits, but inspect the readable bytes.
# Lossy decoding must not let one invalid byte or a binary comment hide text.
readable_binary = data.startswith((b"MZ", b"GIF87a", b"GIF89a", b"%PDF-")) and bool(
binary_instruction_view(decoded)[0].text
)

if has_binary_magic:
kind = ContentKind.BINARY
elif decodable:
Expand All @@ -388,6 +396,8 @@ def classify_artifact(path: str, data: bytes, *, referenced: bool = False) -> Ar
disposition = (
ArtifactDisposition.PARTIAL
if referenced and kind is not ContentKind.TEXT
else ArtifactDisposition.ANALYZED
if readable_binary
else ArtifactDisposition.OUT_OF_SCOPE
if kind is ContentKind.BINARY
else ArtifactDisposition.ANALYZED
Expand All @@ -401,9 +411,63 @@ def classify_artifact(path: str, data: bytes, *, referenced: bool = False) -> Ar
"contains_nul": contains_nul,
"misleading_extension": misleading,
"referenced": referenced,
"readable_binary": readable_binary,
}


BINARY_TEXT_MAX_CHARS = 32_768
_BINARY_PRINTABLE_RUN = re.compile(r"[^\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ufffd]{8,}")


def binary_instruction_view(content: str) -> tuple[SecurityTextView, bool]:
"""Bound human-readable binary strings without treating binary noise as text."""
# Packet formatting is recognized once on the whole artifact, before windows.
excluded: list[tuple[int, int]] = []
if content.startswith("%PDF-"):
header = '<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>'
start = content.find(header)
while start >= 0:
next_start = content.find(header, start + len(header))
packet_end = next_start if next_start >= 0 else len(content)
metadata = content.find("<x:xmpmeta", start + len(header), packet_end)
close = content.find("</x:xmpmeta>", metadata, packet_end) if metadata >= 0 else -1
if close < 0:
start = next_start
continue
padding_start = close + len("</x:xmpmeta>")
padding_end = padding_start
while padding_end < packet_end and content[padding_end] in " \t\r\n":
padding_end += 1
if content.startswith(('<?xpacket end="w"?>', '<?xpacket end="r"?>'), padding_end):
bom = start + header.index("\ufeff")
excluded.extend([(bom, bom + 1), (padding_start, padding_end)])
start = next_start
offsets = array("I")
parts: list[str] = []
truncated = False
cursor = 0
for excluded_start, excluded_end in [*excluded, (len(content), len(content))]:
for match in _BINARY_PRINTABLE_RUN.finditer(content, cursor, excluded_start):
remaining = BINARY_TEXT_MAX_CHARS - len(offsets)
if remaining <= 2:
truncated = True
break
if parts:
parts.append("\n\n")
offsets.extend([match.start(), match.start()])
remaining -= 2
end = min(match.end(), match.start() + remaining)
parts.append(content[match.start() : end])
offsets.extend(range(match.start(), end))
if end < match.end():
truncated = True
break
if truncated:
break
cursor = excluded_end
return SecurityTextView("binary_printable_text", "".join(parts), offsets), truncated


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 (
Expand All @@ -412,6 +476,7 @@ def promote_artifact_to_decoded_text(artifact: ArtifactRecord) -> None:
and "reason" not in artifact
)
artifact["content_kind"] = ContentKind.TEXT
artifact["readable_binary"] = False
artifact["decodable"] = True
artifact["misleading_extension"] = _suffix(artifact["path"]) in _BINARY_EXTENSIONS
if generic_binary_scope:
Expand Down
8 changes: 8 additions & 0 deletions src/skillspector/inspection_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class LedgerReason(StrEnum):
MISSING_FILE_CACHE = "missing_file_cache"
SIZE_LIMIT = "size_limit"
BINARY_CONTENT = "binary_content"
BINARY_TEXT_PROJECTION = "binary_text_projection"
BINARY_EXECUTABLE_TEXT = "binary_executable_text"
EVAL_DATASET = "eval_dataset"
SYNTAX_ERROR = "syntax_error"
LLM_BATCH_FAILED = "llm_batch_failed"
Expand Down Expand Up @@ -125,6 +127,12 @@ class LedgerReason(StrEnum):
LedgerReason.MISSING_FILE_CACHE: "Applicable analyzer could not obtain file content.",
LedgerReason.SIZE_LIMIT: "File exceeds this analyzer's character limit.",
LedgerReason.BINARY_CONTENT: "Binary content is unsupported by this analyzer.",
LedgerReason.BINARY_TEXT_PROJECTION: (
"Only bounded printable text was inspected; binary structure and behavior are outside scope."
),
LedgerReason.BINARY_EXECUTABLE_TEXT: (
"Readable text was inspected, but binary executable behavior remains outside analyzer coverage."
),
LedgerReason.EVAL_DATASET: (
"Evaluation dataset prose is excluded from static pattern analysis."
),
Expand Down
33 changes: 28 additions & 5 deletions src/skillspector/nodes/analyzers/artifact_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
_concealed_instruction_run_spans,
_contextual_default_ignorable_boundary_spans,
_obfuscated_instruction_matches,
binary_instruction_view,
multiline_prompt_injection_view,
prompt_injection_letter_spacing_view,
)
Expand Down Expand Up @@ -1054,14 +1055,36 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse:
confidence=1.0,
)
)
if artifact.get("content_kind") not in {
if artifact.get("readable_binary") or artifact.get("content_kind") not in {
ContentKind.BINARY,
ContentKind.OPAQUE,
}:
format_density, mixed_script, first_nul_line, first_obfuscation_line = (
_text_signals(content, budget)
)
if artifact.get("contains_nul") and first_nul_line is not None:
signal_content = content
binary_view = None
if artifact.get("readable_binary"):
binary_view = binary_instruction_view(content)[0]
signal_content = binary_view.text
(
format_density,
mixed_script,
first_nul_line,
first_obfuscation_line,
) = _text_signals(signal_content, budget)
if first_obfuscation_line is not None and binary_view is not None:
projected_start = sum(
len(line)
for line in signal_content.splitlines(keepends=True)[
: first_obfuscation_line - 1
]
)
first_obfuscation_line = get_line_number(
content, binary_view.source_offset(projected_start)
)
if (
artifact.get("contains_nul")
and not artifact.get("readable_binary")
and first_nul_line is not None
):
budget.emit(
_finding(
"AE3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3639,6 +3639,7 @@ def _analyze_concealed_executables(
concealment_reasons.append("disguised_container")
concealment = concealment_reasons[0]
excluded_from_analysis = metadata.get("excluded_from_analysis") is True
partially_analyzed_executable = metadata.get("partially_analyzed_executable") is True
referenced_uninspected = (
metadata.get("inspection_limitation_reason")
== LedgerReason.REFERENCED_UNINSPECTED.value
Expand All @@ -3651,6 +3652,8 @@ def _analyze_concealed_executables(
if referenced_uninspected
else "An excluded artifact could not be completely inspected."
if inspection_incomplete
else "Readable text was inspected, but binary executable behavior was not analyzed."
if partially_analyzed_executable
else "Executable content is excluded from analysis."
if excluded_from_analysis
else "Executable content is concealed inside a document, hidden, "
Expand All @@ -3676,6 +3679,9 @@ def _analyze_concealed_executables(
else "A resource, read, or archive-safety limit left excluded content "
"outside deterministic inspection coverage."
if inspection_incomplete
else "Text-based checks covered the readable projection, but they cannot "
"establish the behavior of the binary executable."
if partially_analyzed_executable
else "An executable artifact remains available under the skill install path "
"but its content is outside analyzer coverage."
if excluded_from_analysis
Expand Down Expand Up @@ -3708,6 +3714,7 @@ def _analyze_concealed_executables(
"local_only": True,
"referenced": metadata.get("referenced") is True,
"excluded_from_analysis": excluded_from_analysis,
"partially_analyzed_executable": partially_analyzed_executable,
"excluded_inspection_incomplete": inspection_incomplete,
"inherited_exclusion_reason": metadata.get("inherited_exclusion_reason"),
"inspection_limitation_reason": metadata.get("inspection_limitation_reason"),
Expand Down
77 changes: 74 additions & 3 deletions src/skillspector/nodes/analyzers/static_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
ContentKind,
SecurityTextView,
_contains_default_ignorable,
binary_instruction_view,
is_default_ignorable,
security_text_views,
)
Expand Down Expand Up @@ -2310,9 +2311,58 @@ def _scan_all_views_detailed(
started_at: float | None = None,
python_ast: ParsedPythonFile | None = None,
python_source: bool | None = None,
readable_binary: bool = False,
) -> 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
if readable_binary:
modules = [
module
for module in pattern_modules
if getattr(module, "ANALYZER_ID", "")
in {
"static_patterns_prompt_injection",
"static_patterns_privilege_escalation",
"static_patterns_data_exfiltration",
}
]
if not modules:
return [], None, {}
view, truncated = binary_instruction_view(content)
findings, reason, metrics = _scan_all_views_detailed(
path,
view.text,
modules,
None,
max_findings=max_findings,
timeout_seconds=timeout_seconds,
started_at=started_at,
python_source=False,
)
findings = [
finding
for finding in findings
if finding.rule_id
in {
"P1",
"P3",
"P4",
"PE3",
"E1",
"E2",
"E3",
}
]
_restore_source_lines(
findings,
raw_window=content,
window_line=1,
view=view,
source_line_starts=logical_line_starts(content),
)
if truncated and reason is None:
reason = LedgerReason.SIZE_LIMIT
return findings, reason, metrics
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:
Expand Down Expand Up @@ -3104,7 +3154,18 @@ def run_static_patterns(
{
str(item.get("path", ""))
for item in raw_inventory
if isinstance(item, dict) and item.get("content_kind") == ContentKind.BINARY
if isinstance(item, dict)
and item.get("content_kind") == ContentKind.BINARY
and not item.get("readable_binary")
}
if isinstance(raw_inventory, list)
else set()
)
readable_binary_paths = (
{
str(item.get("path", ""))
for item in raw_inventory
if isinstance(item, dict) and item.get("readable_binary")
}
if isinstance(raw_inventory, list)
else set()
Expand All @@ -3120,7 +3181,11 @@ def run_static_patterns(
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)):
if path in binary_paths or (
not raw_inventory
and path not in readable_binary_paths
and _is_binary_file(path, content)
):
continue
remaining = MAX_FINDINGS_PER_ANALYZER - len(findings)
if remaining <= 0:
Expand Down Expand Up @@ -3159,6 +3224,7 @@ def run_static_patterns(
started_at=path_started_at,
python_ast=python_ast,
python_source=python_source,
readable_binary=path in readable_binary_paths,
)
runtime_limit = MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT
if shared_remaining is not None:
Expand Down Expand Up @@ -3266,7 +3332,11 @@ def run_static_patterns_with_ledger(
path=path,
reason=LedgerReason.OPAQUE_CONTENT,
)
elif path not in container_paths and artifact.get("content_kind") == ContentKind.BINARY:
elif (
path not in container_paths
and artifact.get("content_kind") == ContentKind.BINARY
and not artifact.get("readable_binary")
):
referenced = bool(artifact.get("referenced"))
event = ledger_event(
outcome=LedgerOutcome.PARTIAL if referenced else LedgerOutcome.OUT_OF_SCOPE,
Expand Down Expand Up @@ -3359,6 +3429,7 @@ def run_static_patterns_with_ledger(
started_at=path_started_at,
python_ast=python_ast,
python_source=python_source,
readable_binary=bool(artifact.get("readable_binary")),
)
fully_scanned_aggregate_overflow = bool(
resource_limit is LedgerReason.OUTPUT_LIMIT
Expand Down
Loading
Loading