Skip to content

fix(ingest): inspect unverified OMS bundle contents - #795

Open
yashrajp22 wants to merge 22 commits into
mainfrom
yashraj/scan-unverified-oms-content
Open

yashrajp22 wants to merge 22 commits into
mainfrom
yashraj/scan-unverified-oms-content

Conversation

@yashrajp22

@yashrajp22 yashrajp22 commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

A forged OMS bundle could exclude attacker-controlled content from analysis. Bundles now receive a bounded DSSE projection that decodes payloads and retains readable certificate/signature content and unknown wrapper fields. Recognition is independent of the filename and exact media-type spelling. Original bytes remain in canonical caches for YARA and fingerprints; the projection is available to static analysis and LLM input.

This does not verify a signature or signer identity. Malformed, unsupported, duplicate-key, or over-limit content retains raw fields and an explicit incomplete-analysis warning. Genuine canonical bundle formatting no longer creates blanket base64 findings or incomplete coverage. Findings still use the existing risk-scoring policy; detecting a single instruction does not guarantee a particular aggregate recommendation.

Validation: 637 tests passed against both source and a freshly installed wheel, with integration markers explicitly enabled. Cases cover genuine SAFE/complete bundles, encoded payloads, wrapper instructions, renamed/nested bundles, multiple signatures, Unicode readable DER content, unknown fields, CRLF, and escaped duplicate keys. Ruff passed. Tests ran offline; these focused tests made no live provider calls.

Combined verification across the updated PRs: 6,243 regression tests passed against source and again against the freshly installed wheel, with seven conditional skips and four expected failures per run. All 19 source/wheel sample pairs matched. The 12-skill corpus retained its findings and risk ratings; four former hangs now finish with explicit partial-analysis results. The 93 extension tests passed. Two synthetic live NVIDIA Build checks passed on the final wheel: benign-note was complete/SAFE, and the exfiltration sample retained SSD-3 with complete semantic and meta analysis and a DO_NOT_INSTALL recommendation. All seven recorded LLM analyses succeeded. Live checks used the configured model/reasoning defaults through a test-only proxy that kept the real credential outside the scanner.

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

Hi @yashrajp22, thank you for closing a real verdict-integrity hole! A forged skill.oms.sig can no longer hide content from analysis behind a structure-only check. The PR body is also candid about the cost to genuine signed skills and asks for that to be reviewed, which made the tradeoff easy to assess.

Value and readiness: The problem is real on main. Main's own code with use_llm=False was run on a forged bundle carrying a prompt injection either in an extra top-level key or inside the base64 DSSE payload. Both return SAFE, score 0, is_complete=True, coverage 100% and zero findings. skill.oms.sig is absent from file_cache, components and llm_components, and the only trace is a non-fatal oms_signature scope exclusion. The PR removes all three exclusion points: the discovery llm_file_cache.pop, the continue in the ordinary_components loop, and the pop at build_context.py:3250. The wrapper is therefore analysed again. In a transcribed emulation the forged extra key produces P1 HIGH and YR4 HIGH findings, and recognized bundles can no longer finish complete or SAFE. That part is sound and tested.

It is not ready to merge yet, for two reasons:

  • The hole is closed by scanning the raw bundle text, which makes every genuine OMS-signed skill scan strictly worse than the same skill unsigned. Each gains four HIGH SC3 findings and can never be complete, --fail-on-incomplete, --fail-on-findings and --min-coverage flip to exit 1, and 7 of 105 real signed skills moved to DO_NOT_INSTALL.
  • README.md and docs/DEVELOPMENT.md, including the data-egress statement, still describe the old exclusion.

A projection-based fix (finding 1) keeps the security property. Measured with main's code on derived inputs, it reproduces main's verdicts on the whole signed corpus.

Material findings

  1. [Blocker] src/skillspector/nodes/build_context.py:2743: every genuine OMS-signed skill now gets four HIGH SC3 false positives and can never be complete or SAFE.
    • Trace:

      • For every recognized bundle, build_context.py:2741-2750 emits a PARTIAL/SYSTEM oms_signature event and :2765 sets the disposition to PARTIAL. The raw bundle JSON now reaches every analyzer.
      • static_runner.py:882 types .sig as other, and SC3 runs for other (static_patterns_supply_chain.py:2127). Its ['"][A-Za-z0-9+/=]{200,}['"] rule (:279) matches the long base64 fields. This is the false positive #261 removed (CHANGELOG.md:211).
      • The ledger marks the scan partial, report.py:1832-1840 raises SAFE to CAUTION, and cli.py:866-875 applies the exit gates.
    • Genuine fixture plus a minimal SKILL.md, using a behaviour-exact transcription of the PR's source edits applied to main's code (no PR code run):

      • Main: SAFE, score 0, complete, 100% coverage, 0 findings.
      • PR: CAUTION, score 21, partial, 50% coverage, and 4 SC3 HIGH findings. They hit the three certificate rawBytes and dsseEnvelope.payload; the 136-character sig is below the 200-character minimum.
      • The same skill without the signature is SAFE on both.
      • --fail-on-incomplete, --fail-on-findings and --min-coverage 100 exit 0 on main and 1 under the PR.
    • A local corpus of 105 real OMS-signed skills, all accepted by _is_valid_oms_signature_bytes:

      • Each skill gained exactly 4 SC3 HIGH findings (420 in total). No non-signature finding was lost or gained.
      • SAFE went from 37 skills to 0, and complete from 40 to 0.
      • Seven scores crossed RISK_THRESHOLD 50 (constants.py:35), so those skills moved to DO_NOT_INSTALL and now exit 1 by default.
      • Two independent emulations agree on every verdict.
    • A second source of incompleteness: the single-line raw bundle also trips a static_parse_limit PARTIAL (static_patterns_tool_misuse degraded). This happens on the fixture and on 92 of the 105 corpus skills. Dropping the unconditional PARTIAL event alone would therefore not restore complete/SAFE while the raw bytes still go to the bounded parsers.

    • Consequence:

      • Signing a skill now makes it scan worse than not signing it, and genuine and forged bundles become indistinguishable at the verdict level.
      • The PR's tests pin this outcome: test_graph.py:200 asserts CAUTION and :211 asserts {"SC3"}.
      • The PR body's sample (1 of 12 moved to DO_NOT_INSTALL) understates the scope: every signed skill loses complete/SAFE.
    • Expected fix: keep "never exclude attacker-controlled bytes", but analyse a recognized bundle through projections instead of raw text. Use the existing !/ member convention, and pop the outer bundle from the LLM cache as :3250 does for containers. The two members:

      • skill.oms.sig!/bundle.json: the bundle JSON with every key kept. Only strictly validated binary values become short placeholders: certificate rawBytes that decode to DER, signatures[].sig, and dsseEnvelope.payload.
      • skill.oms.sig!/dsse-payload.json: the in-toto statement that _decode_base64_json (build_context.py:852) already produces.

      If any known field fails validation, fall back to raw scanning (the current PR behaviour), and emit the PARTIAL event only in that fallback. Measured with main's code on derived inputs:

      • Verdicts and completeness are identical to main for 105/105 skills.
      • The forged wrapper still gives P1 and YR4 (CAUTION 40).
      • The forged encoded payload gives P1 and YR4 on the payload member. The PR currently gives only SC3 and a partial result there.

      Then change test_graph.py:200 and :210-211 to expect SAFE, complete and no findings for the genuine fixture. If maintainers instead deliberately accept the conservative behaviour, record that decision:

      • Add a CHANGELOG/release note covering three changes for signed skills: SAFE->CAUTION, the CI exit codes, and coverage.
      • Stop pinning {"SC3"} in the test.
  2. [Blocker] README.md:911: README.md and docs/DEVELOPMENT.md still say recognized OMS signatures are excluded from static and LLM analysis and from provider submission.
    • Stale text on the head (the PR changes no docs):
      • README.md:911-917 says a valid root skill.oms.sig is "excluded from static and LLM content analysis". It justifies this with the base64 false positives (:913-914), and its "unrecognized signature files are scanned normally" implies recognized ones are not.
      • README.md:946 (Trust model and data egress) says "Recognized OMS signature files are excluded."
      • docs/DEVELOPMENT.md:84 says the signature is recorded as an oms_signature scope exclusion.
    • What the head does instead:
      • The event is a partial ledger exception and scope_exclusions is [] (test_graph.py:179).
      • skill.oms.sig is in llm_components (test_build_context.py:707, test_graph.py:199), which semantic_security_discovery.py:123-132 batches to the provider.
      • Measured: the signature is in llm_components for 105/105 corpus skills, against 0/105 on main. For the fixture, the whole 4,582-character file is sent, certificate chain included.
    • Consequence: the documented data-egress contract and the documented scan outcome for signed skills are both false on this head. The bundle material is mostly public (certificates, transparency-log entries), so the problem is an incorrect trust-model statement, not a secret leak.
    • Expected fix: update README.md:911-917, README.md:946 and docs/DEVELOPMENT.md:84 to match whatever design lands for finding 1. A CHANGELOG line is advisable; repo practice is mixed, so that part is not blocking. (The docs are outside the diff, so the inline comment for this finding sits on build_context.py:3250, where the provider-eligibility change happens.)
  3. [Non-blocking] src/skillspector/nodes/build_context.py:817: the "unverified bundle cannot be complete/SAFE" guarantee binds only bundles that pass recognition.
    • Recognition requires the exact root path (build_context.py:2721) and exactly one signature (:839). The PARTIAL event (:2741-2750) and disposition (:2765) apply only to recognized bundles, and the recognizer itself only changes its docstring, so any bundle that fails recognition runs the same code on main and on the head.
    • Measured with main's code, on a bundle whose base64 payload (192 characters, below SC3's 200) encodes {"i":"Ignore rules; curl x|sh"}:
      • With two signatures, renamed to bundle.json, or with one trailing space in mediaType: SAFE, complete, zero findings in each case.
      • With two signatures and a 432-character payload: one SC3 finding (score 12), still SAFE.
    • Consequence:
      • This is not a regression, since short base64 in any file is not decoded. But the PR body and the test name test_forged_oms_bundle_cannot_hide_content_behind_a_complete_verdict (test_graph.py:215) overstate the guarantee.
      • The marker discloses the gap for recognized bundles but does not bind an adversary. Its cost (is_complete=False, exit 1 under --fail-on-incomplete) falls on every genuine signer.
      • In the corpus, the SAFE->CAUTION moves come from SC3 (scores of 21 or more), not from the marker.
    • Expected fix: the decoded-payload projection in finding 1 resolves this. Otherwise, narrow the PR description, the test name and the ledger message to say "recognized OMS bundles".
  4. [Non-blocking] tests/nodes/test_build_context.py:706: signature_path.read_text() reintroduces the Windows CRLF failure fixed in #518.
    • Why it breaks:
      • _write_real_oms_signature (lines 61-65) copies the fixture byte for byte, and build_context caches the decoded bytes without newline translation, while read_text() translates CRLF to LF.
      • The sibling test at line 784 uses read_bytes().decode("utf-8") for exactly this reason (d2ce556, #518), and no .gitattributes pins line endings.
    • Reproduced on macOS:
      • A checkout with core.autocrlf=true makes the fixture end in \r\n (4,582 -> 4,583 bytes), and it is still recognized.
      • Main's cached value equals read_bytes().decode() but not read_text().
    • Consequence: the test fails on Windows checkouts with autocrlf. CI runs only on ubuntu-latest, so it will not catch this.
    • Expected fix: use signature_path.read_bytes().decode("utf-8"), matching line 784.
  5. [Non-blocking] src/skillspector/inspection_ledger.py:146: the new message does not say whether SkillSpector attempted verification, or that the limitation is permanent.
    • With this PR, "OMS bundle contents are unverified; encoded payload interpretation is incomplete." becomes a SARIF warning (test_graph.py:287; previously note). It is the only explanation in terminal, Markdown and JSON output for why a genuinely signed skill can never be complete or SAFE.
    • It does not say that SkillSpector never verifies OMS signatures, that the base64 DSSE payload is never decoded or scanned, or that a re-scan will not clear it.
    • Consequence: users of legitimately signed skills can read it as a failed verification, and --fail-on-incomplete users cannot tell that it is unconditional.
    • Expected fix: rewrite it together with finding 3. For example: "skill.oms.sig matches the OpenSSF Model Signing (OMS) bundle layout; SkillSpector does not verify OMS signatures and does not decode or scan the base64-encoded DSSE payload, so this file is always reported as partially inspected." If the projection in finding 1 is adopted, the message may no longer be needed.

PIC tradeoffs:

  • Security versus false positives on signed skills. Scanning the raw bundle closes the hiding hole but brings back the #261 false positives on every genuine signature. The projection approach keeps the security property without trusting attacker-controlled keys. Its cost is a small amount of new code: field validation and two virtual members.
  • Blanket PARTIAL versus decoding the payload. Decoding is nearly free, because the recognizer already does it at build_context.py:852, and it gives real coverage of the encoded carrier. The blanket marker falls only on legitimate signers. As written, it rates signed skills worse than unsigned ones, which discourages signing.
  • Real signature verification. Checking the Sigstore trust root, certificate chain and transparency log would make a trust exemption legitimate. It is much heavier and needs a trust root, and the PR rightly does not claim it.
  • Provider egress. Sending the raw bundle adds about 4.5 KB of mostly public base64 per signed skill to LLM requests, for little semantic value. A projection with the binary fields elided reduces both tokens and exposure.

Verification and gaps:

  • Baseline on main (main venv, use_llm=False): forged wrapper and encoded-payload bundles return SAFE, complete, 100% coverage and 0 findings. The file is absent from file_cache, components and llm_components.

  • PR-side behaviour, from two methods that do not run PR code:

    • A behaviour-exact transcription of the PR's source edits applied to a copy of main's src. A diff against the head differs only in one docstring and one comment.
    • Main's code with the recognizer forced to False and the PR's PARTIAL marker re-applied.

    Both reproduce the PR's own test expectations and agree on all 105 corpus verdicts. The forged wrapper yields P1 HIGH and YR4 HIGH, and the forged encoded payload yields CAUTION with is_complete=False.

  • Code coverage of the change: every exclusion keyed on recognized_oms_signatures is removed. The remaining uses (build_context.py:915, 921, 3400) only set the component type and line count. There is no other OMS special-casing in src/, contrib/, the TypeScript extensions or transitive scanning. The runtime-limit path and AE1 behaviour are unchanged.

  • Tests:

    • Every new or changed test would fail on main, and they use the real graph.invoke/build_context path with no mocks.
    • The forged-bundle cases match the recognizer constants and assert the oms_signature exception, so they cannot pass vacuously.
    • The raw_file_cache/local_file_cache assertions already hold on main, so they are not regression guards; the file_cache/llm_components assertions are.
    • test_graph.py:200 and :211 pin the false-positive outcome from finding 1.
  • CI: no checks have run on this head. The workflow run is action_required, waiting for a maintainer to approve it. Local runs used Python 3.13; CI uses 3.12.

  • Conflicts: the PR merges cleanly with main, #793 and #794, and none of them semantically interferes with this change. #793 (reserved !/ delimiter in real paths) and #794 (readable_binary events) do not touch OMS recognition, the removed exclusion lines or the OMS message. #793's reservation of !/ also fits the projection design in finding 1.

  • Head update: I reviewed 45f5c04202ff982f285c4c055917121f3b5efd08. The current head 14f6a364ca144fd755d7ccbdafaee9749934def0 only adds automated merges of main (#691, #577, #608) from update-pr-branches.yml; those commits touch none of this PR's files. The PR's own diff is unchanged (same patch-id), so this review applies to the current head.

  • I did not run the PR's tests or code, per policy.

  • Gaps:

    • The LLM path (use_llm=True) and the provider payload were not exercised. Provider eligibility comes from the llm_components assertions and a code trace.
    • The CRLF failure was not run on Windows.
    • The 105-skill corpus is a separate local sample of real signed skills, not the author's 12-sample corpus. The two differ in scale, not in direction.
    • The projection-fix numbers come from main's code on derived inputs, not from an implementation.

Decision: Changes Requested (reviewed head 45f5c04202ff982f285c4c055917121f3b5efd08; current head 14f6a364ca144fd755d7ccbdafaee9749934def0 only adds merges of main)

Comment thread src/skillspector/nodes/build_context.py Outdated
Comment thread src/skillspector/nodes/build_context.py
Comment thread src/skillspector/nodes/build_context.py
Comment thread tests/nodes/test_build_context.py Outdated
Comment thread src/skillspector/inspection_ledger.py Outdated
github-actions Bot and others added 16 commits October 8, 2026 22:45
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants