Skip to content

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

Merged
rng1995 merged 47 commits into
mainfrom
yashraj/scan-unverified-oms-content
Oct 16, 2026
Merged

rng1995 merged 47 commits into
mainfrom
yashraj/scan-unverified-oms-content

Conversation

@yashrajp22

@yashrajp22 yashrajp22 commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

A bundle filename or unsigned signature wrapper no longer exempts attacker content from inspection. Bounded DSSE projections expose decoded payloads and readable certificate/signature metadata to static and semantic analysis while preserving raw local evidence. Unknown fields remain visible, and findings refer to the original carrier. This parses content; it does not verify a signature or establish trust in a signer.

Malformed certificates and unexpected parser failures retain raw evidence and report incomplete coverage. The projection supports certificate chains, a single keyless certificate, public-key hints, and transparency-log certificate fields. Real Unicode remains visible; lone surrogates are safely escaped. Oversized bundles and truncated provider excerpts keep their existing limits and warnings.

Validation: 174 tests passed from source and a fresh installed wheel. The same regressions reproduce 14 failures on the previous source and wheel, with 2 control cases passing. The OMS graph tests now run in the normal CI selection.

@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>

@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]

@yashrajp22 thank you for the DSSE projection work. Forged bundles are now inspected, and genuine certificate-chain bundles scan clean, which is a real improvement over main.

Value and readiness

This is a status re-review. The PR's own diff has not changed since the 2026-10-13 review of 6194430f. The head 364f5b37 only adds automated merges of main, and it still merges cleanly. Both blockers are still open, so the PR is not ready.

Material findings

Prior findings (2026-10-13 review):

  1. Blocker, still open. src/skillspector/oms.py:135 (open thread): malformed DER still escapes the narrow except tuples and aborts the scan, and a lone surrogate still fails two static analyzers.
  2. Blocker, still open. src/skillspector/oms.py:115 (open thread): Sigstore keyless and public-key bundles still regress to CAUTION and partial, and keyless bundles still gain SC3 HIGH findings.
  3. Non-blocking, still open:
    • oms.py:74: a bundle over 1,000,000 characters is not decoded, and no oms_signature PARTIAL is recorded.
    • The LLM view of a truncated file loses its audit-gap marker.
    • The graph-level OMS tests are integration-marked, so CI never runs them.

Against current main: none of the PRs merged since the last review touches oms.py, main's OMS recognizer (build_context.py:908-962) or the projection call sites. That covers #786, #796, #806, #779, #810, #799, #749, #802, #774, #755, #807 and #824. The main-side baselines for findings 1 and 2 therefore still hold. I found no new issues on a re-read of the diff.

PIC tradeoffs

Unchanged. Finding 2 is a choice between extending the projection to the keyless and public-key layouts and narrowing the claim, which accepts the regression for those layouts.

Verification and gaps

  • The PR's own per-file diff has identical added and removed lines at 6194430f and at 364f5b37, and every non-merge commit between the two heads comes from main.
  • git merge-tree against 8b993d32 is clean.
  • The author has not replied or pushed since the last review. CI on this head is action_required.
  • This was a static review. I did not re-run the earlier measurements because the code they exercised is unchanged.

Decision: Changes Requested (reviewed head 364f5b37ad50702a78dd1faa5b9e55dee352167f)

@mohgupta-ship-it
mohgupta-ship-it dismissed rng1995’s stale review October 16, 2026 07:07

lgtm, review comments resolved

) -> 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
runtime_limit = MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i believe it shouldnt be hardcoded but behind feature flag

started_at = time.monotonic() if started_at is None else started_at
runtime_limit = MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT
if timeout_seconds is not None:
runtime_limit = min(runtime_limit, max(0.0, timeout_seconds))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i dont get this logic

@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 the follow-up commits. They close both blockers from my last review, and a forged skill.oms.sig can no longer hide instructions from analysis.

Value and readiness

This is a re-review of 89c05c50 and 964429fe at head 37a27de6. Both blockers are fixed for the layouts I reported, so I'm approving. @mohgupta-ship-it still has two open threads on static_runner.py (the feature-flag question at :2318 and "i dont get this logic" at :2320). Branch protection keeps the PR blocked until they're answered and resolved.

Prior findings

  1. Blocker, fixed: malformed DER no longer aborts the scan. _project_bundle falls back to the raw view with complete=False, and certificate, tlog and signature parsing each catch broadly (oms.py:113-116, :132-140, :194, :219). A lone surrogate is escaped with backslashreplace (oms.py:227), and every static analyzer completes.
  2. Blocker, fixed: keyless (certificate plus Rekor v1 tlog), publicKey hint and x509-chain bundles all scan SAFE 0 and complete, the same as main.
  3. Non-blocking, fixed: bundles over 1M characters now record an oms_signature partial. The bounded LLM view keeps its truncation marker. The graph-level OMS tests moved into tests/nodes/test_oms.py, so CI runs them.

Non-blocking follow-ups

  • Two standard Sigstore fields stay raw base64 and each raise one SC3 HIGH (SAFE 12 vs SAFE 0 on main, so --fail-on-findings exits 1):
    • verificationMaterial.timestampVerificationData.rfc3161Timestamps[].signedTimestamp
    • a Rekor v2 dsseV002 entry's signatures[].verifier.x509Certificate.rawBytes (hashedRekordV002 is handled)
  • A publicKey bundle whose tlog verifier is a PEM public key (cosign style) goes CAUTION/partial with SC3 HIGH.
  • The README promises an incomplete-analysis warning for unsupported encodings. Unknown standard fields such as signedTimestamp are scanned as plain content without one.

Heads-up: this PR conflicts with #794 in _scan_all_views_detailed (static_runner.py) and in build_context. Both add a new early branch or loop at the same spot. Whichever lands second needs a merge with main that keeps both.

Verification

  • I ran static-only scans (--no-llm) on the PR head and on main for malformed-DER, surrogate, keyless (Rekor v1 and v2, with and without TSA), publicKey, genuine x509-chain, forged-payload and oversized cases. Forged payloads produce P1 HIGH with oms_projection evidence; main reports them SAFE 0. No analyzer failed in any run.
  • Tests: test_oms.py plus test_build_context.py (183 tests), test_security_remediation.py (422) and related completeness and ledger tests (628) all pass. CI test-unit on this head is still running.

Decision: Approve (reviewed head 37a27de6c6b12c42b88dcc2801ed6d8f4efb01be)

@rng1995
rng1995 merged commit 4ae7c7b into main Oct 16, 2026
6 checks passed
@rng1995
rng1995 deleted the yashraj/scan-unverified-oms-content branch October 16, 2026 08:17
yashrajp22 pushed a commit that referenced this pull request Oct 16, 2026
fix(ingest): inspect unverified OMS bundle contents

(cherry picked from commit 4ae7c7b)
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.

3 participants