Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion docker/clawscan-runtime/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ RUN git init /tmp/skillspector \
RUN python -m pip install --no-cache-dir --upgrade pip uv \
&& python -m pip install --no-cache-dir /tmp/skillspector pytest \
"cisco-ai-skill-scanner==$CISCO_AI_SKILL_SCANNER_VERSION" \
&& python -m pytest -q /tmp/clawscan-adapter-tests/test_skillspector_responses.py /tmp/clawscan-adapter-tests/test_skillspector_applicability.py /tmp/clawscan-adapter-tests/test_skillspector_accounting.py /tmp/clawscan-adapter-tests/test_skillspector_frontmatter_json.py /tmp/clawscan-adapter-tests/test_skillspector_osv_evidence.py /tmp/clawscan-adapter-tests/test_skillspector_pdf.py /tmp/clawscan-adapter-tests/test_skillspector_markdown_containers.py /tmp/clawscan-adapter-tests/test_skillspector_semantic_packets.py /tmp/clawscan-adapter-tests/test_skillspector_meta_packets.py /tmp/clawscan-adapter-tests/test_skillspector_security_view_cache.py /tmp/clawscan-adapter-tests/test_skillspector_ledger_capacity.py /tmp/clawscan-adapter-tests/test_skillspector_table_markers.py /tmp/clawscan-adapter-tests/test_skillspector_fence_boundaries.py /tmp/clawscan-adapter-tests/test_skillspector_csv_markers.py /tmp/clawscan-adapter-tests/test_skillspector_packed_views.py /tmp/clawscan-adapter-tests/test_skillspector_lazy_closers.py /tmp/clawscan-adapter-tests/test_skillspector_bounded_packing.py /tmp/clawscan-adapter-tests/test_skillspector_shell_source.py /tmp/clawscan-adapter-tests/test_skillspector_javascript_ownership.py \
&& python -m pytest -q /tmp/clawscan-adapter-tests/test_skillspector_responses.py /tmp/clawscan-adapter-tests/test_skillspector_applicability.py /tmp/clawscan-adapter-tests/test_skillspector_accounting.py /tmp/clawscan-adapter-tests/test_skillspector_frontmatter_json.py /tmp/clawscan-adapter-tests/test_skillspector_osv_evidence.py /tmp/clawscan-adapter-tests/test_skillspector_pdf.py /tmp/clawscan-adapter-tests/test_skillspector_markdown_containers.py /tmp/clawscan-adapter-tests/test_skillspector_semantic_packets.py /tmp/clawscan-adapter-tests/test_skillspector_meta_packets.py /tmp/clawscan-adapter-tests/test_skillspector_security_view_cache.py /tmp/clawscan-adapter-tests/test_skillspector_ledger_capacity.py /tmp/clawscan-adapter-tests/test_skillspector_table_markers.py /tmp/clawscan-adapter-tests/test_skillspector_fence_boundaries.py /tmp/clawscan-adapter-tests/test_skillspector_csv_markers.py /tmp/clawscan-adapter-tests/test_skillspector_packed_views.py /tmp/clawscan-adapter-tests/test_skillspector_lazy_closers.py /tmp/clawscan-adapter-tests/test_skillspector_bounded_packing.py /tmp/clawscan-adapter-tests/test_skillspector_shell_source.py /tmp/clawscan-adapter-tests/test_skillspector_javascript_ownership.py /tmp/clawscan-adapter-tests/test_skillspector_javascript_validator_native_calls.py /tmp/clawscan-adapter-tests/test_skillspector_javascript_validator_implicit_dispatch.py /tmp/clawscan-adapter-tests/test_skillspector_javascript_validator_coercion.py /tmp/clawscan-adapter-tests/test_skillspector_javascript_validator_type_budget.py /tmp/clawscan-adapter-tests/test_skillspector_javascript_validator_identity_effects.py /tmp/clawscan-adapter-tests/test_skillspector_javascript_validator_values.py \
&& uv tool install /tmp/aig/skill-scan --with pytest \
&& /root/.local/share/uv/tools/aig-skill-scan/bin/python -m pytest -q /tmp/clawscan-adapter-tests/test_aig_responses.py \
&& uv tool install "snyk-agent-scan==$SNYK_AGENT_SCAN_VERSION" \
Expand Down
1,459 changes: 1,443 additions & 16 deletions docker/clawscan-runtime/patches/skillspector-responses.patch

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Primitive results do not prove the effects of implicit operand conversion."""

import pytest

from skillspector.javascript_source import JavaScriptSourceOwnership
from skillspector.nodes.analyzers import static_patterns_tool_misuse as tool
from skillspector.nodes.analyzers import static_runner

CASES = {
"template_to_string": ('toString', '`${payload}`'),
"number_value_of": ('valueOf', '`${Number(payload)}`'),
"number_alias": ('valueOf', '`${convert(payload)}`'),
"string_to_string": ('toString', '`${String(payload)}`'),
"regexp_pattern": ('toString', '`${new RegExp(payload).test("x")}`'),
"regexp_flags": ('toString', '`${new RegExp("x",payload).test("x")}`'),
"json_record": ('toJSON', 'JSON.stringify(payload)'),
"json_nested_record": ('toJSON', 'JSON.stringify({nested:payload})'),
"json_array": ('toJSON', 'JSON.stringify([payload])'),
"comparison": ('valueOf', '`${payload < 1}`'),
"abstract_equality": ('valueOf', '`${payload == 1}`'),
"addition": ('valueOf', '`${payload + 1}`'),
"subtraction": ('valueOf', '`${payload - 1}`'),
"unary_number": ('valueOf', '`${+payload}`'),
"unary_complement": ('valueOf', '`${~payload}`'),
"computed_key": ('toString', '`${({})[payload]}`'),
"computed_record_key": ('toString', 'JSON.stringify({[payload]:1})'),
"search_argument": ('toString', '`${"x".includes(payload)}`'),
"join_element": ('toString', '`${[payload].join(",")}`'),
"math_argument": ('valueOf', '`${Math.abs(payload)}`'),
}


def source_for(hook, expression):
return (
('const convert=Number;' if 'convert(payload)' in expression else '')
+ f'const payload={{{hook}:consume,command:`tou${{"ch"}} /tmp/marker`}};'
'const errors=[];'
f'errors.push({expression});console.log(JSON.stringify(errors));'
)


@pytest.mark.parametrize("case", CASES, ids=CASES)
def test_implicit_callback_operand_remains_individually_incomplete(case):
source = source_for(*CASES[case])
result = static_runner.run_static_patterns_with_ledger(
{"components": ["case.cjs"], "file_cache": {"case.cjs": source}}, [tool]
)
assert result["inspection_ledger"][0]["outcome"] != "completed"
assert JavaScriptSourceOwnership(source, lambda: None).unsupported_values()


@pytest.mark.parametrize("mutation", [
'payload.toJSON=consume;',
'const alias=payload;alias.toJSON=consume;',
'payload["toJSON"]=consume;',
])
def test_serialization_does_not_trust_mutated_fresh_records(mutation):
source = (
'const payload={command:`tou${"ch"} /tmp/marker`};' + mutation
+ 'const errors=[];errors.push(JSON.stringify(payload));console.log(JSON.stringify(errors));'
)
assert JavaScriptSourceOwnership(source, lambda: None).unsupported_values()


@pytest.mark.parametrize("expression", [
'`${Number("1")}`',
'`${String(1)}`',
'`${1 < 2}`',
'`${1 == "1"}`',
'`${1 + 2}`',
'`${+1}`',
'`${Math.abs(-1)}`',
'JSON.stringify({nested:[{value:`x${1}`}],ok:true})',
'`${Boolean(payload)}`',
'`${payload === payload}`',
'`${!payload}`',
])
def test_proved_scalar_and_plain_serialization_operands_complete(expression):
source = (
'const payload={valueOf:consume};const errors=[];'
f'errors.push({expression});console.log(JSON.stringify(errors));'
)
result = static_runner.run_static_patterns_with_ledger(
{"components": ["case.cjs"], "file_cache": {"case.cjs": source}}, [tool]
)
assert result["inspection_ledger"][0]["outcome"] == "completed"
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Owner admission must prove identities and every callback input use."""

import pytest

from skillspector.javascript_source import JavaScriptSourceOwnership
from skillspector.nodes.analyzers import static_patterns_tool_misuse as tool
from skillspector.nodes.analyzers import static_runner

CASES = {
"global_escape": 'leaked = new RegExp(`con${"sole"}.log(1)`).source;',
"global_pattern_escape": '({value: leaked} = {value: new RegExp(`con${"sole"}.log(1)`).source});',
"intrinsic_shorthand_write": '({RegExp} = {RegExp: Worker}); new RegExp(`data:text/javascript,postMessage(${"42"})`);',
"intrinsic_nested_write": '([{RegExp}] = [{RegExp: Worker}]); new RegExp(`data:text/javascript,postMessage(${"42"})`);',
"intrinsic_default_write": '({RegExp = Worker} = {}); new RegExp(`data:text/javascript,postMessage(${"42"})`);',
"annex_b_constructor": 'const captured=[]; {function RegExp(value){captured.push(value);}} new RegExp(`x${1}`);',
"callback_file_content": 'import {writeFile} from "node:fs/promises"; const commands=[`tou${"ch"} /tmp/marker`]; console.log(commands.map(command=>Number(writeFile("startup.sh",command))).join(","));',
"filter_file_content": 'import {writeFile} from "node:fs/promises"; const commands=[`tou${"ch"} /tmp/marker`]; console.log(commands.filter(command=>Boolean(writeFile("startup.sh",command))).join(","));',
"callback_parameter_write": 'const commands=[`tou${"ch"} /tmp/marker`]; console.log(commands.map(command=>Number(command="changed")).join(","));',
"callback_string_transfer": 'import {writeFile} from "node:fs/promises"; const commands=[`tou${"ch"} /tmp/marker`]; console.log(commands.map(command=>Number(writeFile("startup.sh",String(command)))).join(","));',
"callback_regexp_transfer": 'import {writeFile} from "node:fs/promises"; const commands=[`tou${"ch"} /tmp/marker`]; console.log(commands.map(command=>Number(writeFile("startup.sh",new RegExp(command).source))).join(","));',
"local_regexp_transfer": 'import {writeFile} from "node:fs/promises"; const source=new RegExp(`tou${"ch"} /tmp/marker`).source; writeFile("startup.sh",source);',
}


@pytest.mark.parametrize("source", CASES.values(), ids=CASES)
def test_unknown_identity_escape_or_callback_input_remains_incomplete(source):
result = static_runner.run_static_patterns_with_ledger(
{"components": ["case.cjs"], "file_cache": {"case.cjs": source}}, [tool]
)
assert result["inspection_ledger"][0]["outcome"] != "completed"
assert JavaScriptSourceOwnership(source, lambda: None).unsupported_values()
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""An optional closed proof must account for every implicit JS invocation."""

import pytest

from skillspector.javascript_source import JavaScriptSourceOwnership
from skillspector.nodes.analyzers import static_patterns_tool_misuse as tool
from skillspector.nodes.analyzers import static_runner

CASES = {
"await_thenable": 'const payload={then:consume,command:`tou${"ch"} /tmp/marker`};const errors=[];errors.push(`${Boolean(await payload)}`);console.log(JSON.stringify(errors));',
"await_logical_thenable": 'const payload={then:consume,command:`tou${"ch"} /tmp/marker`};const errors=[];errors.push(`${Boolean(await (payload||1))}`);console.log(JSON.stringify(errors));',
"await_private_thenable": 'const payload={then:consume,command:`tou${"ch"} /tmp/marker`};function value(){return payload;}const errors=[];errors.push(`${Boolean(await value())}`);console.log(JSON.stringify(errors));',
"inherited_getter": 'const payload={__proto__:carrier,command:`tou${"ch"} /tmp/marker`};const errors=[];errors.push(`${Boolean(payload.value)}`);console.log(JSON.stringify(errors));',
"inherited_subscript_getter": 'const payload={__proto__:carrier,command:`tou${"ch"} /tmp/marker`};const errors=[];errors.push(`${Boolean(payload["value"])}`);console.log(JSON.stringify(errors));',
"inherited_iterator": 'const payload={__proto__:carrier,command:`tou${"ch"} /tmp/marker`};const errors=[];errors.push(`${Boolean([...payload])}`);console.log(JSON.stringify(errors));',
"unknown_object_spread": 'const payload=carrier;const errors=[];errors.push(`x${Boolean({...payload})}`);console.log(JSON.stringify(errors));',
"unknown_array_spread": 'const payload=carrier;const errors=[];errors.push(`x${Boolean([...payload])}`);console.log(JSON.stringify(errors));',
"destructuring_getter": 'const payload={__proto__:carrier,command:`tou${"ch"} /tmp/marker`};const {value}=payload;const errors=[];errors.push(`${Boolean(value)}`);console.log(JSON.stringify(errors));',
"destructuring_iterator": 'const payload={__proto__:carrier,command:`tou${"ch"} /tmp/marker`};const [value]=payload;const errors=[];errors.push(`${Boolean(value)}`);console.log(JSON.stringify(errors));',
"delegated_generator": 'const payload={__proto__:carrier,command:`tou${"ch"} /tmp/marker`};function* values(){yield* payload;}const errors=[];errors.push(`${Boolean(values().next())}`);console.log(JSON.stringify(errors));',
"async_iteration": 'const payload={__proto__:carrier,command:`tou${"ch"} /tmp/marker`};const errors=[];for await(const value of payload)errors.push(`${Boolean(value)}`);console.log(JSON.stringify(errors));',
"unknown_tag": 'const errors=[];errors.push(`${Boolean(tag`x${1}`)}`);console.log(JSON.stringify(errors));',
"native_tag_shape": 'import {readFile} from "node:fs/promises";const errors=[];errors.push(`${Boolean(readFile`x${1}`)}`);console.log(JSON.stringify(errors));',
"proxy_receiver": 'const payload=new Proxy({command:`tou${"ch"} /tmp/marker`},carrier);const errors=[];errors.push(`${Boolean(payload.value)}`);console.log(JSON.stringify(errors));',
}


@pytest.mark.parametrize("source", CASES.values(), ids=CASES)
def test_unknown_implicit_dispatch_remains_incomplete(source):
report = static_runner.run_static_patterns_with_ledger(
{"components": ["case.mjs"], "file_cache": {"case.mjs": source}}, [tool]
)
assert report["inspection_ledger"][0]["outcome"] != "completed"
assert JavaScriptSourceOwnership(source, lambda: None).unsupported_values()


@pytest.mark.parametrize("expression", [
'`${await "value"}`',
'`${await Number("1")}`',
'`${Boolean({value:1}.value)}`',
'`${[...["a","b"]].join(",")}`',
])
def test_known_native_operands_keep_the_closed_proof(expression):
source = f'const errors=[];errors.push({expression});console.log(JSON.stringify(errors));'
report = static_runner.run_static_patterns_with_ledger(
{"components": ["case.mjs"], "file_cache": {"case.mjs": source}}, [tool]
)
assert report["inspection_ledger"][0]["outcome"] == "completed"
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Native result typing requires the complete consumed argument contract."""

import pytest

from skillspector.javascript_source import JavaScriptSourceOwnership
from skillspector.nodes.analyzers import static_patterns_tool_misuse as tool
from skillspector.nodes.analyzers import static_runner


@pytest.mark.parametrize("bounds", ["offset", "0,offset", "offset,offset"])
def test_argv_slice_does_not_invoke_unproved_bound_conversion(bounds):
source = (
'const offset={valueOf:consume};'
f'console.log(`x${{process.argv.slice({bounds}).join(",")}}`);'
)
report = static_runner.run_static_patterns_with_ledger(
{"components": ["case.mjs"], "file_cache": {"case.mjs": source}}, [tool]
)
assert report["inspection_ledger"][0]["outcome"] != "completed"
assert JavaScriptSourceOwnership(source, lambda: None).unsupported_values()


@pytest.mark.parametrize("receiver", ["process.argv", "args"])
def test_unknown_bound_cannot_invalidate_a_later_console_identity(receiver):
source = ('const args=process.argv;' if receiver == "args" else "") + (
'const offset={valueOf:consume};'
f'{receiver}.slice(offset);console.log(`tou${{"ch"}} /tmp/marker`);'
)
report = static_runner.run_static_patterns_with_ledger(
{"components": ["case.mjs"], "file_cache": {"case.mjs": source}}, [tool]
)
assert report["inspection_ledger"][0]["outcome"] != "completed"
assert JavaScriptSourceOwnership(source, lambda: None).unsupported_values()


@pytest.mark.parametrize("bounds", ["", "2", "1,3", '"1",null'])
def test_argv_slice_scalar_bounds_preserve_native_result_identity(bounds):
source = f'console.log(`x${{process.argv.slice({bounds}).join(",")}}`);'
report = static_runner.run_static_patterns_with_ledger(
{"components": ["case.mjs"], "file_cache": {"case.mjs": source}}, [tool]
)
assert report["inspection_ledger"][0]["outcome"] == "completed"
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Small shared type graphs must remain inside the scanner's runtime budget."""

import json
import subprocess
import sys

import pytest

from skillspector.javascript_native_values import NativeValidatorValues


@pytest.mark.parametrize("error", [False, True])
def test_primitive_union_visits_check_the_runtime_budget(error):
class DeadlineError(Exception):
pass

remaining = 5

def step():
nonlocal remaining
remaining -= 1
if remaining < 0:
raise DeadlineError

owner = NativeValidatorValues(
data=b"",
nodes=[],
parents={},
definition=lambda _: None,
capability=lambda _: None,
private_function=lambda _: None,
reads=lambda _: [],
bindings=lambda _, **kwargs: [],
ambient_intrinsic=lambda *_: None,
step=step,
)
kind = ("string",)
for _ in range(8):
kind = ("either", kind, kind)
with pytest.raises(DeadlineError):
owner.primitive(kind, error=error)


@pytest.mark.parametrize("operation", ["primitive", "equality"])
def test_shared_source_value_graph_does_not_expand_exponentially(operation):
# Run the real source owner in a child: an uncharged recursive traversal
# must fail this test without pinning the entire test or scanner process.
script = r"""
import json,sys
from skillspector.javascript_source import JavaScriptSourceOwnership
if sys.argv[1] == "primitive":
lines = ['const a0=`x${1}`, b0=1;']
for i in range(1, 29):
lines.append(f'const a{i}=a{i-1}||b{i-1}; const b{i}=b{i-1}||a{i-1};')
lines.append('console.log(String(a28));')
else:
lines = ['const a0=`x${1}`, b0=1, c0=`y${2}`, d0=2;']
for i in range(1, 28):
lines.append(f'const a{i}=a{i-1}||b{i-1}, b{i}=b{i-1}||a{i-1};')
lines.append(f'const c{i}=c{i-1}||d{i-1}, d{i}=d{i-1}||c{i-1};')
lines.append('console.log(String(a27||c27));')
source='\n'.join(lines)
checks=0
def check():
global checks
checks += 1
owner=JavaScriptSourceOwnership(source,check)
incomplete=owner.analysis_incomplete()
unsupported=owner.unsupported_values()
print(json.dumps({'incomplete':incomplete,'unsupported':len(unsupported),'checks':checks}))
"""
result = subprocess.run(
[sys.executable, "-c", script, operation],
check=True,
capture_output=True,
text=True,
timeout=3,
)
report = json.loads(result.stdout)
assert report["checks"] > 0


@pytest.mark.parametrize("count, exhausted", [(160, False), (200, True)])
def test_repeated_lexical_lookups_preserve_unknown_call_evidence(count, exhausted):
from skillspector.javascript_source import JavaScriptSourceOwnership

source = 'import {helper} from "./unknown.mjs";\n' + "\n".join(
f'function fn{i}(value){{ const text=`x${{value}}`; return helper(String(text)); }}'
for i in range(count)
)
owner = JavaScriptSourceOwnership(source, lambda: None)
assert owner.analysis_incomplete() is exhausted
spans = owner.unsupported_values()
if exhausted:
assert not spans
else:
assert len(spans) == count
assert all(source[start:end] == "`x${value}`" for start, end in spans)
Loading
Loading